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:
zabudko-ruandClaude Opus 5.5 committed 2026-10-25 22:57:03 +04:00
1 parent dda917fc71
commit 45477fb018
94 files changed
+10955 -650

No files matched your search

+53 -47
View File
@@ -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}>
+2 -1
View File
@@ -4,6 +4,7 @@ import type { Metadata } from 'next';
import { getCatalog, type LandingOffer } from '../lib/data';
import { BotLink } from './client';
import { SERVICE_PAGES } from '../lib/services';
import { LegalLinks } from './legal';
import { FooterContacts, LegalLine } from './seo';
/** Главная (/) и «Уже живу» (/live): индексируются, у каждой свой canonical и hreflang */
@@ -102,7 +103,7 @@ export function HomeFooter({ locale }: { locale: Locale }) {
<span className="h-footer__links">
<FooterContacts />
<BotLink>{t('landing.footer.partner')}</BotLink>
<a href={`/${locale}/privacy`}>{t('landing.footer.privacy')}</a>
<LegalLinks locale={locale} />
</span>
</footer>
);
+107
View File
@@ -0,0 +1,107 @@
import { createT, isLocale, LOCALES, type Locale } from '@barev/i18n';
import { Logo } from '@barev/ui';
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
import { CONTACTS, languageAlternates } from '../lib/seo';
import { RichText } from './rich-text';
import { FooterContacts, LegalLine } from './seo';
/** Страницы с ценами и правилами: их требуют платёжные провайдеры при проверке сайта */
export const LEGAL_PAGES = ['pricing', 'terms', 'refund', 'privacy'] as const;
export type LegalPageId = (typeof LEGAL_PAGES)[number];
/** Ссылки на цены и правила — в подвал любой страницы */
export function LegalLinks({ locale }: { locale: Locale }) {
const t = createT(locale);
return (
<>
{LEGAL_PAGES.map((p) => (
<a key={p} href={`/${locale}/${p}`}>
{t(`landing.footer.${p}`)}
</a>
))}
</>
);
}
/** Мета страницы: заголовок и описание из <ns>.title / <ns>.intro */
export async function legalMetadata(params: Promise<{ locale: string }>, ns: LegalPageId): Promise<Metadata> {
const { locale } = await params;
if (!isLocale(locale)) return {};
const t = createT(locale);
const title = `${t(`${ns}.title`)} — Barev Club`;
const description = t(`${ns}.intro`);
return {
title,
description,
alternates: { canonical: `/${locale}/${ns}`, languages: languageAlternates(`/${ns}`) },
// своё превью ссылки, иначе подставляется слоган из общего layout
openGraph: { title, description, locale, type: 'article', siteName: 'Barev Club' },
twitter: { card: 'summary_large_image', title, description },
};
}
/** Каркас: логотип, языки, заголовок, подвал с реквизитами и ссылками на остальные правила */
export function LegalShell({ locale, page, children }: { locale: Locale; page: LegalPageId; children: ReactNode }) {
const t = createT(locale);
return (
<main className="prose policy">
<div className="policy__top">
<a href={`/${locale}`} style={{ textDecoration: 'none' }}>
<Logo />
</a>
<nav className="policy__langs" aria-label="Language">
{LOCALES.map((l) => (
<a key={l} href={`/${l}/${page}`} hrefLang={l} aria-current={l === locale ? 'page' : undefined}>
{l.toUpperCase()}
</a>
))}
</nav>
</div>
<h1 className="bc-h2" style={{ marginTop: 24 }}>
{t(`${page}.title`)}
</h1>
<p className="policy__updated">{t(`${page}.updated`)}</p>
<p>{t(`${page}.intro`)}</p>
{children}
<footer className="policy__footer">
<LegalLine locale={locale} />
<span className="footer__links">
<FooterContacts />
</span>
<nav className="policy__legal" aria-label={t('landing.footer.rules')}>
{LEGAL_PAGES.filter((p) => p !== page).map((p) => (
<a key={p} href={`/${locale}/${p}`}>
{t(`landing.footer.${p}`)}
</a>
))}
</nav>
</footer>
</main>
);
}
/** Текст правил: оглавление и нумерованные разделы <ns>.<id>.title / <ns>.<id>.body */
export function LegalDocument({ locale, ns, sections }: { locale: Locale; ns: Exclude<LegalPageId, 'pricing'>; sections: readonly string[] }) {
const t = createT(locale);
const vars = { email: CONTACTS.email, phone: CONTACTS.phoneDisplay, tin: CONTACTS.tin };
return (
<LegalShell locale={locale} page={ns}>
<ol className="policy__toc">
{sections.map((s) => (
<li key={s}>
<a href={`#${s}`}>{t(`${ns}.${s}.title`)}</a>
</li>
))}
</ol>
{sections.map((s, i) => (
<section key={s} id={s} className="policy__section">
<h2>
{i + 1}. {t(`${ns}.${s}.title`)}
</h2>
<RichText text={t(`${ns}.${s}.body`, vars)} />
</section>
))}
</LegalShell>
);
}