fix(booking): select doctor before showing real 1C slots
This commit is contained in:
parent
6e30d4cb5f
commit
97fc02778e
101
web/scripts/test-booking-flow.mjs
Normal file
101
web/scripts/test-booking-flow.mjs
Normal file
@ -0,0 +1,101 @@
|
||||
// Изолированные UI-регрессии: только вымышленные врачи и окна, без сети и заявок.
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseHTML } from 'linkedom';
|
||||
import { createServer } from 'vite';
|
||||
|
||||
const { window } = parseHTML('<html><body><div id="root"></div></body></html>');
|
||||
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();
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<div className="lead-modal__backdrop" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
|
||||
@ -223,7 +197,8 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
|
||||
{copy.title}
|
||||
</h2>
|
||||
<p className="lead-modal__subtitle">
|
||||
{doctor ? `Врач: ${doctor.full_name}` : copy.subtitle}
|
||||
{(form === 'appointment' ? selectedDoctor : doctor)
|
||||
? `Врач: ${(form === 'appointment' ? selectedDoctor : doctor).full_name}` : copy.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="lead-modal__close" onClick={onClose} aria-label="Закрыть">
|
||||
@ -231,8 +206,34 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{native ? (
|
||||
<NativeBooking doctor={doctor} initialSlot={slot} onClose={onClose} onUnavailable={onNativeUnavailable} />
|
||||
{form === 'appointment' && status.state !== 'done' && (
|
||||
<label className="lead-field lead-field--doctor">
|
||||
<span className="lead-field__label">Специалист</span>
|
||||
<span className="lead-field__select">
|
||||
<select ref={firstFieldRef} className="lead-field__control" value={values.doctorId} onChange={set('doctorId')}>
|
||||
<option value="">{doctorsLoaded ? 'Выберите врача' : 'Загружаем список врачей…'}</option>
|
||||
{doctor && !doctorOptions.some((d) => d.id === doctor.id) && (
|
||||
<option value={doctor.id}>{doctor.full_name}</option>
|
||||
)}
|
||||
{doctorOptions.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.full_name}{d.position ? ` — ${d.position}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<img src={chevronIcon} alt="" width="16" height="16" />
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{form === 'appointment' && !selectedDoctor ? (
|
||||
<p className="lead-modal__availability" role="status">
|
||||
{doctorsLoaded && doctorOptions.length === 0
|
||||
? 'Список врачей сейчас недоступен. Позвоните в клинику, чтобы уточнить запись.'
|
||||
: 'Сначала выберите врача — покажем только его свободные даты и время из 1С.'}
|
||||
</p>
|
||||
) : native ? (
|
||||
<NativeBooking key={selectedDoctor.id} doctor={selectedDoctor} initialSlot={requestedSlot} onClose={onClose} onUnavailable={onNativeUnavailable} />
|
||||
) : status.state === 'done' ? (
|
||||
<div className="lead-modal__done" role="status">
|
||||
<p>{status.message}</p>
|
||||
@ -241,12 +242,17 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form className={`lead-modal__form${split ? ' lead-modal__form--split' : ''}`} onSubmit={submit} noValidate>
|
||||
<form className="lead-modal__form" onSubmit={submit} noValidate>
|
||||
{form === 'appointment' && (
|
||||
<p className="lead-modal__availability" role="status">
|
||||
Свободные окна этого врача сейчас недоступны для онлайн-записи. Оставьте контакты — администратор уточнит расписание.
|
||||
</p>
|
||||
)}
|
||||
<div className="lead-modal__row lead-modal__contacts">
|
||||
<label className="lead-field">
|
||||
<span className="lead-field__label">Ваше имя</span>
|
||||
<input
|
||||
ref={firstFieldRef}
|
||||
ref={form === 'appointment' ? undefined : firstFieldRef}
|
||||
className="lead-field__control"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
@ -269,13 +275,13 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{needsDoctor && !(scoped && doctorsLoaded && doctorOptions.length === 0) && (
|
||||
{form === 'review' && (
|
||||
<label className="lead-field lead-field--doctor">
|
||||
<span className="lead-field__label">Специалист</span>
|
||||
<span className="lead-field__select">
|
||||
<select className="lead-field__control" value={values.doctorId} onChange={set('doctorId')}>
|
||||
<option value="">
|
||||
{form === 'review' ? 'Выберите врача, если отзыв о нём' : scoped ? 'Любой врач по этой услуге' : 'Выберите направление или врача'}
|
||||
Выберите врача, если отзыв о нём
|
||||
</option>
|
||||
{doctor && !doctorOptions.some((d) => d.id === doctor.id) && (
|
||||
<option value={doctor.id}>{doctor.full_name}</option>
|
||||
@ -292,55 +298,6 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
|
||||
</label>
|
||||
)}
|
||||
|
||||
{form === 'appointment' && (
|
||||
<>
|
||||
<label className="lead-field lead-field--date">
|
||||
<span className="lead-field__label">Дата</span>
|
||||
<span className="lead-field__select lead-field__select--date">
|
||||
<input
|
||||
className="lead-field__control"
|
||||
type="date"
|
||||
min={todayISO()}
|
||||
value={values.date}
|
||||
onChange={set('date')}
|
||||
/>
|
||||
<img src={calendarIcon} alt="" width="18" height="18" />
|
||||
</span>
|
||||
</label>
|
||||
<div className="lead-field lead-field--time">
|
||||
<span className="lead-field__label" id={`${uid}-time`}>
|
||||
Удобное время
|
||||
</span>
|
||||
{values.date ? (
|
||||
<div className="lead-slots" role="radiogroup" aria-labelledby={`${uid}-time`}>
|
||||
{slots
|
||||
.filter((t) => !isPast(t))
|
||||
.map((t) => {
|
||||
const on = values.time === t;
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={on}
|
||||
className={`lead-slot${on ? ' lead-slot--on' : ''}`}
|
||||
onClick={() => setValues((p) => ({ ...p, time: on ? '' : t }))}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{values.date === todayISO() && slots.filter((t) => !isPast(t)).length === 0 && (
|
||||
<p className="lead-slots__hint">На сегодня свободного времени уже нет. Пожалуйста, выберите другую дату.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="lead-slots__hint">Сначала выберите дату — покажем время приёма</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{form === 'review' && (
|
||||
<div className="lead-field">
|
||||
<span className="lead-field__label">Оценка</span>
|
||||
|
||||
@ -75,16 +75,20 @@ export default function NativeBooking({ doctor, initialSlot, onClose, onUnavaila
|
||||
api
|
||||
.doctorSlots(doctor.slug, { signal: ctrl.signal })
|
||||
.then(({ data }) => {
|
||||
if (!data?.enabled || !data.slots?.length) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
const actualSlots = (data?.slots || []).filter((s) => s.status === 'free' && new Date(s.start).getTime() > Date.now());
|
||||
if (!data?.enabled || !actualSlots.length) {
|
||||
unavailable.current();
|
||||
return;
|
||||
}
|
||||
setSlots(data.slots);
|
||||
setSlots(actualSlots);
|
||||
// Окно из карточки тоже должно оставаться свободным в свежем ответе 1С.
|
||||
setChosen(initialSlot ? actualSlots.find((s) => s.start === initialSlot.start && s.branch_id === initialSlot.branch_id) || null : null);
|
||||
setServices(data.services || []);
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (e.name !== 'AbortError') unavailable.current();
|
||||
if (!ctrl.signal.aborted && e.name !== 'AbortError') unavailable.current();
|
||||
});
|
||||
return () => ctrl.abort();
|
||||
}, [doctor.slug]);
|
||||
@ -100,7 +104,7 @@ export default function NativeBooking({ doctor, initialSlot, onClose, onUnavaila
|
||||
return slots.filter((s) => {
|
||||
if (branch && s.branch_id !== branch) return false;
|
||||
const t = new Date(s.start).getTime();
|
||||
return isNaN(t) || t > now;
|
||||
return s.status === 'free' && t > now;
|
||||
});
|
||||
}, [slots, branch]);
|
||||
const days = useMemo(() => groupByDay(visible), [visible]);
|
||||
@ -275,7 +279,7 @@ export default function NativeBooking({ doctor, initialSlot, onClose, onUnavaila
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="native-booking__hint">Выберите удобное время — на следующем шаге укажете контакты.</p>
|
||||
<p className="native-booking__hint">Выберите свободное окно врача — на следующем шаге укажете контакты.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user