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:
1 parent
45d7512d2b
commit
3489e0b816
319 files changed
+45483
-121
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user