'use client'; import { normalizeAgencyCode, pickUtm, type Locale } from '@barev/core'; import { tFromMessages, type Messages } from '@barev/i18n'; import { Icon } from '@barev/ui'; import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { BotLink } from './client'; const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000'; const AGENCY_KEY = 'barev.agency'; const WHEN = ['month', 'quarter', 'later', 'date'] as const; const PACKAGES = ['CONSULT', 'TURNKEY', 'CUSTOM'] as const; const YES_NO = ['flat', 'socialCard', 'bankCard', 'registration', 'residence'] as const; type When = (typeof WHEN)[number]; type Pkg = (typeof PACKAGES)[number]; type Step = 'contact' | 'moved' | 'when' | 'package' | 'custom' | 'done' | 'redirect'; const ORDER: Step[] = ['contact', 'moved', 'when', 'package', 'custom']; interface Options { airport: 'none' | 'standard' | 'premium'; stay: 'none' | 'flat' | 'hotel'; stayBudgetFrom: string; stayBudgetTo: string; flat: boolean; socialCard: boolean; bankCard: boolean; registration: boolean; residence: boolean; } const EMPTY: Options = { airport: 'none', stay: 'none', stayBudgetFrom: '', stayBudgetTo: '', flat: false, socialCard: false, bankCard: false, registration: false, residence: false }; class ApiError extends Error {} /** tRPC-мутация лендинга; ошибка — код домена (CONTACT_INVALID…) или пусто для сети */ async function mutate(path: string, json: unknown): Promise { let res: Response; try { res = await fetch(`${API}/trpc/public.${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ json }) }); } catch { throw new ApiError(''); } const body = (await res.json().catch(() => null)) as { result?: { data?: { json?: T } }; error?: { json?: { message?: string; data?: { domainCode?: string | null } } } } | null; const data = body?.result?.data?.json; if (data === undefined) throw new ApiError(body?.error?.json?.data?.domainCode ?? body?.error?.json?.message ?? 'generic'); return data; } const QuizCtx = createContext<() => void>(() => {}); /** Кнопка, открывающая квиз; на странице может быть несколько */ export function QuizButton({ className, children }: { className?: string; children: ReactNode }) { const open = useContext(QuizCtx); return ( ); } /** * Квиз «Подбор переезда» в модальном окне. Имя и контакт сохраняются сразу после первого шага, * дальше каждый ответ дописывается в ту же заявку — оператор видит её в админке, даже если человек не дошёл до конца. * «Уже живу в Армении» — переход на /live. */ export function QuizProvider({ locale, messages, consultPrice, children }: { locale: Locale; messages: Messages; consultPrice: string; children: ReactNode }) { const t = useMemo(() => tFromMessages(locale, messages), [locale, messages]); const dialog = useRef(null); const heading = useRef(null); const [step, setStep] = useState('contact'); const [dir, setDir] = useState<'fwd' | 'back'>('fwd'); const [lead, setLead] = useState<{ id: string; key: string; number: number; name: string; contact: string } | null>(null); const [name, setName] = useState(''); const [contact, setContact] = useState(''); const [when, setWhen] = useState(null); const [date, setDate] = useState(''); const [pkg, setPkg] = useState(null); const [opts, setOpts] = useState(EMPTY); const [busy, setBusy] = useState(false); const [error, setError] = useState<{ field?: 'name' | 'contact' | 'when' | 'custom'; text: string } | null>(null); const [today, setToday] = useState(''); useEffect(() => setToday(new Date().toISOString().slice(0, 10)), []); // новый шаг — фокус на заголовок, чтобы экранный диктор прочитал вопрос useEffect(() => heading.current?.focus({ preventScroll: true }), [step]); const open = () => { dialog.current?.showModal(); heading.current?.focus({ preventScroll: true }); }; const close = () => dialog.current?.close(); function go(next: Step) { setDir(ORDER.indexOf(next) < ORDER.indexOf(step) ? 'back' : 'fwd'); setError(null); setStep(next); } function back() { const prev: Partial> = { moved: 'contact', when: 'moved', package: 'when', custom: 'package' }; if (prev[step]) go(prev[step]!); } async function run(fn: () => Promise, field?: 'name' | 'contact' | 'when' | 'custom') { setBusy(true); setError(null); try { await fn(); } catch (e) { const code = e instanceof ApiError ? e.message : ''; const text = !code ? t('landing.quiz.errNetwork') : messages[`errors.${code}`] ? t(`errors.${code}`) : t('errors.generic'); setError({ field: code === 'CONTACT_INVALID' ? 'contact' : code === 'NAME_REQUIRED' ? 'name' : field, text }); } finally { setBusy(false); } } const update = (json: Record) => mutate<{ number: number; submitted: boolean }>('leadUpdate', { id: lead!.id, key: lead!.key, ...json }); function submitContact() { if (!name.trim()) return setError({ field: 'name', text: t('landing.quiz.errName') }); const c = contact.trim(); const looksOk = /^(?:https?:\/\/)?(?:t\.me\/)?@?[a-zA-Z][a-zA-Z0-9_]{4,31}$/.test(c) || /^\+?\d{8,15}$/.test(c.replace(/[\s()\-.]/g, '')); if (!looksOk) return setError({ field: 'contact', text: t('landing.quiz.errContact') }); // вернулись назад и ничего не поменяли — заявка уже есть if (lead && lead.name === name.trim() && lead.contact === c) return go('moved'); void run(async () => { const search = new URLSearchParams(window.location.search); let agency = search.get('a') ? normalizeAgencyCode(search.get('a')!) : null; try { agency ??= localStorage.getItem(AGENCY_KEY); } catch { /* приватный режим */ } const utm = pickUtm(search); const r = await mutate<{ id: string; key: string; number: number }>('leadStart', { name: name.trim(), contact: c, locale, agency: agency ?? undefined, utm: Object.keys(utm).length ? utm : undefined, consent: true, }); setLead({ ...r, name: name.trim(), contact: c }); go('moved'); }); } function answerMoved(moved: boolean) { void run(async () => { await update({ moved }); if (!moved) return go('when'); setStep('redirect'); window.location.assign(`/${locale}/live${window.location.search}`); }); } function submitWhen() { if (!when || (when === 'date' && !date)) return setError({ field: 'when', text: t('landing.quiz.errWhen') }); void run(async () => { await update({ moveWhen: when, moveDate: when === 'date' ? date : undefined }); go('package'); }, 'when'); } function choosePackage(p: Pkg) { setPkg(p); void run(async () => { await update({ package: p, submit: p !== 'CUSTOM' }); go(p === 'CUSTOM' ? 'custom' : 'done'); }); } function submitCustom() { const any = opts.airport !== 'none' || opts.stay !== 'none' || YES_NO.some((k) => opts[k]); if (!any) return setError({ field: 'custom', text: t('landing.quiz.errEmpty') }); const num = (v: string) => (v.trim() ? Math.max(0, Math.round(Number(v.replace(/\D/g, '')))) : undefined); void run(async () => { await update({ options: { ...opts, stayBudgetFrom: opts.stay === 'none' ? undefined : num(opts.stayBudgetFrom), stayBudgetTo: opts.stay === 'none' ? undefined : num(opts.stayBudgetTo), }, submit: true, }); go('done'); }, 'custom'); } const index = ORDER.indexOf(step); const total = pkg && pkg !== 'CUSTOM' ? 4 : 5; const err = (field: string) => (error?.field === field ? error.text : null); const set = (k: K, v: Options[K]) => setOpts((o) => ({ ...o, [k]: v })); let body: ReactNode; if (step === 'contact') { body = (
{ e.preventDefault(); submitContact(); }} >

