Snapshot of uncommitted work: landing, bots, prod infra, partner cabinet, backoffice stage 1
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
45d7512d2b
commit
d452824c62
314 files changed
+44394
-121
No files matched your search
@@ -0,0 +1,459 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user