diff --git a/web/scripts/test-booking-flow.mjs b/web/scripts/test-booking-flow.mjs new file mode 100644 index 0000000..33a59c3 --- /dev/null +++ b/web/scripts/test-booking-flow.mjs @@ -0,0 +1,101 @@ +// Изолированные UI-регрессии: только вымышленные врачи и окна, без сети и заявок. +import assert from 'node:assert/strict'; +import { parseHTML } from 'linkedom'; +import { createServer } from 'vite'; + +const { window } = parseHTML('
'); +Object.assign(globalThis, { window, document: window.document, HTMLElement: window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true }); +window.location = new URL('https://booking-test.invalid/'); +const { default: React, act } = await import('react'); +const { createRoot } = await import('react-dom/client'); +const { MemoryRouter } = await import('react-router-dom'); +const vite = await createServer({ server: { middlewareMode: true, hmr: false }, appType: 'custom' }); +const { default: LeadModal } = await vite.ssrLoadModule('/src/components/LeadModal.jsx'); +const { api } = await vite.ssrLoadModule('/src/api.js'); +const doctors = [ + { id: 1, slug: 'first', full_name: 'Первый врач', booking: { provider: 'native' } }, + { id: 2, slug: 'second', full_name: 'Второй врач', booking: { provider: 'native' } }, + { id: 3, slug: 'fallback', full_name: 'Врач без расписания', booking: { provider: 'none' } }, +]; +const pending = []; +api.doctors = async () => ({ data: doctors }); +api.doctorSlots = (slug) => new Promise((resolve, reject) => pending.push({ slug, resolve, reject })); +api.createLead = api.createAppointment = () => { throw new Error('Real submissions are forbidden in this test'); }; +const container = document.getElementById('root'); +const root = createRoot(container); +const render = async (props = {}) => act(async () => root.render(React.createElement(MemoryRouter, {}, React.createElement(LeadModal, { ...props, onClose() {} })))); +const text = () => container.textContent; +const times = () => [...container.querySelectorAll('.slot-chip--time')].map((e) => e.textContent); +async function chooseDoctor(id) { + const select = container.querySelector('select'); + await act(async () => { + // linkedom exposes select.value as read-only; option.selected sets the value. + for (const option of select.options) option.selected = false; + [...select.options].find((option) => option.value === String(id)).selected = true; + select.dispatchEvent(new window.Event('change', { bubbles: true })); + }); +} +const free = (time) => ({ start: `2070-10-23T${time}:00+03:00`, status: 'free', branch_id: 'test', branch: 'Тестовый филиал' }); +async function reply(request, slots, enabled = true) { + await act(async () => request.resolve({ data: { enabled, slots, services: [] } })); +} + +try { + await render(); + assert.equal(pending.length, 0, 'No schedule requests before selecting a doctor'); + assert.equal(container.querySelector('input[type="date"]'), null); + assert.equal(container.querySelector('input[type="tel"]'), null); + assert.deepEqual(times(), []); + assert.match(text(), /Сначала выберите врача/); + + await chooseDoctor(1); + assert.equal(pending[0].slug, 'first'); + assert.match(text(), /Загружаем свободное время/); + await reply(pending[0], [free('09:15'), { ...free('10:00'), status: 'busy' }, { ...free('11:00'), start: '2000-01-01T11:00:00+03:00' }, { ...free('12:00'), start: 'invalid' }]); + assert.deepEqual(times(), ['09:15'], 'Only actual free future slots appear'); + await act(async () => container.querySelector('.slot-chip--time').dispatchEvent(new window.Event('click', { bubbles: true }))); + assert.ok(container.querySelector('input[type="tel"]'), 'Contacts follow an actual slot'); + + await chooseDoctor(2); + assert.deepEqual(times(), []); + assert.equal(container.querySelector('input[type="tel"]'), null, 'Switching doctor clears the chosen slot'); + assert.equal(pending[1].slug, 'second'); + await chooseDoctor(1); + assert.equal(pending[2].slug, 'first'); + await reply(pending[1], [], false); + assert.match(text(), /Загружаем свободное время/, 'Late response for another doctor must be ignored'); + await reply(pending[2], [free('15:45')]); + assert.deepEqual(times(), ['15:45']); + + await chooseDoctor(''); + assert.deepEqual(times(), []); + assert.equal(pending.length, 3, 'Clearing selection must not request an all-doctors schedule'); + await chooseDoctor(3); + assert.match(text(), /Свободные окна этого врача сейчас недоступны/); + assert.deepEqual(times(), []); + assert.equal(container.querySelector('input[type="date"]'), null, 'Fallback cannot offer invented dates'); + assert.ok(container.querySelector('input[type="tel"]')); + await chooseDoctor(2); + await act(async () => pending[3].reject(new Error('Schedule temporarily unavailable'))); + assert.match(text(), /Свободные окна этого врача сейчас недоступны/); + assert.deepEqual(times(), []); + + await act(async () => root.render(null)); + await render({ doctor: doctors[0], slot: free('10:00') }); + await reply(pending[4], [free('16:15')]); + assert.deepEqual(times(), ['16:15']); + assert.equal(container.querySelector('input[type="tel"]'), null, 'A stale initial slot must not be preselected'); + + await act(async () => root.render(null)); + await render({ form: 'callback' }); + assert.ok(container.querySelector('input[type="tel"]')); + assert.equal(container.querySelector('select'), null); + await act(async () => root.render(null)); + await render({ form: 'review' }); + assert.equal(container.querySelectorAll('.lead-rating__star').length, 5); + assert.ok(container.querySelector('select')); + console.log('PASS: doctor-first selection, actual slots, switching/reset, stale responses, fallback, callback and review'); +} finally { + await act(async () => root.unmount()); + await vite.close(); +} diff --git a/web/src/components/LeadModal.css b/web/src/components/LeadModal.css index c4b3fdd..d993889 100644 --- a/web/src/components/LeadModal.css +++ b/web/src/components/LeadModal.css @@ -144,24 +144,6 @@ pointer-events: none; } -.lead-field__select--date img { - right: 14px; -} - -/* нативный календарь оставляем, но его значок прячем под иконкой из макета */ -.lead-field__select--date input::-webkit-calendar-picker-indicator { - position: absolute; - right: 10px; - width: 26px; - height: 26px; - opacity: 0; - cursor: pointer; -} - -.lead-field__select--date input { - padding: 0 44px 0 14px; -} - .lead-rating { display: flex; gap: 6px; @@ -301,124 +283,23 @@ } } -/* слоты по часам: выбор удобного часа приёма */ -.lead-slots { - display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: 8px; -} - -.lead-slot { - height: 40px; - padding: 0 4px; - background: #fff; - border: 1px solid var(--chip-border); - border-radius: var(--r-12); - font: inherit; - font-size: 14px; - font-weight: 600; - color: var(--navy); - cursor: pointer; - transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; -} - -.lead-slot:hover:not(:disabled) { - border-color: var(--coral); -} - -.lead-slot--on { - background: var(--coral); - border-color: var(--coral); - color: #fff; -} - -.lead-slot:disabled { - opacity: 0.35; - cursor: not-allowed; - text-decoration: line-through; -} - -.lead-slots__hint { +.lead-modal__availability { margin: 0; + padding: 14px 16px; + background: var(--chip); + border-radius: var(--r-12); font-size: 13px; - color: var(--muted, #7a8699); + line-height: 1.6; + color: var(--navy); } -@media (max-width: 600px) { - .lead-slots { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } -} - -/* ---------- запись на приём: широкое окно в две колонки ---------- - 24 слота по 30 минут в одну колонку не помещались на экран ноутбука — окно приходилось прокручивать. */ +/* Расширяем окно только для реального расписания выбранного врача. */ .lead-modal--wide { width: 920px; } -.lead-modal__form--split { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - grid-template-areas: - 'contacts date' - 'doctor time' - 'comment time'; - align-items: start; - column-gap: 32px; - row-gap: 18px; -} - -/* всё остальное (согласие, кнопка, подсказки) — на всю ширину под колонками */ -.lead-modal__form--split > * { - grid-column: 1 / -1; -} - -.lead-modal__form--split > .lead-modal__contacts { - grid-area: contacts; - /* в половине окна имя и телефон рядом не помещаются — друг под другом */ - grid-template-columns: 1fr; - row-gap: 18px; -} - -.lead-modal__form--split > .lead-field--doctor { - grid-area: doctor; -} - -.lead-modal__form--split > .lead-field--comment { - grid-area: comment; -} - -.lead-modal__form--split > .lead-field--date { - grid-area: date; -} - -.lead-modal__form--split > .lead-field--time { - grid-area: time; -} - -.lead-modal__form--split .lead-slot { - height: 36px; -} - -/* узкий экран: снова одна колонка */ @media (max-width: 960px) { .lead-modal--wide { width: 650px; } - .lead-modal__form--split { - display: flex; - flex-direction: column; - } -} - -/* телефон: слоты по 6 в ряд — 4 ряда вместо 6 */ -@media (max-width: 600px) { - .lead-modal__form--split .lead-slots { - grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: 6px; - } - .lead-modal__form--split .lead-slot { - height: 36px; - font-size: 13px; - } } diff --git a/web/src/components/LeadModal.jsx b/web/src/components/LeadModal.jsx index a7fa981..37fa925 100644 --- a/web/src/components/LeadModal.jsx +++ b/web/src/components/LeadModal.jsx @@ -2,7 +2,6 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { Link } from 'react-router-dom'; import closeIcon from '../assets/figma/icon-close.svg'; import chevronIcon from '../assets/figma/icon-accordion-chevron.svg'; -import calendarIcon from '../assets/figma/icon-calendar.svg'; import checkIcon from '../assets/figma/icon-check.svg'; import dotGreen from '../assets/figma/icon-dot-green.svg'; import starIcon from '../assets/figma/icon-star.svg'; @@ -15,7 +14,7 @@ import './LeadModal.css'; const COPY = { appointment: { title: 'Записаться на приём', - subtitle: 'Оставьте контакты — администратор поможет выбрать врача и время', + subtitle: 'Выберите врача, затем свободную дату и время приёма', }, callback: { title: 'Обратный звонок', @@ -31,78 +30,51 @@ const COPY = { }, }; -// Часы работы из настроек сайта («Пн–Вс: 08:00–20:00») → слоты по часам: 08:00 … 19:00. -// Пока нет расписания 1С, это пожелание пациента; администратор подтверждает время звонком. -function hourSlots(hours) { - const m = /(\d{1,2}):\d{2}\s*[–—-]\s*(\d{1,2}):\d{2}/.exec(hours || ''); - let from = m ? Number(m[1]) : 8; - let to = m ? Number(m[2]) : 20; - if (!(from >= 0 && to <= 24 && from < to)) [from, to] = [8, 20]; - const list = []; - for (let h = from; h < to; h++) { - const hh = String(h).padStart(2, '0'); - list.push(`${hh}:00`); - list.push(`${hh}:30`); - } - return list; -} - -function todayISO() { - const d = new Date(); - d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); - return d.toISOString().slice(0, 10); -} - export default function LeadModal({ form = 'appointment', doctor, slot, service, specialty, message = '', onClose }) { const copy = COPY[form] || COPY.appointment; const { hours } = useSiteData(); - const slots = useMemo(() => hourSlots(hours), [hours]); const uid = useId(); const dialogRef = useRef(null); const firstFieldRef = useRef(null); - // врач с записью через 1С: сначала выбор окна, потом контакты; если окон нет или 1С не отвечает — обычная заявка - const [nativeOff, setNativeOff] = useState(false); - const native = form === 'appointment' && doctor?.booking?.provider === 'native' && !nativeOff; - const onNativeUnavailable = useCallback(() => setNativeOff(true), []); - const needsDoctor = form === 'appointment' || form === 'review'; // запись на конкретную услугу: в списке только её врачи и врачи её специальности const scoped = Boolean(service && !doctor && form === 'appointment'); const [doctors, setDoctors] = useState([]); const [doctorsLoaded, setDoctorsLoaded] = useState(false); + const [unavailableDoctorId, setUnavailableDoctorId] = useState(null); + const [requestedSlot, setRequestedSlot] = useState(slot); const [values, setValues] = useState({ name: '', phone: '', doctorId: doctor?.id ? String(doctor.id) : '', - date: '', - time: '', message, rating: 5, consent: false, }); const [status, setStatus] = useState({ state: 'idle', message: '' }); + const selectedDoctor = doctors.find((d) => String(d.id) === values.doctorId) + || (doctor && String(doctor.id) === values.doctorId ? doctor : null); + const native = form === 'appointment' && selectedDoctor?.booking?.provider === 'native' + && unavailableDoctorId !== selectedDoctor.id; + const onNativeUnavailable = useCallback( + () => setUnavailableDoctorId(selectedDoctor?.id ?? null), + [selectedDoctor?.id], + ); const set = (key) => (e) => { const v = e?.target ? (e.target.type === 'checkbox' ? e.target.checked : e.target.value) : e; + if (key === 'doctorId') { + setUnavailableDoctorId(null); + setRequestedSlot(undefined); + setStatus({ state: 'idle', message: '' }); + } setValues((prev) => ({ ...prev, [key]: key === 'phone' ? formatPhoneInput(v) : v, - // другая дата — выбранный час мог стать прошедшим - ...(key === 'date' ? { time: '' } : {}), })); }; - // на сегодня — только слоты в будущем - const isPast = (slot) => { - if (values.date !== todayISO()) return false; - const now = new Date(); - const [h, m] = slot.split(':').map(Number); - const slotMinutes = h * 60 + (m || 0); - const nowMinutes = now.getHours() * 60 + now.getMinutes(); - return slotMinutes <= nowMinutes; - }; - useEffect(() => { if (!needsDoctor) return undefined; const ctrl = new AbortController(); @@ -111,12 +83,15 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service, : specialty ? api.doctors({ specialty }, { signal: ctrl.signal }) : Promise.resolve({ data: [] }); load .then(({ data }) => { + if (ctrl.signal.aborted) return; const byId = new Map(); - for (const d of [...own, ...(data || [])]) if (!byId.has(d.id)) byId.set(d.id, d); + for (const d of [...(data || []), ...own]) { + if (!d.absent && !byId.has(d.id)) byId.set(d.id, d); + } setDoctors([...byId.values()]); }) - .catch(() => setDoctors(own)) - .finally(() => setDoctorsLoaded(true)); + .catch(() => { if (!ctrl.signal.aborted) setDoctors(own); }) + .finally(() => { if (!ctrl.signal.aborted) setDoctorsLoaded(true); }); return () => ctrl.abort(); }, [needsDoctor, scoped, service, specialty]); @@ -161,6 +136,10 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service, const submit = async (e) => { e.preventDefault(); + if (form === 'appointment' && !selectedDoctor) { + setStatus({ state: 'error', message: 'Сначала выберите врача' }); + return; + } if (nameRequired && !values.name.trim()) { setStatus({ state: 'error', message: 'Укажите, как к вам обращаться' }); return; @@ -179,8 +158,6 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service, } const extra = {}; - if (values.date) extra.date = values.date; - if (values.time) extra.time = values.time; if (form === 'review') extra.rating = values.rating; setStatus({ state: 'sending', message: '' }); @@ -203,10 +180,7 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service, }; const titleId = `${uid}-title`; - // запись на приём: окно шире, чтобы дни и время помещались без прокрутки; форма заявки — в две колонки - // (контакты слева, дата и время справа) - const wide = form === 'appointment' && status.state !== 'done'; - const split = wide && !native; + const wide = native; return (- {doctor ? `Врач: ${doctor.full_name}` : copy.subtitle} + {(form === 'appointment' ? selectedDoctor : doctor) + ? `Врач: ${(form === 'appointment' ? selectedDoctor : doctor).full_name}` : copy.subtitle}
+ {doctorsLoaded && doctorOptions.length === 0 + ? 'Список врачей сейчас недоступен. Позвоните в клинику, чтобы уточнить запись.' + : 'Сначала выберите врача — покажем только его свободные даты и время из 1С.'} +
+ ) : native ? ( +{status.message}
@@ -241,12 +242,17 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,