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

+446
View File
@@ -0,0 +1,446 @@
'use client';
import {
normalizeAgencyCode,
pickUtm,
quote,
windowStarts,
yerevanDateKey,
type Locale,
type ParamValues,
type Quote,
} from '@barev/core';
import { formatAmd, formatDateTime, tFromMessages, type Messages } from '@barev/i18n';
import { Icon, Stepper } from '@barev/ui';
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import type { CleaningConfig } from '../lib/data';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000';
const AGENCY_KEY = 'barev.agency';
const TYPES = ['regular', 'general', 'renovation'] as const;
type Errors = Partial<Record<'time' | 'district' | 'street' | 'phone' | 'consent' | 'submit', string>>;
/**
* Страница /clean (модель qlean.ru): параметры → доп. услуги → время → адрес → контакты.
* Цена считается в браузере той же функцией quote, что и на сервере. Заявка сразу видна в админке,
* а человек уходит в бота по ссылке, которая привязывает заявку к его Telegram.
*/
export function CleanForm({ locale, config, messages }: { locale: Locale; config: CleaningConfig; messages: Messages }) {
const t = useMemo(() => tFromMessages(locale, messages), [locale, messages]);
const amd = (n: number) => formatAmd(locale, n);
const fields = config.paramsSchema.fields;
const intField = (key: string) => {
const f = fields.find((x) => x.key === key);
return f?.type === 'int' ? f : { min: 1, max: 6, default: 1 };
};
const roomsF = intField('rooms');
const bathsF = intField('bathrooms');
const extrasField = fields.find((f) => f.type === 'extras');
const extrasItems = extrasField?.type === 'extras' ? extrasField.items : [];
const pricing = config.pricing.kind === 'rooms' ? config.pricing : null;
const [type, setType] = useState<(typeof TYPES)[number]>('regular');
const [rooms, setRooms] = useState(Number(roomsF.default ?? 2));
const [baths, setBaths] = useState(Number(bathsF.default ?? 1));
const [extras, setExtras] = useState<Record<string, number>>({});
const params: ParamValues = { cleaningType: type, rooms, bathrooms: baths, extras };
let q: Quote | null = null;
try {
q = quote(config.pricing, params);
} catch {
q = null;
}
// «сейчас» — только в браузере, иначе список времени разойдётся с серверным рендером
const [now, setNow] = useState<Date | null>(null);
useEffect(() => setNow(new Date()), []);
const durationForWindow = Math.min(q?.durationMinutes ?? 120, config.slotConfig.dayEndMinute - config.slotConfig.dayStartMinute);
const days = useMemo(() => {
if (!now) return [];
return Array.from({ length: config.slotConfig.horizonDays + 1 }, (_, i) => {
const date = new Date(now.getTime() + i * 86_400_000);
const dateKey = yerevanDateKey(date);
return { dateKey, date, starts: windowStarts({ dateKey, config: config.slotConfig, durationMinutes: durationForWindow, now }) };
});
}, [now, durationForWindow, config.slotConfig]);
const [dayKey, setDayKey] = useState<string | null>(null);
const [time, setTime] = useState<Date | null>(null);
useEffect(() => {
if (!dayKey && days.length) setDayKey(days.find((d) => d.starts.length)?.dateKey ?? days[0]!.dateKey);
}, [days, dayKey]);
const day = days.find((d) => d.dateKey === dayKey);
// длительность меняется вместе с параметрами — выбранное время могло перестать помещаться в день
useEffect(() => {
if (time && day && !day.starts.some((s) => s.getTime() === time.getTime())) setTime(null);
}, [day, time]);
const [district, setDistrict] = useState('');
const [street, setStreet] = useState('');
const [note, setNote] = useState('');
const [phone, setPhone] = useState('');
const [name, setName] = useState('');
const [consent, setConsent] = useState(false);
const [errors, setErrors] = useState<Errors>({});
const [sending, setSending] = useState(false);
const [done, setDone] = useState<{ number: number; url: string } | null>(null);
const setExtra = (code: string, qty: number) =>
setExtras((prev) => {
const next = { ...prev };
if (qty > 0) next[code] = qty;
else delete next[code];
return next;
});
function validate(): Errors {
const e: Errors = {};
if (!time) e.time = t('landing.clean.err.time');
if (!district) e.district = t('landing.clean.err.district');
if (street.trim().length < 3) e.street = t('landing.clean.err.street');
if (!/^\+?\d{8,15}$/.test(phone.replace(/[\s()\-.]/g, ''))) e.phone = t('landing.clean.err.phone');
if (!consent) e.consent = t('landing.clean.err.consent');
return e;
}
async function submit() {
const e = validate();
setErrors(e);
if (Object.keys(e).length) {
document.querySelector('[data-error="true"]')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
setSending(true);
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);
try {
const res = await fetch(`${API}/trpc/public.cleaningRequest`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
json: {
params,
preferredAt: time!.toISOString(),
districtCode: district,
addressText: street.trim(),
note: note.trim() || undefined,
phone,
name: name.trim() || undefined,
locale,
agency: agency ?? undefined,
utm: Object.keys(utm).length ? utm : undefined,
consent: true,
},
}),
});
const body = (await res.json().catch(() => null)) as {
result?: { data?: { json?: { number: number; url: string } } };
error?: { json?: { message?: string; data?: { domainCode?: string | null } } };
} | null;
const ok = body?.result?.data?.json;
if (!ok) {
const code = body?.error?.json?.data?.domainCode ?? body?.error?.json?.message;
const known = code && messages[`errors.${code}`];
setErrors({ submit: known ? t(`errors.${code}`) : t('errors.generic') });
return;
}
setDone(ok);
window.scrollTo({ top: 0, behavior: 'smooth' });
// в бота — в той же вкладке: на телефоне откроется приложение Telegram
setTimeout(() => window.location.assign(ok.url), 900);
} catch {
setErrors({ submit: t('landing.clean.err.network') });
} finally {
setSending(false);
}
}
const hours = q ? (q.durationMinutes / 60).toLocaleString(locale, { maximumFractionDigits: 1 }) : '…';
const summaryLine = `${t('params.cleaning.rooms.count', { count: rooms })}, ${t('params.cleaning.bathrooms.count', { count: baths })}`;
const when = time ? formatDateTime(locale, time, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' }) : null;
if (done) {
return (
<section className="cl-done" aria-live="polite">
<div className="cl-done__mark">
<Icon name="check" size={34} strokeWidth={2.6} />
</div>
<h1 className="cl-done__title">{t('landing.clean.doneTitle', { number: done.number })}</h1>
<p className="cl-done__text">{t('landing.clean.doneText')}</p>
<div className="cl-done__actions">
<a className="bc-btn cl-btn-pink" href={done.url}>
{t('landing.clean.doneCta')}
</a>
<button type="button" className="bc-btn bc-btn--ghost cl-done__again" onClick={() => window.location.reload()}>
{t('landing.clean.doneAgain')}
</button>
</div>
</section>
);
}
return (
<>
<section className="cl-hero">
<div className="cl-hero__main">
<h1 className="cl-title">{t('landing.clean.title')}</h1>
<p className="cl-sub">{t('landing.clean.sub')}</p>
<div className="cl-types" role="radiogroup" aria-label={t('params.cleaning.cleaningType.label')}>
{TYPES.map((v) => (
<button key={v} type="button" role="radio" aria-checked={type === v} className="cl-type" onClick={() => setType(v)}>
<span className="cl-type__name">{t(`params.cleaning.cleaningType.${v}`)}</span>
<span className="cl-type__hint">{t(`params.cleaning.cleaningType.${v}_hint`)}</span>
{pricing?.tiers[v] && <span className="cl-type__from">{t('common.from', { price: amd(pricing.tiers[v].base) })}</span>}
</button>
))}
</div>
<div className="cl-counts">
<div className="cl-count">
<span>{t('params.cleaning.rooms.label')}</span>
<Stepper value={rooms} min={roomsF.min ?? 1} max={roomsF.max ?? 6} onChange={setRooms} decLabel={t('common.decrease')} incLabel={t('common.increase')} />
</div>
<div className="cl-count">
<span>{t('params.cleaning.bathrooms.label')}</span>
<Stepper value={baths} min={bathsF.min ?? 1} max={bathsF.max ?? 4} onChange={setBaths} decLabel={t('common.decrease')} incLabel={t('common.increase')} />
</div>
</div>
</div>
<div className="cl-hero__price" aria-live="polite">
<PriceTicker value={q ? amd(q.totalPrice) : '…'} />
<div className="cl-duration">
<Icon name="clock" size={18} />
{t('landing.clean.duration', { hours })}
</div>
{config.categoryDiscountPct > 0 && <div className="cl-plus">{t('landing.clean.plusHint', { pct: config.categoryDiscountPct })}</div>}
</div>
</section>
<div className="cl-body">
<div className="cl-steps">
{pricing && extrasItems.length > 0 && (
<Block title={t('landing.clean.extrasTitle')} hint={t('landing.clean.extrasHint')}>
<div className="cl-extras">
{extrasItems.map((it) => {
const p = pricing.extras[it.code];
if (!p) return null;
const qty = extras[it.code] ?? 0;
const single = p.unit === 'service' || it.max === 1;
const label = t(`extras.${it.code}.name`);
const price = p.unit === 'service' ? amd(p.price) : `${amd(p.price)} ${t(`extras.${it.code}.per`)}`;
return single ? (
<button key={it.code} type="button" className="cl-extra" aria-pressed={qty > 0} onClick={() => setExtra(it.code, qty > 0 ? 0 : 1)}>
<span className="cl-extra__name">{label}</span>
<span className="cl-extra__price">{price}</span>
<span className="cl-extra__tick" aria-hidden="true">
<Icon name={qty > 0 ? 'check' : 'plus'} size={16} strokeWidth={2.6} />
</span>
</button>
) : (
<div key={it.code} className="cl-extra" data-on={qty > 0}>
<span className="cl-extra__name">{label}</span>
<span className="cl-extra__price">{price}</span>
<Stepper value={qty} min={0} max={it.max} onChange={(v) => setExtra(it.code, v)} decLabel={`${t('common.decrease')}: ${label}`} incLabel={`${t('common.increase')}: ${label}`} />
</div>
);
})}
</div>
</Block>
)}
<Block title={t('landing.clean.whenTitle')} hint={t('landing.clean.whenHint')} error={errors.time}>
<div className="cl-days" role="listbox" aria-label={t('landing.clean.whenTitle')}>
{days.map((d) => (
<button
key={d.dateKey}
type="button"
role="option"
aria-selected={d.dateKey === dayKey}
className="cl-day"
disabled={!d.starts.length}
onClick={() => setDayKey(d.dateKey)}
>
<span className="cl-day__wd">{formatDateTime(locale, d.date, { weekday: 'short' })}</span>
<span className="cl-day__num">{formatDateTime(locale, d.date, { day: 'numeric' })}</span>
</button>
))}
</div>
{day && day.starts.length > 0 ? (
<div className="cl-times">
{day.starts.map((s) => (
<button key={s.toISOString()} type="button" className="cl-time" aria-pressed={time?.getTime() === s.getTime()} onClick={() => setTime(s)}>
{formatDateTime(locale, s, { hour: '2-digit', minute: '2-digit' })}
</button>
))}
</div>
) : (
day && <p className="cl-muted">{t('landing.clean.noTimes')}</p>
)}
</Block>
<Block title={t('landing.clean.addressTitle')} error={errors.district ?? errors.street}>
<div className="cl-grid2">
<label className="bc-field">
<span className="bc-label">{t('landing.clean.district')}</span>
<select className="bc-input" value={district} onChange={(e) => setDistrict(e.target.value)} aria-invalid={!!errors.district}>
<option value="">{t('landing.clean.districtPlaceholder')}</option>
{config.districts.map((d) => (
<option key={d} value={d}>
{t(`districts.${d}`)}
</option>
))}
</select>
</label>
<label className="bc-field">
<span className="bc-label">{t('landing.clean.street')}</span>
<input
className="bc-input"
value={street}
maxLength={300}
placeholder={t('landing.clean.streetPlaceholder')}
autoComplete="street-address"
aria-invalid={!!errors.street}
onChange={(e) => setStreet(e.target.value)}
/>
</label>
</div>
<label className="bc-field">
<span className="bc-label">{t('landing.clean.note')}</span>
<textarea className="bc-input" rows={2} value={note} maxLength={1000} placeholder={t('landing.clean.notePlaceholder')} onChange={(e) => setNote(e.target.value)} />
</label>
</Block>
<Block title={t('landing.clean.contactTitle')} error={errors.phone ?? errors.consent}>
<div className="cl-grid2">
<label className="bc-field">
<span className="bc-label">{t('landing.clean.phone')}</span>
<input
className="bc-input"
type="tel"
inputMode="tel"
autoComplete="tel"
value={phone}
maxLength={30}
placeholder="+374 91 123456"
aria-invalid={!!errors.phone}
aria-describedby="cl-phone-hint"
onChange={(e) => setPhone(e.target.value)}
/>
<span id="cl-phone-hint" className="cl-muted cl-small">
{t('landing.clean.phoneHint')}
</span>
</label>
<label className="bc-field">
<span className="bc-label">{t('landing.clean.name')}</span>
<input className="bc-input" value={name} maxLength={80} autoComplete="given-name" onChange={(e) => setName(e.target.value)} />
</label>
</div>
<label className="cl-consent">
<input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} aria-invalid={!!errors.consent} />
<span>
{t('landing.clean.consent')}.{' '}
<a href={`/${locale}/privacy`} target="_blank" rel="noopener">
{t('landing.clean.privacy')}
</a>
</span>
</label>
</Block>
</div>
<aside className="cl-receipt" aria-label={t('landing.clean.summaryTitle')}>
<div className="cl-receipt__title">{t('landing.clean.summaryTitle')}</div>
<div className="cl-line">
<span>
{t(`params.cleaning.cleaningType.${type}`)}
<small>{summaryLine}</small>
</span>
<span>{q ? amd(q.servicePrice) : '…'}</span>
</div>
{q?.extras.map((l) => (
<div key={l.code} className="cl-line cl-line--extra">
<span>
{t(`extras.${l.code}.name`)}
{l.qty > 1 ? ` × ${l.qty}` : ''}
</span>
<span>{amd(l.amount)}</span>
</div>
))}
<div className="cl-receipt__rule" />
<div className="cl-line cl-line--total">
<span>{t('landing.clean.total')}</span>
<span>{q ? amd(q.totalPrice) : '…'}</span>
</div>
{when && <div className="cl-receipt__when">{when}</div>}
<button type="button" className="bc-btn bc-btn--block cl-btn-pink" onClick={submit} disabled={sending}>
{sending ? t('landing.clean.sending') : t('landing.clean.submit')}
</button>
{errors.submit && (
<div className="cl-receipt__error" role="alert">
{errors.submit}
</div>
)}
<p className="cl-receipt__after">{t('landing.clean.after')}</p>
</aside>
</div>
{/* телефон: итог и кнопка всегда под рукой */}
<div className="cl-bar">
<div>
<div className="cl-bar__total">{q ? amd(q.totalPrice) : '…'}</div>
<div className="cl-bar__meta">{when ?? t('landing.clean.duration', { hours })}</div>
</div>
<button type="button" className="bc-btn cl-btn-pink" onClick={submit} disabled={sending}>
{sending ? t('landing.clean.sending') : t('landing.clean.submit')}
</button>
</div>
</>
);
}
function Block({ title, hint, error, children }: { title: string; hint?: string; error?: string; children: ReactNode }) {
return (
<section className="cl-block" data-error={!!error}>
<h2 className="cl-block__title">{title}</h2>
{hint && <p className="cl-muted cl-block__hint">{hint}</p>}
{children}
{error && (
<div className="bc-error" role="alert">
{error}
</div>
)}
</section>
);
}
/** Цена: при изменении старое значение уходит вверх, новое приходит снизу (выключено при reduced motion) */
function PriceTicker({ value }: { value: string }) {
const [shown, setShown] = useState(value);
const [prev, setPrev] = useState<string | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (value === shown) return;
setPrev(shown);
setShown(value);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setPrev(null), 260);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
return (
<div className="cl-price">
{prev && (
<span key={`out-${prev}`} className="cl-price__out" aria-hidden="true">
{prev}
</span>
)}
<span key={`in-${shown}`} className={prev ? 'cl-price__in' : undefined}>
{shown}
</span>
</div>
);
}