Apps, web, i18n, UI, partners, payouts, backoffice, bariki, prod infra

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-25 22:55:29 +04:00
1 parent 45d7512d2b
commit 3489e0b816
319 files changed
+45483 -121

No files matched your search

+459
View File
@@ -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>
);
}