Apps, web, i18n, UI, partners, payouts, backoffice, bariki, prod infra

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-25 22:55:29 +04:00
1 parent 45d7512d2b
commit 3489e0b816
319 files changed
+45483 -121

No files matched your search

+38 -4
View File
@@ -1,5 +1,5 @@
import type { ParamsSchema } from './params.ts';
import type { Pricing } from './pricing.ts';
import type { ExtraUnit, Pricing } from './pricing.ts';
import type { SlotConfig } from './slots.ts';
import type { CategoryCode, ProviderRole, ServiceFlow } from './types.ts';
@@ -24,6 +24,32 @@ export interface CatalogService {
const DAY_WINDOW: SlotConfig = { dayStartMinute: 9 * 60, dayEndMinute: 20 * 60, stepMinutes: 60, leadHours: 3, horizonDays: 14 };
const fixed = (price: number, minutes: number): Pricing => ({ kind: 'fixed', price, minutes });
/**
* Доп. услуги клининга (референс — qlean.ru). Цены — заглушки в драмах, владелец меняет их в админке.
* max — сколько единиц клиент может выбрать; minutes — сколько времени добавляет одна единица.
*/
export const CLEANING_EXTRAS: { code: string; unit: ExtraUnit; price: number; minutes: number; max: number }[] = [
{ code: 'windows', unit: 'item', price: 3500, minutes: 20, max: 20 },
{ code: 'balconyGlazing', unit: 'item', price: 8000, minutes: 45, max: 3 },
{ code: 'balcony', unit: 'item', price: 4500, minutes: 30, max: 3 },
{ code: 'fridge', unit: 'service', price: 3000, minutes: 30, max: 1 },
{ code: 'oven', unit: 'service', price: 2500, minutes: 30, max: 1 },
{ code: 'microwave', unit: 'service', price: 1500, minutes: 15, max: 1 },
{ code: 'kitchenCabinets', unit: 'service', price: 4000, minutes: 45, max: 1 },
{ code: 'complexKitchen', unit: 'service', price: 7500, minutes: 60, max: 1 },
{ code: 'chandelier', unit: 'item', price: 2500, minutes: 20, max: 10 },
{ code: 'wardrobe', unit: 'service', price: 3500, minutes: 30, max: 1 },
{ code: 'limescale', unit: 'service', price: 4000, minutes: 30, max: 1 },
{ code: 'petHair', unit: 'service', price: 2000, minutes: 20, max: 1 },
{ code: 'litter', unit: 'item', price: 1500, minutes: 10, max: 5 },
{ code: 'ironing', unit: 'hour', price: 3500, minutes: 60, max: 4 },
{ code: 'extraTime', unit: 'halfHour', price: 2500, minutes: 30, max: 8 },
{ code: 'keys', unit: 'service', price: 3500, minutes: 0, max: 1 },
{ code: 'experienced', unit: 'service', price: 5000, minutes: 0, max: 1 },
{ code: 'vacuum', unit: 'service', price: 4000, minutes: 0, max: 1 },
];
const CLEANING_EXTRAS_PRICES = Object.fromEntries(CLEANING_EXTRAS.map(({ code, unit, price, minutes }) => [code, { unit, price, minutes }]));
export const CATALOG: CatalogService[] = [
{
code: 'cleaning',
@@ -32,20 +58,26 @@ export const CATALOG: CatalogService[] = [
providerRole: 'CLEANER',
stage: 1,
isOnline: false,
// модель цены как у Qlean: тип уборки × комнаты × санузлы + доп. услуги, которые выбирает клиент
pricing: {
kind: 'rooms',
variantParam: 'cleaningType',
roomsParam: 'rooms',
bathroomsParam: 'bathrooms',
extrasParam: 'extras',
tiers: {
regular: { base: 15000, perRoom: 4000, baseMinutes: 120, perRoomMinutes: 45 },
general: { base: 25000, perRoom: 7000, baseMinutes: 210, perRoomMinutes: 75 },
renovation: { base: 35000, perRoom: 10000, baseMinutes: 300, perRoomMinutes: 90 },
regular: { base: 15000, perRoom: 4000, baseMinutes: 120, perRoomMinutes: 45, perBathroom: 3000, perBathroomMinutes: 30 },
general: { base: 25000, perRoom: 7000, baseMinutes: 210, perRoomMinutes: 75, perBathroom: 5000, perBathroomMinutes: 45 },
renovation: { base: 35000, perRoom: 10000, baseMinutes: 300, perRoomMinutes: 90, perBathroom: 7000, perBathroomMinutes: 60 },
},
extras: CLEANING_EXTRAS_PRICES,
},
paramsSchema: {
fields: [
{ key: 'cleaningType', type: 'select', options: ['regular', 'general', 'renovation'], default: 'regular', required: true },
{ key: 'rooms', type: 'int', min: 1, max: 6, default: 2, required: true },
{ key: 'bathrooms', type: 'int', min: 1, max: 4, default: 1, required: true },
{ key: 'extras', type: 'extras', items: CLEANING_EXTRAS.map(({ code, max }) => ({ code, max })), required: false },
],
},
slotConfig: DAY_WINDOW,
@@ -182,6 +214,8 @@ export const CATALOG: CatalogService[] = [
/** Скидка Barev+ по направлениям — заглушки */
export const DEFAULT_CATEGORY_DISCOUNTS: Record<CategoryCode, number> = { HOME: 10, DOCS: 10, LEARNING: 15, PERKS: 0 };
/** скидки Barev Elite по направлениям, % — по умолчанию «до −35%» с главной */
export const DEFAULT_ELITE_DISCOUNTS: Record<CategoryCode, number> = { HOME: 35, DOCS: 35, LEARNING: 35, PERKS: 0 };
/** 12 административных районов Еревана; названия — i18n districts.<code> */
export const YEREVAN_DISTRICTS = [
+81 -5
View File
@@ -6,6 +6,7 @@ import { decodeStartPayload, encodeStartPayload, pickUtm } from './deeplink.ts';
import { agencyCommission, decideGift, discountFor, isAgencyAttributionActive, membershipLevel, payoutFor } from './membership.ts';
import { defaultParams, validateParams } from './params.ts';
import { fromPrice, PricingError, quote } from './pricing.ts';
import { flatPercentRules, payoutByRules, type PayoutRules } from './payout.ts';
import { checkPromo, combineDiscounts, type PromoLike } from './promo.ts';
import { DEFAULT_SETTINGS, resolveSettings } from './settings.ts';
import { daySlots, type ProviderAvailability } from './slots.ts';
@@ -17,6 +18,9 @@ describe('pricing (ORD-4)', () => {
it('считает базовую цену по типу и комнатам', () => {
expect(quote(cleaning.pricing, { cleaningType: 'regular', rooms: 1 })).toEqual({
basePrice: 15000,
breakdown: { variant: 'regular', base: 15000, rooms: { count: 0, amount: 0 }, bathrooms: { count: 0, amount: 0 }, extras: [] },
servicePrice: 15000,
extras: [],
discountPct: 0,
discountAmount: 0,
totalPrice: 15000,
@@ -38,6 +42,26 @@ describe('pricing (ORD-4)', () => {
expect(() => quote(cleaning.pricing, { cleaningType: 'regular', rooms: 1 }, 101)).toThrow(PricingError);
});
it('санузлы и доп. услуги с количеством (модель Qlean)', () => {
const q = quote(cleaning.pricing, { cleaningType: 'regular', rooms: 2, bathrooms: 2, extras: { windows: 3, fridge: 1, ironing: 2 } }, 10);
// 15000 + 4000 (комната) + 3000 (санузел) = 22000; окна 3×3500, холодильник 3000, глажка 2×3500
expect(q.servicePrice).toBe(22000);
expect(q.extras).toEqual([
{ code: 'windows', qty: 3, unit: 'item', unitPrice: 3500, amount: 10500 },
{ code: 'fridge', qty: 1, unit: 'service', unitPrice: 3000, amount: 3000 },
{ code: 'ironing', qty: 2, unit: 'hour', unitPrice: 3500, amount: 7000 },
]);
expect(q.basePrice).toBe(42500);
expect(q.discountAmount).toBe(4250);
expect(q.totalPrice).toBe(38250);
// 120 + 45 + 30 + окна 60 + холодильник 30 + глажка 120
expect(q.durationMinutes).toBe(405);
});
it('неизвестная доп. услуга — ошибка', () => {
expect(() => quote(cleaning.pricing, { cleaningType: 'regular', rooms: 1, extras: { spaceship: 1 } })).toThrow(PricingError);
});
it('«от N ֏» — минимальный тариф', () => {
expect(fromPrice(cleaning.pricing)).toBe(15000);
});
@@ -45,19 +69,29 @@ describe('pricing (ORD-4)', () => {
describe('params (ORD-2)', () => {
it('дефолты и валидация', () => {
expect(defaultParams(cleaning.paramsSchema)).toEqual({ cleaningType: 'regular', rooms: 2 });
expect(validateParams(cleaning.paramsSchema, { cleaningType: 'general', rooms: '3', junk: 1 })).toEqual({
expect(defaultParams(cleaning.paramsSchema)).toEqual({ cleaningType: 'regular', rooms: 2, bathrooms: 1, extras: {} });
expect(validateParams(cleaning.paramsSchema, { cleaningType: 'general', rooms: '3', bathrooms: 2, extras: { windows: 2, oven: 0 }, junk: 1 })).toEqual({
ok: true,
values: { cleaningType: 'general', rooms: 3 },
values: { cleaningType: 'general', rooms: 3, bathrooms: 2, extras: { windows: 2 } },
});
expect(validateParams(cleaning.paramsSchema, { cleaningType: 'x', rooms: 9 })).toEqual({
// больше максимума, неизвестный код, дробное количество
for (const extras of [{ fridge: 2 }, { spaceship: 1 }, { windows: 1.5 }, [1]]) {
expect(validateParams(cleaning.paramsSchema, { cleaningType: 'regular', rooms: 1, bathrooms: 1, extras })).toEqual({
ok: false,
errors: [{ key: 'extras', code: 'invalid' }],
});
}
expect(validateParams(cleaning.paramsSchema, { cleaningType: 'x', rooms: 9, bathrooms: 1 })).toEqual({
ok: false,
errors: [
{ key: 'cleaningType', code: 'invalid' },
{ key: 'rooms', code: 'invalid' },
],
});
expect(validateParams(cleaning.paramsSchema, {})).toMatchObject({ ok: false });
// пропущенные поля с дефолтом подставляются, без дефолта — ошибка
expect(validateParams(cleaning.paramsSchema, {})).toEqual({ ok: true, values: { cleaningType: 'regular', rooms: 2, bathrooms: 1, extras: {} } });
const repair = CATALOG.find((x) => x.code === 'repair')!;
expect(validateParams(repair.paramsSchema, {})).toMatchObject({ ok: false, errors: [{ key: 'repairType', code: 'required' }, { key: 'problem', code: 'required' }] });
});
});
@@ -267,3 +301,45 @@ describe('settings', () => {
expect(s['offer.acceptTimeoutMinutes']).toBe(15);
});
});
describe('выплаты по пресету (джун / мидл / прайват)', () => {
const q = quote(cleaning.pricing, { cleaningType: 'general', rooms: 3, bathrooms: 2, extras: { windows: 4, fridge: 1 } }, 10);
it('разбивка цены по строкам', () => {
expect(q.breakdown).toMatchObject({ variant: 'general', base: 25000, rooms: { count: 2, amount: 14000 }, bathrooms: { count: 1, amount: 5000 } });
expect(q.breakdown.extras.map((e) => [e.code, e.qty, e.amount])).toEqual([
['windows', 4, 14000],
['fridge', 1, 3000],
]);
});
it('фикс и процент по каждой строке, от цены до скидки Barev+', () => {
const rules: PayoutRules = {
base: { general: { type: 'PERCENT', value: 50 }, regular: { type: 'FIXED', value: 9000 } },
room: { type: 'FIXED', value: 2000 },
bathroom: { type: 'PERCENT', value: 60 },
extras: { windows: { type: 'FIXED', value: 1500 } },
};
const p = payoutByRules(q.breakdown, rules);
// 50% от 25000 + 2 комнаты × 2000 + 60% от 5000 + 4 окна × 1500; холодильник без правила — 0
expect(p.lines).toEqual([
{ key: 'base', amount: 12500 },
{ key: 'room', amount: 4000 },
{ key: 'bathroom', amount: 3000 },
{ key: 'extra:windows', amount: 6000 },
]);
expect(p.total).toBe(25500);
});
it('единый процент на всё', () => {
const rules = flatPercentRules(60, ['regular', 'general', 'renovation'], ['windows', 'fridge']);
// 60% от (25000 + 14000 + 5000 + 14000 + 3000)
expect(payoutByRules(q.breakdown, rules).total).toBe(Math.floor(25000 * 0.6) + Math.floor(14000 * 0.6) + 3000 + Math.floor(14000 * 0.6) + 1800);
});
it('услуга с фикс-ценой — основа по ключу «*»', () => {
const repair = CATALOG.find((x) => x.code === 'repair')!;
const b = quote(repair.pricing, { repairType: 'minor', problem: 'x' }).breakdown;
expect(payoutByRules(b, { base: { '*': { type: 'PERCENT', value: 70 } }, room: { type: 'PERCENT', value: 0 }, bathroom: { type: 'PERCENT', value: 0 }, extras: {} }).total).toBe(5600);
});
});
+1
View File
@@ -12,3 +12,4 @@ export * from './promo.ts';
export * from './settings.ts';
export * from './payments.ts';
export * from './catalog.ts';
export * from './payout.ts';
+16 -6
View File
@@ -4,21 +4,31 @@ export interface SubscriptionLike {
startsAt: Date;
endsAt: Date;
status: 'ACTIVE' | 'EXPIRED' | 'CANCELLED';
/** без плана — Barev+ (старые записи и тесты) */
plan?: 'BAREV_PLUS' | 'ELITE';
}
export type MembershipLevel = 'BAREV' | 'BAREV_PLUS';
export type MembershipLevel = 'BAREV' | 'BAREV_PLUS' | 'ELITE';
/** Активная подписка; если их несколько, Elite важнее Barev+ */
export function activeSubscription<T extends SubscriptionLike>(subs: T[], now: Date): T | null {
return subs.find((s) => s.status === 'ACTIVE' && s.startsAt <= now && s.endsAt > now) ?? null;
const active = subs.filter((s) => s.status === 'ACTIVE' && s.startsAt <= now && s.endsAt > now);
return active.find((s) => s.plan === 'ELITE') ?? active[0] ?? null;
}
export function membershipLevel(subs: SubscriptionLike[], now: Date): MembershipLevel {
return activeSubscription(subs, now) ? 'BAREV_PLUS' : 'BAREV';
const active = activeSubscription(subs, now);
if (!active) return 'BAREV';
return active.plan === 'ELITE' ? 'ELITE' : 'BAREV_PLUS';
}
/** Скидка Barev+ по направлению применяется только при активной подписке (SUB-2) */
export function discountFor(level: MembershipLevel, categoryDiscountPct: number): number {
return level === 'BAREV_PLUS' ? categoryDiscountPct : 0;
/**
* Скидка по направлению применяется только при активной подписке (SUB-2).
* У Elite своя скидка; если её не задали (0), действует скидка Barev+.
*/
export function discountFor(level: MembershipLevel, plusPct: number, elitePct = 0): number {
if (level === 'ELITE') return Math.max(elitePct, plusPct);
return level === 'BAREV_PLUS' ? plusPct : 0;
}
export type GiftDecision =
+35 -2
View File
@@ -32,13 +32,25 @@ export const paramFieldSchema = z.discriminatedUnion('type', [
default: z.boolean().optional(),
required: z.boolean().default(false),
}),
/**
* Доп. услуги с количеством (как в Qlean: окна, холодильник, глажка по часам…).
* Значение — { code: количество }; подписи — i18n `extras.<code>.name`, цены — в Service.pricing.
*/
z.object({
key: z.string(),
type: z.literal('extras'),
items: z.array(z.object({ code: z.string(), max: z.number().int().positive() })).min(1),
required: z.boolean().default(false),
}),
]);
export type ParamField = z.infer<typeof paramFieldSchema>;
export const paramsSchemaSchema = z.object({ fields: z.array(paramFieldSchema) });
export type ParamsSchema = z.infer<typeof paramsSchemaSchema>;
export type ParamValues = Record<string, string | number | boolean>;
export type ExtrasValue = Record<string, number>;
export type ParamValue = string | number | boolean | ExtrasValue;
export type ParamValues = Record<string, ParamValue>;
export function defaultParams(schema: ParamsSchema): ParamValues {
const out: ParamValues = {};
@@ -46,6 +58,7 @@ export function defaultParams(schema: ParamsSchema): ParamValues {
if (f.type === 'select') out[f.key] = f.default ?? f.options[0]!;
else if (f.type === 'int') out[f.key] = f.default ?? f.min;
else if (f.type === 'bool') out[f.key] = f.default ?? false;
else if (f.type === 'extras') out[f.key] = {};
}
return out;
}
@@ -63,7 +76,10 @@ export function validateParams(
const raw = input[f.key];
const missing = raw === undefined || raw === null || raw === '';
if (missing) {
if (f.required) errors.push({ key: f.key, code: 'required' });
// поле с дефолтом подставляем — старые клиенты и «Повторить» не знают о новых параметрах
if ('default' in f && f.default !== undefined) values[f.key] = f.default;
else if (f.type === 'extras') values[f.key] = {};
else if (f.required) errors.push({ key: f.key, code: 'required' });
continue;
}
switch (f.type) {
@@ -85,6 +101,23 @@ export function validateParams(
if (typeof raw === 'boolean') values[f.key] = raw;
else errors.push({ key: f.key, code: 'invalid' });
break;
case 'extras': {
if (typeof raw !== 'object' || Array.isArray(raw)) {
errors.push({ key: f.key, code: 'invalid' });
break;
}
const picked: ExtrasValue = {};
let bad = false;
for (const [code, qty] of Object.entries(raw as Record<string, unknown>)) {
const item = f.items.find((i) => i.code === code);
const n = Number(qty);
if (!item || !Number.isInteger(n) || n < 0 || n > item.max) bad = true;
else if (n > 0) picked[code] = n;
}
if (bad) errors.push({ key: f.key, code: 'invalid' });
else values[f.key] = picked;
break;
}
}
}
return errors.length ? { ok: false, errors } : { ok: true, values };
+60
View File
@@ -0,0 +1,60 @@
import { z } from 'zod';
import type { PriceBreakdown } from './pricing.ts';
/**
* Ставки исполнителя по строкам цены (пресеты «Джун / Мидл / Прайват» в админке).
* Для каждой строки — фикс (драмы за единицу) или процент от суммы строки.
* Процент считается от цены до скидки Barev+: скидку клиента оплачивает клуб, а не исполнитель.
*/
export const payoutRuleSchema = z.object({
type: z.enum(['FIXED', 'PERCENT']),
value: z.number().int().min(0),
});
export type PayoutRule = z.infer<typeof payoutRuleSchema>;
const ZERO: PayoutRule = { type: 'PERCENT', value: 0 };
export const payoutRulesSchema = z.object({
/** основа по варианту услуги (тип уборки); для услуг без вариантов — ключ «*» */
base: z.record(z.string(), payoutRuleSchema).default({}),
/** каждая комната сверх первой */
room: payoutRuleSchema.default(ZERO),
/** каждый санузел сверх первого */
bathroom: payoutRuleSchema.default(ZERO),
/** доп. услуги по коду */
extras: z.record(z.string(), payoutRuleSchema).default({}),
});
export type PayoutRules = z.infer<typeof payoutRulesSchema>;
export interface PayoutLine {
/** base | room | bathroom | extra:<code> */
key: string;
amount: number;
}
function apply(rule: PayoutRule | undefined, units: number, amount: number): number {
if (!rule || units <= 0) return 0;
return rule.type === 'FIXED' ? rule.value * units : Math.floor((amount * rule.value) / 100);
}
/** Выплата исполнителю по разбивке цены заказа и правилам пресета */
export function payoutByRules(b: PriceBreakdown, rules: PayoutRules): { total: number; lines: PayoutLine[] } {
const lines: PayoutLine[] = [
{ key: 'base', amount: apply(rules.base[b.variant ?? '*'] ?? rules.base['*'], 1, b.base) },
{ key: 'room', amount: apply(rules.room, b.rooms.count, b.rooms.amount) },
{ key: 'bathroom', amount: apply(rules.bathroom, b.bathrooms.count, b.bathrooms.amount) },
...b.extras.map((e) => ({ key: `extra:${e.code}`, amount: apply(rules.extras[e.code], e.qty, e.amount) })),
].filter((l) => l.amount > 0);
return { total: lines.reduce((s, l) => s + l.amount, 0), lines };
}
/** Пресет «одинаковый процент на всё» — для заготовок */
export function flatPercentRules(pct: number, variants: string[], extraCodes: string[]): PayoutRules {
const r: PayoutRule = { type: 'PERCENT', value: pct };
return {
base: Object.fromEntries((variants.length ? variants : ['*']).map((v) => [v, r])),
room: r,
bathroom: r,
extras: Object.fromEntries(extraCodes.map((c) => [c, r])),
};
}
+90 -13
View File
@@ -4,8 +4,8 @@ import type { ParamValues } from './params.ts';
/**
* Правила цены услуги (Service.pricing). Все суммы — целые драмы.
*
* - `rooms`: тариф по варианту (тип уборки) + доплата за каждую комнату сверх первой.
* Длительность считается так же.
* - `rooms`: тариф по варианту (тип уборки) + доплата за каждую комнату и каждый санузел сверх первого,
* плюс доп. услуги с количеством (модель Qlean). Длительность считается так же.
* - `fixed`: фиксированная цена (заглушка для услуг Этапа 2).
*/
const roomsTier = z.object({
@@ -13,15 +13,32 @@ const roomsTier = z.object({
perRoom: z.number().int().nonnegative(),
baseMinutes: z.number().int().positive(),
perRoomMinutes: z.number().int().nonnegative(),
perBathroom: z.number().int().nonnegative().default(0),
perBathroomMinutes: z.number().int().nonnegative().default(0),
});
/** Единица доп. услуги: штука, услуга целиком, час, 30 минут */
export const EXTRA_UNITS = ['item', 'service', 'hour', 'halfHour'] as const;
export type ExtraUnit = (typeof EXTRA_UNITS)[number];
const extraPrice = z.object({
price: z.number().int().nonnegative(),
/** сколько минут добавляет к визиту одна единица */
minutes: z.number().int().nonnegative(),
unit: z.enum(EXTRA_UNITS),
});
export type ExtraPrice = z.infer<typeof extraPrice>;
export const pricingSchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('rooms'),
/** параметр-вариант, например cleaningType */
variantParam: z.string(),
roomsParam: z.string(),
bathroomsParam: z.string().optional(),
extrasParam: z.string().optional(),
tiers: z.record(z.string(), roomsTier),
extras: z.record(z.string(), extraPrice).default({}),
}),
z.object({
kind: z.literal('fixed'),
@@ -31,8 +48,34 @@ export const pricingSchema = z.discriminatedUnion('kind', [
]);
export type Pricing = z.infer<typeof pricingSchema>;
export interface QuoteLine {
code: string;
qty: number;
unit: ExtraUnit;
unitPrice: number;
amount: number;
}
/** Разбивка цены по строкам — сохраняется в заказе и служит основой для выплат исполнителю */
export interface PriceBreakdown {
variant: string | null;
/** основа: тариф за 1 комнату и 1 санузел (или фикс-цена услуги) */
base: number;
/** комнаты сверх первой */
rooms: { count: number; amount: number };
/** санузлы сверх первого */
bathrooms: { count: number; amount: number };
extras: QuoteLine[];
}
export interface Quote {
/** стоимость до скидки: основная услуга + доп. услуги */
basePrice: number;
breakdown: PriceBreakdown;
/** основная услуга (тип уборки × комнаты × санузлы) */
servicePrice: number;
/** доп. услуги построчно */
extras: QuoteLine[];
discountPct: number;
discountAmount: number;
totalPrice: number;
@@ -40,34 +83,68 @@ export interface Quote {
}
export class PricingError extends Error {
constructor(public code: 'UNKNOWN_VARIANT' | 'BAD_ROOMS' | 'BAD_DISCOUNT') {
constructor(public code: 'UNKNOWN_VARIANT' | 'BAD_ROOMS' | 'BAD_DISCOUNT' | 'UNKNOWN_EXTRA') {
super(code);
}
}
function baseOf(pricing: Pricing, params: ParamValues): { price: number; minutes: number } {
if (pricing.kind === 'fixed') return { price: pricing.price, minutes: pricing.minutes };
function count(params: ParamValues, key: string | undefined, fallback: number): number {
if (!key) return fallback;
const v = params[key];
return v === undefined ? fallback : Number(v);
}
function compute(pricing: Pricing, params: ParamValues): { service: number; minutes: number; extras: QuoteLine[]; breakdown: PriceBreakdown } {
if (pricing.kind === 'fixed') {
return {
service: pricing.price,
minutes: pricing.minutes,
extras: [],
breakdown: { variant: null, base: pricing.price, rooms: { count: 0, amount: 0 }, bathrooms: { count: 0, amount: 0 }, extras: [] },
};
}
const tier = pricing.tiers[String(params[pricing.variantParam])];
if (!tier) throw new PricingError('UNKNOWN_VARIANT');
const rooms = Number(params[pricing.roomsParam]);
if (!Number.isInteger(rooms) || rooms < 1) throw new PricingError('BAD_ROOMS');
const extra = rooms - 1;
return {
price: tier.base + tier.perRoom * extra,
minutes: tier.baseMinutes + tier.perRoomMinutes * extra,
const rooms = count(params, pricing.roomsParam, 1);
const baths = count(params, pricing.bathroomsParam, 1);
if (!Number.isInteger(rooms) || rooms < 1 || !Number.isInteger(baths) || baths < 1) throw new PricingError('BAD_ROOMS');
const service = tier.base + tier.perRoom * (rooms - 1) + tier.perBathroom * (baths - 1);
let minutes = tier.baseMinutes + tier.perRoomMinutes * (rooms - 1) + tier.perBathroomMinutes * (baths - 1);
const lines: QuoteLine[] = [];
const picked = pricing.extrasParam ? params[pricing.extrasParam] : undefined;
if (picked && typeof picked === 'object') {
for (const [code, qty] of Object.entries(picked)) {
if (!qty) continue;
const e = pricing.extras[code];
if (!e) throw new PricingError('UNKNOWN_EXTRA');
lines.push({ code, qty, unit: e.unit, unitPrice: e.price, amount: e.price * qty });
minutes += e.minutes * qty;
}
}
const breakdown: PriceBreakdown = {
variant: String(params[pricing.variantParam]),
base: tier.base,
rooms: { count: rooms - 1, amount: tier.perRoom * (rooms - 1) },
bathrooms: { count: baths - 1, amount: tier.perBathroom * (baths - 1) },
extras: lines,
};
return { service, minutes, extras: lines, breakdown };
}
/**
* ORD-4: базовая цена по параметрам минус скидка Barev+ по направлению.
* ORD-4: стоимость по параметрам (основная услуга + доп. услуги) минус скидка Barev+ по направлению.
* Сумма скидки округляется вниз до целого драма.
*/
export function quote(pricing: Pricing, params: ParamValues, discountPct = 0): Quote {
if (!Number.isInteger(discountPct) || discountPct < 0 || discountPct > 100) throw new PricingError('BAD_DISCOUNT');
const { price, minutes } = baseOf(pricing, params);
const { service, minutes, extras, breakdown } = compute(pricing, params);
const price = service + extras.reduce((s, l) => s + l.amount, 0);
const discountAmount = Math.floor((price * discountPct) / 100);
return {
basePrice: price,
breakdown,
servicePrice: service,
extras,
discountPct,
discountAmount,
totalPrice: price - discountAmount,
+26 -2
View File
@@ -14,7 +14,31 @@ export const settingsSchema = z.object({
/** SUB-3: длительность подарочного Barev+ */
'barevPlus.giftDays': z.number().int().min(1).max(365).default(30),
/** цена подписки в месяц, ֏ (заглушка до решения владельца) */
'barevPlus.monthlyPrice': z.number().int().min(0).default(4900),
'barevPlus.monthlyPrice': z.number().int().min(0).default(4990),
/** Barev+: «скидки до N%» на главной и в тарифах */
'barevPlus.maxDiscountPct': z.number().int().min(0).max(100).default(15),
/** Barev Elite: цена в месяц, ֏ (пока продаётся через оператора) */
'elite.monthlyPrice': z.number().int().min(0).default(35000),
/** Barev Elite: «скидки до N%» на главной, в тарифах и в прайсе /live */
'elite.maxDiscountPct': z.number().int().min(0).max(100).default(35),
/** главная: отель при переезде, ֏/сутки с Barev+ */
'relocation.hotelWithPlus': z.number().int().min(0).default(20000),
/** главная: отель при переезде, ֏/сутки без подписки */
'relocation.hotelBase': z.number().int().min(0).default(35000),
/** главная: консультация по переезду и ВНЖ, ֏ */
'relocation.consultation': z.number().int().min(0).default(45000),
/** /live: документы с подпиской «от N ֏» */
'docs.fromPrice': z.number().int().min(0).default(45000),
/** страница /social-card: соцкарта под ключ (перевод паспорта + запись + сопровождение), ֏ */
'docs.socialCardPrice': z.number().int().min(0).default(25000),
/** страница /residence-permit: сопровождение ВНЖ до получения карты «от N ֏», без госпошлины */
'residence.escortFrom': z.number().int().min(0).default(150000),
/** страница /residence-permit: госпошлина за ВНЖ на 1 год (с 01.11.2026 — 150 000 + 5 950 за карту), ֏ */
'residence.stateFee': z.number().int().min(0).default(155950),
/** страница /airport: встреча «Стандарт» — табличка, до 3 пассажиров, 60 мин ожидания, ֏ */
'transfer.standardPrice': z.number().int().min(0).default(10000),
/** страница /airport: встреча «Премиум» — бизнес-авто, вода, SIM-карта, ֏ */
'transfer.premiumPrice': z.number().int().min(0).default(20000),
/** PRO-5: сколько минут исполнитель может думать над предложением */
'offer.acceptTimeoutMinutes': z.number().int().min(1).max(1440).default(15),
/** MSG-1: чат закрывается через N часов после завершения (Этап 2) */
@@ -22,7 +46,7 @@ export const settingsSchema = z.object({
/** раздел 6: автоудаление сканов через N дней после завершения */
'files.retentionDays': z.number().int().min(1).default(90),
/** текущая версия политики ПДн — сохраняется вместе с согласием */
'consent.version': z.string().default('2026-10-draft'),
'consent.version': z.string().default('2026-10-20'),
});
export type Settings = z.infer<typeof settingsSchema>;
+17
View File
@@ -48,6 +48,23 @@ export function isProviderFree(p: ProviderAvailability, slot: Interval): boolean
* Слоты на день (решение владельца: окно услуги + хотя бы один свободный исполнитель по графику).
* Неназначенные заказы на пересекающееся время тоже занимают мощность, чтобы не продать одно время дважды.
*/
/**
* Время начала в рабочем окне услуги без учёта графиков исполнителей — для заявки с сайта,
* где клиент называет желаемое время, а оператор его подтверждает.
*/
export function windowStarts(input: { dateKey: string; config: SlotConfig; durationMinutes: number; now: Date }): Date[] {
const { year, month, day } = parseDateKey(input.dateKey);
const { config } = input;
const earliest = input.now.getTime() + config.leadHours * HOUR;
const horizonEnd = input.now.getTime() + (config.horizonDays + 1) * 24 * HOUR;
const out: Date[] = [];
for (let m = config.dayStartMinute; m + input.durationMinutes <= config.dayEndMinute; m += config.stepMinutes) {
const start = fromYerevan(year, month, day, m);
if (start.getTime() >= earliest && start.getTime() <= horizonEnd) out.push(start);
}
return out;
}
export function daySlots(input: {
dateKey: string;
config: SlotConfig;
+8 -3
View File
@@ -3,16 +3,21 @@
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./src/index.ts" },
"exports": {
".": "./src/index.ts"
},
"scripts": {
"generate": "prisma generate",
"migrate": "prisma migrate dev",
"deploy": "prisma migrate deploy",
"reset": "prisma migrate reset --force",
"seed": "tsx src/seed/index.ts",
"studio": "prisma studio",
"typecheck": "tsc --noEmit",
"postinstall": "prisma generate"
"postinstall": "prisma generate",
"reset-2fa": "tsx src/scripts/reset-2fa.ts",
"migrate:deploy": "prisma migrate deploy",
"create-admin": "tsx src/scripts/create-admin.ts",
"sync-catalog": "tsx src/scripts/sync-catalog.ts"
},
"dependencies": {
"@prisma/adapter-pg": "7.10.1",
+3 -2
View File
@@ -1,11 +1,12 @@
import path from 'node:path';
import { config } from 'dotenv';
import { defineConfig, env } from 'prisma/config';
import { defineConfig } from 'prisma/config';
config({ path: path.resolve(import.meta.dirname, '../../.env'), quiet: true });
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: { path: 'prisma/migrations', seed: 'tsx src/seed/index.ts' },
datasource: { url: env('DATABASE_URL') },
// generate (в т.ч. при сборке Docker-образа) не требует БД; migrate без DATABASE_URL упадёт с понятной ошибкой
datasource: { url: process.env.DATABASE_URL ?? '' },
});
@@ -0,0 +1,22 @@
-- AlterTable
ALTER TABLE "Order" ADD COLUMN "priceBreakdown" JSONB;
-- AlterTable
ALTER TABLE "Provider" ADD COLUMN "payoutPresetId" TEXT;
-- CreateTable
CREATE TABLE "PayoutPreset" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" "ProviderRole" NOT NULL,
"rules" JSONB NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PayoutPreset_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Provider" ADD CONSTRAINT "Provider_payoutPresetId_fkey" FOREIGN KEY ("payoutPresetId") REFERENCES "PayoutPreset"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,54 @@
-- CreateEnum
CREATE TYPE "WebRequestStatus" AS ENUM ('NEW', 'LINKED', 'ORDERED', 'CANCELLED');
-- CreateTable
CREATE TABLE "WebRequest" (
"id" TEXT NOT NULL,
"number" SERIAL NOT NULL,
"token" TEXT NOT NULL,
"serviceId" TEXT NOT NULL,
"params" JSONB NOT NULL,
"preferredAt" TIMESTAMP(3) NOT NULL,
"durationMinutes" INTEGER NOT NULL,
"districtId" TEXT NOT NULL,
"addressText" TEXT NOT NULL,
"note" TEXT,
"phone" TEXT NOT NULL,
"name" TEXT,
"locale" "Locale" NOT NULL,
"totalPrice" INTEGER NOT NULL,
"consentAt" TIMESTAMP(3) NOT NULL,
"consentVersion" TEXT NOT NULL,
"status" "WebRequestStatus" NOT NULL DEFAULT 'NEW',
"clientId" TEXT,
"orderId" TEXT,
"adminNote" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WebRequest_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "WebRequest_number_key" ON "WebRequest"("number");
-- CreateIndex
CREATE UNIQUE INDEX "WebRequest_token_key" ON "WebRequest"("token");
-- CreateIndex
CREATE UNIQUE INDEX "WebRequest_orderId_key" ON "WebRequest"("orderId");
-- CreateIndex
CREATE INDEX "WebRequest_status_createdAt_idx" ON "WebRequest"("status", "createdAt");
-- AddForeignKey
ALTER TABLE "WebRequest" ADD CONSTRAINT "WebRequest_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WebRequest" ADD CONSTRAINT "WebRequest_districtId_fkey" FOREIGN KEY ("districtId") REFERENCES "District"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WebRequest" ADD CONSTRAINT "WebRequest_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WebRequest" ADD CONSTRAINT "WebRequest_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,38 @@
-- CreateEnum
CREATE TYPE "LeadStatus" AS ENUM ('NEW', 'IN_WORK', 'DONE', 'CANCELLED');
-- CreateEnum
CREATE TYPE "LeadContactType" AS ENUM ('PHONE', 'TELEGRAM');
-- CreateTable
CREATE TABLE "Lead" (
"id" TEXT NOT NULL,
"number" SERIAL NOT NULL,
"key" TEXT NOT NULL,
"name" TEXT NOT NULL,
"contact" TEXT NOT NULL,
"contactType" "LeadContactType" NOT NULL,
"locale" "Locale" NOT NULL,
"agency" TEXT,
"utm" JSONB,
"moved" BOOLEAN,
"moveWhen" TEXT,
"moveDate" TIMESTAMP(3),
"package" TEXT,
"options" JSONB,
"submittedAt" TIMESTAMP(3),
"consentAt" TIMESTAMP(3) NOT NULL,
"consentVersion" TEXT NOT NULL,
"status" "LeadStatus" NOT NULL DEFAULT 'NEW',
"adminNote" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Lead_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Lead_number_key" ON "Lead"("number");
-- CreateIndex
CREATE INDEX "Lead_status_createdAt_idx" ON "Lead"("status", "createdAt");
@@ -0,0 +1,106 @@
-- CreateEnum
CREATE TYPE "ClientSegment" AS ENUM ('MOVING', 'LIVING');
-- CreateEnum
CREATE TYPE "LeadStepStatus" AS ENUM ('TODO', 'IN_PROGRESS', 'DONE', 'SKIPPED');
-- CreateEnum
CREATE TYPE "ClientRequestKind" AS ENUM ('SUBSCRIPTION', 'SERVICE');
-- CreateEnum
CREATE TYPE "ClientRequestStatus" AS ENUM ('NEW', 'DONE', 'CANCELLED');
-- CreateEnum
CREATE TYPE "ConciergeAuthor" AS ENUM ('CLIENT', 'OPERATOR');
-- AlterEnum
ALTER TYPE "SubscriptionPlan" ADD VALUE 'ELITE';
-- AlterTable
ALTER TABLE "Category" ADD COLUMN "eliteDiscountPct" INTEGER NOT NULL DEFAULT 0;
-- AlterTable
ALTER TABLE "Client" ADD COLUMN "segment" "ClientSegment";
-- AlterTable
ALTER TABLE "Lead" ADD COLUMN "checklist" TEXT[] DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "clientId" TEXT;
-- CreateTable
CREATE TABLE "LeadStep" (
"id" TEXT NOT NULL,
"leadId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL,
"status" "LeadStepStatus" NOT NULL DEFAULT 'TODO',
"note" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LeadStep_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ClientRequest" (
"id" TEXT NOT NULL,
"number" SERIAL NOT NULL,
"clientId" TEXT NOT NULL,
"kind" "ClientRequestKind" NOT NULL,
"plan" "SubscriptionPlan",
"serviceCode" TEXT,
"comment" TEXT,
"status" "ClientRequestStatus" NOT NULL DEFAULT 'NEW',
"adminNote" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ClientRequest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ConciergeMessage" (
"id" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"author" "ConciergeAuthor" NOT NULL,
"adminUserId" TEXT,
"text" TEXT NOT NULL,
"readAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ConciergeMessage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "LeadStep_leadId_code_key" ON "LeadStep"("leadId", "code");
-- CreateIndex
CREATE UNIQUE INDEX "ClientRequest_number_key" ON "ClientRequest"("number");
-- CreateIndex
CREATE INDEX "ClientRequest_status_createdAt_idx" ON "ClientRequest"("status", "createdAt");
-- CreateIndex
CREATE INDEX "ClientRequest_clientId_idx" ON "ClientRequest"("clientId");
-- CreateIndex
CREATE INDEX "ConciergeMessage_clientId_createdAt_idx" ON "ConciergeMessage"("clientId", "createdAt");
-- CreateIndex
CREATE INDEX "ConciergeMessage_author_readAt_idx" ON "ConciergeMessage"("author", "readAt");
-- CreateIndex
CREATE INDEX "Lead_clientId_idx" ON "Lead"("clientId");
-- AddForeignKey
ALTER TABLE "Lead" ADD CONSTRAINT "Lead_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LeadStep" ADD CONSTRAINT "LeadStep_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ClientRequest" ADD CONSTRAINT "ClientRequest_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ConciergeMessage" ADD CONSTRAINT "ConciergeMessage_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Elite: скидки по направлениям как на главной («до −35%»)
UPDATE "Category" SET "eliteDiscountPct" = 35 WHERE "code" IN ('HOME', 'DOCS', 'LEARNING');
@@ -0,0 +1,94 @@
-- CreateEnum
CREATE TYPE "RelocationItemKind" AS ENUM ('STEP', 'DOC');
-- AlterEnum
ALTER TYPE "ConciergeAuthor" ADD VALUE 'PROVIDER';
-- DropForeignKey
ALTER TABLE "ConciergeMessage" DROP CONSTRAINT "ConciergeMessage_clientId_fkey";
-- AlterTable
ALTER TABLE "AdminUser" ADD COLUMN "telegramId" BIGINT,
ADD COLUMN "tgLinkToken" TEXT,
ADD COLUMN "tgNotify" BOOLEAN NOT NULL DEFAULT true;
-- AlterTable
ALTER TABLE "ConciergeMessage" ADD COLUMN "providerId" TEXT,
ALTER COLUMN "clientId" DROP NOT NULL;
-- AlterTable
ALTER TABLE "Lead" DROP COLUMN "checklist";
-- AlterTable
ALTER TABLE "LeadStep" ADD COLUMN "title" TEXT;
-- CreateTable
CREATE TABLE "RelocationItem" (
"id" TEXT NOT NULL,
"kind" "RelocationItemKind" NOT NULL,
"code" TEXT NOT NULL,
"name" JSONB NOT NULL,
"hint" JSONB,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"active" BOOLEAN NOT NULL DEFAULT true,
"inTurnkey" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "RelocationItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LeadDoc" (
"id" TEXT NOT NULL,
"leadId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"title" TEXT,
"checked" BOOLEAN NOT NULL DEFAULT false,
"sortOrder" INTEGER NOT NULL,
CONSTRAINT "LeadDoc_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "RelocationItem_kind_code_key" ON "RelocationItem"("kind", "code");
-- CreateIndex
CREATE UNIQUE INDEX "LeadDoc_leadId_code_key" ON "LeadDoc"("leadId", "code");
-- CreateIndex
CREATE UNIQUE INDEX "AdminUser_telegramId_key" ON "AdminUser"("telegramId");
-- CreateIndex
CREATE UNIQUE INDEX "AdminUser_tgLinkToken_key" ON "AdminUser"("tgLinkToken");
-- CreateIndex
CREATE INDEX "ConciergeMessage_providerId_createdAt_idx" ON "ConciergeMessage"("providerId", "createdAt");
-- AddForeignKey
ALTER TABLE "LeadDoc" ADD CONSTRAINT "LeadDoc_leadId_fkey" FOREIGN KEY ("leadId") REFERENCES "Lead"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ConciergeMessage" ADD CONSTRAINT "ConciergeMessage_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ConciergeMessage" ADD CONSTRAINT "ConciergeMessage_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "Provider"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- стартовые шаблоны: шаги переезда и чек-лист документов (дальше правятся в админке)
INSERT INTO "RelocationItem" ("id", "kind", "code", "name", "hint", "sortOrder", "active", "inTurnkey") VALUES
(gen_random_uuid()::text, 'STEP', 'consult', '{"ru": "Консультация по переезду", "en": "Relocation consultation", "zh": "搬家咨询", "hy": "Տեղափոխության խորհրդատվություն", "es": "Consulta de mudanza"}'::jsonb, NULL, 0, true, false),
(gen_random_uuid()::text, 'STEP', 'airport', '{"ru": "Встреча в аэропорту", "en": "Airport pickup", "zh": "机场接机", "hy": "Դիմավորում օդանավակայանում", "es": "Recogida en el aeropuerto"}'::jsonb, NULL, 1, true, true),
(gen_random_uuid()::text, 'STEP', 'stay', '{"ru": "Жильё на первое время", "en": "A place for the first days", "zh": "初期住宿", "hy": "Բնակություն առաջին օրերի համար", "es": "Alojamiento para empezar"}'::jsonb, NULL, 2, true, true),
(gen_random_uuid()::text, 'STEP', 'flat', '{"ru": "Поиск квартиры", "en": "Flat search", "zh": "找房", "hy": "Բնակարանի որոնում", "es": "Búsqueda de piso"}'::jsonb, NULL, 3, true, true),
(gen_random_uuid()::text, 'STEP', 'registration', '{"ru": "Регистрация", "en": "Registration", "zh": "登记", "hy": "Գրանցում", "es": "Empadronamiento"}'::jsonb, NULL, 4, true, true),
(gen_random_uuid()::text, 'STEP', 'socialCard', '{"ru": "Соцкарта", "en": "Social card", "zh": "社保卡", "hy": "Սոցքարտ", "es": "Tarjeta social"}'::jsonb, NULL, 5, true, true),
(gen_random_uuid()::text, 'STEP', 'bankCard', '{"ru": "Банковская карта", "en": "Bank card", "zh": "银行卡", "hy": "Բանկային քարտ", "es": "Tarjeta bancaria"}'::jsonb, NULL, 6, true, true),
(gen_random_uuid()::text, 'STEP', 'residence', '{"ru": "ВНЖ", "en": "Residence permit", "zh": "居留许可", "hy": "Կացության քարտ", "es": "Permiso de residencia"}'::jsonb, NULL, 7, true, true),
(gen_random_uuid()::text, 'DOC', 'passport', '{"ru": "Загранпаспорт", "en": "International passport", "zh": "护照", "hy": "Արտասահմանյան անձնագիր", "es": "Pasaporte"}'::jsonb, '{"ru": "Действует ещё минимум 6 месяцев после переезда", "en": "Valid for at least 6 months after the move", "zh": "搬家后至少还有 6 个月有效期", "hy": "Վավեր տեղափոխվելուց հետո առնվազն 6 ամիս", "es": "Vigente al menos 6 meses después de la mudanza"}'::jsonb, 0, true, false),
(gen_random_uuid()::text, 'DOC', 'passportCopies', '{"ru": "Копии паспорта", "en": "Passport copies", "zh": "护照复印件", "hy": "Անձնագրի պատճեններ", "es": "Copias del pasaporte"}'::jsonb, '{"ru": "2–3 копии страницы с фото", "en": "2–3 copies of the photo page", "zh": "照片页复印 2–3 份", "hy": "Լուսանկարով էջի 2–3 պատճեն", "es": "2–3 copias de la página con foto"}'::jsonb, 1, true, false),
(gen_random_uuid()::text, 'DOC', 'photos', '{"ru": "Фото 3×4", "en": "3×4 photos", "zh": "3×4 照片", "hy": "3×4 լուսանկարներ", "es": "Fotos 3×4"}'::jsonb, '{"ru": "4–6 штук для ВНЖ и соцкарты", "en": "4–6 for the residence permit and social card", "zh": "4–6 张,用于居留许可和社保卡", "hy": "4–6 հատ՝ կացության քարտի և սոցքարտի համար", "es": "4–6 para la residencia y la tarjeta social"}'::jsonb, 2, true, false),
(gen_random_uuid()::text, 'DOC', 'birthCertificate', '{"ru": "Свидетельство о рождении", "en": "Birth certificate", "zh": "出生证明", "hy": "Ծննդյան վկայական", "es": "Partida de nacimiento"}'::jsonb, '{"ru": "С апостилем — нужно для ВНЖ", "en": "Apostilled — needed for the residence permit", "zh": "需海牙认证,办理居留许可时使用", "hy": "Ապոստիլով՝ պետք է կացության քարտի համար", "es": "Apostillada: se necesita para la residencia"}'::jsonb, 3, true, false),
(gen_random_uuid()::text, 'DOC', 'marriageCertificate', '{"ru": "Свидетельство о браке", "en": "Marriage certificate", "zh": "结婚证", "hy": "Ամուսնության վկայական", "es": "Certificado de matrimonio"}'::jsonb, '{"ru": "С апостилем, если переезжаете семьёй", "en": "Apostilled, if moving as a family", "zh": "需海牙认证,全家搬迁时需要", "hy": "Ապոստիլով, եթե տեղափոխվում եք ընտանիքով", "es": "Apostillado, si os mudáis en familia"}'::jsonb, 4, true, false),
(gen_random_uuid()::text, 'DOC', 'diploma', '{"ru": "Диплом", "en": "Diploma", "zh": "学历证书", "hy": "Դիպլոմ", "es": "Título universitario"}'::jsonb, '{"ru": "С апостилем — пригодится для работы и ВНЖ", "en": "Apostilled — useful for work and the residence permit", "zh": "需海牙认证,工作和居留许可时有用", "hy": "Ապոստիլով՝ պետք կգա աշխատանքի և կացության քարտի համար", "es": "Apostillado: útil para trabajar y para la residencia"}'::jsonb, 5, true, false),
(gen_random_uuid()::text, 'DOC', 'criminalRecord', '{"ru": "Справка о несудимости", "en": "Criminal record certificate", "zh": "无犯罪记录证明", "hy": "Դատվածության բացակայության տեղեկանք", "es": "Certificado de antecedentes penales"}'::jsonb, '{"ru": "С апостилем, действует 3 месяца", "en": "Apostilled, valid for 3 months", "zh": "需海牙认证,有效期 3 个月", "hy": "Ապոստիլով, վավեր է 3 ամիս", "es": "Apostillado, válido 3 meses"}'::jsonb, 6, true, false),
(gen_random_uuid()::text, 'DOC', 'insurance', '{"ru": "Медицинская страховка", "en": "Health insurance", "zh": "医疗保险", "hy": "Բժշկական ապահովագրություն", "es": "Seguro médico"}'::jsonb, '{"ru": "На первые месяцы, пока нет ВНЖ", "en": "For the first months before the residence permit", "zh": "覆盖办下居留许可前的头几个月", "hy": "Առաջին ամիսների համար, մինչև կացության քարտը", "es": "Para los primeros meses, hasta tener la residencia"}'::jsonb, 7, true, false)
ON CONFLICT ("kind", "code") DO NOTHING;
@@ -0,0 +1,81 @@
-- CreateEnum
CREATE TYPE "OfferLimit" AS ENUM ('NONE', 'ONCE', 'WEEK', 'MONTH');
-- CreateEnum
CREATE TYPE "PartnerRole" AS ENUM ('OWNER', 'STAFF');
-- AlterTable
ALTER TABLE "CardUsage" ADD COLUMN "level" TEXT,
ADD COLUMN "memberUserId" TEXT,
ADD COLUMN "offerId" TEXT;
-- AlterTable
ALTER TABLE "Partner" ADD COLUMN "ownerInviteCode" TEXT;
-- CreateTable
CREATE TABLE "PartnerOffer" (
"id" TEXT NOT NULL,
"partnerId" TEXT NOT NULL,
"title" JSONB NOT NULL,
"minLevel" "SubscriptionPlan",
"limit" "OfferLimit" NOT NULL DEFAULT 'NONE',
"limitCount" INTEGER NOT NULL DEFAULT 1,
"active" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "PartnerOffer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PartnerMember" (
"id" TEXT NOT NULL,
"partnerId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" "PartnerRole" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PartnerMember_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PartnerInvite" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"partnerId" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PartnerInvite_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PartnerMember_partnerId_userId_key" ON "PartnerMember"("partnerId", "userId");
-- CreateIndex
CREATE UNIQUE INDEX "PartnerInvite_code_key" ON "PartnerInvite"("code");
-- CreateIndex
CREATE INDEX "CardUsage_clientId_offerId_createdAt_idx" ON "CardUsage"("clientId", "offerId", "createdAt");
-- CreateIndex
CREATE INDEX "CardUsage_partnerId_createdAt_idx" ON "CardUsage"("partnerId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "Partner_ownerInviteCode_key" ON "Partner"("ownerInviteCode");
-- AddForeignKey
ALTER TABLE "PartnerOffer" ADD CONSTRAINT "PartnerOffer_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PartnerMember" ADD CONSTRAINT "PartnerMember_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PartnerMember" ADD CONSTRAINT "PartnerMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PartnerInvite" ADD CONSTRAINT "PartnerInvite_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CardUsage" ADD CONSTRAINT "CardUsage_offerId_fkey" FOREIGN KEY ("offerId") REFERENCES "PartnerOffer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,64 @@
-- CreateEnum
CREATE TYPE "StaffRole" AS ENUM ('SUPERADMIN', 'SUPPORT', 'CONCIERGE', 'SALES');
-- AlterTable
ALTER TABLE "AdminUser" ADD COLUMN "roles" "StaffRole"[] DEFAULT ARRAY[]::"StaffRole"[],
ALTER COLUMN "email" DROP NOT NULL,
ALTER COLUMN "passwordHash" DROP NOT NULL;
-- AlterTable
ALTER TABLE "Client" ADD COLUMN "adminNote" TEXT,
ADD COLUMN "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- AlterTable
ALTER TABLE "Subscription" ADD COLUMN "agencyId" TEXT,
ADD COLUMN "amount" INTEGER,
ADD COLUMN "cancelledAt" TIMESTAMP(3),
ADD COLUMN "grantedByAdminId" TEXT,
ADD COLUMN "note" TEXT,
ADD COLUMN "payMethod" TEXT,
ADD COLUMN "refundAmount" INTEGER,
ADD COLUMN "refundedAt" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "AdminLoginRequest" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"pollKey" TEXT NOT NULL,
"adminUserId" TEXT,
"approvedAt" TIMESTAMP(3),
"consumedAt" TIMESTAMP(3),
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AdminLoginRequest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SavedSegment" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"filters" JSONB NOT NULL,
"adminUserId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SavedSegment_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "AdminLoginRequest_code_key" ON "AdminLoginRequest"("code");
-- CreateIndex
CREATE INDEX "Subscription_createdAt_idx" ON "Subscription"("createdAt");
-- AddForeignKey
ALTER TABLE "AdminLoginRequest" ADD CONSTRAINT "AdminLoginRequest_adminUserId_fkey" FOREIGN KEY ("adminUserId") REFERENCES "AdminUser"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SavedSegment" ADD CONSTRAINT "SavedSegment_adminUserId_fkey" FOREIGN KEY ("adminUserId") REFERENCES "AdminUser"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- существующие админы получают роли новой админки
UPDATE "AdminUser" SET "roles" = ARRAY['SUPERADMIN']::"StaffRole"[] WHERE "role" = 'OWNER';
UPDATE "AdminUser" SET "roles" = ARRAY['SUPPORT','CONCIERGE']::"StaffRole"[] WHERE "role" = 'OPERATOR';
-- подарки агентств: запоминаем агентство
UPDATE "Subscription" s SET "agencyId" = c."agencyId" FROM "Client" c WHERE s."clientId" = c.id AND s.source = 'AGENCY_GIFT';
+398 -14
View File
@@ -107,8 +107,17 @@ enum AdminRole {
OPERATOR
}
/// Роли новой админки (apps/backoffice). У одного человека может быть несколько.
enum StaffRole {
SUPERADMIN
SUPPORT
CONCIERGE
SALES
}
enum SubscriptionPlan {
BAREV_PLUS
ELITE
}
enum SubscriptionSource {
@@ -147,6 +156,15 @@ enum TaskStatus {
DONE
}
/// Заявка с сайта: NEW — человек ещё не открыл бота; LINKED — открыл, но заказ не создан (нужен звонок оператора);
/// ORDERED — стала заказом; CANCELLED — отменена оператором
enum WebRequestStatus {
NEW
LINKED
ORDERED
CANCELLED
}
enum PayoutStatus {
DRAFT
APPROVED
@@ -170,6 +188,7 @@ model User {
client Client?
provider Provider?
dispatcher CompanyDispatcher[]
partnerMembers PartnerMember[]
messages Message[]
}
@@ -189,6 +208,12 @@ model Client {
giftUsedAt DateTime?
/// короткий код клубной карты (Этап 3)
cardCode String @unique
/// сценарий приложения: переезжает или уже живёт в Армении; null — ещё не выбрал
segment ClientSegment?
/// метки команды: VIP, проблемный…
tags String[] @default([])
/// заметка команды о клиенте (клиент не видит)
adminNote String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -197,6 +222,10 @@ model Client {
ratings Rating[]
cardUsages CardUsage[]
promoUses PromoCodeUse[]
webRequests WebRequest[]
leads Lead[]
requests ClientRequest[]
concierge ConciergeMessage[]
@@index([agencyId])
}
@@ -254,6 +283,9 @@ model Provider {
onShift Boolean @default(false)
active Boolean @default(true)
inviteCode String @unique
/// пресет ставок (джун / мидл / прайват); если задан — заменяет rateType/rateValue
payoutPresetId String?
payoutPreset PayoutPreset? @relation(fields: [payoutPresetId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -265,14 +297,30 @@ model Provider {
payouts Payout[]
@@index([role, active])
concierge ConciergeMessage[]
}
/// Пресет ставок исполнителя: для каждой строки цены — фикс или % (см. packages/core/src/payout.ts)
model PayoutPreset {
id String @id @default(cuid())
name String
role ProviderRole
rules Json
sortOrder Int @default(0)
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
providers Provider[]
}
model District {
id String @id @default(cuid())
code String @unique
sortOrder Int @default(0)
providers ProviderDistrict[]
orders Order[]
providers ProviderDistrict[]
orders Order[]
webRequests WebRequest[]
}
model ProviderDistrict {
@@ -304,6 +352,8 @@ model Category {
sortOrder Int @default(0)
/// скидка Barev+ на направление, %
barevPlusDiscountPct Int @default(0)
/// скидка Barev Elite на направление, %
eliteDiscountPct Int @default(0)
active Boolean @default(true)
services Service[]
}
@@ -330,8 +380,9 @@ model Service {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
checklist ChecklistItem[]
orders Order[]
checklist ChecklistItem[]
orders Order[]
webRequests WebRequest[]
}
/// Шаблон пункта чек-листа услуги
@@ -379,6 +430,8 @@ model Order {
/// язык клиента на момент заказа
locale Locale
basePrice Int
/// разбивка цены на момент заказа (основа, комнаты, санузлы, доп. услуги) — для расчёта выплат
priceBreakdown Json?
discountPct Int @default(0)
discountAmount Int @default(0)
totalPrice Int
@@ -413,6 +466,7 @@ model Order {
payments Payment[]
commission AgencyCommission?
tasks OperatorTask[]
webRequest WebRequest?
@@index([status])
@@index([scheduledAt])
@@ -644,9 +698,22 @@ model Subscription {
startsAt DateTime
endsAt DateTime
remindedAt DateTime?
/// оплачено клиентом, AMD (PAID); для подарков пусто
amount Int?
/// cash | transfer | card | link
payMethod String?
/// кто из команды начислил / продал
grantedByAdminId String?
/// агентство, подарившее подписку (AGENCY_GIFT)
agencyId String?
note String?
cancelledAt DateTime?
refundAmount Int?
refundedAt DateTime?
createdAt DateTime @default(now())
@@index([clientId, endsAt])
@@index([createdAt])
}
// ───────────── Партнёры и клубная карта (Этап 3) ─────────────
@@ -661,18 +728,90 @@ model Partner {
terms Json?
address String?
active Boolean @default(true)
/// одноразовая ссылка для собственника: t.me/<рабочий бот>?start=po-<код> (выдаёт админка)
ownerInviteCode String? @unique
createdAt DateTime @default(now())
usages CardUsage[]
offers PartnerOffer[]
members PartnerMember[]
invites PartnerInvite[]
}
enum OfferLimit {
/// без ограничений
NONE
/// только первое посещение
ONCE
/// N раз в календарную неделю
WEEK
/// N раз в календарный месяц
MONTH
}
/// Предложение партнёра: что положено клиенту с определённым уровнем и как часто
model PartnerOffer {
id String @id @default(cuid())
partnerId String
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Cascade)
/// { ru, en, zh, hy, es }
title Json
/// минимальный уровень: пусто — любой член клуба, BAREV_PLUS — Barev+ и Elite, ELITE — только Elite
minLevel SubscriptionPlan?
limit OfferLimit @default(NONE)
limitCount Int @default(1)
active Boolean @default(true)
sortOrder Int @default(0)
usages CardUsage[]
}
enum PartnerRole {
OWNER
STAFF
}
/// Собственник или сотрудник партнёра — работает в рабочем боте: проверяет QR клиентов
model PartnerMember {
id String @id @default(cuid())
partnerId String
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id])
role PartnerRole
createdAt DateTime @default(now())
@@unique([partnerId, userId])
}
/// Приглашение сотрудника: собственник показывает QR, сотрудник сканирует — t.me/<рабочий бот>?start=pi-<код>
model PartnerInvite {
id String @id @default(cuid())
code String @unique
partnerId String
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Cascade)
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
}
/// Проверка клубной карты у партнёра (offerId пусто) или списание лимитного предложения
model CardUsage {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id])
partnerId String?
partner Partner? @relation(fields: [partnerId], references: [id])
createdAt DateTime @default(now())
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id])
partnerId String?
partner Partner? @relation(fields: [partnerId], references: [id])
offerId String?
offer PartnerOffer? @relation(fields: [offerId], references: [id], onDelete: SetNull)
/// кто из сотрудников партнёра проверил
memberUserId String?
/// уровень клиента на момент проверки
level String?
createdAt DateTime @default(now())
@@index([clientId, offerId, createdAt])
@@index([partnerId, createdAt])
}
// ───────────── Финансы ─────────────
@@ -694,17 +833,53 @@ model Payout {
model AdminUser {
id String @id @default(cuid())
email String @unique
/// вход по паролю — только старая админка; в новой вход через Telegram
email String? @unique
name String
passwordHash String
passwordHash String?
role AdminRole @default(OPERATOR)
/// роли в новой админке
roles StaffRole[] @default([])
totpSecret String?
totpEnabled Boolean @default(false)
active Boolean @default(true)
/// Telegram оператора — уведомления приходят через рабочий бот
telegramId BigInt? @unique
/// одноразовый код ссылки t.me/<рабочий бот>?start=adm-<код>
tgLinkToken String? @unique
/// присылать уведомления в Telegram
tgNotify Boolean @default(true)
createdAt DateTime @default(now())
sessions AdminSession[]
audit AuditLog[]
sessions AdminSession[]
audit AuditLog[]
loginRequests AdminLoginRequest[]
segments SavedSegment[]
}
/// Вход в новую админку через Telegram: страница показывает ссылку t.me/<рабочий бот>?start=lg-<code>,
/// бот подтверждает по telegramId, страница опрашивает статус и получает сессию
model AdminLoginRequest {
id String @id @default(cuid())
code String @unique
/// секрет браузера — по нему страница забирает сессию (code виден в ссылке)
pollKey String
adminUserId String?
adminUser AdminUser? @relation(fields: [adminUserId], references: [id], onDelete: Cascade)
approvedAt DateTime?
consumedAt DateTime?
expiresAt DateTime
createdAt DateTime @default(now())
}
/// Сохранённая выборка клиентов (фильтры списка)
model SavedSegment {
id String @id @default(cuid())
name String
filters Json
adminUserId String
adminUser AdminUser @relation(fields: [adminUserId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
}
model AdminSession {
@@ -747,3 +922,212 @@ model StartToken {
utm Json?
createdAt DateTime @default(now())
}
/// Заявка на услугу с сайта (barevclub.com/clean). Попадает в админку сразу; когда человек открывает бота
/// по ссылке с token, заявка привязывается к клиенту и, если выбранное время свободно, становится заказом.
model WebRequest {
id String @id @default(cuid())
number Int @unique @default(autoincrement())
/// id StartToken — payload ссылки в бота
token String @unique
serviceId String
service Service @relation(fields: [serviceId], references: [id])
params Json
/// желаемое время, оператор подтверждает
preferredAt DateTime
durationMinutes Int
districtId String
district District @relation(fields: [districtId], references: [id])
addressText String
note String?
phone String
name String?
locale Locale
/// цена на момент заявки, без скидки Barev+
totalPrice Int
consentAt DateTime
consentVersion String
status WebRequestStatus @default(NEW)
clientId String?
client Client? @relation(fields: [clientId], references: [id])
orderId String? @unique
order Order? @relation(fields: [orderId], references: [id])
adminNote String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, createdAt])
}
enum LeadStatus {
NEW
IN_WORK
DONE
CANCELLED
}
enum LeadContactType {
PHONE
TELEGRAM
}
/// Заявка из квиза на главной («Переезжаю в Армению»). Создаётся сразу после шага с контактом,
/// дальше дополняется ответами; submittedAt — человек дошёл до конца.
model Lead {
id String @id @default(cuid())
number Int @unique @default(autoincrement())
/// секрет для дозаписи ответов из браузера (id видят в URL логов, ключ — нет)
key String
name String
contact String
contactType LeadContactType
locale Locale
agency String?
utm Json?
/// уже живёт в Армении — квиз отправил на /live
moved Boolean?
/// month | quarter | later | date
moveWhen String?
moveDate DateTime?
/// CONSULT | TURNKEY | CUSTOM
package String?
/// конструктор: { airport, stay, stayBudgetFrom, stayBudgetTo, flat, socialCard, bankCard, registration, residence }
options Json?
submittedAt DateTime?
consentAt DateTime
consentVersion String
status LeadStatus @default(NEW)
adminNote String?
/// клиент в приложении — заявка из приложения или найдена по @username из квиза
clientId String?
client Client? @relation(fields: [clientId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
steps LeadStep[]
docs LeadDoc[]
@@index([status, createdAt])
@@index([clientId])
}
enum ClientSegment {
MOVING
LIVING
}
enum LeadStepStatus {
TODO
IN_PROGRESS
DONE
SKIPPED
}
/// Шаг плана переезда (аэропорт, жильё, соцкарта…) — статус двигает оператор, клиент видит прогресс
model LeadStep {
id String @id @default(cuid())
leadId String
lead Lead @relation(fields: [leadId], references: [id], onDelete: Cascade)
/// код из шаблона (RelocationItem) или custom-… для шага, добавленного только этому клиенту
code String
/// своё название для этого клиента; пусто — название из шаблона
title String?
sortOrder Int
status LeadStepStatus @default(TODO)
/// комментарий оператора для клиента: «просмотры в среду в 15:00»
note String?
updatedAt DateTime @updatedAt
@@unique([leadId, code])
}
enum RelocationItemKind {
STEP
DOC
}
/// Общий список шагов переезда и документов — редактируется в админке; у клиента копия, которую можно менять отдельно
model RelocationItem {
id String @id @default(cuid())
kind RelocationItemKind
code String
/// { ru, en, zh, hy, es }
name Json
/// подсказка под названием (для документов)
hint Json?
sortOrder Int @default(0)
active Boolean @default(true)
/// шаг входит в пакет «под ключ»
inTurnkey Boolean @default(true)
@@unique([kind, code])
}
/// Документ в чек-листе конкретного клиента
model LeadDoc {
id String @id @default(cuid())
leadId String
lead Lead @relation(fields: [leadId], references: [id], onDelete: Cascade)
code String
title String?
checked Boolean @default(false)
sortOrder Int
@@unique([leadId, code])
}
enum ClientRequestKind {
SUBSCRIPTION
SERVICE
}
enum ClientRequestStatus {
NEW
DONE
CANCELLED
}
/// Заявка из приложения оператору: «хочу Elite», «перезвоните по ремонту»
model ClientRequest {
id String @id @default(cuid())
number Int @unique @default(autoincrement())
clientId String
client Client @relation(fields: [clientId], references: [id])
kind ClientRequestKind
plan SubscriptionPlan?
serviceCode String?
comment String?
status ClientRequestStatus @default(NEW)
adminNote String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, createdAt])
@@index([clientId])
}
enum ConciergeAuthor {
CLIENT
PROVIDER
OPERATOR
}
/// Переписка из админки: с клиентом (клиентский бот) или с исполнителем (рабочий бот); ответ приходит в тот же чат с ботом
model ConciergeMessage {
id String @id @default(cuid())
clientId String?
client Client? @relation(fields: [clientId], references: [id])
providerId String?
provider Provider? @relation(fields: [providerId], references: [id])
author ConciergeAuthor
/// кто из операторов ответил
adminUserId String?
text String
/// оператор прочитал сообщение клиента
readAt DateTime?
createdAt DateTime @default(now())
@@index([clientId, createdAt])
@@index([providerId, createdAt])
@@index([author, readAt])
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Создать или обновить администратора (пароль — из переменной ADMIN_PASSWORD, чтобы не светить его в истории команд).
*
* ADMIN_PASSWORD=… pnpm --filter @barev/db create-admin <логин> [OWNER|OPERATOR] [имя]
*/
import path from 'node:path';
import { config } from 'dotenv';
import { hash } from '@node-rs/argon2';
import { createDb } from '../index.ts';
config({ path: path.resolve(import.meta.dirname, '../../../../.env'), quiet: true });
const [login, role = 'OWNER', name] = process.argv.slice(2);
const password = process.env.ADMIN_PASSWORD;
if (!login || !password || !['OWNER', 'OPERATOR'].includes(role)) {
console.error('ADMIN_PASSWORD=… create-admin <логин> [OWNER|OPERATOR] [имя]');
process.exit(1);
}
const db = createDb();
const email = login.toLowerCase();
const passwordHash = await hash(password);
await db.adminUser.upsert({
where: { email },
create: { email, name: name ?? login, role: role as 'OWNER' | 'OPERATOR', roles: role === 'OWNER' ? ['SUPERADMIN'] : ['SUPPORT', 'CONCIERGE'], passwordHash },
update: { passwordHash, role: role as 'OWNER' | 'OPERATOR', active: true },
});
console.log(`Администратор ${email} (${role}) готов. 2FA настраивается при первом входе.`);
await db.$disconnect();
+26
View File
@@ -0,0 +1,26 @@
/**
* Сброс 2FA администратора (потерян телефон и т.п.). При следующем входе админка покажет QR заново.
*
* pnpm --filter @barev/db reset-2fa owner@barev.local
*/
import path from 'node:path';
import { config } from 'dotenv';
import { createDb } from '../index.ts';
config({ path: path.resolve(import.meta.dirname, '../../../../.env'), quiet: true });
const email = process.argv[2]?.toLowerCase();
if (!email) {
console.error('Укажите email: pnpm --filter @barev/db reset-2fa <email>');
process.exit(1);
}
const db = createDb();
const admin = await db.adminUser.findUnique({ where: { email } });
if (!admin) {
console.error(`Администратор ${email} не найден`);
process.exit(1);
}
await db.adminUser.update({ where: { id: admin.id }, data: { totpEnabled: false, totpSecret: null } });
await db.adminSession.deleteMany({ where: { adminUserId: admin.id } });
console.log(`2FA для ${email} сброшена. При входе появится QR-код для настройки.`);
await db.$disconnect();
+117
View File
@@ -0,0 +1,117 @@
/**
* Синхронизация каталога услуг с кодом (packages/core/src/catalog.ts) без потери настроек владельца.
* Запускается при каждом старте контейнера, идемпотентна:
* - форма заказа (paramsSchema) и чек-лист берутся из кода — они связаны с переводами и логикой;
* - цены: значения, уже заданные в админке, сохраняются; новые поля и доп. услуги добавляются из кода;
* - новые услуги каталога создаются; существующие направления не трогаются;
* - районы Еревана создаются, если их ещё нет (без них нельзя оформить заказ с адресом).
*
* pnpm --filter @barev/db sync-catalog
*/
import path from 'node:path';
import { config } from 'dotenv';
import { CATALOG, DEFAULT_CATEGORY_DISCOUNTS, DEFAULT_ELITE_DISCOUNTS, flatPercentRules, pricingSchema, YEREVAN_DISTRICTS, type Pricing } from '@barev/core';
import type { Db } from '../index.ts';
import { createDb } from '../index.ts';
config({ path: path.resolve(import.meta.dirname, '../../../../.env'), quiet: true });
/** Цены из кода, поверх — сохранённые владельцем значения для тех же ключей */
export function mergePricing(fromCode: Pricing, stored: unknown): Pricing {
const parsed = pricingSchema.safeParse(stored);
if (!parsed.success || parsed.data.kind !== fromCode.kind) return fromCode;
const old = parsed.data;
if (fromCode.kind === 'fixed' || old.kind === 'fixed') return { ...fromCode, ...old } as Pricing;
const oldRaw = (stored ?? {}) as { tiers?: Record<string, Record<string, unknown>>; extras?: Record<string, unknown> };
const tiers = Object.fromEntries(
Object.entries(fromCode.tiers).map(([variant, tier]) => {
const saved = oldRaw.tiers?.[variant] ?? {};
// поля, которых в БД ещё не было, берём из кода (а не дефолт схемы 0)
const merged = { ...tier };
for (const k of Object.keys(tier) as (keyof typeof tier)[]) if (typeof saved[k] === 'number') merged[k] = saved[k] as number;
return [variant, merged];
}),
);
const extras = Object.fromEntries(
Object.entries(fromCode.extras).map(([code, e]) => [code, oldRaw.extras?.[code] ? { ...e, ...old.extras[code], unit: e.unit } : e]),
);
return { ...fromCode, tiers, extras };
}
/** Пресеты ставок клинеров по умолчанию (заглушки — владелец меняет в админке). Создаются, только если пресетов ещё нет. */
export async function ensureDefaultPresets(db: Db) {
if ((await db.payoutPreset.count({ where: { role: 'CLEANER' } })) > 0) return 0;
const cleaning = CATALOG.find((s) => s.code === 'cleaning')!;
const variants = cleaning.pricing.kind === 'rooms' ? Object.keys(cleaning.pricing.tiers) : [];
const extras = cleaning.pricing.kind === 'rooms' ? Object.keys(cleaning.pricing.extras) : [];
const presets = [
{ name: 'Джун', pct: 50 },
{ name: 'Мидл', pct: 60 },
{ name: 'Прайват', pct: 70 },
];
for (const [i, p] of presets.entries()) {
await db.payoutPreset.create({ data: { name: p.name, role: 'CLEANER', sortOrder: i, rules: flatPercentRules(p.pct, variants, extras) } });
}
return presets.length;
}
/** Районы из кода; порядок сортировки не перезаписывается, если район уже есть */
export async function ensureDistricts(db: Db) {
const existing = new Set((await db.district.findMany({ select: { code: true } })).map((d) => d.code));
const missing = YEREVAN_DISTRICTS.map((code, i) => ({ code, sortOrder: i })).filter((d) => !existing.has(d.code));
if (missing.length) await db.district.createMany({ data: missing });
return missing.length;
}
async function main() {
const db = createDb();
let created = 0;
let updated = 0;
for (const [i, s] of CATALOG.entries()) {
const category = await db.category.upsert({
where: { code: s.category },
create: { code: s.category, barevPlusDiscountPct: DEFAULT_CATEGORY_DISCOUNTS[s.category], eliteDiscountPct: DEFAULT_ELITE_DISCOUNTS[s.category] },
update: {},
});
const existing = await db.service.findUnique({ where: { code: s.code } });
if (!existing) {
await db.service.create({
data: {
code: s.code,
categoryId: category.id,
flow: s.flow,
providerRole: s.providerRole,
pricing: s.pricing,
paramsSchema: s.paramsSchema,
slotConfig: s.slotConfig,
isOnline: s.isOnline,
active: s.stage === 1,
sortOrder: i,
},
});
created++;
} else {
await db.service.update({
where: { id: existing.id },
data: { paramsSchema: s.paramsSchema, pricing: mergePricing(s.pricing, existing.pricing) },
});
updated++;
}
const service = await db.service.findUniqueOrThrow({ where: { code: s.code } });
for (const [j, c] of s.checklist.entries()) {
await db.checklistItem.upsert({
where: { serviceId_code: { serviceId: service.id, code: c.code } },
create: { serviceId: service.id, code: c.code, requiresPhoto: !!c.requiresPhoto, sortOrder: j },
update: { requiresPhoto: !!c.requiresPhoto, sortOrder: j },
});
}
}
const presets = await ensureDefaultPresets(db);
const districts = await ensureDistricts(db);
console.log(
`Каталог синхронизирован: создано ${created}, обновлено ${updated}${presets ? `, пресетов ставок: ${presets}` : ''}${districts ? `, районов: ${districts}` : ''}`,
);
await db.$disconnect();
}
if (import.meta.url === `file://${process.argv[1]}`) await main();
+10 -2
View File
@@ -10,6 +10,7 @@ import { hash } from '@node-rs/argon2';
import {
CATALOG,
DEFAULT_CATEGORY_DISCOUNTS,
DEFAULT_ELITE_DISCOUNTS,
DEFAULT_SETTINGS,
YEREVAN_DISTRICTS,
agencyCommission,
@@ -20,6 +21,7 @@ import {
type CategoryCode,
} from '@barev/core';
import { createDb, type Locale, type OrderStatus } from '../index.ts';
import { ensureDefaultPresets } from '../scripts/sync-catalog.ts';
config({ path: path.resolve(import.meta.dirname, '../../../../.env'), quiet: true });
@@ -71,7 +73,7 @@ async function main() {
const categoryOrder: CategoryCode[] = ['HOME', 'DOCS', 'LEARNING', 'PERKS'];
const categories: Record<string, string> = {};
for (const [i, code] of categoryOrder.entries()) {
const c = await db.category.create({ data: { code, sortOrder: i, barevPlusDiscountPct: DEFAULT_CATEGORY_DISCOUNTS[code] } });
const c = await db.category.create({ data: { code, sortOrder: i, barevPlusDiscountPct: DEFAULT_CATEGORY_DISCOUNTS[code], eliteDiscountPct: DEFAULT_ELITE_DISCOUNTS[code] } });
categories[code] = c.id;
}
const services: Record<string, { id: string }> = {};
@@ -102,6 +104,10 @@ async function main() {
data: { code: 'transfer', sortOrder: 1, name: { ru: 'Перевод на карту', en: 'Card transfer', zh: '银行卡转账', hy: 'Փոխանցում քարտին', es: 'Transferencia a tarjeta' } },
});
// ── пресеты ставок клинеров ──
await ensureDefaultPresets(db);
const middle = await db.payoutPreset.findFirstOrThrow({ where: { name: 'Мидл' } });
// ── агентства ──
const domik = await db.agency.create({ data: { code: 'DOMIK', name: 'Domik Realty', commissionPct: 10, contact: '+374 00 000000' } });
await db.agency.create({ data: { code: 'YERHOME', name: 'Yerevan Home', commissionPct: 10 } });
@@ -135,6 +141,7 @@ async function main() {
districts: (keyof typeof districts)[];
companyId?: string;
rate?: { type: 'FIXED' | 'PERCENT'; value: number };
presetId?: string;
onShift?: boolean;
rating?: [number, number];
schedule?: typeof workweek;
@@ -150,6 +157,7 @@ async function main() {
languages: p.languages,
rateType: p.rate?.type ?? 'PERCENT',
rateValue: p.rate?.value ?? 70,
payoutPresetId: p.presetId,
onShift: p.onShift ?? true,
ratingSum: p.rating?.[0] ?? 0,
ratingCount: p.rating?.[1] ?? 0,
@@ -160,7 +168,7 @@ async function main() {
});
}
const maria = await provider({ tg: SEED_TG.cleanerMaria, name: 'Мария Петросян', role: 'CLEANER', languages: ['ru', 'hy', 'en'], districts: ['kentron', 'arabkir'], rating: [47, 10], invite: 'PRO-MARIA' });
const maria = await provider({ tg: SEED_TG.cleanerMaria, name: 'Мария Петросян', role: 'CLEANER', languages: ['ru', 'hy', 'en'], districts: ['kentron', 'arabkir'], rating: [47, 10], presetId: middle.id, invite: 'PRO-MARIA' });
const ani = await provider({ tg: SEED_TG.cleanerAni, name: 'Ани Саргсян', role: 'CLEANER', languages: ['hy', 'ru'], districts: ['shengavit', 'erebuni', 'malatia_sebastia'], rate: { type: 'FIXED', value: 9000 }, rating: [22, 5], invite: 'PRO-ANI' });
const elena = await provider({ tg: SEED_TG.cleanerElena, name: 'Elena Gómez', role: 'CLEANER', languages: ['es', 'en', 'ru'], districts: ['kentron', 'arabkir', 'nor_nork'], schedule: everyday, invite: 'PRO-ELENA' });
await provider({ tg: SEED_TG.driverArman, name: 'Арман Мкртчян', role: 'DRIVER', languages: ['ru', 'hy', 'en'], districts: ['kentron'], schedule: everyday, rate: { type: 'FIXED', value: 7000 }, invite: 'PRO-ARMAN' });
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@barev/i18n",
"version": "0.0.0",
"private": true,
"type": "module",
"sideEffects": false,
"exports": {
".": "./src/index.ts",
"./locales/*.json": "./locales/*.json"
},
"scripts": {
"check": "tsx scripts/check.ts",
"export": "tsx scripts/export.ts",
"import": "tsx scripts/import.ts",
"test": "tsx scripts/check.ts",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"tsx": "^4.20.0",
"@barev/core": "workspace:*"
}
}
+1
View File
@@ -0,0 +1 @@
{ "en": {}, "zh": {}, "hy": {}, "es": {} }
+62
View File
@@ -0,0 +1,62 @@
/**
* Проверка переводов (критерий приёмки «нет ключей без перевода»):
* - в каждом языке есть все ключи ru, без пустых значений и лишних ключей;
* - плейсхолдеры {{x}} совпадают с исходником;
* - отчёт по статусам needs_review / approved.
* Код выхода 1 при любой ошибке — для CI.
*/
import { readdirSync, readFileSync, statSync } from 'node:fs';
import path from 'node:path';
import { dynamicKeys } from './dynamic-keys.ts';
import { placeholders, readLocale, readReview, ROOT, statusOf, TARGETS } from './lib.ts';
const ru = readLocale('ru');
const review = readReview();
const errors: string[] = [];
for (const [key, value] of Object.entries(ru)) {
if (!value.trim()) errors.push(`ru: пустое значение ${key}`);
}
// ключи-литералы в коде (вида t(«a.b»)) должны существовать в ru
const NAMESPACES = Object.keys(JSON.parse(readFileSync(path.join(ROOT, 'locales/ru.json'), 'utf8')) as object);
const KEY_RE = new RegExp(`['\`](?:${NAMESPACES.join('|')})\\.[a-zA-Z0-9_.]+(?=['\`])`, 'g');
function walk(dir: string, out: string[] = []) {
for (const name of readdirSync(dir)) {
if (['node_modules', 'generated', '.next', 'dist', '.turbo'].includes(name)) continue;
const p = path.join(dir, name);
if (statSync(p).isDirectory()) walk(p, out);
else if (/\.(ts|tsx)$/.test(name) && !/\.test\.ts$/.test(name)) out.push(p);
}
return out;
}
const repo = path.resolve(ROOT, '../..');
const pluralBase = (k: string) => k.replace(/_(one|few|many|other)$/, '');
const known = new Set(Object.keys(ru).flatMap((k) => [k, pluralBase(k)]));
for (const file of [...walk(path.join(repo, 'apps')), ...walk(path.join(repo, 'packages'))]) {
for (const m of readFileSync(file, 'utf8').matchAll(KEY_RE)) {
const key = m[0].slice(1);
if (!known.has(key) && !key.endsWith('.')) errors.push(`ключ из кода отсутствует в ru: ${key} (${path.relative(repo, file)})`);
}
}
for (const key of dynamicKeys()) if (!known.has(key)) errors.push(`динамический ключ отсутствует в ru: ${key}`);
for (const locale of TARGETS) {
const msgs = readLocale(locale);
const counts = { approved: 0, needs_review: 0, missing: 0 };
for (const [key, source] of Object.entries(ru)) {
const status = statusOf(review, locale, key, source, msgs[key]);
counts[status]++;
if (status === 'missing') errors.push(`${locale}: нет перевода ${key}`);
else if (placeholders(source) !== placeholders(msgs[key]!)) errors.push(`${locale}: плейсхолдеры не совпадают в ${key}`);
}
for (const key of Object.keys(msgs)) if (!(key in ru)) errors.push(`${locale}: лишний ключ ${key}`);
console.log(`${locale}: ${Object.keys(ru).length} ключей · approved ${counts.approved} · needs_review ${counts.needs_review} · missing ${counts.missing}`);
}
if (errors.length) {
console.error(`\n${errors.length} ошибок:\n` + errors.slice(0, 50).join('\n'));
process.exit(1);
}
console.log('i18n: все ключи переведены.');
+37
View File
@@ -0,0 +1,37 @@
/**
* Ключи, которые строятся динамически из домена (статусы сценариев, кнопки, чек-листы,
* параметры услуг, районы, роли). Используется в check.ts.
*/
import { CATALOG, FLOWS, flowStatuses, PROVIDER_ROLES, SERVICE_FLOWS, SETTING_KEYS, YEREVAN_DISTRICTS } from '@barev/core';
export function dynamicKeys(): string[] {
const keys: string[] = [];
for (const flow of SERVICE_FLOWS) {
for (const s of flowStatuses(flow)) keys.push(`status.${flow}.${s}`, `notify.status.${flow}.${s}`);
for (const t of FLOWS[flow].transitions) if (t.action) keys.push(`pro.action.${t.action}`, `admin.events.${t.action}`);
for (const e of FLOWS[flow].events) keys.push(`pro.action.${e.code}`, `admin.events.${e.code}`, `notify.event.${e.code}`);
keys.push(`client.detail.providerLabel.${flow}`);
}
for (const s of CATALOG) {
keys.push(`services.${s.code}.name`, `services.${s.code}.description`);
for (const c of s.checklist) keys.push(`checklist.${c.code}`);
if (s.pricing.kind === 'rooms') {
for (const [code, e] of Object.entries(s.pricing.extras)) {
keys.push(`extras.${code}.name`);
if (e.unit !== 'service') keys.push(`extras.${code}.per`);
}
}
for (const f of s.paramsSchema.fields) {
if (f.type === 'extras') continue;
keys.push(`params.${s.code}.${f.key}.label`);
if (f.type === 'select') for (const o of f.options) keys.push(`params.${s.code}.${f.key}.${o}`, `params.${s.code}.${f.key}.${o}_hint`);
}
}
for (const d of YEREVAN_DISTRICTS) keys.push(`districts.${d}`);
for (const r of PROVIDER_ROLES) keys.push(`roles.${r}`);
for (const k of SETTING_KEYS) keys.push(`admin.settings.${k.replace('.', '_')}`);
// приложение: статусы шагов переезда, категории партнёров (названия шагов и документов — в базе, RelocationItem)
for (const s of ['TODO', 'IN_PROGRESS', 'DONE', 'SKIPPED']) keys.push(`client.move.status.${s}`);
for (const c of ['cafe', 'restaurant', 'shop', 'beauty', 'other']) keys.push(`client.partners.cat.${c}`);
return keys;
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Выгрузка для носителей языка: i18n-review/<lang>.csv
* key, ru, translation, status, approved
* Носитель правит колонку translation и ставит "yes" в approved, затем: pnpm i18n:import <файл>.
*
* pnpm i18n:export — все языки, только needs_review
* pnpm i18n:export -- --all — все ключи
*/
import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { csvEscape, readLocale, readReview, ROOT, statusOf, TARGETS } from './lib.ts';
const all = process.argv.includes('--all');
const ru = readLocale('ru');
const review = readReview();
const outDir = path.resolve(ROOT, '../../i18n-review');
mkdirSync(outDir, { recursive: true });
for (const locale of TARGETS) {
const msgs = readLocale(locale);
const rows = [['key', 'ru', 'translation', 'status', 'approved']];
for (const [key, source] of Object.entries(ru)) {
const status = statusOf(review, locale, key, source, msgs[key]);
if (!all && status === 'approved') continue;
rows.push([key, source, msgs[key] ?? '', status, '']);
}
const file = path.join(outDir, `${locale}.csv`);
// BOM — чтобы Excel открыл UTF-8 с армянским и китайским
writeFileSync(file, '' + rows.map((r) => r.map(csvEscape).join(',')).join('\n') + '\n');
console.log(`${file}: ${rows.length - 1} строк`);
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Загрузка проверенных переводов: pnpm i18n:import -- i18n-review/hy.csv [i18n-review/zh.csv …]
* Язык берётся из имени файла. Строки с approved=yes помечаются проверенными для текущего русского текста.
*/
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { isLocale } from '../src/index.ts';
import { parseCsv, placeholders, readLocale, readReview, sourceHash, writeLocale, writeReview } from './lib.ts';
const files = process.argv.slice(2).filter((a) => !a.startsWith('--'));
if (!files.length) {
console.error('Укажите CSV-файлы: pnpm i18n:import -- i18n-review/hy.csv');
process.exit(1);
}
const ru = readLocale('ru');
const order = Object.keys(ru);
const review = readReview();
for (const file of files) {
const locale = path.basename(file, '.csv');
if (!isLocale(locale) || locale === 'ru') throw new Error(`Не понимаю язык файла ${file}`);
const [header, ...rows] = parseCsv(readFileSync(path.resolve(process.env.INIT_CWD ?? process.cwd(), file), 'utf8').replace(/^\uFEFF/, ''));
const col = (name: string) => header!.indexOf(name);
const msgs = readLocale(locale);
let updated = 0;
let approved = 0;
for (const r of rows) {
const key = r[col('key')]!;
const translation = r[col('translation')]?.trim();
if (!(key in ru) || !translation) continue;
if (placeholders(ru[key]!) !== placeholders(translation)) {
console.warn(`${locale}: пропущен ${key} — плейсхолдеры не совпадают с русским`);
continue;
}
if (msgs[key] !== translation) updated++;
msgs[key] = translation;
if (/^(yes|да|y|1|ok)$/i.test(r[col('approved')]?.trim() ?? '')) {
review[locale][key] = sourceHash(ru[key]!);
approved++;
}
}
writeLocale(locale, msgs, order);
console.log(`${locale}: обновлено ${updated}, проверено ${approved}`);
}
writeReview(review);
+79
View File
@@ -0,0 +1,79 @@
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { flatten, LOCALES, type Locale, type Messages } from '../src/index.ts';
export const ROOT = path.resolve(import.meta.dirname, '..');
export const TARGETS = LOCALES.filter((l) => l !== 'ru') as Exclude<Locale, 'ru'>[];
type Tree = { [k: string]: string | Tree };
export function readLocale(locale: Locale): Messages {
return flatten(JSON.parse(readFileSync(path.join(ROOT, 'locales', `${locale}.json`), 'utf8')) as Tree);
}
export function writeLocale(locale: Locale, flat: Messages, order: string[]) {
const tree: Tree = {};
for (const key of order) {
if (flat[key] === undefined) continue;
const parts = key.split('.');
let node = tree;
for (const p of parts.slice(0, -1)) node = (node[p] ??= {}) as Tree;
node[parts.at(-1)!] = flat[key]!;
}
writeFileSync(path.join(ROOT, 'locales', `${locale}.json`), JSON.stringify(tree, null, 2) + '\n');
}
/**
* review.json хранит для каждого языка { ключ: хеш русского текста на момент проверки }.
* Если русский текст изменился, хеш не совпадёт — ключ снова needs_review.
*/
export type Review = Record<Exclude<Locale, 'ru'>, Record<string, string>>;
export const readReview = (): Review => JSON.parse(readFileSync(path.join(ROOT, 'review.json'), 'utf8'));
export const writeReview = (r: Review) => writeFileSync(path.join(ROOT, 'review.json'), JSON.stringify(r, null, 2) + '\n');
export const sourceHash = (s: string) => createHash('sha256').update(s).digest('hex').slice(0, 12);
export type Status = 'approved' | 'needs_review' | 'missing';
export function statusOf(review: Review, locale: Exclude<Locale, 'ru'>, key: string, ru: string, value: string | undefined): Status {
if (value === undefined || value === '') return 'missing';
return review[locale][key] === sourceHash(ru) ? 'approved' : 'needs_review';
}
export const placeholders = (s: string) => [...s.matchAll(/\{\{\s*(\w+)\s*\}\}/g)].map((m) => m[1]).sort().join(',');
export function csvEscape(v: string) {
return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
}
export function parseCsv(text: string): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let cell = '';
let quoted = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i]!;
if (quoted) {
if (ch === '"' && text[i + 1] === '"') {
cell += '"';
i++;
} else if (ch === '"') quoted = false;
else cell += ch;
} else if (ch === '"') quoted = true;
else if (ch === ',') {
row.push(cell);
cell = '';
} else if (ch === '\n' || ch === '\r') {
if (ch === '\r' && text[i + 1] === '\n') i++;
row.push(cell);
rows.push(row);
row = [];
cell = '';
} else cell += ch;
}
if (cell || row.length) {
row.push(cell);
rows.push(row);
}
return rows.filter((r) => r.some((c) => c !== ''));
}
+112
View File
@@ -0,0 +1,112 @@
import ru from '../locales/ru.json' with { type: 'json' };
import en from '../locales/en.json' with { type: 'json' };
import zh from '../locales/zh.json' with { type: 'json' };
import hy from '../locales/hy.json' with { type: 'json' };
import es from '../locales/es.json' with { type: 'json' };
export const LOCALES = ['ru', 'en', 'zh', 'hy', 'es'] as const;
export type Locale = (typeof LOCALES)[number];
export const SOURCE_LOCALE: Locale = 'ru';
/** Подписи языков в переключателе (как в макете) */
export const LOCALE_LABELS: Record<Locale, string> = { ru: 'RU', en: 'EN', zh: '中文', hy: 'ՀԱՅ', es: 'ES' };
/** Полное название языка на нём самом */
export const LOCALE_NAMES: Record<Locale, string> = { ru: 'Русский', en: 'English', zh: '中文', hy: 'Հայերեն', es: 'Español' };
/** BCP 47 для Intl */
export const INTL_LOCALE: Record<Locale, string> = { ru: 'ru-RU', en: 'en-GB', zh: 'zh-CN', hy: 'hy-AM', es: 'es-ES' };
type Tree = { [k: string]: string | Tree };
export type Messages = Record<string, string>;
/** {"a":{"b":"x"}} → {"a.b":"x"} */
export function flatten(tree: Tree, prefix = '', out: Messages = {}): Messages {
for (const [k, v] of Object.entries(tree)) {
const key = prefix ? `${prefix}.${k}` : k;
if (typeof v === 'string') out[key] = v;
else flatten(v, key, out);
}
return out;
}
export const resources: Record<Locale, Messages> = {
ru: flatten(ru as Tree),
en: flatten(en as Tree),
zh: flatten(zh as Tree),
hy: flatten(hy as Tree),
es: flatten(es as Tree),
};
export type TParams = Record<string, string | number | null | undefined>;
export type TFunction = (key: string, params?: TParams) => string;
const pluralRules = new Map<Locale, Intl.PluralRules>();
function plural(locale: Locale, n: number): string {
let r = pluralRules.get(locale);
if (!r) pluralRules.set(locale, (r = new Intl.PluralRules(INTL_LOCALE[locale])));
return r.select(n);
}
function interpolate(s: string, params?: TParams) {
if (!params) return s;
return s.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, k: string) => String(params[k] ?? ''));
}
/**
* Перевод с фолбэком на русский. Множественное число: ключи `<key>_one`, `_few`, `_many`, `_other`
* выбираются по params.count. Отсутствующий ключ возвращается как есть — его ловит `pnpm i18n:check`.
*/
const merged = new Map<Locale, Messages>();
function withFallback(locale: Locale): Messages {
let m = merged.get(locale);
if (!m) merged.set(locale, (m = { ...resources.ru, ...(resources[locale] ?? {}) }));
return m;
}
export function createT(locale: Locale): TFunction {
return tFromMessages(locale, withFallback(locale));
}
/**
* Подмножество переводов (с фолбэком на русский) для передачи в клиентский компонент:
* сервер отдаёт только нужные префиксы, а не все словари.
*/
export function pickMessages(locale: Locale, prefixes: string[]): Messages {
const out: Messages = {};
for (const [k, v] of Object.entries(withFallback(locale))) if (prefixes.some((p) => k.startsWith(p))) out[k] = v;
return out;
}
/** t() поверх готового набора сообщений — те же правила множественного числа и подстановок, что у createT */
export function tFromMessages(locale: Locale, messages: Messages): TFunction {
return (key, params) => {
let k = key;
if (typeof params?.count === 'number') {
const form = `${key}_${plural(locale, params.count)}`;
if (messages[form]) k = form;
else if (messages[`${key}_other`]) k = `${key}_other`;
}
const s = messages[k];
return s === undefined ? key : interpolate(s, params);
};
}
export function isLocale(v: unknown): v is Locale {
return typeof v === 'string' && (LOCALES as readonly string[]).includes(v);
}
/** Шрифты: Rubik/Onest не содержат армянский и китайский — для них фолбэк */
export const FONT_FALLBACK: Record<Locale, string> = {
ru: '',
en: '',
es: '',
hy: "'Noto Sans Armenian', ",
zh: "'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', ",
};
export function formatDateTime(locale: Locale, date: Date, opts: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'short' }) {
return new Intl.DateTimeFormat(INTL_LOCALE[locale], { timeZone: 'Asia/Yerevan', ...opts }).format(date);
}
export function formatAmd(locale: Locale, amount: number) {
return `${new Intl.NumberFormat(INTL_LOCALE[locale], { maximumFractionDigits: 0 }).format(amount)} ֏`;
}
+1
View File
@@ -0,0 +1 @@
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["node"] }, "include": ["src", "scripts"] }
+8 -2
View File
@@ -4,7 +4,10 @@
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
".": "./src/index.ts",
"./queue": "./src/queue.ts",
"./env": "./src/env.ts",
"./telegram": "./src/telegram.ts"
},
"scripts": {
"test": "vitest run",
@@ -12,7 +15,10 @@
},
"dependencies": {
"@barev/core": "workspace:*",
"@barev/db": "workspace:*"
"@barev/db": "workspace:*",
"bullmq": "^5.81.5",
"dotenv": "^17.0.0",
"@barev/i18n": "workspace:*"
},
"devDependencies": {
"dotenv": "^17.0.0",
+4 -1
View File
@@ -16,6 +16,7 @@ export interface ParsedService {
code: string;
category: CategoryCode;
categoryDiscountPct: number;
categoryEliteDiscountPct: number;
flow: Service['flow'];
providerRole: Service['providerRole'];
pricing: Pricing;
@@ -27,7 +28,7 @@ export interface ParsedService {
fromPrice: number;
}
type ServiceWithCategory = Service & { category: { code: CategoryCode; barevPlusDiscountPct: number } };
type ServiceWithCategory = Service & { category: { code: CategoryCode; barevPlusDiscountPct: number; eliteDiscountPct: number } };
export function parseService(s: ServiceWithCategory): ParsedService {
const pricing = pricingSchema.parse(s.pricing);
@@ -36,6 +37,7 @@ export function parseService(s: ServiceWithCategory): ParsedService {
code: s.code,
category: s.category.code,
categoryDiscountPct: s.category.barevPlusDiscountPct,
categoryEliteDiscountPct: s.category.eliteDiscountPct,
flow: s.flow,
providerRole: s.providerRole,
pricing,
@@ -57,6 +59,7 @@ export async function listCatalog(db: Db) {
return categories.map((c) => ({
code: c.code,
barevPlusDiscountPct: c.barevPlusDiscountPct,
eliteDiscountPct: c.eliteDiscountPct,
services: c.services.map((s) => {
const p = parseService(s);
return { code: p.code, flow: p.flow, fromPrice: p.fromPrice, active: p.active, isOnline: p.isOnline };
+71
View File
@@ -0,0 +1,71 @@
import type { ClientRequestKind, ClientRequestStatus, SubscriptionPlan } from '@barev/db';
import { DomainError, type Ctx } from './context.ts';
const DAY = 24 * 3600_000;
/**
* Заявка из приложения оператору: подписка (онлайн-оплаты пока нет — оператор договаривается и включает)
* или «перезвоните мне» по услуге, которую ещё нельзя заказать с выбором времени.
* Повторная заявка на ту же подписку или услугу, пока первая открыта, не создаётся.
*/
export async function createClientRequest(
ctx: Ctx,
clientId: string,
input: { kind: ClientRequestKind; plan?: SubscriptionPlan; serviceCode?: string; comment?: string },
) {
if (input.kind === 'SUBSCRIPTION' && !input.plan) throw new DomainError('INVALID_PARAMS');
if (input.kind === 'SERVICE') {
if (!input.serviceCode) throw new DomainError('INVALID_PARAMS');
const service = await ctx.db.service.findUnique({ where: { code: input.serviceCode } });
if (!service) throw new DomainError('NOT_FOUND');
}
const open = await ctx.db.clientRequest.findFirst({
where: { clientId, status: 'NEW', kind: input.kind, plan: input.plan ?? null, serviceCode: input.serviceCode ?? null },
});
if (open) return open;
const request = await ctx.db.clientRequest.create({
data: { clientId, kind: input.kind, plan: input.plan, serviceCode: input.serviceCode, comment: input.comment?.trim() || undefined },
});
await ctx.notify.emit({ type: 'clientRequest.created', requestId: request.id });
return request;
}
/** Открытые заявки клиента — приложение показывает «заявка отправлена» */
export function openClientRequests(ctx: Ctx, clientId: string) {
return ctx.db.clientRequest.findMany({ where: { clientId, status: 'NEW' }, orderBy: { createdAt: 'desc' } });
}
export function listClientRequests(ctx: Ctx, input: { status?: ClientRequestStatus }) {
return ctx.db.clientRequest.findMany({
where: input.status ? { status: input.status } : {},
include: { client: { include: { user: { select: { firstName: true, lastName: true, username: true, phone: true, locale: true } } } } },
orderBy: { createdAt: 'desc' },
take: 200,
});
}
export async function updateClientRequest(ctx: Ctx, input: { id: string; status?: ClientRequestStatus; adminNote?: string }) {
return ctx.db.clientRequest.update({
where: { id: input.id },
data: { status: input.status, adminNote: input.adminNote === undefined ? undefined : input.adminNote.trim() || null },
});
}
/**
* Включить подписку вручную на N дней. Тот же план продлевается с даты окончания;
* переход на другой план начинается сразу (Elite важнее Barev+, пока обе активны).
*/
export async function grantSubscription(ctx: Ctx, input: { clientId: string; plan: SubscriptionPlan; days: number; requestId?: string }) {
const now = ctx.now();
const current = await ctx.db.subscription.findFirst({
where: { clientId: input.clientId, plan: input.plan, status: 'ACTIVE', endsAt: { gt: now } },
orderBy: { endsAt: 'desc' },
});
const startsAt = current?.endsAt ?? now;
const sub = await ctx.db.subscription.create({
data: { clientId: input.clientId, plan: input.plan, source: 'MANUAL', startsAt, endsAt: new Date(startsAt.getTime() + input.days * DAY) },
});
if (input.requestId) await ctx.db.clientRequest.update({ where: { id: input.requestId }, data: { status: 'DONE' } });
await ctx.notify.emit({ type: 'subscription.activated', subscriptionId: sub.id });
return sub;
}
+87
View File
@@ -0,0 +1,87 @@
import { DomainError, type Ctx } from './context.ts';
/** С кем переписка: клиент (клиентский бот) или исполнитель (рабочий бот) */
export type Peer = { clientId: string; providerId?: undefined } | { providerId: string; clientId?: undefined };
const peerWhere = (p: Peer) => (p.clientId ? { clientId: p.clientId } : { providerId: p.providerId });
async function incoming(ctx: Ctx, peer: Peer, text: string) {
const body = text.trim().slice(0, 4000);
if (!body) return null;
const msg = await ctx.db.conciergeMessage.create({ data: { ...peerWhere(peer), author: peer.clientId ? 'CLIENT' : 'PROVIDER', text: body } });
await ctx.notify.emit({ type: 'concierge.message', messageId: msg.id });
return msg;
}
/** Сообщение клиента боту — в чат консьержа в админке и уведомление операторам */
export const addClientMessage = (ctx: Ctx, clientId: string, text: string) => incoming(ctx, { clientId }, text);
/** Сообщение исполнителя рабочему боту */
export const addProviderMessage = (ctx: Ctx, providerId: string, text: string) => incoming(ctx, { providerId }, text);
/** Список диалогов для оператора: последнее сообщение и сколько непрочитанных, свежие сверху */
export async function listConversations(ctx: Ctx) {
const [last, unread] = await Promise.all([
ctx.db.conciergeMessage.findMany({
distinct: ['clientId', 'providerId'],
orderBy: [{ clientId: 'asc' }, { providerId: 'asc' }, { createdAt: 'desc' }],
include: {
client: { include: { user: { select: { firstName: true, lastName: true, username: true, locale: true } } } },
provider: { include: { user: { select: { username: true, locale: true } } } },
},
}),
ctx.db.conciergeMessage.groupBy({ by: ['clientId', 'providerId'], where: { author: { not: 'OPERATOR' }, readAt: null }, _count: true }),
]);
const key = (m: { clientId: string | null; providerId: string | null }) => (m.clientId ? `c:${m.clientId}` : `p:${m.providerId}`);
const unreadBy = new Map(unread.map((u) => [key(u), u._count]));
return last
.map((m) => ({
key: key(m),
clientId: m.clientId,
providerId: m.providerId,
name: m.client ? `${m.client.user.firstName ?? ''} ${m.client.user.lastName ?? ''}`.trim() : (m.provider?.name ?? ''),
username: m.client?.user.username ?? m.provider?.user?.username ?? null,
locale: m.client?.user.locale ?? m.provider?.user?.locale ?? 'ru',
segment: m.client?.segment ?? null,
providerRole: m.provider?.role ?? null,
last: { text: m.text, author: m.author, createdAt: m.createdAt },
unread: unreadBy.get(key(m)) ?? 0,
}))
.sort((a, b) => b.last.createdAt.getTime() - a.last.createdAt.getTime());
}
/** Переписка; открыл — входящие прочитаны. Пустая — можно написать первым */
export async function conversation(ctx: Ctx, peer: Peer) {
await ctx.db.conciergeMessage.updateMany({ where: { ...peerWhere(peer), author: { not: 'OPERATOR' }, readAt: null }, data: { readAt: ctx.now() } });
return ctx.db.conciergeMessage.findMany({ where: peerWhere(peer), orderBy: { createdAt: 'asc' }, take: 500 });
}
/** Кто это — для заголовка нового диалога, который оператор начинает первым */
export async function peerInfo(ctx: Ctx, peer: Peer) {
if (peer.clientId) {
const c = await ctx.db.client.findUniqueOrThrow({ where: { id: peer.clientId }, include: { user: true } });
return { name: `${c.user.firstName ?? ''} ${c.user.lastName ?? ''}`.trim(), username: c.user.username, locale: c.user.locale, reachable: true };
}
const p = await ctx.db.provider.findUniqueOrThrow({ where: { id: peer.providerId! }, include: { user: true } });
return { name: p.name, username: p.user?.username ?? null, locale: p.user?.locale ?? 'ru', reachable: !!p.user };
}
export function unreadConciergeCount(ctx: Ctx) {
return ctx.db.conciergeMessage.count({ where: { author: { not: 'OPERATOR' }, readAt: null } });
}
/** Ответ оператора: сохраняем и отправляем в чат с ботом (воркер) */
export async function replyTo(ctx: Ctx, input: Peer & { adminUserId: string; text: string }) {
const peer: Peer = input.clientId ? { clientId: input.clientId } : { providerId: input.providerId! };
if (peer.providerId) {
const p = await ctx.db.provider.findUnique({ where: { id: peer.providerId } });
// исполнитель ещё не открыл рабочий бот по инвайту — написать ему некуда
if (!p?.userId) throw new DomainError('PROVIDER_NOT_LINKED');
}
const msg = await ctx.db.conciergeMessage.create({ data: { ...peerWhere(peer), author: 'OPERATOR', adminUserId: input.adminUserId, text: input.text.trim().slice(0, 4000) } });
// ответили — входящие этого диалога прочитаны
await ctx.db.conciergeMessage.updateMany({ where: { ...peerWhere(peer), author: { not: 'OPERATOR' }, readAt: null }, data: { readAt: ctx.now() } });
await ctx.notify.emit({ type: 'concierge.reply', messageId: msg.id });
return msg;
}
/** Совместимость: ответ клиенту */
export const replyToClient = (ctx: Ctx, input: { clientId: string; adminUserId: string; text: string }) => replyTo(ctx, input);
+23 -2
View File
@@ -14,7 +14,18 @@ export type DomainEvent =
| { type: 'offer.expired'; offerId: string }
| { type: 'company.order'; orderId: string; companyId: string }
| { type: 'order.reminder'; orderId: string; hours: 24 | 1 }
| { type: 'gift.activated'; clientId: string; endsAt: string };
| { type: 'gift.activated'; clientId: string; endsAt: string }
/** оператор сменил статус шага переезда — клиенту сообщение в бота */
| { type: 'lead.step'; stepId: string }
/** оператор ответил в чате консьержа — ответ уходит в чат с ботом */
| { type: 'concierge.reply'; messageId: string }
/** оператор включил подписку по заявке из приложения */
| { type: 'subscription.activated'; subscriptionId: string }
/** уведомления операторам в рабочий бот */
| { type: 'concierge.message'; messageId: string }
| { type: 'lead.submitted'; leadId: string }
| { type: 'clientRequest.created'; requestId: string }
| { type: 'webRequest.created'; requestId: string };
export interface Notifier {
emit(event: DomainEvent): Promise<void> | void;
@@ -59,4 +70,14 @@ export type DomainErrorCode =
| 'PROVIDER_BUSY'
| 'ALREADY_ASSIGNED'
| 'INVITE_INVALID'
| 'NOT_PAID_STATE';
| 'NOT_PAID_STATE'
| 'PHONE_INVALID'
| 'CONTACT_INVALID'
| 'NAME_REQUIRED'
| 'PROVIDER_NOT_LINKED'
| 'CARD_EXPIRED'
| 'OFFER_NOT_AVAILABLE'
| 'OFFER_LIMIT_REACHED';
/** Имя очереди BullMQ, через которую API и боты передают события воркеру */
export const EVENTS_QUEUE = 'barev-events';
+13
View File
@@ -0,0 +1,13 @@
import path from 'node:path';
import { config } from 'dotenv';
/** Загружает корневой .env для процессов монорепо (боты, воркер) */
export function loadRootEnv() {
config({ path: path.resolve(import.meta.dirname, '../../../.env'), quiet: true });
}
export function requireEnv(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`${name} is not set`);
return v;
}
+7
View File
@@ -8,3 +8,10 @@ export * from './assignment.ts';
export * from './providers.ts';
export * from './queries.ts';
export * from './reminders.ts';
export * from './payouts.ts';
export * from './webRequests.ts';
export * from './leads.ts';
export * from './relocation.ts';
export * from './clientRequests.ts';
export * from './concierge.ts';
export * from './partners.ts';
+129
View File
@@ -0,0 +1,129 @@
import { randomBytes } from 'node:crypto';
import type { Locale, Utm } from '@barev/core';
import type { LeadContactType, LeadStatus, Prisma } from '@barev/db';
import { DomainError, type Ctx } from './context.ts';
import { getSettings } from './settings.ts';
import { ensureLeadDocs, syncLeadSteps } from './relocation.ts';
import { normalizePhone } from './webRequests.ts';
/**
* Контакт из квиза: телефон или Telegram. «@anna_k», «t.me/anna_k» и «anna_k» — Telegram,
* строка из цифр (с «+», пробелами, скобками) — телефон.
*/
export function normalizeContact(raw: string): { contact: string; contactType: LeadContactType } | null {
const s = raw.trim();
const tg = s.match(/^(?:https?:\/\/)?(?:t\.me\/|telegram\.me\/)?@?([a-zA-Z][a-zA-Z0-9_]{4,31})$/);
if (tg) return { contact: `@${tg[1]}`, contactType: 'TELEGRAM' };
const phone = normalizePhone(s);
return phone ? { contact: phone, contactType: 'PHONE' } : null;
}
export const LEAD_MOVE_WHEN = ['month', 'quarter', 'later', 'date'] as const;
export const LEAD_PACKAGES = ['CONSULT', 'TURNKEY', 'CUSTOM'] as const;
/** Конструктор пакета: что человек отметил. Бюджет отеля — драмы за сутки. */
export interface LeadOptions {
airport?: 'none' | 'standard' | 'premium';
stay?: 'none' | 'flat' | 'hotel';
stayBudgetFrom?: number;
stayBudgetTo?: number;
flat?: boolean;
socialCard?: boolean;
bankCard?: boolean;
registration?: boolean;
residence?: boolean;
}
export interface CreateLeadInput {
name: string;
contact: string;
locale: Locale;
agency?: string;
utm?: Utm;
}
/** Шаг 1 квиза: имя и контакт сохраняются сразу — даже если дальше человек закроет квиз, оператор ему перезвонит */
export async function createLead(ctx: Ctx, input: CreateLeadInput) {
const name = input.name.trim();
if (name.length < 1) throw new DomainError('NAME_REQUIRED');
const contact = normalizeContact(input.contact);
if (!contact) throw new DomainError('CONTACT_INVALID');
const settings = await getSettings(ctx.db);
return ctx.db.lead.create({
data: {
key: randomBytes(12).toString('base64url'),
name,
...contact,
locale: input.locale,
agency: input.agency,
utm: input.utm && Object.keys(input.utm).length ? (input.utm as Prisma.InputJsonValue) : undefined,
consentAt: ctx.now(),
consentVersion: settings['consent.version'],
},
});
}
export interface UpdateLeadInput {
id: string;
key: string;
moved?: boolean;
moveWhen?: (typeof LEAD_MOVE_WHEN)[number];
moveDate?: Date;
package?: (typeof LEAD_PACKAGES)[number];
options?: LeadOptions;
submit?: boolean;
}
/** Следующие шаги квиза дописывают ответы по ключу из шага 1; после отправки заявку меняет только оператор */
export async function updateLead(ctx: Ctx, input: UpdateLeadInput) {
const lead = await ctx.db.lead.findUnique({ where: { id: input.id } });
if (!lead || lead.key !== input.key) throw new DomainError('NOT_FOUND');
if (lead.submittedAt) return lead;
const moveDate = input.moveWhen === undefined ? undefined : input.moveWhen === 'date' ? (input.moveDate ?? null) : null;
const updated = await ctx.db.lead.update({
where: { id: lead.id },
data: {
moved: input.moved,
moveWhen: input.moveWhen,
moveDate,
package: input.package,
options: input.options as Prisma.InputJsonValue | undefined,
// «уже живу» — квиз на этом заканчивается, заявка тоже считается отправленной
submittedAt: input.submit || input.moved === true ? ctx.now() : undefined,
},
});
// отправленная заявка на переезд — сразу с планом по шагам для оператора
if (updated.submittedAt && !updated.moved) await syncLeadSteps(ctx.db, updated);
if (updated.submittedAt) await ctx.notify.emit({ type: 'lead.submitted', leadId: updated.id });
return updated;
}
/** Для админки: свежие сверху */
export async function listLeads(ctx: Ctx, input: { status?: LeadStatus }) {
// заявки, отправленные до появления шаблонов, получают план и документы при первом показе
const bare = await ctx.db.lead.findMany({ where: { submittedAt: { not: null }, moved: { not: true }, package: { not: null }, docs: { none: {} } } });
for (const l of bare) {
await syncLeadSteps(ctx.db, l);
await ensureLeadDocs(ctx.db, l.id);
}
return ctx.db.lead.findMany({
where: input.status ? { status: input.status } : {},
omit: { key: true },
include: {
steps: { orderBy: { sortOrder: 'asc' } },
docs: { orderBy: { sortOrder: 'asc' } },
client: { select: { id: true, user: { select: { username: true, firstName: true } } } },
},
orderBy: { createdAt: 'desc' },
take: 200,
});
}
/** Оператор ведёт заявку: статус и заметка для себя */
export async function updateLeadByAdmin(ctx: Ctx, input: { id: string; status?: LeadStatus; adminNote?: string }) {
return ctx.db.lead.update({
where: { id: input.id },
data: { status: input.status, adminNote: input.adminNote === undefined ? undefined : input.adminNote.trim() || null },
omit: { key: true },
});
}
+2 -1
View File
@@ -31,7 +31,7 @@ export async function quoteForClient(ctx: Ctx, clientId: string, serviceCode: st
const params = validateParams(service.paramsSchema, rawParams);
if (!params.ok) throw new DomainError('INVALID_PARAMS', { errors: params.errors });
const membership = await getMembership(ctx.db, clientId, ctx.now());
const q = quote(service.pricing, params.values, discountFor(membership.level, service.categoryDiscountPct));
const q = quote(service.pricing, params.values, discountFor(membership.level, service.categoryDiscountPct, service.categoryEliteDiscountPct));
return { service, params: params.values, quote: q, membership };
}
@@ -105,6 +105,7 @@ export async function createOrder(ctx: Ctx, input: CreateOrderInput) {
note: input.note?.trim() || undefined,
locale: client.user.locale,
basePrice: q.basePrice,
priceBreakdown: q.breakdown as unknown as Prisma.InputJsonValue,
discountPct: q.discountPct,
discountAmount: q.discountAmount,
totalPrice: q.totalPrice,
+223
View File
@@ -0,0 +1,223 @@
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { fromYerevan, toYerevan, type MembershipLevel } from '@barev/core';
import type { OfferLimit, Prisma, SubscriptionPlan } from '@barev/db';
import { getMembership, upsertUser, type TelegramUserInput } from './clients.ts';
import { DomainError, type Ctx } from './context.ts';
/** QR клиента меняется каждую минуту; принимаем текущее окно и два предыдущих — запас на медленный скан */
const WINDOW_MS = 60_000;
const ACCEPT_WINDOWS = 2;
/** После проверки сотрудник может списать лимитное предложение в течение этого времени */
const REDEEM_MS = 15 * 60_000;
const INVITE_MS = 24 * 3600_000;
const secret = () => process.env.CARD_QR_SECRET || process.env.ADMIN_SESSION_SECRET || 'dev-card-secret';
const sign = (cardCode: string, w: number) => createHmac('sha256', secret()).update(`${cardCode}:${w}`).digest('hex').slice(0, 12);
/**
* Живой код клубной карты для QR: cd-<код карты>-<окно>-<подпись>.
* Помещается в payload Telegram (≤64 символа, [A-Za-z0-9_-]) — QR открывает рабочий бот обычной камерой.
*/
export function cardQrToken(cardCode: string, now: Date) {
const w = Math.floor(now.getTime() / WINDOW_MS);
return { token: `cd-${cardCode}-${w.toString(36)}-${sign(cardCode, w)}`, expiresAt: new Date((w + 1) * WINDOW_MS) };
}
export function verifyCardToken(token: string, now: Date): string | null {
const m = /^cd-([A-Z0-9]{4,16})-([a-z0-9]+)-([a-f0-9]{12})$/.exec(token.trim());
if (!m) return null;
const [, cardCode, w36, sig] = m;
const w = parseInt(w36!, 36);
const current = Math.floor(now.getTime() / WINDOW_MS);
if (w > current || current - w > ACCEPT_WINDOWS) return null;
const expected = Buffer.from(sign(cardCode!, w));
return timingSafeEqual(expected, Buffer.from(sig!)) ? cardCode! : null;
}
/** Достаём код из того, что вернул сканер: ссылка t.me/…?start=cd-… или сам код */
export function tokenFromScan(raw: string): string {
const m = /start=(cd-[A-Za-z0-9_-]+)/.exec(raw);
return m ? m[1]! : raw.trim();
}
const RANK: Record<MembershipLevel, number> = { BAREV: 0, BAREV_PLUS: 1, ELITE: 2 };
const planRank = (p: SubscriptionPlan | null) => (p === 'ELITE' ? 2 : p === 'BAREV_PLUS' ? 1 : 0);
/** Начало периода лимита по Еревану: ONCE — всё время, WEEK — с понедельника, MONTH — с 1-го числа */
export function limitPeriodStart(limit: OfferLimit, now: Date): Date | null {
if (limit === 'NONE') return null;
if (limit === 'ONCE') return new Date(0);
const p = toYerevan(now);
if (limit === 'MONTH') return fromYerevan(p.year, p.month, 1);
return new Date(fromYerevan(p.year, p.month, p.day).getTime() - p.weekday * 24 * 3600_000);
}
/** Партнёры, где пользователь — собственник или сотрудник */
export async function partnerMemberships(ctx: Ctx, telegramId: bigint) {
return ctx.db.partnerMember.findMany({ where: { user: { telegramId }, partner: { active: true } }, include: { partner: true } });
}
async function memberOf(ctx: Ctx, telegramId: bigint, partnerId?: string) {
const list = await partnerMemberships(ctx, telegramId);
const m = partnerId ? list.find((x) => x.partnerId === partnerId) : list[0];
if (!m) throw new DomainError('FORBIDDEN');
return m;
}
type Localized = Record<string, string>;
async function offersFor(ctx: Ctx, partnerId: string, clientId: string, level: MembershipLevel) {
const now = ctx.now();
const offers = await ctx.db.partnerOffer.findMany({ where: { partnerId, active: true }, orderBy: { sortOrder: 'asc' } });
return Promise.all(
offers.map(async (o) => {
const eligible = RANK[level] >= planRank(o.minLevel);
const since = limitPeriodStart(o.limit, now);
const used = since ? await ctx.db.cardUsage.count({ where: { clientId, offerId: o.id, createdAt: { gte: since } } }) : 0;
const quota = o.limit === 'ONCE' ? 1 : o.limitCount;
return {
id: o.id,
title: o.title as Localized,
minLevel: o.minLevel,
limit: o.limit,
limitCount: o.limitCount,
eligible,
/** сколько ещё можно списать в текущем периоде; null — без ограничений */
remaining: o.limit === 'NONE' ? null : Math.max(0, quota - used),
};
}),
);
}
/**
* Сотрудник партнёра отсканировал QR клиента: кто это, какой уровень, что положено.
* Каждая проверка записывается как визит; лимитные предложения списываются отдельно (redeemOffer).
*/
export async function checkCard(ctx: Ctx, input: { telegramId: bigint; token: string; partnerId?: string }) {
const member = await memberOf(ctx, input.telegramId, input.partnerId);
const cardCode = verifyCardToken(tokenFromScan(input.token), ctx.now());
if (!cardCode) throw new DomainError('CARD_EXPIRED');
const client = await ctx.db.client.findUnique({ where: { cardCode }, include: { user: true } });
if (!client?.consentAt) throw new DomainError('CARD_EXPIRED');
const membership = await getMembership(ctx.db, client.id, ctx.now());
const visit = await ctx.db.cardUsage.create({
data: { clientId: client.id, partnerId: member.partnerId, memberUserId: member.userId, level: membership.level },
});
return {
checkId: visit.id,
partner: { id: member.partnerId, name: member.partner.name },
client: { firstName: client.user.firstName ?? '', cardCode },
level: membership.level,
endsAt: membership.endsAt,
offers: await offersFor(ctx, member.partnerId, client.id, membership.level),
};
}
/** Списать лимитное предложение по недавней проверке */
export async function redeemOffer(ctx: Ctx, input: { telegramId: bigint; checkId: string; offerId: string }) {
const check = await ctx.db.cardUsage.findUnique({ where: { id: input.checkId } });
if (!check || check.offerId || !check.partnerId || ctx.now().getTime() - check.createdAt.getTime() > REDEEM_MS) throw new DomainError('CARD_EXPIRED');
const member = await memberOf(ctx, input.telegramId, check.partnerId);
const offers = await offersFor(ctx, member.partnerId, check.clientId, (check.level as MembershipLevel) ?? 'BAREV');
const offer = offers.find((o) => o.id === input.offerId);
if (!offer?.eligible) throw new DomainError('OFFER_NOT_AVAILABLE');
if (offer.remaining !== null && offer.remaining <= 0) throw new DomainError('OFFER_LIMIT_REACHED');
await ctx.db.cardUsage.create({ data: { clientId: check.clientId, partnerId: member.partnerId, offerId: offer.id, memberUserId: member.userId, level: check.level } });
return { ...offer, remaining: offer.remaining === null ? null : offer.remaining - 1 };
}
/** Кабинет партнёра: предложения, сотрудники (видит собственник), последние проверки */
export async function partnerCabinet(ctx: Ctx, telegramId: bigint, partnerId?: string) {
const member = await memberOf(ctx, telegramId, partnerId);
const isOwner = member.role === 'OWNER';
const since = new Date(ctx.now().getTime() - 30 * 24 * 3600_000);
const [offers, members, recent, visits30] = await Promise.all([
ctx.db.partnerOffer.findMany({ where: { partnerId: member.partnerId, active: true }, orderBy: { sortOrder: 'asc' } }),
isOwner ? ctx.db.partnerMember.findMany({ where: { partnerId: member.partnerId }, include: { user: true }, orderBy: { createdAt: 'asc' } }) : [],
ctx.db.cardUsage.findMany({
where: { partnerId: member.partnerId, ...(isOwner ? {} : { memberUserId: member.userId }) },
include: { client: { include: { user: { select: { firstName: true } } } }, offer: true },
orderBy: { createdAt: 'desc' },
take: 30,
}),
ctx.db.cardUsage.count({ where: { partnerId: member.partnerId, offerId: null, createdAt: { gte: since } } }),
]);
return {
partner: { id: member.partnerId, name: member.partner.name, address: member.partner.address },
role: member.role,
offers: offers.map((o) => ({ id: o.id, title: o.title as Localized, minLevel: o.minLevel, limit: o.limit, limitCount: o.limitCount })),
members: members.map((m) => ({ id: m.id, role: m.role, name: `${m.user.firstName ?? ''} ${m.user.lastName ?? ''}`.trim(), username: m.user.username, isMe: m.userId === member.userId })),
recent: recent.map((u) => ({ id: u.id, at: u.createdAt, client: u.client.user.firstName ?? '', level: u.level, offer: (u.offer?.title as Localized | undefined) ?? null })),
visits30,
};
}
const proBotUrl = (payload: string) => `https://t.me/${process.env.PRO_BOT_USERNAME ?? 'barev_pro_bot'}?start=${payload}`;
/** Собственник приглашает сотрудника: ссылка-QR на сутки, одноразовая */
export async function createStaffInvite(ctx: Ctx, telegramId: bigint, partnerId?: string) {
const member = await memberOf(ctx, telegramId, partnerId);
if (member.role !== 'OWNER') throw new DomainError('FORBIDDEN');
const code = randomBytes(9).toString('base64url').replace(/[-_]/g, 'x');
const invite = await ctx.db.partnerInvite.create({ data: { code, partnerId: member.partnerId, expiresAt: new Date(ctx.now().getTime() + INVITE_MS) } });
return { url: proBotUrl(`pi-${code}`), expiresAt: invite.expiresAt };
}
export async function removePartnerMember(ctx: Ctx, telegramId: bigint, memberId: string) {
const target = await ctx.db.partnerMember.findUniqueOrThrow({ where: { id: memberId } });
const me = await memberOf(ctx, telegramId, target.partnerId);
if (me.role !== 'OWNER' || target.role === 'OWNER') throw new DomainError('FORBIDDEN');
await ctx.db.partnerMember.delete({ where: { id: memberId } });
}
/** Ссылка для собственника — выдаёт админка; одноразовая */
export async function createOwnerInvite(ctx: Ctx, partnerId: string) {
const code = randomBytes(9).toString('base64url').replace(/[-_]/g, 'x');
await ctx.db.partner.update({ where: { id: partnerId }, data: { ownerInviteCode: code } });
return { url: proBotUrl(`po-${code}`) };
}
/** /start po-… (собственник) или pi-… (сотрудник) в рабочем боте */
export async function acceptPartnerInvite(ctx: Ctx, tg: TelegramUserInput, payload: string) {
const user = await upsertUser(ctx.db, tg);
if (payload.startsWith('po-')) {
const partner = await ctx.db.partner.findUnique({ where: { ownerInviteCode: payload.slice(3) } });
if (!partner) throw new DomainError('INVITE_INVALID');
await ctx.db.$transaction([
ctx.db.partnerMember.upsert({ where: { partnerId_userId: { partnerId: partner.id, userId: user.id } }, create: { partnerId: partner.id, userId: user.id, role: 'OWNER' }, update: { role: 'OWNER' } }),
ctx.db.partner.update({ where: { id: partner.id }, data: { ownerInviteCode: null } }),
]);
return { role: 'OWNER' as const, partnerName: partner.name };
}
const invite = await ctx.db.partnerInvite.findUnique({ where: { code: payload.slice(3) }, include: { partner: true } });
if (!invite || invite.usedAt || invite.expiresAt < ctx.now()) throw new DomainError('INVITE_INVALID');
await ctx.db.$transaction([
ctx.db.partnerMember.upsert({ where: { partnerId_userId: { partnerId: invite.partnerId, userId: user.id } }, create: { partnerId: invite.partnerId, userId: user.id, role: 'STAFF' }, update: {} }),
ctx.db.partnerInvite.update({ where: { id: invite.id }, data: { usedAt: ctx.now() } }),
]);
return { role: 'STAFF' as const, partnerName: invite.partner.name };
}
/** Каталог партнёров для клиента: предложения с отметкой «доступно на твоём уровне» и остатком по лимиту */
export async function clientPartners(ctx: Ctx, clientId: string) {
const membership = await getMembership(ctx.db, clientId, ctx.now());
const partners = await ctx.db.partner.findMany({ where: { active: true }, orderBy: [{ category: 'asc' }, { name: 'asc' }] });
return Promise.all(
partners.map(async (p) => ({
id: p.id,
name: p.name,
category: p.category,
address: p.address,
offer: p.offer as Localized,
offers: await offersFor(ctx, p.id, clientId, membership.level),
})),
);
}
export type PartnerOfferInput = { id?: string; partnerId: string; title: Localized; minLevel: SubscriptionPlan | null; limit: OfferLimit; limitCount: number; active: boolean };
export async function savePartnerOffer(ctx: Ctx, input: PartnerOfferInput) {
const data = { title: input.title as Prisma.InputJsonValue, minLevel: input.minLevel, limit: input.limit, limitCount: Math.max(1, input.limitCount), active: input.active };
if (input.id) return ctx.db.partnerOffer.update({ where: { id: input.id }, data });
const last = await ctx.db.partnerOffer.findFirst({ where: { partnerId: input.partnerId }, orderBy: { sortOrder: 'desc' } });
return ctx.db.partnerOffer.create({ data: { ...data, partnerId: input.partnerId, sortOrder: (last?.sortOrder ?? -1) + 1 } });
}
+65
View File
@@ -0,0 +1,65 @@
import {
defaultParams,
paramsSchemaSchema,
payoutByRules,
payoutFor,
payoutRulesSchema,
pricingSchema,
quote,
type ParamValues,
type PriceBreakdown,
type RateType,
} from '@barev/core';
type Rate = { type: RateType; value: number };
export interface PayoutProvider {
rateType: RateType;
rateValue: number;
company: { rateType: RateType; rateValue: number } | null;
payoutPreset: { rules: unknown } | null;
}
export interface PayoutOrder {
totalPrice: number;
basePrice: number;
params: unknown;
priceBreakdown: unknown;
service: { pricing: unknown; paramsSchema: unknown };
}
/** Разбивка цены заказа: сохранённая при создании или пересчитанная по параметрам (старые заказы) */
export function breakdownOf(o: PayoutOrder): PriceBreakdown | null {
if (o.priceBreakdown && typeof o.priceBreakdown === 'object') return o.priceBreakdown as PriceBreakdown;
try {
const schema = paramsSchemaSchema.parse(o.service.paramsSchema);
const params = { ...defaultParams(schema), ...(o.params as ParamValues) };
return quote(pricingSchema.parse(o.service.pricing), params).breakdown;
} catch {
return null;
}
}
/**
* PRO-8: выплата исполнителю за заказ.
* - сотрудник компании — договорная ставка компании (от итоговой цены);
* - исполнитель с пресетом (джун / мидл / прайват) — фикс или % по каждой строке цены;
* - иначе — индивидуальная ставка (фикс за заказ или % от итога).
*/
export function orderPayout(o: PayoutOrder, p: PayoutProvider): number {
if (p.company) return payoutFor(o.totalPrice, { type: p.company.rateType, value: p.company.rateValue });
if (p.payoutPreset) {
const rules = payoutRulesSchema.safeParse(p.payoutPreset.rules);
const b = breakdownOf(o);
if (rules.success && b) return payoutByRules(b, rules.data).total;
}
const rate: Rate = { type: p.rateType, value: p.rateValue };
return payoutFor(o.totalPrice, rate);
}
/** Описание ставки для экрана «История» исполнителя */
export function rateLabel(p: PayoutProvider & { payoutPreset: { name: string; rules: unknown } | null }) {
if (p.company) return { kind: 'company' as const, type: p.company.rateType, value: p.company.rateValue };
if (p.payoutPreset) return { kind: 'preset' as const, name: p.payoutPreset.name };
return { kind: 'own' as const, type: p.rateType, value: p.rateValue };
}
+11 -14
View File
@@ -1,4 +1,5 @@
import { averageRating, payoutFor, providerActions, startOfYerevanDay, getFlow, type ServiceFlow } from '@barev/core';
import { averageRating, providerActions, startOfYerevanDay, getFlow, type ServiceFlow } from '@barev/core';
import { orderPayout, rateLabel } from './payouts.ts';
import type { Db } from '@barev/db';
import { DomainError, type Ctx } from './context.ts';
import { upsertUser, type TelegramUserInput } from './clients.ts';
@@ -33,6 +34,7 @@ export async function proIdentity(db: Db, telegramId: bigint) {
include: {
provider: { include: { company: true } },
dispatcher: { include: { company: true } },
partnerMembers: { include: { partner: true } },
},
});
if (!user) return null;
@@ -41,6 +43,8 @@ export async function proIdentity(db: Db, telegramId: bigint) {
user,
provider,
companies: user.dispatcher.filter((d) => d.company.active).map((d) => d.company),
/** собственник или сотрудник партнёра клуба — проверяет QR клиентов */
partners: user.partnerMembers.filter((m) => m.partner.active).map((m) => ({ id: m.partnerId, name: m.partner.name, role: m.role })),
};
}
@@ -48,14 +52,9 @@ export async function setShift(db: Db, providerId: string, onShift: boolean) {
return db.provider.update({ where: { id: providerId }, data: { onShift } });
}
/** Ставка, по которой считается заработок: у сотрудника компании — ставка компании */
function rateOf(p: { rateType: 'FIXED' | 'PERCENT'; rateValue: number; company: { rateType: 'FIXED' | 'PERCENT'; rateValue: number } | null }) {
return p.company ? { type: p.company.rateType, value: p.company.rateValue } : { type: p.rateType, value: p.rateValue };
}
/** PRO-1/PRO-2: шапка и заказы на сегодня */
export async function proToday(ctx: Ctx, providerId: string) {
const provider = await ctx.db.provider.findUniqueOrThrow({ where: { id: providerId }, include: { company: true } });
const provider = await ctx.db.provider.findUniqueOrThrow({ where: { id: providerId }, include: { company: true, payoutPreset: true } });
const dayStart = startOfYerevanDay(ctx.now());
const dayEnd = new Date(dayStart.getTime() + DAY_MS);
const [orders, offers] = await Promise.all([
@@ -76,7 +75,6 @@ export async function proToday(ctx: Ctx, providerId: string) {
orderBy: { scheduledAt: 'asc' },
take: 20,
});
const rate = rateOf(provider);
const doneToday = orders.filter((o) => getFlow(o.service.flow as ServiceFlow).done.includes(o.status));
const current =
orders.find((o) => !getFlow(o.service.flow as ServiceFlow).done.includes(o.status) && o.status !== getFlow(o.service.flow as ServiceFlow).assigned) ??
@@ -91,7 +89,7 @@ export async function proToday(ctx: Ctx, providerId: string) {
rating: averageRating(provider.ratingSum, provider.ratingCount),
companyName: provider.company?.name ?? null,
},
earningsToday: doneToday.reduce((s, o) => s + payoutFor(o.totalPrice, rate), 0),
earningsToday: doneToday.reduce((s, o) => s + orderPayout(o, provider), 0),
currentOrderId: current?.id ?? null,
orders: orders.map((o) => ({ ...proOrderView(o), actions: providerActions(o.service.flow as ServiceFlow, o.status) })),
upcoming: upcoming.map(proOrderView),
@@ -153,19 +151,18 @@ export async function proOrder(ctx: Ctx, providerId: string, orderId: string) {
/** PRO-6: история и заработок за период */
export async function proHistory(ctx: Ctx, providerId: string, from: Date, to: Date) {
const provider = await ctx.db.provider.findUniqueOrThrow({ where: { id: providerId }, include: { company: true } });
const provider = await ctx.db.provider.findUniqueOrThrow({ where: { id: providerId }, include: { company: true, payoutPreset: true } });
const orders = await ctx.db.order.findMany({
where: { providerId, scheduledAt: { gte: from, lt: to } },
include: { service: true, district: true, client: { include: { user: true } } },
orderBy: { scheduledAt: 'desc' },
});
const rate = rateOf(provider);
const done = orders.filter((o) => getFlow(o.service.flow as ServiceFlow).done.includes(o.status));
return {
orders: orders.map((o) => ({ ...proOrderView(o), payout: getFlow(o.service.flow as ServiceFlow).done.includes(o.status) ? payoutFor(o.totalPrice, rate) : 0 })),
totalEarned: done.reduce((s, o) => s + payoutFor(o.totalPrice, rate), 0),
orders: orders.map((o) => ({ ...proOrderView(o), payout: getFlow(o.service.flow as ServiceFlow).done.includes(o.status) ? orderPayout(o, provider) : 0 })),
totalEarned: done.reduce((s, o) => s + orderPayout(o, provider), 0),
doneCount: done.length,
rate,
rate: rateLabel(provider),
};
}
+21
View File
@@ -0,0 +1,21 @@
import { Queue } from 'bullmq';
import { EVENTS_QUEUE, type DomainEvent, type Notifier } from './context.ts';
export function redisConnection(url: string) {
const u = new URL(url);
return { host: u.hostname, port: Number(u.port || 6379), password: u.password || undefined, maxRetriesPerRequest: null };
}
/** Notifier, который кладёт доменные события в очередь воркера */
export function createQueueNotifier(redisUrl: string): Notifier & { queue: Queue<DomainEvent> } {
const queue = new Queue<DomainEvent>(EVENTS_QUEUE, {
connection: redisConnection(redisUrl),
defaultJobOptions: { attempts: 5, backoff: { type: 'exponential', delay: 2000 }, removeOnComplete: 1000, removeOnFail: 5000 },
});
return {
queue,
async emit(event) {
await queue.add(event.type, event);
},
};
}
+209
View File
@@ -0,0 +1,209 @@
import { randomBytes } from 'node:crypto';
import type { ClientSegment, Db, LeadStepStatus, Prisma, RelocationItemKind, Tx } from '@barev/db';
import { DomainError, type Ctx } from './context.ts';
import type { LeadOptions, UpdateLeadInput } from './leads.ts';
import { getSettings } from './settings.ts';
/** Ответы конструктора квиза, которые превращаются в шаги с тем же кодом */
const OPTION_STEPS = ['airport', 'stay', 'flat', 'registration', 'socialCard', 'bankCard', 'residence'] as const;
type Localized = Record<string, string>;
export interface RelocationItemInput {
id?: string;
kind: RelocationItemKind;
name: Localized;
hint?: Localized | null;
active: boolean;
inTurnkey?: boolean;
}
/** Какие шаги нужны по пакету: консультация — один шаг, «под ключ» — шаблоны с пометкой, конструктор — отмеченные */
export function stepCodesFor(pkg: string | null, options: LeadOptions | null, templates: { code: string; inTurnkey: boolean }[]): string[] {
const codes = new Set(templates.map((t) => t.code));
if (pkg === 'CONSULT') return codes.has('consult') ? ['consult'] : [];
if (pkg === 'TURNKEY') return templates.filter((t) => t.inTurnkey && t.code !== 'consult').map((t) => t.code);
if (pkg !== 'CUSTOM' || !options) return [];
return OPTION_STEPS.filter((c) => {
if (!codes.has(c)) return false;
if (c === 'airport') return !!options.airport && options.airport !== 'none';
if (c === 'stay') return !!options.stay && options.stay !== 'none';
return !!options[c];
});
}
const templatesOf = (db: Db | Tx, kind: RelocationItemKind) => db.relocationItem.findMany({ where: { kind, active: true }, orderBy: { sortOrder: 'asc' } });
/** Создаёт недостающие шаги плана по шаблонам; уже начатые оператором не трогает */
export async function syncLeadSteps(db: Db | Tx, lead: { id: string; package: string | null; options: Prisma.JsonValue }) {
const templates = await templatesOf(db, 'STEP');
const codes = stepCodesFor(lead.package, lead.options as LeadOptions | null, templates);
if (!codes.length) return;
const order = new Map(templates.map((t) => [t.code, t.sortOrder]));
await db.leadStep.createMany({ data: codes.map((code) => ({ leadId: lead.id, code, sortOrder: order.get(code) ?? 0 })), skipDuplicates: true });
}
/** Чек-лист документов клиента — копия общего списка при первом обращении, дальше живёт своей жизнью */
export async function ensureLeadDocs(db: Db | Tx, leadId: string) {
if (await db.leadDoc.count({ where: { leadId } })) return;
const templates = await templatesOf(db, 'DOC');
await db.leadDoc.createMany({ data: templates.map((t) => ({ leadId, code: t.code, sortOrder: t.sortOrder })), skipDuplicates: true });
}
export async function setSegment(ctx: Ctx, clientId: string, segment: ClientSegment) {
return ctx.db.client.update({ where: { id: clientId }, data: { segment } });
}
const leadInclude = { steps: { orderBy: { sortOrder: 'asc' } }, docs: { orderBy: { sortOrder: 'asc' } } } satisfies Prisma.LeadInclude;
/** Названия шагов и документов по коду (для своих шагов клиента — title в самой записи) */
export async function relocationNames(db: Db | Tx) {
const items = await db.relocationItem.findMany();
return Object.fromEntries(items.map((i) => [`${i.kind}:${i.code}`, { name: i.name as Localized, hint: (i.hint as Localized | null) ?? null }]));
}
/**
* План переезда клиента: последняя заявка из приложения или с сайта.
* Заявку с сайта узнаём по нику Telegram из квиза — при первом открытии приложения она привязывается к клиенту.
*/
export async function clientRelocation(ctx: Ctx, clientId: string) {
let lead = await ctx.db.lead.findFirst({ where: { clientId }, orderBy: { createdAt: 'desc' } });
if (!lead) {
const client = await ctx.db.client.findUniqueOrThrow({ where: { id: clientId }, include: { user: true } });
const username = client.user.username;
if (!username) return null;
const site = await ctx.db.lead.findFirst({
where: { clientId: null, contactType: 'TELEGRAM', contact: { equals: `@${username}`, mode: 'insensitive' }, moved: { not: true } },
orderBy: { createdAt: 'desc' },
});
if (!site) return null;
lead = await ctx.db.lead.update({ where: { id: site.id }, data: { clientId } });
await syncLeadSteps(ctx.db, lead);
}
await ensureLeadDocs(ctx.db, lead.id);
return ctx.db.lead.findUniqueOrThrow({ where: { id: lead.id }, include: leadInclude });
}
export interface AppLeadInput {
moveWhen: NonNullable<UpdateLeadInput['moveWhen']>;
moveDate?: Date;
package: NonNullable<UpdateLeadInput['package']>;
options?: LeadOptions;
comment?: string;
}
/** Заявка на переезд из приложения: контакт — Telegram клиента, согласие уже дано в боте */
export async function createAppLead(ctx: Ctx, clientId: string, input: AppLeadInput) {
const client = await ctx.db.client.findUniqueOrThrow({ where: { id: clientId }, include: { user: true } });
if (!client.consentAt) throw new DomainError('CONSENT_REQUIRED');
if (input.package === 'CUSTOM' && stepCodesFor('CUSTOM', input.options ?? null, await templatesOf(ctx.db, 'STEP')).length === 0) throw new DomainError('INVALID_PARAMS');
const settings = await getSettings(ctx.db);
const lead = await ctx.db.lead.create({
data: {
key: randomBytes(12).toString('base64url'),
name: client.user.firstName ?? 'Telegram',
contact: client.user.username ? `@${client.user.username}` : (client.user.phone ?? `tg:${client.user.telegramId}`),
contactType: client.user.username || !client.user.phone ? 'TELEGRAM' : 'PHONE',
locale: client.user.locale,
moved: false,
moveWhen: input.moveWhen,
moveDate: input.moveWhen === 'date' ? (input.moveDate ?? null) : null,
package: input.package,
options: input.package === 'CUSTOM' ? (input.options as Prisma.InputJsonValue) : undefined,
adminNote: input.comment?.trim() || undefined,
submittedAt: ctx.now(),
consentAt: client.consentAt,
consentVersion: client.consentVersion ?? settings['consent.version'],
clientId,
},
});
await syncLeadSteps(ctx.db, lead);
await ensureLeadDocs(ctx.db, lead.id);
await ctx.notify.emit({ type: 'lead.submitted', leadId: lead.id });
return ctx.db.lead.findUniqueOrThrow({ where: { id: lead.id }, include: leadInclude });
}
/** Клиент отмечает документ из своего чек-листа */
export async function toggleRelocationDoc(ctx: Ctx, clientId: string, docId: string, checked: boolean) {
const doc = await ctx.db.leadDoc.findUnique({ where: { id: docId }, include: { lead: true } });
if (!doc || doc.lead.clientId !== clientId) throw new DomainError('NOT_FOUND');
return ctx.db.leadDoc.update({ where: { id: doc.id }, data: { checked } });
}
/** Оператор двигает шаг плана; при смене статуса или комментария клиенту уходит сообщение в бота */
export async function updateLeadStep(ctx: Ctx, input: { stepId: string; status?: LeadStepStatus; note?: string; title?: string }) {
const step = await ctx.db.leadStep.findUniqueOrThrow({ where: { id: input.stepId }, include: { lead: true } });
const updated = await ctx.db.leadStep.update({
where: { id: step.id },
data: {
status: input.status,
note: input.note === undefined ? undefined : input.note.trim() || null,
title: input.title === undefined ? undefined : input.title.trim() || null,
},
});
if (step.lead.clientId && ((input.status && input.status !== step.status) || (input.note !== undefined && updated.note !== step.note))) {
await ctx.notify.emit({ type: 'lead.step', stepId: step.id });
}
// оператор начал работу по шагам — заявка «в работе»
if (input.status && step.lead.status === 'NEW') await ctx.db.lead.update({ where: { id: step.leadId }, data: { status: 'IN_WORK' } });
return updated;
}
const customCode = () => `custom-${randomBytes(4).toString('hex')}`;
/** Добавить клиенту шаг: из общего списка (code) или свой (title) — в конец плана */
export async function addLeadStep(ctx: Ctx, input: { leadId: string; code?: string; title?: string }) {
const last = await ctx.db.leadStep.findFirst({ where: { leadId: input.leadId }, orderBy: { sortOrder: 'desc' } });
const title = input.title?.trim();
if (!input.code && !title) throw new DomainError('INVALID_PARAMS');
const exists = input.code ? await ctx.db.leadStep.findUnique({ where: { leadId_code: { leadId: input.leadId, code: input.code } } }) : null;
if (exists) return exists;
return ctx.db.leadStep.create({ data: { leadId: input.leadId, code: input.code ?? customCode(), title: input.code ? undefined : title, sortOrder: (last?.sortOrder ?? 0) + 1 } });
}
export const removeLeadStep = (ctx: Ctx, stepId: string) => ctx.db.leadStep.delete({ where: { id: stepId } });
/** Документы клиента: добавить из общего списка или свой, переименовать, отметить, удалить */
export async function addLeadDoc(ctx: Ctx, input: { leadId: string; code?: string; title?: string }) {
await ensureLeadDocs(ctx.db, input.leadId);
const last = await ctx.db.leadDoc.findFirst({ where: { leadId: input.leadId }, orderBy: { sortOrder: 'desc' } });
const title = input.title?.trim();
if (!input.code && !title) throw new DomainError('INVALID_PARAMS');
const exists = input.code ? await ctx.db.leadDoc.findUnique({ where: { leadId_code: { leadId: input.leadId, code: input.code } } }) : null;
if (exists) return exists;
return ctx.db.leadDoc.create({ data: { leadId: input.leadId, code: input.code ?? customCode(), title: input.code ? undefined : title, sortOrder: (last?.sortOrder ?? 0) + 1 } });
}
export function updateLeadDoc(ctx: Ctx, input: { docId: string; title?: string; checked?: boolean }) {
return ctx.db.leadDoc.update({
where: { id: input.docId },
data: { checked: input.checked, title: input.title === undefined ? undefined : input.title.trim() || null },
});
}
export const removeLeadDoc = (ctx: Ctx, docId: string) => ctx.db.leadDoc.delete({ where: { id: docId } });
/** Общий список шагов и документов для админки */
export const listRelocationItems = (ctx: Ctx) => ctx.db.relocationItem.findMany({ orderBy: [{ kind: 'asc' }, { sortOrder: 'asc' }] });
/** Сохранить пункт общего списка. Новым клиентам он попадёт в план; у текущих — добавляется вручную */
export async function saveRelocationItem(ctx: Ctx, input: RelocationItemInput) {
if (!input.name.ru?.trim()) throw new DomainError('INVALID_PARAMS');
const data = { name: input.name, hint: input.hint ?? undefined, active: input.active, inTurnkey: input.inTurnkey ?? input.kind === 'STEP' };
if (input.id) return ctx.db.relocationItem.update({ where: { id: input.id }, data });
const last = await ctx.db.relocationItem.findFirst({ where: { kind: input.kind }, orderBy: { sortOrder: 'desc' } });
return ctx.db.relocationItem.create({ data: { ...data, kind: input.kind, code: `c-${randomBytes(4).toString('hex')}`, sortOrder: (last?.sortOrder ?? -1) + 1 } });
}
/** Поднять / опустить пункт в общем списке */
export async function moveRelocationItem(ctx: Ctx, input: { id: string; dir: -1 | 1 }) {
const item = await ctx.db.relocationItem.findUniqueOrThrow({ where: { id: input.id } });
const neighbour = await ctx.db.relocationItem.findFirst({
where: { kind: item.kind, sortOrder: input.dir < 0 ? { lt: item.sortOrder } : { gt: item.sortOrder } },
orderBy: { sortOrder: input.dir < 0 ? 'desc' : 'asc' },
});
if (!neighbour) return;
await ctx.db.$transaction([
ctx.db.relocationItem.update({ where: { id: item.id }, data: { sortOrder: neighbour.sortOrder } }),
ctx.db.relocationItem.update({ where: { id: neighbour.id }, data: { sortOrder: item.sortOrder } }),
]);
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Отправка сообщений через Telegram Bot API без зависимостей (воркер).
* Без токена (локальная разработка) сообщение печатается в консоль.
*/
export type InlineButton = { text: string; callback_data: string } | { text: string; web_app: { url: string } } | { text: string; url: string };
export interface OutgoingMessage {
chatId: bigint | number | string;
text: string;
buttons?: InlineButton[][];
}
/** Telegram принимает web_app-кнопки только с https — в разработке такие кнопки опускаются */
export function webAppButton(text: string, url: string): InlineButton | null {
return url.startsWith('https://') ? { text, web_app: { url } } : null;
}
export function compactRows(rows: (InlineButton | null)[][]): InlineButton[][] {
return rows.map((r) => r.filter((b): b is InlineButton => !!b)).filter((r) => r.length > 0);
}
export class TelegramSender {
constructor(
private token: string,
private label: string,
) {}
async send(msg: OutgoingMessage): Promise<void> {
if (!this.token) {
const buttons = msg.buttons?.flat().map((b) => `[${b.text}]`).join(' ') ?? '';
console.log(`[${this.label} → ${msg.chatId}] ${msg.text.replace(/<[^>]+>/g, '')} ${buttons}`);
return;
}
const res = await fetch(`https://api.telegram.org/bot${this.token}/sendMessage`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
chat_id: String(msg.chatId),
text: msg.text,
parse_mode: 'HTML',
link_preview_options: { is_disabled: true },
reply_markup: msg.buttons?.length ? { inline_keyboard: msg.buttons } : undefined,
}),
});
if (!res.ok) {
const body = await res.text();
// 403 — пользователь заблокировал бота, 400 chat not found — бот ему ещё не писал
// (например, тестовые пользователи seed); повторять бессмысленно
if (res.status === 403 || (res.status === 400 && body.includes('chat not found'))) {
console.warn(`[${this.label}] chat ${msg.chatId} unreachable: ${res.status}`);
return;
}
throw new Error(`Telegram ${res.status}: ${body}`);
}
}
}
export const escapeHtml = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+162
View File
@@ -0,0 +1,162 @@
import { randomBytes } from 'node:crypto';
import { quote, validateParams, windowStarts, yerevanDateKey, type Locale, type Utm } from '@barev/core';
import type { Prisma, WebRequestStatus } from '@barev/db';
import { getServiceByCode } from './catalog.ts';
import { giveConsent } from './clients.ts';
import { DomainError, type Ctx } from './context.ts';
import { createOrder } from './orders.ts';
import { getSettings } from './settings.ts';
/** Телефон: цифры с необязательным «+», 8–15 цифр (E.164). Пробелы, скобки и дефисы убираем. */
export function normalizePhone(raw: string): string | null {
const s = raw.trim().replace(/[\s()\-.]/g, '');
if (!/^\+?\d{8,15}$/.test(s)) return null;
return s.startsWith('+') ? s : `+${s}`;
}
export interface CreateWebRequestInput {
serviceCode: string;
params: Record<string, unknown>;
preferredAt: Date;
districtCode: string;
addressText: string;
note?: string;
phone: string;
name?: string;
locale: Locale;
agency?: string;
utm?: Utm;
}
/**
* Заявка с сайта: цена и длительность считаются на сервере, время — в рабочем окне услуги.
* Вместе с заявкой создаётся StartToken: по ссылке t.me/<bot>?start=s-<token> бот узнаёт заявку,
* а startClient — язык, агентство и UTM.
*/
export async function createWebRequest(ctx: Ctx, input: CreateWebRequestInput) {
const service = await getServiceByCode(ctx.db, input.serviceCode, { activeOnly: true });
if (service.isOnline) throw new DomainError('SERVICE_UNAVAILABLE');
const params = validateParams(service.paramsSchema, input.params);
if (!params.ok) throw new DomainError('INVALID_PARAMS', { errors: params.errors });
const q = quote(service.pricing, params.values);
const phone = normalizePhone(input.phone);
if (!phone) throw new DomainError('PHONE_INVALID');
const addressText = input.addressText.trim();
const district = await ctx.db.district.findUnique({ where: { code: input.districtCode } });
if (!district || addressText.length < 3) throw new DomainError('ADDRESS_REQUIRED');
// длинная уборка может не поместиться в один день — тогда сверяем только начало, длительность согласует оператор
const window = service.slotConfig.dayEndMinute - service.slotConfig.dayStartMinute;
const starts = windowStarts({
dateKey: yerevanDateKey(input.preferredAt),
config: service.slotConfig,
durationMinutes: Math.min(q.durationMinutes, window),
now: ctx.now(),
});
if (!starts.some((s) => s.getTime() === input.preferredAt.getTime())) throw new DomainError('SLOT_UNAVAILABLE');
const settings = await getSettings(ctx.db);
const token = randomBytes(6).toString('base64url').replace(/[-_]/g, 'x');
const [, request] = await ctx.db.$transaction([
ctx.db.startToken.create({ data: { id: token, locale: input.locale, agency: input.agency, utm: input.utm } }),
ctx.db.webRequest.create({
data: {
token,
serviceId: service.id,
params: params.values as Prisma.InputJsonValue,
preferredAt: input.preferredAt,
durationMinutes: q.durationMinutes,
districtId: district.id,
addressText,
note: input.note?.trim() || undefined,
phone,
name: input.name?.trim() || undefined,
locale: input.locale,
totalPrice: q.totalPrice,
consentAt: ctx.now(),
consentVersion: settings['consent.version'],
},
}),
]);
await ctx.notify.emit({ type: 'webRequest.created', requestId: request.id });
return request;
}
type Gift = Awaited<ReturnType<typeof giveConsent>>;
export type LinkResult =
| { kind: 'none' }
| { kind: 'ordered'; requestNumber: number; orderNumber: number; scheduledAt: Date; gift?: Gift }
| { kind: 'linked'; requestNumber: number; gift?: Gift };
/**
* Человек открыл бота по ссылке заявки: привязываем заявку к клиенту, переносим согласие с сайта
* и пробуем сразу оформить заказ на желаемое время. Если время занято или исполнителей нет —
* заявка остаётся оператору (LINKED). Повторный /start по той же ссылке ничего не меняет.
*/
export async function linkWebRequest(ctx: Ctx, token: string, clientId: string): Promise<LinkResult> {
const request = await ctx.db.webRequest.findUnique({ where: { token }, include: { service: true, district: true } });
if (!request) return { kind: 'none' };
if (request.clientId && request.clientId !== clientId) return { kind: 'none' };
if (request.status === 'ORDERED' && request.orderId) {
const order = await ctx.db.order.findUniqueOrThrow({ where: { id: request.orderId } });
return { kind: 'ordered', requestNumber: request.number, orderNumber: order.number, scheduledAt: order.scheduledAt };
}
if (request.status !== 'NEW') return { kind: 'linked', requestNumber: request.number };
const client = await ctx.db.client.findUniqueOrThrow({ where: { id: clientId }, include: { user: true } });
// согласие дано на сайте; вместе с ним активируется подарок агентства, если положен
const gift = client.consentAt ? null : await giveConsent(ctx, clientId);
if (!client.user.phone) await ctx.db.user.update({ where: { id: client.userId }, data: { phone: request.phone } });
await ctx.db.webRequest.update({ where: { id: request.id }, data: { clientId, status: 'LINKED' } });
try {
const order = await createOrder(ctx, {
clientId,
serviceCode: request.service.code,
params: request.params as Record<string, unknown>,
scheduledAt: request.preferredAt,
addressText: request.addressText,
districtId: request.districtId,
note: request.note ?? undefined,
});
// заявка могла быть отменена оператором, пока создавался заказ, — тогда не перезаписываем статус
await ctx.db.webRequest.updateMany({ where: { id: request.id, status: 'LINKED' }, data: { status: 'ORDERED', orderId: order.id } });
return { kind: 'ordered', requestNumber: request.number, orderNumber: order.number, scheduledAt: order.scheduledAt, gift };
} catch (e) {
if (e instanceof DomainError) return { kind: 'linked', requestNumber: request.number, gift };
throw e;
}
}
/** Для админки: свежие сверху, по статусу */
export async function listWebRequests(ctx: Ctx, input: { status?: WebRequestStatus }) {
return ctx.db.webRequest.findMany({
where: input.status ? { status: input.status } : {},
include: {
service: { select: { code: true } },
district: { select: { code: true } },
client: { select: { id: true, user: { select: { firstName: true, lastName: true, username: true } } } },
order: { select: { id: true, number: true, status: true } },
},
orderBy: { createdAt: 'desc' },
take: 200,
});
}
/** Оператор отменяет заявку или возвращает её в работу; заметка — для себя (созвонились, перезвонить…) */
export async function updateWebRequest(ctx: Ctx, input: { id: string; status?: 'CANCELLED' | 'REOPEN'; adminNote?: string }) {
const request = await ctx.db.webRequest.findUniqueOrThrow({ where: { id: input.id } });
let status: WebRequestStatus | undefined;
if (input.status === 'CANCELLED') {
if (request.status === 'ORDERED') throw new DomainError('TRANSITION_NOT_ALLOWED');
status = 'CANCELLED';
} else if (input.status === 'REOPEN') {
if (request.status !== 'CANCELLED') throw new DomainError('TRANSITION_NOT_ALLOWED');
status = request.clientId ? 'LINKED' : 'NEW';
}
return ctx.db.webRequest.update({
where: { id: input.id },
data: { status, adminNote: input.adminNote === undefined ? undefined : input.adminNote.trim() || null },
});
}
+326 -1
View File
@@ -16,11 +16,46 @@ import {
providerAction,
quoteForClient,
respondToOffer,
orderPayout,
rescheduleByClient,
setChecklistItem,
startClient,
transitionOrder,
DomainError,
createWebRequest,
linkWebRequest,
normalizePhone,
updateWebRequest,
createLead,
normalizeContact,
updateLead,
updateLeadByAdmin,
addClientMessage,
clientRelocation,
conversation,
createAppLead,
createClientRequest,
getMembership,
grantSubscription,
listConversations,
replyToClient,
toggleRelocationDoc,
updateLeadStep,
addLeadDoc,
addLeadStep,
addProviderMessage,
replyTo,
saveRelocationItem,
acceptPartnerInvite,
cardQrToken,
checkCard,
clientPartners,
createOwnerInvite,
createStaffInvite,
partnerCabinet,
redeemOffer,
savePartnerOffer,
verifyCardToken,
} from '../src/index.ts';
import { db, fixtures, makeCtx, NOW, slotAt, tgUser } from './helpers.ts';
@@ -106,7 +141,7 @@ describe('заказ (ORD-3…6)', () => {
const plus = await onboardedClient(ctx, 10, encodeStartPayload({ agency: 'DOMIK' }));
const plain = await onboardedClient(ctx, 11);
const p = { cleaningType: 'regular', rooms: 2 };
expect((await quoteForClient(ctx, plus, 'cleaning', p)).quote).toEqual({ basePrice: 19000, discountPct: 10, discountAmount: 1900, totalPrice: 17100, durationMinutes: 165 });
expect((await quoteForClient(ctx, plus, 'cleaning', p)).quote).toMatchObject({ basePrice: 19000, servicePrice: 19000, extras: [], discountPct: 10, discountAmount: 1900, totalPrice: 17100, durationMinutes: 165 });
expect((await quoteForClient(ctx, plain, 'cleaning', p)).quote.totalPrice).toBe(19000);
});
@@ -325,3 +360,293 @@ describe('оплата и напоминания', () => {
expect(events.filter((e) => e.type === 'order.reminder').map((e) => (e as { hours: number }).hours)).toEqual([24, 1]);
});
});
describe('выплаты клинеру по пресету', () => {
it('заказ хранит разбивку цены; заработок считается по строкам пресета', async () => {
const { ctx } = makeCtx(NOW);
const clientId = await onboardedClient(ctx, 70);
const order = await createOrder(ctx, {
clientId,
serviceCode: 'cleaning',
params: { cleaningType: 'regular', rooms: 2, bathrooms: 2, extras: { windows: 2 } },
scheduledAt: slotAt(2, 11),
addressText: 'Pushkin 10',
districtId: fx.kentron.id,
});
expect(order.priceBreakdown).toMatchObject({ variant: 'regular', base: 15000, rooms: { count: 1, amount: 4000 }, bathrooms: { count: 1, amount: 3000 } });
const preset = await db.payoutPreset.create({
data: {
name: 'Мидл',
role: 'CLEANER',
rules: { base: { regular: { type: 'PERCENT', value: 60 } }, room: { type: 'FIXED', value: 2500 }, bathroom: { type: 'FIXED', value: 1500 }, extras: { windows: { type: 'FIXED', value: 1000 } } },
},
});
await db.provider.update({ where: { id: fx.maria.id }, data: { payoutPresetId: preset.id } });
const maria = await db.provider.findUniqueOrThrow({ where: { id: fx.maria.id }, include: { company: true, payoutPreset: true } });
const full = await db.order.findUniqueOrThrow({ where: { id: order.id }, include: { service: true } });
// 60% от 15000 + 2500 за комнату + 1500 за санузел + 2 окна × 1000
expect(orderPayout(full, maria)).toBe(9000 + 2500 + 1500 + 2000);
// без пресета — индивидуальная ставка 70% от итога
expect(orderPayout(full, { ...maria, payoutPreset: null })).toBe(Math.floor(full.totalPrice * 0.7));
// сотрудник компании — ставка компании
const lusine = await db.provider.findUniqueOrThrow({ where: { id: fx.lusine.id }, include: { company: true, payoutPreset: true } });
expect(orderPayout(full, lusine)).toBe(Math.floor(full.totalPrice * 0.65));
});
});
describe('заявка с сайта (/clean)', () => {
const input = (when = slotAt(2, 11)) => ({
serviceCode: 'cleaning',
params: { cleaningType: 'regular', rooms: 2, bathrooms: 1, extras: { windows: 2 } },
preferredAt: when,
districtCode: 'kentron',
addressText: 'Pushkin 10',
phone: '+374 (91) 12-34-56',
name: 'Anna',
locale: 'en' as const,
agency: 'DOMIK',
});
it('цена считается на сервере, телефон нормализуется, токен ведёт в бота с языком и агентством', async () => {
const { ctx } = makeCtx(NOW);
const r = await createWebRequest(ctx, input());
expect(r).toMatchObject({ status: 'NEW', phone: '+37491123456', totalPrice: 15000 + 4000 + 2 * 3500, locale: 'en' });
const token = await db.startToken.findUniqueOrThrow({ where: { id: r.token } });
expect(token).toMatchObject({ locale: 'en', agency: 'DOMIK' });
});
it('время вне окна, в прошлом или кривой телефон — отказ', async () => {
const { ctx } = makeCtx(NOW);
expect(await code(createWebRequest(ctx, input(slotAt(2, 21))))).toBe('SLOT_UNAVAILABLE');
expect(await code(createWebRequest(ctx, input(slotAt(0, 11))))).toBe('SLOT_UNAVAILABLE');
expect(await code(createWebRequest(ctx, { ...input(), phone: '12-34' }))).toBe('PHONE_INVALID');
expect(await code(createWebRequest(ctx, { ...input(), districtCode: 'mars' }))).toBe('ADDRESS_REQUIRED');
expect(normalizePhone('8 (999) 123-45-67')).toBe('+89991234567');
});
it('открыл бота: согласие с сайта, агентство, заказ на желаемое время; повторный /start ничего не дублирует', async () => {
const { ctx, events } = makeCtx(NOW);
const r = await createWebRequest(ctx, input());
const start = await startClient(ctx, tgUser(77), encodeStartPayload({ token: r.token }));
expect(start.locale).toBe('en');
const link = await linkWebRequest(ctx, r.token, start.clientId);
expect(link).toMatchObject({ kind: 'ordered', requestNumber: r.number, scheduledAt: slotAt(2, 11) });
const client = await db.client.findUniqueOrThrow({ where: { id: start.clientId }, include: { user: true } });
expect(client.consentAt).not.toBeNull();
expect(client.agencyId).toBe(fx.agency.id);
expect(client.user.phone).toBe('+37491123456');
const saved = await db.webRequest.findUniqueOrThrow({ where: { id: r.id }, include: { order: true } });
expect(saved.status).toBe('ORDERED');
// подарок агентства (Barev+) активировался вместе с согласием — в заказе уже скидка 10% на «Дом»
expect(link.kind === 'ordered' && link.gift).toBeTruthy();
expect(saved.order).toMatchObject({ status: 'NEW', basePrice: 26000, totalPrice: 23400, districtId: fx.kentron.id });
expect(events.filter((e) => e.type === 'order.created')).toHaveLength(1);
expect(await linkWebRequest(ctx, r.token, start.clientId)).toMatchObject({ kind: 'ordered', orderNumber: saved.order!.number });
expect(await db.order.count()).toBe(1);
// чужой Telegram по той же ссылке заявку не забирает
const other = await startClient(ctx, tgUser(78), encodeStartPayload({ token: r.token }));
expect(await linkWebRequest(ctx, r.token, other.clientId)).toEqual({ kind: 'none' });
});
it('нет свободного исполнителя — заявка остаётся оператору; отмену и возврат делает админ', async () => {
const { ctx } = makeCtx(NOW);
await db.provider.updateMany({ data: { active: false } });
const r = await createWebRequest(ctx, input());
const start = await startClient(ctx, tgUser(79));
expect(await linkWebRequest(ctx, r.token, start.clientId)).toMatchObject({ kind: 'linked', requestNumber: r.number });
expect(await db.order.count()).toBe(0);
expect((await db.webRequest.findUniqueOrThrow({ where: { id: r.id } })).status).toBe('LINKED');
await updateWebRequest(ctx, { id: r.id, status: 'CANCELLED', adminNote: 'перезвонить в пятницу' });
expect(await db.webRequest.findUniqueOrThrow({ where: { id: r.id } })).toMatchObject({ status: 'CANCELLED', adminNote: 'перезвонить в пятницу' });
await updateWebRequest(ctx, { id: r.id, status: 'REOPEN' });
expect((await db.webRequest.findUniqueOrThrow({ where: { id: r.id } })).status).toBe('LINKED');
});
});
describe('квиз на главной (заявка на переезд)', () => {
it('контакт: телефон или Telegram, мусор — отказ', async () => {
expect(normalizeContact('+374 91 12-34-56')).toEqual({ contact: '+37491123456', contactType: 'PHONE' });
expect(normalizeContact('@anna_k')).toEqual({ contact: '@anna_k', contactType: 'TELEGRAM' });
expect(normalizeContact('https://t.me/anna_k')).toEqual({ contact: '@anna_k', contactType: 'TELEGRAM' });
expect(normalizeContact('anna')).toBeNull();
const { ctx } = makeCtx(NOW);
expect(await code(createLead(ctx, { name: 'Anna', contact: '12', locale: 'ru' }))).toBe('CONTACT_INVALID');
expect(await code(createLead(ctx, { name: ' ', contact: '@anna_k', locale: 'ru' }))).toBe('NAME_REQUIRED');
});
it('шаг 1 сохраняется сразу, ответы дописываются по ключу, после отправки заявка не меняется', async () => {
const { ctx } = makeCtx(NOW);
const lead = await createLead(ctx, { name: 'Anna', contact: '@anna_k', locale: 'en', utm: { utm_source: 'ig' } });
expect(lead).toMatchObject({ status: 'NEW', submittedAt: null, locale: 'en', consentAt: NOW });
expect(await code(updateLead(ctx, { id: lead.id, key: 'wrong', moved: false }))).toBe('NOT_FOUND');
await updateLead(ctx, { id: lead.id, key: lead.key, moved: false, moveWhen: 'date', moveDate: slotAt(30, 0) });
await updateLead(ctx, { id: lead.id, key: lead.key, package: 'CUSTOM', options: { airport: 'premium', stay: 'hotel', stayBudgetFrom: 20000, stayBudgetTo: 40000, residence: true } });
const sent = await updateLead(ctx, { id: lead.id, key: lead.key, submit: true });
expect(sent).toMatchObject({ moved: false, moveWhen: 'date', moveDate: slotAt(30, 0), package: 'CUSTOM', submittedAt: NOW });
expect(sent.options).toMatchObject({ airport: 'premium', stayBudgetTo: 40000 });
await updateLead(ctx, { id: lead.id, key: lead.key, package: 'CONSULT' });
expect((await db.lead.findUniqueOrThrow({ where: { id: lead.id } })).package).toBe('CUSTOM');
expect(await updateLeadByAdmin(ctx, { id: lead.id, status: 'IN_WORK', adminNote: ' позвонить ' })).toMatchObject({ status: 'IN_WORK', adminNote: 'позвонить' });
});
it('«уже живу в Армении» — квиз закончен, заявка отправлена', async () => {
const { ctx } = makeCtx(NOW);
const lead = await createLead(ctx, { name: 'Ivan', contact: '+79991234567', locale: 'ru' });
expect(await updateLead(ctx, { id: lead.id, key: lead.key, moved: true })).toMatchObject({ moved: true, submittedAt: NOW });
});
});
describe('приложение: переезд, Elite, заявки оператору, консьерж', () => {
it('заявка из приложения строит план по шагам; оператор двигает шаг — клиенту событие', async () => {
const { ctx, events } = makeCtx(NOW);
const clientId = await onboardedClient(ctx, 501);
const lead = await createAppLead(ctx, clientId, { moveWhen: 'month', package: 'CUSTOM', options: { airport: 'standard', stay: 'none', flat: true, residence: true } });
expect(lead.steps.map((s) => s.code)).toEqual(['airport', 'flat', 'residence']);
expect(lead).toMatchObject({ clientId, submittedAt: NOW, moved: false });
expect(await code(createAppLead(ctx, clientId, { moveWhen: 'month', package: 'CUSTOM', options: { airport: 'none' } }))).toBe('INVALID_PARAMS');
await updateLeadStep(ctx, { stepId: lead.steps[0]!.id, status: 'DONE', note: 'Водитель Арам, +374 91 000000' });
expect(events).toContainEqual({ type: 'lead.step', stepId: lead.steps[0]!.id });
expect((await db.lead.findUniqueOrThrow({ where: { id: lead.id } })).status).toBe('IN_WORK');
const plan = (await clientRelocation(ctx, clientId))!;
expect(plan.docs.map((d) => d.code)).toEqual(['passport', 'diploma', 'criminalRecord']);
await toggleRelocationDoc(ctx, clientId, plan.docs[1]!.id, true);
expect((await clientRelocation(ctx, clientId))!.docs.filter((d) => d.checked).map((d) => d.code)).toEqual(['diploma']);
const other = await onboardedClient(ctx, 599);
expect(await code(toggleRelocationDoc(ctx, other, plan.docs[0]!.id, true))).toBe('NOT_FOUND');
});
it('заявка с сайта находится по нику Telegram и получает план «под ключ»', async () => {
const { ctx } = makeCtx(NOW);
const site = await createLead(ctx, { name: 'Ann', contact: '@Ann_Moving', locale: 'en' });
await updateLead(ctx, { id: site.id, key: site.key, moved: false });
await updateLead(ctx, { id: site.id, key: site.key, package: 'TURNKEY', submit: true });
const start = await startClient(ctx, { ...tgUser(502), username: 'ann_moving' });
const plan = await clientRelocation(ctx, start.clientId);
expect(plan?.id).toBe(site.id);
expect(plan?.steps).toHaveLength(7);
});
it('Elite: своя скидка в заказе, заявка на подписку не дублируется, оператор включает', async () => {
const { ctx, events } = makeCtx(NOW);
await db.category.updateMany({ where: { code: 'HOME' }, data: { eliteDiscountPct: 35 } });
const clientId = await onboardedClient(ctx, 503);
const r1 = await createClientRequest(ctx, clientId, { kind: 'SUBSCRIPTION', plan: 'ELITE' });
const r2 = await createClientRequest(ctx, clientId, { kind: 'SUBSCRIPTION', plan: 'ELITE' });
expect(r2.id).toBe(r1.id);
await grantSubscription(ctx, { clientId, plan: 'ELITE', days: 30, requestId: r1.id });
expect((await db.clientRequest.findUniqueOrThrow({ where: { id: r1.id } })).status).toBe('DONE');
expect(events.some((e) => e.type === 'subscription.activated')).toBe(true);
expect((await getMembership(db, clientId, NOW)).level).toBe('ELITE');
const order = await newOrder(ctx, clientId);
// уборка 2 комнаты 15 000 + 4 000 → −35%
expect(order).toMatchObject({ basePrice: 19000, totalPrice: 12350 });
expect(await code(createClientRequest(ctx, clientId, { kind: 'SERVICE', serviceCode: 'nope' }))).toBe('NOT_FOUND');
});
it('консьерж: сообщения клиента непрочитаны, пока оператор не открыл диалог; ответ уходит в бота', async () => {
const { ctx, events } = makeCtx(NOW);
const clientId = await onboardedClient(ctx, 504);
await addClientMessage(ctx, clientId, 'Как продлить визу?');
expect((await listConversations(ctx))[0]).toMatchObject({ clientId, unread: 1 });
await conversation(ctx, clientId);
expect((await listConversations(ctx))[0]!.unread).toBe(0);
const admin = await db.adminUser.create({ data: { email: 'op@test', name: 'Оператор', passwordHash: 'x' } });
const msg = await replyToClient(ctx, { clientId, adminUserId: admin.id, text: 'Подскажем завтра утром' });
expect(events).toContainEqual({ type: 'concierge.reply', messageId: msg.id });
});
});
describe('шаблоны переезда и переписка с исполнителями', () => {
it('новый шаг в общем списке попадает в «под ключ» новым клиентам; у клиента — свои шаги и документы', async () => {
const { ctx } = makeCtx(NOW);
await saveRelocationItem(ctx, { kind: 'STEP', name: { ru: 'Школа для ребёнка' }, active: true, inTurnkey: true });
await saveRelocationItem(ctx, { kind: 'STEP', name: { ru: 'Скрытый' }, active: false, inTurnkey: true });
const clientId = await onboardedClient(ctx, 601);
const lead = await createAppLead(ctx, clientId, { moveWhen: 'later', package: 'TURNKEY' });
expect(lead.steps).toHaveLength(8);
expect(lead.steps.some((s) => s.code.startsWith('c-'))).toBe(true);
const custom = await addLeadStep(ctx, { leadId: lead.id, title: 'Купить сим-карту' });
expect(custom).toMatchObject({ title: 'Купить сим-карту', status: 'TODO' });
expect(await addLeadStep(ctx, { leadId: lead.id, code: 'airport' })).toMatchObject({ id: lead.steps.find((s) => s.code === 'airport')!.id });
await addLeadDoc(ctx, { leadId: lead.id, title: 'Справка 2-НДФЛ' });
expect((await clientRelocation(ctx, clientId))!.docs.map((d) => d.title ?? d.code)).toEqual(['passport', 'diploma', 'criminalRecord', 'Справка 2-НДФЛ']);
});
it('исполнитель пишет боту — операторам уведомление; ответ уходит исполнителю', async () => {
const { ctx, events } = makeCtx(NOW);
const provider = await db.provider.findFirstOrThrow({ where: { userId: { not: null } } });
const msg = await addProviderMessage(ctx, provider.id, 'Клиент не открывает дверь');
expect(events).toContainEqual({ type: 'concierge.message', messageId: msg!.id });
const admin = await db.adminUser.create({ data: { email: 'op2@test', name: 'Оператор', passwordHash: 'x' } });
const reply = await replyTo(ctx, { providerId: provider.id, adminUserId: admin.id, text: 'Звоним клиенту' });
expect(reply).toMatchObject({ providerId: provider.id, author: 'OPERATOR' });
expect((await db.conciergeMessage.findUniqueOrThrow({ where: { id: msg!.id } })).readAt).not.toBeNull();
const unlinked = await db.provider.create({ data: { name: 'Без бота', role: 'CLEANER', languages: ['ru'], inviteCode: 'X-NOBOT' } });
expect(await code(replyTo(ctx, { providerId: unlinked.id, adminUserId: admin.id, text: 'привет' }))).toBe('PROVIDER_NOT_LINKED');
});
});
describe('партнёры: QR клубной карты и кабинет в рабочем боте', () => {
async function setup(ctx: ReturnType<typeof makeCtx>['ctx']) {
const partner = await db.partner.create({ data: { category: 'cafe', name: 'Lumen', offer: { ru: 'кофе' } } });
const every = await savePartnerOffer(ctx, { partnerId: partner.id, title: { ru: '−10% на всё' }, minLevel: null, limit: 'NONE', limitCount: 1, active: true });
const weekly = await savePartnerOffer(ctx, { partnerId: partner.id, title: { ru: 'Кофе в подарок' }, minLevel: 'BAREV_PLUS', limit: 'WEEK', limitCount: 1, active: true });
const first = await savePartnerOffer(ctx, { partnerId: partner.id, title: { ru: 'Десерт на первый визит' }, minLevel: null, limit: 'ONCE', limitCount: 1, active: true });
const { url } = await createOwnerInvite(ctx, partner.id);
const owner = await acceptPartnerInvite(ctx, tgUser(800), url.split('start=')[1]!);
expect(owner.role).toBe('OWNER');
return { partner, every, weekly, first };
}
it('QR меняется каждую минуту, старый скриншот через 3 минуты не проходит, подделка не проходит', () => {
const { token } = cardQrToken('ABCD2345', NOW);
expect(token.length).toBeLessThanOrEqual(64);
expect(verifyCardToken(token, NOW)).toBe('ABCD2345');
expect(verifyCardToken(token, new Date(NOW.getTime() + 90_000))).toBe('ABCD2345');
expect(verifyCardToken(token, new Date(NOW.getTime() + 4 * 60_000))).toBeNull();
expect(verifyCardToken(token.replace('ABCD2345', 'ZZZZ2345'), NOW)).toBeNull();
});
it('сотрудник проверяет карту: уровень, что положено, лимиты «раз в неделю» и «первый визит»', async () => {
const { ctx, clock } = makeCtx(NOW);
const { partner, weekly, first } = await setup(ctx);
const { url } = await createStaffInvite(ctx, 800n);
expect((await acceptPartnerInvite(ctx, tgUser(801), url.split('start=')[1]!)).role).toBe('STAFF');
expect(await code(acceptPartnerInvite(ctx, tgUser(802), url.split('start=')[1]!))).toBe('INVITE_INVALID');
expect(await code(createStaffInvite(ctx, 801n))).toBe('FORBIDDEN');
const clientId = await onboardedClient(ctx, 810);
const card = (await db.client.findUniqueOrThrow({ where: { id: clientId } })).cardCode;
let check = await checkCard(ctx, { telegramId: 801n, token: `https://t.me/bot?start=${cardQrToken(card, NOW).token}` });
expect(check.level).toBe('BAREV');
expect(check.offers.find((o) => o.id === weekly.id)).toMatchObject({ eligible: false });
expect(await code(redeemOffer(ctx, { telegramId: 801n, checkId: check.checkId, offerId: weekly.id }))).toBe('OFFER_NOT_AVAILABLE');
expect(await redeemOffer(ctx, { telegramId: 801n, checkId: check.checkId, offerId: first.id })).toMatchObject({ remaining: 0 });
expect(await code(redeemOffer(ctx, { telegramId: 801n, checkId: check.checkId, offerId: first.id }))).toBe('OFFER_LIMIT_REACHED');
await grantSubscription(ctx, { clientId, plan: 'BAREV_PLUS', days: 30 });
check = await checkCard(ctx, { telegramId: 801n, token: cardQrToken(card, NOW).token });
expect(check.offers.find((o) => o.id === weekly.id)).toMatchObject({ eligible: true, remaining: 1 });
await redeemOffer(ctx, { telegramId: 801n, checkId: check.checkId, offerId: weekly.id });
// через неделю — снова можно (19.10 — понедельник, 26.10 — следующий)
clock.now = new Date(NOW.getTime() + 7 * 24 * 3600_000);
check = await checkCard(ctx, { telegramId: 801n, token: cardQrToken(card, clock.now).token });
expect(check.offers.find((o) => o.id === weekly.id)).toMatchObject({ remaining: 1 });
expect(check.offers.find((o) => o.id === first.id)).toMatchObject({ remaining: 0 });
// чужой человек проверить не может; собственник видит сотрудников и проверки
expect(await code(checkCard(ctx, { telegramId: 999n, token: cardQrToken(card, clock.now).token }))).toBe('FORBIDDEN');
const cab = await partnerCabinet(ctx, 800n);
expect(cab).toMatchObject({ role: 'OWNER', visits30: 3 });
expect(cab.members.map((m) => m.role)).toEqual(['OWNER', 'STAFF']);
expect((await partnerCabinet(ctx, 801n)).members).toEqual([]);
expect((await clientPartners(ctx, clientId)).find((p) => p.id === partner.id)!.offers).toHaveLength(3);
});
});
+9
View File
@@ -38,7 +38,16 @@ export const slotAt = (dayOffset: number, hour: number) => {
let tgSeq = 1000;
export async function fixtures() {
// шаблоны переезда (в базе их создаёт миграция; тесты чистят все таблицы)
const steps = ['consult', 'airport', 'stay', 'flat', 'registration', 'socialCard', 'bankCard', 'residence'];
const docs = ['passport', 'diploma', 'criminalRecord'];
await wipe();
await db.relocationItem.createMany({
data: [
...steps.map((code, i) => ({ kind: 'STEP' as const, code, name: { ru: code }, sortOrder: i, inTurnkey: code !== 'consult' })),
...docs.map((code, i) => ({ kind: 'DOC' as const, code, name: { ru: code }, sortOrder: i, inTurnkey: false })),
],
});
for (const [i, code] of YEREVAN_DISTRICTS.entries()) await db.district.create({ data: { code, sortOrder: i } });
const kentron = await db.district.findUniqueOrThrow({ where: { code: 'kentron' } });
const home = await db.category.create({ data: { code: 'HOME', barevPlusDiscountPct: DEFAULT_CATEGORY_DISCOUNTS.HOME } });
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@barev/ui",
"version": "0.0.0",
"private": true,
"type": "module",
"sideEffects": ["*.css"],
"exports": {
".": "./src/index.tsx",
"./styles.css": "./src/styles.css"
},
"scripts": { "typecheck": "tsc --noEmit" },
"peerDependencies": { "react": "^19.0.0" },
"devDependencies": { "@types/react": "^19.2.0", "react": "^19.2.0" }
}
+211
View File
@@ -0,0 +1,211 @@
import type { ButtonHTMLAttributes, ReactNode, AnchorHTMLAttributes, CSSProperties } from 'react';
const cx = (...c: (string | false | null | undefined)[]) => c.filter(Boolean).join(' ');
export type Tone = 'home' | 'docs' | 'learn' | 'perks' | 'pink' | 'blue' | 'dark' | 'white';
export type CategoryCode = 'HOME' | 'DOCS' | 'LEARNING' | 'PERKS';
/** Цвет направления (раздел 9) */
export const CATEGORY_TONE: Record<CategoryCode, Tone> = { HOME: 'home', DOCS: 'docs', LEARNING: 'learn', PERKS: 'perks' };
type BtnVariant = 'dark' | 'light' | 'outline' | 'accent' | 'ghost' | 'danger';
export function Button({
variant = 'dark',
size,
block,
className,
...rest
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: BtnVariant; size?: 'sm'; block?: boolean }) {
return <button type="button" className={cx('bc-btn', `bc-btn--${variant}`, size && `bc-btn--${size}`, block && 'bc-btn--block', className)} {...rest} />;
}
export function LinkButton({
variant = 'dark',
size,
block,
className,
...rest
}: AnchorHTMLAttributes<HTMLAnchorElement> & { variant?: BtnVariant; size?: 'sm'; block?: boolean }) {
return <a className={cx('bc-btn', `bc-btn--${variant}`, size && `bc-btn--${size}`, block && 'bc-btn--block', className)} {...rest} />;
}
export function Card({ tone, tight, className, style, children, as: As = 'div', ...rest }: { tone?: Tone; tight?: boolean; className?: string; style?: CSSProperties; children?: ReactNode; as?: 'div' | 'section' | 'article' } & Record<string, unknown>) {
return (
<As className={cx('bc-card', tight && 'bc-card--tight', tone && `bc-tone-${tone}`, className)} style={style} {...rest}>
{children}
</As>
);
}
export function Badge({ children, dot, style }: { children: ReactNode; dot?: boolean; style?: CSSProperties }) {
return (
<span className={cx('bc-badge', dot && 'bc-badge--dot')} style={style}>
{children}
</span>
);
}
export function Chip({ pressed, ...rest }: ButtonHTMLAttributes<HTMLButtonElement> & { pressed?: boolean }) {
return <button type="button" className="bc-chip" aria-pressed={pressed} {...rest} />;
}
export function Segmented<T extends string>({ value, options, onChange, label }: { value: T; options: { value: T; label: ReactNode }[]; onChange: (v: T) => void; label?: string }) {
return (
<div className="bc-segmented" role="group" aria-label={label}>
{options.map((o) => (
<button key={o.value} type="button" aria-pressed={o.value === value} onClick={() => onChange(o.value)}>
{o.label}
</button>
))}
</div>
);
}
export function Stepper({ value, min, max, onChange, decLabel = '−', incLabel = '+' }: { value: number; min: number; max: number; onChange: (v: number) => void; decLabel?: string; incLabel?: string }) {
return (
<div className="bc-stepper">
<button type="button" aria-label={decLabel} disabled={value <= min} onClick={() => onChange(Math.max(min, value - 1))}>
−
</button>
<output aria-live="polite">{value}</output>
<button type="button" aria-label={incLabel} disabled={value >= max} onClick={() => onChange(Math.min(max, value + 1))}>
+
</button>
</div>
);
}
export function Field({ label, error, children, htmlFor }: { label?: ReactNode; error?: ReactNode; children: ReactNode; htmlFor?: string }) {
return (
<div className="bc-field">
{label && (
<label className="bc-label" htmlFor={htmlFor}>
{label}
</label>
)}
{children}
{error && <div className="bc-error">{error}</div>}
</div>
);
}
export function Spinner({ label }: { label?: string }) {
return <div className="bc-spinner" role="status" aria-label={label} />;
}
export function Center({ children }: { children: ReactNode }) {
return <div className="bc-center">{children}</div>;
}
/** «Цветок» из макета: круг + 10 лепестков */
export function Flower({ size = 72, color = '#B8235A', rotate = -10, children, textColor = '#fff', fontSize }: { size?: number; color?: string; rotate?: number; children?: ReactNode; textColor?: string; fontSize?: number }) {
return (
<div className="bc-flower" style={{ width: size, height: size, transform: `rotate(${rotate}deg)` }}>
<svg aria-hidden="true" viewBox="0 0 100 100" width={size} height={size}>
<g fill={color}>
<circle cx="50" cy="50" r="34" />
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => {
const a = (i * Math.PI) / 5;
return <circle key={i} cx={50 + 32 * Math.cos(a)} cy={50 + 32 * Math.sin(a)} r="16" />;
})}
</g>
</svg>
{children !== undefined && <span style={{ color: textColor, fontSize: fontSize ?? size * 0.36, transform: `rotate(${-rotate}deg)` }}>{children}</span>}
</div>
);
}
export function Sheet({ open, onClose, children, label }: { open: boolean; onClose: () => void; children: ReactNode; label?: string }) {
if (!open) return null;
return (
<div className="bc-sheet-backdrop" onClick={onClose}>
<div className="bc-sheet" role="dialog" aria-modal="true" aria-label={label} onClick={(e) => e.stopPropagation()}>
{children}
</div>
</div>
);
}
export function Steps({ items }: { items: { label: ReactNode; state: 'done' | 'current' | 'todo'; meta?: ReactNode }[] }) {
return (
<ol className="bc-steps" style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{items.map((s, i) => (
<li key={i} className={`bc-step bc-step--${s.state}`}>
<span className="bc-step__dot" aria-hidden="true">
{s.state === 'done' && <Icon name="check" size={14} />}
</span>
<span>
<span className="bc-step__label">{s.label}</span>
{s.meta && <div className="bc-small bc-muted">{s.meta}</div>}
</span>
</li>
))}
</ol>
);
}
// ───── иконки из макета (stroke 2, round) ─────
const ICONS = {
home: 'M4 11l8-7 8 7v9H4z',
docs: 'M7 3h7l5 5v13H7zM14 3v5h5M10 13h6M10 17h6',
learn: 'M4 5h6a2 2 0 0 1 2 2v13a2 2 0 0 0-2-2H4zM20 5h-6a2 2 0 0 0-2 2v13a2 2 0 0 1 2-2h6z',
gift: 'M4 8h16v4H4zM5 12h14v8H5zM12 8v12M12 8c-2-3-6-3-6 0M12 8c2-3 6-3 6 0',
check: 'M5 12l4 4 10-10',
arrow: 'M5 12h14M13 6l6 6-6 6',
back: 'M19 12H5M11 18l-6-6 6-6',
plus: 'M12 5v14M5 12h14',
grid: 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z',
user: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM4 21a8 8 0 0 1 16 0',
clock: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2',
pin: 'M12 21s-7-6.2-7-11a7 7 0 0 1 14 0c0 4.8-7 11-7 11zM12 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z',
camera: 'M4 8h3l2-3h6l2 3h3v11H4zM12 17a4 4 0 1 0 0-8 4 4 0 0 0 0 8z',
chat: 'M4 5h16v11H8l-4 4z',
star: 'M12 3l2.7 5.6 6.1.9-4.4 4.3 1 6.1L12 17l-5.4 2.9 1-6.1L3.2 9.5l6.1-.9z',
logo: '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',
close: 'M6 6l12 12M18 6L6 18',
repeat: 'M4 12a8 8 0 0 1 14-5.3L20 9M20 4v5h-5M20 12a8 8 0 0 1-14 5.3L4 15M4 20v-5h5',
globe: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3c2.5 2.7 3.8 5.7 3.8 9s-1.3 6.3-3.8 9c-2.5-2.7-3.8-5.7-3.8-9S9.5 5.7 12 3z',
} as const;
export type IconName = keyof typeof ICONS;
export const CATEGORY_ICON: Record<CategoryCode, IconName> = { HOME: 'home', DOCS: 'docs', LEARNING: 'learn', PERKS: 'gift' };
export function Icon({ name, size = 22, strokeWidth = 2, style, label }: { name: IconName; size?: number; strokeWidth?: number; style?: CSSProperties; label?: string }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
style={{ flex: 'none', ...style }}
aria-hidden={label ? undefined : true}
role={label ? 'img' : undefined}
aria-label={label}
>
<path d={ICONS[name]} />
</svg>
);
}
export function Logo({ size = 24, color = '#4A1028', text = 'Barev Club' }: { size?: number; color?: string; text?: string }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color }}>
<Icon name="logo" size={size} strokeWidth={2.4} />
<div style={{ fontFamily: 'var(--bc-font-head)', fontWeight: 700, fontSize: size * 0.84 }}>{text}</div>
</div>
);
}
export function Toast({ message, kind = 'info' }: { message: ReactNode; kind?: 'info' | 'error' }) {
return (
<div className={cx('bc-toast', kind === 'error' && 'bc-toast--error')} role={kind === 'error' ? 'alert' : 'status'}>
{message}
</div>
);
}
+598
View File
@@ -0,0 +1,598 @@
/*
* Barev Club — дизайн-токены и базовые компоненты (PRD, раздел 9; макет «Лендинг v2 · плотный»).
* Подключение: import '@barev/ui/styles.css'. Шрифты Rubik + Onest грузит приложение.
*/
:root {
--bc-bg: #fbf6ee;
--bc-surface: #ffffff;
--bc-ink: #1e1b1a;
--bc-ink-2: #5c5550;
--bc-ink-3: #8a827c;
--bc-line: #ece4d8;
--bc-accent: #b8235a;
--bc-plum: #4a1028;
--bc-dark: #1e1b1a;
--bc-dark-2: #2e2a28;
--bc-ok: #1f7a52;
--bc-danger: #b3261e;
/* направления: фон / текст на фоне */
--bc-home: #a8c38a;
--bc-home-ink: #1f3311;
--bc-docs: #f9cdb0;
--bc-docs-ink: #4d200a;
--bc-learn: #cfc8f5;
--bc-learn-ink: #2b1f5c;
--bc-perks: #f7da6e;
--bc-perks-ink: #4a1028;
--bc-pink: #f6c3d3;
--bc-pink-ink: #4a1028;
--bc-blue: #bcd0f2;
--bc-blue-ink: #12264d;
--bc-r-sm: 20px;
--bc-r-md: 24px;
--bc-r-lg: 32px;
--bc-r-xl: 36px;
--bc-pill: 999px;
--bc-tap: 44px;
--bc-font-head: 'Rubik', system-ui, sans-serif;
--bc-font-body: 'Onest', system-ui, sans-serif;
--bc-shadow: 0 8px 30px rgba(30, 27, 26, 0.08);
}
/* Армянский и китайский: Rubik и Onest их не содержат */
:root:lang(hy) {
--bc-font-head: 'Rubik', 'Noto Sans Armenian', system-ui, sans-serif;
--bc-font-body: 'Onest', 'Noto Sans Armenian', system-ui, sans-serif;
}
:root:lang(zh) {
--bc-font-head: 'Rubik', 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', system-ui, sans-serif;
--bc-font-body: 'Onest', 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', system-ui, sans-serif;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
background: var(--bc-bg);
color: var(--bc-ink);
font-family: var(--bc-font-body);
font-size: 15px;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
}
a {
color: inherit;
}
button,
input,
select,
textarea {
font: inherit;
color: inherit;
}
:focus-visible {
outline: 3px solid var(--bc-accent);
outline-offset: 2px;
}
/* ───── типографика ───── */
.bc-h1,
.bc-h2,
.bc-h3 {
font-family: var(--bc-font-head);
margin: 0;
color: inherit;
}
.bc-h1 {
font-weight: 800;
font-size: clamp(32px, 5.4vw, 74px);
line-height: 0.98;
letter-spacing: -0.03em;
}
.bc-h2 {
font-weight: 700;
font-size: 26px;
line-height: 1.1;
letter-spacing: -0.02em;
}
.bc-h3 {
font-weight: 700;
font-size: 18px;
line-height: 1.2;
}
/* приём «жирное + курсив» */
.bc-italic {
font-weight: 400;
font-style: italic;
}
.bc-muted {
color: var(--bc-ink-2);
}
.bc-small {
font-size: 13px;
}
.bc-num {
font-family: var(--bc-font-head);
font-weight: 800;
font-variant-numeric: tabular-nums;
}
/* ───── кнопки-таблетки ───── */
.bc-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
min-height: var(--bc-tap);
height: 52px;
padding: 0 24px;
border-radius: var(--bc-pill);
border: 2px solid transparent;
font-weight: 600;
font-size: 16px;
text-decoration: none;
cursor: pointer;
transition: transform 0.08s ease, opacity 0.15s ease, background 0.15s ease;
white-space: nowrap;
user-select: none;
}
.bc-btn:active {
transform: scale(0.98);
}
.bc-btn[disabled] {
opacity: 0.45;
cursor: not-allowed;
transform: none;
}
.bc-btn--dark {
background: var(--bc-dark);
color: #fff;
}
.bc-btn--light {
background: var(--bc-surface);
color: var(--bc-ink);
}
.bc-btn--outline {
background: transparent;
border-color: var(--bc-ink);
color: var(--bc-ink);
}
.bc-btn--accent {
background: var(--bc-accent);
color: #fff;
}
.bc-btn--ghost {
background: transparent;
color: var(--bc-ink);
padding: 0 12px;
}
.bc-btn--danger {
background: transparent;
border-color: var(--bc-danger);
color: var(--bc-danger);
}
.bc-btn--sm {
height: var(--bc-tap);
padding: 0 16px;
font-size: 14px;
}
.bc-btn--block {
width: 100%;
}
/* ───── карточки ───── */
.bc-card {
background: var(--bc-surface);
border-radius: var(--bc-r-lg);
padding: 20px;
}
.bc-card--tight {
border-radius: var(--bc-r-md);
padding: 14px 16px;
}
.bc-tone-home {
background: var(--bc-home);
color: var(--bc-home-ink);
}
.bc-tone-docs {
background: var(--bc-docs);
color: var(--bc-docs-ink);
}
.bc-tone-learn {
background: var(--bc-learn);
color: var(--bc-learn-ink);
}
.bc-tone-perks {
background: var(--bc-perks);
color: var(--bc-perks-ink);
}
.bc-tone-pink {
background: var(--bc-pink);
color: var(--bc-pink-ink);
}
.bc-tone-blue {
background: var(--bc-blue);
color: var(--bc-blue-ink);
}
.bc-tone-dark {
background: var(--bc-dark);
color: #fff;
}
.bc-tone-white {
background: var(--bc-surface);
color: var(--bc-ink);
}
/* ───── бейджи и чипы ───── */
.bc-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: var(--bc-pill);
background: var(--bc-surface);
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.bc-badge--dot::before {
content: '';
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.bc-chip {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: var(--bc-tap);
padding: 8px 16px;
border-radius: var(--bc-pill);
background: var(--bc-surface);
border: 2px solid transparent;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.bc-chip[aria-pressed='true'] {
background: var(--bc-dark);
color: #fff;
}
.bc-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
/* строка-таблетка услуги «Клининг … от N ֏» */
.bc-row-pill {
min-height: 46px;
padding: 0 16px;
border-radius: var(--bc-pill);
background: var(--bc-surface);
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 15px;
color: var(--bc-ink);
text-decoration: none;
}
/* ───── поля ───── */
.bc-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.bc-label {
font-size: 13px;
font-weight: 600;
color: var(--bc-ink-2);
}
.bc-input {
min-height: 50px;
width: 100%;
padding: 12px 16px;
border-radius: 18px;
border: 2px solid var(--bc-line);
background: var(--bc-surface);
font-size: 16px;
outline: none;
}
.bc-input:focus {
border-color: var(--bc-ink);
}
textarea.bc-input {
resize: vertical;
min-height: 90px;
}
.bc-error {
color: var(--bc-danger);
font-size: 13px;
font-weight: 500;
}
/* степпер −/+ */
.bc-stepper {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px;
border-radius: var(--bc-pill);
background: var(--bc-surface);
}
.bc-stepper button {
width: var(--bc-tap);
height: var(--bc-tap);
border-radius: 50%;
border: none;
background: var(--bc-bg);
font-size: 20px;
font-weight: 700;
cursor: pointer;
}
.bc-stepper button[disabled] {
opacity: 0.35;
}
.bc-stepper output {
min-width: 36px;
text-align: center;
font-family: var(--bc-font-head);
font-weight: 700;
font-size: 18px;
}
/* сегменты (переключатель тарифов, табы) */
.bc-segmented {
display: flex;
padding: 4px;
gap: 4px;
border-radius: var(--bc-pill);
background: var(--bc-surface);
}
.bc-segmented button {
flex: 1;
min-height: 40px;
border-radius: var(--bc-pill);
border: none;
background: transparent;
font-weight: 600;
font-size: 14px;
cursor: pointer;
}
.bc-segmented button[aria-pressed='true'] {
background: var(--bc-dark);
color: #fff;
}
/* ───── тёмная нижняя навигация-таблетка с белой «+» ───── */
.bc-bottomnav {
position: fixed;
left: 50%;
bottom: calc(12px + env(safe-area-inset-bottom));
transform: translateX(-50%);
width: min(440px, calc(100% - 24px));
height: 64px;
padding: 0 8px;
border-radius: var(--bc-pill);
background: var(--bc-dark);
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 10px 30px rgba(30, 27, 26, 0.25);
z-index: 50;
}
.bc-bottomnav a,
.bc-bottomnav button {
flex: 1;
height: 52px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
color: #cfc8c2;
text-decoration: none;
font-size: 11px;
font-weight: 500;
border: none;
background: none;
cursor: pointer;
border-radius: var(--bc-pill);
}
.bc-bottomnav [aria-current='page'] {
color: #fff;
}
.bc-bottomnav .bc-bottomnav__plus {
flex: none;
width: 52px;
height: 52px;
border-radius: 50%;
background: #fff;
color: var(--bc-dark);
}
/* ───── «цветок» (scalloped shape) ───── */
.bc-flower {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
}
.bc-flower svg {
position: absolute;
inset: 0;
}
.bc-flower > span {
position: relative;
font-family: var(--bc-font-head);
font-weight: 800;
}
/* ───── прочее ───── */
.bc-stack {
display: flex;
flex-direction: column;
gap: 12px;
}
.bc-row {
display: flex;
align-items: center;
gap: 10px;
}
.bc-between {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.bc-divider {
height: 1px;
background: var(--bc-line);
border: none;
margin: 4px 0;
}
.bc-spinner {
width: 28px;
height: 28px;
border-radius: 50%;
border: 3px solid var(--bc-line);
border-top-color: var(--bc-accent);
animation: bc-spin 0.8s linear infinite;
}
@keyframes bc-spin {
to {
transform: rotate(360deg);
}
}
.bc-center {
display: flex;
align-items: center;
justify-content: center;
min-height: 40vh;
}
.bc-sheet-backdrop {
position: fixed;
inset: 0;
background: rgba(30, 27, 26, 0.45);
z-index: 80;
display: flex;
align-items: flex-end;
justify-content: center;
}
.bc-sheet {
width: min(560px, 100%);
max-height: 88vh;
overflow: auto;
background: var(--bc-bg);
border-radius: var(--bc-r-xl) var(--bc-r-xl) 0 0;
padding: 20px 16px calc(20px + env(safe-area-inset-bottom));
}
@media (min-width: 720px) {
.bc-sheet-backdrop {
align-items: center;
}
.bc-sheet {
border-radius: var(--bc-r-xl);
}
}
.bc-toast {
position: fixed;
left: 50%;
top: calc(12px + env(safe-area-inset-top));
transform: translateX(-50%);
z-index: 90;
max-width: calc(100% - 24px);
padding: 12px 18px;
border-radius: var(--bc-pill);
background: var(--bc-dark);
color: #fff;
font-weight: 600;
font-size: 14px;
box-shadow: var(--bc-shadow);
}
.bc-toast--error {
background: var(--bc-danger);
}
/* шкала прогресса статусов */
.bc-steps {
display: flex;
flex-direction: column;
gap: 0;
}
.bc-step {
display: flex;
gap: 12px;
align-items: flex-start;
min-height: 40px;
}
.bc-step__dot {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid var(--bc-line);
background: var(--bc-surface);
flex: none;
display: flex;
align-items: center;
justify-content: center;
margin-top: 1px;
}
.bc-step--done .bc-step__dot {
background: var(--bc-ok);
border-color: var(--bc-ok);
color: #fff;
}
.bc-step--current .bc-step__dot {
border-color: var(--bc-accent);
box-shadow: 0 0 0 4px rgba(184, 35, 90, 0.15);
}
.bc-step--current .bc-step__label {
font-weight: 700;
}
.bc-step--todo .bc-step__label {
color: var(--bc-ink-3);
}
/* чекбокс-строка (чек-лист исполнителя) */
.bc-check {
display: flex;
align-items: center;
gap: 12px;
min-height: 56px;
padding: 10px 16px;
border-radius: var(--bc-r-sm);
background: var(--bc-surface);
cursor: pointer;
border: 2px solid transparent;
}
.bc-check input {
width: 24px;
height: 24px;
accent-color: var(--bc-ok);
flex: none;
}
.bc-check--done {
border-color: var(--bc-ok);
}
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
transition: none !important;
}
}
+1
View File
@@ -0,0 +1 @@
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "lib": ["ES2023", "DOM"], "jsx": "react-jsx" }, "include": ["src"] }
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@barev/web",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@barev/i18n": "workspace:*",
"@tanstack/react-query": "^5.104.0",
"@trpc/client": "^11.19.0",
"@trpc/tanstack-react-query": "^11.19.0",
"superjson": "^2.2.6",
"@barev/ui": "workspace:*"
},
"peerDependencies": {
"react": "^19.0.0"
},
"devDependencies": {
"@barev/api": "workspace:*",
"@types/react": "^19.2.0",
"react": "^19.2.0"
}
}
+16
View File
@@ -0,0 +1,16 @@
/** Даты по Еревану (UTC+4, без перехода на летнее время) для выбора слотов */
const OFFSET = 4 * 3600_000;
export function yerevanDateKey(d: Date): string {
return new Date(d.getTime() + OFFSET).toISOString().slice(0, 10);
}
/** Ключи следующих n дней, начиная с сегодня по Еревану */
export function nextDays(n: number, from = new Date()): string[] {
const out: string[] = [];
for (let i = 0; i < n; i++) out.push(yerevanDateKey(new Date(from.getTime() + i * 86_400_000)));
return out;
}
/** Полдень дня — чтобы форматировать дату без сдвига */
export const dateKeyToDate = (key: string) => new Date(`${key}T12:00:00+04:00`);
+58
View File
@@ -0,0 +1,58 @@
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import { createT, formatAmd, formatDateTime, isLocale, type Locale, type TFunction } from '@barev/i18n';
import { reportClientError } from './telegram.ts';
interface I18nValue {
locale: Locale;
t: TFunction;
setLocale: (l: Locale) => void;
amd: (n: number) => string;
dt: (d: Date, opts?: Intl.DateTimeFormatOptions) => string;
}
const Ctx = createContext<I18nValue | null>(null);
export function pickLocale(...candidates: (string | null | undefined)[]): Locale {
for (const c of candidates) {
const base = c?.split('-')[0]?.toLowerCase();
if (isLocale(base)) return base;
}
return 'ru';
}
export function I18nProvider({ initial, children }: { initial: Locale; children: ReactNode }) {
const [locale, setLocale] = useState<Locale>(initial);
useEffect(() => setLocale(initial), [initial]);
useEffect(() => {
document.documentElement.lang = locale;
}, [locale]);
const value = useMemo<I18nValue>(
() => ({
locale,
t: createT(locale),
setLocale,
amd: (n) => formatAmd(locale, n),
dt: (d, opts) => formatDateTime(locale, d, opts),
}),
[locale],
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useI18n(): I18nValue {
const v = useContext(Ctx);
if (!v) throw new Error('I18nProvider missing');
return v;
}
/** Текст ошибки API: errors.<код бизнес-ошибки> или общий */
export function errorText(t: TFunction, err: unknown): string {
reportClientError(err, 'errorText');
const e = err as { data?: { domainCode?: string | null; code?: string }; message?: string } | null;
const code = e?.data?.domainCode ?? e?.message;
if (code && /^[A-Z0-9_]+$/.test(code)) {
const s = t(`errors.${code}`);
if (s !== `errors.${code}`) return s;
}
return t('errors.generic');
}
+7
View File
@@ -0,0 +1,7 @@
export * from './telegram.ts';
export * from './router.tsx';
export * from './i18n.tsx';
export * from './trpc.tsx';
export * from './toast.tsx';
export * from './dates.ts';
export * from './params.ts';
+19
View File
@@ -0,0 +1,19 @@
import type { TFunction } from '@barev/i18n';
/** Краткое описание параметров заказа: «Генеральная · 2 комнаты · 2 санузла» */
export function paramsSummary(t: TFunction, serviceCode: string, params: Record<string, unknown>): string {
if (serviceCode !== 'cleaning') return '';
const parts = [t(`params.cleaning.cleaningType.${String(params.cleaningType)}`), t('params.cleaning.rooms.count', { count: Number(params.rooms ?? 1) })];
const baths = Number(params.bathrooms ?? 1);
if (baths > 1) parts.push(t('params.cleaning.bathrooms.count', { count: baths }));
return parts.join(' · ');
}
/** Выбранные доп. услуги: [{ code, qty, label: «Мытьё окон × 3» }] */
export function paramsExtras(t: TFunction, params: Record<string, unknown>) {
const extras = params.extras;
if (!extras || typeof extras !== 'object') return [];
return Object.entries(extras as Record<string, number>)
.filter(([, qty]) => qty > 0)
.map(([code, qty]) => ({ code, qty, label: `${t(`extras.${code}.name`)}${qty > 1 ? ` × ${qty}` : ''}` }));
}
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, useSyncExternalStore } from 'react';
import { tg } from './telegram.ts';
/**
* Минимальный hash-роутер: Mini App открывается по одному URL, а hash переживает
* перезагрузку WebView и не требует настройки сервера.
*/
function subscribe(cb: () => void) {
window.addEventListener('hashchange', cb);
return () => window.removeEventListener('hashchange', cb);
}
// Telegram открывает Mini App с #tgWebAppData=… — это не маршрут, а параметры запуска
const snapshot = () => {
const h = window.location.hash.replace(/^#/, '');
return h.startsWith('/') ? h : '/';
};
export function useLocation() {
const raw = useSyncExternalStore(subscribe, snapshot, () => '/');
const [path, search = ''] = raw.split('?');
return { path: path || '/', query: new URLSearchParams(search) };
}
export function navigate(to: string, opts: { replace?: boolean } = {}) {
if (opts.replace) window.location.replace(`#${to}`);
else window.location.hash = to;
}
/** Сопоставление '/orders/:id' → { id } */
export function match(pattern: string, path: string): Record<string, string> | null {
const p = pattern.split('/').filter(Boolean);
const a = path.split('/').filter(Boolean);
if (p.length !== a.length) return null;
const params: Record<string, string> = {};
for (let i = 0; i < p.length; i++) {
if (p[i]!.startsWith(':')) params[p[i]!.slice(1)] = decodeURIComponent(a[i]!);
else if (p[i] !== a[i]) return null;
}
return params;
}
/** Кнопка «Назад» Telegram на всех экранах, кроме корневых */
export function useTelegramBack(path: string, roots: string[]) {
useEffect(() => {
const back = tg()?.BackButton;
if (!back) return;
const onBack = () => window.history.back();
if (roots.includes(path)) back.hide();
else {
back.show();
back.onClick(onBack);
}
return () => back.offClick(onBack);
}, [path, roots]);
}
+168
View File
@@ -0,0 +1,168 @@
/**
* Telegram WebApp (скрипт telegram-web-app.js подключён в index.html).
* Вне Telegram (локальная разработка) initData берётся из #tgWebAppData=… — см. pnpm dev:initdata.
*/
interface TgWebApp {
initData: string;
initDataUnsafe: { user?: { id: number; first_name?: string; language_code?: string }; start_param?: string };
ready(): void;
expand(): void;
close(): void;
setHeaderColor?(color: string): void;
setBackgroundColor?(color: string): void;
BackButton: { show(): void; hide(): void; onClick(cb: () => void): void; offClick(cb: () => void): void };
HapticFeedback?: { impactOccurred(s: 'light' | 'medium' | 'heavy'): void; notificationOccurred(s: 'success' | 'error' | 'warning'): void };
showConfirm?(message: string, cb: (ok: boolean) => void): void;
openLink?(url: string): void;
openTelegramLink?(url: string): void;
showScanQrPopup?(params: { text?: string }, cb: (text: string) => boolean | void): void;
closeScanQrPopup?(): void;
platform?: string;
}
declare global {
interface Window {
Telegram?: { WebApp?: TgWebApp };
}
}
export const tg = (): TgWebApp | undefined => (typeof window !== 'undefined' ? window.Telegram?.WebApp : undefined);
/** Сканер QR Telegram (только внутри Telegram 6.4+); null — сканера нет, например в браузере */
export function scanQr(text: string): Promise<string> | null {
const app = tg();
if (!app?.showScanQrPopup || !app.initData) return null;
return new Promise((resolve) => {
app.showScanQrPopup!({ text }, (value) => {
resolve(value);
return true;
});
});
}
/** Ссылка t.me внутри Telegram открывает чат без выхода из Mini App; в браузере — новая вкладка */
export function openTelegram(url: string) {
const app = tg();
if (app?.openTelegramLink && app.initData) app.openTelegramLink(url);
else window.open(url, '_blank', 'noopener');
}
const DEV_KEY = 'barev.dev.initData';
/** initData для заголовка Authorization: tma … */
export function getInitData(): string {
const real = tg()?.initData;
if (real) {
// telegram-web-app.js уже прочитал параметры запуска — убираем их из адреса
if (window.location.hash.includes('tgWebAppData')) window.history.replaceState(null, '', window.location.pathname + window.location.search + '#/');
return real;
}
// разработка: #tgWebAppData=<urlencoded initData>, запоминаем на вкладку
const m = /tgWebAppData=([^&]+)/.exec(window.location.hash);
if (m) {
const data = decodeURIComponent(m[1]!);
try {
sessionStorage.setItem(DEV_KEY, data);
} catch {
/* приватный режим */
}
window.history.replaceState(null, '', window.location.pathname + '#/');
return data;
}
try {
return sessionStorage.getItem(DEV_KEY) ?? '';
} catch {
return '';
}
}
/** Маршрут из ?r=/orders/123 — так ведут кнопки в уведомлениях (hash занят параметрами Telegram) */
function applyStartRoute() {
const params = new URLSearchParams(window.location.search);
const r = params.get('r');
if (!r?.startsWith('/')) return;
params.delete('r');
const qs = params.size ? `?${params}` : '';
window.history.replaceState(null, '', `${window.location.pathname}${qs}#${r}`);
}
export function initTelegram(bg = '#FBF6EE') {
installErrorReporting();
getInitData();
applyStartRoute();
const app = tg();
if (!app) return;
app.ready();
app.expand();
app.setHeaderColor?.(bg);
app.setBackgroundColor?.(bg);
}
/** Язык пользователя Telegram — пока API не ответило */
export function telegramLanguage(): string | undefined {
const fromTg = tg()?.initDataUnsafe.user?.language_code;
if (fromTg) return fromTg;
try {
const user = new URLSearchParams(getInitData()).get('user');
return user ? (JSON.parse(user) as { language_code?: string }).language_code : undefined;
} catch {
return undefined;
}
}
/** Виброотклик. Не во всех клиентах Telegram он поддерживается — ошибки глушим, это не повод ломать сценарий */
function safe(fn: () => void) {
try {
fn();
} catch {
/* метод не поддерживается этой версией Telegram */
}
}
export const haptic = {
tap: () => safe(() => tg()?.HapticFeedback?.impactOccurred('light')),
ok: () => safe(() => tg()?.HapticFeedback?.notificationOccurred('success')),
error: () => safe(() => tg()?.HapticFeedback?.notificationOccurred('error')),
};
/** Подтверждение: нативное в Telegram, window.confirm в браузере */
export function confirmDialog(message: string): Promise<boolean> {
const app = tg();
if (app?.showConfirm && app.initData) {
try {
return new Promise((resolve) => app.showConfirm!(message, resolve));
} catch {
/* старая версия Telegram — обычный confirm */
}
}
return Promise.resolve(window.confirm(message));
}
/**
* Сообщить серверу об ошибке в браузере (в логе API: client error). Без персональных данных:
* текст ошибки, первые строки стека, адрес экрана и версия Telegram.
*/
export function reportClientError(err: unknown, where = 'unknown') {
try {
const e = err as { message?: string; stack?: string; data?: unknown };
if (e?.data) return; // ошибки API и так видны в логах сервера
const body = JSON.stringify({
where,
message: String(e?.message ?? err).slice(0, 300),
stack: String(e?.stack ?? '').split('\n').slice(0, 6).join('\n').slice(0, 1200),
path: window.location.hash.split('?')[0]?.slice(0, 80),
tg: tg()?.platform ? `${tg()!.platform} ${(tg() as unknown as { version?: string }).version ?? ''}` : 'browser',
});
navigator.sendBeacon?.('/client-log', new Blob([body], { type: 'application/json' }));
} catch {
/* не мешаем пользователю */
}
}
let installed = false;
/** Перехват необработанных ошибок страницы */
export function installErrorReporting() {
if (installed || typeof window === 'undefined') return;
installed = true;
window.addEventListener('error', (e) => reportClientError(e.error ?? e.message, 'window.onerror'));
window.addEventListener('unhandledrejection', (e) => reportClientError(e.reason, 'unhandledrejection'));
}
+23
View File
@@ -0,0 +1,23 @@
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react';
import { Toast } from '@barev/ui';
type Show = (message: string, kind?: 'info' | 'error') => void;
const Ctx = createContext<Show>(() => {});
export function ToastProvider({ children }: { children: ReactNode }) {
const [toast, setToast] = useState<{ message: string; kind: 'info' | 'error' } | null>(null);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
const show = useCallback<Show>((message, kind = 'info') => {
setToast({ message, kind });
clearTimeout(timer.current);
timer.current = setTimeout(() => setToast(null), 3200);
}, []);
return (
<Ctx.Provider value={show}>
{children}
{toast && <Toast message={toast.message} kind={toast.kind} />}
</Ctx.Provider>
);
}
export const useToast = () => useContext(Ctx);
+51
View File
@@ -0,0 +1,51 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import { createTRPCContext } from '@trpc/tanstack-react-query';
import { useState, type ReactNode } from 'react';
import superjson from 'superjson';
import type { AppRouter } from '@barev/api';
export const { TRPCProvider, useTRPC, useTRPCClient } = createTRPCContext<AppRouter>();
export type { AppRouter };
export function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 15_000,
refetchOnWindowFocus: true,
retry: (count, err) => {
const code = (err as { data?: { httpStatus?: number } }).data?.httpStatus ?? 0;
return code >= 500 && count < 2;
},
},
},
});
}
/**
* Провайдер tRPC + React Query. `auth` — заголовки для каждого запроса
* (initData Mini App) или credentials для cookie-сессии админки.
*/
export function ApiProvider({ url = '/trpc', headers, withCredentials, children }: { url?: string; headers?: () => Record<string, string>; withCredentials?: boolean; children: ReactNode }) {
const [queryClient] = useState(makeQueryClient);
const [client] = useState(() =>
createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url,
transformer: superjson,
headers: () => headers?.() ?? {},
fetch: withCredentials ? (input, init) => fetch(input, { ...init, credentials: 'include' }) : undefined,
}),
],
}),
);
return (
<QueryClientProvider client={queryClient}>
<TRPCProvider trpcClient={client} queryClient={queryClient}>
{children}
</TRPCProvider>
</QueryClientProvider>
);
}
+1
View File
@@ -0,0 +1 @@
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "lib": ["ES2023", "DOM"], "jsx": "react-jsx", "types": ["node"] }, "include": ["src"] }