Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
453 lines
20 KiB
TypeScript
453 lines
20 KiB
TypeScript
'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<'phone' | '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 [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 (!/^\+?\d{8,15}$/.test(phone.replace(/[\s()\-.]/g, ''))) e.phone = t('landing.clean.err.phone');
|
||
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 || undefined,
|
||
// короткий адрес не отправляем — бот спросит его заново
|
||
addressText: street.trim().length >= 3 ? street.trim() : undefined,
|
||
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>
|
||
<label className="bc-field cl-hero-phone" data-error={!!errors.phone}>
|
||
<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)}
|
||
/>
|
||
{errors.phone ? (
|
||
<span className="bc-error" role="alert">
|
||
{errors.phone}
|
||
</span>
|
||
) : (
|
||
<span id="cl-phone-hint" className="cl-muted cl-small">
|
||
{t('landing.clean.phoneHint')}
|
||
</span>
|
||
)}
|
||
</label>
|
||
</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.whenHintOptional')}>
|
||
<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')} hint={t('landing.clean.addressHintOptional')}>
|
||
<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)}>
|
||
<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"
|
||
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.nameTitle')}>
|
||
<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>
|
||
</Block>
|
||
{/* на телефоне кнопка в нижней панели — текст о согласии под формой */}
|
||
<ConsentNote locale={locale} t={t} className="cl-consent-note cl-consent-note--mobile" />
|
||
</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>
|
||
<ConsentNote locale={locale} t={t} />
|
||
</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 ConsentNote({ locale, t, className = 'cl-consent-note' }: { locale: Locale; t: (k: string) => string; className?: string }) {
|
||
return (
|
||
<p className={className}>
|
||
{t('landing.clean.consentByClick')}{' '}
|
||
<a href={`/${locale}/privacy`} target="_blank" rel="noopener">
|
||
{t('landing.clean.privacy')}
|
||
</a>
|
||
</p>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|