'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>; /** * Страница /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>({}); 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(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(null); const [time, setTime] = useState(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({}); 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 (

{t('landing.clean.doneTitle', { number: done.number })}

{t('landing.clean.doneText')}

{t('landing.clean.doneCta')}
); } return ( <>

{t('landing.clean.title')}

{t('landing.clean.sub')}

{TYPES.map((v) => ( ))}
{t('params.cleaning.rooms.label')}
{t('params.cleaning.bathrooms.label')}
{t('landing.clean.duration', { hours })}
{config.categoryDiscountPct > 0 &&
{t('landing.clean.plusHint', { pct: config.categoryDiscountPct })}
}
{pricing && extrasItems.length > 0 && (
{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 ? ( ) : (
0}> {label} {price} setExtra(it.code, v)} decLabel={`${t('common.decrease')}: ${label}`} incLabel={`${t('common.increase')}: ${label}`} />
); })}
)}
{days.map((d) => ( ))}
{day && day.starts.length > 0 ? (
{day.starts.map((s) => ( ))}
) : ( day &&

{t('landing.clean.noTimes')}

)}