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:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 01:23:32 +04:00
1 parent 45d7512d2b
commit d452824c62
314 files changed
+44394 -121

No files matched your search

+105
View File
@@ -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',
};
}
}
+47
View File
@@ -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;
}
}
+44
View File
@@ -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() },
);
}
+19
View File
@@ -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}` : ''}`);
}
+23
View File
@@ -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', '/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: 'Ա/Ձ Զաբուդկո Յուրի Կոնստանտինի',
};
+79
View File
@@ -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);