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
@@ -5,6 +5,7 @@ import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { CleanForm } from '../../../components/clean';
|
||||
import { FooterContacts, LegalLine } from '../../../components/seo';
|
||||
import { LegalLinks } from '../../../components/legal';
|
||||
import { BotLink, BotLinkProvider, LangMenu, LangSwitch } from '../../../components/client';
|
||||
import { getCleaning } from '../../../lib/data';
|
||||
|
||||
@@ -113,7 +114,7 @@ export default async function CleanPage({ params }: { params: Promise<{ locale:
|
||||
<div className="footer__links">
|
||||
<a href={`/${locale}`}>{t('landing.clean.home')}</a>
|
||||
<FooterContacts />
|
||||
<a href={`/${locale}/privacy`}>{t('landing.footer.privacy')}</a>
|
||||
<LegalLinks locale={locale} />
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { createT, formatAmd, isLocale, LOCALES, type Locale } from '@barev/i18n';
|
||||
import { Icon } from '@barev/ui';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { LegalShell, legalMetadata } from '../../../components/legal';
|
||||
import { getCatalog } from '../../../lib/data';
|
||||
|
||||
export const revalidate = 300;
|
||||
export const dynamicParams = false;
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
export function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
return legalMetadata(params, 'pricing');
|
||||
}
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
price: string;
|
||||
vars?: Record<string, string>;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Цены: подписки клуба и услуги, которые сейчас продаются. Все суммы — из настроек админки
|
||||
* (как на главной и страницах услуг), поэтому страница не расходится с ботом.
|
||||
*/
|
||||
export default async function Pricing({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale: raw } = await params;
|
||||
if (!isLocale(raw)) notFound();
|
||||
const locale: Locale = raw;
|
||||
const t = createT(locale);
|
||||
const amd = (n: number) => formatAmd(locale, n);
|
||||
const { offer, barevPlus, categories } = await getCatalog();
|
||||
const cleaning = categories.find((c) => c.code === 'HOME')?.services.find((s) => s.code === 'cleaning')?.fromPrice ?? 0;
|
||||
const from = (n: number) => t('pricing.from', { price: amd(n) });
|
||||
|
||||
const plans = [
|
||||
{ id: 'free', name: 'Barev', price: t('landing.club.free'), pct: 0 },
|
||||
{ id: 'plus', name: 'Barev+', price: t('landing.club.perMonth', { price: amd(barevPlus.monthlyPrice) }), pct: offer.plusMaxPct },
|
||||
{ id: 'elite', name: 'Barev Elite', price: t('landing.club.perMonth', { price: amd(offer.elitePrice) }), pct: offer.eliteMaxPct },
|
||||
] as const;
|
||||
|
||||
const groups: { id: 'HOME' | 'DOCS'; rows: Row[] }[] = [
|
||||
{
|
||||
id: 'HOME',
|
||||
rows: [
|
||||
{ id: 'cleaning', price: from(cleaning), href: '/clean' },
|
||||
{ id: 'transferStandard', price: amd(offer.transferStandard), href: '/airport' },
|
||||
{ id: 'transferPremium', price: amd(offer.transferPremium), href: '/airport' },
|
||||
{ id: 'hotel', price: t('pricing.perDay', { price: from(offer.hotelWithPlus) }), vars: { base: amd(offer.hotelBase) } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'DOCS',
|
||||
rows: [
|
||||
{ id: 'socialCard', price: amd(offer.socialCard), href: '/social-card' },
|
||||
{ id: 'consultation', price: amd(offer.consultation), href: '/residence-permit' },
|
||||
{ id: 'residence', price: from(offer.residenceEscortFrom), vars: { fee: amd(offer.residenceStateFee) }, href: '/residence-permit' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<LegalShell locale={locale} page="pricing">
|
||||
<section className="policy__section" id="plans">
|
||||
<h2>{t('pricing.plansTitle')}</h2>
|
||||
<p>{t('pricing.plansNote')}</p>
|
||||
<div className="pr-plans">
|
||||
{plans.map((p) => (
|
||||
<article key={p.id} className="pr-plan" data-plan={p.id}>
|
||||
<h3>{p.name}</h3>
|
||||
<span className="pr-plan__price">{p.price}</span>
|
||||
<ul>
|
||||
{[1, 2, 3].map((n) => (
|
||||
<li key={n}>
|
||||
<Icon name="check" size={16} strokeWidth={2.6} />
|
||||
{t(`landing.club.${p.id}F${n}`, { pct: p.pct })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<small>{t(`pricing.planNote.${p.id}`, { days: barevPlus.giftDays })}</small>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="policy__section" id="services">
|
||||
<h2>{t('pricing.servicesTitle')}</h2>
|
||||
<p>{t('pricing.servicesNote')}</p>
|
||||
{groups.map((g) => (
|
||||
<div key={g.id} className="pr-group">
|
||||
<h3>{t(`categories.${g.id}.name`)}</h3>
|
||||
<dl className="pr-list">
|
||||
{g.rows.map((r) => (
|
||||
<div key={r.id} className="pr-row">
|
||||
<dt>
|
||||
{r.href ? <a href={`/${locale}${r.href}`}>{t(`pricing.rows.${r.id}.name`)}</a> : t(`pricing.rows.${r.id}.name`)}
|
||||
<small>{t(`pricing.rows.${r.id}.note`, r.vars)}</small>
|
||||
</dt>
|
||||
<dd>{r.price}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="policy__section" id="payment">
|
||||
<h2>{t('pricing.payTitle')}</h2>
|
||||
<p>{t('pricing.payBody')}</p>
|
||||
<p>
|
||||
<a href={`/${locale}/terms`}>{t('landing.footer.terms')}</a> · <a href={`/${locale}/refund`}>{t('landing.footer.refund')}</a>
|
||||
</p>
|
||||
</section>
|
||||
</LegalShell>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +1,22 @@
|
||||
import { createT, isLocale, LOCALES, type Locale } from '@barev/i18n';
|
||||
import { Logo } from '@barev/ui';
|
||||
import type { Metadata } from 'next';
|
||||
import { isLocale, LOCALES } from '@barev/i18n';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { RichText } from '../../../components/rich-text';
|
||||
import { FooterContacts, LegalLine } from '../../../components/seo';
|
||||
import { CONTACTS, languageAlternates } from '../../../lib/seo';
|
||||
import { LegalDocument, legalMetadata } from '../../../components/legal';
|
||||
|
||||
export const dynamicParams = false;
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
/** Разделы политики по порядку; тексты — privacy.<id>.title / privacy.<id>.body */
|
||||
/** Разделы по порядку; тексты — privacy.<id>.title / privacy.<id>.body */
|
||||
const SECTIONS = ['operator', 'data', 'purposes', 'basis', 'recipients', 'location', 'retention', 'security', 'rights', 'marketing', 'cookies', 'age', 'changes'] as const;
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) return {};
|
||||
const t = createT(locale);
|
||||
return {
|
||||
title: `${t('privacy.title')} — Barev Club`,
|
||||
description: t('privacy.intro'),
|
||||
alternates: { canonical: `/${locale}/privacy`, languages: languageAlternates('/privacy') },
|
||||
// своё превью ссылки, иначе подставляется старый слоган из общего layout
|
||||
openGraph: { title: `${t('privacy.title')} — Barev Club`, description: t('privacy.intro'), locale, type: 'article', siteName: 'Barev Club' },
|
||||
twitter: { card: 'summary_large_image', title: `${t('privacy.title')} — Barev Club`, description: t('privacy.intro') },
|
||||
};
|
||||
export function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
return legalMetadata(params, 'privacy');
|
||||
}
|
||||
|
||||
/** ONB-3: политика конфиденциальности. Оператор — ИП владельца; основной текст на русском, переводы needs_review */
|
||||
export default async function Privacy({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale: raw } = await params;
|
||||
if (!isLocale(raw)) notFound();
|
||||
const locale: Locale = raw;
|
||||
const t = createT(locale);
|
||||
const vars = { email: CONTACTS.email, phone: CONTACTS.phoneDisplay, tin: CONTACTS.tin };
|
||||
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}/privacy`} hrefLang={l} aria-current={l === locale ? 'page' : undefined}>
|
||||
{l.toUpperCase()}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<h1 className="bc-h2" style={{ marginTop: 24 }}>
|
||||
{t('privacy.title')}
|
||||
</h1>
|
||||
<p className="policy__updated">{t('privacy.updated')}</p>
|
||||
<p>{t('privacy.intro')}</p>
|
||||
<ol className="policy__toc">
|
||||
{SECTIONS.map((s) => (
|
||||
<li key={s}>
|
||||
<a href={`#${s}`}>{t(`privacy.${s}.title`)}</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{SECTIONS.map((s, i) => (
|
||||
<section key={s} id={s} className="policy__section">
|
||||
<h2>
|
||||
{i + 1}. {t(`privacy.${s}.title`)}
|
||||
</h2>
|
||||
<RichText text={t(`privacy.${s}.body`, vars)} />
|
||||
</section>
|
||||
))}
|
||||
<footer className="policy__footer">
|
||||
<LegalLine locale={locale} />
|
||||
<span className="footer__links">
|
||||
<FooterContacts />
|
||||
</span>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
/** ONB-3: политика конфиденциальности. Оператор — ИП владельца. Основной текст на русском, переводы needs_review */
|
||||
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
return <LegalDocument locale={locale} ns="privacy" sections={SECTIONS} />;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { isLocale, LOCALES } from '@barev/i18n';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { LegalDocument, legalMetadata } from '../../../components/legal';
|
||||
|
||||
export const dynamicParams = false;
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
/** Разделы по порядку; тексты — refund.<id>.title / refund.<id>.body */
|
||||
const SECTIONS = ['cancelClient', 'cancelUs', 'quality', 'subscription', 'nonRefundable', 'howTo', 'timing'] as const;
|
||||
|
||||
export function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
return legalMetadata(params, 'refund');
|
||||
}
|
||||
|
||||
/** Правила отмены и возврата денег. Основной текст на русском, переводы needs_review */
|
||||
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
return <LegalDocument locale={locale} ns="refund" sections={SECTIONS} />;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { isLocale, LOCALES } from '@barev/i18n';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { LegalDocument, legalMetadata } from '../../../components/legal';
|
||||
|
||||
export const dynamicParams = false;
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
/** Разделы по порядку; тексты — terms.<id>.title / terms.<id>.body */
|
||||
const SECTIONS = ['about', 'acceptance', 'orders', 'prices', 'payment', 'cancel', 'subscription', 'perks', 'docs', 'client', 'quality', 'liability', 'content', 'changes', 'law'] as const;
|
||||
|
||||
export function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
return legalMetadata(params, 'terms');
|
||||
}
|
||||
|
||||
/** Условия использования сервиса (публичная оферта). Основной текст на русском, переводы needs_review */
|
||||
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
return <LegalDocument locale={locale} ns="terms" sections={SECTIONS} />;
|
||||
}
|
||||
@@ -455,6 +455,33 @@ button.cl-extra {
|
||||
color: #e9c9d5;
|
||||
}
|
||||
|
||||
/* телефон сразу под комнатами и санузлами — единственное обязательное поле, кроме параметров */
|
||||
.cl-hero-phone {
|
||||
max-width: 420px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.cl-hero-phone .bc-input {
|
||||
background: #fff;
|
||||
}
|
||||
.cl-hero-phone[data-error='true'] .bc-input {
|
||||
border-color: var(--bc-danger);
|
||||
}
|
||||
/* согласие на обработку данных — нажатием кнопки */
|
||||
.cl-consent-note {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: #e9c9d5;
|
||||
}
|
||||
.cl-consent-note a {
|
||||
color: inherit;
|
||||
}
|
||||
.cl-consent-note--mobile {
|
||||
display: none;
|
||||
color: var(--bc-ink-2, #6b5d57);
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
/* нижняя панель — только на телефоне */
|
||||
.cl-bar {
|
||||
display: none;
|
||||
@@ -659,6 +686,12 @@ button.cl-extra {
|
||||
.cl-receipt .cl-btn-pink {
|
||||
display: none;
|
||||
}
|
||||
.cl-receipt .cl-consent-note {
|
||||
display: none;
|
||||
}
|
||||
.cl-consent-note--mobile {
|
||||
display: block;
|
||||
}
|
||||
.cl-bar {
|
||||
position: fixed;
|
||||
left: 12px;
|
||||
|
||||
@@ -419,6 +419,129 @@ a:hover {
|
||||
.policy__footer a {
|
||||
color: inherit;
|
||||
}
|
||||
.policy__legal {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 20px;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
/* страница цен */
|
||||
.pr-plans {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.pr-plan {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 18px;
|
||||
border-radius: 20px;
|
||||
background: #fff;
|
||||
border: 1.5px solid rgba(74, 16, 40, 0.12);
|
||||
}
|
||||
.pr-plan[data-plan='plus'] {
|
||||
background: var(--bc-pink);
|
||||
border-color: var(--bc-pink);
|
||||
}
|
||||
.pr-plan[data-plan='elite'] {
|
||||
background: var(--bc-perks);
|
||||
border-color: var(--bc-perks);
|
||||
}
|
||||
.pr-plan h3 {
|
||||
margin: 0;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 800;
|
||||
font-style: italic;
|
||||
font-size: 26px;
|
||||
line-height: 1.1;
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.pr-plan__price {
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.pr-plan ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 4px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.pr-plan li {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.pr-plan li svg {
|
||||
flex: none;
|
||||
margin-top: 2px;
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.pr-plan small {
|
||||
margin-top: auto;
|
||||
padding-top: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: var(--bc-ink-2);
|
||||
}
|
||||
.pr-group h3 {
|
||||
margin: 24px 0 4px;
|
||||
font-family: var(--bc-font-head);
|
||||
font-size: 18px;
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.pr-list {
|
||||
margin: 0;
|
||||
border-radius: 20px;
|
||||
background: #fff;
|
||||
padding: 4px 18px;
|
||||
}
|
||||
.pr-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.pr-row + .pr-row {
|
||||
border-top: 1px solid rgba(74, 16, 40, 0.1);
|
||||
}
|
||||
.pr-row dt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.pr-row dt a {
|
||||
color: inherit;
|
||||
}
|
||||
.pr-row dt small {
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: var(--bc-ink-2);
|
||||
}
|
||||
.pr-row dd {
|
||||
margin: 0;
|
||||
flex: none;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.pr-plans {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.wrap {
|
||||
padding: 12px 16px 24px;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { LOCALES } from '@barev/i18n';
|
||||
export const SITE = (process.env.LANDING_URL ?? 'http://localhost:3000').replace(/\/$/, '');
|
||||
|
||||
/** Страницы, которые должны быть в поиске; экспериментальные /landN закрыты noindex и сюда не входят */
|
||||
export const INDEXED_PATHS = ['', '/live', '/clean', '/social-card', '/residence-permit', '/airport', '/privacy'] as const;
|
||||
export const INDEXED_PATHS = ['', '/live', '/clean', '/social-card', '/residence-permit', '/airport', '/pricing', '/terms', '/refund', '/privacy'] as const;
|
||||
|
||||
/** hreflang для пути: все языки + x-default на русскую версию (абсолютные адреса для sitemap) */
|
||||
export function languageAlternates(path: string, absolute = false): Record<string, string> {
|
||||
|
||||
Vendored
+2
-2
@@ -1,7 +1,7 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/types/root-params.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/dev/types/root-params.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1 @@
|
||||
{"fileNames":["../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es5.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2023.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.iterable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.core.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.date.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.object.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.array.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.object.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.date.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.number.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.promise.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.string.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.weakref.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.intl.d.ts","../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/libLine truncated
|
||||
Reference in new issue
Block a user