Apps, web, i18n, UI, partners, payouts, backoffice, bariki, prod infra
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
45d7512d2b
commit
3489e0b816
319 files changed
+45483
-121
No files matched your search
@@ -0,0 +1,8 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
/** /apartments → /ru/apartments (страница квартир есть только на русском); ?a= и UTM сохраняются */
|
||||
export default async function ApartmentsRedirect({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(await searchParams)) if (typeof v === 'string') qs.set(k, v);
|
||||
redirect(`/ru/apartments${qs.size ? `?${qs}` : ''}`);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirectToLocale } from '../../../lib/redirect';
|
||||
|
||||
/** barevclub.com/clean → /<язык>/clean — короткий адрес для рекламы и QR */
|
||||
export default async function CleanRedirect({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
return redirectToLocale(searchParams, '/clean');
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
/** /flower → /ru/flower (страница цветов пока только на русском); ?a= и UTM сохраняются */
|
||||
export default async function FlowerRedirect({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(await searchParams)) if (typeof v === 'string') qs.set(k, v);
|
||||
redirect(`/ru/flower${qs.size ? `?${qs}` : ''}`);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirectToLocale } from '../../../lib/redirect';
|
||||
|
||||
/** /land1 → /<язык>/land1 */
|
||||
export default async function Land1Redirect({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
return redirectToLocale(searchParams, '/land1');
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirectToLocale } from '../../../lib/redirect';
|
||||
|
||||
/** /land2 → /<язык>/land2 */
|
||||
export default async function Land2Redirect({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
return redirectToLocale(searchParams, '/land2');
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirectToLocale } from '../../../lib/redirect';
|
||||
|
||||
/** /land3 → /<язык>/land3 */
|
||||
export default async function Land3Redirect({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
return redirectToLocale(searchParams, '/land3');
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirectToLocale } from '../../../lib/redirect';
|
||||
|
||||
/** /land4 → /<язык>/land4 */
|
||||
export default async function Land4Redirect({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
return redirectToLocale(searchParams, '/land4');
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirectToLocale } from '../../../lib/redirect';
|
||||
|
||||
/** /land5 → /<язык>/land5 */
|
||||
export default async function Land5Redirect({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
return redirectToLocale(searchParams, '/land5');
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirectToLocale } from '../../../lib/redirect';
|
||||
|
||||
/** /land6 → /<язык>/land6 */
|
||||
export default async function Land6Redirect({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
return redirectToLocale(searchParams, '/land6');
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
// Отдельный root layout для «/», который только перенаправляет на язык
|
||||
export default function RedirectLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="ru">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirectToLocale } from '../../../lib/redirect';
|
||||
|
||||
/** /live → /<язык>/live */
|
||||
export default async function LiveRedirect({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
return redirectToLocale(searchParams, '/live');
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirectToLocale } from '../../lib/redirect';
|
||||
|
||||
/** «/» → /<язык> */
|
||||
export default async function Root({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
return redirectToLocale(searchParams);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createT, isLocale, LOCALES } from '@barev/i18n';
|
||||
import { OG_SIZE, ogImage } from '../../../lib/og';
|
||||
|
||||
export const size = OG_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
/** OG-картинка страницы /airport — заголовок H1 */
|
||||
export default async function OgImage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
return ogImage(createT(isLocale(locale) ? locale : 'ru')('landing.svc.airport.h1'));
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import '../../variants.css';
|
||||
import '../../home.css';
|
||||
import '../../service.css';
|
||||
import { isLocale, LOCALES } from '@barev/i18n';
|
||||
import type { Viewport } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ServicePage, serviceMetadata } from '../../../components/service-page';
|
||||
|
||||
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 serviceMetadata(params, 'airport');
|
||||
}
|
||||
export const viewport: Viewport = { themeColor: '#4A1028' };
|
||||
|
||||
/** /airport — страница услуги под поисковый запрос; тексты landing.svc.airport, цены из админки */
|
||||
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
return <ServicePage locale={locale} id="airport" />;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import '../../variants.css';
|
||||
import '../../home.css';
|
||||
import '../../service.css';
|
||||
import '../../flats.css';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { Flats } from '../../../components/flats';
|
||||
import { HomeFooter } from '../../../components/home';
|
||||
import { VariantHeader } from '../../../components/variant';
|
||||
import { getCatalog } from '../../../lib/data';
|
||||
import { getFlats } from '../../../lib/flats';
|
||||
|
||||
// Квартиры из CRM меняются в течение дня — страница собирается на каждый запрос, список кешируется на минуту.
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const dynamicParams = false;
|
||||
/** Только русская версия (решение владельца, 20.10.2026) */
|
||||
export function generateStaticParams() {
|
||||
return [{ locale: 'ru' }];
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Квартиры в аренду в Ереване — запись на просмотр | Barev Club',
|
||||
description: 'Проверенные квартиры от агентства-партнёра: фото, описание, цена. Выберите квартиру и удобный день — агент позвонит и покажет её.',
|
||||
alternates: { canonical: '/ru/apartments' },
|
||||
openGraph: {
|
||||
title: 'Квартиры в аренду в Ереване',
|
||||
locale: 'ru',
|
||||
type: 'website',
|
||||
siteName: 'Barev Club',
|
||||
},
|
||||
};
|
||||
export const viewport: Viewport = { themeColor: '#4A1028' };
|
||||
|
||||
/** /ru/apartments — «Найдём квартиру»: объекты AREV из CRM и запись на просмотр */
|
||||
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
if (locale !== 'ru') notFound();
|
||||
const [flats, catalog] = await Promise.all([getFlats(), getCatalog()]);
|
||||
const telegramUrl = `https://t.me/${catalog.botUsername}`;
|
||||
|
||||
return (
|
||||
<div className="v2">
|
||||
<div className="v-wrap">
|
||||
<VariantHeader locale="ru" path="" className="vh--dark" logoColor="#F6C3D3" />
|
||||
|
||||
<nav className="s-crumbs" aria-label="Навигация">
|
||||
<a href="/ru">Главная</a>
|
||||
<span aria-hidden="true">/</span>
|
||||
<span aria-current="page">Квартиры</span>
|
||||
</nav>
|
||||
|
||||
<section className="fl-hero">
|
||||
<h1 className="s-title">Найдём квартиру</h1>
|
||||
<p className="s-lead">
|
||||
Квартиры, которые агент уже проверил и согласовал с собственником. Листайте фото, читайте описание и выберите день — агент позвонит, подтвердит
|
||||
время и покажет квартиру.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{flats?.length ? (
|
||||
<Flats flats={flats} telegramUrl={telegramUrl} />
|
||||
) : (
|
||||
<section className="fl-empty">
|
||||
<h2>{flats ? 'Сейчас свободных квартир нет' : 'Список квартир временно недоступен'}</h2>
|
||||
<p>Напишите нам в Telegram — подберём варианты под ваш бюджет и район.</p>
|
||||
<a className="bc-btn fl-book" href={telegramUrl}>
|
||||
Написать в Telegram
|
||||
</a>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<HomeFooter locale="ru" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createT, isLocale, LOCALES } from '@barev/i18n';
|
||||
import { OG_SIZE, ogImage } from '../../../lib/og';
|
||||
|
||||
export const size = OG_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
/** OG-картинка страницы заказа уборки */
|
||||
export default async function OgImage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const t = createT(isLocale(locale) ? locale : 'ru');
|
||||
return ogImage(t('landing.clean.meta.title').replace(/ — Barev Club$/, ''));
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import '../../clean.css';
|
||||
import { createT, isLocale, LOCALES, pickMessages, type Locale } from '@barev/i18n';
|
||||
import { Logo } from '@barev/ui';
|
||||
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';
|
||||
|
||||
export const revalidate = 300;
|
||||
export const dynamicParams = false;
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
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('landing.clean.meta.title'),
|
||||
description: t('landing.clean.meta.description'),
|
||||
alternates: {
|
||||
canonical: `/${locale}/clean`,
|
||||
languages: Object.fromEntries([...LOCALES.map((l) => [l, `/${l}/clean`]), ['x-default', '/ru/clean']]),
|
||||
},
|
||||
openGraph: { title: t('landing.clean.meta.title'), description: t('landing.clean.meta.description'), locale, type: 'website', siteName: 'Barev Club' },
|
||||
};
|
||||
}
|
||||
|
||||
const ZONES = ['kitchen', 'bathroom', 'rooms'] as const;
|
||||
|
||||
/** barevclub.com/clean — заказ уборки с сайта по модели qlean.ru */
|
||||
export default async function CleanPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale: raw } = await params;
|
||||
if (!isLocale(raw)) notFound();
|
||||
const locale: Locale = raw;
|
||||
const t = createT(locale);
|
||||
const config = await getCleaning();
|
||||
const messages = pickMessages(locale, ['landing.clean.', 'params.cleaning.', 'extras.', 'districts.', 'errors.', 'common.']);
|
||||
|
||||
return (
|
||||
<BotLinkProvider locale={locale} bot={config.botUsername}>
|
||||
<div className="wrap cl-wrap">
|
||||
<header className="header">
|
||||
<a href={`/${locale}`} aria-label={t('landing.clean.home')} style={{ display: 'inline-flex' }}>
|
||||
<Logo />
|
||||
</a>
|
||||
<nav className="nav" aria-label={t('landing.nav.label')}>
|
||||
<a href={`/${locale}`}>{t('landing.clean.home')}</a>
|
||||
<a href="#included">{t('landing.clean.includedTitle')}</a>
|
||||
<a href="#how">{t('landing.clean.howTitle')}</a>
|
||||
</nav>
|
||||
<div className="langs">
|
||||
<span className="langs__row">
|
||||
<LangSwitch current={locale} path="/clean" />
|
||||
</span>
|
||||
<LangMenu current={locale} path="/clean" />
|
||||
<BotLink className="bc-btn bc-btn--dark bc-btn--sm" style={{ marginLeft: 6 }}>
|
||||
<span className="tg-long">{t('landing.openTelegram')}</span>
|
||||
<span className="tg-short">Telegram</span>
|
||||
</BotLink>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<CleanForm locale={locale} config={config} messages={messages} />
|
||||
|
||||
<section id="included" className="cl-included">
|
||||
<h2 className="cl-section-title">{t('landing.clean.includedTitle')}</h2>
|
||||
<div className="cl-zones">
|
||||
{ZONES.map((z) => (
|
||||
<div key={z} className="cl-zone">
|
||||
<h3>{t(`landing.clean.zone.${z}`)}</h3>
|
||||
<ul>
|
||||
{t(`landing.clean.inc.${z}`)
|
||||
.split('\n')
|
||||
.map((line) => (
|
||||
<li key={line}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="cl-variants">
|
||||
<p>{t('landing.clean.general')}</p>
|
||||
<p>{t('landing.clean.renovation')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="how" className="how cl-how">
|
||||
<h2 className="bc-h2" style={{ fontSize: 34 }}>
|
||||
{t('landing.clean.howTitle')}
|
||||
</h2>
|
||||
<ol className="cl-how__grid">
|
||||
{([1, 2, 3] as const).map((n) => (
|
||||
<li key={n} className="how__step">
|
||||
<div className="how__num" style={{ background: n === 1 ? 'var(--bc-home)' : n === 2 ? 'var(--bc-blue)' : 'var(--bc-pink)', color: 'var(--bc-ink)' }}>
|
||||
{n}
|
||||
</div>
|
||||
<div style={{ fontWeight: 600 }}>{t(`landing.clean.how${n}`)}</div>
|
||||
<div style={{ fontSize: 13, color: '#CFC8C2' }}>{t(`landing.clean.how${n}Sub`)}</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<footer className="footer">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ fontFamily: 'var(--bc-font-head)', fontWeight: 700, color: 'var(--bc-plum)' }}>{t('landing.footer.brand')}</span>
|
||||
<LegalLine locale={locale} />
|
||||
</div>
|
||||
<div className="footer__links">
|
||||
<a href={`/${locale}`}>{t('landing.clean.home')}</a>
|
||||
<FooterContacts />
|
||||
<LegalLinks locale={locale} />
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</BotLinkProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import '../../variants.css';
|
||||
import '../../home.css';
|
||||
import '../../service.css';
|
||||
import '../../flower.css';
|
||||
import { Flower } from '@barev/ui';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { BotLink, BotLinkProvider } from '../../../components/client';
|
||||
import { FlowerPlans, NextDelivery } from '../../../components/flowers';
|
||||
import { HomeFooter } from '../../../components/home';
|
||||
import { VariantHeader } from '../../../components/variant';
|
||||
import { getCatalog } from '../../../lib/data';
|
||||
|
||||
export const revalidate = 300;
|
||||
export const dynamicParams = false;
|
||||
/** Пока только русская версия — как /apartments */
|
||||
export function generateStaticParams() {
|
||||
return [{ locale: 'ru' }];
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Цветы по подписке в Ереване — свежий букет каждую неделю | Barev Club',
|
||||
description: 'Сезонные букеты S, M и L по подписке: раз в неделю, раз в 2 недели или раз в месяц. Цветы из армянских теплиц, доставка по Еревану бесплатно, пауза в один клик.',
|
||||
alternates: { canonical: '/ru/flower' },
|
||||
openGraph: { title: 'Цветы по подписке в Ереване', locale: 'ru', type: 'website', siteName: 'Barev Club' },
|
||||
};
|
||||
export const viewport: Viewport = { themeColor: '#4A1028' };
|
||||
|
||||
const STEPS = [
|
||||
{ when: 'Сегодня', title: 'Выберите размер и частоту', text: 'S, M или L — раз в неделю, раз в 2 недели или раз в месяц. Пожелания вроде «без лилий» или «светлые тона» напишите в чат.' },
|
||||
{ when: 'За день', title: 'Флорист соберёт букет', text: 'Из того, что сейчас самое свежее: цветы приезжают с теплиц Гохта и Армавира за 1–2 дня до вас. Каждый раз новый букет.' },
|
||||
{ when: 'В ваш день', title: 'Привезём домой или в офис', text: 'Утром, 9–13, или вечером, 17–21. Курьер позвонит за 30 минут. Доставка по Еревану бесплатно.' },
|
||||
{ when: 'Когда угодно', title: 'Пауза или отмена в один клик', text: 'Уезжаете — поставьте подписку на паузу в Telegram за 48 часов до доставки. Без звонков и штрафов.' },
|
||||
];
|
||||
|
||||
const SEASONS = [
|
||||
{ tone: 'learn', name: 'Весна', text: 'Тюльпаны, ранункулюсы, мимоза и гиацинты. К 8 марта и 7 апреля — особые букеты.' },
|
||||
{ tone: 'pink', name: 'Начало лета', text: 'Пионы, пионовидные розы, альстромерия и полевые цветы.' },
|
||||
{ tone: 'perks', name: 'Лето', text: 'Подсолнухи, эустома, гортензия и розы на пике сезона.' },
|
||||
{ tone: 'docs', name: 'Осень и зима', text: 'Георгины, хризантемы, гиперикум, эвкалипт и тепличные розы.' },
|
||||
] as const;
|
||||
|
||||
const FAQ = [
|
||||
['Можно выбрать цветы самому?', 'Состав подбирает флорист из того, что сейчас в сезоне, — поэтому букеты свежее и каждый раз разные. Пожелания и аллергии учтём.'],
|
||||
['Когда списываются деньги?', 'За 2 дня до каждой доставки. Предоплаты за месяц нет. Пакеты на 3, 6 и 12 месяцев оплачиваются сразу.'],
|
||||
['Как поставить на паузу или отменить?', 'В Telegram, не позже чем за 48 часов до доставки. Пауза — на любой срок, отмена — без штрафов.'],
|
||||
['Куда вы доставляете?', 'По всему Еревану бесплатно. В Абовян, Эчмиадзин и Масис — за 2 000 ֏.'],
|
||||
['Что, если меня не будет дома?', 'Курьер позвонит за 30 минут. Оставим букет консьержу или соседу либо перенесём доставку на следующий день.'],
|
||||
['Зимой букеты будут такими же?', 'Размер и количество стеблей не меняются. Зимой в букетах больше тепличных роз, хризантем и эвкалипта.'],
|
||||
] as const;
|
||||
|
||||
/** /ru/flower — цветы по подписке: тарифы S/M/L, оформление в Telegram */
|
||||
export default async function FlowerPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
if (locale !== 'ru') notFound();
|
||||
const catalog = await getCatalog();
|
||||
|
||||
return (
|
||||
<BotLinkProvider locale="ru" bot={catalog.botUsername}>
|
||||
<div className="v2">
|
||||
<div className="v-wrap">
|
||||
<VariantHeader locale="ru" path="" className="vh--dark" logoColor="#F6C3D3" />
|
||||
|
||||
<nav className="s-crumbs" aria-label="Навигация">
|
||||
<a href="/ru">Главная</a>
|
||||
<span aria-hidden="true">/</span>
|
||||
<span aria-current="page">Цветы</span>
|
||||
</nav>
|
||||
|
||||
<section className="v2-hero fw-hero">
|
||||
<div className="v2-word fw-word" aria-hidden="true">
|
||||
Цветы
|
||||
<span className="fw-word__sticker">
|
||||
<Flower size={112} color="#F7DA6E" textColor="#4A1028" rotate={-12} fontSize={17}>
|
||||
{'от\n8 900 ֏'}
|
||||
</Flower>
|
||||
</span>
|
||||
</div>
|
||||
<ul className="v2-hello" aria-hidden="true">
|
||||
<li>каждую неделю</li>
|
||||
<li>раз в 2 недели</li>
|
||||
<li>раз в месяц</li>
|
||||
</ul>
|
||||
<div className="v2-hero__text">
|
||||
<h1 className="v2-title">Свежие цветы дома — по подписке</h1>
|
||||
<div className="h-hero__side">
|
||||
<p className="v2-sub">Флорист соберёт сезонный букет, а мы привезём его в удобный день. Вам останется поставить цветы в вазу.</p>
|
||||
<div className="h-actions">
|
||||
<a href="#plans" className="bc-btn h-cta">
|
||||
Выбрать букет
|
||||
</a>
|
||||
<BotLink className="bc-btn h-cta-ghost">Написать в Telegram</BotLink>
|
||||
</div>
|
||||
<NextDelivery />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ol className="v2-line" aria-label="Как это работает">
|
||||
{STEPS.map((s, i) => (
|
||||
<li key={s.when} className="v2-step" data-live={i === 0}>
|
||||
<div className="v2-step__when">{s.when}</div>
|
||||
<div className="v2-step__body">
|
||||
<h2>{s.title}</h2>
|
||||
<p>{s.text}</p>
|
||||
</div>
|
||||
<div className="v2-step__meta">{i === 2 && <span className="v2-chip">0 ֏ доставка</span>}</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<FlowerPlans />
|
||||
|
||||
<section className="fw-section" aria-labelledby="season-title">
|
||||
<div className="h-section-head">
|
||||
<h2 id="season-title">Что будет в букетах</h2>
|
||||
<p>Берём то, что сейчас в сезоне в армянских теплицах, поэтому цветы свежее и стоят дольше.</p>
|
||||
</div>
|
||||
<div className="fw-seasons">
|
||||
{SEASONS.map((s) => (
|
||||
<div key={s.name} className={`fw-season bc-tone-${s.tone}`}>
|
||||
<h3>{s.name}</h3>
|
||||
<p>{s.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="fw-duo">
|
||||
<div className="fw-gift">
|
||||
<span className="fw-kicker">Для тех, кто далеко</span>
|
||||
<h2>Цветы маме в Ереван — каждый месяц</h2>
|
||||
<p>Оплачиваете из любой страны, мы вручаем букет с вашей открыткой и присылаем фото в WhatsApp или Telegram. Пакеты на 3, 6 и 12 месяцев.</p>
|
||||
<div className="fw-gift__prices">
|
||||
<div>
|
||||
<b>$39</b>
|
||||
<span>букет M</span>
|
||||
</div>
|
||||
<div>
|
||||
<b>$59</b>
|
||||
<span>букет L</span>
|
||||
</div>
|
||||
<div>
|
||||
<b>$89</b>
|
||||
<span>L + ваза и сладости</span>
|
||||
</div>
|
||||
</div>
|
||||
<BotLink className="bc-btn fw-gift__cta">Подарить подписку</BotLink>
|
||||
</div>
|
||||
<div className="fw-guarantee">
|
||||
<Flower size={84} color="#1F3311" textColor="#A8C38A" rotate={8} fontSize={15}>
|
||||
100%
|
||||
</Flower>
|
||||
<h2>Первый букет без риска</h2>
|
||||
<p>Не понравится — пришлите фото в течение суток, и мы вернём деньги полностью. Если букет простоял меньше 5 дней, заменим его бесплатно.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="fw-faq-wrap" aria-labelledby="faq-title">
|
||||
<div className="h-section-head">
|
||||
<h2 id="faq-title">Частые вопросы</h2>
|
||||
</div>
|
||||
<div className="faq fw-faq">
|
||||
{FAQ.map(([q, a]) => (
|
||||
<details key={q}>
|
||||
<summary>{q}</summary>
|
||||
<p>{a}</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="v2-final">
|
||||
<div>
|
||||
<h2>Первый букет — уже на этой неделе</h2>
|
||||
<p>Напишите в Telegram размер и частоту — подтвердим день доставки и пришлём ссылку на оплату. Цена для первых 100 подписчиков сохранится, даже когда вырастет.</p>
|
||||
</div>
|
||||
<BotLink className="bc-btn v2-final__cta">Оформить подписку</BotLink>
|
||||
</section>
|
||||
|
||||
<HomeFooter locale="ru" />
|
||||
</div>
|
||||
</div>
|
||||
</BotLinkProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { createT, formatAmd, isLocale, LOCALES, type Locale } from '@barev/i18n';
|
||||
import { CATEGORY_ICON, Flower, Icon, Logo, type CategoryCode } from '@barev/ui';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { BotLink, BotLinkProvider, LangSwitch } from '../../../components/client';
|
||||
import { getCatalog } from '../../../lib/data';
|
||||
|
||||
export const revalidate = 300;
|
||||
export const dynamicParams = false;
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
/** Прежняя главная (до октября 2026) — сохранена для сравнения, в поиск не попадает */
|
||||
export const metadata = { robots: { index: false, follow: true } };
|
||||
|
||||
const TONE: Record<CategoryCode, { bg: string; ink: string }> = {
|
||||
HOME: { bg: 'var(--bc-home)', ink: 'var(--bc-home-ink)' },
|
||||
DOCS: { bg: 'var(--bc-docs)', ink: 'var(--bc-docs-ink)' },
|
||||
LEARNING: { bg: 'var(--bc-learn)', ink: 'var(--bc-learn-ink)' },
|
||||
PERKS: { bg: 'var(--bc-perks)', ink: 'var(--bc-perks-ink)' },
|
||||
};
|
||||
|
||||
/** Прежняя главная: лендинг v2 · плотный (LND-1) */
|
||||
export default async function Landing({ 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 catalog = await getCatalog();
|
||||
const cat = (code: CategoryCode) => catalog.categories.find((c) => c.code === code);
|
||||
const cleaningFrom = cat('HOME')?.services.find((s) => s.code === 'cleaning')?.fromPrice ?? 0;
|
||||
const maxDiscount = Math.max(0, ...catalog.categories.map((c) => c.barevPlusDiscountPct));
|
||||
const discounts = catalog.categories.filter((c) => c.barevPlusDiscountPct > 0);
|
||||
const preview = [
|
||||
{ tone: 'var(--bc-blue)', ink: 'var(--bc-blue-ink)', title: t('landing.preview.cleaningRegular'), meta: t('landing.preview.cleaningRegularMeta'), badge: t('status.CLEANING.EN_ROUTE') },
|
||||
{ tone: 'var(--bc-home)', ink: 'var(--bc-home-ink)', title: t('landing.preview.cleaning'), meta: t('landing.preview.cleaningMeta'), badge: t('status.CLEANING.ASSIGNED') },
|
||||
{ tone: 'var(--bc-docs)', ink: 'var(--bc-docs-ink)', title: t('services.social_card.name'), meta: t('status.DOCUMENTS.DOCS_REVIEW'), badge: t('landing.preview.inWork') },
|
||||
{ tone: 'var(--bc-learn)', ink: 'var(--bc-learn-ink)', title: t('landing.preview.lesson'), meta: t('landing.preview.lessonMeta'), badge: t('landing.preview.today') },
|
||||
];
|
||||
const faq = ['install', 'languages', 'plus', 'gift'] as const;
|
||||
const check = (color: string) => <Icon name="check" size={18} strokeWidth={2.6} style={{ color }} />;
|
||||
|
||||
return (
|
||||
<BotLinkProvider locale={locale} bot={catalog.botUsername}>
|
||||
<div className="wrap">
|
||||
<header className="header">
|
||||
<Logo />
|
||||
<nav className="nav" aria-label={t('landing.nav.label')}>
|
||||
<a href="#services">{t('landing.nav.services')}</a>
|
||||
<a href="#how">{t('landing.nav.how')}</a>
|
||||
<a href="#plans">{t('landing.nav.plans')}</a>
|
||||
<a href="#faq">{t('landing.nav.faq')}</a>
|
||||
</nav>
|
||||
<div className="langs">
|
||||
<LangSwitch current={locale} path="/land0" />
|
||||
<BotLink className="bc-btn bc-btn--dark bc-btn--sm" style={{ marginLeft: 6 }}>
|
||||
{t('landing.openTelegram')}
|
||||
</BotLink>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="row">
|
||||
<div className="hero">
|
||||
<div className="hero__tag">{t('landing.hero.tag')}</div>
|
||||
<h1 className="bc-h1">{t('landing.hero.title')}</h1>
|
||||
<p className="hero__sub">{t('landing.hero.sub')}</p>
|
||||
<div className="row" style={{ gap: 10 }}>
|
||||
<BotLink className="bc-btn bc-btn--dark" style={{ height: 56, padding: '0 28px' }}>
|
||||
{t('landing.hero.cta')}
|
||||
<Icon name="arrow" size={18} strokeWidth={2.2} />
|
||||
</BotLink>
|
||||
<a href="#services" className="bc-btn bc-btn--light" style={{ height: 56 }}>
|
||||
{t('landing.hero.services')}
|
||||
</a>
|
||||
</div>
|
||||
{/* сейчас заказать можно уборку — короткий путь на форму /clean */}
|
||||
<a href={`/${locale}/clean`} className="clean-promo">
|
||||
<span className="clean-promo__text">
|
||||
<b>{t('landing.hero.cleanTitle', { price: amd(cleaningFrom) })}</b>
|
||||
<span>{t('landing.hero.cleanText')}</span>
|
||||
</span>
|
||||
<span className="bc-btn bc-btn--accent clean-promo__cta">{t('landing.hero.cleanCta')}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="side">
|
||||
<div className="bc-card" style={{ borderRadius: 36, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div className="bc-between" style={{ padding: '0 4px 6px' }}>
|
||||
<div className="bc-h3" style={{ fontSize: 20 }}>
|
||||
{t('landing.preview.title')}
|
||||
</div>
|
||||
<div className="bc-small bc-muted">{t('landing.preview.inApp')}</div>
|
||||
</div>
|
||||
{preview.map((p) => (
|
||||
<div key={p.title} className="preview-row" style={{ background: p.tone, color: p.ink }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{p.title}</div>
|
||||
<div style={{ fontSize: 12 }}>{p.meta}</div>
|
||||
</div>
|
||||
<span className="bc-badge">{p.badge}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="bc-card bc-tone-perks" style={{ flex: 1, borderRadius: 30, padding: 18, display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Flower size={72} rotate={-10}>
|
||||
5
|
||||
</Flower>
|
||||
<div className="langs-sticker">
|
||||
<span>{t('landing.languages')}</span>
|
||||
<span className="langs-sticker__list">RU EN 中文 HY ES</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bc-card bc-tone-learn" style={{ flex: 1, borderRadius: 30, padding: 18, display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 4 }}>
|
||||
<div style={{ fontFamily: 'var(--bc-font-head)', fontWeight: 700, fontSize: 18 }}>{t('landing.noInstall')}</div>
|
||||
<div className="bc-small">{t('landing.noInstallSub')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="services" className="row">
|
||||
{(['HOME', 'DOCS', 'LEARNING'] as const).map((code) => (
|
||||
<div key={code} className="dir" style={{ background: TONE[code].bg, color: TONE[code].ink }}>
|
||||
<div className="bc-between">
|
||||
<h2 className="dir__title" style={{ margin: 0 }}>
|
||||
{t(`categories.${code}.name`)}
|
||||
</h2>
|
||||
<Icon name={CATEGORY_ICON[code]} size={28} />
|
||||
</div>
|
||||
<div className="bc-stack" style={{ gap: 6 }}>
|
||||
{(cat(code)?.services ?? []).map((s) =>
|
||||
s.code === 'cleaning' && s.active ? (
|
||||
<a key={s.code} href={`/${locale}/clean`} className="bc-row-pill">
|
||||
<span style={{ fontWeight: 600 }}>{t(`services.${s.code}.name`)}</span>
|
||||
<span>{t('common.from', { price: amd(s.fromPrice) })}</span>
|
||||
</a>
|
||||
) : (
|
||||
<div key={s.code} className="bc-row-pill" data-soon={!s.active}>
|
||||
<span style={{ fontWeight: 600 }}>{t(`services.${s.code}.name`)}</span>
|
||||
<span>{s.active ? t('common.from', { price: amd(s.fromPrice) }) : t('common.soon')}</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 14 }}>{t(`landing.dir.${code}`)}</div>
|
||||
{code === 'HOME' ? (
|
||||
<a href={`/${locale}/clean`} style={{ marginTop: 'auto', fontWeight: 700, color: TONE[code].ink }}>
|
||||
{t('landing.hero.cleanCta')} →
|
||||
</a>
|
||||
) : (
|
||||
<BotLink style={{ marginTop: 'auto', fontWeight: 700, color: TONE[code].ink }}>
|
||||
{(cat(code)?.services ?? []).some((s) => s.active) ? t(`landing.dir.${code}_cta`) : t('landing.dir.soonCta')} →
|
||||
</BotLink>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="dir" style={{ background: TONE.PERKS.bg, color: TONE.PERKS.ink }}>
|
||||
<div className="bc-between">
|
||||
<h2 className="dir__title" style={{ margin: 0 }}>
|
||||
{t('categories.PERKS.name')}
|
||||
</h2>
|
||||
<Icon name="gift" size={28} />
|
||||
</div>
|
||||
<div className="perks-big">{t('landing.perks.upTo', { pct: maxDiscount })}</div>
|
||||
<div className="bc-chips">
|
||||
{(['cafes', 'restaurants', 'gifts'] as const).map((k) => (
|
||||
<span key={k} className="bc-badge" style={{ padding: '8px 14px', fontSize: 14, fontWeight: 500 }}>
|
||||
{t(`landing.perks.${k}`)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 14 }}>{t('landing.perks.text')}</div>
|
||||
<BotLink style={{ marginTop: 'auto', fontWeight: 700, color: TONE.PERKS.ink }}>{t('landing.perks.cta')} →</BotLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="how" className="row">
|
||||
<div className="how">
|
||||
<h2 className="bc-h2" style={{ fontSize: 34 }}>
|
||||
<span style={{ fontWeight: 700 }}>{t('landing.how.titleBold')}</span> <span className="bc-italic">{t('landing.how.titleItalic')}</span>
|
||||
</h2>
|
||||
<div className="how__grid">
|
||||
{[
|
||||
{ bg: 'var(--bc-pink)', ink: 'var(--bc-plum)' },
|
||||
{ bg: 'var(--bc-perks)', ink: 'var(--bc-plum)' },
|
||||
{ bg: 'var(--bc-home)', ink: 'var(--bc-home-ink)' },
|
||||
{ bg: 'var(--bc-blue)', ink: 'var(--bc-blue-ink)' },
|
||||
].map((c, i) => (
|
||||
<div key={i} className="how__step">
|
||||
<div className="how__num" style={{ background: c.bg, color: c.ink }}>
|
||||
{i + 1}
|
||||
</div>
|
||||
<div style={{ fontWeight: 600 }}>{t(`landing.how.step${i + 1}`)}</div>
|
||||
<div style={{ fontSize: 13, color: '#CFC8C2' }}>{t(`landing.how.step${i + 1}_sub`)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="agency">
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ fontFamily: 'var(--bc-font-head)', fontWeight: 800, fontSize: 23, lineHeight: 1.1, textWrap: 'balance', hyphens: 'manual' }}>{t('landing.agency.title')}</div>
|
||||
<div style={{ fontSize: 15 }}>{t('landing.agency.text')}</div>
|
||||
</div>
|
||||
<div className="sticker">
|
||||
<div style={{ fontFamily: 'var(--bc-font-head)', fontStyle: 'italic', fontSize: 20, fontWeight: 500 }}>{t('landing.agency.gift')}</div>
|
||||
<div style={{ fontSize: 12 }}>{t('landing.agency.giftSub')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="plans" className="row" style={{ alignItems: 'stretch' }}>
|
||||
<div className="plan" style={{ background: '#fff' }}>
|
||||
<div className="bc-between" style={{ alignItems: 'baseline' }}>
|
||||
<div className="bc-h2" style={{ fontSize: 28 }}>
|
||||
Barev
|
||||
</div>
|
||||
<div className="bc-num" style={{ fontSize: 32 }}>
|
||||
{amd(0)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="plan__list">
|
||||
<div>
|
||||
{check('var(--bc-ok)')}
|
||||
{t('plans.free.perks')}
|
||||
</div>
|
||||
<div>
|
||||
{check('var(--bc-ok)')}
|
||||
{t('plans.free.prices')}
|
||||
</div>
|
||||
<div>
|
||||
{check('var(--bc-ok)')}
|
||||
{t('plans.free.status')}
|
||||
</div>
|
||||
</div>
|
||||
<BotLink className="bc-btn bc-btn--outline bc-btn--block" style={{ marginTop: 'auto' }}>
|
||||
{t('landing.hero.cta')}
|
||||
</BotLink>
|
||||
</div>
|
||||
<div className="plan bc-tone-learn">
|
||||
<div className="bc-between" style={{ alignItems: 'baseline' }}>
|
||||
<div className="bc-h2" style={{ fontSize: 28 }}>
|
||||
Barev+
|
||||
</div>
|
||||
<div className="bc-num" style={{ fontSize: 24 }}>
|
||||
{t('plans.plus.price', { price: amd(catalog.barevPlus.monthlyPrice) })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="plan__list">
|
||||
<div>
|
||||
{check('var(--bc-accent)')}
|
||||
{t('plans.plus.all')}
|
||||
</div>
|
||||
{discounts.map((c) => (
|
||||
<div key={c.code}>
|
||||
{check('var(--bc-accent)')}
|
||||
{t('plans.plus.discount', { pct: c.barevPlusDiscountPct, category: t(`categories.${c.code}.name`) })}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<BotLink className="bc-btn bc-btn--dark bc-btn--block" style={{ marginTop: 'auto' }}>
|
||||
{t('landing.plans.tryPlus')}
|
||||
</BotLink>
|
||||
</div>
|
||||
<div id="faq" className="faq">
|
||||
<h2 className="bc-h2" style={{ fontSize: 28, marginBottom: 4 }}>
|
||||
{t('landing.nav.faq')}
|
||||
</h2>
|
||||
{faq.map((k, i) => (
|
||||
<details key={k} open={i === 0}>
|
||||
<summary>{t(`landing.faq.${k}.q`)}</summary>
|
||||
<p>{t(`landing.faq.${k}.a`, { pct: maxDiscount, days: catalog.barevPlus.giftDays })}</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="footer">
|
||||
<div style={{ fontFamily: 'var(--bc-font-head)', fontWeight: 700, color: 'var(--bc-plum)' }}>{t('landing.footer.brand')}</div>
|
||||
<div className="footer__links">
|
||||
<BotLink>{t('landing.footer.partner')}</BotLink>
|
||||
{process.env.CONTACT_EMAIL && <a href={`mailto:${process.env.CONTACT_EMAIL}`}>{process.env.CONTACT_EMAIL}</a>}
|
||||
<a href={`/${locale}/privacy`}>{t('landing.footer.privacy')}</a>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</BotLinkProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import '../../variants.css';
|
||||
import { quote } from '@barev/core';
|
||||
import { createT, formatAmd, isLocale, LOCALES, type Locale } from '@barev/i18n';
|
||||
import { CATEGORY_ICON, Icon, type CategoryCode } from '@barev/ui';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { BotLink, BotLinkProvider } from '../../../components/client';
|
||||
import { VariantHeader, variantMetadata } from '../../../components/variant';
|
||||
import { getCatalog, getCleaning } 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 variantMetadata(params, '/land1', 'landing.v1.title');
|
||||
}
|
||||
|
||||
/** Версия 1 «Чат»: продукт живёт в Telegram, поэтому страница — переписка с ботом, кнопки — как inline-клавиатура */
|
||||
export default async function Land1({ 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 [catalog, cleaning] = await Promise.all([getCatalog(), getCleaning()]);
|
||||
// цена в диалоге — настоящая: обычная уборка, 2 комнаты, 1 санузел
|
||||
const demoPrice = quote(cleaning.pricing, { cleaningType: 'regular', rooms: 2, bathrooms: 1 }).totalPrice;
|
||||
const groups = catalog.categories.filter((c) => c.code !== 'PERKS' && c.services.length > 0);
|
||||
|
||||
const chat = [
|
||||
{ from: 'me', text: t('landing.v1.u1'), time: '10:02' },
|
||||
{ from: 'bot', text: t('landing.v1.b1', { price: amd(demoPrice) }), time: '10:02' },
|
||||
{ from: 'me', text: t('landing.v1.u2'), time: '10:03' },
|
||||
{ from: 'bot', text: t('landing.v1.b2'), time: '10:03' },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<BotLinkProvider locale={locale} bot={catalog.botUsername}>
|
||||
<div className="v1">
|
||||
<div className="v-wrap">
|
||||
<VariantHeader locale={locale} path="/land1" />
|
||||
|
||||
<section className="v1-hero">
|
||||
<div className="v1-hero__text">
|
||||
<h1 className="v1-title">{t('landing.v1.title')}</h1>
|
||||
<p className="v1-sub">{t('landing.v1.sub')}</p>
|
||||
</div>
|
||||
|
||||
<div className="v1-chat" aria-label={t('landing.v1.botName')}>
|
||||
<div className="v1-chat__head">
|
||||
<span className="v1-avatar" aria-hidden="true">
|
||||
B
|
||||
</span>
|
||||
<span>
|
||||
<b>{t('landing.v1.botName')}</b>
|
||||
<small>{t('landing.v1.botStatus')}</small>
|
||||
</span>
|
||||
</div>
|
||||
<ol className="v1-chat__log">
|
||||
{chat.map((m, i) => (
|
||||
<li key={i} className={`v1-msg v1-msg--${m.from}`} style={{ ['--d' as string]: `${0.3 + i * 0.9}s` }}>
|
||||
{m.text}
|
||||
<time>{m.time}</time>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{/* как inline-клавиатура под последним сообщением бота */}
|
||||
<div className="v1-keys" style={{ ['--d' as string]: `${0.3 + chat.length * 0.9}s` }}>
|
||||
<a href={`/${locale}/clean`} className="v1-key v1-key--main">
|
||||
{t('landing.hero.cleanCta')}
|
||||
</a>
|
||||
<BotLink className="v1-key">{t('landing.v1.cta')}</BotLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="v1-card">
|
||||
<h2 className="v1-h2">{t('landing.v1.canTitle')}</h2>
|
||||
<p className="v1-muted">{t('landing.v1.canText')}</p>
|
||||
<div className="v1-groups">
|
||||
{groups.map((g) => (
|
||||
<div key={g.code} className="v1-group">
|
||||
<h3>
|
||||
<Icon name={CATEGORY_ICON[g.code as CategoryCode]} size={20} />
|
||||
{t(`categories.${g.code}.name`)}
|
||||
</h3>
|
||||
<div className="v1-group__keys">
|
||||
{g.services.map((s) =>
|
||||
s.code === 'cleaning' && s.active ? (
|
||||
<a key={s.code} href={`/${locale}/clean`} className="v1-svc v1-svc--live">
|
||||
<span>{t(`services.${s.code}.name`)}</span>
|
||||
<span>{t('common.from', { price: amd(s.fromPrice) })}</span>
|
||||
</a>
|
||||
) : (
|
||||
<div key={s.code} className="v1-svc">
|
||||
<span>{t(`services.${s.code}.name`)}</span>
|
||||
<span>{s.active ? t('common.from', { price: amd(s.fromPrice) }) : t('common.soon')}</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="v1-why">
|
||||
<h2 className="v1-h2">{t('landing.v1.whyTitle')}</h2>
|
||||
<div className="v1-why__grid">
|
||||
{([1, 2, 3] as const).map((n) => (
|
||||
<div key={n} className="v1-why__item">
|
||||
<b>{t(`landing.v1.why${n}`)}</b>
|
||||
<span>{t(`landing.v1.why${n}Sub`)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="v1-final">
|
||||
<div>
|
||||
<h2 className="v1-final__title">{t('landing.v1.ctaTitle')}</h2>
|
||||
<p>{t('landing.v1.ctaText')}</p>
|
||||
</div>
|
||||
{/* поле ввода сообщения: «Barev!» уже набрано, кнопка отправки открывает бота */}
|
||||
<BotLink className="v1-compose">
|
||||
<span className="v1-compose__text">Barev!</span>
|
||||
<span className="v1-compose__send" aria-hidden="true">
|
||||
<Icon name="arrow" size={20} strokeWidth={2.4} />
|
||||
</span>
|
||||
<span className="v-sr">{t('landing.v1.cta')}</span>
|
||||
</BotLink>
|
||||
</section>
|
||||
|
||||
<footer className="v-footer">
|
||||
<span>{t('landing.footer.brand')}</span>
|
||||
<a href={`/${locale}/privacy`}>{t('landing.footer.privacy')}</a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</BotLinkProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { isLocale } from '@barev/i18n';
|
||||
import { notFound, permanentRedirect } from 'next/navigation';
|
||||
|
||||
/** Версия 2 «Переезд по шагам» стала главной — старые ссылки /land2 ведут на неё с теми же параметрами */
|
||||
export default async function Land2({ params, searchParams }: { params: Promise<{ locale: string }>; searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
const [{ locale }, sp] = await Promise.all([params, searchParams]);
|
||||
if (!isLocale(locale)) notFound();
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(sp)) if (typeof v === 'string') qs.set(k, v);
|
||||
permanentRedirect(`/${locale}${qs.size ? `?${qs}` : ''}`);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import '../../variants.css';
|
||||
import { createT, formatAmd, isLocale, LOCALES, type Locale } from '@barev/i18n';
|
||||
import { Icon } from '@barev/ui';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { BotLink, BotLinkProvider } from '../../../components/client';
|
||||
import { GiftCode } from '../../../components/gift-code';
|
||||
import { VariantHeader, variantMetadata } from '../../../components/variant';
|
||||
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 variantMetadata(params, '/land3', 'landing.v3.title');
|
||||
}
|
||||
|
||||
/** Версия 3 «Подарок от агентства»: для тех, кто пришёл по QR из договора аренды. Бирка для ключей с месяцем Barev+ */
|
||||
export default async function Land3({ 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 catalog = await getCatalog();
|
||||
const home = catalog.categories.find((c) => c.code === 'HOME');
|
||||
const cleaningFrom = home?.services.find((s) => s.code === 'cleaning')?.fromPrice ?? 0;
|
||||
const homePct = home?.barevPlusDiscountPct ?? 0;
|
||||
// так же округляет скидку сервер: вниз до целого драма
|
||||
const cleaningWithPlus = cleaningFrom - Math.floor((cleaningFrom * homePct) / 100);
|
||||
const discounts = catalog.categories.filter((c) => c.barevPlusDiscountPct > 0);
|
||||
|
||||
return (
|
||||
<BotLinkProvider locale={locale} bot={catalog.botUsername}>
|
||||
<div className="v3">
|
||||
<div className="v-wrap">
|
||||
<VariantHeader locale={locale} path="/land3" />
|
||||
|
||||
<section className="v3-hero">
|
||||
<div className="v3-hero__text">
|
||||
<span className="v3-kicker">{t('landing.v3.tag')}</span>
|
||||
<h1 className="v3-title">{t('landing.v3.title')}</h1>
|
||||
<p className="v3-sub">{t('landing.v3.sub')}</p>
|
||||
<BotLink className="bc-btn bc-btn--accent v3-cta">{t('landing.v3.cta')}</BotLink>
|
||||
</div>
|
||||
<div className="v3-tagwrap" aria-hidden="true">
|
||||
<svg className="v3-string" viewBox="0 0 120 160" fill="none">
|
||||
<path d="M60 0 C 20 40, 100 80, 60 132" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||
</svg>
|
||||
<div className="v3-tag">
|
||||
<span className="v3-tag__hole" />
|
||||
<span className="v3-tag__label">{t('landing.agency.gift')}</span>
|
||||
<span className="v3-tag__title">{t('landing.v3.giftTitle')}</span>
|
||||
<GiftCode withCode={t('landing.v3.giftCode')} withoutCode={t('landing.v3.giftNoCode')} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="v3-steps">
|
||||
<h2 className="v3-h2">{t('landing.v3.stepsTitle')}</h2>
|
||||
<ol>
|
||||
{([1, 2, 3] as const).map((n) => (
|
||||
<li key={n}>
|
||||
<span className="v3-num">{n}</span>
|
||||
<b>{t(`landing.v3.step${n}`)}</b>
|
||||
<span>{t(`landing.v3.step${n}Sub`)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section className="v3-benefits">
|
||||
<div className="v3-deal">
|
||||
<h2 className="v3-h2">{t('landing.v3.benefitsTitle')}</h2>
|
||||
{homePct > 0 && cleaningFrom > 0 && (
|
||||
<a href={`/${locale}/clean`} className="v3-deal__card">
|
||||
<b>{t('landing.v3.cleaningDeal', { price: amd(cleaningWithPlus), full: amd(cleaningFrom) })}</b>
|
||||
<span>{t('landing.v3.cleaningDealSub')}</span>
|
||||
<span className="v3-deal__go">{t('landing.hero.cleanCta')}</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<ul className="v3-list">
|
||||
{discounts.map((c) => (
|
||||
<li key={c.code}>
|
||||
<Icon name="check" size={20} strokeWidth={2.6} />
|
||||
{t('plans.plus.discount', { pct: c.barevPlusDiscountPct, category: t(`categories.${c.code}.name`) })}
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<Icon name="check" size={20} strokeWidth={2.6} />
|
||||
{t('plans.free.perks')}
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="v3-final">
|
||||
<p>{t('landing.v3.noCode')}</p>
|
||||
<BotLink className="bc-btn bc-btn--dark">{t('landing.v3.cta')}</BotLink>
|
||||
</section>
|
||||
|
||||
<footer className="v-footer">
|
||||
<span>{t('landing.footer.brand')}</span>
|
||||
<a href={`/${locale}/privacy`}>{t('landing.footer.privacy')}</a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</BotLinkProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import '../../variants.css';
|
||||
import { createT, formatAmd, isLocale, LOCALES, type Locale } from '@barev/i18n';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { BotLink, BotLinkProvider } from '../../../components/client';
|
||||
import { VariantHeader, variantMetadata } from '../../../components/variant';
|
||||
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 variantMetadata(params, '/land4', 'landing.v4.title');
|
||||
}
|
||||
|
||||
/** Текст табло: каждая буква — отдельная перекидная пластинка, при загрузке они один раз «перещёлкиваются» по очереди */
|
||||
function Flaps({ text, start = 0 }: { text: string; start?: number }) {
|
||||
return (
|
||||
<span className="v4-flaps" aria-label={text}>
|
||||
{Array.from(text).map((ch, i) =>
|
||||
ch === ' ' ? (
|
||||
<span key={i} className="v4-gap" aria-hidden="true" />
|
||||
) : (
|
||||
<span key={i} className="v4-flap" aria-hidden="true" style={{ ['--i' as string]: start + i }}>
|
||||
{ch}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Версия 4 «Табло прилёта»: для тех, кто только прилетел. Услуги клуба — как рейсы на табло, CTA — посадочный талон */
|
||||
export default async function Land4({ 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 catalog = await getCatalog();
|
||||
const rows = catalog.categories
|
||||
.filter((c) => c.code !== 'PERKS')
|
||||
.flatMap((c) => c.services)
|
||||
.sort((a, b) => Number(b.active) - Number(a.active));
|
||||
|
||||
return (
|
||||
<BotLinkProvider locale={locale} bot={catalog.botUsername}>
|
||||
<div className="v4">
|
||||
<div className="v-wrap">
|
||||
<VariantHeader locale={locale} path="/land4" />
|
||||
|
||||
<section className="v4-hero">
|
||||
<h1 className="v4-title">{t('landing.v4.title')}</h1>
|
||||
<p className="v4-sub">{t('landing.v4.sub')}</p>
|
||||
</section>
|
||||
|
||||
<section className="v4-board" aria-label={`${t('landing.v4.board')}: ${t('landing.v4.city')}`}>
|
||||
<div className="v4-board__head">
|
||||
<span className="v4-board__name">
|
||||
{t('landing.v4.board')}
|
||||
</span>
|
||||
<span className="v4-board__city">{t('landing.v4.city')} EVN</span>
|
||||
</div>
|
||||
<table className="v4-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('landing.v4.colFlight')}</th>
|
||||
<th>{t('landing.v4.colService')}</th>
|
||||
<th>{t('landing.v4.colPrice')}</th>
|
||||
<th>{t('landing.v4.colStatus')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((s, i) => (
|
||||
<tr key={s.code} data-live={s.active}>
|
||||
<td className="v4-code">BC {String(i + 1).padStart(3, '0')}</td>
|
||||
<td className="v4-name">
|
||||
{s.code === 'cleaning' && s.active ? (
|
||||
<a href={`/${locale}/clean`}>
|
||||
<Flaps text={t(`services.${s.code}.name`)} start={i * 3} />
|
||||
</a>
|
||||
) : (
|
||||
<Flaps text={t(`services.${s.code}.name`)} start={i * 3} />
|
||||
)}
|
||||
</td>
|
||||
<td className="v4-price">{s.active ? t('common.from', { price: amd(s.fromPrice) }) : '—'}</td>
|
||||
<td>
|
||||
<span className="v4-status">{s.active ? t('landing.v4.live') : t('landing.v4.soon')}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{/* посадочный талон: корешок отрывается по пунктиру, кнопка — посадка в бота */}
|
||||
<section className="v4-pass">
|
||||
<div className="v4-pass__main">
|
||||
<div className="v4-pass__title">{t('landing.v4.passTitle')}</div>
|
||||
<dl className="v4-pass__grid">
|
||||
<div>
|
||||
<dt>{t('landing.v4.passenger')}</dt>
|
||||
<dd>{t('landing.v4.passengerValue')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('landing.v4.from')}</dt>
|
||||
<dd>{t('landing.v4.fromValue')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('landing.v4.to')}</dt>
|
||||
<dd>{t('landing.v4.toValue')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('landing.v4.gate')}</dt>
|
||||
<dd>{t('landing.v4.gateValue')}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div className="v4-pass__stub">
|
||||
<BotLink className="bc-btn v4-pass__cta">{t('landing.v4.cta')}</BotLink>
|
||||
<a href={`/${locale}/clean`} className="v4-pass__link">
|
||||
{t('landing.v4.clean')}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="v-footer">
|
||||
<span>{t('landing.footer.brand')}</span>
|
||||
<a href={`/${locale}/privacy`}>{t('landing.footer.privacy')}</a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</BotLinkProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import '../../variants.css';
|
||||
import { createT, isLocale, LOCALES, pickMessages, type Locale } from '@barev/i18n';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { BotLink, BotLinkProvider } from '../../../components/client';
|
||||
import { Savings } from '../../../components/savings';
|
||||
import { VariantHeader, variantMetadata } from '../../../components/variant';
|
||||
import { getCatalog, getCleaning } 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 variantMetadata(params, '/land5', 'landing.v5.title');
|
||||
}
|
||||
|
||||
/** Версия 5 «Калькулятор Barev+»: для тех, кто решает про подписку. Честная экономия за вычетом цены подписки */
|
||||
export default async function Land5({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale: raw } = await params;
|
||||
if (!isLocale(raw)) notFound();
|
||||
const locale: Locale = raw;
|
||||
const t = createT(locale);
|
||||
const [catalog, cleaning] = await Promise.all([getCatalog(), getCleaning()]);
|
||||
const others = catalog.categories.filter((c) => c.code !== 'HOME' && c.barevPlusDiscountPct > 0);
|
||||
const messages = pickMessages(locale, ['landing.v5.', 'params.cleaning.', 'common.']);
|
||||
|
||||
return (
|
||||
<BotLinkProvider locale={locale} bot={catalog.botUsername}>
|
||||
<div className="v5">
|
||||
<div className="v-wrap">
|
||||
<VariantHeader locale={locale} path="/land5" />
|
||||
|
||||
<section className="v5-hero">
|
||||
<h1 className="v5-title">{t('landing.v5.title')}</h1>
|
||||
<p className="v5-sub">{t('landing.v5.sub')}</p>
|
||||
</section>
|
||||
|
||||
<Savings locale={locale} config={cleaning} monthlyPrice={catalog.barevPlus.monthlyPrice} messages={messages} />
|
||||
|
||||
<section className="v5-more">
|
||||
<div>
|
||||
<h2>{t('landing.v5.other')}</h2>
|
||||
<ul>
|
||||
{others.map((c) => (
|
||||
<li key={c.code}>
|
||||
<b>−{c.barevPlusDiscountPct}%</b>
|
||||
{t(`categories.${c.code}.name`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="v5-more__cta">
|
||||
<BotLink className="bc-btn bc-btn--dark">{t('landing.v5.cta')}</BotLink>
|
||||
<p>{t('landing.v5.gift')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="v-footer">
|
||||
<span>{t('landing.footer.brand')}</span>
|
||||
<a href={`/${locale}/privacy`}>{t('landing.footer.privacy')}</a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</BotLinkProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import '../../variants.css';
|
||||
import { quote } from '@barev/core';
|
||||
import { createT, formatAmd, formatDateTime, isLocale, LOCALES, type Locale } from '@barev/i18n';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { BotLink, BotLinkProvider } from '../../../components/client';
|
||||
import { VariantHeader, variantMetadata } from '../../../components/variant';
|
||||
import { getCatalog, getCleaning } 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 variantMetadata(params, '/land6', 'landing.v6.tag');
|
||||
}
|
||||
|
||||
/** Пример дохода агентства: условные клиенты и заказы, цена и процент — настоящие из каталога и настроек */
|
||||
const EXAMPLE = { clients: 20, orders: 2 };
|
||||
|
||||
/** Печать клуба: текст по кругу и «Barev» в центре */
|
||||
function Stamp() {
|
||||
return (
|
||||
<svg className="v6-stamp" viewBox="0 0 160 160" aria-hidden="true">
|
||||
<defs>
|
||||
<path id="v6-ring" d="M80 80 m-58 0 a58 58 0 1 1 116 0 a58 58 0 1 1 -116 0" />
|
||||
</defs>
|
||||
<circle cx="80" cy="80" r="74" fill="none" stroke="currentColor" strokeWidth="3" />
|
||||
<circle cx="80" cy="80" r="44" fill="none" stroke="currentColor" strokeWidth="2" />
|
||||
{/* длина текста = окружность r=58 (2π·58 ≈ 364), чтобы конец не наезжал на начало */}
|
||||
<text fontSize="14" fontWeight="700" fill="currentColor">
|
||||
<textPath href="#v6-ring" textLength="356" lengthAdjust="spacing">
|
||||
BAREV CLUB ✦ YEREVAN ✦ PARTNER ✦
|
||||
</textPath>
|
||||
</text>
|
||||
<text x="80" y="88" textAnchor="middle" fontSize="24" fontWeight="800" fontStyle="italic" fill="currentColor">
|
||||
Barev
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Версия 6 «Для агентств»: B2B-страница главного канала привлечения, оформленная как договор с пунктами и печатью */
|
||||
export default async function Land6({ 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 [catalog, cleaning] = await Promise.all([getCatalog(), getCleaning()]);
|
||||
const agency = catalog.agency ?? { commissionPct: 10, attributionMonths: 12 };
|
||||
const price = quote(cleaning.pricing, { cleaningType: 'regular', rooms: 2, bathrooms: 1 }).totalPrice;
|
||||
const amount = Math.round((EXAMPLE.clients * EXAMPLE.orders * price * agency.commissionPct) / 100);
|
||||
const vars = { pct: agency.commissionPct, months: agency.attributionMonths, days: catalog.barevPlus.giftDays };
|
||||
const sections = [
|
||||
{ key: 's1', items: ['s1i1', 's1i2', 's1i3'] },
|
||||
{ key: 's2', items: ['s2i1', 's2i2', 's2i3'] },
|
||||
{ key: 's3', items: ['s3i1', 's3i2', 's3i3'] },
|
||||
];
|
||||
|
||||
return (
|
||||
<BotLinkProvider locale={locale} bot={catalog.botUsername}>
|
||||
<div className="v6">
|
||||
<div className="v-wrap">
|
||||
<VariantHeader locale={locale} path="/land6" />
|
||||
|
||||
<article className="v6-doc">
|
||||
<div className="v6-doc__top">
|
||||
<span className="v6-tag">{t('landing.v6.tag')}</span>
|
||||
<span className="v6-date">
|
||||
{t('landing.v6.docDate')}, {formatDateTime(locale, new Date(), { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="v6-title">{t('landing.v6.title')}</h1>
|
||||
<p className="v6-sub">{t('landing.v6.sub')}</p>
|
||||
|
||||
<div className="v6-body">
|
||||
<div className="v6-clauses">
|
||||
{sections.map((s, i) => (
|
||||
<section key={s.key} className="v6-clause">
|
||||
<h2>
|
||||
<span>{i + 1}.</span> {t(`landing.v6.${s.key}`)}
|
||||
</h2>
|
||||
<ol>
|
||||
{s.items.map((item, j) => (
|
||||
<li key={item}>
|
||||
<span className="v6-num">
|
||||
{i + 1}.{j + 1}
|
||||
</span>
|
||||
<span>{t(`landing.v6.${item}`, vars)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<aside className="v6-example">
|
||||
<div className="v6-example__title">{t('landing.v6.exampleTitle')}</div>
|
||||
<div className="v6-example__calc">{t('landing.v6.exampleText', { clients: EXAMPLE.clients, orders: EXAMPLE.orders, price: amd(price) })}</div>
|
||||
<div className="v6-example__pct">× {agency.commissionPct}%</div>
|
||||
<div className="v6-example__result">{t('landing.v6.exampleResult', { amount: amd(amount) })}</div>
|
||||
<p>{t('landing.v6.exampleNote')}</p>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div className="v6-sign">
|
||||
<div className="v6-sign__line">
|
||||
<span />
|
||||
{t('landing.v6.signAgency')}
|
||||
</div>
|
||||
<div className="v6-sign__line v6-sign__line--us">
|
||||
<Stamp />
|
||||
<span />
|
||||
{t('landing.v6.signUs')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BotLink className="bc-btn bc-btn--accent v6-cta">{t('landing.v6.cta')}</BotLink>
|
||||
</article>
|
||||
|
||||
<footer className="v-footer">
|
||||
<span>{t('landing.footer.brand')}</span>
|
||||
<a href={`/${locale}/privacy`}>{t('landing.footer.privacy')}</a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</BotLinkProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import '@barev/ui/styles.css';
|
||||
import '../landing.css';
|
||||
import { createT, isLocale, LOCALES, type Locale } from '@barev/i18n';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Noto_Sans_Armenian, Onest, Rubik } from 'next/font/google';
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
const rubik = Rubik({ subsets: ['latin', 'cyrillic'], weight: ['400', '500', '700', '800'], style: ['normal', 'italic'], variable: '--font-rubik', display: 'swap' });
|
||||
const onest = Onest({ subsets: ['latin', 'cyrillic'], weight: ['400', '500', '600'], variable: '--font-onest', display: 'swap' });
|
||||
const armenian = Noto_Sans_Armenian({ subsets: ['armenian'], weight: ['400', '600', '700'], variable: '--font-armenian', display: 'swap', preload: false });
|
||||
|
||||
const SITE = process.env.LANDING_URL ?? 'http://localhost:3000';
|
||||
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
export const viewport: Viewport = { themeColor: '#FBF6EE', width: 'device-width', initialScale: 1 };
|
||||
|
||||
/** LND-4: SEO-мета на каждом языке и hreflang */
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) return {};
|
||||
const t = createT(locale);
|
||||
// по умолчанию — заголовок и описание главной (страницы задают свои)
|
||||
const title = t('landing.home.metaTitle');
|
||||
const description = t('landing.home.metaDescription');
|
||||
return {
|
||||
metadataBase: new URL(SITE),
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: `/${locale}`,
|
||||
languages: Object.fromEntries([...LOCALES.map((l) => [l, `/${l}`]), ['x-default', '/ru']]),
|
||||
},
|
||||
openGraph: { title, description, locale, type: 'website', siteName: 'Barev Club' },
|
||||
twitter: { card: 'summary_large_image' },
|
||||
// подтверждение прав в Search Console, Яндекс Вебмастере и Bing — коды из .env (можно и через DNS)
|
||||
verification: {
|
||||
google: process.env.GOOGLE_SITE_VERIFICATION || undefined,
|
||||
yandex: process.env.YANDEX_VERIFICATION || undefined,
|
||||
other: process.env.BING_SITE_VERIFICATION ? { 'msvalidate.01': process.env.BING_SITE_VERIFICATION } : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({ children, params }: { children: ReactNode; params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
return (
|
||||
<html lang={locale as Locale} className={`${rubik.variable} ${onest.variable} ${armenian.variable}`}>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createT, isLocale, LOCALES } from '@barev/i18n';
|
||||
import { OG_SIZE, ogImage } from '../../../lib/og';
|
||||
|
||||
export const size = OG_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
/** OG-картинка «Уже живу в Армении» */
|
||||
export default async function OgImage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
return ogImage(createT(isLocale(locale) ? locale : 'ru')('landing.live.title'));
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import '../../variants.css';
|
||||
import '../../home.css';
|
||||
import { createT, formatAmd, isLocale, LOCALES, type Locale } from '@barev/i18n';
|
||||
import type { Viewport } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { BotLink, BotLinkProvider } from '../../../components/client';
|
||||
import { AudienceSwitch, ClubPlans, HomeFooter, homeMetadata } from '../../../components/home';
|
||||
import { ClubJsonLd } from '../../../components/seo';
|
||||
import { VariantHeader } from '../../../components/variant';
|
||||
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 homeMetadata(params, '/live');
|
||||
}
|
||||
export const viewport: Viewport = { themeColor: '#4A1028' };
|
||||
|
||||
/**
|
||||
* Прайс по подписке: клининг можно заказать сразу, трансфер — со скидкой Elite, документы — «от N ֏»,
|
||||
* остальное пока «скоро».
|
||||
*/
|
||||
const ITEMS = [
|
||||
{ key: 'cleaning', value: 'order' },
|
||||
{ key: 'repair', value: 'soon' },
|
||||
{ key: 'transfer', value: 'pct' },
|
||||
{ key: 'tours', value: 'soon' },
|
||||
{ key: 'docs', value: 'from' },
|
||||
{ key: 'lang', value: 'soon' },
|
||||
{ key: 'career', value: 'soon' },
|
||||
{ key: 'partners', value: 'soon' },
|
||||
] as const;
|
||||
|
||||
/** Версия главной «Уже живу в Армении»: что даёт подписка — скидки по направлениям и тарифы */
|
||||
export default async function Live({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale: raw } = await params;
|
||||
if (!isLocale(raw)) notFound();
|
||||
const locale: Locale = raw;
|
||||
const t = createT(locale);
|
||||
const catalog = await getCatalog();
|
||||
|
||||
return (
|
||||
<BotLinkProvider locale={locale} bot={catalog.botUsername}>
|
||||
<div className="v2">
|
||||
<ClubJsonLd locale={locale} path="/live" bot={catalog.botUsername} plusPrice={catalog.barevPlus.monthlyPrice} offer={catalog.offer} />
|
||||
<div className="v-wrap">
|
||||
<VariantHeader locale={locale} path="/live" className="vh--dark" logoColor="#F6C3D3" />
|
||||
<AudienceSwitch locale={locale} current="live" />
|
||||
|
||||
<section className="v2-hero">
|
||||
<div className="v2-word" aria-hidden="true">
|
||||
Barev
|
||||
</div>
|
||||
<ul className="v2-hello" aria-hidden="true">
|
||||
{t('landing.v2.hello')
|
||||
.split('\n')
|
||||
.map((h) => (
|
||||
<li key={h}>{h}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="v2-hero__text">
|
||||
<h1 className="v2-title">{t('landing.live.title')}</h1>
|
||||
<div className="h-hero__side">
|
||||
<p className="v2-sub">{t('landing.live.sub')}</p>
|
||||
<div className="h-actions">
|
||||
<BotLink className="bc-btn h-cta">{t('landing.live.cta')}</BotLink>
|
||||
<a href="#plans" className="bc-btn h-cta-ghost">
|
||||
{t('landing.live.ctaPlans')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="discounts" className="h-price" aria-labelledby="discounts-title">
|
||||
<div className="h-section-head">
|
||||
<h2 id="discounts-title">{t('landing.live.listTitle')}</h2>
|
||||
<p>{t('landing.live.listNote', { pct: catalog.offer.plusMaxPct })}</p>
|
||||
</div>
|
||||
<ul className="h-price__list">
|
||||
{ITEMS.map((it) => (
|
||||
<li key={it.key} className="h-price__row" data-kind={it.value}>
|
||||
<b className="h-price__name">{t(`landing.live.${it.key}`)}</b>
|
||||
<span className="h-price__dots" aria-hidden="true" />
|
||||
{it.value === 'order' ? (
|
||||
<a href={`/${locale}/clean`} className="bc-btn h-price__order">
|
||||
{t('landing.live.orderNow')}
|
||||
</a>
|
||||
) : (
|
||||
<span className="h-price__value">
|
||||
{it.value === 'pct'
|
||||
? t('landing.live.upTo', { pct: catalog.offer.eliteMaxPct })
|
||||
: it.value === 'from'
|
||||
? t('landing.live.docsFrom', { price: formatAmd(locale, catalog.offer.docsFrom) })
|
||||
: t('common.soon')}
|
||||
</span>
|
||||
)}
|
||||
<small className="h-price__hint">{t(`landing.live.${it.key}Hint`)}</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<ClubPlans locale={locale} plusPrice={catalog.barevPlus.monthlyPrice} offer={catalog.offer} />
|
||||
|
||||
<section className="v2-final">
|
||||
<div>
|
||||
<h2>{t('landing.live.finalTitle')}</h2>
|
||||
<p>{t('landing.live.finalText')}</p>
|
||||
</div>
|
||||
<BotLink className="bc-btn v2-final__cta">{t('landing.live.cta')}</BotLink>
|
||||
</section>
|
||||
|
||||
<HomeFooter locale={locale} />
|
||||
</div>
|
||||
</div>
|
||||
</BotLinkProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createT, isLocale, LOCALES } from '@barev/i18n';
|
||||
import { OG_SIZE, ogImage } from '../../lib/og';
|
||||
|
||||
export const size = OG_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
/** LND-4: OG-картинка главной «Переезжаю» на каждом языке — тот же заголовок, что в H1 */
|
||||
export default async function OgImage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
return ogImage(createT(isLocale(locale) ? locale : 'ru')('landing.home.title'));
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import '../variants.css';
|
||||
import '../home.css';
|
||||
import { createT, formatAmd, isLocale, LOCALES, pickMessages, type Locale } from '@barev/i18n';
|
||||
import type { Viewport } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { BotLink, BotLinkProvider } from '../../components/client';
|
||||
import { AudienceSwitch, ClubPlans, HomeFooter, homeMetadata } from '../../components/home';
|
||||
import { QuizButton, QuizProvider } from '../../components/quiz';
|
||||
import { ClubJsonLd } from '../../components/seo';
|
||||
import { VariantHeader } from '../../components/variant';
|
||||
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 homeMetadata(params, '');
|
||||
}
|
||||
export const viewport: Viewport = { themeColor: '#4A1028' };
|
||||
|
||||
/** Шаги переезда: когда, что делаем и короткий факт (цена или срок) справа */
|
||||
const STEPS = ['airport', 'flat', 'docs', 'permit', 'perks', 'chat'] as const;
|
||||
const WHEN: Record<(typeof STEPS)[number], string> = {
|
||||
airport: 'whenArrive',
|
||||
flat: 'whenFirst',
|
||||
docs: 'whenSettled',
|
||||
permit: 'whenLater',
|
||||
perks: 'whenDaily',
|
||||
chat: 'whenAnytime',
|
||||
};
|
||||
|
||||
/** Шаги таймлайна со своей страницей услуги — заголовок шага ведёт на неё */
|
||||
const LINKS: Partial<Record<(typeof STEPS)[number], string>> = { airport: '/airport', docs: '/social-card', permit: '/residence-permit' };
|
||||
|
||||
/**
|
||||
* Главная «Переезжаю»: огромное «Barev», таймлайн от аэропорта до ВНЖ, тарифы клуба и квиз-подбор.
|
||||
* Версия для тех, кто уже живёт в Армении, — /live.
|
||||
*/
|
||||
export default async function Home({ 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 catalog = await getCatalog();
|
||||
const messages = pickMessages(locale, ['landing.quiz.', 'errors.']);
|
||||
|
||||
const meta = (key: (typeof STEPS)[number]) => {
|
||||
switch (key) {
|
||||
case 'airport':
|
||||
return null;
|
||||
case 'flat':
|
||||
return null;
|
||||
case 'docs':
|
||||
return null;
|
||||
case 'permit':
|
||||
return null;
|
||||
case 'perks':
|
||||
// скидки партнёров ещё не запущены — вместо ссылки плашка «Скоро» в стиле «Подробнее»
|
||||
return (
|
||||
<span className="bc-btn h-more h-more--soon" aria-disabled="true">
|
||||
{t('landing.home.perksSoon')}
|
||||
</span>
|
||||
);
|
||||
case 'chat':
|
||||
return <BotLink className="bc-btn v2-btn">{t('landing.home.chatCta')}</BotLink>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BotLinkProvider locale={locale} bot={catalog.botUsername}>
|
||||
<QuizProvider locale={locale} messages={messages} consultPrice={amd(catalog.offer.consultation)}>
|
||||
<div className="v2">
|
||||
<ClubJsonLd locale={locale} path="" bot={catalog.botUsername} plusPrice={catalog.barevPlus.monthlyPrice} offer={catalog.offer} />
|
||||
<div className="v-wrap">
|
||||
<VariantHeader locale={locale} path="" className="vh--dark" logoColor="#F6C3D3" />
|
||||
<AudienceSwitch locale={locale} current="move" />
|
||||
|
||||
<section className="v2-hero">
|
||||
<div className="v2-word" aria-hidden="true">
|
||||
Barev
|
||||
</div>
|
||||
<ul className="v2-hello" aria-hidden="true">
|
||||
{t('landing.v2.hello')
|
||||
.split('\n')
|
||||
.map((h) => (
|
||||
<li key={h}>{h}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="v2-hero__text">
|
||||
<h1 className="v2-title">{t('landing.home.title')}</h1>
|
||||
<div className="h-hero__side">
|
||||
<p className="v2-sub">{t('landing.home.sub')}</p>
|
||||
<div className="h-actions">
|
||||
<QuizButton className="bc-btn h-cta">{t('landing.home.cta')}</QuizButton>
|
||||
<BotLink className="bc-btn h-cta-ghost">{t('landing.home.ctaChat')}</BotLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ol className="v2-line">
|
||||
{STEPS.map((key) => {
|
||||
// квартиры (/apartments) пока есть только на русском
|
||||
const link = key === 'flat' ? (locale === 'ru' ? '/apartments' : undefined) : LINKS[key];
|
||||
return (
|
||||
<li key={key} className="v2-step" data-live={key === 'airport' || key === 'chat' || Boolean(link && key === 'flat')}>
|
||||
<div className="v2-step__when">{t(`landing.home.${WHEN[key]}`)}</div>
|
||||
<div className="v2-step__body">
|
||||
<h2>{link ? <a href={`/${locale}${link}`}>{t(`landing.home.${key}Title`)}</a> : t(`landing.home.${key}Title`)}</h2>
|
||||
<p>{t(`landing.home.${key}Text`)}</p>
|
||||
</div>
|
||||
<div className="v2-step__meta">
|
||||
{meta(key)}
|
||||
{link && (
|
||||
<a href={`/${locale}${link}`} className="bc-btn h-more">
|
||||
{t('landing.home.more')} →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
<ClubPlans locale={locale} plusPrice={catalog.barevPlus.monthlyPrice} offer={catalog.offer} />
|
||||
|
||||
<section className="v2-final">
|
||||
<div>
|
||||
<h2>{t('landing.home.finalTitle')}</h2>
|
||||
<p>{t('landing.home.finalText')}</p>
|
||||
</div>
|
||||
<QuizButton className="bc-btn v2-final__cta">{t('landing.home.cta')}</QuizButton>
|
||||
</section>
|
||||
|
||||
<HomeFooter locale={locale} />
|
||||
</div>
|
||||
</div>
|
||||
</QuizProvider>
|
||||
</BotLinkProvider>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
/** Разделы по порядку; тексты — privacy.<id>.title / privacy.<id>.body */
|
||||
const SECTIONS = ['operator', 'data', 'purposes', 'basis', 'recipients', 'location', 'retention', 'security', 'rights', 'marketing', 'cookies', 'age', 'changes'] as const;
|
||||
|
||||
export function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
return legalMetadata(params, 'privacy');
|
||||
}
|
||||
|
||||
/** 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,14 @@
|
||||
import { createT, isLocale, LOCALES } from '@barev/i18n';
|
||||
import { OG_SIZE, ogImage } from '../../../lib/og';
|
||||
|
||||
export const size = OG_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
/** OG-картинка страницы /residence-permit — заголовок H1 */
|
||||
export default async function OgImage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
return ogImage(createT(isLocale(locale) ? locale : 'ru')('landing.svc.residence.h1'));
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import '../../variants.css';
|
||||
import '../../home.css';
|
||||
import '../../service.css';
|
||||
import { isLocale, LOCALES } from '@barev/i18n';
|
||||
import type { Viewport } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ServicePage, serviceMetadata } from '../../../components/service-page';
|
||||
|
||||
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 serviceMetadata(params, 'residence');
|
||||
}
|
||||
export const viewport: Viewport = { themeColor: '#4A1028' };
|
||||
|
||||
/** /residence-permit — страница услуги под поисковый запрос; тексты landing.svc.residence, цены из админки */
|
||||
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
return <ServicePage locale={locale} id="residence" />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createT, isLocale, LOCALES } from '@barev/i18n';
|
||||
import { OG_SIZE, ogImage } from '../../../lib/og';
|
||||
|
||||
export const size = OG_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
/** OG-картинка страницы /social-card — заголовок H1 */
|
||||
export default async function OgImage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
return ogImage(createT(isLocale(locale) ? locale : 'ru')('landing.svc.socialCard.h1'));
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import '../../variants.css';
|
||||
import '../../home.css';
|
||||
import '../../service.css';
|
||||
import { isLocale, LOCALES } from '@barev/i18n';
|
||||
import type { Viewport } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ServicePage, serviceMetadata } from '../../../components/service-page';
|
||||
|
||||
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 serviceMetadata(params, 'socialCard');
|
||||
}
|
||||
export const viewport: Viewport = { themeColor: '#4A1028' };
|
||||
|
||||
/** /social-card — страница услуги под поисковый запрос; тексты landing.svc.socialCard, цены из админки */
|
||||
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
return <ServicePage locale={locale} id="socialCard" />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { crmFetch } from '../../../../../../lib/flats';
|
||||
|
||||
/** Фото квартиры из CRM (обрезанное там же); кешируется браузером на сутки */
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string; index: string }> }) {
|
||||
const { id, index } = await params;
|
||||
if (!/^\d{1,9}$/.test(id) || !/^\d{1,2}$/.test(index)) return new Response(null, { status: 404 });
|
||||
try {
|
||||
const res = await crmFetch(`/api/barev/objects/${id}/photos/${index}`, {
|
||||
next: { revalidate: 86_400 },
|
||||
});
|
||||
if (!res.ok) return new Response(null, { status: 404 });
|
||||
return new Response(res.body, {
|
||||
headers: {
|
||||
'content-type': 'image/jpeg',
|
||||
'cache-control': 'public, max-age=86400, stale-while-revalidate=604800',
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return new Response(null, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { crmFetch } from '../../../../lib/flats';
|
||||
|
||||
// Заявка на просмотр со страницы /ru/apartments → CRM (клиент «BarevClub» и сообщение в группу AREV).
|
||||
|
||||
/** Не больше 5 заявок с одного адреса за 10 минут — от случайных повторов и ботов */
|
||||
const hits = new Map<string, number[]>();
|
||||
function limited(ip: string) {
|
||||
const now = Date.now();
|
||||
const recent = (hits.get(ip) ?? []).filter((t) => now - t < 10 * 60_000);
|
||||
recent.push(now);
|
||||
hits.set(ip, recent);
|
||||
if (hits.size > 5000) hits.clear();
|
||||
return recent.length > 5;
|
||||
}
|
||||
|
||||
const json = (body: object, status = 200) => Response.json(body, { status });
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const ip = (req.headers.get('x-forwarded-for') ?? '').split(',')[0]!.trim() || 'unknown';
|
||||
if (limited(ip)) return json({ code: 'TOO_MANY' }, 429);
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await req.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return json({ code: 'VALIDATION' }, 400);
|
||||
}
|
||||
// скрытое поле: его заполняют только боты
|
||||
if (typeof body.website === 'string' && body.website) return json({ ok: true });
|
||||
const str = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
|
||||
const payload = {
|
||||
leadId: Number(body.leadId),
|
||||
day: str(body.day),
|
||||
time: str(body.time),
|
||||
name: str(body.name),
|
||||
phone: str(body.phone),
|
||||
telegram: str(body.telegram),
|
||||
};
|
||||
try {
|
||||
const res = await crmFetch('/api/barev/requests', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as { code?: string };
|
||||
if (!res.ok) return json({ code: data.code ?? 'FAILED' }, res.status === 401 ? 503 : res.status);
|
||||
return json({ ok: true });
|
||||
} catch {
|
||||
return json({ code: 'FAILED' }, 502);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
|
||||
export const size = { width: 180, height: 180 };
|
||||
export const contentType = 'image/png';
|
||||
|
||||
/** Иконка для iOS и для логотипа в выдаче: тот же знак, что в шапке сайта */
|
||||
export default function AppleIcon() {
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div style={{ width: '100%', height: '100%', background: '#4A1028', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="124" height="124" viewBox="0 0 24 24" fill="none" stroke="#F6C3D3" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 21.5a9.5 9.5 0 1 0 0-19 9.5 9.5 0 0 0 0 19zM8 12.5l2.6 2.6L16 9.5" />
|
||||
</svg>
|
||||
</div>
|
||||
),
|
||||
size,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
/* barevclub.com/clean — заказ уборки. Токены бренда из @barev/ui; главный акцент — крупная живая цена в зелёном блоке «Дом»,
|
||||
второй — «чек» цвета plum с зубчатым краем. Остальное — спокойные белые карточки. */
|
||||
|
||||
.cl-wrap {
|
||||
padding-bottom: 110px; /* место под нижнюю панель на телефоне */
|
||||
}
|
||||
|
||||
/* ───── hero: калькулятор ───── */
|
||||
.cl-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
|
||||
gap: 28px;
|
||||
padding: 40px;
|
||||
border-radius: 36px;
|
||||
background: var(--bc-home);
|
||||
color: var(--bc-home-ink);
|
||||
}
|
||||
.cl-hero__main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
.cl-title {
|
||||
margin: 0;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 800;
|
||||
font-size: clamp(36px, 5vw, 64px);
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.03em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.cl-sub {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
line-height: 1.5;
|
||||
max-width: 52ch;
|
||||
}
|
||||
.cl-types {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.cl-type {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 22px;
|
||||
border: 2px solid transparent;
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.cl-type:hover {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
.cl-type[aria-checked='true'] {
|
||||
background: #fff;
|
||||
border-color: var(--bc-home-ink);
|
||||
}
|
||||
.cl-type__name {
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 700;
|
||||
font-size: 17px;
|
||||
}
|
||||
.cl-type__hint {
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.cl-type__from {
|
||||
margin-top: auto;
|
||||
padding-top: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cl-counts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.cl-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 4px 4px 4px 18px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cl-count .bc-stepper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cl-hero__price {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
/* главный элемент страницы: цена меняется при каждом выборе */
|
||||
.cl-price {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 800;
|
||||
font-size: clamp(52px, 6.6vw, 100px);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
padding-bottom: 0.06em;
|
||||
}
|
||||
.cl-price__out {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
animation: cl-out 0.24s ease-in forwards;
|
||||
}
|
||||
.cl-price__in {
|
||||
display: inline-block;
|
||||
animation: cl-in 0.24s ease-out;
|
||||
}
|
||||
@keyframes cl-out {
|
||||
to {
|
||||
transform: translateY(-60%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes cl-in {
|
||||
from {
|
||||
transform: translateY(60%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cl-price__out {
|
||||
display: none;
|
||||
}
|
||||
.cl-price__in {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.cl-duration {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.cl-plus {
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
background: var(--bc-pink);
|
||||
color: var(--bc-plum);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ───── форма + чек ───── */
|
||||
.cl-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 380px;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
.cl-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.cl-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 28px;
|
||||
border-radius: 32px;
|
||||
background: #fff;
|
||||
scroll-margin-top: 16px;
|
||||
}
|
||||
.cl-block[data-error='true'] {
|
||||
box-shadow: inset 0 0 0 2px var(--bc-danger);
|
||||
}
|
||||
.cl-block__title {
|
||||
margin: 0;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 700;
|
||||
font-size: 24px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.cl-block__hint {
|
||||
margin: -8px 0 0;
|
||||
}
|
||||
.cl-muted {
|
||||
color: var(--bc-ink-2);
|
||||
}
|
||||
.cl-small {
|
||||
font-size: 13px;
|
||||
}
|
||||
.cl-grid2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* доп. услуги: плитки; выбранная — зелёная, как «Дом» */
|
||||
.cl-extras {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.cl-extra {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
min-height: 96px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 22px;
|
||||
border: 2px solid var(--bc-line);
|
||||
background: #fff;
|
||||
color: var(--bc-ink);
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
button.cl-extra {
|
||||
cursor: pointer;
|
||||
padding-right: 48px;
|
||||
}
|
||||
.cl-extra[aria-pressed='true'],
|
||||
.cl-extra[data-on='true'] {
|
||||
border-color: var(--bc-home);
|
||||
background: color-mix(in srgb, var(--bc-home) 28%, #fff);
|
||||
}
|
||||
.cl-extra__name {
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.cl-extra__price {
|
||||
font-size: 13px;
|
||||
color: var(--bc-ink-2);
|
||||
}
|
||||
.cl-extra__tick {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bc-bg);
|
||||
}
|
||||
.cl-extra[aria-pressed='true'] .cl-extra__tick {
|
||||
background: var(--bc-home-ink);
|
||||
color: #fff;
|
||||
}
|
||||
.cl-extra .bc-stepper {
|
||||
margin-top: auto;
|
||||
background: var(--bc-bg);
|
||||
}
|
||||
.cl-extra .bc-stepper button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #fff;
|
||||
}
|
||||
.cl-extra .bc-stepper output {
|
||||
min-width: 28px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* дата и время */
|
||||
.cl-days {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.cl-day {
|
||||
flex: none;
|
||||
width: 60px;
|
||||
padding: 10px 0;
|
||||
border-radius: 20px;
|
||||
border: 2px solid var(--bc-line);
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: var(--bc-ink);
|
||||
}
|
||||
.cl-day[aria-selected='true'] {
|
||||
background: var(--bc-dark);
|
||||
border-color: var(--bc-dark);
|
||||
color: #fff;
|
||||
}
|
||||
.cl-day:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.cl-day__wd {
|
||||
font-size: 12px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.cl-day__num {
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 700;
|
||||
font-size: 20px;
|
||||
}
|
||||
.cl-times {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(78px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.cl-time {
|
||||
height: 44px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid var(--bc-line);
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: pointer;
|
||||
color: var(--bc-ink);
|
||||
}
|
||||
.cl-time[aria-pressed='true'] {
|
||||
background: var(--bc-home);
|
||||
border-color: var(--bc-home-ink);
|
||||
color: var(--bc-home-ink);
|
||||
}
|
||||
|
||||
.cl-consent {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cl-consent input {
|
||||
flex: none;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin: 0;
|
||||
accent-color: var(--bc-plum);
|
||||
}
|
||||
.cl-consent a {
|
||||
color: var(--bc-accent);
|
||||
}
|
||||
|
||||
/* видимый фокус для всего интерактивного на странице */
|
||||
.cl-wrap :is(button, a, input, select, textarea):focus-visible {
|
||||
outline: 3px solid var(--bc-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* чек: plum, зубчатый нижний край */
|
||||
.cl-receipt {
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 28px 26px 40px;
|
||||
border-radius: 32px 32px 0 0;
|
||||
background: var(--bc-plum);
|
||||
color: #fff;
|
||||
--tooth: 14px;
|
||||
-webkit-mask:
|
||||
linear-gradient(#000 0 0) top / 100% calc(100% - var(--tooth)) no-repeat,
|
||||
radial-gradient(circle at 50% 0, #000 calc(var(--tooth) - 0.5px), transparent var(--tooth)) bottom / calc(var(--tooth) * 2) var(--tooth) repeat-x;
|
||||
mask:
|
||||
linear-gradient(#000 0 0) top / 100% calc(100% - var(--tooth)) no-repeat,
|
||||
radial-gradient(circle at 50% 0, #000 calc(var(--tooth) - 0.5px), transparent var(--tooth)) bottom / calc(var(--tooth) * 2) var(--tooth) repeat-x;
|
||||
}
|
||||
.cl-receipt__title {
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.cl-line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.cl-line > span:last-child {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cl-line small {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 13px;
|
||||
color: #e9c9d5;
|
||||
}
|
||||
.cl-line--extra {
|
||||
font-size: 14px;
|
||||
color: #f4dce5;
|
||||
}
|
||||
.cl-receipt__rule {
|
||||
border-top: 2px dashed rgba(255, 255, 255, 0.28);
|
||||
margin: 4px 0;
|
||||
}
|
||||
.cl-line--total {
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 800;
|
||||
font-size: 28px;
|
||||
}
|
||||
.cl-receipt__when {
|
||||
margin-top: -4px;
|
||||
font-size: 14px;
|
||||
color: #e9c9d5;
|
||||
}
|
||||
.cl-receipt__when::first-letter {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.cl-btn-pink {
|
||||
background: var(--bc-pink);
|
||||
color: var(--bc-plum);
|
||||
font-weight: 700;
|
||||
}
|
||||
.cl-btn-pink:hover {
|
||||
background: #fbd5e1;
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.cl-receipt .cl-btn-pink {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.cl-receipt__error {
|
||||
padding: 10px 14px;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
color: var(--bc-danger);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.cl-receipt__after {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: #e9c9d5;
|
||||
}
|
||||
|
||||
/* нижняя панель — только на телефоне */
|
||||
.cl-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ───── заявка отправлена ───── */
|
||||
.cl-done {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 18px;
|
||||
padding: 48px 40px;
|
||||
border-radius: 36px;
|
||||
background: var(--bc-plum);
|
||||
color: #fff;
|
||||
}
|
||||
.cl-done__mark {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bc-home);
|
||||
color: var(--bc-home-ink);
|
||||
}
|
||||
.cl-done__title {
|
||||
margin: 0;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 800;
|
||||
font-size: clamp(32px, 5vw, 56px);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.cl-done__text {
|
||||
margin: 0;
|
||||
max-width: 52ch;
|
||||
font-size: 18px;
|
||||
line-height: 1.5;
|
||||
color: #f4dce5;
|
||||
}
|
||||
.cl-done__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.cl-done__again {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ───── что входит ───── */
|
||||
.cl-included {
|
||||
padding: 32px;
|
||||
border-radius: 36px;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.cl-section-title {
|
||||
margin: 0;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 700;
|
||||
font-size: 34px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.cl-zones {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.cl-zone {
|
||||
padding: 22px;
|
||||
border-radius: 26px;
|
||||
background: var(--bc-bg);
|
||||
}
|
||||
.cl-zone h3 {
|
||||
margin: 0 0 10px;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 700;
|
||||
font-size: 20px;
|
||||
}
|
||||
.cl-zone ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.cl-zone li {
|
||||
position: relative;
|
||||
padding-left: 22px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.cl-zone li::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: 0.45em;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--bc-home);
|
||||
}
|
||||
.cl-variants {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.cl-variants p {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
.cl-how__grid {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ───── адаптив ───── */
|
||||
@media (max-width: 980px) {
|
||||
.cl-hero,
|
||||
.cl-body {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.cl-receipt {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.cl-hero {
|
||||
padding: 24px;
|
||||
border-radius: 28px;
|
||||
gap: 20px;
|
||||
}
|
||||
.cl-types {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.cl-type {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
column-gap: 12px;
|
||||
}
|
||||
.cl-type__hint {
|
||||
grid-column: 1;
|
||||
}
|
||||
.cl-type__from {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
align-self: center;
|
||||
padding-top: 0;
|
||||
}
|
||||
.cl-block {
|
||||
padding: 22px 18px;
|
||||
border-radius: 28px;
|
||||
}
|
||||
.cl-grid2,
|
||||
.cl-zones,
|
||||
.cl-variants,
|
||||
.cl-how__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
/* доп. услуги на телефоне — список: название и цена слева, переключатель справа */
|
||||
.cl-extras {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
.cl-extra {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
column-gap: 10px;
|
||||
min-height: 64px;
|
||||
padding: 10px 10px 10px 16px;
|
||||
}
|
||||
.cl-extra__price {
|
||||
grid-column: 1;
|
||||
}
|
||||
.cl-extra .bc-stepper {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
margin-top: 0;
|
||||
}
|
||||
button.cl-extra {
|
||||
padding-right: 56px;
|
||||
}
|
||||
.cl-extra__tick {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.cl-included,
|
||||
.cl-done {
|
||||
padding: 24px;
|
||||
border-radius: 28px;
|
||||
}
|
||||
.cl-receipt .cl-btn-pink {
|
||||
display: none;
|
||||
}
|
||||
.cl-bar {
|
||||
position: fixed;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 10px 10px 20px;
|
||||
border-radius: 999px;
|
||||
background: var(--bc-plum);
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 30px rgba(74, 16, 40, 0.3);
|
||||
}
|
||||
.cl-bar__total {
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 800;
|
||||
font-size: 20px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.cl-bar__meta {
|
||||
font-size: 12px;
|
||||
color: #e9c9d5;
|
||||
max-width: 40vw;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cl-bar .bc-btn {
|
||||
height: 48px;
|
||||
padding: 0 18px;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1,467 @@
|
||||
/* /ru/apartments: квартиры из CRM — тёмная тема главной, карточки светлые, как клубная карта */
|
||||
|
||||
.fl-hero {
|
||||
padding: 24px 8px 4px;
|
||||
}
|
||||
.fl-hero .s-lead {
|
||||
max-width: 68ch;
|
||||
}
|
||||
|
||||
.fl-filter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
.fl-chip {
|
||||
height: 40px;
|
||||
padding: 0 18px;
|
||||
border-radius: var(--bc-pill);
|
||||
border: 2px solid color-mix(in srgb, var(--bc-pink) 45%, transparent);
|
||||
background: transparent;
|
||||
color: var(--bc-bg);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.fl-chip[aria-pressed='true'] {
|
||||
background: var(--bc-pink);
|
||||
border-color: var(--bc-pink);
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.fl-count {
|
||||
margin-left: auto;
|
||||
font-size: 14px;
|
||||
color: #e9c9d5;
|
||||
}
|
||||
|
||||
.fl-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 20px;
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
list-style: none;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.fl-list {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.fl-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-radius: var(--bc-r-lg);
|
||||
background: var(--bc-bg);
|
||||
color: var(--bc-ink);
|
||||
}
|
||||
.fl-card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 20px 22px 22px;
|
||||
flex: 1;
|
||||
}
|
||||
.fl-card__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
.fl-card__title {
|
||||
margin: 0;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
line-height: 1.15;
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.fl-card__facts {
|
||||
margin: 6px 0 0;
|
||||
font-size: 15px;
|
||||
color: #6b5d58;
|
||||
}
|
||||
.fl-card__price {
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 800;
|
||||
font-size: 20px;
|
||||
line-height: 1.15;
|
||||
text-align: right;
|
||||
color: var(--bc-accent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 420px) {
|
||||
.fl-card__head {
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.fl-card__price {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.fl-text p {
|
||||
margin: 0;
|
||||
white-space: pre-line;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.fl-text[data-open='false'] p {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 7;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.fl-more {
|
||||
align-self: flex-start;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--bc-accent);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.fl-book {
|
||||
width: 100%;
|
||||
margin-top: auto;
|
||||
background: var(--bc-plum);
|
||||
color: #fff;
|
||||
}
|
||||
.fl-card .fl-book {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* галерея */
|
||||
.fl-gallery {
|
||||
position: relative;
|
||||
aspect-ratio: 4 / 3;
|
||||
background: #2f0a19;
|
||||
}
|
||||
.fl-gallery--empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #e9c9d5;
|
||||
font-size: 15px;
|
||||
}
|
||||
.fl-gallery__track {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
overscroll-behavior-x: contain;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.fl-gallery__track::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.fl-gallery__slide {
|
||||
flex: 0 0 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
scroll-snap-align: start;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.fl-gallery__slide img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.fl-gallery__nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
translate: 0 -50%;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(251, 246, 238, 0.92);
|
||||
color: var(--bc-plum);
|
||||
font-size: 30px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--bc-shadow);
|
||||
}
|
||||
.fl-gallery__nav[disabled] {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.fl-gallery__nav--prev {
|
||||
left: 12px;
|
||||
}
|
||||
.fl-gallery__nav--next {
|
||||
right: 12px;
|
||||
}
|
||||
.fl-gallery__count {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--bc-pill);
|
||||
background: rgba(30, 27, 26, 0.7);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@media (hover: none) {
|
||||
.fl-gallery:not(.fl-gallery--full) .fl-gallery__nav {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* фото на весь экран */
|
||||
.fl-viewer {
|
||||
width: 100vw;
|
||||
max-width: none;
|
||||
height: 100dvh;
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: #120509;
|
||||
color: #fff;
|
||||
}
|
||||
.fl-viewer[open] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.fl-viewer::backdrop {
|
||||
background: #120509;
|
||||
}
|
||||
.fl-viewer__bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.fl-gallery--full {
|
||||
flex: 1;
|
||||
aspect-ratio: auto;
|
||||
min-height: 0;
|
||||
background: none;
|
||||
}
|
||||
.fl-gallery--full .fl-gallery__slide {
|
||||
cursor: default;
|
||||
}
|
||||
.fl-gallery--full .fl-gallery__slide img {
|
||||
object-fit: contain;
|
||||
}
|
||||
.fl-viewer__book {
|
||||
width: auto;
|
||||
margin: 12px 16px 16px;
|
||||
background: var(--bc-pink);
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.fl-close {
|
||||
flex-shrink: 0;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: inherit;
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* запись на просмотр */
|
||||
.fl-modal {
|
||||
width: min(560px, calc(100vw - 24px));
|
||||
max-height: calc(100dvh - 24px);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--bc-r-lg);
|
||||
background: var(--bc-bg);
|
||||
color: var(--bc-ink);
|
||||
}
|
||||
.fl-modal::backdrop {
|
||||
background: rgba(30, 10, 18, 0.7);
|
||||
}
|
||||
.fl-modal__box {
|
||||
position: relative;
|
||||
padding: 28px 24px 24px;
|
||||
}
|
||||
.fl-modal__close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
background: rgba(74, 16, 40, 0.08);
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.fl-modal h2 {
|
||||
margin: 0 48px 0 0;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 800;
|
||||
font-size: 26px;
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.fl-modal__flat {
|
||||
margin: 6px 0 18px;
|
||||
color: #6b5d58;
|
||||
font-size: 15px;
|
||||
}
|
||||
.fl-field {
|
||||
margin: 0 0 16px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.fl-field legend,
|
||||
.fl-input span {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.fl-days {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.fl-times {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.fl-day input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.fl-day span {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
min-width: 72px;
|
||||
min-height: 60px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--bc-r-sm);
|
||||
border: 2px solid var(--bc-line);
|
||||
background: var(--bc-surface);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
.fl-day small {
|
||||
font-size: 12px;
|
||||
color: #6b5d58;
|
||||
}
|
||||
.fl-day b {
|
||||
font-size: 15px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fl-day input:checked + span {
|
||||
border-color: var(--bc-plum);
|
||||
background: var(--bc-plum);
|
||||
color: #fff;
|
||||
}
|
||||
.fl-day input:checked + span small {
|
||||
color: #f4dce5;
|
||||
}
|
||||
.fl-day input:focus-visible + span {
|
||||
outline: 3px solid var(--bc-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.fl-input {
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.fl-input input {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
padding: 0 16px;
|
||||
border-radius: 14px;
|
||||
border: 2px solid var(--bc-line);
|
||||
background: var(--bc-surface);
|
||||
font: inherit;
|
||||
font-size: 16px;
|
||||
color: var(--bc-ink);
|
||||
}
|
||||
.fl-input input:focus {
|
||||
outline: none;
|
||||
border-color: var(--bc-plum);
|
||||
}
|
||||
.fl-contacts {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.fl-contacts {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
.fl-hp {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.fl-error {
|
||||
margin: 4px 0 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--bc-danger) 10%, transparent);
|
||||
color: var(--bc-danger);
|
||||
font-size: 14px;
|
||||
}
|
||||
.fl-error a {
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
.fl-note {
|
||||
margin: 12px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: #6b5d58;
|
||||
}
|
||||
.fl-note a {
|
||||
color: inherit;
|
||||
}
|
||||
.fl-done p {
|
||||
margin: 12px 0 20px;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.fl-empty {
|
||||
margin: 8px;
|
||||
padding: 32px 24px;
|
||||
border-radius: var(--bc-r-lg);
|
||||
background: color-mix(in srgb, var(--bc-pink) 12%, transparent);
|
||||
}
|
||||
.fl-empty h2 {
|
||||
margin: 0;
|
||||
font-family: var(--bc-font-head);
|
||||
font-size: 24px;
|
||||
}
|
||||
.fl-empty p {
|
||||
margin: 10px 0 20px;
|
||||
color: #f4dce5;
|
||||
}
|
||||
.fl-empty .fl-book {
|
||||
width: auto;
|
||||
background: var(--bc-pink);
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
/* /ru/flower — цветы по подписке. Продолжение главной v2: plum, огромное слово, таймлайн и тарифы-карты.
|
||||
Здесь только своё: наклейка с ценой, переключатель частоты, карты S/M/L, сезоны, подарок и гарантия. */
|
||||
|
||||
/* ───── hero: «Цветы» + наклейка «от 8 900 ֏» ───── */
|
||||
.fw-word {
|
||||
position: relative;
|
||||
justify-self: start;
|
||||
font-size: clamp(84px, 15vw, 220px);
|
||||
}
|
||||
.fw-word__sticker {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -64px;
|
||||
font-style: normal;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.fw-word__sticker .bc-flower > span {
|
||||
line-height: 1.05;
|
||||
text-align: center;
|
||||
max-width: 76%;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.fw-next {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: #e9c9d5;
|
||||
}
|
||||
.fw-next svg {
|
||||
vertical-align: -3px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.fw-next b {
|
||||
color: var(--bc-bg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ───── переключатель частоты: как «Переезжаю / Уже живу» ───── */
|
||||
.fw-freq {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-self: flex-start;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.fw-freq button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
padding: 0 18px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: var(--bc-bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.fw-freq button:hover {
|
||||
color: var(--bc-pink);
|
||||
}
|
||||
.fw-freq button[aria-checked='true'] {
|
||||
background: var(--bc-pink);
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.fw-freq__save {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--bc-home);
|
||||
color: var(--bc-home-ink);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ───── карты S / M / L ───── */
|
||||
.fw-plan {
|
||||
position: relative;
|
||||
}
|
||||
.fw-plan__badge {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: -12px;
|
||||
right: 16px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--bc-home);
|
||||
color: var(--bc-home-ink);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
transform: rotate(3deg);
|
||||
}
|
||||
.fw-plan__photo {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
border-radius: 24px;
|
||||
}
|
||||
.fw-card {
|
||||
grid-template-areas: 'club chip' '. .' 'name name' 'price price';
|
||||
}
|
||||
/* вместо «чипа» банковской карты — крупная буква размера */
|
||||
.fw-card__letter {
|
||||
grid-area: chip;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 800;
|
||||
font-style: italic;
|
||||
font-size: 56px;
|
||||
line-height: 0.8;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.fw-card .h-card__price small {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.fw-plan[data-size='s'] .h-card {
|
||||
border: 1.5px solid rgba(246, 195, 211, 0.45);
|
||||
color: var(--bc-bg);
|
||||
}
|
||||
.fw-plan[data-size='m'] .h-card {
|
||||
background: var(--bc-pink);
|
||||
}
|
||||
.fw-plan[data-size='l'] .h-card {
|
||||
background: var(--bc-perks);
|
||||
}
|
||||
.fw-plan[data-size='m'] .h-plan__cta {
|
||||
background: var(--bc-pink);
|
||||
border-color: var(--bc-pink);
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.fw-plan[data-size='l'] .h-plan__cta {
|
||||
background: var(--bc-perks);
|
||||
border-color: var(--bc-perks);
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.fw-plan[data-size='l'] .h-plan__list svg {
|
||||
color: var(--bc-perks);
|
||||
}
|
||||
.fw-plan[data-size='m'] .h-plan__cta:hover,
|
||||
.fw-plan[data-size='l'] .h-plan__cta:hover {
|
||||
opacity: 0.92;
|
||||
}
|
||||
.fw-plan__facts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.fw-plan__facts span {
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1.5px solid rgba(246, 195, 211, 0.3);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #f4dce5;
|
||||
}
|
||||
.fw-plan__anchor {
|
||||
margin: -6px 4px 0;
|
||||
font-size: 14px;
|
||||
color: #d9b4c3;
|
||||
}
|
||||
.fw-plan__anchor s {
|
||||
text-decoration-thickness: 1.5px;
|
||||
}
|
||||
.fw-plan__month {
|
||||
margin: 0 4px;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
.fw-plan__month b {
|
||||
color: var(--bc-home);
|
||||
font-weight: 600;
|
||||
}
|
||||
.fw-plans__note {
|
||||
margin: 0;
|
||||
padding: 0 8px;
|
||||
font-size: 14px;
|
||||
color: #d9b4c3;
|
||||
}
|
||||
|
||||
/* ───── сезоны ───── */
|
||||
.fw-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
margin-top: 56px;
|
||||
}
|
||||
.fw-seasons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.fw-season {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 200px;
|
||||
padding: 26px;
|
||||
border-radius: 32px;
|
||||
}
|
||||
.fw-season h3 {
|
||||
margin: 0;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 800;
|
||||
font-style: italic;
|
||||
font-size: 30px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.fw-season p {
|
||||
margin: auto 0 0;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ───── подарок из-за рубежа + гарантия ───── */
|
||||
.fw-duo {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
margin-top: 56px;
|
||||
}
|
||||
.fw-gift,
|
||||
.fw-guarantee {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 36px;
|
||||
border-radius: 36px;
|
||||
}
|
||||
.fw-gift {
|
||||
background: var(--bc-blue);
|
||||
color: var(--bc-blue-ink);
|
||||
}
|
||||
.fw-guarantee {
|
||||
background: var(--bc-home);
|
||||
color: var(--bc-home-ink);
|
||||
}
|
||||
.fw-gift h2,
|
||||
.fw-guarantee h2 {
|
||||
margin: 0;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 800;
|
||||
font-size: clamp(28px, 3vw, 40px);
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.03em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.fw-gift p,
|
||||
.fw-guarantee p {
|
||||
margin: 0;
|
||||
max-width: 52ch;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.fw-kicker {
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.fw-gift__prices {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.fw-gift__prices div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 18px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
.fw-gift__prices b {
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 800;
|
||||
font-style: italic;
|
||||
font-size: 30px;
|
||||
line-height: 1;
|
||||
}
|
||||
.fw-gift__prices span {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.fw-gift__cta {
|
||||
align-self: flex-start;
|
||||
margin-top: 6px;
|
||||
height: 52px;
|
||||
background: var(--bc-blue-ink);
|
||||
color: #fff;
|
||||
}
|
||||
.fw-gift__cta:hover {
|
||||
color: #fff;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
/* ───── вопросы: розовый блок с белыми карточками из landing.css ───── */
|
||||
.fw-faq-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
margin-top: 56px;
|
||||
}
|
||||
.fw-faq {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.fw-seasons {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.fw-duo {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.fw-word__sticker {
|
||||
top: auto;
|
||||
bottom: -18px;
|
||||
right: -40px;
|
||||
}
|
||||
.fw-word__sticker .bc-flower {
|
||||
width: 88px !important;
|
||||
height: 88px !important;
|
||||
}
|
||||
.fw-word__sticker .bc-flower svg {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
}
|
||||
.fw-freq {
|
||||
align-self: stretch;
|
||||
}
|
||||
.fw-freq button {
|
||||
flex: 1 1 0;
|
||||
justify-content: center;
|
||||
padding: 0 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.fw-seasons,
|
||||
.fw-faq {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.fw-season {
|
||||
min-height: 0;
|
||||
}
|
||||
.fw-gift,
|
||||
.fw-guarantee {
|
||||
padding: 26px 22px;
|
||||
border-radius: 28px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
/* Главная (/ и /live): продолжение версии v2 — plum, огромное «Barev», таймлайн.
|
||||
Здесь только то, чего нет в variants.css: переключатель аудитории, тарифы-карты, прайс скидок и квиз. */
|
||||
|
||||
.h-switch {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.h-switch a {
|
||||
padding: 10px 18px;
|
||||
border-radius: 999px;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: var(--bc-bg);
|
||||
text-decoration: none;
|
||||
}
|
||||
.h-switch a:hover {
|
||||
color: var(--bc-pink);
|
||||
}
|
||||
.h-switch a[aria-current='page'] {
|
||||
background: var(--bc-pink);
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
|
||||
.h-hero__side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
.h-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.h-cta,
|
||||
.h-cta-ghost {
|
||||
height: 56px;
|
||||
padding: 0 28px;
|
||||
font-size: 17px;
|
||||
}
|
||||
.h-cta {
|
||||
background: var(--bc-pink);
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.h-cta:hover {
|
||||
color: var(--bc-plum);
|
||||
opacity: 0.92;
|
||||
}
|
||||
.h-cta-ghost {
|
||||
background: transparent;
|
||||
border: 1.5px solid rgba(246, 195, 211, 0.5);
|
||||
color: var(--bc-bg);
|
||||
}
|
||||
.h-cta-ghost:hover {
|
||||
border-color: var(--bc-pink);
|
||||
color: var(--bc-pink);
|
||||
}
|
||||
|
||||
/* шаг «Прилёт»: цена с подпиской и без — столбиком */
|
||||
.v2-step__meta:has(.h-chip-note) {
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
.h-chip-note {
|
||||
font-size: 13px;
|
||||
color: #e9c9d5;
|
||||
}
|
||||
|
||||
.h-section-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.h-section-head h2 {
|
||||
margin: 0;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 800;
|
||||
font-size: clamp(32px, 4vw, 52px);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.h-section-head p {
|
||||
margin: 0;
|
||||
max-width: 52ch;
|
||||
font-size: 17px;
|
||||
line-height: 1.5;
|
||||
color: #e9c9d5;
|
||||
}
|
||||
|
||||
/* ═════════ тарифы: клубные карты ═════════ */
|
||||
.h-plans {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
margin-top: 40px;
|
||||
}
|
||||
.h-plans__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 28px;
|
||||
}
|
||||
.h-plan {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.h-card {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-rows: auto 1fr auto auto;
|
||||
grid-template-areas: 'club chip' '. .' 'name name' 'price price';
|
||||
aspect-ratio: 1.586;
|
||||
padding: 20px 22px;
|
||||
border-radius: 18px;
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.h-plan[data-plan='free'] .h-card {
|
||||
border: 1.5px solid rgba(246, 195, 211, 0.45);
|
||||
color: var(--bc-bg);
|
||||
}
|
||||
.h-plan[data-plan='plus'] .h-card {
|
||||
background: var(--bc-pink);
|
||||
}
|
||||
.h-plan[data-plan='elite'] .h-card {
|
||||
background: var(--bc-perks);
|
||||
}
|
||||
.h-card__club {
|
||||
grid-area: club;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
opacity: 0.8;
|
||||
}
|
||||
/* контактная площадка карты */
|
||||
.h-card__chip {
|
||||
grid-area: chip;
|
||||
width: 42px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, currentColor 16%, transparent);
|
||||
border: 1.5px solid color-mix(in srgb, currentColor 30%, transparent);
|
||||
}
|
||||
.h-card__name {
|
||||
grid-area: name;
|
||||
margin: 0;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 800;
|
||||
font-style: italic;
|
||||
font-size: clamp(30px, 3vw, 42px);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.h-card__price {
|
||||
grid-area: price;
|
||||
margin-top: 6px;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
}
|
||||
.h-plan__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
padding: 0 4px;
|
||||
list-style: none;
|
||||
}
|
||||
.h-plan__list li {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
line-height: 1.4;
|
||||
color: #f4dce5;
|
||||
}
|
||||
.h-plan__list svg {
|
||||
flex: none;
|
||||
margin-top: 1px;
|
||||
color: var(--bc-pink);
|
||||
}
|
||||
.h-plan[data-plan='elite'] .h-plan__list svg {
|
||||
color: var(--bc-perks);
|
||||
}
|
||||
.h-plan__cta {
|
||||
margin-top: auto;
|
||||
height: 52px;
|
||||
background: transparent;
|
||||
border: 1.5px solid rgba(246, 195, 211, 0.5);
|
||||
color: var(--bc-bg);
|
||||
}
|
||||
.h-plan__cta:hover {
|
||||
color: var(--bc-pink);
|
||||
border-color: var(--bc-pink);
|
||||
}
|
||||
.h-plan[data-plan='plus'] .h-plan__cta {
|
||||
background: var(--bc-pink);
|
||||
border-color: var(--bc-pink);
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.h-plan[data-plan='elite'] .h-plan__cta {
|
||||
background: var(--bc-perks);
|
||||
border-color: var(--bc-perks);
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.h-plan[data-plan='plus'] .h-plan__cta:hover,
|
||||
.h-plan[data-plan='elite'] .h-plan__cta:hover {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
/* ═════════ /live: прайс скидок с отточиями ═════════ */
|
||||
.h-price {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.h-price__list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.h-price__row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(24px, 1fr) auto;
|
||||
grid-template-areas: 'name dots value' 'hint hint hint';
|
||||
align-items: baseline;
|
||||
column-gap: 14px;
|
||||
row-gap: 4px;
|
||||
padding: 20px 8px;
|
||||
border-top: 1px solid rgba(246, 195, 211, 0.16);
|
||||
}
|
||||
.h-price__name {
|
||||
grid-area: name;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 700;
|
||||
font-size: clamp(22px, 2.6vw, 32px);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.h-price__dots {
|
||||
grid-area: dots;
|
||||
border-bottom: 2px dotted rgba(246, 195, 211, 0.4);
|
||||
}
|
||||
.h-price__value {
|
||||
grid-area: value;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 800;
|
||||
font-style: italic;
|
||||
font-size: clamp(24px, 3vw, 38px);
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--bc-pink);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.h-price__row[data-kind='from'] .h-price__value {
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-size: clamp(18px, 2vw, 24px);
|
||||
color: var(--bc-perks);
|
||||
}
|
||||
.h-price__row[data-kind='soon'] .h-price__name,
|
||||
.h-price__row[data-kind='soon'] .h-price__hint {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.h-price__row[data-kind='soon'] .h-price__value {
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-size: 16px;
|
||||
color: #e9c9d5;
|
||||
}
|
||||
.h-price__order {
|
||||
grid-area: value;
|
||||
align-self: center;
|
||||
height: 46px;
|
||||
padding: 0 22px;
|
||||
background: var(--bc-home);
|
||||
color: var(--bc-home-ink);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.h-price__order:hover {
|
||||
color: var(--bc-home-ink);
|
||||
opacity: 0.9;
|
||||
}
|
||||
.h-price__hint {
|
||||
grid-area: hint;
|
||||
font-size: 15px;
|
||||
color: #e9c9d5;
|
||||
}
|
||||
|
||||
.h-footer__brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.h-footer__legal {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.h-footer__links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.h-plans__grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
max-width: 440px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.h-switch {
|
||||
align-self: stretch;
|
||||
}
|
||||
.h-switch a {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 10px;
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
}
|
||||
.h-cta,
|
||||
.h-cta-ghost {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
.v2-step__meta:has(.h-chip-note) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.h-section-head,
|
||||
.h-price__row {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═════════ квиз ═════════ */
|
||||
body:has(.q[open]) {
|
||||
overflow: hidden;
|
||||
}
|
||||
.q {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.q[open] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.q::backdrop {
|
||||
background: rgba(36, 8, 20, 0.62);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.q-panel {
|
||||
width: min(560px, 100%);
|
||||
max-height: 100%;
|
||||
overflow: auto;
|
||||
padding: 18px 28px 30px;
|
||||
border-radius: 32px;
|
||||
background: var(--bc-bg);
|
||||
color: var(--bc-ink);
|
||||
}
|
||||
.q :is(a, button, input):focus-visible {
|
||||
outline: 3px solid var(--bc-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.q-top {
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr 44px;
|
||||
align-items: center;
|
||||
}
|
||||
.q-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
color: var(--bc-plum);
|
||||
cursor: pointer;
|
||||
}
|
||||
/* прогресс — те же точки на оси, что в таймлайне главной */
|
||||
.q-dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.q-dots li {
|
||||
position: relative;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--bc-plum);
|
||||
transition: background-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.q-dots li + li {
|
||||
margin-left: 26px;
|
||||
}
|
||||
.q-dots li + li::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: calc(100% + 2px);
|
||||
top: 50%;
|
||||
width: 26px;
|
||||
height: 2px;
|
||||
margin-top: -1px;
|
||||
background: rgba(74, 16, 40, 0.2);
|
||||
}
|
||||
.q-dots li[data-state='past'] {
|
||||
background: var(--bc-plum);
|
||||
}
|
||||
.q-dots li[data-state='past']::before,
|
||||
.q-dots li[data-state='now']::before {
|
||||
background: var(--bc-plum);
|
||||
}
|
||||
.q-dots li[data-state='now'] {
|
||||
background: var(--bc-accent);
|
||||
border-color: var(--bc-accent);
|
||||
box-shadow: 0 0 0 5px rgba(184, 35, 90, 0.18);
|
||||
}
|
||||
.q-step {
|
||||
animation: q-in 0.32s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.q-step[data-dir='back'] {
|
||||
animation-name: q-in-back;
|
||||
}
|
||||
@keyframes q-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(28px);
|
||||
}
|
||||
}
|
||||
@keyframes q-in-back {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-28px);
|
||||
}
|
||||
}
|
||||
.q-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.q-title {
|
||||
margin: 26px 0 4px;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 800;
|
||||
font-size: clamp(26px, 4vw, 34px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--bc-plum);
|
||||
text-wrap: balance;
|
||||
}
|
||||
.q-title:focus {
|
||||
outline: none;
|
||||
}
|
||||
.q-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.q-field > span {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--bc-ink-2);
|
||||
}
|
||||
.q-field small,
|
||||
.q-error {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--bc-danger);
|
||||
}
|
||||
.q-input {
|
||||
width: 100%;
|
||||
height: 54px;
|
||||
padding: 0 16px;
|
||||
border: 1.5px solid var(--bc-line);
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 17px;
|
||||
color: var(--bc-ink);
|
||||
}
|
||||
.q-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--bc-plum);
|
||||
}
|
||||
.q-field[data-error='true'] .q-input {
|
||||
border-color: var(--bc-danger);
|
||||
}
|
||||
.q-primary {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
margin-top: 4px;
|
||||
background: var(--bc-plum);
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
}
|
||||
.q-primary:hover {
|
||||
color: #fff;
|
||||
opacity: 0.92;
|
||||
}
|
||||
.q-primary:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.q-consent {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: var(--bc-ink-3);
|
||||
}
|
||||
.q-consent a {
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.q-choices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.q-choice {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
padding: 18px 20px;
|
||||
border: 1.5px solid var(--bc-line);
|
||||
border-radius: 20px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 17px;
|
||||
color: var(--bc-ink);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background-color 0.15s;
|
||||
}
|
||||
.q-choice:hover {
|
||||
border-color: var(--bc-plum);
|
||||
}
|
||||
.q-choice[aria-checked='true'] {
|
||||
border-color: var(--bc-plum);
|
||||
background: var(--bc-pink);
|
||||
}
|
||||
.q-choice:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
.q-choice--tall > span:last-child {
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
color: var(--bc-ink-2);
|
||||
}
|
||||
.q-choice__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
.q-price {
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 700;
|
||||
color: var(--bc-plum);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.q-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--bc-line);
|
||||
}
|
||||
.q-row__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.q-row__label {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.q-row__hint {
|
||||
font-size: 13px;
|
||||
color: var(--bc-ok);
|
||||
}
|
||||
.q-seg {
|
||||
display: inline-flex;
|
||||
padding: 3px;
|
||||
border: 1.5px solid var(--bc-line);
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
}
|
||||
.q-seg button {
|
||||
min-height: 38px;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--bc-ink-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.q-seg button[aria-checked='true'] {
|
||||
background: var(--bc-plum);
|
||||
color: #fff;
|
||||
}
|
||||
.q-budget {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
margin: 6px 0 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
.q-budget legend {
|
||||
margin-bottom: 6px;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
color: var(--bc-ink-2);
|
||||
}
|
||||
.q-budget label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--bc-ink-3);
|
||||
}
|
||||
.q-budget .q-input {
|
||||
height: 46px;
|
||||
}
|
||||
.q-done {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.q-done .q-title {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.q-done__mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-top: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--bc-home);
|
||||
color: var(--bc-home-ink);
|
||||
}
|
||||
.q-lead {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
line-height: 1.5;
|
||||
color: var(--bc-ink-2);
|
||||
}
|
||||
.q-link {
|
||||
padding: 10px;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--bc-plum);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.q {
|
||||
padding: 0;
|
||||
}
|
||||
.q[open] {
|
||||
align-items: flex-end;
|
||||
}
|
||||
.q-panel {
|
||||
max-height: 94%;
|
||||
padding: 14px 18px 28px;
|
||||
border-radius: 28px 28px 0 0;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.q-step {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* заголовок шага таймлайна — ссылка на страницу услуги, подчёркивание только при наведении */
|
||||
.v2-step__body h2 a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.v2-step__body h2 a:hover {
|
||||
color: var(--bc-pink);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 6px;
|
||||
}
|
||||
/* кнопка «Подробнее →» в шаге таймлайна */
|
||||
.h-more {
|
||||
height: 40px;
|
||||
padding: 0 18px;
|
||||
font-size: 15px;
|
||||
background: transparent;
|
||||
border: 1.5px solid var(--bc-pink);
|
||||
color: var(--bc-pink);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.h-more:hover {
|
||||
background: var(--bc-pink);
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.h-more--soon,
|
||||
.h-more--soon:hover {
|
||||
background: transparent;
|
||||
color: var(--bc-pink);
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
/* ссылки на услуги в подвале */
|
||||
.h-footer__services {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 18px;
|
||||
width: 100%;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(246, 195, 211, 0.16);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="12" fill="#4A1028"/><g transform="translate(6 6) scale(1.5)" fill="none" stroke="#F6C3D3" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21.5a9.5 9.5 0 1 0 0-19 9.5 9.5 0 0 0 0 19zM8 12.5l2.6 2.6L16 9.5"/></g></svg>
|
||||
|
After Width: | Height: | Size: 341 B |
@@ -0,0 +1,635 @@
|
||||
/* next/font даёт хешированные имена семейств — подменяем токены шрифтов */
|
||||
:root {
|
||||
--bc-font-head: var(--font-rubik), var(--font-armenian), 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', system-ui, sans-serif;
|
||||
--bc-font-body: var(--font-onest), var(--font-armenian), 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', system-ui, sans-serif;
|
||||
}
|
||||
:root:lang(hy),
|
||||
:root:lang(zh) {
|
||||
--bc-font-head: var(--font-rubik), var(--font-armenian), 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', system-ui, sans-serif;
|
||||
--bc-font-body: var(--font-onest), var(--font-armenian), 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', system-ui, sans-serif;
|
||||
}
|
||||
a:hover {
|
||||
color: var(--bc-accent);
|
||||
}
|
||||
.wrap {
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 20px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 8px 8px 20px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.nav a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.langs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.lang {
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
color: var(--bc-ink);
|
||||
}
|
||||
.lang[aria-current='true'] {
|
||||
background: var(--bc-pink);
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
/* мобильное меню языков и короткая подпись кнопки Telegram: включаются на узком экране */
|
||||
.langs__row {
|
||||
display: contents;
|
||||
}
|
||||
.lang-menu,
|
||||
.tg-short {
|
||||
display: none;
|
||||
}
|
||||
.lang-menu {
|
||||
position: relative;
|
||||
}
|
||||
.lang-menu__current {
|
||||
gap: 6px;
|
||||
list-style: none;
|
||||
background: rgba(30, 27, 26, 0.06);
|
||||
}
|
||||
.lang-menu__current::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.lang-menu[open] .lang-menu__current svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.lang-menu__list {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 6px;
|
||||
border-radius: 24px;
|
||||
background: #fff;
|
||||
box-shadow: var(--bc-shadow);
|
||||
}
|
||||
.hero {
|
||||
flex: 999 1 560px;
|
||||
min-width: 0;
|
||||
padding: 40px;
|
||||
border-radius: 36px;
|
||||
background: var(--bc-pink);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.hero__tag {
|
||||
align-self: flex-start;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.hero__sub {
|
||||
margin: 0;
|
||||
font-size: 19px;
|
||||
line-height: 1.5;
|
||||
max-width: 560px;
|
||||
}
|
||||
.clean-promo {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 18px 18px 18px 22px;
|
||||
border-radius: 26px;
|
||||
background: #fff;
|
||||
color: var(--bc-ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
.clean-promo:hover {
|
||||
color: var(--bc-ink);
|
||||
}
|
||||
.clean-promo__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
flex: 1 1 260px;
|
||||
}
|
||||
.clean-promo__text b {
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 700;
|
||||
font-size: 20px;
|
||||
}
|
||||
.clean-promo__text span {
|
||||
font-size: 15px;
|
||||
color: var(--bc-ink-2);
|
||||
}
|
||||
.clean-promo__cta {
|
||||
flex: none;
|
||||
}
|
||||
.clean-promo:hover .clean-promo__cta {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.side {
|
||||
flex: 1 1 380px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.preview-row {
|
||||
padding: 12px 14px;
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.dir {
|
||||
flex: 1 1 270px;
|
||||
min-width: 0;
|
||||
padding: 24px;
|
||||
border-radius: 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.dir__title {
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 800;
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
}
|
||||
.how {
|
||||
flex: 999 1 640px;
|
||||
min-width: 0;
|
||||
padding: 28px;
|
||||
border-radius: 36px;
|
||||
background: var(--bc-dark);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
.how__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.how__step {
|
||||
padding: 16px;
|
||||
border-radius: 24px;
|
||||
background: var(--bc-dark-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.how__num {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
.agency {
|
||||
flex: 1 1 360px;
|
||||
min-width: 0;
|
||||
padding: 28px;
|
||||
border-radius: 36px;
|
||||
background: var(--bc-home);
|
||||
color: var(--bc-home-ink);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.sticker {
|
||||
width: 120px;
|
||||
height: 110px;
|
||||
flex: none;
|
||||
padding: 14px;
|
||||
background: var(--bc-perks);
|
||||
border-radius: 6px;
|
||||
transform: rotate(6deg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.plan {
|
||||
flex: 1 1 300px;
|
||||
min-width: 0;
|
||||
padding: 28px;
|
||||
border-radius: 36px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.plan__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.plan__list div {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.faq {
|
||||
flex: 1 1 340px;
|
||||
min-width: 0;
|
||||
padding: 28px;
|
||||
border-radius: 36px;
|
||||
background: var(--bc-pink);
|
||||
color: var(--bc-plum);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.faq details {
|
||||
border-radius: 20px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
color: var(--bc-ink);
|
||||
}
|
||||
.faq summary {
|
||||
min-height: 48px;
|
||||
padding: 12px 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.faq summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.faq summary::after {
|
||||
content: '+';
|
||||
font-size: 18px;
|
||||
}
|
||||
.faq details[open] summary::after {
|
||||
content: '−';
|
||||
}
|
||||
.faq details p {
|
||||
margin: 0;
|
||||
padding: 0 16px 14px;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
color: var(--bc-ink-2);
|
||||
}
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 18px 24px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
font-size: 14px;
|
||||
color: var(--bc-ink-2);
|
||||
}
|
||||
.footer__links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
}
|
||||
.prose {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* политика конфиденциальности */
|
||||
.policy {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.policy h1 {
|
||||
font-size: clamp(28px, 7vw, 44px);
|
||||
text-wrap: balance;
|
||||
}
|
||||
.policy__top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.policy__langs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.policy__langs a {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--bc-ink-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
.policy__langs a[aria-current='page'] {
|
||||
background: var(--bc-plum);
|
||||
color: #fff;
|
||||
}
|
||||
.policy__updated {
|
||||
color: var(--bc-ink-2);
|
||||
font-size: 14px;
|
||||
}
|
||||
.policy__toc {
|
||||
margin: 20px 0 8px;
|
||||
padding: 16px 20px 16px 40px;
|
||||
border-radius: 24px;
|
||||
background: #fff;
|
||||
}
|
||||
.policy__toc a {
|
||||
color: inherit;
|
||||
}
|
||||
.policy__section {
|
||||
scroll-margin-top: 16px;
|
||||
}
|
||||
.policy__section h2 {
|
||||
margin: 32px 0 8px;
|
||||
font-family: var(--bc-font-head);
|
||||
font-size: 22px;
|
||||
line-height: 1.25;
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.policy__section ul {
|
||||
padding-left: 22px;
|
||||
}
|
||||
.policy__section li {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.policy__footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 40px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid rgba(74, 16, 40, 0.15);
|
||||
font-size: 14px;
|
||||
color: var(--bc-ink-2);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.header {
|
||||
border-radius: 28px;
|
||||
padding: 12px 12px 12px 16px;
|
||||
}
|
||||
.nav {
|
||||
display: none;
|
||||
}
|
||||
.header {
|
||||
flex-wrap: nowrap;
|
||||
padding: 8px 8px 8px 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.header .langs {
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.header .langs > a {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
.langs__row {
|
||||
display: none;
|
||||
}
|
||||
.lang-menu {
|
||||
display: block;
|
||||
}
|
||||
.tg-long {
|
||||
display: none;
|
||||
}
|
||||
.tg-short {
|
||||
display: inline;
|
||||
}
|
||||
.hero {
|
||||
padding: 24px;
|
||||
border-radius: 28px;
|
||||
}
|
||||
.clean-promo__cta {
|
||||
width: 100%;
|
||||
}
|
||||
.how__grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.footer {
|
||||
border-radius: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.bc-row-pill > span:last-child {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.perks-big {
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 800;
|
||||
font-size: clamp(40px, 4.2vw, 64px);
|
||||
line-height: 1;
|
||||
letter-spacing: -2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* услуга ещё не запущена */
|
||||
.bc-row-pill[data-soon='true'] > span:last-child {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
opacity: 0.7;
|
||||
}
|
||||
a.bc-row-pill {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
a.bc-row-pill:hover {
|
||||
box-shadow: inset 0 0 0 2px currentColor;
|
||||
color: inherit;
|
||||
}
|
||||
.langs-sticker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 700;
|
||||
font-size: 20px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.langs-sticker__list {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
word-spacing: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
import { SITE } from '../lib/seo';
|
||||
|
||||
/**
|
||||
* Всем поисковикам и ботам ИИ (ChatGPT, Perplexity, Claude, Gemini) — можно всё, кроме служебных адресов.
|
||||
* Боты ИИ перечислены явно: так видно, что доступ им открыт намеренно.
|
||||
*/
|
||||
const AI_BOTS = [
|
||||
'OAI-SearchBot',
|
||||
'ChatGPT-User',
|
||||
'GPTBot',
|
||||
'PerplexityBot',
|
||||
'Perplexity-User',
|
||||
'Claude-SearchBot',
|
||||
'Claude-User',
|
||||
'ClaudeBot',
|
||||
'Google-Extended',
|
||||
'Applebot-Extended',
|
||||
];
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
// API и файлы на том же домене (nginx проксирует их в barev-app) — в поиске не нужны
|
||||
const disallow = ['/trpc/', '/files/', '/client-log'];
|
||||
return {
|
||||
rules: [
|
||||
{ userAgent: '*', allow: '/', disallow },
|
||||
{ userAgent: AI_BOTS, allow: '/', disallow },
|
||||
],
|
||||
sitemap: `${SITE}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
/* Страницы услуг (/social-card, /residence-permit, /airport): тёмная тема главной, текст — колонкой для чтения */
|
||||
|
||||
.s-crumbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 8px 8px 0;
|
||||
font-size: 14px;
|
||||
color: #e9c9d5;
|
||||
}
|
||||
.s-crumbs a {
|
||||
color: var(--bc-pink);
|
||||
}
|
||||
|
||||
.s-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
|
||||
gap: 32px;
|
||||
align-items: start;
|
||||
padding: 24px 8px 8px;
|
||||
}
|
||||
.s-hero__text {
|
||||
min-width: 0;
|
||||
}
|
||||
.s-title {
|
||||
margin: 0;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 800;
|
||||
font-size: clamp(34px, 5vw, 64px);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
text-wrap: balance;
|
||||
color: var(--bc-bg);
|
||||
}
|
||||
.s-lead {
|
||||
margin: 20px 0 0;
|
||||
max-width: 62ch;
|
||||
font-size: 18px;
|
||||
line-height: 1.6;
|
||||
color: #f4dce5;
|
||||
}
|
||||
.s-updated {
|
||||
margin: 14px 0 0;
|
||||
font-size: 13px;
|
||||
color: #d9b4c3;
|
||||
}
|
||||
|
||||
/* карточка цены — как клубная карта */
|
||||
.s-price {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 28px;
|
||||
border-radius: 32px;
|
||||
background: var(--bc-pink);
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.s-price__label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.s-price__value {
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 800;
|
||||
font-style: italic;
|
||||
font-size: clamp(36px, 4.4vw, 56px);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.s-price__note {
|
||||
font-size: 15px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.s-price__cta {
|
||||
margin-top: 16px;
|
||||
height: 56px;
|
||||
background: var(--bc-plum);
|
||||
color: #fff;
|
||||
}
|
||||
.s-price__cta:hover {
|
||||
color: #fff;
|
||||
opacity: 0.92;
|
||||
}
|
||||
.s-price__more {
|
||||
align-self: center;
|
||||
margin-top: 6px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
|
||||
.s-facts dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
.s-fact {
|
||||
padding: 18px 20px;
|
||||
border-radius: 24px;
|
||||
border: 1.5px solid rgba(246, 195, 211, 0.3);
|
||||
}
|
||||
.s-fact dt {
|
||||
font-size: 13px;
|
||||
color: #e9c9d5;
|
||||
}
|
||||
.s-fact dd {
|
||||
margin: 6px 0 0;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 700;
|
||||
font-size: 19px;
|
||||
line-height: 1.25;
|
||||
color: var(--bc-bg);
|
||||
}
|
||||
|
||||
.s-section {
|
||||
margin-top: 32px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.s-h2 {
|
||||
margin: 0 0 8px;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 800;
|
||||
font-size: clamp(28px, 3.4vw, 44px);
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.02em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.s-h3 {
|
||||
margin: 0 0 10px;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 700;
|
||||
font-size: 20px;
|
||||
}
|
||||
.s-steps .v2-step {
|
||||
grid-template-columns: 110px minmax(0, 1fr);
|
||||
}
|
||||
.s-steps .v2-step__body h3 {
|
||||
margin: 0;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 700;
|
||||
font-size: clamp(20px, 2.2vw, 26px);
|
||||
}
|
||||
|
||||
/* подробности — колонкой ~65 знаков */
|
||||
.s-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
max-width: 760px;
|
||||
margin-top: 40px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.s-block {
|
||||
font-size: 17px;
|
||||
line-height: 1.65;
|
||||
color: #f4dce5;
|
||||
}
|
||||
.s-block .s-h2 {
|
||||
font-size: clamp(24px, 2.6vw, 32px);
|
||||
color: var(--bc-bg);
|
||||
}
|
||||
.s-block p {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.s-block ul {
|
||||
margin: 0 0 10px;
|
||||
padding-left: 22px;
|
||||
}
|
||||
.s-block li {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.s-faq {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 860px;
|
||||
}
|
||||
.s-faq details {
|
||||
border-top: 1px solid rgba(246, 195, 211, 0.16);
|
||||
padding: 18px 0;
|
||||
}
|
||||
.s-faq summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.s-faq summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.s-faq summary::after {
|
||||
content: '+';
|
||||
flex: none;
|
||||
font-family: var(--bc-font-head);
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
color: var(--bc-pink);
|
||||
}
|
||||
.s-faq details[open] summary::after {
|
||||
content: '−';
|
||||
}
|
||||
.s-faq summary h3 {
|
||||
margin: 0;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 700;
|
||||
font-size: clamp(18px, 2vw, 22px);
|
||||
line-height: 1.3;
|
||||
}
|
||||
.s-faq details p {
|
||||
margin: 10px 0 0;
|
||||
max-width: 68ch;
|
||||
font-size: 17px;
|
||||
line-height: 1.6;
|
||||
color: #f4dce5;
|
||||
}
|
||||
|
||||
.s-meta {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 32px;
|
||||
margin-top: 16px;
|
||||
padding: 24px 8px 0;
|
||||
}
|
||||
.s-related {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.s-related a {
|
||||
display: inline-block;
|
||||
padding: 8px 16px;
|
||||
border-radius: 999px;
|
||||
border: 1.5px solid rgba(246, 195, 211, 0.5);
|
||||
color: var(--bc-bg);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
.s-related a:hover {
|
||||
border-color: var(--bc-pink);
|
||||
color: var(--bc-pink);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.s-hero,
|
||||
.s-meta {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.s-facts dl {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.s-hero {
|
||||
padding: 16px 0 0;
|
||||
}
|
||||
.s-section,
|
||||
.s-body,
|
||||
.s-meta,
|
||||
.s-crumbs {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
.s-price {
|
||||
padding: 22px;
|
||||
border-radius: 28px;
|
||||
}
|
||||
.s-steps .v2-step {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@media (max-width: 380px) {
|
||||
.s-facts dl {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { LOCALES } from '@barev/i18n';
|
||||
import type { MetadataRoute } from 'next';
|
||||
import { INDEXED_PATHS, languageAlternates, SITE } from '../lib/seo';
|
||||
|
||||
/** Все индексируемые страницы на 5 языках; у каждой — hreflang на все версии, включая саму себя */
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return INDEXED_PATHS.flatMap((path) =>
|
||||
LOCALES.map((locale) => ({
|
||||
url: `${SITE}/${locale}${path}`,
|
||||
changeFrequency: 'weekly' as const,
|
||||
priority: path === '' ? 1 : 0.8,
|
||||
alternates: { languages: languageAlternates(path, true) },
|
||||
})),
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 143 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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') }} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { CATALOG, CATEGORY_CODES, DEFAULT_CATEGORY_DISCOUNTS, DEFAULT_SETTINGS, fromPrice, YEREVAN_DISTRICTS, type CategoryCode, type ParamsSchema, type Pricing, type SlotConfig } from '@barev/core';
|
||||
|
||||
export interface LandingCatalog {
|
||||
categories: { code: CategoryCode; barevPlusDiscountPct: number; services: { code: string; fromPrice: number; active: boolean }[] }[];
|
||||
barevPlus: { monthlyPrice: number; giftDays: number };
|
||||
/** цены и скидки главной из настроек (админка → Настройки) */
|
||||
offer: LandingOffer;
|
||||
agency: { commissionPct: number; attributionMonths: number };
|
||||
botUsername: string;
|
||||
}
|
||||
|
||||
export interface LandingOffer {
|
||||
plusMaxPct: number;
|
||||
elitePrice: number;
|
||||
eliteMaxPct: number;
|
||||
hotelWithPlus: number;
|
||||
hotelBase: number;
|
||||
consultation: number;
|
||||
docsFrom: number;
|
||||
/** страницы услуг: /social-card, /residence-permit, /airport */
|
||||
socialCard: number;
|
||||
residenceEscortFrom: number;
|
||||
residenceStateFee: number;
|
||||
transferStandard: number;
|
||||
transferPremium: number;
|
||||
}
|
||||
|
||||
/** Ключи настроек → поля для лендинга; то же отдаёт public.catalog */
|
||||
export function offerFromSettings(s: typeof DEFAULT_SETTINGS): LandingOffer {
|
||||
return {
|
||||
plusMaxPct: s['barevPlus.maxDiscountPct'],
|
||||
elitePrice: s['elite.monthlyPrice'],
|
||||
eliteMaxPct: s['elite.maxDiscountPct'],
|
||||
hotelWithPlus: s['relocation.hotelWithPlus'],
|
||||
hotelBase: s['relocation.hotelBase'],
|
||||
consultation: s['relocation.consultation'],
|
||||
docsFrom: s['docs.fromPrice'],
|
||||
socialCard: s['docs.socialCardPrice'],
|
||||
residenceEscortFrom: s['residence.escortFrom'],
|
||||
residenceStateFee: s['residence.stateFee'],
|
||||
transferStandard: s['transfer.standardPrice'],
|
||||
transferPremium: s['transfer.premiumPrice'],
|
||||
};
|
||||
}
|
||||
|
||||
const API = process.env.API_URL ?? 'http://localhost:4000';
|
||||
|
||||
/** Запасной вариант, если API недоступно при сборке: цены-заглушки из каталога */
|
||||
function fallback(): LandingCatalog {
|
||||
return {
|
||||
categories: CATEGORY_CODES.map((code) => ({
|
||||
code,
|
||||
barevPlusDiscountPct: DEFAULT_CATEGORY_DISCOUNTS[code],
|
||||
services: CATALOG.filter((s) => s.category === code).map((s) => ({ code: s.code, fromPrice: fromPrice(s.pricing), active: s.stage === 1 })),
|
||||
})),
|
||||
barevPlus: { monthlyPrice: DEFAULT_SETTINGS['barevPlus.monthlyPrice'], giftDays: DEFAULT_SETTINGS['barevPlus.giftDays'] },
|
||||
offer: offerFromSettings(DEFAULT_SETTINGS),
|
||||
agency: { commissionPct: DEFAULT_SETTINGS['agency.defaultCommissionPct'], attributionMonths: DEFAULT_SETTINGS['agency.attributionMonths'] },
|
||||
botUsername: process.env.CLIENT_BOT_USERNAME ?? 'barev_club_bot',
|
||||
};
|
||||
}
|
||||
|
||||
/** LND-5: цены и состав услуг — из справочника (ISR, обновление раз в 5 минут) */
|
||||
export async function getCatalog(): Promise<LandingCatalog> {
|
||||
try {
|
||||
const res = await fetch(`${API}/trpc/public.catalog`, { next: { revalidate: 300 } });
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const body = (await res.json()) as { result: { data: { json: LandingCatalog } } };
|
||||
// старое API без offer или без новых полей — значения по умолчанию
|
||||
return { ...body.result.data.json, offer: { ...offerFromSettings(DEFAULT_SETTINGS), ...body.result.data.json.offer } };
|
||||
} catch {
|
||||
return fallback();
|
||||
}
|
||||
}
|
||||
|
||||
export interface CleaningConfig {
|
||||
active: boolean;
|
||||
pricing: Pricing;
|
||||
paramsSchema: ParamsSchema;
|
||||
slotConfig: SlotConfig;
|
||||
categoryDiscountPct: number;
|
||||
districts: string[];
|
||||
botUsername: string;
|
||||
}
|
||||
|
||||
/** Страница /clean: правила цены из справочника (ISR); без API — значения из каталога в коде */
|
||||
export async function getCleaning(): Promise<CleaningConfig> {
|
||||
try {
|
||||
const res = await fetch(`${API}/trpc/public.cleaning`, { next: { revalidate: 300 } });
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const body = (await res.json()) as { result: { data: { json: CleaningConfig } } };
|
||||
return body.result.data.json;
|
||||
} catch {
|
||||
const s = CATALOG.find((c) => c.code === 'cleaning')!;
|
||||
return {
|
||||
active: true,
|
||||
pricing: s.pricing,
|
||||
paramsSchema: s.paramsSchema,
|
||||
slotConfig: s.slotConfig,
|
||||
categoryDiscountPct: DEFAULT_CATEGORY_DISCOUNTS.HOME,
|
||||
districts: [...YEREVAN_DISTRICTS],
|
||||
botUsername: process.env.CLIENT_BOT_USERNAME ?? 'barev_club_bot',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Квартиры для страницы /ru/apartments — из CRM (crm.yandp.tech), организация AREV.
|
||||
// Ключ CRM знает только сервер лендинга: браузер ходит за фото и заявками через /api/flats/*.
|
||||
|
||||
const CRM = (process.env.CRM_URL ?? 'https://crm.yandp.tech').replace(/\/$/, '');
|
||||
const KEY = process.env.CRM_BAREV_KEY ?? '';
|
||||
|
||||
export interface Flat {
|
||||
id: number;
|
||||
/** «Описание для клиента» из карточки объекта в CRM */
|
||||
text: string;
|
||||
dealType: 'rent' | 'sale' | null;
|
||||
propertyType: string | null;
|
||||
district: string | null;
|
||||
price: number | null;
|
||||
currency: string | null;
|
||||
rooms: number | null;
|
||||
area: number | null;
|
||||
floor: number | null;
|
||||
floorsTotal: number | null;
|
||||
/** сколько фото; само фото — /api/flats/photo/<id>/<номер> */
|
||||
photos: number;
|
||||
}
|
||||
|
||||
export const crmConfigured = () => Boolean(KEY);
|
||||
|
||||
export function crmFetch(path: string, init: RequestInit & { next?: { revalidate: number } } = {}) {
|
||||
return fetch(`${CRM}${path}`, {
|
||||
...init,
|
||||
headers: { ...(init.headers ?? {}), 'x-barev-key': KEY },
|
||||
signal: init.signal ?? AbortSignal.timeout(20_000),
|
||||
});
|
||||
}
|
||||
|
||||
/** Объекты, по которым агент дозвонился и указал комиссию. null — CRM недоступна. */
|
||||
export async function getFlats(): Promise<Flat[] | null> {
|
||||
if (!KEY) return null;
|
||||
try {
|
||||
const res = await crmFetch('/api/barev/objects', {
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
return (await res.json()) as Flat[];
|
||||
} catch (e) {
|
||||
console.error('[flats] CRM недоступна:', e instanceof Error ? e.message : e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { ImageResponse } from 'next/og';
|
||||
|
||||
export const OG_SIZE = { width: 1200, height: 630 };
|
||||
|
||||
/**
|
||||
* Шрифты лежат в apps/landing/assets: Rubik — латиница и кириллица,
|
||||
* Noto Sans Armenian — армянский (без него вместо букв были квадраты). Китайский Next догружает сам.
|
||||
*/
|
||||
async function fonts() {
|
||||
const dir = path.join(process.cwd(), 'assets');
|
||||
const [rubik, armenian] = await Promise.all([readFile(path.join(dir, 'Rubik-ExtraBold.ttf')), readFile(path.join(dir, 'NotoSansArmenian-Bold.ttf'))]);
|
||||
return [
|
||||
{ name: 'Rubik', data: rubik, weight: 800 as const, style: 'normal' as const },
|
||||
{ name: 'Noto Sans Armenian', data: armenian, weight: 700 as const, style: 'normal' as const },
|
||||
];
|
||||
}
|
||||
|
||||
/** OG-картинка в цветах тёмной главной: заголовок страницы, домен и полоски четырёх направлений */
|
||||
export async function ogImage(title: string) {
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div style={{ width: '100%', height: '100%', background: '#4A1028', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: 72, color: '#FBF6EE', fontFamily: 'Rubik, "Noto Sans Armenian"' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, fontSize: 40, color: '#F6C3D3' }}>
|
||||
<svg width="52" height="52" viewBox="0 0 24 24" fill="none" stroke="#F6C3D3" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 21.5a9.5 9.5 0 1 0 0-19 9.5 9.5 0 0 0 0 19zM8 12.5l2.6 2.6L16 9.5" />
|
||||
</svg>
|
||||
Barev Club
|
||||
</div>
|
||||
<div style={{ display: 'flex', fontSize: title.length > 40 ? 64 : 80, lineHeight: 1.05, letterSpacing: -2, maxWidth: 1000 }}>{title}</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', gap: 14 }}>
|
||||
{['#A8C38A', '#F9CDB0', '#CFC8F5', '#F7DA6E'].map((c) => (
|
||||
<div key={c} style={{ width: 110, height: 32, borderRadius: 999, background: c }} />
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 30, color: '#F6C3D3' }}>barevclub.com</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{ ...OG_SIZE, fonts: await fonts() },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { isLocale } from '@barev/i18n';
|
||||
import { cookies, headers } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
/** Адрес без языка → язык из cookie (выбор на лендинге) или Accept-Language; ?a= и UTM сохраняются */
|
||||
export async function redirectToLocale(searchParams: SearchParams, path = ''): Promise<never> {
|
||||
const [sp, c, h] = await Promise.all([searchParams, cookies(), headers()]);
|
||||
const saved = c.get('barev_lang')?.value;
|
||||
const accepted = (h.get('accept-language') ?? '')
|
||||
.split(',')
|
||||
.map((p) => p.split(';')[0]!.trim().split('-')[0]!.toLowerCase())
|
||||
.find(isLocale);
|
||||
const locale = isLocale(saved) ? saved : (accepted ?? 'ru');
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(sp)) if (typeof v === 'string') qs.set(k, v);
|
||||
redirect(`/${locale}${path}${qs.size ? `?${qs}` : ''}`);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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', '/pricing', '/terms', '/refund', '/privacy'] as const;
|
||||
|
||||
/** hreflang для пути: все языки + x-default на русскую версию (абсолютные адреса для sitemap) */
|
||||
export function languageAlternates(path: string, absolute = false): Record<string, string> {
|
||||
const base = absolute ? SITE : '';
|
||||
return Object.fromEntries([...LOCALES.map((l) => [l, `${base}/${l}${path}`]), ['x-default', `${base}/ru${path}`]]);
|
||||
}
|
||||
|
||||
/** Реквизиты и контакты из выписки ИП (госреестр РА, 14.09.2026). Адрес и паспорт не публикуем — по решению владельца */
|
||||
export const CONTACTS = {
|
||||
email: process.env.CONTACT_EMAIL || 'yuri@zabudko.com',
|
||||
phone: '+37477408332',
|
||||
phoneDisplay: '+374 77 408 332',
|
||||
/** ՀՎՀՀ — налоговый номер ИП */
|
||||
tin: '20404101',
|
||||
/** официальное название ИП в госреестре */
|
||||
legalName: 'Ա/Ձ Զաբուդկո Յուրի Կոնստանտինի',
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import { formatAmd, type Locale } from '@barev/i18n';
|
||||
import type { LandingCatalog } from './data';
|
||||
|
||||
/** Страницы услуг под поисковые запросы: адрес, количество блоков в текстах и откуда брать цены */
|
||||
export type ServicePageId = 'socialCard' | 'residence' | 'airport';
|
||||
|
||||
export interface ServicePageConfig {
|
||||
id: ServicePageId;
|
||||
path: '/social-card' | '/residence-permit' | '/airport';
|
||||
/** schema.org serviceType — для JSON-LD */
|
||||
serviceType: string;
|
||||
steps: number;
|
||||
blocks: number;
|
||||
prices: number;
|
||||
faq: number;
|
||||
/** подстановки для текстов: цены из админки и скидки по направлениям */
|
||||
vars: (c: LandingCatalog, locale: Locale) => Record<string, string>;
|
||||
/** цены для разметки Offer, ֏ */
|
||||
offers: (c: LandingCatalog) => { name: string; price: number; minPrice?: boolean }[];
|
||||
}
|
||||
|
||||
const pct = (c: LandingCatalog, code: 'HOME' | 'DOCS') => String(c.categories.find((x) => x.code === code)?.barevPlusDiscountPct ?? 0);
|
||||
|
||||
export const SERVICE_PAGES: Record<ServicePageId, ServicePageConfig> = {
|
||||
socialCard: {
|
||||
id: 'socialCard',
|
||||
path: '/social-card',
|
||||
serviceType: 'Public Services Number (social card) registration in Armenia',
|
||||
steps: 5,
|
||||
blocks: 4,
|
||||
prices: 2,
|
||||
faq: 5,
|
||||
vars: (c, l) => ({ price: formatAmd(l, c.offer.socialCard), docsPct: pct(c, 'DOCS') }),
|
||||
offers: (c) => [{ name: 'Social card, end to end', price: c.offer.socialCard }],
|
||||
},
|
||||
residence: {
|
||||
id: 'residence',
|
||||
path: '/residence-permit',
|
||||
serviceType: 'Residence permit support in Armenia',
|
||||
steps: 6,
|
||||
blocks: 5,
|
||||
prices: 4,
|
||||
faq: 6,
|
||||
vars: (c, l) => ({
|
||||
consult: formatAmd(l, c.offer.consultation),
|
||||
escort: formatAmd(l, c.offer.residenceEscortFrom),
|
||||
fee: formatAmd(l, c.offer.residenceStateFee),
|
||||
}),
|
||||
offers: (c) => [
|
||||
{ name: 'Consultation', price: c.offer.consultation },
|
||||
{ name: 'Residence permit support', price: c.offer.residenceEscortFrom, minPrice: true },
|
||||
],
|
||||
},
|
||||
airport: {
|
||||
id: 'airport',
|
||||
path: '/airport',
|
||||
serviceType: 'Airport transfer with meet and greet, Zvartnots (EVN) to Yerevan',
|
||||
steps: 5,
|
||||
blocks: 3,
|
||||
prices: 4,
|
||||
faq: 5,
|
||||
vars: (c, l) => ({
|
||||
standard: formatAmd(l, c.offer.transferStandard),
|
||||
premium: formatAmd(l, c.offer.transferPremium),
|
||||
hotelPlus: formatAmd(l, c.offer.hotelWithPlus),
|
||||
hotelBase: formatAmd(l, c.offer.hotelBase),
|
||||
homePct: pct(c, 'HOME'),
|
||||
}),
|
||||
offers: (c) => [
|
||||
{ name: 'Standard', price: c.offer.transferStandard },
|
||||
{ name: 'Premium', price: c.offer.transferPremium },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const SERVICE_PATHS = Object.values(SERVICE_PAGES).map((p) => p.path);
|
||||
|
||||
/** 1..n — для перебора ключей s1, s2, … */
|
||||
export const range = (n: number) => Array.from({ length: n }, (_, i) => i + 1);
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/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,16 @@
|
||||
import path from 'node:path';
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const config: NextConfig = {
|
||||
transpilePackages: ['@barev/ui', '@barev/i18n', '@barev/core'],
|
||||
poweredByHeader: false,
|
||||
// продакшен: самодостаточная сборка для Docker (монорепо — трассировка от корня)
|
||||
output: 'standalone',
|
||||
outputFileTracingRoot: path.resolve(import.meta.dirname, '../..'),
|
||||
// шрифты для OG-картинок читаются с диска — кладём их в standalone-сборку
|
||||
outputFileTracingIncludes: { '/**': ['./assets/**'] },
|
||||
reactStrictMode: true,
|
||||
agentRules: false,
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@barev/landing",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3000",
|
||||
"build": "next build",
|
||||
"start": "next start --port 3000",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@barev/core": "workspace:*",
|
||||
"@barev/i18n": "workspace:*",
|
||||
"@barev/ui": "workspace:*",
|
||||
"next": "^16.0.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"jsx": "preserve",
|
||||
"types": ["node"],
|
||||
"plugins": [{ "name": "next" }],
|
||||
"allowJs": false,
|
||||
"incremental": true
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -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