Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
460 lines
18 KiB
TypeScript
460 lines
18 KiB
TypeScript
'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<T>(path: string, json: unknown): Promise<T> {
|
|
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 (
|
|
<button type="button" className={className} onClick={open} aria-haspopup="dialog">
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Квиз «Подбор переезда» в модальном окне. Имя и контакт сохраняются сразу после первого шага,
|
|
* дальше каждый ответ дописывается в ту же заявку — оператор видит её в админке, даже если человек не дошёл до конца.
|
|
* «Уже живу в Армении» — переход на /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<HTMLDialogElement>(null);
|
|
const heading = useRef<HTMLHeadingElement>(null);
|
|
const [step, setStep] = useState<Step>('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<When | null>(null);
|
|
const [date, setDate] = useState('');
|
|
const [pkg, setPkg] = useState<Pkg | null>(null);
|
|
const [opts, setOpts] = useState<Options>(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<Record<Step, Step>> = { moved: 'contact', when: 'moved', package: 'when', custom: 'package' };
|
|
if (prev[step]) go(prev[step]!);
|
|
}
|
|
|
|
async function run(fn: () => Promise<void>, 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<string, unknown>) => 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 extends keyof Options>(k: K, v: Options[K]) => setOpts((o) => ({ ...o, [k]: v }));
|
|
|
|
let body: ReactNode;
|
|
if (step === 'contact') {
|
|
body = (
|
|
<form
|
|
className="q-form"
|
|
noValidate
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
submitContact();
|
|
}}
|
|
>
|
|
<h2 ref={heading} tabIndex={-1} className="q-title">
|
|
{t('landing.quiz.contactTitle')}
|
|
</h2>
|
|
<label className="q-field" data-error={!!err('name')}>
|
|
<span>{t('landing.quiz.name')}</span>
|
|
<input className="q-input" value={name} onChange={(e) => setName(e.target.value)} autoComplete="given-name" maxLength={80} aria-invalid={!!err('name')} />
|
|
{err('name') && <small role="alert">{err('name')}</small>}
|
|
</label>
|
|
<label className="q-field" data-error={!!err('contact')}>
|
|
<span>{t('landing.quiz.contact')}</span>
|
|
<input
|
|
className="q-input"
|
|
value={contact}
|
|
onChange={(e) => setContact(e.target.value)}
|
|
placeholder={t('landing.quiz.contactHint')}
|
|
autoComplete="tel"
|
|
inputMode="text"
|
|
maxLength={60}
|
|
aria-invalid={!!err('contact')}
|
|
/>
|
|
{err('contact') && <small role="alert">{err('contact')}</small>}
|
|
</label>
|
|
{error && !error.field && <p className="q-error" role="alert">{error.text}</p>}
|
|
<button type="submit" className="bc-btn q-primary" disabled={busy}>
|
|
{busy ? t('landing.quiz.sending') : t('landing.quiz.next')}
|
|
</button>
|
|
<p className="q-consent">
|
|
{t('landing.quiz.consentPre')}
|
|
<a href={`/${locale}/privacy`} target="_blank" rel="noopener">
|
|
{t('landing.quiz.consentLink')}
|
|
</a>
|
|
</p>
|
|
</form>
|
|
);
|
|
} else if (step === 'moved') {
|
|
body = (
|
|
<div className="q-form">
|
|
<h2 ref={heading} tabIndex={-1} className="q-title">
|
|
{t('landing.quiz.movedTitle')}
|
|
</h2>
|
|
<div className="q-choices">
|
|
<button type="button" className="q-choice" disabled={busy} onClick={() => answerMoved(false)}>
|
|
<b>{t('landing.quiz.movedNo')}</b>
|
|
</button>
|
|
<button type="button" className="q-choice" disabled={busy} onClick={() => answerMoved(true)}>
|
|
<b>{t('landing.quiz.movedYes')}</b>
|
|
</button>
|
|
</div>
|
|
{error && <p className="q-error" role="alert">{error.text}</p>}
|
|
</div>
|
|
);
|
|
} else if (step === 'when') {
|
|
body = (
|
|
<form
|
|
className="q-form"
|
|
noValidate
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
submitWhen();
|
|
}}
|
|
>
|
|
<h2 ref={heading} tabIndex={-1} className="q-title" id="q-when">
|
|
{t('landing.quiz.whenTitle')}
|
|
</h2>
|
|
<div className="q-choices" role="radiogroup" aria-labelledby="q-when">
|
|
{WHEN.map((w) => (
|
|
<button key={w} type="button" role="radio" aria-checked={when === w} className="q-choice" onClick={() => setWhen(w)}>
|
|
<b>{t(`landing.quiz.when_${w}`)}</b>
|
|
</button>
|
|
))}
|
|
</div>
|
|
{when === 'date' && (
|
|
<label className="q-field">
|
|
<span>{t('landing.quiz.date')}</span>
|
|
<input className="q-input" type="date" min={today} value={date} onChange={(e) => setDate(e.target.value)} />
|
|
</label>
|
|
)}
|
|
{error && <p className="q-error" role="alert">{error.text}</p>}
|
|
<button type="submit" className="bc-btn q-primary" disabled={busy}>
|
|
{busy ? t('landing.quiz.sending') : t('landing.quiz.next')}
|
|
</button>
|
|
</form>
|
|
);
|
|
} else if (step === 'package') {
|
|
body = (
|
|
<div className="q-form">
|
|
<h2 ref={heading} tabIndex={-1} className="q-title">
|
|
{t('landing.quiz.packageTitle')}
|
|
</h2>
|
|
<div className="q-choices">
|
|
{PACKAGES.map((p) => (
|
|
<button key={p} type="button" className="q-choice q-choice--tall" disabled={busy} onClick={() => choosePackage(p)}>
|
|
<span className="q-choice__head">
|
|
<b>{t(`landing.quiz.package_${p}`)}</b>
|
|
{p === 'CONSULT' && <span className="q-price">{consultPrice}</span>}
|
|
{p === 'TURNKEY' && <span className="q-price">{t('landing.quiz.package_TURNKEY_price')}</span>}
|
|
</span>
|
|
<span>{t(`landing.quiz.package_${p}_text`)}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
{error && <p className="q-error" role="alert">{error.text}</p>}
|
|
</div>
|
|
);
|
|
} else if (step === 'custom') {
|
|
body = (
|
|
<form
|
|
className="q-form"
|
|
noValidate
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
submitCustom();
|
|
}}
|
|
>
|
|
<h2 ref={heading} tabIndex={-1} className="q-title">
|
|
{t('landing.quiz.customTitle')}
|
|
</h2>
|
|
<Segmented label={t('landing.quiz.airport')} value={opts.airport} options={['none', 'standard', 'premium'] as const} text={(v) => t(`landing.quiz.airport_${v}`)} onChange={(v) => set('airport', v)} />
|
|
<Segmented label={t('landing.quiz.stay')} value={opts.stay} options={['none', 'flat', 'hotel'] as const} text={(v) => t(`landing.quiz.stay_${v}`)} onChange={(v) => set('stay', v)}>
|
|
{opts.stay !== 'none' && (
|
|
<fieldset className="q-budget">
|
|
<legend>{t('landing.quiz.budget')}</legend>
|
|
<label>
|
|
<span>{t('landing.quiz.budgetFrom')}</span>
|
|
<input className="q-input" inputMode="numeric" value={opts.stayBudgetFrom} onChange={(e) => set('stayBudgetFrom', e.target.value.replace(/\D/g, '').slice(0, 7))} placeholder="20 000" />
|
|
</label>
|
|
<label>
|
|
<span>{t('landing.quiz.budgetTo')}</span>
|
|
<input className="q-input" inputMode="numeric" value={opts.stayBudgetTo} onChange={(e) => set('stayBudgetTo', e.target.value.replace(/\D/g, '').slice(0, 7))} placeholder="40 000" />
|
|
</label>
|
|
</fieldset>
|
|
)}
|
|
</Segmented>
|
|
{YES_NO.map((k) => (
|
|
<Segmented
|
|
key={k}
|
|
label={t(`landing.quiz.${k}`)}
|
|
value={opts[k] ? 'yes' : 'no'}
|
|
options={['no', 'yes'] as const}
|
|
text={(v) => t(`landing.quiz.${v}`)}
|
|
onChange={(v) => set(k, v === 'yes')}
|
|
hint={opts[k] ? t('landing.quiz.yesHint') : undefined}
|
|
/>
|
|
))}
|
|
{error && <p className="q-error" role="alert">{error.text}</p>}
|
|
<button type="submit" className="bc-btn q-primary" disabled={busy}>
|
|
{busy ? t('landing.quiz.sending') : t('landing.quiz.submit')}
|
|
</button>
|
|
</form>
|
|
);
|
|
} else if (step === 'done') {
|
|
body = (
|
|
<div className="q-form q-done">
|
|
<span className="q-done__mark" aria-hidden="true">
|
|
<Icon name="check" size={30} strokeWidth={2.6} />
|
|
</span>
|
|
<h2 ref={heading} tabIndex={-1} className="q-title">
|
|
{t('landing.quiz.doneTitle', { number: lead?.number ?? '' })}
|
|
</h2>
|
|
<p className="q-lead">{t('landing.quiz.doneText', { contact: lead?.contact ?? '' })}</p>
|
|
<BotLink className="bc-btn q-primary">{t('landing.quiz.doneBot')}</BotLink>
|
|
<button type="button" className="q-link" onClick={close}>
|
|
{t('landing.quiz.doneClose')}
|
|
</button>
|
|
</div>
|
|
);
|
|
} else {
|
|
body = (
|
|
<div className="q-form">
|
|
<h2 ref={heading} tabIndex={-1} className="q-title" aria-live="polite">
|
|
{t('landing.quiz.movingToLive')}
|
|
</h2>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<QuizCtx.Provider value={open}>
|
|
{children}
|
|
<dialog
|
|
ref={dialog}
|
|
className="q"
|
|
aria-label={t('landing.quiz.dialog')}
|
|
onClick={(e) => {
|
|
// клик по затемнению вокруг окна закрывает квиз; ответы остаются
|
|
if (e.target === e.currentTarget) close();
|
|
}}
|
|
>
|
|
<div className="q-panel">
|
|
<div className="q-top">
|
|
{index > 0 ? (
|
|
<button type="button" className="q-icon" onClick={back} aria-label={t('landing.quiz.back')}>
|
|
<Icon name="back" size={20} />
|
|
</button>
|
|
) : (
|
|
<span />
|
|
)}
|
|
{index < 0 ? (
|
|
<span />
|
|
) : (
|
|
<ol className="q-dots" aria-label={t('landing.quiz.progress', { n: index + 1, total })}>
|
|
{ORDER.slice(0, total).map((s, i) => (
|
|
<li key={s} data-state={i < index ? 'past' : i === index ? 'now' : 'next'} />
|
|
))}
|
|
</ol>
|
|
)}
|
|
<button type="button" className="q-icon" onClick={close} aria-label={t('landing.quiz.close')}>
|
|
<Icon name="close" size={20} />
|
|
</button>
|
|
</div>
|
|
<div key={step} className="q-step" data-dir={dir}>
|
|
{body}
|
|
</div>
|
|
</div>
|
|
</dialog>
|
|
</QuizCtx.Provider>
|
|
);
|
|
}
|
|
|
|
/** Строка конструктора: подпись и переключатель из 2–3 вариантов */
|
|
function Segmented<V extends string>({
|
|
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 (
|
|
<div className="q-row">
|
|
<div className="q-row__head">
|
|
<span className="q-row__label">{label}</span>
|
|
<div className="q-seg" role="radiogroup" aria-label={label}>
|
|
{options.map((o) => (
|
|
<button key={o} type="button" role="radio" aria-checked={value === o} onClick={() => onChange(o)}>
|
|
{text(o)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{hint && <small className="q-row__hint">{hint}</small>}
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|