Snapshot of uncommitted work: landing, bots, prod infra, partner cabinet, backoffice stage 1
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
45d7512d2b
commit
d452824c62
314 files changed
+44394
-121
No files matched your search
@@ -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,122 @@
|
||||
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 { 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 />
|
||||
<a href={`/${locale}/privacy`}>{t('landing.footer.privacy')}</a>
|
||||
</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,80 @@
|
||||
import { createT, isLocale, LOCALES, type Locale } from '@barev/i18n';
|
||||
import { Logo } from '@barev/ui';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { RichText } from '../../../components/rich-text';
|
||||
import { FooterContacts, LegalLine } from '../../../components/seo';
|
||||
import { CONTACTS, languageAlternates } from '../../../lib/seo';
|
||||
|
||||
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 async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) return {};
|
||||
const t = createT(locale);
|
||||
return {
|
||||
title: `${t('privacy.title')} — Barev Club`,
|
||||
description: t('privacy.intro'),
|
||||
alternates: { canonical: `/${locale}/privacy`, languages: languageAlternates('/privacy') },
|
||||
// своё превью ссылки, иначе подставляется старый слоган из общего layout
|
||||
openGraph: { title: `${t('privacy.title')} — Barev Club`, description: t('privacy.intro'), locale, type: 'article', siteName: 'Barev Club' },
|
||||
twitter: { card: 'summary_large_image', title: `${t('privacy.title')} — Barev Club`, description: t('privacy.intro') },
|
||||
};
|
||||
}
|
||||
|
||||
/** ONB-3: политика конфиденциальности. Оператор — ИП владельца; основной текст на русском, переводы needs_review */
|
||||
export default async function Privacy({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale: raw } = await params;
|
||||
if (!isLocale(raw)) notFound();
|
||||
const locale: Locale = raw;
|
||||
const t = createT(locale);
|
||||
const vars = { email: CONTACTS.email, phone: CONTACTS.phoneDisplay, tin: CONTACTS.tin };
|
||||
return (
|
||||
<main className="prose policy">
|
||||
<div className="policy__top">
|
||||
<a href={`/${locale}`} style={{ textDecoration: 'none' }}>
|
||||
<Logo />
|
||||
</a>
|
||||
<nav className="policy__langs" aria-label="Language">
|
||||
{LOCALES.map((l) => (
|
||||
<a key={l} href={`/${l}/privacy`} hrefLang={l} aria-current={l === locale ? 'page' : undefined}>
|
||||
{l.toUpperCase()}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<h1 className="bc-h2" style={{ marginTop: 24 }}>
|
||||
{t('privacy.title')}
|
||||
</h1>
|
||||
<p className="policy__updated">{t('privacy.updated')}</p>
|
||||
<p>{t('privacy.intro')}</p>
|
||||
<ol className="policy__toc">
|
||||
{SECTIONS.map((s) => (
|
||||
<li key={s}>
|
||||
<a href={`#${s}`}>{t(`privacy.${s}.title`)}</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{SECTIONS.map((s, i) => (
|
||||
<section key={s} id={s} className="policy__section">
|
||||
<h2>
|
||||
{i + 1}. {t(`privacy.${s}.title`)}
|
||||
</h2>
|
||||
<RichText text={t(`privacy.${s}.body`, vars)} />
|
||||
</section>
|
||||
))}
|
||||
<footer className="policy__footer">
|
||||
<LegalLine locale={locale} />
|
||||
<span className="footer__links">
|
||||
<FooterContacts />
|
||||
</span>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
Reference in new issue
Block a user