Files
2026-10-25 22:55:29 +04:00

447 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}