// setup.jsx — Page 3: Consultation Setup with sidebar wizard
// Sub-steps: 1) Welcome, 2) Video onboarding, 3) Detailed intake form, 4) Booking calendar
function SetupPage({ navigate, formData, setFormData, intakeData, setIntakeData, booking, setBooking, tweaks, hasBooked }) {
const [step, setStep] = React.useState(1);
const [completed, setCompleted] = React.useState({ 1: false, 2: false, 3: false, 4: false });
// Track which steps have ever been visited so we can keep them mounted
// (display:none instead of unmount) — prevents re-fetching API on tab switch.
const [mounted, setMounted] = React.useState({ 1: true });
const markDone = (n) => setCompleted((c) => ({ ...c, [n]: true }));
const go = (n) => {
setMounted(m => ({ ...m, [n]: true }));
setStep(n);
};
const STEPS = [
{ n: 1, title: "Введение", sub: "О консультации и процессе" },
{ n: 2, title: "Инструкция", sub: "Видео-разбор от Дениса · 17 глав" },
{ n: 3, title: "Анкета", sub: "Расскажите о вашей ситуации" },
{ n: 4, title: "Бронирование", sub: "Выберите удобное время" },
];
return (
Шаг {step} из 4
{mounted[1] &&
{ markDone(1); go(2); }} formData={formData}/>
}
{mounted[2] &&
{ markDone(2); go(3); }} onBack={() => go(1)}/>
}
{mounted[3] &&
{ markDone(3); go(4); }} onBack={() => go(2)}/>
}
{mounted[4] &&
{ markDone(4); navigate("confirmation"); }}
onBack={() => go(3)} formData={formData} intakeData={intakeData}/>
}
);
}
/* -------- Step 1: Welcome -------- */
const WELCOME_ICON_MAP = {
Clock: Icon.Clock, Calendar: Icon.Calendar, Video: Icon.Video,
Doc: Icon.Doc, User: Icon.User, Upload: Icon.Upload,
Shield: Icon.Shield, Globe: Icon.Globe, Help: Icon.Help,
Lock: Icon.Lock, Check: Icon.Check, Plane: Icon.Plane,
};
const WELCOME_FALLBACK_ICONS = [Icon.Clock, Icon.Help, Icon.Doc, Icon.Shield];
function SetupWelcome({ onNext, formData }) {
const [content, setContent] = React.useState(null);
React.useEffect(() => {
fetch("/api/welcome")
.then(r => r.ok ? r.json() : null)
.then(d => { if (d) setContent(d); })
.catch(() => {});
}, []);
const heading = content?.heading ?? "Добро пожаловать";
const lead = content?.lead ?? "";
const features = content?.features ?? [];
const whyEnabled = content?.why_enabled ?? true;
const whyTitle = content?.why_title ?? "Почему это важно?";
const whyBody = content?.why_body ?? "";
if (!content) return (
);
return (
{heading}!
{lead}
{features.map((f, i) => {
const Ico = WELCOME_ICON_MAP[f.icon] ?? WELCOME_FALLBACK_ICONS[i % WELCOME_FALLBACK_ICONS.length];
return (
);
})}
{whyEnabled && (
)}
Начать
);
}
/* -------- Step 2: Video -------- */
const VIDEO_LIBRARY = [
{ t: "Знакомство: как проходит консультация", d: "3:42", x: "Короткое введение в формат: что мы обсудим, как лучше подготовиться, какие материалы будут полезны. Этот ролик стоит посмотреть в первую очередь — он задаёт контекст для всех остальных." },
{ t: "Какие вопросы подготовить заранее", d: "4:15", x: "Чек-лист вопросов, которые помогут вам получить максимум от консультации. Чем точнее вы сформулируете свои сомнения, тем конкретнее будут наши рекомендации." },
{ t: "Документы и материалы, которые могут понадобиться", d: "5:08", x: "Список документов, которые стоит подготовить: дипломы, сертификаты языка, опыт работы, рекомендации. Расскажем, что обязательно, а что — желательно." },
{ t: "Типы виз: обзор основных категорий", d: "8:21", x: "Рабочая, учебная, партнёрская, инвесторская, по таланту, гуманитарная. Сильные и слабые стороны каждой, кому какая подходит." },
{ t: "Skilled Migrant Category: как набрать баллы", d: "9:54", x: "Подробный разбор балльной системы: возраст, образование, опыт, английский, предложение о работе. Реальные примеры успешных и неуспешных профилей." },
{ t: "Work Visa: как найти работодателя", d: "7:33", x: "Стратегии поиска работы из-за границы. Какие компании готовы спонсировать визу, как составить резюме под местный рынок, на что обращают внимание рекрутёры." },
{ t: "Study Pathway: учёба как путь к иммиграции", d: "6:47", x: "Какие специальности и уровни образования открывают дорогу к ПМЖ. Стоимость, сроки, требования к английскому. Когда учёба — это инвестиция, а когда — пустая трата денег." },
{ t: "Партнёрская виза: что важно знать", d: "5:29", x: "Документы, доказательства отношений, частые отказы. Разница между супружеской и de facto визой. Что делать, если отношения нестандартные." },
{ t: "Бизнес и инвестиции: путь предпринимателя", d: "8:02", x: "Entrepreneur Work Visa, Investor Visa, требования к капиталу и бизнес-плану. Реальные пороги входа и сроки рассмотрения." },
{ t: "Английский язык: уровень и сертификаты", d: "5:51", x: "IELTS, PTE, TOEFL — какой сдавать, на какой балл целиться. Где готовиться, сколько это стоит, можно ли обойти этот пункт." },
{ t: "Финансовая подготовка к переезду", d: "6:38", x: "Минимальный бюджет, подушка безопасности, перевод денег между странами. Налоговые нюансы: где платить, как избежать двойного налогообложения." },
{ t: "Переезд с семьёй: что учесть", d: "7:14", x: "Визы для супруга и детей, школы, страховка, аренда жилья на семью. Разница между переездом одного человека и переездом семьи." },
{ t: "Медицина и страхование", d: "4:55", x: "Медосмотр для визы, какие требования, у кого могут быть проблемы. Страховка на период переезда и после." },
{ t: "Жизнь после переезда: первые месяцы", d: "9:11", x: "IRD-номер, банк, аренда, водительские права, симка. Что делать в первые две недели — пошаговый план." },
{ t: "Поиск работы на месте", d: "8:27", x: "Локальные сайты, рекрутёры, networking. Как переписать резюме и LinkedIn под местный рынок. Чего ждать на интервью и какие зарплаты реалистичны." },
{ t: "Частые ошибки кандидатов", d: "6:03", x: "Топ-15 ошибок, которые мы видим у клиентов: от плохо оформленных документов до неправильного выбора визы. Как их избежать." },
{ t: "Что делать после консультации", d: "4:22", x: "Как использовать наши рекомендации, в каком порядке начинать действовать, когда возвращаться для повторной консультации." },
];
function SetupVideo({ onNext, onBack }) {
const [openVideos, setOpenVideos] = React.useState({}); // id -> true
const [playingId, setPlayingId] = React.useState(null);
const [openFaq, setOpenFaq] = React.useState(-1);
const toggle = (i) => setOpenVideos((o) => ({ ...o, [i]: !o[i] }));
const FAQ = [
{ q: "Обязательно ли смотреть все 17 видео?", a: "Нет, но мы рекомендуем посмотреть хотя бы первые 5 — они дают общую картину. Остальные стоит выбирать по вашей ситуации (тип визы, семейное положение, профессия)." },
{ q: "Сколько длится консультация?", a: "Стандартная консультация — 60 минут. При необходимости мы можем продлить разговор — предупредим заранее." },
{ q: "На каком языке проводится консультация?", a: "На русском или английском — на ваш выбор. Возможен смешанный формат." },
{ q: "Получу ли я запись консультации?", a: "Да, после консультации мы отправим запись и резюме с планом действий на email." },
{ q: "Что если я не смогу подключиться вовремя?", a: "Можем перенести консультацию один раз бесплатно, если вы предупредите за 24 часа." },
];
const watchedCount = Object.values(openVideos).filter(Boolean).length;
return (
Видео-материалы
17 коротких видео по ключевым темам иммиграции. Посмотрите те, что относятся к вашей ситуации — это сильно ускорит консультацию.
Не обязательно смотреть все. Минимум — первые 5 для общего контекста, затем выбирайте по теме.
Все материалы
{watchedCount} из {VIDEO_LIBRARY.length} открыто
{VIDEO_LIBRARY.map((v, i) => {
const isOpen = !!openVideos[i];
return (
toggle(i)}>
{String(i+1).padStart(2,"0")}
{v.t}
{v.d}
{isOpen && (
{ e.stopPropagation(); setPlayingId(playingId===i?null:i); }}>
{playingId === i ? (
Здесь будет встроенное видео
) : (
<>
{v.d}
>
)}
{v.x}
)}
);
})}
Частые вопросы
{FAQ.map((f, i) => (
setOpenFaq(openFaq===i?-1:i)}>
{f.q}
{openFaq===i &&
{f.a}
}
))}
Назад
Перейти к анкете
);
}
/* -------- Step 3: Intake form (multi-section) -------- */
const ENGLISH_LEVELS = ["A1 — Beginner","A2 — Elementary","B1 — Intermediate","B2 — Upper-Intermediate","C1 — Advanced","C2 — Proficiency / Native"];
const EDU_LEVELS = ["Среднее","Среднее специальное","Незаконченное высшее","Бакалавр","Специалист","Магистр","PhD / Кандидат наук"];
function PersonBlock({ prefix, data, update, optional }) {
const v = (k) => data[`${prefix}_${k}`] || "";
const set = (k, val) => update(`${prefix}_${k}`, val);
return (
);
}
const _PERSON_FIELDS = (prefix) => [
{ key: `${prefix}_name`, label: "Имя", type: "text", hint: null, placeholder: "Имя и фамилия", options: null, optional: false, half: true },
{ key: `${prefix}_age`, label: "Возраст (полных лет)", type: "number", hint: null, placeholder: "35", options: null, optional: false, half: true },
{ key: `${prefix}_eduLevel`, label: "Образование — уровень", type: "select", hint: "Неоконченное укажите тоже", placeholder: null, options: EDU_LEVELS, optional: false, half: true },
{ key: `${prefix}_gpa`, label: "Средний балл", type: "text", hint: "По главному из имеющихся образований", placeholder: "Например: 4.7 или 85%", options: null, optional: false, half: true },
{ key: `${prefix}_major`, label: "Специальность", type: "text", hint: "Что изучали или изучаете", placeholder: "Например: Информатика и вычислительная техника", options: null, optional: false, half: false },
{ key: `${prefix}_experience`, label: "Опыт работы", type: "textarea", hint: "Основной, наиболее значимый", placeholder: "Должность, индустрия, сколько лет, что делали", options: null, optional: false, half: false },
{ key: `${prefix}_english`, label: "Уровень английского", type: "chips", hint: null, placeholder: null, options: ENGLISH_LEVELS, optional: false, half: false },
];
const FALLBACK_SECTIONS = [
{
key: "main",
title: "Основной заявитель",
description: "Тот, кто планирует учиться или работать в Новой Зеландии.",
fields: _PERSON_FIELDS("main"),
},
{
key: "partner",
title: "Партнёр",
description: "Супруг или супруга, если планируете переезжать вместе.",
fields: [
{ key: "hasPartner", label: "Едете с партнёром?", type: "partner_toggle", hint: null, placeholder: null, options: null, optional: false, half: false },
..._PERSON_FIELDS("partner"),
],
},
{
key: "other",
title: "Прочее",
description: null,
fields: [
{ key: "kids", label: "Количество и возраст детей", type: "text", hint: "Если детей нет — оставьте пустым", placeholder: "Например: двое, 6 и 9 лет", options: null, optional: true, half: false },
{ key: "pets", label: "Наличие и вид домашних животных", type: "text", hint: null, placeholder: "Например: кошка, или собака средней породы", options: null, optional: true, half: false },
{ key: "refusals", label: "Отказы в визах", type: "textarea", hint: "Укажите страны и типы виз", placeholder: "Если отказов не было — оставьте пустым", options: null, optional: true, half: false },
{ key: "arrival", label: "Когда планируете оказаться в Новой Зеландии", type: "text", hint: null, placeholder: "Например: осень 2026, февральский интейк 2027", options: null, optional: false, half: false },
],
},
{
key: "cv",
title: "Резюме",
description: "Прикрепите резюме, если есть. На любом языке — мы разберёмся.",
fields: [
{ key: "cv", label: "Резюме", type: "file", hint: null, placeholder: null, options: null, optional: true, half: false },
],
},
{
key: "questions",
title: "Вопросы к консультации",
description: "Что вы хотите обсудить с Денисом? Чем конкретнее — тем полезнее.",
fields: [
{ key: "questions", label: "Ваши вопросы", type: "textarea", hint: null,
placeholder: "Например:\n1. Какая программа лучше под мою специальность?\n2. Реально ли с моим опытом найти работу в первые 6 месяцев?\n3. Что с переводом денег и оплатой обучения в моей ситуации?",
options: null, optional: false, half: false },
{ key: "_note_booking", label: "После сохранения анкеты вы сможете выбрать удобное время для консультации.", type: "note", hint: null, placeholder: null, options: null, optional: false, half: false },
],
},
];
function IntakeSectionFields({ section, intakeData, update, disabled }) {
const hasPartner = !!intakeData.hasPartner;
const hasToggle = section.fields.some(f => f.type === "partner_toggle");
const noop = () => {};
function renderField(field) {
if (field.type === "partner_toggle") {
return (
update("hasPartner",false)} disabled={disabled}>Еду один / одна
update("hasPartner",true)} disabled={disabled}>Еду с партнёром
);
}
if (field.type === "select") {
return (
update(field.key,e.target.value)} disabled={disabled}>
Выберите
{(field.options||[]).map(o => {o} )}
);
}
if (field.type === "chips") {
return (
{(field.options||[]).map(o => (
update(field.key,o)} disabled={disabled}>{o}
))}
);
}
if (field.type === "file") {
return update(field.key,v)}/>;
}
if (field.type === "note") {
return {field.label} ;
}
if (field.type === "textarea") {
return (
);
}
return (
update(field.key,e.target.value)}
readOnly={disabled}/>
);
}
function renderGrouped(fields) {
const out = [];
let i = 0;
while (i < fields.length) {
if (fields[i].half && fields[i+1]?.half) {
out.push({renderField(fields[i])}{renderField(fields[i+1])}
);
i += 2;
} else {
out.push(renderField(fields[i]));
i++;
}
}
return out;
}
if (hasToggle) {
const toggleField = section.fields.find(f => f.type === "partner_toggle");
const bodyFields = section.fields.filter(f => f.type !== "partner_toggle");
return (
<>
{section.title}
{section.description && (
{section.description}
)}
{renderField(toggleField)}
{hasPartner ? (
{renderGrouped(bodyFields)}
) : (
Если планы изменятся — можно вернуться и заполнить блок партнёра позже.
)}
>
);
}
return (
{section.title}
{section.description && (
{section.description}
)}
{renderGrouped(section.fields)}
);
}
function SetupIntake({ intakeData, setIntakeData, hasBooked, onNext, onBack }) {
const [section, setSection] = React.useState(0);
const [sections, setSections] = React.useState(FALLBACK_SECTIONS);
const [saving, setSaving] = React.useState(false);
const [saveError, setSaveError] = React.useState(false);
React.useEffect(() => {
fetch("/api/intake/sections")
.then(r => r.ok ? r.json() : null)
.then(data => { if (Array.isArray(data) && data.length > 0) setSections(data); })
.catch(() => {});
}, []);
// Load saved answers on mount
React.useEffect(() => {
const token = localStorage.getItem("kiwi_session_token");
if (!token) return;
fetch("/api/customer/anketa", { headers: { Authorization: `Bearer ${token}` } })
.then(r => r.ok ? r.json() : null)
.then(d => { if (d && d.data && Object.keys(d.data).length > 0) setIntakeData(d.data); })
.catch(() => {});
}, []);
const update = (k, v) => setIntakeData((d) => ({ ...d, [k]: v }));
const saveAnketa = async () => {
const token = localStorage.getItem("kiwi_session_token");
if (!token) return true; // no session — skip silently
setSaving(true);
setSaveError(false);
try {
const res = await fetch("/api/customer/anketa", {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ data: intakeData }),
});
if (!res.ok) { setSaveError(true); setSaving(false); return false; }
} catch { setSaveError(true); setSaving(false); return false; }
setSaving(false);
return true;
};
const nextSection = async () => {
if (section < sections.length - 1) {
setSection(section + 1);
} else {
const ok = await saveAnketa();
if (ok) onNext();
}
};
const prevSection = () => {
if (section > 0) setSection(section - 1);
else onBack();
};
const locked = !!hasBooked;
return (
Анкета
Расскажите о себе чуть больше — это поможет нам подробнее подготовиться к консультации и дать более персонализированные советы.
{locked && (
🔒
Анкета заблокирована
После бронирования изменять анкету невозможно. Если есть вопросы — свяжитесь с консультантом.
)}
{/* Section tabs */}
{sections.map((s, i) => (
setSection(i)}>
{i+1} {s.title}
))}
Назад
{saveError &&
Ошибка сохранения }
{section + 1} из {sections.length}
{!locked && (
{saving ? "Сохранение…" : (section < sections.length - 1 ? "Далее" : "К бронированию")}
{!saving && }
)}
);
}
function FileUpload({ value, onChange, disabled }) {
const inputRef = React.useRef(null);
const [uploading, setUploading] = React.useState(false);
const [uploadError, setUploadError] = React.useState("");
const handleFile = async (f) => {
if (!f) return;
setUploadError("");
setUploading(true);
try {
const token = localStorage.getItem("kiwi_session_token");
const fd = new FormData();
fd.append("file", f);
const res = await fetch("/api/customer/resume", {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: fd,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
setUploadError(err.detail || "Ошибка загрузки");
return;
}
const data = await res.json();
onChange({ name: data.filename, size: data.size, fileId: data.fileId });
} catch {
setUploadError("Ошибка сети");
} finally {
setUploading(false);
}
};
if (value && value.fileId) {
return (
{value.name}
{(value.size/1024).toFixed(0)} KB · загружено
{!disabled && (
onChange(null)}>Удалить
)}
);
}
return (
!uploading && !disabled && inputRef.current?.click()}>
{ const f = e.target.files?.[0]; if (f) handleFile(f); e.target.value = ""; }}/>
{uploading ? : }
{uploading ? "Загружаем…" : "Перетащите файл сюда или нажмите, чтобы выбрать"}
PDF, DOC, DOCX · до 10 МБ
{uploadError &&
{uploadError}
}
);
}
const TIMEZONES = [
{ iana: "Pacific/Honolulu", label: "Hawaii Time" },
{ iana: "America/Anchorage", label: "Alaska Time" },
{ iana: "America/Los_Angeles", label: "Pacific Time – US & Canada" },
{ iana: "America/Denver", label: "Mountain Time – US & Canada" },
{ iana: "America/Chicago", label: "Central Time – US & Canada" },
{ iana: "America/New_York", label: "Eastern Time – US & Canada" },
{ iana: "America/Halifax", label: "Atlantic Time – Canada" },
{ iana: "America/Sao_Paulo", label: "Brasilia" },
{ iana: "America/Argentina/Buenos_Aires", label: "Buenos Aires" },
{ iana: "Europe/London", label: "London" },
{ iana: "Europe/Paris", label: "Central European Time" },
{ iana: "Europe/Kyiv", label: "Eastern European Time" },
{ iana: "Europe/Moscow", label: "Moscow" },
{ iana: "Asia/Dubai", label: "Gulf Standard Time" },
{ iana: "Asia/Karachi", label: "Pakistan Standard Time" },
{ iana: "Asia/Kolkata", label: "India Standard Time" },
{ iana: "Asia/Bangkok", label: "Indochina Time" },
{ iana: "Asia/Singapore", label: "Singapore Time" },
{ iana: "Asia/Shanghai", label: "China Standard Time" },
{ iana: "Asia/Tokyo", label: "Japan Standard Time" },
{ iana: "Australia/Sydney", label: "Australian Eastern Time" },
{ iana: "Pacific/Auckland", label: "New Zealand Time" },
];
function TimezonePicker({ timezone, onChange }) {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
const ref = React.useRef(null);
React.useEffect(() => {
if (!open) return;
const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener("mousedown", h);
return () => document.removeEventListener("mousedown", h);
}, [open]);
const entry = TIMEZONES.find(t => t.iana === timezone) || { iana: timezone, label: timezone };
const currentTime = new Date().toLocaleTimeString("en-US", {
timeZone: timezone, hour: "numeric", minute: "2-digit", hour12: true,
});
const filtered = TIMEZONES.filter(t =>
t.label.toLowerCase().includes(search.toLowerCase()) ||
t.iana.toLowerCase().includes(search.toLowerCase())
);
return (
{ setOpen(o => !o); setSearch(""); }}
>
{entry.label}
({currentTime})
{open && (
setSearch(e.target.value)}
placeholder="Поиск таймзоны…"
style={{width:"100%",border:"1px solid var(--line)",borderRadius:6,
padding:"6px 10px",fontSize:13,outline:"none",boxSizing:"border-box"}}/>
{filtered.map(t => (
{ onChange(t.iana); setOpen(false); }}
style={{display:"block",width:"100%",textAlign:"left",padding:"9px 14px",
fontSize:13,background: t.iana===timezone ? "var(--brand-soft)" : "none",
color: t.iana===timezone ? "var(--brand)" : "var(--ink)",
fontWeight: t.iana===timezone ? 600 : 400,
border:0,cursor:"pointer"}}>
{t.label}
))}
{filtered.length === 0 && (
Не найдено
)}
)}
);
}
/* -------- Step 4: Booking calendar (Calendly API + custom UI) -------- */
function SetupBooking({ booking, setBooking, onBook, onBack, formData, intakeData }) {
const [month, setMonth] = React.useState(() => new Date());
const [rawSlots, setRawSlots] = React.useState(null); // flat [{startTime, schedulingUrl}]
const [slotsError, setSlotsError] = React.useState(false);
const [confirming, setConfirming] = React.useState(false);
const [timezone, setTimezone] = React.useState(
() => Intl.DateTimeFormat().resolvedOptions().timeZone
);
const selectedDate = booking.date;
const selectedTime = booking.time;
const monthKey = `${month.getFullYear()}-${String(month.getMonth() + 1).padStart(2, "0")}`;
React.useEffect(() => {
setRawSlots(null);
setSlotsError(false);
setBooking(b => ({ ...b, date: null, time: null }));
fetch(`/api/calendly/slots?month=${monthKey}`)
.then(r => { if (!r.ok) throw new Error(); return r.json(); })
.then(data => setRawSlots(data))
.catch(() => { setRawSlots([]); setSlotsError(true); });
}, [monthKey]);
// Regroup when timezone changes — date/time display must stay consistent
const slots = React.useMemo(() => {
if (!rawSlots) return null;
const grouped = {};
for (const slot of rawSlots) {
const key = new Date(slot.startTime).toLocaleDateString("sv", { timeZone: timezone });
(grouped[key] = grouped[key] || []).push(slot);
}
return grouped;
}, [rawSlots, timezone]);
// Clear selection when timezone changes so stale date/time keys don't linger
React.useEffect(() => {
setBooking(b => ({ ...b, date: null, time: null }));
}, [timezone]);
React.useEffect(() => {
const onMessage = (e) => {
if (e.data?.event !== "calendly.event_scheduled") return;
const p = e.data.payload;
setBooking(b => ({
...b,
startTime: p.event?.start_time,
inviteeName: p.invitee?.name,
inviteeEmail: p.invitee?.email,
eventUri: p.event?.uri,
}));
const name = formData.customerName || p.invitee?.name || formData.email || "";
const email = formData.email || p.invitee?.email || "";
if (email) {
fetch("/api/application", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, destination: formData.destination || "" }),
}).catch(() => {});
}
// Record booking on backend (locks anketa)
const sessionToken = localStorage.getItem("kiwi_session_token");
const doBook = () => setTimeout(onBook, 400);
if (sessionToken) {
fetch("/api/customer/booking", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${sessionToken}` },
body: JSON.stringify({
calendly_event_uri: p.event?.uri || null,
scheduled_at: p.event?.start_time || null,
}),
}).catch(() => {}).finally(doBook);
} else {
doBook();
}
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, []);
const monthName = month.toLocaleString("ru-RU", { month: "long", year: "numeric" });
const firstDay = new Date(month.getFullYear(), month.getMonth(), 1);
let startWeekday = firstDay.getDay();
startWeekday = startWeekday === 0 ? 6 : startWeekday - 1;
const daysInMonth = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate();
const days = [];
for (let i = 0; i < startWeekday; i++) days.push(null);
for (let i = 1; i <= daysInMonth; i++) days.push(i);
while (days.length % 7 !== 0) days.push(null);
const fmtKey = (d) =>
`${month.getFullYear()}-${String(month.getMonth()+1).padStart(2,"0")}-${String(d).padStart(2,"0")}`;
const todayKey = new Date().toLocaleDateString("sv", { timeZone: timezone });
const isAvailable = (d) => {
if (!d || !slots) return false;
return fmtKey(d) >= todayKey && !!(slots[fmtKey(d)]?.length);
};
const isToday = (d) => !!d && fmtKey(d) === todayKey;
const goPrev = () => setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1));
const goNext = () => setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1));
const slotsForDate = selectedDate ? (slots?.[fmtKey(selectedDate)] || []) : [];
const formatDate = (d) =>
new Date(month.getFullYear(), month.getMonth(), d)
.toLocaleDateString("ru-RU", { day: "numeric", month: "long", weekday: "long" });
const fmtTime = (isoStr) =>
new Date(isoStr).toLocaleTimeString("ru-RU", { timeZone: timezone, hour: "2-digit", minute: "2-digit" });
const loadCalendlyAndOpen = (schedulingUrl) => {
const open = () => {
const url = new URL(schedulingUrl);
const fullName = intakeData?.main_name || [formData?.firstName, formData?.lastName].filter(Boolean).join(" ");
if (fullName) url.searchParams.set("name", fullName);
if (formData?.email) url.searchParams.set("email", formData.email);
url.searchParams.set("hide_gdpr_banner", "1");
url.searchParams.set("hide_event_type_details", "1");
window.Calendly.initPopupWidget({ url: url.toString() });
setConfirming(false);
};
if (window.Calendly) { open(); return; }
if (!document.getElementById("calendly-css")) {
const link = document.createElement("link");
link.id = "calendly-css";
link.rel = "stylesheet";
link.href = "https://assets.calendly.com/assets/external/widget.css";
document.head.appendChild(link);
}
const s = document.createElement("script");
s.src = "https://assets.calendly.com/assets/external/widget.js";
s.onload = open;
document.head.appendChild(s);
};
const handleConfirm = () => {
const slot = slotsForDate.find(s => s.startTime === selectedTime);
if (!slot) return;
setConfirming(true);
loadCalendlyAndOpen(slot.schedulingUrl);
};
return (
Выберите удобное время
Консультация длится 45 минут и проходит онлайн. Ссылку пришлём на email.
{slotsError && (
Не удалось загрузить расписание. Попробуйте перезагрузить страницу.
)}
{rawSlots === null && (
Загружаем доступное время…
)}
{/* Calendar */}
{monthName}
{["Пн","Вт","Ср","Чт","Пт","Сб","Вс"].map((w) => (
{w}
))}
{days.map((d, i) => {
if (!d) return
;
const avail = isAvailable(d);
return (
avail && setBooking(b => ({...b, date: d, time: null}))}>
{d}
);
})}
Выбрано
Сегодня
Недоступно
{/* Times */}
{selectedDate ? "Доступное время" : "Сначала выберите дату"}
{selectedDate ? formatDate(selectedDate) : "—"}
{selectedDate ? (
slotsForDate.length > 0 ? (
{slotsForDate.map((slot) => (
setBooking(b => ({...b, time: slot.startTime}))}>
{fmtTime(slot.startTime)}
))}
) : (
Нет доступного времени
)
) : (
Выберите день в календаре слева
)}
Что выбрано
Дата
{selectedDate ? formatDate(selectedDate) : "—"}
Время
{selectedTime ? fmtTime(selectedTime) : "—"}
Длительность
45 минут
{confirming
? Открываем…
: <>Подтвердить бронирование >}
Назад к анкете
);
}
window.SetupPage = SetupPage;