Bariki cashback, order edit, and current work in progress
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
dda917fc71
commit
45477fb018
94 files changed
+10955
-650
No files matched your search
@@ -19,10 +19,11 @@ 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>>;
|
||||
type Errors = Partial<Record<'phone' | 'submit', string>>;
|
||||
|
||||
/**
|
||||
* Страница /clean (модель qlean.ru): параметры → доп. услуги → время → адрес → контакты.
|
||||
* Страница /clean (модель qlean.ru): параметры и телефон → доп. услуги → время → адрес → имя.
|
||||
* Обязательны только тип уборки, комнаты, санузлы и телефон; время и адрес бот доспросит, а оператор уточнит.
|
||||
* Цена считается в браузере той же функцией quote, что и на сервере. Заявка сразу видна в админке,
|
||||
* а человек уходит в бота по ссылке, которая привязывает заявку к его Telegram.
|
||||
*/
|
||||
@@ -81,7 +82,6 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
|
||||
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);
|
||||
@@ -96,11 +96,7 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -127,9 +123,10 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
|
||||
body: JSON.stringify({
|
||||
json: {
|
||||
params,
|
||||
preferredAt: time!.toISOString(),
|
||||
districtCode: district,
|
||||
addressText: street.trim(),
|
||||
preferredAt: time?.toISOString(),
|
||||
districtCode: district || undefined,
|
||||
// короткий адрес не отправляем — бот спросит его заново
|
||||
addressText: street.trim().length >= 3 ? street.trim() : undefined,
|
||||
note: note.trim() || undefined,
|
||||
phone,
|
||||
name: name.trim() || undefined,
|
||||
@@ -211,6 +208,30 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
|
||||
<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) : '…'} />
|
||||
@@ -254,7 +275,7 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
|
||||
</Block>
|
||||
)}
|
||||
|
||||
<Block title={t('landing.clean.whenTitle')} hint={t('landing.clean.whenHint')} error={errors.time}>
|
||||
<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
|
||||
@@ -284,11 +305,11 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
|
||||
)}
|
||||
</Block>
|
||||
|
||||
<Block title={t('landing.clean.addressTitle')} error={errors.district ?? errors.street}>
|
||||
<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)} aria-invalid={!!errors.district}>
|
||||
<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}>
|
||||
@@ -305,7 +326,6 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
|
||||
maxLength={300}
|
||||
placeholder={t('landing.clean.streetPlaceholder')}
|
||||
autoComplete="street-address"
|
||||
aria-invalid={!!errors.street}
|
||||
onChange={(e) => setStreet(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
@@ -316,41 +336,14 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
|
||||
</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>
|
||||
<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')}>
|
||||
@@ -386,6 +379,7 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
|
||||
</div>
|
||||
)}
|
||||
<p className="cl-receipt__after">{t('landing.clean.after')}</p>
|
||||
<ConsentNote locale={locale} t={t} />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -403,6 +397,18 @@ export function CleanForm({ locale, config, messages }: { locale: Locale; config
|
||||
);
|
||||
}
|
||||
|
||||
/** Согласие на обработку данных — нажатием кнопки, без отдельной галочки */
|
||||
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}>
|
||||
|
||||
Reference in new issue
Block a user