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,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);
|
||||
Reference in new issue
Block a user