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:
zabudko-ruandClaude Opus 5.5 committed 2026-10-25 22:55:29 +04:00
1 parent 45d7512d2b
commit 3489e0b816
319 files changed
+45483 -121

No files matched your search

+446
View File
@@ -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>
);
}
+117
View File
@@ -0,0 +1,117 @@
'use client';
import { encodeStartPayload, normalizeAgencyCode, pickUtm, type Locale } from '@barev/core';
import { LOCALE_LABELS, LOCALES } from '@barev/i18n';
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000';
const AGENCY_KEY = 'barev.agency';
interface Ctx {
href: string;
agency: string | null;
query: string;
}
const LinkCtx = createContext<Ctx>({ href: '', agency: null, query: '' });
/**
* LND-3: ссылка в бота с payload (язык, агентство, UTM). Без JS работает ссылка с языком (SSR);
* в браузере дописываем код агентства из ?a= и, если есть UTM, получаем короткий токен у API.
* Код агентства запоминается: клиент мог перейти на лендинг по QR, а нажать CTA позже.
*/
export function BotLinkProvider({ locale, bot, children }: { locale: Locale; bot: string; children: ReactNode }) {
const base = `https://t.me/${bot}?start=${encodeStartPayload({ locale })}`;
const [state, setState] = useState<Ctx>({ href: base, agency: null, query: '' });
useEffect(() => {
const params = new URLSearchParams(window.location.search);
let agency = params.get('a') ? normalizeAgencyCode(params.get('a')!) : null;
try {
if (agency) localStorage.setItem(AGENCY_KEY, agency);
else agency = localStorage.getItem(AGENCY_KEY);
} catch {
/* приватный режим */
}
const utm = pickUtm(params);
const query = window.location.search;
const simple = `https://t.me/${bot}?start=${encodeStartPayload({ locale, agency: agency ?? undefined })}`;
setState({ href: simple, agency, query });
if (Object.keys(utm).length === 0) return;
fetch(`${API}/trpc/public.startLink`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ json: { locale, agency: agency ?? undefined, utm } }),
})
.then((r) => (r.ok ? r.json() : null))
.then((b: { result?: { data?: { json?: { url: string } } } } | null) => {
const url = b?.result?.data?.json?.url;
if (url) setState((s) => ({ ...s, href: url }));
})
.catch(() => {});
}, [bot, locale]);
return <LinkCtx.Provider value={state}>{children}</LinkCtx.Provider>;
}
/** Код агентства из ?a= или сохранённый ранее — для подарка на /land3 */
export function useAgency(): string | null {
return useContext(LinkCtx).agency;
}
export function BotLink({ className, style, children }: { className?: string; style?: React.CSSProperties; children: ReactNode }) {
const { href } = useContext(LinkCtx);
return (
<a href={href} className={className} style={style} rel="noopener">
{children}
</a>
);
}
/** LND-2: переключатель языков; выбор запоминается в cookie и уходит в payload. path — страница без языка, например /clean */
export function LangSwitch({ current, path = '' }: { current: Locale; path?: string }) {
const { query } = useContext(LinkCtx);
return (
<>
{LOCALES.map((l) => (
<a
key={l}
href={`/${l}${path}${query}`}
className="lang"
aria-current={l === current}
hrefLang={l}
lang={l}
onClick={() => {
document.cookie = `barev_lang=${l}; path=/; max-age=31536000; samesite=lax`;
}}
>
{LOCALE_LABELS[l]}
</a>
))}
</>
);
}
/** Языки на мобилке: вместо ряда из пяти кнопок — текущий язык, по нажатию раскрывается список */
export function LangMenu({ current, path = '' }: { current: Locale; path?: string }) {
const ref = useRef<HTMLDetailsElement>(null);
useEffect(() => {
const close = (e: PointerEvent) => {
if (ref.current?.open && !ref.current.contains(e.target as Node)) ref.current.open = false;
};
document.addEventListener('pointerdown', close);
return () => document.removeEventListener('pointerdown', close);
}, []);
return (
<details ref={ref} className="lang-menu">
<summary className="lang lang-menu__current">
{LOCALE_LABELS[current]}
<svg width="10" height="6" viewBox="0 0 10 6" aria-hidden="true">
<path d="M1 1l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</summary>
<div className="lang-menu__list">
<LangSwitch current={current} path={path} />
</div>
</details>
);
}
+425
View File
@@ -0,0 +1,425 @@
'use client';
import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
import type { Flat } from '../lib/flats';
// Страница /ru/apartments: квартиры AREV из CRM, галерея фото, описание и запись на просмотр.
// Страница только на русском (решение владельца), поэтому тексты здесь, а не в словарях i18n.
const TIMES = [
{ id: 'morning', label: 'Утро', hint: '10:00–13:00' },
{ id: 'day', label: 'День', hint: '13:00–17:00' },
{ id: 'evening', label: 'Вечер', hint: '17:00–20:00' },
] as const;
type TimeId = (typeof TIMES)[number]['id'];
const ERRORS: Record<string, string> = {
CONTACT_REQUIRED: 'Укажите телефон или Telegram — так агент сможет с вами связаться.',
BAD_PHONE: 'Похоже, в телефоне ошибка. Пример: +374 91 123456.',
BAD_TELEGRAM: 'Ник в Telegram — латиницей, например @anna_ru.',
BAD_DAY: 'Этот день уже недоступен — выберите другой.',
OBJECT_NOT_FOUND: 'Эту квартиру только что сняли с показа. Выберите другую.',
NOT_FOUND: 'Эту квартиру только что сняли с показа. Выберите другую.',
TOO_MANY: 'Слишком много заявок подряд. Попробуйте через несколько минут.',
};
const FALLBACK_ERROR = 'Не получилось отправить заявку. Попробуйте ещё раз или напишите нам в Telegram.';
const SYMBOL: Record<string, string> = {
USD: '$',
AMD: '֏',
RUB: '₽',
EUR: '€',
};
function price(f: Flat) {
if (!f.price) return 'Цена по запросу';
const n = Math.round(f.price).toLocaleString('ru-RU');
const c = f.currency ?? 'AMD';
const sum = c === 'USD' || c === 'EUR' ? `${SYMBOL[c]}${n}` : `${n} ${SYMBOL[c] ?? c}`;
return f.dealType === 'rent' ? `${sum} / мес` : sum;
}
const KIND: Record<string, string> = {
apartment: 'квартира',
house: 'дом',
commercial: 'помещение',
};
function title(f: Flat) {
const kind = KIND[f.propertyType ?? 'apartment'] ?? 'объект';
return f.rooms ? `${f.rooms}-комн. ${kind}` : kind[0]!.toUpperCase() + kind.slice(1);
}
function facts(f: Flat) {
return [f.area ? `${Math.round(f.area)} м²` : null, f.floor ? (f.floorsTotal ? `этаж ${f.floor}/${f.floorsTotal}` : `этаж ${f.floor}`) : null].filter(
Boolean,
) as string[];
}
const photo = (f: Flat, i: number) => `/api/flats/photo/${f.id}/${i}`;
/** Ближайшие 7 дней, начиная с завтра, по Еревану */
function nextDays() {
const ymd = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Yerevan' });
const week = new Intl.DateTimeFormat('ru-RU', {
weekday: 'short',
timeZone: 'Asia/Yerevan',
});
const date = new Intl.DateTimeFormat('ru-RU', {
day: 'numeric',
month: 'short',
timeZone: 'Asia/Yerevan',
});
return Array.from({ length: 7 }, (_, k) => {
const d = new Date(Date.now() + (k + 1) * 86_400_000);
return {
id: ymd.format(d),
week: k === 0 ? 'завтра' : week.format(d),
date: date.format(d).replace('.', ''),
};
});
}
const ROOMS = [
{ id: 0, label: 'Все' },
{ id: 1, label: '1 комн.' },
{ id: 2, label: '2 комн.' },
{ id: 3, label: '3 комн.' },
{ id: 4, label: '4+' },
];
export function Flats({ flats, telegramUrl }: { flats: Flat[]; telegramUrl: string }) {
const [rooms, setRooms] = useState(0);
const [booking, setBooking] = useState<Flat | null>(null);
const [viewer, setViewer] = useState<{ flat: Flat; index: number } | null>(null);
const shown = useMemo(() => flats.filter((f) => !rooms || (rooms === 4 ? (f.rooms ?? 0) >= 4 : f.rooms === rooms)), [flats, rooms]);
const available = useMemo(() => new Set(flats.map((f) => Math.min(f.rooms ?? 0, 4))), [flats]);
return (
<>
<div className="fl-filter" role="group" aria-label="Комнаты">
{ROOMS.filter((r) => !r.id || available.has(r.id)).map((r) => (
<button key={r.id} type="button" className="fl-chip" aria-pressed={rooms === r.id} onClick={() => setRooms(r.id)}>
{r.label}
</button>
))}
<span className="fl-count">{countLabel(shown.length)}</span>
</div>
<ul className="fl-list">
{shown.map((f) => (
<FlatCard key={f.id} flat={f} onBook={() => setBooking(f)} onOpen={(index) => setViewer({ flat: f, index })} />
))}
</ul>
{viewer && (
<Viewer
flat={viewer.flat}
start={viewer.index}
onClose={() => setViewer(null)}
onBook={() => {
setBooking(viewer.flat);
setViewer(null);
}}
/>
)}
{booking && <Booking flat={booking} telegramUrl={telegramUrl} onClose={() => setBooking(null)} />}
</>
);
}
function countLabel(n: number) {
const m10 = n % 10;
const m100 = n % 100;
const word = m10 === 1 && m100 !== 11 ? 'вариант' : m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14) ? 'варианта' : 'вариантов';
return `${n} ${word}`;
}
function FlatCard({ flat: f, onBook, onOpen }: { flat: Flat; onBook: () => void; onOpen: (index: number) => void }) {
const [open, setOpen] = useState(false);
return (
<li className="fl-card">
<Gallery flat={f} onOpen={onOpen} />
<div className="fl-card__body">
<div className="fl-card__head">
<div>
<h2 className="fl-card__title">{title(f)}</h2>
<p className="fl-card__facts">{[f.district, ...facts(f)].filter(Boolean).join(' · ')}</p>
</div>
<p className="fl-card__price">{price(f)}</p>
</div>
<div className="fl-text" data-open={open}>
<p>{f.text}</p>
</div>
<button type="button" className="fl-more" onClick={() => setOpen(!open)} aria-expanded={open}>
{open ? 'Свернуть' : 'Читать описание полностью'}
</button>
<button type="button" className="bc-btn fl-book" onClick={onBook}>
Записаться на просмотр
</button>
</div>
</li>
);
}
/** Лента фото: листается пальцем, стрелками и колесом; по нажатию — на весь экран */
function Gallery({ flat: f, onOpen, start = 0, full = false }: { flat: Flat; onOpen?: (index: number) => void; start?: number; full?: boolean }) {
const ref = useRef<HTMLDivElement>(null);
const [index, setIndex] = useState(start);
const count = f.photos;
useEffect(() => {
const el = ref.current;
if (el && start)
el.scrollTo({
left: start * el.clientWidth,
behavior: 'instant' as ScrollBehavior,
});
}, [start]);
const go = (to: number) => {
const el = ref.current;
if (!el) return;
const next = Math.max(0, Math.min(count - 1, to));
el.scrollTo({ left: next * el.clientWidth, behavior: 'smooth' });
};
useEffect(() => {
if (!full) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowRight') go(index + 1);
if (e.key === 'ArrowLeft') go(index - 1);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
});
if (!count) return <div className="fl-gallery fl-gallery--empty">Фото скоро появятся</div>;
return (
<div className={full ? 'fl-gallery fl-gallery--full' : 'fl-gallery'}>
<div
ref={ref}
className="fl-gallery__track"
onScroll={(e) => {
const el = e.currentTarget;
setIndex(Math.round(el.scrollLeft / el.clientWidth));
}}
>
{Array.from({ length: count }, (_, i) => (
<button
key={i}
type="button"
className="fl-gallery__slide"
onClick={() => onOpen?.(i)}
tabIndex={onOpen ? 0 : -1}
aria-label={onOpen ? `Открыть фото ${i + 1} из ${count}` : undefined}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={photo(f, i)} alt={`${title(f)}, фото ${i + 1}`} loading={i === start ? 'eager' : 'lazy'} decoding="async" />
</button>
))}
</div>
{count > 1 && (
<>
<button
type="button"
className="fl-gallery__nav fl-gallery__nav--prev"
onClick={() => go(index - 1)}
disabled={index === 0}
aria-label="Предыдущее фото"
>
‹
</button>
<button
type="button"
className="fl-gallery__nav fl-gallery__nav--next"
onClick={() => go(index + 1)}
disabled={index >= count - 1}
aria-label="Следующее фото"
>
›
</button>
</>
)}
<span className="fl-gallery__count">
{index + 1} / {count}
</span>
</div>
);
}
function useDialog(onClose: () => void) {
const ref = useRef<HTMLDialogElement>(null);
const latest = useRef(onClose);
latest.current = onClose;
useEffect(() => {
const d = ref.current;
if (!d) return;
if (!d.open) d.showModal();
document.body.style.overflow = 'hidden';
// Esc и закрытие формой — тот же выход, что и крестик
const close = () => latest.current();
d.addEventListener('close', close);
return () => {
d.removeEventListener('close', close);
document.body.style.overflow = '';
};
}, []);
return ref;
}
function Viewer({ flat, start, onClose, onBook }: { flat: Flat; start: number; onClose: () => void; onBook: () => void }) {
const ref = useDialog(onClose);
return (
<dialog ref={ref} className="fl-viewer" aria-label={`Фото: ${title(flat)}`} onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="fl-viewer__bar">
<span>
{title(flat)} · {price(flat)}
</span>
<button type="button" className="fl-close" onClick={onClose} aria-label="Закрыть">
×
</button>
</div>
<Gallery flat={flat} start={start} full />
<button type="button" className="bc-btn fl-book fl-viewer__book" onClick={onBook}>
Записаться на просмотр
</button>
</dialog>
);
}
function Booking({ flat, telegramUrl, onClose }: { flat: Flat; telegramUrl: string; onClose: () => void }) {
const ref = useDialog(onClose);
const days = useMemo(nextDays, []);
const [day, setDay] = useState(days[0]!.id);
const [time, setTime] = useState<TimeId>('evening');
const [state, setState] = useState<'form' | 'sending' | 'done'>('form');
const [error, setError] = useState<string | null>(null);
const submit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
const phone = String(data.get('phone') ?? '').trim();
const telegram = String(data.get('telegram') ?? '').trim();
if (!phone && !telegram) return setError(ERRORS.CONTACT_REQUIRED!);
setError(null);
setState('sending');
try {
const res = await fetch('/api/flats/request', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
leadId: flat.id,
day,
time,
name: data.get('name'),
phone,
telegram,
website: data.get('website'),
}),
});
const body = (await res.json().catch(() => ({}))) as { code?: string };
if (!res.ok) {
setError(ERRORS[body.code ?? ''] ?? FALLBACK_ERROR);
return setState('form');
}
setState('done');
} catch {
setError(FALLBACK_ERROR);
setState('form');
}
};
const chosen = days.find((d) => d.id === day)!;
const slot = TIMES.find((t) => t.id === time)!;
return (
<dialog ref={ref} className="fl-modal" aria-labelledby="fl-modal-title" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="fl-modal__box">
<button type="button" className="fl-close fl-modal__close" onClick={onClose} aria-label="Закрыть">
×
</button>
{state === 'done' ? (
<div className="fl-done">
<h2 id="fl-modal-title">Заявка принята</h2>
<p>
Агент свяжется с вами, чтобы подтвердить просмотр: {chosen.week}, {chosen.date}, {slot.label.toLowerCase()} ({slot.hint}).
</p>
<button type="button" className="bc-btn fl-book" onClick={onClose}>
Смотреть другие квартиры
</button>
</div>
) : (
<form onSubmit={submit} noValidate>
<h2 id="fl-modal-title">Запись на просмотр</h2>
<p className="fl-modal__flat">
{title(flat)}
{flat.district ? `, ${flat.district}` : ''} · {price(flat)}
</p>
<fieldset className="fl-field">
<legend>День</legend>
<div className="fl-days">
{days.map((d) => (
<label key={d.id} className="fl-day">
<input type="radio" name="day" value={d.id} checked={day === d.id} onChange={() => setDay(d.id)} />
<span>
<small>{d.week}</small>
<b>{d.date}</b>
</span>
</label>
))}
</div>
</fieldset>
<fieldset className="fl-field">
<legend>Время</legend>
<div className="fl-times">
{TIMES.map((t) => (
<label key={t.id} className="fl-day">
<input type="radio" name="time" value={t.id} checked={time === t.id} onChange={() => setTime(t.id)} />
<span>
<b>{t.label}</b>
<small>{t.hint}</small>
</span>
</label>
))}
</div>
</fieldset>
<label className="fl-input">
<span>Как к вам обращаться</span>
<input name="name" required maxLength={80} autoComplete="name" placeholder="Имя" />
</label>
<div className="fl-contacts">
<label className="fl-input">
<span>Телефон</span>
<input name="phone" type="tel" maxLength={40} autoComplete="tel" placeholder="+374 …" inputMode="tel" />
</label>
<label className="fl-input">
<span>или Telegram</span>
<input name="telegram" maxLength={80} placeholder="@username" autoCapitalize="none" autoCorrect="off" />
</label>
</div>
<input name="website" tabIndex={-1} autoComplete="off" className="fl-hp" aria-hidden="true" />
{error && (
<p className="fl-error" role="alert">
{error}
{error === FALLBACK_ERROR && (
<>
{' '}
<a href={telegramUrl}>Написать в Telegram</a>
</>
)}
</p>
)}
<button type="submit" className="bc-btn fl-book" disabled={state === 'sending'}>
{state === 'sending' ? 'Отправляем…' : 'Записаться'}
</button>
<p className="fl-note">
Нажимая «Записаться», вы соглашаетесь с <a href="/ru/privacy">политикой конфиденциальности</a>. Мы передадим контакты только агенту, который ведёт
эту квартиру.
</p>
</form>
)}
</div>
</dialog>
);
}
+151
View File
@@ -0,0 +1,151 @@
'use client';
import { Icon } from '@barev/ui';
import { useEffect, useState } from 'react';
import imgL from '../assets/flowers/l.jpg';
import imgM from '../assets/flowers/m.jpg';
import imgS from '../assets/flowers/s.jpg';
import { BotLink } from './client';
/** Цены за одну доставку; при еженедельной подписке — своя цена (≈ −10%) */
const PLANS = [
{
id: 's',
letter: 'S',
name: 'Мини',
stems: 10,
stemsLabel: '9–11 стеблей',
photo: imgS,
alt: 'Букет S: розовые кустовые розы, белая альстромерия и эвкалипт в крафте',
base: 9900,
week: 8900,
salon: '13–15 тыс. ֏',
features: ['Сезонный микс от флориста', 'Для кухни или рабочего стола, ~35 см', 'Крафт-упаковка и подкормка'],
},
{
id: 'm',
letter: 'M',
name: 'Классика',
stems: 16,
stemsLabel: '15–17 стеблей',
photo: imgM,
alt: 'Букет M: персиковые пионовидные розы, сиреневая эустома и белые хризантемы в крафте',
base: 14900,
week: 13400,
salon: '19–23 тыс. ֏',
features: ['Сезонный микс с акцентными цветами', 'Для гостиной, ~45 см', 'Ваза к первому букету — остаётся у вас'],
},
{
id: 'l',
letter: 'L',
name: 'Пышный',
stems: 27,
stemsLabel: '25–30 стеблей',
photo: imgL,
alt: 'Букет L: розовые розы, белая гортензия, ранункулюсы и эустома в крафте',
base: 22900,
week: 20600,
salon: '30–42 тыс. ֏',
features: ['Эустома, пионовидные розы и другие премиальные сорта', 'Заметен с порога, ~55 см', 'Ваза к первому букету — остаётся у вас'],
},
] as const;
const FREQ = [
{ id: 'week', label: 'Каждую неделю', perMonth: 4.3 },
{ id: 'biweek', label: 'Раз в 2 недели', perMonth: 2.15 },
{ id: 'month', label: 'Раз в месяц', perMonth: 1 },
] as const;
type FreqId = (typeof FREQ)[number]['id'];
const amd = (n: number) => `${Math.round(n).toLocaleString('ru-RU')} ֏`;
const round10 = (n: number) => Math.round(n / 10) * 10;
const round100 = (n: number) => Math.round(n / 100) * 100;
/** Тарифы S/M/L как клубные карты главной; частота меняет цену и «≈ в месяц» */
export function FlowerPlans() {
const [freq, setFreq] = useState<FreqId>('biweek');
const f = FREQ.find((x) => x.id === freq)!;
return (
<section id="plans" className="h-plans fw-plans" aria-labelledby="plans-title">
<div className="h-section-head">
<h2 id="plans-title">Выберите свой букет</h2>
<p>Цена за одну доставку, доставка по Еревану уже включена. Без договоров: пауза и отмена в один клик.</p>
</div>
<div className="fw-freq" role="radiogroup" aria-label="Как часто привозить">
{FREQ.map((x) => (
<button key={x.id} type="button" role="radio" aria-checked={freq === x.id} onClick={() => setFreq(x.id)}>
{x.label}
{x.id === 'week' && <span className="fw-freq__save">−10%</span>}
</button>
))}
</div>
<div className="h-plans__grid">
{PLANS.map((p) => {
const price = freq === 'week' ? p.week : p.base;
const monthly = price * f.perMonth;
const saving = (p.base - p.week) * f.perMonth;
return (
<article key={p.id} className="h-plan fw-plan" data-size={p.id}>
{p.id === 'm' && <span className="fw-plan__badge">Выбирают чаще всего</span>}
<img className="fw-plan__photo" src={p.photo.src} width={p.photo.width} height={p.photo.height} alt={p.alt} loading="lazy" decoding="async" />
<div className="h-card fw-card">
<span className="h-card__club">Barev Club · Цветы</span>
<span className="fw-card__letter" aria-hidden="true">
{p.letter}
</span>
<h3 className="h-card__name">{p.name}</h3>
<span className="h-card__price">
{amd(price)} <small>/ букет</small>
</span>
</div>
<div className="fw-plan__facts">
<span>{p.stemsLabel}</span>
<span>≈ {amd(price / p.stems)} за стебель</span>
<span>≈ {amd(round10(monthly / 30))} в день</span>
</div>
<p className="fw-plan__anchor">
В салоне похожий букет — <s>{p.salon}</s> + доставка
</p>
<ul className="h-plan__list">
{p.features.map((x) => (
<li key={x}>
<Icon name="check" size={18} strokeWidth={2.6} />
{x}
</li>
))}
</ul>
<p className="fw-plan__month">
{freq === 'month' ? 'Одна доставка в месяц' : `≈ ${amd(round100(monthly))} в месяц`}
{freq === 'week' && <b> · экономия {amd(round100(saving))} в месяц</b>}
</p>
<BotLink className="bc-btn h-plan__cta">Оформить {p.letter}</BotLink>
</article>
);
})}
</div>
<p className="fw-plans__note">Оплата картой ArCa, Visa или Mastercard — за 2 дня до каждой доставки. Предоплаты за месяц нет.</p>
</section>
);
}
/** Ближайшая доставка: через 2 дня, если оформить до 20:00, иначе через 3; в воскресенье не возим. Считается в браузере — страница кешируется */
export function NextDelivery() {
const [text, setText] = useState<{ date: string; when: string } | null>(null);
useEffect(() => {
const now = new Date();
const early = now.getHours() < 20;
const d = new Date(now);
d.setDate(d.getDate() + (early ? 2 : 3));
if (d.getDay() === 0) d.setDate(d.getDate() + 1);
setText({ date: d.toLocaleDateString('ru-RU', { weekday: 'long', day: 'numeric', month: 'long' }), when: early ? 'сегодня' : 'завтра' });
}, []);
if (!text) return null;
return (
<p className="fw-next">
<Icon name="clock" size={18} /> Ближайшая доставка — <b>{text.date}</b>, если оформить {text.when} до 20:00
</p>
);
}
+9
View File
@@ -0,0 +1,9 @@
'use client';
import { useAgency } from './client';
/** Строка на бирке подарка: код из ссылки агентства (?a=) или подсказка, где его взять */
export function GiftCode({ withCode, withoutCode }: { withCode: string; withoutCode: string }) {
const agency = useAgency();
return <span className="v3-tag__code">{agency ? withCode.replace('{{code}}', agency) : withoutCode}</span>;
}
+110
View File
@@ -0,0 +1,110 @@
import { createT, formatAmd, isLocale, LOCALES, type Locale } from '@barev/i18n';
import { Icon } from '@barev/ui';
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 */
export async function homeMetadata(params: Promise<{ locale: string }>, path: '' | '/live'): Promise<Metadata> {
const { locale } = await params;
if (!isLocale(locale)) return {};
const t = createT(locale);
const { offer } = await getCatalog();
const title = t(path ? 'landing.live.metaTitle' : 'landing.home.metaTitle');
const description = t(path ? 'landing.live.metaDescription' : 'landing.home.metaDescription', { pct: offer.eliteMaxPct });
return {
title,
description,
alternates: {
canonical: `/${locale}${path}`,
languages: Object.fromEntries([...LOCALES.map((l) => [l, `/${l}${path}`]), ['x-default', `/ru${path}`]]),
},
openGraph: { title, description, locale, type: 'website', siteName: 'Barev Club' },
};
}
/** Переключатель аудитории: две версии главной — «Переезжаю» и «Уже живу в Армении» */
export function AudienceSwitch({ locale, current }: { locale: Locale; current: 'move' | 'live' }) {
const t = createT(locale);
return (
<nav className="h-switch" aria-label={t('landing.home.switchLabel')}>
<a href={`/${locale}`} aria-current={current === 'move' ? 'page' : undefined}>
{t('landing.home.switchMove')}
</a>
<a href={`/${locale}/live`} aria-current={current === 'live' ? 'page' : undefined}>
{t('landing.home.switchLive')}
</a>
</nav>
);
}
/**
* Тарифы как клубные карты: Barev — прозрачная, Barev+ — розовая, Elite — жёлтая.
* Оформление подписки — в боте, поэтому все кнопки ведут туда.
*/
export function ClubPlans({ locale, plusPrice, offer }: { locale: Locale; plusPrice: number; offer: LandingOffer }) {
const t = createT(locale);
const amd = (n: number) => formatAmd(locale, n);
const plans = [
{ id: 'free', name: 'Barev', price: t('landing.club.free'), cta: t('landing.club.freeCta') },
{ id: 'plus', name: 'Barev+', price: t('landing.club.perMonth', { price: amd(plusPrice) }), cta: t('landing.club.plusCta') },
{ id: 'elite', name: 'Barev Elite', price: t('landing.club.perMonth', { price: amd(offer.elitePrice) }), cta: t('landing.club.eliteCta') },
] as const;
return (
<section id="plans" className="h-plans" aria-labelledby="plans-title">
<div className="h-section-head">
<h2 id="plans-title">{t('landing.club.title')}</h2>
<p>{t('landing.club.sub')}</p>
</div>
<div className="h-plans__grid">
{plans.map((p) => (
<article key={p.id} className="h-plan" data-plan={p.id}>
<div className="h-card">
<span className="h-card__club">Barev Club</span>
<span className="h-card__chip" aria-hidden="true" />
<h3 className="h-card__name">{p.name}</h3>
<span className="h-card__price">{p.price}</span>
</div>
<ul className="h-plan__list">
{[1, 2, 3].map((n) => (
<li key={n}>
<Icon name="check" size={18} strokeWidth={2.6} />
{t(`landing.club.${p.id}F${n}`, { pct: p.id === 'elite' ? offer.eliteMaxPct : offer.plusMaxPct })}
</li>
))}
</ul>
<BotLink className="bc-btn h-plan__cta">{p.cta}</BotLink>
</article>
))}
</div>
</section>
);
}
export function HomeFooter({ locale }: { locale: Locale }) {
const t = createT(locale);
return (
<footer className="v-footer v-footer--dark">
<nav className="h-footer__services" aria-label={t('landing.footer.services')}>
{Object.values(SERVICE_PAGES).map((p) => (
<a key={p.id} href={`/${locale}${p.path}`}>
{t(`landing.svc.${p.id}.crumb`)}
</a>
))}
<a href={`/${locale}/clean`}>{t('landing.svc.common.clean')}</a>
</nav>
<span className="h-footer__brand">
{t('landing.footer.brand')}
<LegalLine locale={locale} className="h-footer__legal" />
</span>
<span className="h-footer__links">
<FooterContacts />
<BotLink>{t('landing.footer.partner')}</BotLink>
<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>
);
}
+459
View File
@@ -0,0 +1,459 @@
'use client';
import { normalizeAgencyCode, pickUtm, type Locale } from '@barev/core';
import { tFromMessages, type Messages } from '@barev/i18n';
import { Icon } from '@barev/ui';
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { BotLink } from './client';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000';
const AGENCY_KEY = 'barev.agency';
const WHEN = ['month', 'quarter', 'later', 'date'] as const;
const PACKAGES = ['CONSULT', 'TURNKEY', 'CUSTOM'] as const;
const YES_NO = ['flat', 'socialCard', 'bankCard', 'registration', 'residence'] as const;
type When = (typeof WHEN)[number];
type Pkg = (typeof PACKAGES)[number];
type Step = 'contact' | 'moved' | 'when' | 'package' | 'custom' | 'done' | 'redirect';
const ORDER: Step[] = ['contact', 'moved', 'when', 'package', 'custom'];
interface Options {
airport: 'none' | 'standard' | 'premium';
stay: 'none' | 'flat' | 'hotel';
stayBudgetFrom: string;
stayBudgetTo: string;
flat: boolean;
socialCard: boolean;
bankCard: boolean;
registration: boolean;
residence: boolean;
}
const EMPTY: Options = { airport: 'none', stay: 'none', stayBudgetFrom: '', stayBudgetTo: '', flat: false, socialCard: false, bankCard: false, registration: false, residence: false };
class ApiError extends Error {}
/** tRPC-мутация лендинга; ошибка — код домена (CONTACT_INVALID…) или пусто для сети */
async function mutate<T>(path: string, json: unknown): Promise<T> {
let res: Response;
try {
res = await fetch(`${API}/trpc/public.${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ json }) });
} catch {
throw new ApiError('');
}
const body = (await res.json().catch(() => null)) as { result?: { data?: { json?: T } }; error?: { json?: { message?: string; data?: { domainCode?: string | null } } } } | null;
const data = body?.result?.data?.json;
if (data === undefined) throw new ApiError(body?.error?.json?.data?.domainCode ?? body?.error?.json?.message ?? 'generic');
return data;
}
const QuizCtx = createContext<() => void>(() => {});
/** Кнопка, открывающая квиз; на странице может быть несколько */
export function QuizButton({ className, children }: { className?: string; children: ReactNode }) {
const open = useContext(QuizCtx);
return (
<button type="button" className={className} onClick={open} aria-haspopup="dialog">
{children}
</button>
);
}
/**
* Квиз «Подбор переезда» в модальном окне. Имя и контакт сохраняются сразу после первого шага,
* дальше каждый ответ дописывается в ту же заявку — оператор видит её в админке, даже если человек не дошёл до конца.
* «Уже живу в Армении» — переход на /live.
*/
export function QuizProvider({ locale, messages, consultPrice, children }: { locale: Locale; messages: Messages; consultPrice: string; children: ReactNode }) {
const t = useMemo(() => tFromMessages(locale, messages), [locale, messages]);
const dialog = useRef<HTMLDialogElement>(null);
const heading = useRef<HTMLHeadingElement>(null);
const [step, setStep] = useState<Step>('contact');
const [dir, setDir] = useState<'fwd' | 'back'>('fwd');
const [lead, setLead] = useState<{ id: string; key: string; number: number; name: string; contact: string } | null>(null);
const [name, setName] = useState('');
const [contact, setContact] = useState('');
const [when, setWhen] = useState<When | null>(null);
const [date, setDate] = useState('');
const [pkg, setPkg] = useState<Pkg | null>(null);
const [opts, setOpts] = useState<Options>(EMPTY);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<{ field?: 'name' | 'contact' | 'when' | 'custom'; text: string } | null>(null);
const [today, setToday] = useState('');
useEffect(() => setToday(new Date().toISOString().slice(0, 10)), []);
// новый шаг — фокус на заголовок, чтобы экранный диктор прочитал вопрос
useEffect(() => heading.current?.focus({ preventScroll: true }), [step]);
const open = () => {
dialog.current?.showModal();
heading.current?.focus({ preventScroll: true });
};
const close = () => dialog.current?.close();
function go(next: Step) {
setDir(ORDER.indexOf(next) < ORDER.indexOf(step) ? 'back' : 'fwd');
setError(null);
setStep(next);
}
function back() {
const prev: Partial<Record<Step, Step>> = { moved: 'contact', when: 'moved', package: 'when', custom: 'package' };
if (prev[step]) go(prev[step]!);
}
async function run(fn: () => Promise<void>, field?: 'name' | 'contact' | 'when' | 'custom') {
setBusy(true);
setError(null);
try {
await fn();
} catch (e) {
const code = e instanceof ApiError ? e.message : '';
const text = !code ? t('landing.quiz.errNetwork') : messages[`errors.${code}`] ? t(`errors.${code}`) : t('errors.generic');
setError({ field: code === 'CONTACT_INVALID' ? 'contact' : code === 'NAME_REQUIRED' ? 'name' : field, text });
} finally {
setBusy(false);
}
}
const update = (json: Record<string, unknown>) => mutate<{ number: number; submitted: boolean }>('leadUpdate', { id: lead!.id, key: lead!.key, ...json });
function submitContact() {
if (!name.trim()) return setError({ field: 'name', text: t('landing.quiz.errName') });
const c = contact.trim();
const looksOk = /^(?:https?:\/\/)?(?:t\.me\/)?@?[a-zA-Z][a-zA-Z0-9_]{4,31}$/.test(c) || /^\+?\d{8,15}$/.test(c.replace(/[\s()\-.]/g, ''));
if (!looksOk) return setError({ field: 'contact', text: t('landing.quiz.errContact') });
// вернулись назад и ничего не поменяли — заявка уже есть
if (lead && lead.name === name.trim() && lead.contact === c) return go('moved');
void run(async () => {
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);
const r = await mutate<{ id: string; key: string; number: number }>('leadStart', {
name: name.trim(),
contact: c,
locale,
agency: agency ?? undefined,
utm: Object.keys(utm).length ? utm : undefined,
consent: true,
});
setLead({ ...r, name: name.trim(), contact: c });
go('moved');
});
}
function answerMoved(moved: boolean) {
void run(async () => {
await update({ moved });
if (!moved) return go('when');
setStep('redirect');
window.location.assign(`/${locale}/live${window.location.search}`);
});
}
function submitWhen() {
if (!when || (when === 'date' && !date)) return setError({ field: 'when', text: t('landing.quiz.errWhen') });
void run(async () => {
await update({ moveWhen: when, moveDate: when === 'date' ? date : undefined });
go('package');
}, 'when');
}
function choosePackage(p: Pkg) {
setPkg(p);
void run(async () => {
await update({ package: p, submit: p !== 'CUSTOM' });
go(p === 'CUSTOM' ? 'custom' : 'done');
});
}
function submitCustom() {
const any = opts.airport !== 'none' || opts.stay !== 'none' || YES_NO.some((k) => opts[k]);
if (!any) return setError({ field: 'custom', text: t('landing.quiz.errEmpty') });
const num = (v: string) => (v.trim() ? Math.max(0, Math.round(Number(v.replace(/\D/g, '')))) : undefined);
void run(async () => {
await update({
options: {
...opts,
stayBudgetFrom: opts.stay === 'none' ? undefined : num(opts.stayBudgetFrom),
stayBudgetTo: opts.stay === 'none' ? undefined : num(opts.stayBudgetTo),
},
submit: true,
});
go('done');
}, 'custom');
}
const index = ORDER.indexOf(step);
const total = pkg && pkg !== 'CUSTOM' ? 4 : 5;
const err = (field: string) => (error?.field === field ? error.text : null);
const set = <K extends keyof Options>(k: K, v: Options[K]) => setOpts((o) => ({ ...o, [k]: v }));
let body: ReactNode;
if (step === 'contact') {
body = (
<form
className="q-form"
noValidate
onSubmit={(e) => {
e.preventDefault();
submitContact();
}}
>
<h2 ref={heading} tabIndex={-1} className="q-title">
{t('landing.quiz.contactTitle')}
</h2>
<label className="q-field" data-error={!!err('name')}>
<span>{t('landing.quiz.name')}</span>
<input className="q-input" value={name} onChange={(e) => setName(e.target.value)} autoComplete="given-name" maxLength={80} aria-invalid={!!err('name')} />
{err('name') && <small role="alert">{err('name')}</small>}
</label>
<label className="q-field" data-error={!!err('contact')}>
<span>{t('landing.quiz.contact')}</span>
<input
className="q-input"
value={contact}
onChange={(e) => setContact(e.target.value)}
placeholder={t('landing.quiz.contactHint')}
autoComplete="tel"
inputMode="text"
maxLength={60}
aria-invalid={!!err('contact')}
/>
{err('contact') && <small role="alert">{err('contact')}</small>}
</label>
{error && !error.field && <p className="q-error" role="alert">{error.text}</p>}
<button type="submit" className="bc-btn q-primary" disabled={busy}>
{busy ? t('landing.quiz.sending') : t('landing.quiz.next')}
</button>
<p className="q-consent">
{t('landing.quiz.consentPre')}
<a href={`/${locale}/privacy`} target="_blank" rel="noopener">
{t('landing.quiz.consentLink')}
</a>
</p>
</form>
);
} else if (step === 'moved') {
body = (
<div className="q-form">
<h2 ref={heading} tabIndex={-1} className="q-title">
{t('landing.quiz.movedTitle')}
</h2>
<div className="q-choices">
<button type="button" className="q-choice" disabled={busy} onClick={() => answerMoved(false)}>
<b>{t('landing.quiz.movedNo')}</b>
</button>
<button type="button" className="q-choice" disabled={busy} onClick={() => answerMoved(true)}>
<b>{t('landing.quiz.movedYes')}</b>
</button>
</div>
{error && <p className="q-error" role="alert">{error.text}</p>}
</div>
);
} else if (step === 'when') {
body = (
<form
className="q-form"
noValidate
onSubmit={(e) => {
e.preventDefault();
submitWhen();
}}
>
<h2 ref={heading} tabIndex={-1} className="q-title" id="q-when">
{t('landing.quiz.whenTitle')}
</h2>
<div className="q-choices" role="radiogroup" aria-labelledby="q-when">
{WHEN.map((w) => (
<button key={w} type="button" role="radio" aria-checked={when === w} className="q-choice" onClick={() => setWhen(w)}>
<b>{t(`landing.quiz.when_${w}`)}</b>
</button>
))}
</div>
{when === 'date' && (
<label className="q-field">
<span>{t('landing.quiz.date')}</span>
<input className="q-input" type="date" min={today} value={date} onChange={(e) => setDate(e.target.value)} />
</label>
)}
{error && <p className="q-error" role="alert">{error.text}</p>}
<button type="submit" className="bc-btn q-primary" disabled={busy}>
{busy ? t('landing.quiz.sending') : t('landing.quiz.next')}
</button>
</form>
);
} else if (step === 'package') {
body = (
<div className="q-form">
<h2 ref={heading} tabIndex={-1} className="q-title">
{t('landing.quiz.packageTitle')}
</h2>
<div className="q-choices">
{PACKAGES.map((p) => (
<button key={p} type="button" className="q-choice q-choice--tall" disabled={busy} onClick={() => choosePackage(p)}>
<span className="q-choice__head">
<b>{t(`landing.quiz.package_${p}`)}</b>
{p === 'CONSULT' && <span className="q-price">{consultPrice}</span>}
{p === 'TURNKEY' && <span className="q-price">{t('landing.quiz.package_TURNKEY_price')}</span>}
</span>
<span>{t(`landing.quiz.package_${p}_text`)}</span>
</button>
))}
</div>
{error && <p className="q-error" role="alert">{error.text}</p>}
</div>
);
} else if (step === 'custom') {
body = (
<form
className="q-form"
noValidate
onSubmit={(e) => {
e.preventDefault();
submitCustom();
}}
>
<h2 ref={heading} tabIndex={-1} className="q-title">
{t('landing.quiz.customTitle')}
</h2>
<Segmented label={t('landing.quiz.airport')} value={opts.airport} options={['none', 'standard', 'premium'] as const} text={(v) => t(`landing.quiz.airport_${v}`)} onChange={(v) => set('airport', v)} />
<Segmented label={t('landing.quiz.stay')} value={opts.stay} options={['none', 'flat', 'hotel'] as const} text={(v) => t(`landing.quiz.stay_${v}`)} onChange={(v) => set('stay', v)}>
{opts.stay !== 'none' && (
<fieldset className="q-budget">
<legend>{t('landing.quiz.budget')}</legend>
<label>
<span>{t('landing.quiz.budgetFrom')}</span>
<input className="q-input" inputMode="numeric" value={opts.stayBudgetFrom} onChange={(e) => set('stayBudgetFrom', e.target.value.replace(/\D/g, '').slice(0, 7))} placeholder="20 000" />
</label>
<label>
<span>{t('landing.quiz.budgetTo')}</span>
<input className="q-input" inputMode="numeric" value={opts.stayBudgetTo} onChange={(e) => set('stayBudgetTo', e.target.value.replace(/\D/g, '').slice(0, 7))} placeholder="40 000" />
</label>
</fieldset>
)}
</Segmented>
{YES_NO.map((k) => (
<Segmented
key={k}
label={t(`landing.quiz.${k}`)}
value={opts[k] ? 'yes' : 'no'}
options={['no', 'yes'] as const}
text={(v) => t(`landing.quiz.${v}`)}
onChange={(v) => set(k, v === 'yes')}
hint={opts[k] ? t('landing.quiz.yesHint') : undefined}
/>
))}
{error && <p className="q-error" role="alert">{error.text}</p>}
<button type="submit" className="bc-btn q-primary" disabled={busy}>
{busy ? t('landing.quiz.sending') : t('landing.quiz.submit')}
</button>
</form>
);
} else if (step === 'done') {
body = (
<div className="q-form q-done">
<span className="q-done__mark" aria-hidden="true">
<Icon name="check" size={30} strokeWidth={2.6} />
</span>
<h2 ref={heading} tabIndex={-1} className="q-title">
{t('landing.quiz.doneTitle', { number: lead?.number ?? '' })}
</h2>
<p className="q-lead">{t('landing.quiz.doneText', { contact: lead?.contact ?? '' })}</p>
<BotLink className="bc-btn q-primary">{t('landing.quiz.doneBot')}</BotLink>
<button type="button" className="q-link" onClick={close}>
{t('landing.quiz.doneClose')}
</button>
</div>
);
} else {
body = (
<div className="q-form">
<h2 ref={heading} tabIndex={-1} className="q-title" aria-live="polite">
{t('landing.quiz.movingToLive')}
</h2>
</div>
);
}
return (
<QuizCtx.Provider value={open}>
{children}
<dialog
ref={dialog}
className="q"
aria-label={t('landing.quiz.dialog')}
onClick={(e) => {
// клик по затемнению вокруг окна закрывает квиз; ответы остаются
if (e.target === e.currentTarget) close();
}}
>
<div className="q-panel">
<div className="q-top">
{index > 0 ? (
<button type="button" className="q-icon" onClick={back} aria-label={t('landing.quiz.back')}>
<Icon name="back" size={20} />
</button>
) : (
<span />
)}
{index < 0 ? (
<span />
) : (
<ol className="q-dots" aria-label={t('landing.quiz.progress', { n: index + 1, total })}>
{ORDER.slice(0, total).map((s, i) => (
<li key={s} data-state={i < index ? 'past' : i === index ? 'now' : 'next'} />
))}
</ol>
)}
<button type="button" className="q-icon" onClick={close} aria-label={t('landing.quiz.close')}>
<Icon name="close" size={20} />
</button>
</div>
<div key={step} className="q-step" data-dir={dir}>
{body}
</div>
</div>
</dialog>
</QuizCtx.Provider>
);
}
/** Строка конструктора: подпись и переключатель из 2–3 вариантов */
function Segmented<V extends string>({
label,
value,
options,
text,
onChange,
hint,
children,
}: {
label: string;
value: V;
options: readonly V[];
text: (v: V) => string;
onChange: (v: V) => void;
hint?: string;
children?: ReactNode;
}) {
return (
<div className="q-row">
<div className="q-row__head">
<span className="q-row__label">{label}</span>
<div className="q-seg" role="radiogroup" aria-label={label}>
{options.map((o) => (
<button key={o} type="button" role="radio" aria-checked={value === o} onClick={() => onChange(o)}>
{text(o)}
</button>
))}
</div>
</div>
{hint && <small className="q-row__hint">{hint}</small>}
{children}
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
/** Абзацы и списки из текста перевода: строки с «• » — пункты списка, остальные — абзацы */
export function RichText({ text }: { text: string }) {
const blocks: ({ kind: 'p'; text: string } | { kind: 'ul'; items: string[] })[] = [];
for (const line of text.split('\n')) {
if (line.startsWith('• ')) {
const last = blocks.at(-1);
if (last?.kind === 'ul') last.items.push(line.slice(2));
else blocks.push({ kind: 'ul', items: [line.slice(2)] });
} else if (line.trim()) blocks.push({ kind: 'p', text: line });
}
return (
<>
{blocks.map((b, i) =>
b.kind === 'p' ? (
<p key={i}>{b.text}</p>
) : (
<ul key={i}>
{b.items.map((it) => (
<li key={it}>{it}</li>
))}
</ul>
),
)}
</>
);
}
+86
View File
@@ -0,0 +1,86 @@
'use client';
import { discountFor, quote, type Locale } from '@barev/core';
import { formatAmd, tFromMessages, type Messages } from '@barev/i18n';
import { Stepper } from '@barev/ui';
import { useMemo, useState } from 'react';
import type { CleaningConfig } from '../lib/data';
const TYPES = ['regular', 'general', 'renovation'] as const;
/**
* Калькулятор экономии с Barev+: цена уборки — функцией quote, как на сервере, скидка — как у участника Barev+.
* Подписка вычитается из экономии; если выгоды нет, так и пишем и показываем, с какого числа уборок она появится.
*/
export function Savings({ locale, config, monthlyPrice, messages }: { locale: Locale; config: CleaningConfig; monthlyPrice: number; messages: Messages }) {
const t = useMemo(() => tFromMessages(locale, messages), [locale, messages]);
const amd = (n: number) => formatAmd(locale, n);
// стартовый сценарий: генеральная уборка трёшки дважды в месяц — дальше человек подставляет своё
const [type, setType] = useState<(typeof TYPES)[number]>('general');
const [rooms, setRooms] = useState(3);
const [count, setCount] = useState(2);
const pct = discountFor('BAREV_PLUS', config.categoryDiscountPct);
const params = { cleaningType: type, rooms, bathrooms: 1 };
const one = quote(config.pricing, params);
const oneWithPlus = quote(config.pricing, params, pct);
const without = one.totalPrice * count;
const withPlus = oneWithPlus.totalPrice * count + monthlyPrice;
const save = without - withPlus;
const perCleaning = one.totalPrice - oneWithPlus.totalPrice;
// выгода есть, когда скидка за n уборок больше цены подписки: n × perCleaning > monthlyPrice
const breakeven = perCleaning > 0 ? Math.floor(monthlyPrice / perCleaning) + 1 : null;
const max = Math.max(without, withPlus);
return (
<div className="v5-calc">
<div className="v5-inputs">
<div className="v5-seg" role="radiogroup" aria-label={t('params.cleaning.cleaningType.label')}>
{TYPES.map((v) => (
<button key={v} type="button" role="radio" aria-checked={type === v} onClick={() => setType(v)}>
{t(`params.cleaning.cleaningType.${v}`)}
</button>
))}
</div>
<div className="v5-row">
<span>{t('params.cleaning.rooms.label')}</span>
<Stepper value={rooms} min={1} max={6} onChange={setRooms} decLabel={t('common.decrease')} incLabel={t('common.increase')} />
</div>
<div className="v5-row">
<span>{t('landing.v5.perMonth')}</span>
<Stepper value={count} min={1} max={8} onChange={setCount} decLabel={t('common.decrease')} incLabel={t('common.increase')} />
</div>
</div>
<div className="v5-result" aria-live="polite">
<div className="v5-save">
<span className="v5-save__label">{save > 0 ? t('landing.v5.save') : t('landing.v5.noSave')}</span>
<span className="v5-save__num">{save > 0 ? amd(save) : '0 ֏'}</span>
{save > 0 && <span className="v5-save__year">{t('landing.v5.perYear', { amount: amd(save * 12) })}</span>}
</div>
<div className="v5-bars">
<div className="v5-bar">
<span className="v5-bar__label">{t('landing.v5.without')}</span>
<span className="v5-bar__track">
<span className="v5-bar__fill v5-bar__fill--without" style={{ width: `${(without / max) * 100}%` }} />
</span>
<span className="v5-bar__num">{amd(without)}</span>
</div>
<div className="v5-bar">
<span className="v5-bar__label">
{t('landing.v5.with')}
<small>{t('landing.v5.withNote', { price: amd(monthlyPrice) })}</small>
</span>
<span className="v5-bar__track">
<span className="v5-bar__fill v5-bar__fill--with" style={{ width: `${(withPlus / max) * 100}%` }} />
</span>
<span className="v5-bar__num">{amd(withPlus)}</span>
</div>
</div>
{breakeven !== null && (
<p className="v5-breakeven">{breakeven === 1 ? t('landing.v5.breakevenFirst') : t('landing.v5.breakeven', { count: breakeven })}</p>
)}
</div>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { createT, LOCALES, type Locale } from '@barev/i18n';
import type { LandingOffer } from '../lib/data';
import { CONTACTS, SITE } from '../lib/seo';
/** Телефон и почта для подвала: видны текстом, чтобы их могли прочитать люди, поисковики и ИИ */
export function FooterContacts() {
return (
<>
<a href={`tel:${CONTACTS.phone}`}>{CONTACTS.phoneDisplay}</a>
<a href={`mailto:${CONTACTS.email}`}>{CONTACTS.email}</a>
</>
);
}
/** Строка с реквизитами ИП — сигнал доверия для клиентов и E-E-A-T для Google */
export function LegalLine({ locale, className }: { locale: Locale; className?: string }) {
return <small className={className}>{createT(locale)('landing.footer.legal', { tin: CONTACTS.tin })}</small>;
}
/**
* Структурированные данные (JSON-LD) для Google и ИИ-поиска: кто мы, где работаем, на каких языках
* и сколько стоит подписка. Цены берутся из настроек админки, как и на самой странице.
*/
export function ClubJsonLd({ locale, path, bot, plusPrice, offer }: { locale: Locale; path: '' | '/live'; bot: string; plusPrice: number; offer: LandingOffer }) {
const t = createT(locale);
const org = `${SITE}/#org`;
const telegram = `https://t.me/${bot}`;
const { email, phone: telephone } = CONTACTS;
const plan = (name: string, price: number) => ({
'@type': 'Offer',
name,
price,
priceCurrency: 'AMD',
...(price > 0 && { priceSpecification: { '@type': 'UnitPriceSpecification', price, priceCurrency: 'AMD', unitCode: 'MON' } }),
url: `${SITE}/${locale}${path}#plans`,
});
const data = {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'Organization',
'@id': org,
name: 'Barev Club',
legalName: CONTACTS.legalName,
taxID: CONTACTS.tin,
url: SITE,
logo: `${SITE}/apple-icon`,
description: t('landing.seo.org'),
sameAs: [telegram],
email,
telephone,
address: { '@type': 'PostalAddress', addressLocality: 'Yerevan', addressCountry: 'AM' },
contactPoint: { '@type': 'ContactPoint', contactType: 'customer support', url: telegram, telephone, email, availableLanguage: [...LOCALES] },
},
{
'@type': 'ProfessionalService',
'@id': `${SITE}/#service`,
name: 'Barev Club',
url: `${SITE}/${locale}`,
image: `${SITE}/${locale}${path}/opengraph-image`,
description: t('landing.seo.org'),
parentOrganization: { '@id': org },
telephone,
email,
address: { '@type': 'PostalAddress', addressLocality: 'Yerevan', addressCountry: 'AM' },
areaServed: { '@type': 'City', name: 'Yerevan' },
availableLanguage: [...LOCALES],
currenciesAccepted: 'AMD',
hasOfferCatalog: {
'@type': 'OfferCatalog',
name: t('landing.club.title'),
itemListElement: [plan('Barev', 0), plan('Barev+', plusPrice), plan('Barev Elite', offer.elitePrice)],
},
},
{ '@type': 'WebSite', '@id': `${SITE}/#website`, url: SITE, name: 'Barev Club', inLanguage: locale, publisher: { '@id': org } },
],
};
// «<» экранируем, чтобы текст из настроек не мог закрыть тег script
return <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, '\\u003c') }} />;
}
+214
View File
@@ -0,0 +1,214 @@
import { createT, isLocale, type Locale } from '@barev/i18n';
import type { Metadata } from 'next';
import { getCatalog } from '../lib/data';
import { languageAlternates, SITE } from '../lib/seo';
import { range, SERVICE_PAGES, type ServicePageId } from '../lib/services';
import { BotLink, BotLinkProvider } from './client';
import { HomeFooter } from './home';
import { RichText } from './rich-text';
import { VariantHeader } from './variant';
/** Title, description, canonical и hreflang страницы услуги; цены в описании — из админки */
export async function serviceMetadata(params: Promise<{ locale: string }>, id: ServicePageId): Promise<Metadata> {
const { locale } = await params;
if (!isLocale(locale)) return {};
const cfg = SERVICE_PAGES[id];
const t = createT(locale);
const vars = cfg.vars(await getCatalog(), locale);
const k = `landing.svc.${id}`;
const title = t(`${k}.meta.title`, vars);
const description = t(`${k}.meta.description`, vars);
return {
title,
description,
alternates: { canonical: `/${locale}${cfg.path}`, languages: languageAlternates(cfg.path) },
openGraph: { title, description, locale, type: 'website', siteName: 'Barev Club' },
twitter: { card: 'summary_large_image', title, description },
};
}
/**
* Страница услуги под поисковый запрос: прямой ответ с ценой в первом экране, факты, шаги,
* подробности, цены и FAQ. Так её удобно читать людям и цитировать поиску и ИИ.
*/
export async function ServicePage({ locale, id }: { locale: Locale; id: ServicePageId }) {
const cfg = SERVICE_PAGES[id];
const t = createT(locale);
const catalog = await getCatalog();
const vars = cfg.vars(catalog, locale);
const k = `landing.svc.${id}`;
const c = (key: string) => t(`landing.svc.common.${key}`);
const tv = (key: string) => t(`${k}.${key}`, vars);
const others = Object.values(SERVICE_PAGES).filter((p) => p.id !== id);
const url = `${SITE}/${locale}${cfg.path}`;
const jsonLd = {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'Service',
'@id': `${url}#service`,
name: tv('h1'),
serviceType: cfg.serviceType,
description: tv('lead'),
url,
inLanguage: locale,
provider: { '@id': `${SITE}/#org` },
areaServed: { '@type': 'City', name: 'Yerevan' },
offers: cfg.offers(catalog).map((o) => ({
'@type': 'Offer',
name: o.name,
priceCurrency: 'AMD',
...(o.minPrice ? { priceSpecification: { '@type': 'PriceSpecification', minPrice: o.price, priceCurrency: 'AMD' } } : { price: o.price }),
url,
})),
},
{
'@type': 'FAQPage',
'@id': `${url}#faq`,
mainEntity: range(cfg.faq).map((n) => ({
'@type': 'Question',
name: tv(`faq.q${n}.q`),
acceptedAnswer: { '@type': 'Answer', text: tv(`faq.q${n}.a`) },
})),
},
{
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: c('home'), item: `${SITE}/${locale}` },
{ '@type': 'ListItem', position: 2, name: tv('crumb'), item: url },
],
},
],
};
return (
<BotLinkProvider locale={locale} bot={catalog.botUsername}>
<div className="v2">
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c') }} />
<div className="v-wrap">
<VariantHeader locale={locale} path={cfg.path} className="vh--dark" logoColor="#F6C3D3" />
<nav className="s-crumbs" aria-label={c('breadcrumbs')}>
<a href={`/${locale}`}>{c('home')}</a>
<span aria-hidden="true">/</span>
<span aria-current="page">{tv('crumb')}</span>
</nav>
<section className="s-hero">
<div className="s-hero__text">
<h1 className="s-title">{tv('h1')}</h1>
<p className="s-lead">{tv('lead')}</p>
<p className="s-updated">{c('updated')}</p>
</div>
<aside className="s-price" aria-label={tv('priceLabel')}>
<span className="s-price__label">{tv('priceLabel')}</span>
<span className="s-price__value">{tv('priceValue')}</span>
<span className="s-price__note">{tv('priceNote')}</span>
<BotLink className="bc-btn s-price__cta">{c('cta')}</BotLink>
<a href={`/${locale}`} className="s-price__more">
{c('ctaMove')} →
</a>
</aside>
</section>
<section className="s-facts" aria-label={c('factsTitle')}>
<h2 className="v-sr">{c('factsTitle')}</h2>
<dl>
{range(4).map((n) => (
<div key={n} className="s-fact">
<dt>{tv(`facts.f${n}.label`)}</dt>
<dd>{tv(`facts.f${n}.value`)}</dd>
</div>
))}
</dl>
</section>
<section className="s-section" aria-labelledby="steps-title">
<h2 id="steps-title" className="s-h2">
{tv('stepsTitle')}
</h2>
<ol className="v2-line s-steps">
{range(cfg.steps).map((n) => (
<li key={n} className="v2-step" data-live={n === 1}>
<div className="v2-step__when">{tv(`steps.s${n}.when`)}</div>
<div className="v2-step__body">
<h3>{tv(`steps.s${n}.title`)}</h3>
<p>{tv(`steps.s${n}.text`)}</p>
</div>
</li>
))}
</ol>
</section>
<div className="s-body">
{range(cfg.blocks).map((n) => (
<section key={n} className="s-block">
<h2 className="s-h2">{tv(`blocks.b${n}.title`)}</h2>
<RichText text={tv(`blocks.b${n}.body`)} />
</section>
))}
</div>
<section className="s-section" aria-labelledby="prices-title">
<h2 id="prices-title" className="s-h2">
{c('pricesTitle')}
</h2>
<ul className="h-price__list">
{range(cfg.prices).map((n) => (
<li key={n} className="h-price__row">
<b className="h-price__name">{tv(`prices.p${n}.name`)}</b>
<span className="h-price__dots" aria-hidden="true" />
<span className="h-price__value">{tv(`prices.p${n}.value`)}</span>
<small className="h-price__hint">{tv(`prices.p${n}.note`)}</small>
</li>
))}
</ul>
</section>
<section className="s-section" aria-labelledby="faq-title">
<h2 id="faq-title" className="s-h2">
{c('faqTitle')}
</h2>
<div className="s-faq">
{range(cfg.faq).map((n) => (
<details key={n} open={n === 1}>
<summary>
<h3>{tv(`faq.q${n}.q`)}</h3>
</summary>
<p>{tv(`faq.q${n}.a`)}</p>
</details>
))}
</div>
</section>
<section className="v2-final">
<div>
<h2>{tv('finalTitle')}</h2>
<p>{tv('finalText')}</p>
</div>
<BotLink className="bc-btn v2-final__cta">{c('cta')}</BotLink>
</section>
<section className="s-meta">
<div>
<h2 className="s-h3">{c('relatedTitle')}</h2>
<ul className="s-related">
{others.map((p) => (
<li key={p.id}>
<a href={`/${locale}${p.path}`}>{t(`landing.svc.${p.id}.crumb`)}</a>
</li>
))}
<li>
<a href={`/${locale}/clean`}>{c('clean')}</a>
</li>
</ul>
</div>
</section>
<HomeFooter locale={locale} />
</div>
</div>
</BotLinkProvider>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { createT, isLocale, LOCALES, type Locale } from '@barev/i18n';
import { Logo } from '@barev/ui';
import type { Metadata } from 'next';
import { BotLink, LangMenu, LangSwitch } from './client';
/** Экспериментальные версии лендинга (/land1…3): своя каноническая ссылка и noindex, чтобы не конкурировать с главной в поиске */
export async function variantMetadata(params: Promise<{ locale: string }>, path: string, titleKey: string): Promise<Metadata> {
const { locale } = await params;
if (!isLocale(locale)) return {};
const t = createT(locale);
return {
title: `${t(titleKey)} — Barev Club`,
description: t('landing.meta.description'),
robots: { index: false, follow: true },
alternates: { canonical: `/${locale}${path}`, languages: Object.fromEntries(LOCALES.map((l) => [l, `/${l}${path}`])) },
};
}
/** Шапка версий лендинга: логотип ведёт на главную, языки остаются на той же версии */
export function VariantHeader({ locale, path, className, logoColor }: { locale: Locale; path: string; className?: string; logoColor?: string }) {
const t = createT(locale);
return (
<header className={`vh ${className ?? ''}`}>
<a href={`/${locale}`} aria-label="Barev Club" className="vh__logo">
<Logo color={logoColor} />
</a>
<div className="vh__right">
<div className="vh__langs">
<LangSwitch current={locale} path={path} />
</div>
<LangMenu current={locale} path={path} />
<BotLink className="bc-btn bc-btn--sm vh__cta">
<span className="tg-long">{t('landing.openTelegram')}</span>
<span className="tg-short">Telegram</span>
</BotLink>
</div>
</header>
);
}