Files
BarevClub/apps/landing/components/clean.tsx
T
2026-10-25 22:57:03 +04:00

453 lines
20 KiB
TypeScript
Raw 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<'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>
);
}