{t('landing.quiz.contactTitle')}

{error && !error.field &&

{error.text}

}

{t('landing.quiz.consentPre')} {t('landing.quiz.consentLink')}

); } else if (step === 'moved') { body = (

{t('landing.quiz.movedTitle')}

{error &&

{error.text}

}
); } else if (step === 'when') { body = (
{ e.preventDefault(); submitWhen(); }} >

{t('landing.quiz.whenTitle')}

{WHEN.map((w) => ( ))}
{when === 'date' && ( )} {error &&

{error.text}

}
); } else if (step === 'package') { body = (

{t('landing.quiz.packageTitle')}

{PACKAGES.map((p) => ( ))}
{error &&

{error.text}

}
); } else if (step === 'custom') { body = (
{ e.preventDefault(); submitCustom(); }} >

{t('landing.quiz.customTitle')}

t(`landing.quiz.airport_${v}`)} onChange={(v) => set('airport', v)} /> t(`landing.quiz.stay_${v}`)} onChange={(v) => set('stay', v)}> {opts.stay !== 'none' && (
{t('landing.quiz.budget')}
)}
{YES_NO.map((k) => ( t(`landing.quiz.${v}`)} onChange={(v) => set(k, v === 'yes')} hint={opts[k] ? t('landing.quiz.yesHint') : undefined} /> ))} {error &&

{error.text}

} ); } else if (step === 'done') { body = (

{t('landing.quiz.doneTitle', { number: lead?.number ?? '' })}

{t('landing.quiz.doneText', { contact: lead?.contact ?? '' })}

{t('landing.quiz.doneBot')}
); } else { body = (

{t('landing.quiz.movingToLive')}

); } return ( {children} { // клик по затемнению вокруг окна закрывает квиз; ответы остаются if (e.target === e.currentTarget) close(); }} >
{index > 0 ? ( ) : ( )} {index < 0 ? ( ) : (
    {ORDER.slice(0, total).map((s, i) => (
  1. ))}
)}
{body}
); } /** Строка конструктора: подпись и переключатель из 2–3 вариантов */ function Segmented({ label, value, options, text, onChange, hint, children, }: { label: string; value: V; options: readonly V[]; text: (v: V) => string; onChange: (v: V) => void; hint?: string; children?: ReactNode; }) { return (
{label}
{options.map((o) => ( ))}
{hint && {hint}} {children}
); }