Backoffice stage 5: products, broadcasts, activity log, settings

- Products: services with full setup — content (name, description, extras names
  in 5 languages via text overrides), price tiers and extras, booking window,
  late-cancel fee, on/off, checklist; category discounts by membership level;
  membership plan prices; promo codes; relocation steps and documents.
- Text overrides: TextOverride table on top of the i18n files; API, bots and the
  worker reload them every minute, mini apps fetch public.texts at start.
- Promo codes applied to orders (admin new-order form and client API): not
  stacked with membership discount — the larger wins; once per client; validity,
  limit and categories.
- Broadcasts to the client bot by any client filter, per-language texts with
  Russian fallback, test to yourself, ~25 msg/s, per-recipient status, resume after
  worker restart, history with progress and stop.
- Activity log (who/when/what, filters, details) and Settings page with all
  owner settings grouped, plus payment methods.
- Tests for promo codes and text overrides.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 03:17:56 +04:00
1 parent ac786cc5cc
commit 0edc6432b7
34 files changed
+2704 -26

No files matched your search

+1
View File
@@ -23,6 +23,7 @@
"@aws-sdk/s3-request-presigner": "^3.1152.0",
"@barev/core": "workspace:*",
"@barev/db": "workspace:*",
"@barev/i18n": "workspace:*",
"@barev/services": "workspace:*",
"@fastify/cookie": "^11.0.0",
"@fastify/cors": "^11.1.0",
+137
View File
@@ -0,0 +1,137 @@
/** Этап 5: рассылки клиентам, журнал действий, настройки */
import { DAY, SETTING_KEYS, settingsSchema } from '@barev/core';
import type { Prisma } from '@barev/db';
import { getSettings } from '@barev/services';
import { TelegramSender } from '@barev/services/telegram';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { env } from '../../env.ts';
import { rolesProcedure, router, superProcedure } from '../../trpc.ts';
import { clientFilters, filteredClients } from './clients.ts';
const LANGS = ['ru', 'en', 'zh', 'hy', 'es'] as const;
const texts = z.object({ ru: z.string().trim().min(1).max(3500), en: z.string().max(3500), zh: z.string().max(3500), hy: z.string().max(3500), es: z.string().max(3500) });
const escapeHtml = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
/** Рассылки делают поддержка и суперадмин */
const bc = rolesProcedure('SUPPORT');
/** Получатели: клиенты по фильтрам, только давшие согласие (иначе бот им не писал) */
async function recipients(db: import('@barev/db').Db, filters: z.infer<typeof clientFilters>, now: Date) {
const { list } = await filteredClients(db, filters, now);
return list.filter((c) => !!c.consentAt).map((c) => ({ id: c.id, locale: c.user.locale }));
}
export const boBroadcastsRouter = router({
/** Сколько клиентов получит и на каких языках */
preview: bc.input(clientFilters).query(async ({ ctx, input }) => {
const list = await recipients(ctx.db, input, ctx.svc.now());
const byLocale = Object.fromEntries(LANGS.map((l) => [l, list.filter((c) => c.locale === l).length])) as Record<(typeof LANGS)[number], number>;
return { total: list.length, byLocale };
}),
/** Проверка: прислать себе в клиентский бот (нужно, чтобы вы хоть раз открыли его) */
test: bc.input(z.object({ text: z.string().trim().min(1).max(3500), appButton: z.boolean() })).mutation(async ({ ctx, input }) => {
if (!ctx.admin.telegramId) throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'TELEGRAM_NOT_LINKED' });
const bot = new TelegramSender(env.CLIENT_BOT_TOKEN, 'client-bot');
const app = env.CLIENT_MINIAPP_URL;
const ok = await bot.send({
chatId: ctx.admin.telegramId,
text: escapeHtml(input.text),
buttons: input.appButton && app.startsWith('https://') ? [[{ text: 'Barev Club', web_app: { url: app } }]] : [],
});
if (!ok) throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'TEST_UNREACHABLE' });
}),
/** Запустить рассылку: фиксируем получателей, воркер отправляет с ограничением скорости Telegram */
send: bc.input(z.object({ filters: clientFilters, texts, appButton: z.boolean() })).mutation(async ({ ctx, input }) => {
const list = await recipients(ctx.db, input.filters, ctx.svc.now());
if (!list.length) throw new TRPCError({ code: 'BAD_REQUEST', message: 'NO_RECIPIENTS' });
const b = await ctx.db.broadcast.create({
data: {
texts: input.texts,
appButton: input.appButton,
filters: input.filters as Prisma.InputJsonValue,
total: list.length,
createdById: ctx.admin.id,
recipients: { createMany: { data: list.map((c) => ({ clientId: c.id })) } },
},
});
await ctx.svc.notify.emit({ type: 'broadcast.start', broadcastId: b.id });
return { id: b.id, total: list.length };
}),
list: bc.query(async ({ ctx }) => {
const rows = await ctx.db.broadcast.findMany({ orderBy: { createdAt: 'desc' }, take: 50 });
const admins = new Map((await ctx.db.adminUser.findMany({ where: { id: { in: rows.map((r) => r.createdById).filter((x): x is string => !!x) } }, select: { id: true, name: true } })).map((a) => [a.id, a.name]));
return rows.map((r) => ({
id: r.id,
text: (r.texts as Record<string, string>).ru ?? '',
status: r.status,
total: r.total,
sent: r.sent,
blocked: r.blocked,
failed: r.failed,
by: r.createdById ? (admins.get(r.createdById) ?? null) : null,
createdAt: r.createdAt,
finishedAt: r.finishedAt,
}));
}),
cancel: bc.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await ctx.db.broadcast.updateMany({ where: { id: input.id, status: 'SENDING' }, data: { status: 'CANCELLED', finishedAt: ctx.svc.now() } });
}),
});
// ───── журнал действий ─────
export const boJournalRouter = router({
list: superProcedure
.input(z.object({ adminUserId: z.string().optional(), entity: z.string().max(40).optional(), days: z.number().int().min(1).max(365).default(30), cursor: z.string().optional() }))
.query(async ({ ctx, input }) => {
const since = new Date(ctx.svc.now().getTime() - input.days * DAY);
const rows = await ctx.db.auditLog.findMany({
where: { createdAt: { gte: since }, ...(input.adminUserId ? { adminUserId: input.adminUserId } : {}), ...(input.entity ? { entity: input.entity } : {}) },
orderBy: { createdAt: 'desc' },
take: 101,
...(input.cursor ? { cursor: { id: input.cursor }, skip: 1 } : {}),
include: { adminUser: { select: { id: true, name: true } } },
});
const page = rows.slice(0, 100);
return {
items: page.map((r) => ({ id: r.id, at: r.createdAt, by: r.adminUser, action: r.action, entity: r.entity, entityId: r.entityId, data: r.data as Record<string, unknown> | null })),
next: rows.length > 100 ? page.at(-1)!.id : null,
};
}),
facets: superProcedure.query(async ({ ctx }) => {
const [staff, entities] = await Promise.all([
ctx.db.adminUser.findMany({ orderBy: { name: 'asc' }, select: { id: true, name: true } }),
ctx.db.auditLog.groupBy({ by: ['entity'], _count: true, orderBy: { _count: { entity: 'desc' } } }),
]);
return { staff, entities: entities.map((e) => e.entity) };
}),
});
// ───── настройки ─────
export const boSettingsRouter = router({
get: superProcedure.query(({ ctx }) => getSettings(ctx.db)),
save: superProcedure.input(settingsSchema.partial()).mutation(async ({ ctx, input }) => {
for (const key of SETTING_KEYS) {
const value = input[key];
if (value === undefined) continue;
await ctx.db.setting.upsert({ where: { key }, create: { key, value: value as never }, update: { value: value as never } });
}
}),
paymentMethods: superProcedure.query(({ ctx }) => ctx.db.paymentMethod.findMany({ orderBy: { sortOrder: 'asc' } })),
savePaymentMethod: superProcedure
.input(z.object({ id: z.string().optional(), code: z.string().trim().min(1).max(40), name: texts, active: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
if (id) await ctx.db.paymentMethod.update({ where: { id }, data });
else {
const last = await ctx.db.paymentMethod.findFirst({ orderBy: { sortOrder: 'desc' } });
await ctx.db.paymentMethod.create({ data: { ...data, sortOrder: (last?.sortOrder ?? 0) + 1 } });
}
}),
});
+1 -1
View File
@@ -110,7 +110,7 @@ function baseWhere(f: ClientFilters): Prisma.ClientWhereInput {
return and.length ? { AND: and } : {};
}
async function filteredClients(db: Db, f: ClientFilters, now: Date) {
export async function filteredClients(db: Db, f: ClientFilters, now: Date) {
const rows = await db.client.findMany({ where: baseWhere(f), include: clientInclude, orderBy: { createdAt: 'desc' }, take: 5000 });
const money = await revenueByClient(
db,
+6
View File
@@ -7,6 +7,8 @@ import { boOrdersRouter } from './orders.ts';
import { boProvidersRouter } from './providers.ts';
import { boCrmRouter } from './crm.ts';
import { boPartnersRouter } from './partners.ts';
import { boProductsRouter } from './products.ts';
import { boBroadcastsRouter, boJournalRouter, boSettingsRouter } from './admin5.ts';
/** API новой админки (apps/backoffice) */
export const backofficeRouter = router({
@@ -20,4 +22,8 @@ export const backofficeRouter = router({
providers: boProvidersRouter,
crm: boCrmRouter,
partners: boPartnersRouter,
products: boProductsRouter,
broadcasts: boBroadcastsRouter,
journal: boJournalRouter,
settings: boSettingsRouter,
});
+5 -3
View File
@@ -337,10 +337,10 @@ export const boOrdersRouter = router({
/** Цена и длительность для клиента (с его скидкой по подписке) */
quote: ordersProcedure
.input(z.object({ clientId: z.string(), service: z.string(), params: z.record(z.string(), z.unknown()) }))
.input(z.object({ clientId: z.string(), service: z.string(), params: z.record(z.string(), z.unknown()), promoCode: z.string().trim().max(40).optional() }))
.query(async ({ ctx, input }) => {
const { quote: q, membership } = await quoteForClient(ctx.svc, input.clientId, input.service, input.params);
return { durationMinutes: q.durationMinutes, total: q.totalPrice, base: q.basePrice, discountPct: q.discountPct, level: membership.level };
const { quote: q, membership, promo } = await quoteForClient(ctx.svc, input.clientId, input.service, input.params, input.promoCode);
return { durationMinutes: q.durationMinutes, total: q.totalPrice, base: q.basePrice, discountPct: q.discountPct, level: membership.level, promo: promo ? promo.code : null };
}),
slots: ordersProcedure
@@ -359,10 +359,12 @@ export const boOrdersRouter = router({
note: z.string().trim().max(1000).optional(),
paymentMethodId: z.string().optional(),
allowBusySlot: z.boolean().default(false),
promoCode: z.string().trim().max(40).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const o = await createOrder(ctx.svc, {
promoCode: input.promoCode,
clientId: input.clientId,
serviceCode: input.service,
params: input.params,
+160
View File
@@ -0,0 +1,160 @@
import { randomBytes } from 'node:crypto';
import { CATEGORY_CODES, DAY, fromPrice, paramsSchemaSchema, pricingSchema, slotConfigSchema } from '@barev/core';
import { defaultText, LOCALES, type Locale } from '@barev/i18n';
import { isEditableTextKey, listRelocationItems, loadTextOverrides, moveRelocationItem, normalizePromo, saveRelocationItem } from '@barev/services';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { rolesProcedure, router, superProcedure } from '../../trpc.ts';
/** Продукты видят поддержка и суперадмин; меняет — суперадмин (цены и скидки — деньги) */
const view = rolesProcedure('SUPPORT');
const edit = superProcedure;
const localized = z.object({ ru: z.string().max(500), en: z.string().max(500), zh: z.string().max(500), hy: z.string().max(500), es: z.string().max(500) });
/** Текст по ключу на 5 языках: стандартный (из переводов) и изменённый в админке */
async function textsFor(db: import('@barev/db').Db, keys: string[]) {
const rows = await db.textOverride.findMany({ where: { key: { in: keys } } });
const out: Record<string, Record<Locale, { default: string | null; value: string | null }>> = {};
for (const key of keys) {
out[key] = Object.fromEntries(LOCALES.map((l) => [l, { default: defaultText(l, key), value: rows.find((r) => r.key === key && r.locale === l)?.value ?? null }])) as Record<Locale, { default: string | null; value: string | null }>;
}
return out;
}
export const boProductsRouter = router({
/** Направления (скидки по уровням) и услуги со всеми настройками */
catalog: view.query(async ({ ctx }) => {
const since = new Date(ctx.svc.now().getTime() - 30 * DAY);
const [categories, orders30] = await Promise.all([
ctx.db.category.findMany({ orderBy: { sortOrder: 'asc' }, include: { services: { orderBy: { sortOrder: 'asc' }, include: { checklist: { orderBy: { sortOrder: 'asc' } } } } } }),
ctx.db.order.groupBy({ by: ['serviceId'], where: { createdAt: { gte: since }, status: { not: 'CANCELLED' } }, _count: true }),
]);
const count = new Map(orders30.map((o) => [o.serviceId, o._count]));
return categories.map((c) => ({
id: c.id,
code: c.code,
barevPlusDiscountPct: c.barevPlusDiscountPct,
eliteDiscountPct: c.eliteDiscountPct,
services: c.services.map((s) => {
const pricing = pricingSchema.parse(s.pricing);
return {
id: s.id,
code: s.code,
flow: s.flow,
providerRole: s.providerRole,
active: s.active,
isOnline: s.isOnline,
lateCancelFeePct: s.lateCancelFeePct,
pricing,
paramsSchema: paramsSchemaSchema.parse(s.paramsSchema),
slotConfig: slotConfigSchema.parse(s.slotConfig),
fromPrice: fromPrice(pricing),
orders30: count.get(s.id) ?? 0,
checklist: s.checklist.map((x) => ({ code: x.code, requiresPhoto: x.requiresPhoto })),
};
}),
}));
}),
/** Тексты для формы: название/описание услуги, доп. услуги, пункты чек-листа */
texts: view.input(z.object({ keys: z.array(z.string().max(120)).max(200) })).query(({ ctx, input }) => textsFor(ctx.db, input.keys.filter(isEditableTextKey))),
/** Сохранить тексты; пустое значение — вернуть стандартный перевод */
saveTexts: edit.input(z.array(z.object({ key: z.string().max(120), locale: z.enum(LOCALES), value: z.string().max(2000) })).max(400)).mutation(async ({ ctx, input }) => {
for (const r of input) {
if (!isEditableTextKey(r.key)) throw new TRPCError({ code: 'BAD_REQUEST', message: 'TEXT_KEY_FORBIDDEN' });
const value = r.value.trim();
const same = value === (defaultText(r.locale, r.key) ?? '');
if (!value || same) await ctx.db.textOverride.deleteMany({ where: { key: r.key, locale: r.locale } });
else await ctx.db.textOverride.upsert({ where: { key_locale: { key: r.key, locale: r.locale } }, create: { key: r.key, locale: r.locale, value, updatedById: ctx.admin.id }, update: { value, updatedById: ctx.admin.id } });
}
// этот процесс видит правки сразу; боты и воркер — в течение минуты
await loadTextOverrides(ctx.db);
}),
saveService: edit
.input(z.object({ id: z.string(), active: z.boolean(), lateCancelFeePct: z.number().int().min(0).max(100), pricing: pricingSchema, slotConfig: slotConfigSchema }))
.mutation(async ({ ctx, input }) => {
if (input.slotConfig.dayEndMinute <= input.slotConfig.dayStartMinute) throw new TRPCError({ code: 'BAD_REQUEST', message: 'SLOT_WINDOW_INVALID' });
const { id, ...data } = input;
await ctx.db.service.update({ where: { id }, data });
}),
saveCategory: edit
.input(z.object({ id: z.string(), barevPlusDiscountPct: z.number().int().min(0).max(100), eliteDiscountPct: z.number().int().min(0).max(100) }))
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
await ctx.db.category.update({ where: { id }, data });
}),
/** Чек-лист услуги: новые заказы получают новый список, уже созданные — без изменений */
saveChecklist: edit
.input(z.object({ serviceId: z.string(), items: z.array(z.object({ code: z.string().max(60).optional(), label: z.string().max(120).optional(), requiresPhoto: z.boolean() })).max(30) }))
.mutation(async ({ ctx, input }) => {
const items = input.items.map((x) => ({ ...x, code: x.code || `c_${randomBytes(3).toString('hex')}` }));
await ctx.db.$transaction([
ctx.db.checklistItem.deleteMany({ where: { serviceId: input.serviceId } }),
ctx.db.checklistItem.createMany({ data: items.map((x, i) => ({ serviceId: input.serviceId, code: x.code, requiresPhoto: x.requiresPhoto, sortOrder: i })) }),
]);
// подпись нового пункта — русский текст (переводы можно добавить в «Текстах»)
for (const x of items) {
if (x.label?.trim() && !defaultText('ru', `checklist.${x.code}`)) {
await ctx.db.textOverride.upsert({ where: { key_locale: { key: `checklist.${x.code}`, locale: 'ru' } }, create: { key: `checklist.${x.code}`, locale: 'ru', value: x.label.trim(), updatedById: ctx.admin.id }, update: { value: x.label.trim() } });
}
}
await loadTextOverrides(ctx.db);
}),
// ───── промокоды ─────
promos: view.query(async ({ ctx }) => {
const list = await ctx.db.promoCode.findMany({ where: { kind: 'DISCOUNT' }, orderBy: [{ active: 'desc' }, { createdAt: 'desc' }] });
const now = ctx.svc.now();
return list.map((p) => ({
id: p.id,
code: p.code,
discountPct: p.discountPct ?? 0,
maxUses: p.maxUses,
usedCount: p.usedCount,
validFrom: p.validFrom,
validUntil: p.validUntil,
categories: p.categories,
note: p.note,
active: p.active,
state: !p.active ? 'off' : p.validUntil && p.validUntil < now ? 'expired' : p.maxUses != null && p.usedCount >= p.maxUses ? 'exhausted' : p.validFrom && p.validFrom > now ? 'scheduled' : 'live',
}));
}),
savePromo: edit
.input(
z.object({
id: z.string().optional(),
code: z.string().min(3).max(40),
discountPct: z.number().int().min(1).max(100),
maxUses: z.number().int().min(1).nullable(),
validFrom: z.date().nullable(),
validUntil: z.date().nullable(),
categories: z.array(z.enum(CATEGORY_CODES)),
note: z.string().max(300).nullable(),
active: z.boolean(),
}),
)
.mutation(async ({ ctx, input }) => {
const code = normalizePromo(input.code);
if (!/^[A-Z0-9_-]{3,40}$/.test(code)) throw new TRPCError({ code: 'BAD_REQUEST', message: 'PROMO_CODE_FORMAT' });
const clash = await ctx.db.promoCode.findUnique({ where: { code } });
if (clash && clash.id !== input.id) throw new TRPCError({ code: 'BAD_REQUEST', message: 'PROMO_CODE_TAKEN' });
const { id, ...rest } = input;
const data = { ...rest, code, kind: 'DISCOUNT' as const };
if (id) await ctx.db.promoCode.update({ where: { id }, data });
else await ctx.db.promoCode.create({ data });
}),
// ───── релокация: общий список шагов и документов ─────
relocation: view.query(({ ctx }) => listRelocationItems(ctx.svc)),
saveRelocation: edit
.input(z.object({ id: z.string().optional(), kind: z.enum(['STEP', 'DOC']), name: localized, hint: localized.nullable().optional(), active: z.boolean(), inTurnkey: z.boolean().optional() }))
.mutation(({ ctx, input }) => saveRelocationItem(ctx.svc, input).then((x) => ({ id: x.id }))),
moveRelocation: edit.input(z.object({ id: z.string(), dir: z.union([z.literal(-1), z.literal(1)]) })).mutation(({ ctx, input }) => moveRelocationItem(ctx.svc, input)),
});
+7 -2
View File
@@ -182,8 +182,11 @@ export const clientRouter = router({
/** ORD-4 */
quote: clientProcedure
.input(z.object({ serviceCode: z.string(), params: paramsInput }))
.query(async ({ ctx, input }) => (await quoteForClient(ctx.svc, ctx.client.id, input.serviceCode, input.params)).quote),
.input(z.object({ serviceCode: z.string(), params: paramsInput, promoCode: z.string().trim().max(40).optional() }))
.query(async ({ ctx, input }) => {
const r = await quoteForClient(ctx.svc, ctx.client.id, input.serviceCode, input.params, input.promoCode);
return { ...r.quote, promo: r.promo ? { code: r.promo.code, discountPct: r.promo.discountPct } : null };
}),
/** ORD-3 */
slots: clientProcedure
@@ -207,6 +210,8 @@ export const clientRouter = router({
note: z.string().max(1000).optional(),
paymentMethodId: z.string().optional(),
repeatOfId: z.string().optional(),
/** промокод: применится, если выгоднее скидки подписки */
promoCode: z.string().trim().max(40).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
+3
View File
@@ -9,6 +9,9 @@ import { publicProcedure, router } from '../trpc.ts';
export const publicRouter = router({
/** LND-5: цены «от» и скидки — для лендинга */
/** Тексты, изменённые в админке, — Mini App подставляет их поверх своих переводов */
texts: publicProcedure.query(({ ctx }) => ctx.db.textOverride.findMany({ select: { key: true, locale: true, value: true } })),
catalog: publicProcedure.query(async ({ ctx }) => {
const [categories, settings] = await Promise.all([listCatalog(ctx.db), getSettings(ctx.db)]);
return {
+4 -1
View File
@@ -4,7 +4,7 @@ import multipart, { type MultipartFile } from '@fastify/multipart';
import * as Sentry from '@sentry/node';
import { fastifyTRPCPlugin, type FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
import { getFlow } from '@barev/core';
import { DomainError, getClientByTelegramId, proIdentity, receiveMessage, setChecklistItem, staffSend } from '@barev/services';
import { DomainError, getClientByTelegramId, proIdentity, receiveMessage, setChecklistItem, staffSend, startTextOverrides } from '@barev/services';
import { getFile, putFile } from '@barev/services/storage';
import { TelegramSender } from '@barev/services/telegram';
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
@@ -278,6 +278,9 @@ app.post('/client-log', { bodyLimit: 4096 }, async (req, reply) => {
app.get('/health', async () => ({ ok: true }));
// тексты из админки поверх переводов (обновляются раз в минуту)
startTextOverrides(db);
await ensureBucket().catch((e) => app.log.warn({ err: e }, 'S3 недоступно — загрузка фото работать не будет'));
await app.listen({ port: env.API_PORT, host: '0.0.0.0' });
console.log(`API: http://localhost:${env.API_PORT}`);
+13 -2
View File
@@ -19,6 +19,8 @@ import { Crm } from './pages/Crm.tsx';
import { LeadCard } from './pages/LeadCard.tsx';
import { AgencyCard, Agencies } from './pages/Agencies.tsx';
import { VenueCard, Venues } from './pages/Venues.tsx';
import { Products, ServiceEditor } from './pages/Products.tsx';
import { Broadcasts, Journal, Settings } from './pages/Admin5.tsx';
export type Role = 'SUPERADMIN' | 'SUPPORT' | 'CONCIERGE' | 'SALES';
export interface Me {
@@ -57,9 +59,10 @@ const NAV: NavItem[] = [
{ path: '/agencies', key: 'nav.agencies', icon: 'agencies', roles: [], group: 'partners' },
{ path: '/venues', key: 'nav.venues', icon: 'venues', roles: [], group: 'partners' },
{ path: '/crm', key: 'nav.crm', icon: 'crm', roles: ['SALES'], group: 'partners' },
{ path: '/products', key: 'nav.products', icon: 'products', stage: 5, roles: [], group: 'setup' },
{ path: '/journal', key: 'nav.journal', icon: 'journal', stage: 5, roles: [], group: 'setup' },
{ path: '/products', key: 'nav.products', icon: 'products', roles: ['SUPPORT'], group: 'setup' },
{ path: '/journal', key: 'nav.journal', icon: 'journal', roles: [], group: 'setup' },
{ path: '/team', key: 'nav.team', icon: 'team', roles: [], group: 'setup' },
{ path: '/settings', key: 'nav.settings', icon: 'gear', roles: [], group: 'setup' },
];
const GROUP_KEY = { work: 'nav.groupWork', partners: 'nav.groupPartners', setup: 'nav.groupSetup' } as const;
@@ -89,6 +92,9 @@ function Shell({ me }: { me: Me }) {
const agencyId = match('/agencies/:id', path)?.id;
const venueId = match('/venues/:id', path)?.id;
const canCrm = items.some((n) => n.path === '/crm');
const productId = match('/products/:id', path)?.id;
const canProducts = items.some((n) => n.path === '/products');
const canBroadcast = isSuper || me.roles.includes('SUPPORT');
if (path === '/') page = <Dashboard me={me} />;
else if ((path === '/chats' || threadId) && canChat) page = <Chats me={me} threadId={threadId} />;
else if (path === '/orders' && canOrders) page = <Orders me={me} />;
@@ -101,6 +107,11 @@ function Shell({ me }: { me: Me }) {
else if (agencyId && isSuper) page = <AgencyCard id={agencyId} />;
else if (path === '/venues' && isSuper) page = <Venues />;
else if (venueId && isSuper) page = <VenueCard id={venueId} />;
else if (path === '/products' && canProducts) page = <Products me={me} />;
else if (productId && canProducts) page = <ServiceEditor id={productId} me={me} />;
else if (path === '/broadcasts' && canBroadcast) page = <Broadcasts />;
else if (path === '/journal' && isSuper) page = <Journal />;
else if (path === '/settings' && isSuper) page = <Settings />;
else if (path === '/clients' && current) page = <Clients me={me} />;
else if (clientId && items.some((n) => n.path === '/clients')) page = <ClientCard id={clientId} me={me} />;
else if (path === '/team' && isSuper) page = <Team me={me} />;
+118
View File
@@ -0,0 +1,118 @@
/* Этап 5: продукты, рассылки, журнал, настройки */
/* текст на 5 языках */
.bo-l5 {
display: grid;
gap: 6px;
}
.bo-l5__row {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
align-items: start;
gap: 8px;
}
.bo-l5__lang {
padding-top: 11px;
font-family: var(--bc-font-head);
font-size: 12px;
font-weight: 700;
color: var(--bc-ink-3);
}
.bo-l5 .bo-input::placeholder,
.bo-l5 .bo-textarea::placeholder {
color: var(--bc-ink-3);
}
/* таблица цен */
.bo-tablewrap {
overflow-x: auto;
margin: 0 -6px;
}
.bo-ptab th {
white-space: nowrap;
font-size: 12px;
}
.bo-ptab td {
padding: 4px 6px;
}
.bo-ptab .bo-input {
min-width: 78px;
min-height: 36px;
padding: 4px 10px;
text-align: right;
}
.bo-promo {
font-family: var(--bc-font-head);
font-weight: 700;
letter-spacing: 0.04em;
padding: 4px 10px;
border-radius: 10px;
background: var(--bc-perks);
color: var(--bc-perks-ink);
}
/* рассылка */
.bo-bcwho {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 16px;
background: var(--bc-blue);
color: var(--bc-blue-ink);
}
/* журнал */
.bo-journal {
list-style: none;
margin: 0;
padding: 0;
}
.bo-journal li {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
border-top: 1px solid var(--bc-line);
font-size: 14px;
}
.bo-journal li:first-child {
border-top: 0;
}
.bo-journal .bo-feed__time {
width: 110px;
flex: none;
padding-top: 4px;
}
.bo-json {
margin: 8px 0 0;
padding: 10px 12px;
border-radius: 12px;
background: var(--bo-soft);
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
max-height: 240px;
overflow: auto;
}
/* настройки */
.bo-settings {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 14px;
align-items: start;
}
.bo-settings .bo-input[data-changed] {
border-color: var(--bc-accent);
background: #fff6f9;
}
@media (max-width: 860px) {
.bo-journal .bo-feed__time {
width: 80px;
}
.bo-settings {
grid-template-columns: minmax(0, 1fr);
}
}
+1
View File
@@ -37,6 +37,7 @@ const PATHS = {
archive: 'M4 5h16v4H4zM5 9v10h14V9M10 13h4',
bot: 'M6 9h12v10H6zM12 5v4M9.5 13.5v.01M14.5 13.5v.01M9 17h6M3 13v2M21 13v2',
warn: 'M12 3l10 18H2zM12 10v5M12 18v.01',
gear: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z',
phone: 'M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2z',
} as const;
export type IconName = keyof typeof PATHS;
+567
View File
@@ -386,6 +386,195 @@ const ru = {
'chat.notLinked': 'Рабочий бот не открыт',
'chat.details': 'Подробнее',
'chat.more': 'Ещё действия',
'audit.bo.crm.addTask': 'поставил(а) задачу',
'audit.bo.crm.setTaskDone': 'отметил(а) задачу',
'audit.bo.crm.removeTask': 'удалил(а) задачу',
'audit.bo.crm.remove': 'удалил(а) лид',
'audit.bo.segments.save': 'сохранил(а) сегмент клиентов',
'audit.bo.segments.remove': 'удалил(а) сегмент клиентов',
'audit.bo.team.linkUrl': 'выпустил(а) ссылку для привязки Telegram',
'audit.bo.auth.telegramLink': 'привязывает свой Telegram',
'audit.bo.auth.logout': 'вышел(а) из админки',
'audit.bo.clients.exportCsv': 'выгрузил(а) клиентов в Excel',
// ───── продукты, рассылки, журнал, настройки (этап 5) ─────
'nav.settings': 'Настройки',
'pr.title': 'Продукты',
'pr.tab.services': 'Услуги и цены',
'pr.tab.plans': 'Подписка и скидки',
'pr.tab.promos': 'Промокоды',
'pr.tab.relocation': 'Релокация',
'pr.readOnly': 'Менять цены и условия может суперадмин — здесь всё видно для справки.',
'pr.catDiscounts': 'скидки: Barev+ −{plus}%, Elite −{elite}%',
'pr.on': 'включена',
'pr.off': 'выключена',
'pr.from': 'от {sum}',
'pr.orders30': 'заказов за 30 дн: {n}',
'pr.setup': 'Настроить',
'pr.back': 'Все услуги',
'pr.content': 'Наполнение',
'pr.contentHint': 'Так услуга называется в приложении, боте и уведомлениях. Пустое поле — стандартный текст (виден серым). Боты подхватывают правку в течение минуты.',
'pr.name': 'Название',
'pr.description': 'Описание',
'pr.extrasNames': 'Названия доп. услуг ({n})',
'pr.price': 'Цена и длительность',
'pr.fixedPrice': 'Цена, ֏',
'pr.minutes': 'Длительность, мин',
'pr.base': 'База, ֏',
'pr.perRoom': '+ комната, ֏',
'pr.perBath': '+ санузел, ֏',
'pr.baseMin': 'База, мин',
'pr.perRoomMin': '+ комната, мин',
'pr.perBathMin': '+ санузел, мин',
'pr.roomsHint': 'База — за 1 комнату и 1 санузел; дальше доплата за каждую комнату и санузел сверх первого.',
'pr.extras': 'Доп. услуги',
'pr.unitCol': 'Единица',
'pr.extraPrice': 'Цена, ֏',
'pr.extraMin': '+ мин',
'pr.unit.item': 'за штуку',
'pr.unit.service': 'услуга',
'pr.unit.hour': 'за час',
'pr.unit.halfHour': 'за 30 мин',
'pr.priceHint': 'Новые цены действуют для новых заказов; уже оформленные заказы не меняются. Скидка подписки считается от этих цен.',
'pr.settings': 'Запись и отмена',
'pr.activeLabel': 'Услуга доступна клиентам',
'pr.windowFrom': 'Запись с',
'pr.windowTo': 'до',
'pr.step': 'Шаг слотов, мин',
'pr.lead': 'Не раньше чем через, ч',
'pr.horizon': 'Запись на дней вперёд',
'pr.lateFee': 'Удержание за позднюю отмену, %',
'pr.discounts': 'Скидки по подписке',
'pr.discountsHint': 'Действуют на всё направление «{cat}».',
'pr.checklist': 'Чек-лист исполнителя',
'pr.photo': 'фото',
'pr.newItem': 'Новый пункт',
'pr.checklistHint': 'Исполнитель отмечает пункты в рабочем боте; с «фото» — только с фотоотчётом. Меняется для новых заказов.',
'pr.plans': 'Тарифы подписки',
'pr.plansSuper': 'Цены подписки видит и меняет суперадмин.',
'pr.plansHint': '«Скидка до» показывается на сайте и в приложении; реальные скидки — в таблице справа и в услугах.',
'pr.discountTable': 'Скидки по направлениям',
'pr.discountTableHint': 'Меняются в карточке любой услуги направления.',
'pr.promoHint': 'Промокод даёт скидку на заказ. Со скидкой подписки не складывается — действует большая. Один код — один раз на клиента.',
'pr.newPromo': 'Новый промокод',
'pr.promo': 'Промокод',
'pr.noPromos': 'Промокодов пока нет.',
'pr.used': 'использован {n} из {max}',
'pr.until': 'до {date}',
'pr.state.live': 'действует',
'pr.state.off': 'выключен',
'pr.state.expired': 'истёк',
'pr.state.exhausted': 'исчерпан',
'pr.state.scheduled': 'ещё не начался',
'pr.code': 'Код',
'pr.pct': 'Скидка, %',
'pr.validFrom': 'Действует с',
'pr.validUntil': 'по',
'pr.maxUses': 'Сколько раз всего',
'pr.maxUsesHint': 'Пусто — без ограничения',
'pr.forCats': 'На какие направления',
'pr.forCatsHint': 'Ничего не выбрано — на все',
'pr.promoNotePh': 'Откуда код: рассылка, блогер, партнёр',
'pr.promoActive': 'Промокод действует',
'pr.rel.STEP': 'Шаги переезда',
'pr.rel.DOC': 'Документы для переезда',
'pr.relHint.STEP': 'Новые клиенты получают план из этого списка; у текущих шаги добавляются вручную в старой админке.',
'pr.relHint.DOC': 'Чек-лист документов в приложении клиента.',
'pr.turnkey': 'под ключ',
'pr.turnkeyLabel': 'Входит в пакет «под ключ»',
'pr.docHint': 'Подсказка под названием',
'pr.relActive': 'Показывать клиентам',
'no.promo': 'Промокод',
'no.promoPh': 'Если клиент назвал код',
'no.promoOk': 'Код {code} применён',
'no.promoWeaker': 'Скидка по подписке больше — код не нужен',
'bc.title': 'Рассылка в бот',
'bc.recipients': 'Получат: {n}',
'bc.filtersHint': 'Получатели — клиенты из списка с текущими фильтрами, которые дали согласие в боте.',
'bc.textRu': 'Текст (по-русски)',
'bc.textPh': 'Например: «С 1 ноября — новые районы для уборки…»',
'bc.otherLangs': 'Переводы для других языков',
'bc.clientsN': 'клиентов: {n}',
'bc.fallback': 'Для {langs} перевода нет — эти клиенты получат текст по-русски.',
'bc.appButton': 'Кнопка «Открыть приложение» под сообщением',
'bc.test': 'Прислать себе',
'bc.testSent': 'Отправили вам в клиентский бот',
'bc.send': 'Отправить {n}',
'bc.confirm': 'Точно отправить {n}?',
'bc.sureHint': 'Отменить отправленное нельзя. Нажмите ещё раз, чтобы запустить.',
'bc.started': 'Рассылка запущена: {n} получателей',
'bc.history': 'Рассылки',
'bc.historyBtn': 'История рассылок',
'bc.historyHint': 'Отправляем примерно 25 сообщений в секунду — так Telegram не блокирует бота.',
'bc.none': 'Рассылок ещё не было. Запустить можно из списка клиентов — по любым фильтрам.',
'bc.status.SENDING': 'идёт',
'bc.status.DONE': 'отправлено',
'bc.status.CANCELLED': 'остановлено',
'bc.sent': 'доставлено {n} из {total}',
'bc.blocked': 'заблокировали бота: {n}',
'bc.failed': 'ошибок: {n}',
'bc.cancel': 'Остановить',
'bc.cancelConfirm': 'Остановить рассылку? Оставшиеся клиенты сообщение не получат.',
'jr.title': 'Журнал действий',
'jr.who': 'Кто',
'jr.what': 'Раздел',
'jr.everyone': 'Все сотрудники',
'jr.allSections': 'Все разделы',
'jr.days': '{n} дн',
'jr.empty': 'За этот период действий нет.',
'jr.system': 'Система',
'jr.open': 'открыть',
'jr.details': 'подробности',
'jr.hide': 'скрыть',
'jr.more': 'Показать ещё',
'jr.entity.clients': 'Клиенты',
'jr.entity.orders': 'Заказы',
'jr.entity.providers': 'Исполнители',
'jr.entity.chats': 'Чаты',
'jr.entity.crm': 'CRM',
'jr.entity.partners': 'Партнёры',
'jr.entity.products': 'Продукты',
'jr.entity.broadcasts': 'Рассылки',
'jr.entity.settings': 'Настройки',
'jr.entity.team': 'Команда',
'jr.entity.segments': 'Сегменты',
'jr.entity.auth': 'Вход',
'st.title': 'Настройки',
'st.saveN': 'Сохранить ({n})',
'st.group.orders': 'Заказы',
'st.group.subs': 'Подписка',
'st.group.agencies': 'Агентства',
'st.group.sales': 'Бонус продажников',
'st.group.site': 'Цены на сайте',
'st.group.other': 'Прочее',
'st.payments': 'Способы оплаты',
'st.payment': 'Способ оплаты',
'st.code': 'Код',
'st.paymentActive': 'Показывать клиентам',
'err.PROMO_INVALID': 'Такого промокода нет или он выключен.',
'err.PROMO_EXPIRED': 'Срок промокода истёк или он исчерпан.',
'err.PROMO_USED': 'Клиент уже использовал этот промокод.',
'err.PROMO_NOT_FOR_SERVICE': 'Промокод не действует на эту услугу.',
'err.PROMO_CODE_FORMAT': 'Код — латиница, цифры, «-» и «_», от 3 символов.',
'err.PROMO_CODE_TAKEN': 'Такой промокод уже есть.',
'err.SLOT_WINDOW_INVALID': 'Конец окна записи должен быть позже начала.',
'err.TELEGRAM_NOT_LINKED': 'Привяжите свой Telegram (баннер сверху), чтобы присылать себе проверку.',
'err.TEST_UNREACHABLE': 'Не дошло: откройте клиентского бота Barev Club хотя бы раз и попробуйте снова.',
'err.NO_RECIPIENTS': 'По этим фильтрам нет клиентов, которым можно написать.',
'audit.bo.products.saveTexts': 'изменил(а) тексты услуги',
'audit.bo.products.saveService': 'изменил(а) цену или настройки услуги',
'audit.bo.products.saveCategory': 'изменил(а) скидки направления',
'audit.bo.products.saveChecklist': 'изменил(а) чек-лист услуги',
'audit.bo.products.savePromo': 'изменил(а) промокод',
'audit.bo.products.saveRelocation': 'изменил(а) шаблон релокации',
'audit.bo.products.moveRelocation': 'изменил(а) порядок шагов релокации',
'audit.bo.broadcasts.send': 'запустил(а) рассылку',
'audit.bo.broadcasts.test': 'отправил(а) себе тест рассылки',
'audit.bo.broadcasts.cancel': 'остановил(а) рассылку',
'audit.bo.settings.save': 'изменил(а) настройки',
'audit.bo.settings.savePaymentMethod': 'изменил(а) способ оплаты',
'audit.bo.team.create': 'добавил(а) сотрудника',
'audit.bo.team.update': 'изменил(а) роли сотрудника',
// ───── партнёры и CRM (этап 4) ─────
'crm.title': 'CRM',
@@ -1252,6 +1441,195 @@ const en: Dict = {
'chat.notLinked': 'Work bot not opened',
'chat.details': 'Details',
'chat.more': 'More actions',
'audit.bo.crm.addTask': 'added a task',
'audit.bo.crm.setTaskDone': 'updated a task',
'audit.bo.crm.removeTask': 'deleted a task',
'audit.bo.crm.remove': 'deleted a lead',
'audit.bo.segments.save': 'saved a client segment',
'audit.bo.segments.remove': 'deleted a client segment',
'audit.bo.team.linkUrl': 'issued a Telegram link',
'audit.bo.auth.telegramLink': 'is linking their Telegram',
'audit.bo.auth.logout': 'signed out',
'audit.bo.clients.exportCsv': 'exported clients to Excel',
// ───── продукты, рассылки, журнал, настройки (этап 5) ─────
'nav.settings': 'Settings',
'pr.title': 'Products',
'pr.tab.services': 'Services & prices',
'pr.tab.plans': 'Membership & discounts',
'pr.tab.promos': 'Promo codes',
'pr.tab.relocation': 'Relocation',
'pr.readOnly': 'Only a superadmin can change prices and terms — shown here for reference.',
'pr.catDiscounts': 'discounts: Barev+ −{plus}%, Elite −{elite}%',
'pr.on': 'on',
'pr.off': 'off',
'pr.from': 'from {sum}',
'pr.orders30': '{n} orders in 30 d',
'pr.setup': 'Set up',
'pr.back': 'All services',
'pr.content': 'Content',
'pr.contentHint': 'How the service is named in the app, bot and notifications. Empty = the standard text (shown in grey). Bots pick up changes within a minute.',
'pr.name': 'Name',
'pr.description': 'Description',
'pr.extrasNames': 'Extras names ({n})',
'pr.price': 'Price and duration',
'pr.fixedPrice': 'Price, ֏',
'pr.minutes': 'Duration, min',
'pr.base': 'Base, ֏',
'pr.perRoom': '+ room, ֏',
'pr.perBath': '+ bathroom, ֏',
'pr.baseMin': 'Base, min',
'pr.perRoomMin': '+ room, min',
'pr.perBathMin': '+ bathroom, min',
'pr.roomsHint': 'Base covers 1 room and 1 bathroom; each extra room and bathroom is added on top.',
'pr.extras': 'Extras',
'pr.unitCol': 'Unit',
'pr.extraPrice': 'Price, ֏',
'pr.extraMin': '+ min',
'pr.unit.item': 'per item',
'pr.unit.service': 'service',
'pr.unit.hour': 'per hour',
'pr.unit.halfHour': 'per 30 min',
'pr.priceHint': 'New prices apply to new orders; existing orders don’t change. Membership discounts apply on top.',
'pr.settings': 'Booking and cancellation',
'pr.activeLabel': 'Service available to clients',
'pr.windowFrom': 'Booking from',
'pr.windowTo': 'until',
'pr.step': 'Slot step, min',
'pr.lead': 'Not sooner than, h',
'pr.horizon': 'Bookable days ahead',
'pr.lateFee': 'Late cancellation fee, %',
'pr.discounts': 'Membership discounts',
'pr.discountsHint': 'Apply to the whole “{cat}” category.',
'pr.checklist': 'Provider checklist',
'pr.photo': 'photo',
'pr.newItem': 'New item',
'pr.checklistHint': 'The provider ticks items in the work bot; “photo” items need a photo. Applies to new orders.',
'pr.plans': 'Membership plans',
'pr.plansSuper': 'Plan prices are visible to the superadmin.',
'pr.plansHint': '“Up to” is shown on the site and in the app; actual discounts are in the table and services.',
'pr.discountTable': 'Discounts by category',
'pr.discountTableHint': 'Change them in any service of the category.',
'pr.promoHint': 'A promo code gives an order discount. It doesn’t stack with membership — the larger applies. One use per client.',
'pr.newPromo': 'New promo code',
'pr.promo': 'Promo code',
'pr.noPromos': 'No promo codes yet.',
'pr.used': 'used {n} of {max}',
'pr.until': 'until {date}',
'pr.state.live': 'live',
'pr.state.off': 'off',
'pr.state.expired': 'expired',
'pr.state.exhausted': 'used up',
'pr.state.scheduled': 'not started',
'pr.code': 'Code',
'pr.pct': 'Discount, %',
'pr.validFrom': 'Valid from',
'pr.validUntil': 'until',
'pr.maxUses': 'Total uses',
'pr.maxUsesHint': 'Empty — unlimited',
'pr.forCats': 'Categories',
'pr.forCatsHint': 'Nothing selected — all',
'pr.promoNotePh': 'Where it’s from: newsletter, blogger, partner',
'pr.promoActive': 'Promo code active',
'pr.rel.STEP': 'Relocation steps',
'pr.rel.DOC': 'Relocation documents',
'pr.relHint.STEP': 'New clients get their plan from this list; existing clients are edited individually.',
'pr.relHint.DOC': 'The documents checklist in the client app.',
'pr.turnkey': 'turnkey',
'pr.turnkeyLabel': 'Included in the turnkey package',
'pr.docHint': 'Hint under the name',
'pr.relActive': 'Show to clients',
'no.promo': 'Promo code',
'no.promoPh': 'If the client gave a code',
'no.promoOk': 'Code {code} applied',
'no.promoWeaker': 'The membership discount is bigger — no need for the code',
'bc.title': 'Bot broadcast',
'bc.recipients': 'Recipients: {n}',
'bc.filtersHint': 'Recipients are the clients matching the current filters who gave consent in the bot.',
'bc.textRu': 'Text (Russian)',
'bc.textPh': 'E.g. “From 1 November — new districts for cleaning…”',
'bc.otherLangs': 'Translations for other languages',
'bc.clientsN': 'clients: {n}',
'bc.fallback': 'No translation for {langs} — those clients get the Russian text.',
'bc.appButton': '“Open the app” button under the message',
'bc.test': 'Send to me',
'bc.testSent': 'Sent to you in the client bot',
'bc.send': 'Send to {n}',
'bc.confirm': 'Really send to {n}?',
'bc.sureHint': 'Sent messages can’t be recalled. Click again to start.',
'bc.started': 'Broadcast started: {n} recipients',
'bc.history': 'Broadcasts',
'bc.historyBtn': 'Broadcast history',
'bc.historyHint': 'We send about 25 messages per second so Telegram doesn’t throttle the bot.',
'bc.none': 'No broadcasts yet. Start one from the clients list with any filters.',
'bc.status.SENDING': 'sending',
'bc.status.DONE': 'done',
'bc.status.CANCELLED': 'stopped',
'bc.sent': 'delivered {n} of {total}',
'bc.blocked': 'blocked the bot: {n}',
'bc.failed': 'errors: {n}',
'bc.cancel': 'Stop',
'bc.cancelConfirm': 'Stop the broadcast? Remaining clients won’t get it.',
'jr.title': 'Activity log',
'jr.who': 'Who',
'jr.what': 'Section',
'jr.everyone': 'All staff',
'jr.allSections': 'All sections',
'jr.days': '{n} d',
'jr.empty': 'No activity for this period.',
'jr.system': 'System',
'jr.open': 'open',
'jr.details': 'details',
'jr.hide': 'hide',
'jr.more': 'Show more',
'jr.entity.clients': 'Clients',
'jr.entity.orders': 'Orders',
'jr.entity.providers': 'Providers',
'jr.entity.chats': 'Chats',
'jr.entity.crm': 'CRM',
'jr.entity.partners': 'Partners',
'jr.entity.products': 'Products',
'jr.entity.broadcasts': 'Broadcasts',
'jr.entity.settings': 'Settings',
'jr.entity.team': 'Team',
'jr.entity.segments': 'Segments',
'jr.entity.auth': 'Sign-in',
'st.title': 'Settings',
'st.saveN': 'Save ({n})',
'st.group.orders': 'Orders',
'st.group.subs': 'Membership',
'st.group.agencies': 'Agencies',
'st.group.sales': 'Sales bonus',
'st.group.site': 'Website prices',
'st.group.other': 'Other',
'st.payments': 'Payment methods',
'st.payment': 'Payment method',
'st.code': 'Code',
'st.paymentActive': 'Show to clients',
'err.PROMO_INVALID': 'No such promo code, or it’s off.',
'err.PROMO_EXPIRED': 'The promo code has expired or is used up.',
'err.PROMO_USED': 'The client has already used this code.',
'err.PROMO_NOT_FOR_SERVICE': 'The code doesn’t apply to this service.',
'err.PROMO_CODE_FORMAT': 'Code: Latin letters, digits, “-” and “_”, 3+ characters.',
'err.PROMO_CODE_TAKEN': 'This code already exists.',
'err.SLOT_WINDOW_INVALID': 'The booking window must end after it starts.',
'err.TELEGRAM_NOT_LINKED': 'Link your Telegram (banner at the top) to send yourself a test.',
'err.TEST_UNREACHABLE': 'Not delivered: open the Barev Club client bot once and try again.',
'err.NO_RECIPIENTS': 'No clients to message with these filters.',
'audit.bo.products.saveTexts': 'edited service texts',
'audit.bo.products.saveService': 'edited a service’s price or settings',
'audit.bo.products.saveCategory': 'edited category discounts',
'audit.bo.products.saveChecklist': 'edited a service checklist',
'audit.bo.products.savePromo': 'edited a promo code',
'audit.bo.products.saveRelocation': 'edited a relocation template',
'audit.bo.products.moveRelocation': 'reordered relocation steps',
'audit.bo.broadcasts.send': 'started a broadcast',
'audit.bo.broadcasts.test': 'sent a test broadcast',
'audit.bo.broadcasts.cancel': 'stopped a broadcast',
'audit.bo.settings.save': 'changed settings',
'audit.bo.settings.savePaymentMethod': 'edited a payment method',
'audit.bo.team.create': 'added a team member',
'audit.bo.team.update': 'changed team roles',
// ───── партнёры и CRM (этап 4) ─────
'crm.title': 'CRM',
@@ -2115,6 +2493,195 @@ const hy: Dict = {
'chat.notLinked': 'Աշխատանքային բոտը բացված չէ',
'chat.details': 'Մանրամասներ',
'chat.more': 'Այլ գործողություններ',
'audit.bo.crm.addTask': 'ավելացրեց առաջադրանք',
'audit.bo.crm.setTaskDone': 'նշեց առաջադրանքը',
'audit.bo.crm.removeTask': 'ջնջեց առաջադրանքը',
'audit.bo.crm.remove': 'ջնջեց լիդը',
'audit.bo.segments.save': 'պահպանեց սեգմենտը',
'audit.bo.segments.remove': 'ջնջեց սեգմենտը',
'audit.bo.team.linkUrl': 'տվեց Telegram հղում',
'audit.bo.auth.telegramLink': 'կապում է Telegram-ը',
'audit.bo.auth.logout': 'դուրս եկավ',
'audit.bo.clients.exportCsv': 'արտահանեց հաճախորդներին',
// ───── продукты, рассылки, журнал, настройки (этап 5) ─────
'nav.settings': 'Կարգավորումներ',
'pr.title': 'Ապրանքներ',
'pr.tab.services': 'Ծառայություններ և գներ',
'pr.tab.plans': 'Բաժանորդագրություն և զեղչեր',
'pr.tab.promos': 'Պրոմոկոդեր',
'pr.tab.relocation': 'Տեղափոխություն',
'pr.readOnly': 'Գները փոխում է միայն սուպերադմինը։',
'pr.catDiscounts': 'զեղչեր՝ Barev+ −{plus}%, Elite −{elite}%',
'pr.on': 'միացված',
'pr.off': 'անջատված',
'pr.from': '{sum}-ից',
'pr.orders30': '30 օրում պատվերներ՝ {n}',
'pr.setup': 'Կարգավորել',
'pr.back': 'Բոլոր ծառայությունները',
'pr.content': 'Բովանդակություն',
'pr.contentHint': 'Այսպես է կոչվում ծառայությունը հավելվածում և բոտում։ Դատարկ՝ ստանդարտ տեքստ։',
'pr.name': 'Անվանում',
'pr.description': 'Նկարագրություն',
'pr.extrasNames': 'Լրացուցիչ ծառայությունների անվանումներ ({n})',
'pr.price': 'Գին և տևողություն',
'pr.fixedPrice': 'Գին, ֏',
'pr.minutes': 'Տևողություն, ր',
'pr.base': 'Բազա, ֏',
'pr.perRoom': '+ սենյակ, ֏',
'pr.perBath': '+ սանհանգույց, ֏',
'pr.baseMin': 'Բազա, ր',
'pr.perRoomMin': '+ սենյակ, ր',
'pr.perBathMin': '+ սանհանգույց, ր',
'pr.roomsHint': 'Բազան՝ 1 սենյակ և 1 սանհանգույց։',
'pr.extras': 'Լրացուցիչ',
'pr.unitCol': 'Միավոր',
'pr.extraPrice': 'Գին, ֏',
'pr.extraMin': '+ ր',
'pr.unit.item': 'հատը',
'pr.unit.service': 'ծառայություն',
'pr.unit.hour': 'ժամը',
'pr.unit.halfHour': '30 րոպեն',
'pr.priceHint': 'Նոր գները գործում են նոր պատվերների համար։',
'pr.settings': 'Գրանցում և չեղարկում',
'pr.activeLabel': 'Ծառայությունը հասանելի է',
'pr.windowFrom': 'Գրանցում՝ սկսած',
'pr.windowTo': 'մինչև',
'pr.step': 'Քայլ, ր',
'pr.lead': 'Ոչ շուտ քան, ժ',
'pr.horizon': 'Օրեր առաջ',
'pr.lateFee': 'Ուշ չեղարկման պահում, %',
'pr.discounts': 'Բաժանորդային զեղչեր',
'pr.discountsHint': 'Գործում են ամբողջ «{cat}» ուղղության համար։',
'pr.checklist': 'Կատարողի ստուգացանկ',
'pr.photo': 'լուսանկար',
'pr.newItem': 'Նոր կետ',
'pr.checklistHint': 'Կատարողը նշում է կետերը բոտում։',
'pr.plans': 'Բաժանորդագրության սակագներ',
'pr.plansSuper': 'Գները տեսնում է սուպերադմինը։',
'pr.plansHint': '«Մինչև» զեղչը ցուցադրվում է կայքում։',
'pr.discountTable': 'Զեղչեր ըստ ուղղության',
'pr.discountTableHint': 'Փոխվում են ծառայության քարտում։',
'pr.promoHint': 'Պրոմոկոդը զեղչ է տալիս։ Չի գումարվում բաժանորդային զեղչին։',
'pr.newPromo': 'Նոր պրոմոկոդ',
'pr.promo': 'Պրոմոկոդ',
'pr.noPromos': 'Պրոմոկոդեր դեռ չկան։',
'pr.used': 'օգտագործված {n}/{max}',
'pr.until': 'մինչև {date}',
'pr.state.live': 'գործում է',
'pr.state.off': 'անջատված',
'pr.state.expired': 'ժամկետանց',
'pr.state.exhausted': 'սպառված',
'pr.state.scheduled': 'դեռ չի սկսվել',
'pr.code': 'Կոդ',
'pr.pct': 'Զեղչ, %',
'pr.validFrom': 'Գործում է՝ սկսած',
'pr.validUntil': 'մինչև',
'pr.maxUses': 'Ընդհանուր քանակ',
'pr.maxUsesHint': 'Դատարկ՝ անսահմանափակ',
'pr.forCats': 'Ուղղություններ',
'pr.forCatsHint': 'Ոչինչ՝ բոլորը',
'pr.promoNotePh': 'Որտեղից է կոդը',
'pr.promoActive': 'Պրոմոկոդն ակտիվ է',
'pr.rel.STEP': 'Տեղափոխության քայլեր',
'pr.rel.DOC': 'Փաստաթղթեր',
'pr.relHint.STEP': 'Նոր հաճախորդները ստանում են այս ցանկը։',
'pr.relHint.DOC': 'Փաստաթղթերի ցանկ հավելվածում։',
'pr.turnkey': 'բանալի հանձնած',
'pr.turnkeyLabel': 'Մտնում է «բանալի հանձնած» փաթեթում',
'pr.docHint': 'Հուշում',
'pr.relActive': 'Ցույց տալ հաճախորդներին',
'no.promo': 'Պրոմոկոդ',
'no.promoPh': 'Եթե հաճախորդը կոդ ունի',
'no.promoOk': '{code} կոդը կիրառված է',
'no.promoWeaker': 'Բաժանորդային զեղչն ավելի մեծ է',
'bc.title': 'Ուղարկում բոտով',
'bc.recipients': 'Կստանան՝ {n}',
'bc.filtersHint': 'Ստացողներ՝ ընթացիկ ֆիլտրերով հաճախորդներ։',
'bc.textRu': 'Տեքստ (ռուսերեն)',
'bc.textPh': 'Օրինակ՝ «Նոյեմբերի 1-ից…»',
'bc.otherLangs': 'Թարգմանություններ',
'bc.clientsN': 'հաճախորդներ՝ {n}',
'bc.fallback': '{langs}-ի համար թարգմանություն չկա՝ կստանան ռուսերեն։',
'bc.appButton': '«Բացել հավելվածը» կոճակ',
'bc.test': 'Ուղարկել ինձ',
'bc.testSent': 'Ուղարկվեց ձեզ',
'bc.send': 'Ուղարկել {n}',
'bc.confirm': 'Իսկապե՞ս ուղարկել {n}',
'bc.sureHint': 'Ուղարկվածը հետ կանչել հնարավոր չէ։',
'bc.started': 'Ուղարկումը սկսվեց՝ {n}',
'bc.history': 'Ուղարկումներ',
'bc.historyBtn': 'Ուղարկումների պատմություն',
'bc.historyHint': 'Ուղարկում ենք վայրկյանում ~25 հաղորդագրություն։',
'bc.none': 'Ուղարկումներ դեռ չեն եղել։',
'bc.status.SENDING': 'ընթացքում',
'bc.status.DONE': 'ուղարկված',
'bc.status.CANCELLED': 'կանգնեցված',
'bc.sent': 'առաքված {n}/{total}',
'bc.blocked': 'արգելափակել են բոտը՝ {n}',
'bc.failed': 'սխալներ՝ {n}',
'bc.cancel': 'Կանգնեցնել',
'bc.cancelConfirm': 'Կանգնեցնե՞լ ուղարկումը։',
'jr.title': 'Գործողությունների մատյան',
'jr.who': 'Ով',
'jr.what': 'Բաժին',
'jr.everyone': 'Բոլոր աշխատակիցները',
'jr.allSections': 'Բոլոր բաժինները',
'jr.days': '{n} օր',
'jr.empty': 'Այս ժամանակահատվածում գործողություններ չկան։',
'jr.system': 'Համակարգ',
'jr.open': 'բացել',
'jr.details': 'մանրամասներ',
'jr.hide': 'թաքցնել',
'jr.more': 'Ցույց տալ ավելին',
'jr.entity.clients': 'Հաճախորդներ',
'jr.entity.orders': 'Պատվերներ',
'jr.entity.providers': 'Կատարողներ',
'jr.entity.chats': 'Զրույցներ',
'jr.entity.crm': 'CRM',
'jr.entity.partners': 'Գործընկերներ',
'jr.entity.products': 'Ապրանքներ',
'jr.entity.broadcasts': 'Ուղարկումներ',
'jr.entity.settings': 'Կարգավորումներ',
'jr.entity.team': 'Թիմ',
'jr.entity.segments': 'Սեգմենտներ',
'jr.entity.auth': 'Մուտք',
'st.title': 'Կարգավորումներ',
'st.saveN': 'Պահպանել ({n})',
'st.group.orders': 'Պատվերներ',
'st.group.subs': 'Բաժանորդագրություն',
'st.group.agencies': 'Գործակալություններ',
'st.group.sales': 'Վաճառողների բոնուս',
'st.group.site': 'Կայքի գներ',
'st.group.other': 'Այլ',
'st.payments': 'Վճարման եղանակներ',
'st.payment': 'Վճարման եղանակ',
'st.code': 'Կոդ',
'st.paymentActive': 'Ցույց տալ',
'err.PROMO_INVALID': 'Այդպիսի պրոմոկոդ չկա։',
'err.PROMO_EXPIRED': 'Պրոմոկոդի ժամկետն անցել է։',
'err.PROMO_USED': 'Հաճախորդն արդեն օգտագործել է այս կոդը։',
'err.PROMO_NOT_FOR_SERVICE': 'Կոդը չի գործում այս ծառայության համար։',
'err.PROMO_CODE_FORMAT': 'Կոդ՝ լատինատառ և թվեր։',
'err.PROMO_CODE_TAKEN': 'Այս կոդն արդեն կա։',
'err.SLOT_WINDOW_INVALID': 'Պատուհանի ավարտը պետք է լինի սկզբից հետո։',
'err.TELEGRAM_NOT_LINKED': 'Կապեք ձեր Telegram-ը։',
'err.TEST_UNREACHABLE': 'Չհասավ․ բացեք հաճախորդի բոտը։',
'err.NO_RECIPIENTS': 'Այս ֆիլտրերով հաճախորդներ չկան։',
'audit.bo.products.saveTexts': 'փոխեց ծառայության տեքստերը',
'audit.bo.products.saveService': 'փոխեց ծառայության գինը',
'audit.bo.products.saveCategory': 'փոխեց զեղչերը',
'audit.bo.products.saveChecklist': 'փոխեց ստուգացանկը',
'audit.bo.products.savePromo': 'փոխեց պրոմոկոդը',
'audit.bo.products.saveRelocation': 'փոխեց տեղափոխության ձևանմուշը',
'audit.bo.products.moveRelocation': 'փոխեց քայլերի հերթականությունը',
'audit.bo.broadcasts.send': 'սկսեց ուղարկումը',
'audit.bo.broadcasts.test': 'ուղարկեց փորձնական',
'audit.bo.broadcasts.cancel': 'կանգնեցրեց ուղարկումը',
'audit.bo.settings.save': 'փոխեց կարգավորումները',
'audit.bo.settings.savePaymentMethod': 'փոխեց վճարման եղանակը',
'audit.bo.team.create': 'ավելացրեց աշխատակից',
'audit.bo.team.update': 'փոխեց դերերը',
// ───── партнёры и CRM (этап 4) ─────
'crm.title': 'CRM',
+1
View File
@@ -3,6 +3,7 @@ import './styles.css';
import './chats.css';
import './orders.css';
import './crm.css';
import './admin5.css';
import { ApiProvider, ToastProvider } from '@barev/web';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
+405
View File
@@ -0,0 +1,405 @@
/** Этап 5: рассылки, журнал действий, настройки */
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useToast, useTRPC } from '@barev/web';
import { useEffect, useState } from 'react';
import { errText, useT } from '../i18n.tsx';
import { Avatar, Dialog, Field, I, Skeleton } from '../components/kit.tsx';
import { actionLabel } from './Dashboard.tsx';
const LANGS = ['ru', 'en', 'hy', 'zh', 'es'] as const;
type L5 = Record<(typeof LANGS)[number], string>;
// ───── рассылка ─────
/** Рассылка по текущим фильтрам списка клиентов */
export function BroadcastDialog({ filters, onClose }: { filters: Record<string, unknown>; onClose: () => void }) {
const { t } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const preview = useQuery(trpc.bo.broadcasts.preview.queryOptions(filters as never));
const [texts, setTexts] = useState<L5>({ ru: '', en: '', hy: '', zh: '', es: '' });
const [more, setMore] = useState(false);
const [appButton, setAppButton] = useState(true);
const [sure, setSure] = useState(false);
const onError = (e: unknown) => toast(errText(t, e), 'error');
const test = useMutation(trpc.bo.broadcasts.test.mutationOptions({ onSuccess: () => toast(t('bc.testSent')), onError }));
const send = useMutation(
trpc.bo.broadcasts.send.mutationOptions({
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: trpc.bo.broadcasts.list.queryKey() });
toast(t('bc.started', { n: r.total }));
onClose();
location.hash = '#/broadcasts';
},
onError,
}),
);
const p = preview.data;
const missing = p ? LANGS.filter((l) => l !== 'ru' && p.byLocale[l] > 0 && !texts[l].trim()) : [];
return (
<Dialog
open
wide
onClose={onClose}
title={t('bc.title')}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" disabled={!texts.ru.trim() || test.isPending} onClick={() => test.mutate({ text: texts.ru, appButton })}>
{t('bc.test')}
</button>
<span className="bo-grow" />
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button
type="button"
className="bo-btn bo-btn--accent"
disabled={!texts.ru.trim() || !p?.total || send.isPending}
onClick={() => (sure ? send.mutate({ filters: filters as never, texts, appButton }) : setSure(true))}
>
{sure ? t('bc.confirm', { n: p?.total ?? 0 }) : t('bc.send', { n: p?.total ?? 0 })}
</button>
</>
}
>
<div className="bo-bcwho">
<I name="clients" size={20} />
{p ? (
<span>
<b>{t('bc.recipients', { n: p.total })}</b>
<span className="bo-small bo-muted" style={{ display: 'block' }}>
{LANGS.filter((l) => p.byLocale[l] > 0)
.map((l) => `${l.toUpperCase()} ${p.byLocale[l]}`)
.join(' · ') || '—'}
</span>
</span>
) : (
<span className="bo-muted">{t('ui.loading')}</span>
)}
</div>
<p className="bo-hint">{t('bc.filtersHint')}</p>
<Field label={t('bc.textRu')}>
<textarea className="bo-textarea" style={{ minHeight: 120 }} value={texts.ru} maxLength={3500} onChange={(e) => (setTexts({ ...texts, ru: e.target.value }), setSure(false))} placeholder={t('bc.textPh')} />
</Field>
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-accent)', padding: 0, alignSelf: 'flex-start' }} onClick={() => setMore((m) => !m)}>
{t('bc.otherLangs')} {more ? '▴' : '▾'}
</button>
{more &&
LANGS.filter((l) => l !== 'ru').map((l) => (
<Field key={l} label={`${l.toUpperCase()}${p?.byLocale[l] ? ` · ${t('bc.clientsN', { n: p.byLocale[l] })}` : ''}`}>
<textarea className="bo-textarea" style={{ minHeight: 70 }} value={texts[l]} maxLength={3500} onChange={(e) => setTexts({ ...texts, [l]: e.target.value })} />
</Field>
))}
{missing.length > 0 && <p className="bo-hint">{t('bc.fallback', { langs: missing.map((l) => l.toUpperCase()).join(', ') })}</p>}
<label className="bo-check">
<input type="checkbox" checked={appButton} onChange={(e) => setAppButton(e.target.checked)} />
{t('bc.appButton')}
</label>
{sure && <p className="bo-hint" style={{ color: 'var(--bc-danger)' }}>{t('bc.sureHint')}</p>}
</Dialog>
);
}
export function Broadcasts() {
const { t, dateTime } = useT();
const trpc = useTRPC();
const toast = useToast();
const qc = useQueryClient();
const list = useQuery({ ...trpc.bo.broadcasts.list.queryOptions(), refetchInterval: (q) => (q.state.data?.some((b) => b.status === 'SENDING') ? 3000 : false) });
const cancel = useMutation(trpc.bo.broadcasts.cancel.mutationOptions({ onSuccess: () => qc.invalidateQueries({ queryKey: trpc.bo.broadcasts.list.queryKey() }), onError: (e) => toast(errText(t, e), 'error') }));
return (
<>
<a className="bo-back" href="#/clients">
<I name="back" size={16} />
{t('card.back')}
</a>
<div className="bo-head">
<h1 className="bo-title">{t('bc.history')}</h1>
</div>
<p className="bo-hint" style={{ marginBottom: 14 }}>
{t('bc.historyHint')}
</p>
{!list.data ? (
<Skeleton h={200} />
) : list.data.length === 0 ? (
<div className="bo-panel bo-empty">{t('bc.none')}</div>
) : (
<div className="bo-col" style={{ gap: 10 }}>
{list.data.map((b) => {
const done = b.sent + b.blocked + b.failed;
return (
<section key={b.id} className="bo-panel">
<div className="bo-panel__head">
<span className="bo-small bo-muted">
{dateTime(b.createdAt)} · {b.by ?? '—'}
</span>
<span className={`bo-chip${b.status === 'DONE' ? ' bo-chip--ok' : b.status === 'SENDING' ? ' bo-chip--warn' : ''}`}>{t(`bc.status.${b.status}`)}</span>
</div>
<p style={{ margin: '0 0 12px', whiteSpace: 'pre-wrap' }}>{b.text}</p>
<div className="bo-bar__track" style={{ height: 8 }}>
<div className="bo-bar__fill" style={{ width: `${b.total ? (done / b.total) * 100 : 0}%` }} />
</div>
<div className="bo-small" style={{ display: 'flex', gap: 14, flexWrap: 'wrap', marginTop: 8 }}>
<span>{t('bc.sent', { n: b.sent, total: b.total })}</span>
{b.blocked > 0 && <span className="bo-muted">{t('bc.blocked', { n: b.blocked })}</span>}
{b.failed > 0 && <span style={{ color: 'var(--bc-danger)' }}>{t('bc.failed', { n: b.failed })}</span>}
{b.status === 'SENDING' && (
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-danger)', padding: 0 }} onClick={() => confirm(t('bc.cancelConfirm')) && cancel.mutate({ id: b.id })}>
{t('bc.cancel')}
</button>
)}
</div>
</section>
);
})}
</div>
)}
</>
);
}
// ───── журнал ─────
/** Куда ведёт запись журнала */
function entityHref(entity: string, id: string | null) {
if (!id) return null;
const map: Record<string, string> = { clients: 'clients', orders: 'orders', providers: 'providers', chats: 'chats', crm: 'crm', agency: 'agencies', venue: 'venues' };
const base = map[entity];
return base ? `#/${base}/${id}` : null;
}
export function Journal() {
const { t, dateTime } = useT();
const trpc = useTRPC();
const [who, setWho] = useState('');
const [entity, setEntity] = useState('');
const [days, setDays] = useState(7);
const [open, setOpen] = useState<string | null>(null);
const facets = useQuery(trpc.bo.journal.facets.queryOptions());
const list = useInfiniteQuery({
...trpc.bo.journal.list.infiniteQueryOptions({ adminUserId: who || undefined, entity: entity || undefined, days }, { getNextPageParam: (p) => p.next ?? undefined }),
placeholderData: keepPreviousData,
});
const items = list.data?.pages.flatMap((p) => p.items) ?? [];
return (
<>
<div className="bo-head">
<h1 className="bo-title">{t('jr.title')}</h1>
</div>
<div className="bo-toolbar">
<select className="bo-select" style={{ width: 'auto' }} value={who} onChange={(e) => setWho(e.target.value)} aria-label={t('jr.who')}>
<option value="">{t('jr.everyone')}</option>
{facets.data?.staff.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
<select className="bo-select" style={{ width: 'auto' }} value={entity} onChange={(e) => setEntity(e.target.value)} aria-label={t('jr.what')}>
<option value="">{t('jr.allSections')}</option>
{facets.data?.entities.map((x) => (
<option key={x} value={x}>
{t(`jr.entity.${x}`) === `jr.entity.${x}` ? x : t(`jr.entity.${x}`)}
</option>
))}
</select>
<div className="bo-pills">
{[1, 7, 30, 90].map((d) => (
<button key={d} type="button" className="bo-pill" aria-pressed={days === d} onClick={() => setDays(d)}>
{t('jr.days', { n: d })}
</button>
))}
</div>
</div>
{list.isPending ? (
<Skeleton h={300} />
) : items.length === 0 ? (
<div className="bo-panel bo-empty">{t('jr.empty')}</div>
) : (
<section className="bo-panel" style={{ padding: 8 }}>
<ul className="bo-journal">
{items.map((r) => {
const href = entityHref(r.entity, r.entityId);
return (
<li key={r.id}>
<span className="bo-feed__time">{dateTime(r.at)}</span>
{r.by ? <Avatar name={r.by.name} id={r.by.id} size={28} /> : <span style={{ width: 28 }} />}
<span className="bo-grow" style={{ minWidth: 0, whiteSpace: 'normal' }}>
<b>{r.by?.name ?? t('jr.system')}</b> {actionLabel(t, r.action)}
{href && (
<>
{' '}
<a href={href} className="bo-small">
{t('jr.open')}
</a>
</>
)}
{r.data && (
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-ink-3)', padding: '0 0 0 8px', fontSize: 12 }} onClick={() => setOpen(open === r.id ? null : r.id)}>
{open === r.id ? t('jr.hide') : t('jr.details')}
</button>
)}
{open === r.id && <pre className="bo-json">{JSON.stringify(r.data, null, 2)}</pre>}
</span>
</li>
);
})}
</ul>
{list.hasNextPage && (
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" style={{ margin: 10 }} disabled={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
{t('jr.more')}
</button>
)}
</section>
)}
</>
);
}
// ───── настройки ─────
const GROUPS: { key: string; keys: string[] }[] = [
{ key: 'orders', keys: ['cancel.freeHours', 'offer.acceptTimeoutMinutes', 'assign.auto', 'assign.maxAutoOffers'] },
{ key: 'subs', keys: ['barevPlus.monthlyPrice', 'barevPlus.maxDiscountPct', 'barevPlus.giftDays', 'elite.monthlyPrice', 'elite.maxDiscountPct'] },
{ key: 'agencies', keys: ['agency.attributionMonths', 'agency.defaultCommissionPct'] },
{ key: 'sales', keys: ['sales.bonusPerVenue', 'sales.bonusPerAgency', 'sales.bonusAgencyPct', 'sales.bonusAgencyMonths'] },
{ key: 'site', keys: ['relocation.hotelWithPlus', 'relocation.hotelBase', 'relocation.consultation', 'docs.fromPrice', 'docs.socialCardPrice', 'residence.escortFrom', 'residence.stateFee', 'transfer.standardPrice', 'transfer.premiumPrice'] },
{ key: 'other', keys: ['chat.closeAfterHours', 'files.retentionDays', 'consent.version'] },
];
export function Settings() {
const { t, shared } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const s = useQuery(trpc.bo.settings.get.queryOptions());
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
useEffect(() => {
if (s.data && !draft) setDraft({ ...s.data });
}, [s.data, draft]);
const save = useMutation(
trpc.bo.settings.save.mutationOptions({
onSuccess: () => {
qc.invalidateQueries();
toast(t('prv.saved'));
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
if (!draft || !s.data) return <Skeleton h={400} />;
const changed = Object.keys(draft).filter((k) => draft[k] !== (s.data as Record<string, unknown>)[k]);
return (
<>
<div className="bo-head">
<h1 className="bo-title">{t('st.title')}</h1>
<button type="button" className="bo-btn bo-btn--accent" disabled={!changed.length || save.isPending} onClick={() => save.mutate(Object.fromEntries(changed.map((k) => [k, draft[k]])) as never)}>
{changed.length ? t('st.saveN', { n: changed.length }) : t('ui.save')}
</button>
</div>
<div className="bo-settings">
{GROUPS.map((g) => (
<section key={g.key} className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t(`st.group.${g.key}`)}</h2>
</div>
<div className="bo-col" style={{ gap: 12 }}>
{g.keys.map((k) => {
const v = draft[k];
const label = shared(`admin.settings.${k.replace('.', '_')}`);
return typeof v === 'boolean' ? (
<label key={k} className="bo-check">
<input type="checkbox" checked={v} onChange={(e) => setDraft({ ...draft, [k]: e.target.checked })} />
{label}
</label>
) : (
<Field key={k} label={label}>
<input
className="bo-input"
inputMode={typeof v === 'number' ? 'numeric' : undefined}
value={String(v ?? '')}
onChange={(e) => setDraft({ ...draft, [k]: typeof v === 'number' ? Number(e.target.value.replace(/\D/g, '')) || 0 : e.target.value })}
data-changed={changed.includes(k) || undefined}
/>
</Field>
);
})}
</div>
</section>
))}
<PaymentMethods />
</div>
</>
);
}
function PaymentMethods() {
const { t, locale } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const list = useQuery(trpc.bo.settings.paymentMethods.queryOptions());
const [edit, setEdit] = useState<{ id?: string; code: string; name: L5; active: boolean } | null>(null);
const save = useMutation(
trpc.bo.settings.savePaymentMethod.mutationOptions({
onSuccess: () => {
qc.invalidateQueries({ queryKey: trpc.bo.settings.paymentMethods.queryKey() });
setEdit(null);
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('st.payments')}</h2>
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setEdit({ code: '', name: { ru: '', en: '', hy: '', zh: '', es: '' }, active: true })}>
<I name="plus" size={16} />
</button>
</div>
<ul className="bo-list">
{(list.data ?? []).map((m) => {
const name = m.name as L5;
return (
<li key={m.id} style={{ opacity: m.active ? 1 : 0.5 }}>
<span className="bo-grow">{name[locale as keyof L5] || name.ru}</span>
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setEdit({ id: m.id, code: m.code, name: { ...{ ru: '', en: '', hy: '', zh: '', es: '' }, ...name }, active: m.active })}>
{t('prv.edit')}
</button>
</li>
);
})}
</ul>
{edit && (
<Dialog
open
onClose={() => setEdit(null)}
title={t('st.payment')}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={() => setEdit(null)}>
{t('ui.close')}
</button>
<button type="button" className="bo-btn" disabled={!edit.code.trim() || !edit.name.ru.trim() || save.isPending} onClick={() => save.mutate(edit)}>
{t('ui.save')}
</button>
</>
}
>
<Field label={t('st.code')}>
<input className="bo-input" value={edit.code} maxLength={40} onChange={(e) => setEdit({ ...edit, code: e.target.value.replace(/[^a-z0-9_]/gi, '').toLowerCase() })} placeholder="cash" />
</Field>
{LANGS.map((l) => (
<Field key={l} label={l.toUpperCase()}>
<input className="bo-input" value={edit.name[l]} maxLength={100} onChange={(e) => setEdit({ ...edit, name: { ...edit.name, [l]: e.target.value } })} />
</Field>
))}
<label className="bo-check">
<input type="checkbox" checked={edit.active} onChange={(e) => setEdit({ ...edit, active: e.target.checked })} />
{t('st.paymentActive')}
</label>
</Dialog>
)}
</section>
);
}
+15 -4
View File
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react';
import type { Me } from '../App.tsx';
import { errText, useT } from '../i18n.tsx';
import { Avatar, Dialog, download, Field, I, PlanBadge, Skeleton, sourceLabel } from '../components/kit.tsx';
import { BroadcastDialog } from './Admin5.tsx';
type Plan = 'any' | 'none' | 'BAREV_PLUS' | 'ELITE';
interface Filters {
@@ -78,6 +79,8 @@ export function Clients({ me }: { me: Me }) {
}),
);
const [saveOpen, setSaveOpen] = useState(false);
const [broadcast, setBroadcast] = useState(false);
const canBroadcast = isSuper || me.roles.includes('SUPPORT');
const data = list.data;
const from = data ? page * data.pageSize + 1 : 0;
@@ -90,10 +93,17 @@ export function Clients({ me }: { me: Me }) {
<h1 className="bo-title">{t('clients.title')}</h1>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button type="button" className="bo-btn bo-btn--ghost" title={t('clients.broadcastSoon')} disabled>
<I name="send" size={16} />
{t('clients.broadcast')}
</button>
{canBroadcast && (
<>
<a className="bo-btn bo-btn--ghost" href="#/broadcasts">
{t('bc.historyBtn')}
</a>
<button type="button" className="bo-btn bo-btn--ghost" onClick={() => setBroadcast(true)}>
<I name="send" size={16} />
{t('clients.broadcast')}
</button>
</>
)}
<button type="button" className="bo-btn bo-btn--ghost" onClick={() => exportCsv.mutate(f)} disabled={exportCsv.isPending}>
<I name="download" size={16} />
{t('clients.export')}
@@ -339,6 +349,7 @@ export function Clients({ me }: { me: Me }) {
)}
<SaveSegment open={saveOpen} onClose={() => setSaveOpen(false)} filters={f} />
{broadcast && <BroadcastDialog filters={f as Record<string, unknown>} onClose={() => setBroadcast(false)} />}
</>
);
}
+14 -1
View File
@@ -123,8 +123,14 @@ export function NewOrderDialog({ clientId: fixedClient, onClose }: { clientId?:
const [note, setNote] = useState('');
const [payment, setPayment] = useState('');
const [at, setAt] = useState<{ when: Date; busy: boolean } | null>(null);
const [promo, setPromo] = useState('');
const [promoQ, setPromoQ] = useState('');
useEffect(() => {
const id = setTimeout(() => setPromoQ(promo.trim()), 400);
return () => clearTimeout(id);
}, [promo]);
const ready = !!service && Object.keys(params).length > 0;
const quote = useQuery({ ...trpc.bo.orders.quote.queryOptions({ clientId, service: service?.code ?? '', params }), enabled: ready && !!clientId, placeholderData: keepPreviousData });
const quote = useQuery({ ...trpc.bo.orders.quote.queryOptions({ clientId, service: service?.code ?? '', params, promoCode: promoQ || undefined }), enabled: ready && !!clientId, placeholderData: keepPreviousData, retry: false });
const create = useMutation(
trpc.bo.orders.create.mutationOptions({
onSuccess: (r) => {
@@ -178,6 +184,7 @@ export function NewOrderDialog({ clientId: fixedClient, onClose }: { clientId?:
districtId: needsAddress ? districtId : undefined,
note: note || undefined,
paymentMethodId: payment || undefined,
promoCode: promoQ && !quote.isError ? promoQ : undefined,
})
}
>
@@ -244,6 +251,12 @@ export function NewOrderDialog({ clientId: fixedClient, onClose }: { clientId?:
</select>
</Field>
</div>
<Field
label={t('no.promo')}
hint={promoQ ? (quote.isError ? errText(t, quote.error) : quote.data?.promo ? t('no.promoOk', { code: quote.data.promo }) : quote.data ? t('no.promoWeaker') : undefined) : undefined}
>
<input className="bo-input" value={promo} maxLength={40} onChange={(e) => setPromo(e.target.value.toUpperCase())} placeholder={t('no.promoPh')} style={{ maxWidth: 260 }} />
</Field>
<p className="bo-hint">{t('no.hint')}</p>
</Dialog>
);
+837
View File
@@ -0,0 +1,837 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { navigate, useLocation, useToast, useTRPC } from '@barev/web';
import { useEffect, useMemo, useState } from 'react';
import type { Me } from '../App.tsx';
import { errText, useT } from '../i18n.tsx';
import { Dialog, Field, I, Skeleton } from '../components/kit.tsx';
type Tab = 'services' | 'plans' | 'promos' | 'relocation';
const LANGS = ['ru', 'en', 'hy', 'zh', 'es'] as const;
type Lang = (typeof LANGS)[number];
type L5 = Record<Lang, string>;
const EMPTY: L5 = { ru: '', en: '', hy: '', zh: '', es: '' };
const CATS = ['HOME', 'DOCS', 'LEARNING', 'PERKS'] as const;
const num = (v: string) => v.replace(/\D/g, '');
const hhmm = (m: number) => `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
const toMin = (s: string) => {
const [h, m] = s.split(':').map(Number);
return (h ?? 0) * 60 + (m ?? 0);
};
export function Products({ me }: { me: Me }) {
const { t } = useT();
const { query } = useLocation();
const tab = (query.get('tab') as Tab) || 'services';
const canEdit = me.roles.includes('SUPERADMIN');
return (
<>
<div className="bo-head">
<h1 className="bo-title">{t('pr.title')}</h1>
</div>
<div className="bo-ogroups" role="tablist">
{(['services', 'plans', 'promos', 'relocation'] as const).map((x) => (
<button key={x} type="button" role="tab" className="bo-ogroup" aria-selected={tab === x} onClick={() => navigate(x === 'services' ? '/products' : `/products?tab=${x}`, { replace: true })}>
{t(`pr.tab.${x}`)}
</button>
))}
</div>
{!canEdit && <p className="bo-hint" style={{ marginBottom: 12 }}>{t('pr.readOnly')}</p>}
{tab === 'services' && <Services />}
{tab === 'plans' && <Plans canEdit={canEdit} />}
{tab === 'promos' && <Promos canEdit={canEdit} />}
{tab === 'relocation' && <Relocation canEdit={canEdit} />}
</>
);
}
// ───── услуги ─────
function Services() {
const { t, shared, amd } = useT();
const trpc = useTRPC();
const cat = useQuery(trpc.bo.products.catalog.queryOptions());
if (!cat.data) return <Skeleton h={300} />;
return (
<div className="bo-col" style={{ gap: 18 }}>
{cat.data.map((c) => (
<section key={c.id} className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{shared(`categories.${c.code}.name`)}</h2>
<span className="bo-small bo-muted">{t('pr.catDiscounts', { plus: c.barevPlusDiscountPct, elite: c.eliteDiscountPct })}</span>
</div>
<ul className="bo-list">
{c.services.map((s) => (
<li key={s.id} style={{ opacity: s.active ? 1 : 0.55 }}>
<a href={`#/products/${s.id}`} className="bo-grow" style={{ color: 'inherit', textDecoration: 'none' }}>
<b>{shared(`services.${s.code}.name`)}</b>
<span className="bo-small bo-muted" style={{ display: 'block' }}>
{[s.active ? t('pr.on') : t('pr.off'), t('pr.from', { sum: amd(s.fromPrice) }), shared(`roles.${s.providerRole}`), t('pr.orders30', { n: s.orders30 })].join(' · ')}
</span>
</a>
<a className="bo-btn bo-btn--ghost bo-btn--sm" href={`#/products/${s.id}`}>
{t('pr.setup')}
</a>
</li>
))}
</ul>
</section>
))}
</div>
);
}
/** Поля на 5 языках со стандартным текстом в подсказке */
function L5Editor({ label, value, defaults, onChange, multiline, disabled }: { label: string; value: L5; defaults: Partial<L5>; onChange: (v: L5) => void; multiline?: boolean; disabled?: boolean }) {
return (
<div className="bo-field">
<span className="bo-label">{label}</span>
<div className="bo-l5">
{LANGS.map((l) => (
<label key={l} className="bo-l5__row">
<span className="bo-l5__lang">{l.toUpperCase()}</span>
{multiline ? (
<textarea className="bo-textarea" style={{ minHeight: 60 }} disabled={disabled} value={value[l]} placeholder={defaults[l] ?? ''} onChange={(e) => onChange({ ...value, [l]: e.target.value })} />
) : (
<input className="bo-input" disabled={disabled} value={value[l]} placeholder={defaults[l] ?? ''} onChange={(e) => onChange({ ...value, [l]: e.target.value })} />
)}
</label>
))}
</div>
</div>
);
}
type Catalog = NonNullable<ReturnType<typeof useCatalog>>;
function useCatalog() {
const trpc = useTRPC();
return useQuery(trpc.bo.products.catalog.queryOptions()).data;
}
export function ServiceEditor({ id, me }: { id: string; me: Me }) {
const { t, shared } = useT();
const cat = useCatalog();
const canEdit = me.roles.includes('SUPERADMIN');
const found = useMemo(() => {
for (const c of cat ?? []) for (const s of c.services) if (s.id === id) return { c, s };
return null;
}, [cat, id]);
const back = (
<a className="bo-back" href="#/products">
<I name="back" size={16} />
{t('pr.back')}
</a>
);
if (!cat) return <Skeleton h={300} />;
if (!found) return back;
const { c, s } = found;
return (
<>
{back}
<div className="bo-head">
<div>
<h1 className="bo-title">{shared(`services.${s.code}.name`)}</h1>
<p className="bo-sub">{[shared(`categories.${c.code}.name`), shared(`roles.${s.providerRole}`), s.isOnline ? shared('common.online') : null].filter(Boolean).join(' · ')}</p>
</div>
</div>
<div className="bo-client-grid">
<div className="bo-col">
<ContentPanel code={s.code} canEdit={canEdit} extras={s.pricing.kind === 'rooms' ? Object.keys(s.pricing.extras) : []} />
</div>
<div className="bo-col">
<SettingsPanel service={s} canEdit={canEdit} />
<CategoryPanel category={c} canEdit={canEdit} />
<ChecklistPanel service={s} canEdit={canEdit} />
</div>
</div>
<div style={{ marginTop: 16 }}>
<PricePanel service={s} canEdit={canEdit} />
</div>
</>
);
}
function useSaved() {
const { t } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
return {
onSuccess: () => {
qc.invalidateQueries({ queryKey: trpc.bo.products.catalog.queryKey() });
qc.invalidateQueries({ queryKey: trpc.bo.products.texts.queryKey() });
toast(t('prv.saved'));
},
onError: (e: unknown) => toast(errText(t, e), 'error'),
};
}
/** Наполнение: название и описание услуги, названия доп. услуг */
function ContentPanel({ code, canEdit, extras }: { code: string; canEdit: boolean; extras: string[] }) {
const { t, shared } = useT();
const trpc = useTRPC();
const keys = [`services.${code}.name`, `services.${code}.description`, ...extras.map((x) => `extras.${x}.name`)];
const texts = useQuery(trpc.bo.products.texts.queryOptions({ keys }));
const [draft, setDraft] = useState<Record<string, L5> | null>(null);
useEffect(() => {
if (texts.data && !draft) setDraft(Object.fromEntries(keys.map((k) => [k, Object.fromEntries(LANGS.map((l) => [l, texts.data[k]?.[l]?.value ?? ''])) as L5])));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [texts.data]);
const save = useMutation(trpc.bo.products.saveTexts.mutationOptions(useSaved()));
const defaults = (k: string) => Object.fromEntries(LANGS.map((l) => [l, texts.data?.[k]?.[l]?.default ?? ''])) as L5;
const [showExtras, setShowExtras] = useState(false);
if (!draft) return <Skeleton h={200} />;
return (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('pr.content')}</h2>
</div>
<p className="bo-hint" style={{ marginBottom: 12 }}>
{t('pr.contentHint')}
</p>
<div className="bo-col" style={{ gap: 14 }}>
<L5Editor label={t('pr.name')} value={draft[keys[0]!]!} defaults={defaults(keys[0]!)} onChange={(v) => setDraft({ ...draft, [keys[0]!]: v })} disabled={!canEdit} />
<L5Editor label={t('pr.description')} value={draft[keys[1]!]!} defaults={defaults(keys[1]!)} onChange={(v) => setDraft({ ...draft, [keys[1]!]: v })} multiline disabled={!canEdit} />
{extras.length > 0 && (
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-accent)', padding: 0, alignSelf: 'flex-start' }} onClick={() => setShowExtras((x) => !x)}>
{t('pr.extrasNames', { n: extras.length })} {showExtras ? '▴' : '▾'}
</button>
)}
{showExtras &&
extras.map((x) => {
const k = `extras.${x}.name`;
return <L5Editor key={k} label={shared(k)} value={draft[k]!} defaults={defaults(k)} onChange={(v) => setDraft({ ...draft, [k]: v })} disabled={!canEdit} />;
})}
</div>
{canEdit && (
<button
type="button"
className="bo-btn bo-btn--sm"
style={{ marginTop: 14 }}
disabled={save.isPending}
onClick={() => save.mutate(keys.flatMap((k) => LANGS.map((l) => ({ key: k, locale: l, value: draft[k]![l] }))))}
>
{t('ui.save')}
</button>
)}
</section>
);
}
type Service = Catalog[number]['services'][number];
/** Цена: тарифы по варианту (комнаты, санузлы) и доп. услуги — или фиксированная цена */
function PricePanel({ service: s, canEdit }: { service: Service; canEdit: boolean }) {
const { t, shared } = useT();
const trpc = useTRPC();
const [p, setP] = useState(s.pricing);
useEffect(() => setP(s.pricing), [s.pricing]);
const save = useMutation(trpc.bo.products.saveService.mutationOptions(useSaved()));
const unitLabel = (u: string) => t(`pr.unit.${u}`);
const variantLabel = (v: string) => {
if (p.kind !== 'rooms') return v;
const k = `params.${s.code}.${p.variantParam}.${v}`;
const r = shared(k);
return r === k ? v : r;
};
const n = (v: number, on: (x: number) => void) => <input className="bo-input bo-num" inputMode="numeric" disabled={!canEdit} value={String(v)} onChange={(e) => on(Number(num(e.target.value)) || 0)} />;
return (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('pr.price')}</h2>
</div>
{p.kind === 'fixed' ? (
<div className="bo-row2">
<Field label={t('pr.fixedPrice')}>{n(p.price, (x) => setP({ ...p, price: x }))}</Field>
<Field label={t('pr.minutes')}>{n(p.minutes, (x) => setP({ ...p, minutes: Math.max(1, x) }))}</Field>
</div>
) : (
<>
<div className="bo-tablewrap">
<table className="bo-table bo-ptab">
<thead>
<tr>
<th />
<th>{t('pr.base')}</th>
<th>{t('pr.perRoom')}</th>
<th>{t('pr.perBath')}</th>
<th>{t('pr.baseMin')}</th>
<th>{t('pr.perRoomMin')}</th>
<th>{t('pr.perBathMin')}</th>
</tr>
</thead>
<tbody>
{Object.entries(p.tiers).map(([v, tier]) => {
const set = (patch: Partial<typeof tier>) => setP({ ...p, tiers: { ...p.tiers, [v]: { ...tier, ...patch } } });
return (
<tr key={v}>
<td>
<b>{variantLabel(v)}</b>
</td>
<td>{n(tier.base, (x) => set({ base: x }))}</td>
<td>{n(tier.perRoom, (x) => set({ perRoom: x }))}</td>
<td>{n(tier.perBathroom, (x) => set({ perBathroom: x }))}</td>
<td>{n(tier.baseMinutes, (x) => set({ baseMinutes: Math.max(1, x) }))}</td>
<td>{n(tier.perRoomMinutes, (x) => set({ perRoomMinutes: x }))}</td>
<td>{n(tier.perBathroomMinutes, (x) => set({ perBathroomMinutes: x }))}</td>
</tr>
);
})}
</tbody>
</table>
</div>
<p className="bo-hint" style={{ margin: '8px 0 16px' }}>
{t('pr.roomsHint')}
</p>
<h3 className="bo-h3" style={{ marginBottom: 8 }}>
{t('pr.extras')}
</h3>
<div className="bo-tablewrap">
<table className="bo-table bo-ptab">
<thead>
<tr>
<th />
<th>{t('pr.unitCol')}</th>
<th>{t('pr.extraPrice')}</th>
<th>{t('pr.extraMin')}</th>
</tr>
</thead>
<tbody>
{Object.entries(p.extras).map(([code, ex]) => {
const set = (patch: Partial<typeof ex>) => setP({ ...p, extras: { ...p.extras, [code]: { ...ex, ...patch } } });
return (
<tr key={code}>
<td>{shared(`extras.${code}.name`)}</td>
<td className="bo-small bo-muted">{unitLabel(ex.unit)}</td>
<td>{n(ex.price, (x) => set({ price: x }))}</td>
<td>{n(ex.minutes, (x) => set({ minutes: x }))}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
)}
{canEdit && (
<button type="button" className="bo-btn bo-btn--sm" style={{ marginTop: 14 }} disabled={save.isPending} onClick={() => save.mutate({ id: s.id, active: s.active, lateCancelFeePct: s.lateCancelFeePct, pricing: p, slotConfig: s.slotConfig })}>
{t('ui.save')}
</button>
)}
<p className="bo-hint" style={{ marginTop: 10 }}>
{t('pr.priceHint')}
</p>
</section>
);
}
/** Включена ли услуга, окно записи, удержание за позднюю отмену */
function SettingsPanel({ service: s, canEdit }: { service: Service; canEdit: boolean }) {
const { t } = useT();
const trpc = useTRPC();
const [f, setF] = useState({ active: s.active, fee: String(s.lateCancelFeePct), from: hhmm(s.slotConfig.dayStartMinute), to: hhmm(s.slotConfig.dayEndMinute), step: String(s.slotConfig.stepMinutes), lead: String(s.slotConfig.leadHours), horizon: String(s.slotConfig.horizonDays) });
const save = useMutation(trpc.bo.products.saveService.mutationOptions(useSaved()));
return (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('pr.settings')}</h2>
</div>
<div className="bo-col" style={{ gap: 12 }}>
<label className="bo-check">
<input type="checkbox" disabled={!canEdit} checked={f.active} onChange={(e) => setF({ ...f, active: e.target.checked })} />
{t('pr.activeLabel')}
</label>
<div className="bo-row2">
<Field label={t('pr.windowFrom')}>
<input className="bo-input" type="time" step={900} disabled={!canEdit} value={f.from} onChange={(e) => setF({ ...f, from: e.target.value })} />
</Field>
<Field label={t('pr.windowTo')}>
<input className="bo-input" type="time" step={900} disabled={!canEdit} value={f.to} onChange={(e) => setF({ ...f, to: e.target.value })} />
</Field>
</div>
<div className="bo-row2">
<Field label={t('pr.step')}>
<input className="bo-input" inputMode="numeric" disabled={!canEdit} value={f.step} onChange={(e) => setF({ ...f, step: num(e.target.value) })} />
</Field>
<Field label={t('pr.lead')}>
<input className="bo-input" inputMode="numeric" disabled={!canEdit} value={f.lead} onChange={(e) => setF({ ...f, lead: num(e.target.value) })} />
</Field>
</div>
<div className="bo-row2">
<Field label={t('pr.horizon')}>
<input className="bo-input" inputMode="numeric" disabled={!canEdit} value={f.horizon} onChange={(e) => setF({ ...f, horizon: num(e.target.value) })} />
</Field>
<Field label={t('pr.lateFee')}>
<input className="bo-input" inputMode="numeric" disabled={!canEdit} value={f.fee} onChange={(e) => setF({ ...f, fee: num(e.target.value) })} />
</Field>
</div>
</div>
{canEdit && (
<button
type="button"
className="bo-btn bo-btn--sm"
style={{ marginTop: 14 }}
disabled={save.isPending}
onClick={() =>
save.mutate({
id: s.id,
active: f.active,
lateCancelFeePct: Math.min(100, Number(f.fee) || 0),
pricing: s.pricing,
slotConfig: { dayStartMinute: toMin(f.from), dayEndMinute: toMin(f.to), stepMinutes: Math.max(15, Number(f.step) || 30), leadHours: Number(f.lead) || 0, horizonDays: Math.max(1, Number(f.horizon) || 14) },
})
}
>
{t('ui.save')}
</button>
)}
</section>
);
}
/** Скидки по уровню подписки — на всё направление */
function CategoryPanel({ category: c, canEdit }: { category: Catalog[number]; canEdit: boolean }) {
const { t, shared } = useT();
const trpc = useTRPC();
const [plus, setPlus] = useState(String(c.barevPlusDiscountPct));
const [elite, setElite] = useState(String(c.eliteDiscountPct));
const save = useMutation(trpc.bo.products.saveCategory.mutationOptions(useSaved()));
return (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('pr.discounts')}</h2>
</div>
<p className="bo-hint" style={{ marginBottom: 10 }}>
{t('pr.discountsHint', { cat: shared(`categories.${c.code}.name`) })}
</p>
<div className="bo-row2">
<Field label="Barev+, %">
<input className="bo-input" inputMode="numeric" disabled={!canEdit} value={plus} onChange={(e) => setPlus(num(e.target.value))} />
</Field>
<Field label="Barev Elite, %">
<input className="bo-input" inputMode="numeric" disabled={!canEdit} value={elite} onChange={(e) => setElite(num(e.target.value))} />
</Field>
</div>
{canEdit && (
<button type="button" className="bo-btn bo-btn--sm" style={{ marginTop: 12 }} disabled={save.isPending} onClick={() => save.mutate({ id: c.id, barevPlusDiscountPct: Math.min(100, Number(plus) || 0), eliteDiscountPct: Math.min(100, Number(elite) || 0) })}>
{t('ui.save')}
</button>
)}
</section>
);
}
/** Чек-лист исполнителя: пункты и где нужно фото */
function ChecklistPanel({ service: s, canEdit }: { service: Service; canEdit: boolean }) {
const { t, shared } = useT();
const trpc = useTRPC();
const [items, setItems] = useState<{ code?: string; label?: string; requiresPhoto: boolean }[]>(s.checklist);
useEffect(() => setItems(s.checklist), [s.checklist]);
const [label, setLabel] = useState('');
const save = useMutation(trpc.bo.products.saveChecklist.mutationOptions(useSaved()));
return (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('pr.checklist')}</h2>
</div>
<ul className="bo-list">
{items.map((x, i) => (
<li key={x.code ?? `new-${i}`}>
<span className="bo-grow">{x.code ? shared(`checklist.${x.code}`) : x.label}</span>
<label className="bo-check bo-small">
<input type="checkbox" disabled={!canEdit} checked={x.requiresPhoto} onChange={(e) => setItems(items.map((y, k) => (k === i ? { ...y, requiresPhoto: e.target.checked } : y)))} />
{t('pr.photo')}
</label>
{canEdit && (
<button type="button" className="bo-iconbtn" onClick={() => setItems(items.filter((_, k) => k !== i))} aria-label={t('chat.tplDelete')}>
<I name="close" size={16} />
</button>
)}
</li>
))}
</ul>
{canEdit && (
<>
<form
style={{ display: 'flex', gap: 8, marginTop: 10 }}
onSubmit={(e) => {
e.preventDefault();
if (!label.trim()) return;
setItems([...items, { label: label.trim(), requiresPhoto: false }]);
setLabel('');
}}
>
<input className="bo-input" value={label} maxLength={120} onChange={(e) => setLabel(e.target.value)} placeholder={t('pr.newItem')} style={{ minHeight: 36, padding: '6px 12px' }} />
<button type="submit" className="bo-btn bo-btn--ghost bo-btn--sm" disabled={!label.trim()}>
<I name="plus" size={16} />
</button>
</form>
<button type="button" className="bo-btn bo-btn--sm" style={{ marginTop: 12 }} disabled={save.isPending} onClick={() => save.mutate({ serviceId: s.id, items })}>
{t('ui.save')}
</button>
</>
)}
<p className="bo-hint" style={{ marginTop: 10 }}>
{t('pr.checklistHint')}
</p>
</section>
);
}
// ───── подписка ─────
const PLAN_KEYS = ['barevPlus.monthlyPrice', 'barevPlus.maxDiscountPct', 'barevPlus.giftDays', 'elite.monthlyPrice', 'elite.maxDiscountPct'] as const;
function Plans({ canEdit }: { canEdit: boolean }) {
const { t, shared } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const settings = useQuery({ ...trpc.bo.settings.get.queryOptions(), enabled: canEdit });
const cat = useCatalog();
const [f, setF] = useState<Record<string, string> | null>(null);
useEffect(() => {
if (settings.data && !f) setF(Object.fromEntries(PLAN_KEYS.map((k) => [k, String(settings.data[k])])));
}, [settings.data, f]);
const save = useMutation(
trpc.bo.settings.save.mutationOptions({
onSuccess: () => {
qc.invalidateQueries({ queryKey: trpc.bo.settings.get.queryKey() });
toast(t('prv.saved'));
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<div className="bo-client-grid">
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('pr.plans')}</h2>
</div>
{!canEdit ? (
<p className="bo-hint">{t('pr.plansSuper')}</p>
) : !f ? (
<Skeleton h={200} />
) : (
<>
<div className="bo-col" style={{ gap: 12 }}>
{PLAN_KEYS.map((k) => (
<Field key={k} label={shared(`admin.settings.${k.replace('.', '_')}`)}>
<input className="bo-input" inputMode="numeric" value={f[k]} onChange={(e) => setF({ ...f, [k]: num(e.target.value) })} />
</Field>
))}
</div>
<button type="button" className="bo-btn bo-btn--sm" style={{ marginTop: 14 }} disabled={save.isPending} onClick={() => save.mutate(Object.fromEntries(PLAN_KEYS.map((k) => [k, Number(f[k]) || 0])))}>
{t('ui.save')}
</button>
<p className="bo-hint" style={{ marginTop: 10 }}>
{t('pr.plansHint')}
</p>
</>
)}
</section>
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('pr.discountTable')}</h2>
</div>
{!cat ? (
<Skeleton h={160} />
) : (
<table className="bo-table">
<thead>
<tr>
<th />
<th className="bo-r">Barev+</th>
<th className="bo-r">Elite</th>
</tr>
</thead>
<tbody>
{cat.map((c) => (
<tr key={c.id}>
<td>{shared(`categories.${c.code}.name`)}</td>
<td className="bo-r bo-num">−{c.barevPlusDiscountPct}%</td>
<td className="bo-r bo-num">−{c.eliteDiscountPct}%</td>
</tr>
))}
</tbody>
</table>
)}
<p className="bo-hint" style={{ marginTop: 10 }}>
{t('pr.discountTableHint')}
</p>
</section>
</div>
);
}
// ───── промокоды ─────
type Promo = { id: string; code: string; discountPct: number; maxUses: number | null; usedCount: number; validFrom: Date | null; validUntil: Date | null; categories: string[]; note: string | null; active: boolean; state: string };
function Promos({ canEdit }: { canEdit: boolean }) {
const { t, date, shared } = useT();
const trpc = useTRPC();
const list = useQuery(trpc.bo.products.promos.queryOptions());
const [edit, setEdit] = useState<Promo | 'new' | null>(null);
return (
<section className="bo-panel">
<div className="bo-panel__head">
<p className="bo-hint" style={{ margin: 0, maxWidth: 560 }}>
{t('pr.promoHint')}
</p>
{canEdit && (
<button type="button" className="bo-btn bo-btn--sm" onClick={() => setEdit('new')}>
<I name="plus" size={16} />
{t('pr.newPromo')}
</button>
)}
</div>
{!list.data ? (
<Skeleton h={160} />
) : list.data.length === 0 ? (
<p className="bo-empty">{t('pr.noPromos')}</p>
) : (
<ul className="bo-list">
{list.data.map((p) => (
<li key={p.id}>
<span className="bo-promo">{p.code}</span>
<span className="bo-grow" style={{ minWidth: 0 }}>
<b className="bo-num">−{p.discountPct}%</b>
<span className="bo-small bo-muted">
{' '}
· {t('pr.used', { n: p.usedCount, max: p.maxUses ?? '∞' })}
{p.validUntil && ` · ${t('pr.until', { date: date(p.validUntil) })}`}
{p.categories.length > 0 && ` · ${p.categories.map((c) => shared(`categories.${c}.name`)).join(', ')}`}
{p.note && ` · ${p.note}`}
</span>
</span>
<span className={`bo-chip${p.state === 'live' ? ' bo-chip--ok' : ''}`}>{t(`pr.state.${p.state}`)}</span>
{canEdit && (
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setEdit(p)}>
{t('prv.edit')}
</button>
)}
</li>
))}
</ul>
)}
{edit && <PromoDialog promo={edit === 'new' ? undefined : edit} onClose={() => setEdit(null)} />}
</section>
);
}
function PromoDialog({ promo, onClose }: { promo?: Promo; onClose: () => void }) {
const { t, shared } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const day = (d: Date | null) => (d ? new Date(d.getTime() + 4 * 3600_000).toISOString().slice(0, 10) : '');
const [f, setF] = useState({
code: promo?.code ?? '',
pct: String(promo?.discountPct ?? 10),
max: promo?.maxUses != null ? String(promo.maxUses) : '',
from: day(promo?.validFrom ?? null),
until: day(promo?.validUntil ?? null),
categories: (promo?.categories ?? []) as (typeof CATS)[number][],
note: promo?.note ?? '',
active: promo?.active ?? true,
});
const save = useMutation(
trpc.bo.products.savePromo.mutationOptions({
onSuccess: () => {
qc.invalidateQueries({ queryKey: trpc.bo.products.promos.queryKey() });
toast(t('prv.saved'));
onClose();
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<Dialog
open
onClose={onClose}
title={promo ? t('pr.promo') : t('pr.newPromo')}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button
type="button"
className="bo-btn"
disabled={f.code.trim().length < 3 || !Number(f.pct) || save.isPending}
onClick={() =>
save.mutate({
id: promo?.id,
code: f.code,
discountPct: Math.min(100, Number(f.pct)),
maxUses: f.max ? Number(f.max) : null,
validFrom: f.from ? new Date(`${f.from}T00:00:00+04:00`) : null,
validUntil: f.until ? new Date(`${f.until}T23:59:59+04:00`) : null,
categories: f.categories,
note: f.note || null,
active: f.active,
})
}
>
{t('ui.save')}
</button>
</>
}
>
<div className="bo-row2">
<Field label={t('pr.code')}>
<input className="bo-input" value={f.code} maxLength={40} onChange={(e) => setF({ ...f, code: e.target.value.toUpperCase().replace(/[^A-Z0-9_-]/g, '') })} placeholder="AUTUMN15" />
</Field>
<Field label={t('pr.pct')}>
<input className="bo-input" inputMode="numeric" value={f.pct} onChange={(e) => setF({ ...f, pct: num(e.target.value) })} />
</Field>
</div>
<div className="bo-row2">
<Field label={t('pr.validFrom')}>
<input className="bo-input" type="date" value={f.from} onChange={(e) => setF({ ...f, from: e.target.value })} />
</Field>
<Field label={t('pr.validUntil')}>
<input className="bo-input" type="date" value={f.until} onChange={(e) => setF({ ...f, until: e.target.value })} />
</Field>
</div>
<Field label={t('pr.maxUses')} hint={t('pr.maxUsesHint')}>
<input className="bo-input" inputMode="numeric" value={f.max} onChange={(e) => setF({ ...f, max: num(e.target.value) })} placeholder="∞" />
</Field>
<div>
<span className="bo-label">{t('pr.forCats')}</span>
<div className="bo-pills" style={{ marginTop: 6 }}>
{CATS.map((c) => (
<button key={c} type="button" className="bo-pill" aria-pressed={f.categories.includes(c)} onClick={() => setF({ ...f, categories: f.categories.includes(c) ? f.categories.filter((x) => x !== c) : [...f.categories, c] })}>
{shared(`categories.${c}.name`)}
</button>
))}
</div>
<span className="bo-hint">{t('pr.forCatsHint')}</span>
</div>
<Field label={t('crm.note')}>
<input className="bo-input" value={f.note} maxLength={300} onChange={(e) => setF({ ...f, note: e.target.value })} placeholder={t('pr.promoNotePh')} />
</Field>
<label className="bo-check">
<input type="checkbox" checked={f.active} onChange={(e) => setF({ ...f, active: e.target.checked })} />
{t('pr.promoActive')}
</label>
</Dialog>
);
}
// ───── релокация ─────
type RelItem = { id: string; kind: 'STEP' | 'DOC'; code: string; name: unknown; hint: unknown; active: boolean; inTurnkey: boolean };
function Relocation({ canEdit }: { canEdit: boolean }) {
const { t, locale } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const list = useQuery(trpc.bo.products.relocation.queryOptions());
const move = useMutation(trpc.bo.products.moveRelocation.mutationOptions({ onSuccess: () => qc.invalidateQueries({ queryKey: trpc.bo.products.relocation.queryKey() }), onError: (e) => toast(errText(t, e), 'error') }));
const [edit, setEdit] = useState<{ kind: 'STEP' | 'DOC'; item?: RelItem } | null>(null);
const name = (v: unknown) => {
const o = (v ?? {}) as Record<string, string>;
return o[locale] || o.ru || '';
};
return (
<div className="bo-client-grid">
{(['STEP', 'DOC'] as const).map((kind) => (
<section key={kind} className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t(`pr.rel.${kind}`)}</h2>
{canEdit && (
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setEdit({ kind })}>
<I name="plus" size={16} />
</button>
)}
</div>
<ul className="bo-list">
{(list.data ?? [])
.filter((x) => x.kind === kind)
.map((x) => (
<li key={x.id} style={{ opacity: x.active ? 1 : 0.5 }}>
{canEdit && (
<span className="bo-stages__arrows">
<button type="button" onClick={() => move.mutate({ id: x.id, dir: -1 })} aria-label="↑">
<I name="arrowUp" size={14} />
</button>
<button type="button" onClick={() => move.mutate({ id: x.id, dir: 1 })} aria-label="↓">
<I name="arrowDown" size={14} />
</button>
</span>
)}
<span className="bo-grow" style={{ whiteSpace: 'normal' }}>
{name(x.name)}
{kind === 'STEP' && x.inTurnkey && <span className="bo-chip" style={{ marginLeft: 8 }}>{t('pr.turnkey')}</span>}
</span>
{canEdit && (
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setEdit({ kind, item: x as RelItem })}>
{t('prv.edit')}
</button>
)}
</li>
))}
</ul>
<p className="bo-hint" style={{ marginTop: 10 }}>
{t(`pr.relHint.${kind}`)}
</p>
</section>
))}
{edit && <RelocationDialog kind={edit.kind} item={edit.item} onClose={() => setEdit(null)} />}
</div>
);
}
function RelocationDialog({ kind, item, onClose }: { kind: 'STEP' | 'DOC'; item?: RelItem; onClose: () => void }) {
const { t } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const [name, setName] = useState<L5>({ ...EMPTY, ...((item?.name as Partial<L5>) ?? {}) });
const [hint, setHint] = useState<L5>({ ...EMPTY, ...((item?.hint as Partial<L5>) ?? {}) });
const [active, setActive] = useState(item?.active ?? true);
const [turnkey, setTurnkey] = useState(item?.inTurnkey ?? true);
const save = useMutation(
trpc.bo.products.saveRelocation.mutationOptions({
onSuccess: () => {
qc.invalidateQueries({ queryKey: trpc.bo.products.relocation.queryKey() });
toast(t('prv.saved'));
onClose();
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<Dialog
open
wide
onClose={onClose}
title={t(`pr.rel.${kind}`)}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button type="button" className="bo-btn" disabled={!name.ru.trim() || save.isPending} onClick={() => save.mutate({ id: item?.id, kind, name, hint: kind === 'DOC' ? hint : null, active, inTurnkey: kind === 'STEP' ? turnkey : false })}>
{t('ui.save')}
</button>
</>
}
>
<L5Editor label={t('pr.name')} value={name} defaults={{}} onChange={setName} />
{kind === 'DOC' && <L5Editor label={t('pr.docHint')} value={hint} defaults={{}} onChange={setHint} />}
{kind === 'STEP' && (
<label className="bo-check">
<input type="checkbox" checked={turnkey} onChange={(e) => setTurnkey(e.target.checked)} />
{t('pr.turnkeyLabel')}
</label>
)}
<label className="bo-check">
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
{t('pr.relActive')}
</label>
</Dialog>
);
}
+2
View File
@@ -6,6 +6,7 @@ import { getDb } from '@barev/db';
import { createT, formatDateTime, isLocale, LOCALE_NAMES, LOCALES, type Locale } from '@barev/i18n';
import { decodeStartPayload } from '@barev/core';
import {
startTextOverrides,
addClientMessage,
applyAgencyCode,
DomainError,
@@ -32,6 +33,7 @@ if (!token) console.log('CLIENT_BOT_TOKEN не задан — клиентски
const db = getDb();
const notifier = createQueueNotifier(process.env.REDIS_URL ?? 'redis://localhost:6379');
const ctx: Ctx = { db, notify: notifier, now: () => new Date() };
startTextOverrides(db);
const APP_URL = process.env.CLIENT_MINIAPP_URL ?? '';
const LANDING = process.env.LANDING_URL ?? '';
const bot = new Bot(token || 'disabled:token');
+2
View File
@@ -5,6 +5,7 @@
import { getDb } from '@barev/db';
import { createT, formatDateTime, type Locale } from '@barev/i18n';
import {
startTextOverrides,
acceptPartnerInvite,
addProviderMessage,
checkCard,
@@ -32,6 +33,7 @@ if (!token) console.log('PRO_BOT_TOKEN не задан — бот исполни
const db = getDb();
const notifier = createQueueNotifier(process.env.REDIS_URL ?? 'redis://localhost:6379');
const ctx: Ctx = { db, notify: notifier, now: () => new Date() };
startTextOverrides(db);
const APP_URL = process.env.PRO_MINIAPP_URL ?? '';
const bot = new Bot(token || 'disabled:token');
+3 -1
View File
@@ -1,11 +1,13 @@
import '@barev/ui/styles.css';
import './app.css';
import { ApiProvider, getInitData, initTelegram, ToastProvider } from '@barev/web';
import { ApiProvider, loadTextOverrides, getInitData, initTelegram, ToastProvider } from '@barev/web';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App.tsx';
initTelegram();
// названия и описания услуг, изменённые в админке
void loadTextOverrides();
const initData = getInitData();
createRoot(document.getElementById('root')!).render(
+3 -1
View File
@@ -1,12 +1,14 @@
import '@barev/ui/styles.css';
import './app.css';
import { ApiProvider, initTelegram, ToastProvider } from '@barev/web';
import { ApiProvider, loadTextOverrides, initTelegram, ToastProvider } from '@barev/web';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App.tsx';
import { authHeader, hasInitData } from './auth.ts';
initTelegram();
// названия и описания услуг, изменённые в админке
void loadTextOverrides();
createRoot(document.getElementById('root')!).render(
<StrictMode>
+61 -1
View File
@@ -4,7 +4,7 @@
*/
import { getDb } from '@barev/db';
import { createT, formatAmd, formatDateTime, type Locale } from '@barev/i18n';
import { autoAssign, chatRecipients, dueReminders, dueSalesTasks, EVENTS_QUEUE, expireOffers, retryAutoAssign, type AutoAssignResult, type Ctx, type DomainEvent } from '@barev/services';
import { autoAssign, startTextOverrides, chatRecipients, dueReminders, dueSalesTasks, EVENTS_QUEUE, expireOffers, retryAutoAssign, type AutoAssignResult, type Ctx, type DomainEvent } from '@barev/services';
import { getFile } from '@barev/services/storage';
import { loadRootEnv } from '@barev/services/env';
import { createQueueNotifier, redisConnection } from '@barev/services/queue';
@@ -17,6 +17,7 @@ const db = getDb();
const REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6379';
const notifier = createQueueNotifier(REDIS_URL);
const ctx: Ctx = { db, notify: notifier, now: () => new Date() };
startTextOverrides(db);
const clientBot = new TelegramSender(process.env.CLIENT_BOT_TOKEN ?? '', 'client-bot');
const proBot = new TelegramSender(process.env.PRO_BOT_TOKEN ?? '', 'pro-bot');
const CLIENT_APP = process.env.CLIENT_MINIAPP_URL ?? '';
@@ -46,6 +47,59 @@ async function reportAuto(orderId: string, res: AutoAssignResult) {
);
}
// ───── рассылки клиентам ─────
const running = new Set<string>();
const pause = (ms: number) => new Promise((r) => setTimeout(r, ms));
/**
* Рассылка: порциями по 25, ~25 сообщений в секунду (лимит Telegram — 30).
* Каждый получатель отмечается отдельно, поэтому после перезапуска воркера отправка продолжается с места остановки.
*/
async function runBroadcast(id: string) {
if (running.has(id)) return;
running.add(id);
try {
for (;;) {
const b = await db.broadcast.findUnique({ where: { id } });
if (!b || b.status !== 'SENDING') return;
const batch = await db.broadcastRecipient.findMany({ where: { broadcastId: id, status: 'PENDING' }, take: 25 });
if (!batch.length) {
await db.broadcast.update({ where: { id }, data: { status: 'DONE', finishedAt: new Date() } });
return;
}
const clients = await db.client.findMany({ where: { id: { in: batch.map((r) => r.clientId) } }, include: { user: true } });
const texts = b.texts as Record<string, string>;
for (const r of batch) {
const c = clients.find((x) => x.id === r.clientId);
let status: 'SENT' | 'BLOCKED' | 'FAILED' = 'FAILED';
let error: string | null = null;
if (c) {
const locale = c.user.locale as Locale;
const text = (texts[locale] || texts.ru || '').trim();
try {
const ok = await clientBot.send({
chatId: c.user.telegramId,
text: escapeHtml(text),
buttons: b.appButton ? compactRows([[webAppButton(createT(locale)('bot.client.openApp'), `${CLIENT_APP}/`)]]) : [],
});
status = ok ? 'SENT' : 'BLOCKED';
} catch (e) {
error = String(e).slice(0, 300);
}
}
await db.broadcastRecipient.update({ where: { id: r.id }, data: { status, error, sentAt: new Date() } });
await db.broadcast.update({ where: { id }, data: status === 'SENT' ? { sent: { increment: 1 } } : status === 'BLOCKED' ? { blocked: { increment: 1 } } : { failed: { increment: 1 } } });
await pause(40);
}
}
} catch (e) {
console.error('[worker] broadcast failed', id, e);
} finally {
running.delete(id);
}
}
/** Новая админка, если уже на сервере; иначе — раздел переписки в старой */
const BACKOFFICE_URL = process.env.BACKOFFICE_URL ?? '';
const chatButton = (threadId: string | null, peer: string): InlineButton | null =>
@@ -286,6 +340,10 @@ async function handle(event: DomainEvent) {
for (const a of recipients) await proBot.send({ chatId: a.telegramId!, text, buttons });
return;
}
case 'broadcast.start':
// отправка идёт вне задачи очереди — длинная рассылка не держит очередь
void runBroadcast(event.broadcastId);
return;
case 'sales.taskDue': {
const task = await db.salesTask.findUnique({ where: { id: event.taskId }, include: { assignee: true, lead: true } });
if (!task || task.doneAt || !task.assignee.telegramId || !task.assignee.active) return;
@@ -381,6 +439,8 @@ async function tick() {
for (const { orderId, res } of await retryAutoAssign(ctx)) await reportAuto(orderId, res);
// задачи продажников, у которых наступил срок
await dueSalesTasks(ctx);
// рассылки, прерванные перезапуском, — продолжить
for (const b of await db.broadcast.findMany({ where: { status: 'SENDING' }, select: { id: true } })) void runBroadcast(b.id);
} catch (e) {
console.error('[worker] tick failed', e);
} finally {
@@ -0,0 +1,60 @@
-- CreateEnum
CREATE TYPE "BroadcastStatus" AS ENUM ('SENDING', 'DONE', 'CANCELLED');
-- CreateEnum
CREATE TYPE "BroadcastRecipientStatus" AS ENUM ('PENDING', 'SENT', 'BLOCKED', 'FAILED');
-- AlterTable
ALTER TABLE "PromoCode" ADD COLUMN "categories" "CategoryCode"[] DEFAULT ARRAY[]::"CategoryCode"[],
ADD COLUMN "note" TEXT;
-- CreateTable
CREATE TABLE "TextOverride" (
"key" TEXT NOT NULL,
"locale" "Locale" NOT NULL,
"value" TEXT NOT NULL,
"updatedById" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TextOverride_pkey" PRIMARY KEY ("key","locale")
);
-- CreateTable
CREATE TABLE "Broadcast" (
"id" TEXT NOT NULL,
"texts" JSONB NOT NULL,
"appButton" BOOLEAN NOT NULL DEFAULT true,
"filters" JSONB NOT NULL,
"status" "BroadcastStatus" NOT NULL DEFAULT 'SENDING',
"total" INTEGER NOT NULL DEFAULT 0,
"sent" INTEGER NOT NULL DEFAULT 0,
"blocked" INTEGER NOT NULL DEFAULT 0,
"failed" INTEGER NOT NULL DEFAULT 0,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"finishedAt" TIMESTAMP(3),
CONSTRAINT "Broadcast_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BroadcastRecipient" (
"id" TEXT NOT NULL,
"broadcastId" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"status" "BroadcastRecipientStatus" NOT NULL DEFAULT 'PENDING',
"error" TEXT,
"sentAt" TIMESTAMP(3),
CONSTRAINT "BroadcastRecipient_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "BroadcastRecipient_broadcastId_status_idx" ON "BroadcastRecipient"("broadcastId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "BroadcastRecipient_broadcastId_clientId_key" ON "BroadcastRecipient"("broadcastId", "clientId");
-- AddForeignKey
ALTER TABLE "BroadcastRecipient" ADD CONSTRAINT "BroadcastRecipient_broadcastId_fkey" FOREIGN KEY ("broadcastId") REFERENCES "Broadcast"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+66
View File
@@ -706,6 +706,10 @@ model PromoCode {
validFrom DateTime?
validUntil DateTime?
active Boolean @default(true)
/// для команды: откуда код (рассылка, блогер, партнёр)
note String?
/// на какие направления действует; пусто — на все
categories CategoryCode[] @default([])
createdAt DateTime @default(now())
uses PromoCodeUse[]
@@ -1369,3 +1373,65 @@ model SalesTask {
@@index([assigneeId, doneAt, dueAt])
}
// ───────────── Продукты, рассылки (этап 5) ─────────────
/// Текст, изменённый в админке поверх переводов из packages/i18n (название и описание услуги, пункт чек-листа…).
/// Пустой — не храним: действует стандартный перевод
model TextOverride {
key String
locale Locale
value String
updatedById String?
updatedAt DateTime @updatedAt
@@id([key, locale])
}
enum BroadcastStatus {
SENDING
DONE
CANCELLED
}
enum BroadcastRecipientStatus {
PENDING
SENT
/// заблокировал бота или ни разу его не открывал
BLOCKED
FAILED
}
/// Рассылка клиентам в бот по сегменту (фильтры списка клиентов). Текст — на языке клиента, фолбэк — русский
model Broadcast {
id String @id @default(cuid())
/// { ru, en, zh, hy, es }
texts Json
/// кнопка «Открыть приложение» под сообщением
appButton Boolean @default(true)
/// фильтры списка клиентов, по которым собрали получателей
filters Json
status BroadcastStatus @default(SENDING)
total Int @default(0)
sent Int @default(0)
blocked Int @default(0)
failed Int @default(0)
createdById String?
createdAt DateTime @default(now())
finishedAt DateTime?
recipients BroadcastRecipient[]
}
model BroadcastRecipient {
id String @id @default(cuid())
broadcastId String
broadcast Broadcast @relation(fields: [broadcastId], references: [id], onDelete: Cascade)
clientId String
status BroadcastRecipientStatus @default(PENDING)
error String?
sentAt DateTime?
@@unique([broadcastId, clientId])
@@index([broadcastId, status])
}
+32 -1
View File
@@ -56,12 +56,43 @@ function interpolate(s: string, params?: TParams) {
* выбираются по params.count. Отсутствующий ключ возвращается как есть — его ловит `pnpm i18n:check`.
*/
const merged = new Map<Locale, Messages>();
/** Тексты, изменённые в админке (TextOverride): поверх переводов из locales/*.json */
const overrides: Partial<Record<Locale, Messages>> = {};
let overridesVersion = 0;
const listeners = new Set<() => void>();
function withFallback(locale: Locale): Messages {
let m = merged.get(locale);
if (!m) merged.set(locale, (m = { ...resources.ru, ...(resources[locale] ?? {}) }));
// свой перевод языка важнее русской правки; своя правка языка важнее всего
if (!m) merged.set(locale, (m = { ...resources.ru, ...(overrides.ru ?? {}), ...(resources[locale] ?? {}), ...(overrides[locale] ?? {}) }));
return m;
}
/**
* Подставить тексты из админки. Вызывают API, боты и воркер (из БД) и Mini App (из public.texts).
* После вызова createT отдаёт новые тексты; подписчики (React-провайдер) перерисовываются.
*/
export function setTextOverrides(rows: { key: string; locale: string; value: string }[]) {
for (const l of LOCALES) overrides[l] = {};
for (const r of rows) if (isLocale(r.locale) && r.value) overrides[r.locale]![r.key] = r.value;
merged.clear();
overridesVersion++;
for (const cb of listeners) cb();
}
/** Номер версии правок — чтобы мемоизированный t пересоздавался */
export const textOverridesVersion = () => overridesVersion;
export function onTextOverrides(cb: () => void) {
listeners.add(cb);
return () => listeners.delete(cb);
}
/** Стандартный перевод (без правок из админки) — подсказка в форме редактирования */
export function defaultText(locale: Locale, key: string): string | null {
return resources[locale]?.[key] ?? null;
}
export function createT(locale: Locale): TFunction {
return tFromMessages(locale, withFallback(locale));
}
+7 -1
View File
@@ -27,6 +27,8 @@ export type DomainEvent =
| { type: 'chat.assigned'; threadId: string; assigneeId: string; byAdminId: string }
/** срок задачи продажника — напоминание в рабочий бот */
| { type: 'sales.taskDue'; taskId: string }
/** рассылка клиентам запущена — воркер отправляет */
| { type: 'broadcast.start'; broadcastId: string }
| { type: 'lead.submitted'; leadId: string }
| { type: 'clientRequest.created'; requestId: string }
| { type: 'webRequest.created'; requestId: string };
@@ -88,7 +90,11 @@ export type DomainErrorCode =
| 'ALREADY_CONVERTED'
| 'AGENCY_CODE_FORMAT'
| 'AGENCY_CODE_TAKEN'
| 'REASON_REQUIRED';
| 'REASON_REQUIRED'
| 'PROMO_INVALID'
| 'PROMO_EXPIRED'
| 'PROMO_USED'
| 'PROMO_NOT_FOR_SERVICE';
/** Имя очереди BullMQ, через которую API и боты передают события воркеру */
export const EVENTS_QUEUE = 'barev-events';
+2
View File
@@ -16,4 +16,6 @@ export * from './clientRequests.ts';
export * from './concierge.ts';
export * from './chats.ts';
export * from './sales.ts';
export * from './texts.ts';
export * from './promo.ts';
export * from './partners.ts';
+17 -5
View File
@@ -25,14 +25,19 @@ import { getServiceByCode, type ParsedService } from './catalog.ts';
import { getMembership } from './clients.ts';
import { DomainError, type Ctx, type DomainEvent } from './context.ts';
import { getSettings } from './settings.ts';
import { findPromo } from './promo.ts';
export async function quoteForClient(ctx: Ctx, clientId: string, serviceCode: string, rawParams: Record<string, unknown>) {
export async function quoteForClient(ctx: Ctx, clientId: string, serviceCode: string, rawParams: Record<string, unknown>, promoCode?: string | null) {
const service = await getServiceByCode(ctx.db, serviceCode, { activeOnly: true });
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, service.categoryEliteDiscountPct));
return { service, params: params.values, quote: q, membership };
const memberPct = discountFor(membership.level, service.categoryDiscountPct, service.categoryEliteDiscountPct);
// промокод не складывается со скидкой подписки — берём большую
const promo = promoCode?.trim() ? await findPromo(ctx.db, { code: promoCode, clientId, category: service.category, now: ctx.now() }) : null;
const promoWins = !!promo && promo.discountPct! > memberPct;
const q = quote(service.pricing, params.values, promoWins ? promo!.discountPct! : memberPct);
return { service, params: params.values, quote: q, membership, promo: promoWins ? promo : null };
}
export async function availableSlots(ctx: Ctx, input: { serviceCode: string; params: Record<string, unknown>; dateKey: string; excludeOrderId?: string }) {
@@ -78,13 +83,15 @@ export interface CreateOrderInput {
createdBy?: { type: 'ADMIN'; id: string };
/** оператор ставит время, даже если по графику все заняты (договорился с исполнителем сам) */
allowBusySlot?: boolean;
/** промокод на скидку (применяется, если выгоднее скидки подписки) */
promoCode?: string | null;
}
/** ORD-5: подтверждение заказа → «Новый», чек-лист из шаблона, запись в историю */
export async function createOrder(ctx: Ctx, input: CreateOrderInput) {
const client = await ctx.db.client.findUniqueOrThrow({ where: { id: input.clientId }, include: { user: true } });
if (!client.consentAt) throw new DomainError('CONSENT_REQUIRED');
const { service, params, quote: q } = await quoteForClient(ctx, input.clientId, input.serviceCode, input.params);
const { service, params, quote: q, promo } = await quoteForClient(ctx, input.clientId, input.serviceCode, input.params, input.promoCode);
if (!service.isOnline && (!input.addressText?.trim() || !input.districtId)) throw new DomainError('ADDRESS_REQUIRED');
const flow = getFlow(service.flow);
@@ -95,7 +102,7 @@ export async function createOrder(ctx: Ctx, input: CreateOrderInput) {
if (!input.allowBusySlot) await assertSlotFree(tx, service, input.scheduledAt, q.durationMinutes, ctx.now());
else if (input.scheduledAt <= ctx.now()) throw new DomainError('SLOT_UNAVAILABLE');
const checklist = await tx.checklistItem.findMany({ where: { serviceId: service.id }, orderBy: { sortOrder: 'asc' } });
return tx.order.create({
const created = await tx.order.create({
data: {
clientId: input.clientId,
serviceId: service.id,
@@ -123,6 +130,11 @@ export async function createOrder(ctx: Ctx, input: CreateOrderInput) {
history: { create: input.createdBy ? { toStatus: flow.initial, actorType: 'ADMIN', actorId: input.createdBy.id, event: 'createdByStaff' } : { toStatus: flow.initial, actorType: 'CLIENT', actorId: input.clientId } },
},
});
if (promo) {
await tx.promoCodeUse.create({ data: { promoId: promo.id, clientId: input.clientId, orderId: created.id } });
await tx.promoCode.update({ where: { id: promo.id }, data: { usedCount: { increment: 1 } } });
}
return created;
},
{ isolationLevel: 'ReadCommitted' },
);
+25
View File
@@ -0,0 +1,25 @@
/**
* Промокоды (этап 5): скидка на заказ. Со скидкой подписки не складывается — действует большая.
* Один код — один раз на клиента; срок, лимит использований и направления задаются в админке.
*/
import type { CategoryCode } from '@barev/core';
import type { Db, Tx } from '@barev/db';
import { DomainError, type Ctx } from './context.ts';
export const normalizePromo = (code: string) => code.trim().toUpperCase().replace(/\s+/g, '');
export async function findPromo(db: Db | Tx, input: { code: string; clientId: string; category: CategoryCode; now: Date }) {
const promo = await db.promoCode.findUnique({ where: { code: normalizePromo(input.code) } });
if (!promo || !promo.active || promo.kind !== 'DISCOUNT' || !promo.discountPct) throw new DomainError('PROMO_INVALID');
if ((promo.validFrom && promo.validFrom > input.now) || (promo.validUntil && promo.validUntil < input.now)) throw new DomainError('PROMO_EXPIRED');
if (promo.maxUses != null && promo.usedCount >= promo.maxUses) throw new DomainError('PROMO_EXPIRED');
if (promo.categories.length && !promo.categories.includes(input.category)) throw new DomainError('PROMO_NOT_FOR_SERVICE');
if (await db.promoCodeUse.findUnique({ where: { promoId_clientId: { promoId: promo.id, clientId: input.clientId } } })) throw new DomainError('PROMO_USED');
return promo;
}
/** Проверка кода для формы заказа: какую скидку он даст */
export async function checkPromo(ctx: Ctx, input: { code: string; clientId: string; category: CategoryCode }) {
const p = await findPromo(ctx.db, { ...input, now: ctx.now() });
return { code: p.code, discountPct: p.discountPct! };
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Тексты из админки (TextOverride) поверх переводов: процессы с доступом к БД (API, боты, воркер)
* подгружают их при старте и обновляют раз в минуту.
*/
import { setTextOverrides } from '@barev/i18n';
import type { Db } from '@barev/db';
/** Ключи, которые можно менять из админки: тексты услуг, доп. услуг, чек-листов и параметров заказа */
export const EDITABLE_TEXT_PREFIXES = ['services.', 'extras.', 'checklist.', 'params.'];
export const isEditableTextKey = (key: string) => EDITABLE_TEXT_PREFIXES.some((p) => key.startsWith(p)) && key.length <= 120;
export async function loadTextOverrides(db: Db) {
const rows = await db.textOverride.findMany({ select: { key: true, locale: true, value: true } });
setTextOverrides(rows);
return rows;
}
let timer: ReturnType<typeof setInterval> | null = null;
export function startTextOverrides(db: Db, everyMs = 60_000) {
const run = () => loadTextOverrides(db).catch((e) => console.warn('[texts] не удалось загрузить правки текстов', e));
void run();
if (!timer) {
timer = setInterval(run, everyMs);
timer.unref?.();
}
}
+78
View File
@@ -0,0 +1,78 @@
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import { createT } from '@barev/i18n';
import { createOrder, DomainError, giveConsent, grantSubscription, loadTextOverrides, quoteForClient, startClient } from '../src/index.ts';
import { db, fixtures, makeCtx, NOW, slotAt, tgUser } from './helpers.ts';
let fx: Awaited<ReturnType<typeof fixtures>>;
beforeEach(async () => {
fx = await fixtures();
});
afterAll(async () => {
await db.textOverride.deleteMany({});
await loadTextOverrides(db);
await db.$disconnect();
});
const code = (p: Promise<unknown>) => p.then(() => 'OK', (e) => (e instanceof DomainError ? e.code : String(e)));
const params = { cleaningType: 'regular', rooms: 2 };
async function client(ctx: ReturnType<typeof makeCtx>['ctx'], tg: number) {
const s = await startClient(ctx, tgUser(tg));
await giveConsent(ctx, s.clientId);
return s.clientId;
}
describe('промокоды', () => {
it('скидка по коду; один раз на клиента; не складывается с подпиской — берётся большая', async () => {
const { ctx } = makeCtx(NOW);
await db.promoCode.create({ data: { code: 'AUTUMN20', kind: 'DISCOUNT', discountPct: 20 } });
await db.promoCode.create({ data: { code: 'SMALL5', kind: 'DISCOUNT', discountPct: 5 } });
const c = await client(ctx, 901);
const q = await quoteForClient(ctx, c, 'cleaning', params, 'autumn20');
expect(q.quote).toMatchObject({ basePrice: 19000, discountPct: 20, totalPrice: 15200 });
expect(q.promo?.code).toBe('AUTUMN20');
const o = await createOrder(ctx, { clientId: c, serviceCode: 'cleaning', params, scheduledAt: slotAt(2, 11), addressText: 'Pushkin 10', districtId: fx.kentron.id, promoCode: 'AUTUMN20' });
expect(o.totalPrice).toBe(15200);
expect((await db.promoCode.findUniqueOrThrow({ where: { code: 'AUTUMN20' } })).usedCount).toBe(1);
expect(await code(quoteForClient(ctx, c, 'cleaning', params, 'AUTUMN20'))).toBe('PROMO_USED');
// подписчик со скидкой 10%: код на 5% не нужен — остаётся скидка подписки, код не тратится
const member = await client(ctx, 902);
await grantSubscription(ctx, { clientId: member, plan: 'BAREV_PLUS', days: 30 });
const m = await quoteForClient(ctx, member, 'cleaning', params, 'SMALL5');
expect(m.quote.discountPct).toBe(10);
expect(m.promo).toBeNull();
});
it('выключен, истёк, исчерпан или не для этого направления — понятная ошибка', async () => {
const { ctx } = makeCtx(NOW);
const c = await client(ctx, 903);
await db.promoCode.create({ data: { code: 'OFF', kind: 'DISCOUNT', discountPct: 10, active: false } });
await db.promoCode.create({ data: { code: 'OLD', kind: 'DISCOUNT', discountPct: 10, validUntil: new Date(NOW.getTime() - 1000) } });
await db.promoCode.create({ data: { code: 'FULL', kind: 'DISCOUNT', discountPct: 10, maxUses: 1, usedCount: 1 } });
await db.promoCode.create({ data: { code: 'DOCSONLY', kind: 'DISCOUNT', discountPct: 10, categories: ['DOCS'] } });
expect(await code(quoteForClient(ctx, c, 'cleaning', params, 'NOPE'))).toBe('PROMO_INVALID');
expect(await code(quoteForClient(ctx, c, 'cleaning', params, 'OFF'))).toBe('PROMO_INVALID');
expect(await code(quoteForClient(ctx, c, 'cleaning', params, 'OLD'))).toBe('PROMO_EXPIRED');
expect(await code(quoteForClient(ctx, c, 'cleaning', params, 'FULL'))).toBe('PROMO_EXPIRED');
expect(await code(quoteForClient(ctx, c, 'cleaning', params, 'DOCSONLY'))).toBe('PROMO_NOT_FOR_SERVICE');
});
});
describe('тексты из админки', () => {
it('правка подменяет стандартный текст, свой перевод языка важнее русской правки', async () => {
const before = createT('ru')('services.cleaning.name');
await db.textOverride.create({ data: { key: 'services.cleaning.name', locale: 'ru', value: 'Уборка квартиры' } });
await db.textOverride.create({ data: { key: 'services.cleaning.name', locale: 'hy', value: 'Բնակարանի մաքրում' } });
await loadTextOverrides(db);
expect(createT('ru')('services.cleaning.name')).toBe('Уборка квартиры');
expect(createT('hy')('services.cleaning.name')).toBe('Բնակարանի մաքրում');
// у английского свой перевод — русская правка его не трогает
expect(createT('en')('services.cleaning.name')).not.toBe('Уборка квартиры');
await db.textOverride.deleteMany({});
await loadTextOverrides(db);
expect(createT('ru')('services.cleaning.name')).toBe(before);
});
});
+22 -2
View File
@@ -1,5 +1,5 @@
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import { createT, formatAmd, formatDateTime, isLocale, type Locale, type TFunction } from '@barev/i18n';
import { createT, formatAmd, formatDateTime, isLocale, onTextOverrides, setTextOverrides, textOverridesVersion, type Locale, type TFunction } from '@barev/i18n';
import { reportClientError } from './telegram.ts';
interface I18nValue {
@@ -26,6 +26,12 @@ export function I18nProvider({ initial, children }: { initial: Locale; children:
useEffect(() => {
document.documentElement.lang = locale;
}, [locale]);
// тексты из админки подгрузились — перерисовать
const [version, setVersion] = useState(textOverridesVersion());
useEffect(() => {
const off = onTextOverrides(() => setVersion(textOverridesVersion()));
return () => void off();
}, []);
const value = useMemo<I18nValue>(
() => ({
locale,
@@ -34,7 +40,8 @@ export function I18nProvider({ initial, children }: { initial: Locale; children:
amd: (n) => formatAmd(locale, n),
dt: (d, opts) => formatDateTime(locale, d, opts),
}),
[locale],
// eslint-disable-next-line react-hooks/exhaustive-deps
[locale, version],
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
@@ -56,3 +63,16 @@ export function errorText(t: TFunction, err: unknown): string {
}
return t('errors.generic');
}
/** Тексты, изменённые в админке (названия и описания услуг и т.п.) — подгрузить с API при старте Mini App */
export async function loadTextOverrides(url = '/trpc/public.texts') {
try {
const res = await fetch(url);
if (!res.ok) return;
const body = (await res.json()) as { result?: { data?: { json?: { key: string; locale: string; value: string }[] } } };
const rows = body.result?.data?.json;
if (rows) setTextOverrides(rows);
} catch {
// нет сети — остаются стандартные тексты
}
}
+3
View File
@@ -236,6 +236,9 @@ importers:
'@barev/db':
specifier: workspace:*
version: link:../../packages/db
'@barev/i18n':
specifier: workspace:*
version: link:../../packages/i18n
'@barev/services':
specifier: workspace:*
version: link:../../packages/services