Products: create services from the admin
- "New service": from scratch (category, who does it — cleaner, driver, career consultant or docs agent —, fixed price and duration, online, name and description in 5 languages) or as a copy of an existing service (prices, questions, checklist, booking window and question texts copied). Created switched off; delete only without orders; reorder within a category. - "Questions in the order form" builder for fixed-price services: choice, number, text, yes/no, required flag; labels stored as text overrides. - The admin itself now loads text overrides, so new service names show right away; the mini app hides missing optional texts (description, option hints) instead of showing keys. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
0edc6432b7
commit
8866616782
8 files changed
+599
-13
No files matched your search
@@ -1,6 +1,6 @@
|
||||
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 { CATEGORY_CODES, DAY, fromPrice, paramsSchemaSchema, pricingSchema, ROLE_FLOWS, slotConfigSchema } from '@barev/core';
|
||||
import { createT, 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';
|
||||
@@ -11,6 +11,18 @@ 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) });
|
||||
|
||||
/**
|
||||
* С какими исполнителями можно завести новую услугу с нуля: у них простой сценарий заказа.
|
||||
* Ремонт (смета) и уроки (пакет занятий) — только копией существующей услуги.
|
||||
*/
|
||||
const NEW_SERVICE_ROLES = ['CLEANER', 'DRIVER', 'CAREER_CONSULTANT', 'DOCS_AGENT'] as const;
|
||||
const DEFAULT_SLOTS = { dayStartMinute: 9 * 60, dayEndMinute: 20 * 60, stepMinutes: 60, leadHours: 3, horizonDays: 14 };
|
||||
|
||||
/** Ключи подписей вопросов услуги: params.<код>.<поле>.label и варианты */
|
||||
function paramKeys(schema: { fields: { key: string; type: string; options?: string[] }[] }, code: string) {
|
||||
return schema.fields.flatMap((f) => [`params.${code}.${f.key}.label`, ...(f.options ?? []).flatMap((o) => [`params.${code}.${f.key}.${o}`, `params.${code}.${f.key}.${o}_hint`])]);
|
||||
}
|
||||
|
||||
/** Текст по ключу на 5 языках: стандартный (из переводов) и изменённый в админке */
|
||||
async function textsFor(db: import('@barev/db').Db, keys: string[]) {
|
||||
const rows = await db.textOverride.findMany({ where: { key: { in: keys } } });
|
||||
@@ -105,6 +117,141 @@ export const boProductsRouter = router({
|
||||
await loadTextOverrides(ctx.db);
|
||||
}),
|
||||
|
||||
// ───── новые услуги ─────
|
||||
|
||||
/**
|
||||
* Новая услуга: с нуля (фикс-цена, свои вопросы клиенту) или копия существующей (с её ценами, вопросами и чек-листом).
|
||||
* Код генерируется; название и описание — тексты из админки. Создаётся выключенной: включите, когда всё настроено.
|
||||
*/
|
||||
createService: edit
|
||||
.input(
|
||||
z.object({
|
||||
categoryId: z.string(),
|
||||
name: localized.extend({ ru: z.string().trim().min(1).max(120) }),
|
||||
description: localized,
|
||||
copyFromId: z.string().optional(),
|
||||
providerRole: z.enum(NEW_SERVICE_ROLES).optional(),
|
||||
isOnline: z.boolean().default(false),
|
||||
price: z.number().int().min(0).default(0),
|
||||
minutes: z.number().int().min(15).max(24 * 60).default(60),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const code = `s${randomBytes(4).toString('hex')}`;
|
||||
const src = input.copyFromId ? await ctx.db.service.findUnique({ where: { id: input.copyFromId }, include: { checklist: { orderBy: { sortOrder: 'asc' } } } }) : null;
|
||||
if (input.copyFromId && !src) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||
if (!src && !input.providerRole) throw new TRPCError({ code: 'BAD_REQUEST', message: 'ROLE_REQUIRED' });
|
||||
const role = src?.providerRole ?? input.providerRole!;
|
||||
const last = await ctx.db.service.findFirst({ where: { categoryId: input.categoryId }, orderBy: { sortOrder: 'desc' } });
|
||||
const created = await ctx.db.service.create({
|
||||
data: {
|
||||
code,
|
||||
categoryId: input.categoryId,
|
||||
flow: src?.flow ?? ROLE_FLOWS[role][0]!,
|
||||
providerRole: role,
|
||||
isOnline: src?.isOnline ?? input.isOnline,
|
||||
pricing: src ? (src.pricing as object) : { kind: 'fixed', price: input.price, minutes: input.minutes },
|
||||
paramsSchema: src ? (src.paramsSchema as object) : { fields: [] },
|
||||
slotConfig: src ? (src.slotConfig as object) : DEFAULT_SLOTS,
|
||||
lateCancelFeePct: src?.lateCancelFeePct ?? 30,
|
||||
active: false,
|
||||
sortOrder: (last?.sortOrder ?? 0) + 1,
|
||||
checklist: src ? { create: src.checklist.map((c) => ({ code: c.code, requiresPhoto: c.requiresPhoto, sortOrder: c.sortOrder })) } : undefined,
|
||||
},
|
||||
});
|
||||
// тексты: название и описание; у копии — ещё подписи вопросов (params.<код>.*) на всех языках
|
||||
const rows: { key: string; locale: Locale; value: string }[] = [];
|
||||
for (const l of LOCALES) {
|
||||
if (input.name[l]?.trim()) rows.push({ key: `services.${code}.name`, locale: l, value: input.name[l].trim() });
|
||||
if (input.description[l]?.trim()) rows.push({ key: `services.${code}.description`, locale: l, value: input.description[l].trim() });
|
||||
}
|
||||
if (src) {
|
||||
for (const l of LOCALES) {
|
||||
const t = createT(l);
|
||||
for (const key of paramKeys(paramsSchemaSchema.parse(src.paramsSchema), src.code)) {
|
||||
const v = t(key);
|
||||
if (v !== key) rows.push({ key: key.replace(`params.${src.code}.`, `params.${code}.`), locale: l, value: v });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const r of rows) await ctx.db.textOverride.create({ data: { ...r, updatedById: ctx.admin.id } });
|
||||
await loadTextOverrides(ctx.db);
|
||||
return { id: created.id };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Вопросы клиенту при заказе (для услуг с фикс-ценой): выбор из вариантов, число, текст, да/нет.
|
||||
* Подписи — по-русски (в других языках клиент увидит русский, пока нет перевода).
|
||||
*/
|
||||
saveParams: edit
|
||||
.input(
|
||||
z.object({
|
||||
serviceId: z.string(),
|
||||
fields: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string().max(40).optional(),
|
||||
type: z.enum(['select', 'int', 'text', 'bool']),
|
||||
label: z.string().trim().min(1).max(120),
|
||||
required: z.boolean(),
|
||||
options: z.array(z.object({ key: z.string().max(40).optional(), label: z.string().trim().min(1).max(120) })).max(20).default([]),
|
||||
min: z.number().int().min(0).max(1000).default(1),
|
||||
max: z.number().int().min(1).max(1000).default(10),
|
||||
}),
|
||||
)
|
||||
.max(12),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const s = await ctx.db.service.findUnique({ where: { id: input.serviceId } });
|
||||
if (!s) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||
if (pricingSchema.parse(s.pricing).kind !== 'fixed') throw new TRPCError({ code: 'BAD_REQUEST', message: 'PARAMS_LOCKED' });
|
||||
const short = () => randomBytes(2).toString('hex');
|
||||
const fields = input.fields.map((f) => ({ ...f, key: f.key || `q${short()}`, options: f.options.map((o) => ({ ...o, key: o.key || `o${short()}` })) }));
|
||||
for (const f of fields) if (f.type === 'select' && f.options.length < 2) throw new TRPCError({ code: 'BAD_REQUEST', message: 'OPTIONS_REQUIRED' });
|
||||
const schema = {
|
||||
fields: fields.map((f) =>
|
||||
f.type === 'select'
|
||||
? { key: f.key, type: 'select' as const, options: f.options.map((o) => o.key), required: f.required }
|
||||
: f.type === 'int'
|
||||
? { key: f.key, type: 'int' as const, min: Math.min(f.min, f.max), max: Math.max(f.min, f.max), default: Math.min(f.min, f.max), required: f.required }
|
||||
: f.type === 'text'
|
||||
? { key: f.key, type: 'text' as const, maxLength: 1000, required: f.required }
|
||||
: { key: f.key, type: 'bool' as const, default: false, required: false },
|
||||
),
|
||||
};
|
||||
paramsSchemaSchema.parse(schema);
|
||||
await ctx.db.service.update({ where: { id: s.id }, data: { paramsSchema: schema } });
|
||||
// подписи: старые подписи вопросов этой услуги убираем, новые пишем
|
||||
await ctx.db.textOverride.deleteMany({ where: { key: { startsWith: `params.${s.code}.` }, locale: 'ru' } });
|
||||
for (const f of fields) {
|
||||
await ctx.db.textOverride.create({ data: { key: `params.${s.code}.${f.key}.label`, locale: 'ru', value: f.label, updatedById: ctx.admin.id } });
|
||||
for (const o of f.options) await ctx.db.textOverride.create({ data: { key: `params.${s.code}.${f.key}.${o.key}`, locale: 'ru', value: o.label, updatedById: ctx.admin.id } });
|
||||
}
|
||||
await loadTextOverrides(ctx.db);
|
||||
}),
|
||||
|
||||
/** Удалить можно только услугу без заказов; иначе — выключить */
|
||||
deleteService: edit.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
const s = await ctx.db.service.findUnique({ where: { id: input.id }, include: { _count: { select: { orders: true, webRequests: true } } } });
|
||||
if (!s) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||
if (s._count.orders || s._count.webRequests) throw new TRPCError({ code: 'BAD_REQUEST', message: 'SERVICE_HAS_ORDERS' });
|
||||
await ctx.db.service.delete({ where: { id: s.id } });
|
||||
await ctx.db.textOverride.deleteMany({ where: { OR: [{ key: { startsWith: `services.${s.code}.` } }, { key: { startsWith: `params.${s.code}.` } }] } });
|
||||
await loadTextOverrides(ctx.db);
|
||||
}),
|
||||
|
||||
/** Порядок услуг в направлении — как в каталоге у клиента */
|
||||
moveService: edit.input(z.object({ id: z.string(), dir: z.union([z.literal(-1), z.literal(1)]) })).mutation(async ({ ctx, input }) => {
|
||||
const s = await ctx.db.service.findUniqueOrThrow({ where: { id: input.id } });
|
||||
const list = await ctx.db.service.findMany({ where: { categoryId: s.categoryId }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] });
|
||||
const i = list.findIndex((x) => x.id === s.id);
|
||||
const j = i + input.dir;
|
||||
if (j < 0 || j >= list.length) return;
|
||||
[list[i], list[j]] = [list[j]!, list[i]!];
|
||||
await ctx.db.$transaction(list.map((x, k) => ctx.db.service.update({ where: { id: x.id }, data: { sortOrder: k } })));
|
||||
}),
|
||||
|
||||
// ───── промокоды ─────
|
||||
|
||||
promos: view.query(async ({ ctx }) => {
|
||||
|
||||
@@ -116,3 +116,10 @@
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* вопрос клиенту в редакторе услуги */
|
||||
.bo-qfield {
|
||||
padding: 12px;
|
||||
border-radius: 16px;
|
||||
background: var(--bo-soft);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createT as createSharedT, formatAmd, formatDateTime, type Locale as SharedLocale } from '@barev/i18n';
|
||||
import { createT as createSharedT, formatAmd, formatDateTime, onTextOverrides, textOverridesVersion, type Locale as SharedLocale } from '@barev/i18n';
|
||||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
|
||||
/** Языки новой админки */
|
||||
@@ -386,6 +386,47 @@ const ru = {
|
||||
'chat.notLinked': 'Рабочий бот не открыт',
|
||||
'chat.details': 'Подробнее',
|
||||
'chat.more': 'Ещё действия',
|
||||
'pr.newService': 'Новая услуга',
|
||||
'pr.newServiceHint': 'С нуля — с фиксированной ценой и вашими вопросами клиенту, или копией готовой услуги.',
|
||||
'pr.fromScratch': 'С нуля',
|
||||
'pr.copyOf': 'Копия готовой',
|
||||
'pr.category': 'Направление',
|
||||
'pr.copyFrom': 'Скопировать услугу',
|
||||
'pr.copyHint': 'Скопируются цены, вопросы клиенту, чек-лист и окно записи — потом поменяете что нужно. Так можно сделать, например, «Генеральную уборку офиса» из клининга.',
|
||||
'pr.who': 'Кто выполняет',
|
||||
'pr.whoHint': 'Определяет, кому система предлагает заказ и какие шаги видит исполнитель. Ремонт и уроки — только копией готовой услуги.',
|
||||
'pr.flowHint.CLEANER': 'выехал → работа → готово, с чек-листом',
|
||||
'pr.flowHint.DRIVER': 'водитель на месте → в пути → завершено',
|
||||
'pr.flowHint.CAREER_CONSULTANT': 'консультант → материалы → встреча → итог',
|
||||
'pr.flowHint.DOCS_AGENT': 'проверка документов → подача → готово',
|
||||
'pr.online': 'Онлайн — без адреса и выезда',
|
||||
'pr.namePh': 'Например: Мойка окон',
|
||||
'pr.create': 'Создать услугу',
|
||||
'pr.createHint': 'Услуга появится выключенной: настройте цену, вопросы и чек-лист, затем включите её в «Запись и отмена» — и она сразу появится в приложении.',
|
||||
'pr.created': 'Услуга создана — осталось настроить и включить',
|
||||
'pr.questions': 'Вопросы клиенту при заказе',
|
||||
'pr.questionsHint': 'Что клиент выбирает или пишет в форме заказа. Ответы видят исполнитель и команда.',
|
||||
'pr.qLabel': 'Вопрос, например «Сколько окон?»',
|
||||
'pr.qType.select': 'выбор варианта',
|
||||
'pr.qType.int': 'число',
|
||||
'pr.qType.text': 'текст',
|
||||
'pr.qType.bool': 'да / нет',
|
||||
'pr.qOption': 'Вариант {n}',
|
||||
'pr.qAddOption': 'вариант',
|
||||
'pr.qFrom': 'от',
|
||||
'pr.qTo': 'до',
|
||||
'pr.qRequired': 'обязательно',
|
||||
'pr.qAdd': 'Вопрос',
|
||||
'pr.qNone': 'Вопросов нет — клиент выберет только время и адрес.',
|
||||
'pr.delete': 'Удалить услугу',
|
||||
'pr.deleteConfirm': 'Удалить услугу? Можно, только если по ней ещё не было заказов — иначе выключите её.',
|
||||
'err.SERVICE_HAS_ORDERS': 'По услуге уже были заказы — удалить нельзя, выключите её в «Запись и отмена».',
|
||||
'err.OPTIONS_REQUIRED': 'У вопроса с выбором нужно минимум 2 варианта.',
|
||||
'err.PARAMS_LOCKED': 'У этой услуги цена зависит от ответов — вопросы меняются программистом.',
|
||||
'audit.bo.products.createService': 'создал(а) услугу',
|
||||
'audit.bo.products.saveParams': 'изменил(а) вопросы услуги',
|
||||
'audit.bo.products.deleteService': 'удалил(а) услугу',
|
||||
'audit.bo.products.moveService': 'изменил(а) порядок услуг',
|
||||
'audit.bo.crm.addTask': 'поставил(а) задачу',
|
||||
'audit.bo.crm.setTaskDone': 'отметил(а) задачу',
|
||||
'audit.bo.crm.removeTask': 'удалил(а) задачу',
|
||||
@@ -1441,6 +1482,47 @@ const en: Dict = {
|
||||
'chat.notLinked': 'Work bot not opened',
|
||||
'chat.details': 'Details',
|
||||
'chat.more': 'More actions',
|
||||
'pr.newService': 'New service',
|
||||
'pr.newServiceHint': 'From scratch with a fixed price and your own questions, or as a copy of an existing service.',
|
||||
'pr.fromScratch': 'From scratch',
|
||||
'pr.copyOf': 'Copy of existing',
|
||||
'pr.category': 'Category',
|
||||
'pr.copyFrom': 'Copy service',
|
||||
'pr.copyHint': 'Prices, questions, checklist and booking window are copied — change what you need. E.g. an “Office deep clean” from cleaning.',
|
||||
'pr.who': 'Who does it',
|
||||
'pr.whoHint': 'Decides who gets the order and which steps the provider sees. Repairs and lessons — only as a copy.',
|
||||
'pr.flowHint.CLEANER': 'on the way → work → done, with a checklist',
|
||||
'pr.flowHint.DRIVER': 'driver arrived → riding → done',
|
||||
'pr.flowHint.CAREER_CONSULTANT': 'consultant → materials → meeting → result',
|
||||
'pr.flowHint.DOCS_AGENT': 'document check → submission → done',
|
||||
'pr.online': 'Online — no address or visit',
|
||||
'pr.namePh': 'E.g. Window cleaning',
|
||||
'pr.create': 'Create service',
|
||||
'pr.createHint': 'The service starts switched off: set price, questions and checklist, then switch it on — it appears in the app right away.',
|
||||
'pr.created': 'Service created — set it up and switch it on',
|
||||
'pr.questions': 'Questions in the order form',
|
||||
'pr.questionsHint': 'What the client picks or types when ordering. The provider and team see the answers.',
|
||||
'pr.qLabel': 'Question, e.g. “How many windows?”',
|
||||
'pr.qType.select': 'choice',
|
||||
'pr.qType.int': 'number',
|
||||
'pr.qType.text': 'text',
|
||||
'pr.qType.bool': 'yes / no',
|
||||
'pr.qOption': 'Option {n}',
|
||||
'pr.qAddOption': 'option',
|
||||
'pr.qFrom': 'from',
|
||||
'pr.qTo': 'to',
|
||||
'pr.qRequired': 'required',
|
||||
'pr.qAdd': 'Question',
|
||||
'pr.qNone': 'No questions — the client picks only time and address.',
|
||||
'pr.delete': 'Delete service',
|
||||
'pr.deleteConfirm': 'Delete the service? Only possible without orders — otherwise switch it off.',
|
||||
'err.SERVICE_HAS_ORDERS': 'The service has orders — switch it off instead.',
|
||||
'err.OPTIONS_REQUIRED': 'A choice question needs at least 2 options.',
|
||||
'err.PARAMS_LOCKED': 'This service’s price depends on the answers — questions are changed by a developer.',
|
||||
'audit.bo.products.createService': 'created a service',
|
||||
'audit.bo.products.saveParams': 'edited service questions',
|
||||
'audit.bo.products.deleteService': 'deleted a service',
|
||||
'audit.bo.products.moveService': 'reordered services',
|
||||
'audit.bo.crm.addTask': 'added a task',
|
||||
'audit.bo.crm.setTaskDone': 'updated a task',
|
||||
'audit.bo.crm.removeTask': 'deleted a task',
|
||||
@@ -2493,6 +2575,47 @@ const hy: Dict = {
|
||||
'chat.notLinked': 'Աշխատանքային բոտը բացված չէ',
|
||||
'chat.details': 'Մանրամասներ',
|
||||
'chat.more': 'Այլ գործողություններ',
|
||||
'pr.newService': 'Նոր ծառայություն',
|
||||
'pr.newServiceHint': 'Զրոյից՝ ֆիքսված գնով, կամ պատրաստի ծառայության պատճենով։',
|
||||
'pr.fromScratch': 'Զրոյից',
|
||||
'pr.copyOf': 'Պատրաստիի պատճեն',
|
||||
'pr.category': 'Ուղղություն',
|
||||
'pr.copyFrom': 'Պատճենել ծառայությունը',
|
||||
'pr.copyHint': 'Կպատճենվեն գները, հարցերը, ստուգացանկը։',
|
||||
'pr.who': 'Ով է կատարում',
|
||||
'pr.whoHint': 'Որոշում է, ում է առաջարկվում պատվերը։',
|
||||
'pr.flowHint.CLEANER': 'ճանապարհին → աշխատանք → պատրաստ',
|
||||
'pr.flowHint.DRIVER': 'վարորդը տեղում → ճանապարհին → ավարտ',
|
||||
'pr.flowHint.CAREER_CONSULTANT': 'խորհրդատու → հանդիպում → արդյունք',
|
||||
'pr.flowHint.DOCS_AGENT': 'ստուգում → ներկայացում → պատրաստ',
|
||||
'pr.online': 'Առցանց՝ առանց հասցեի',
|
||||
'pr.namePh': 'Օրինակ՝ Պատուհանների լվացում',
|
||||
'pr.create': 'Ստեղծել ծառայությունը',
|
||||
'pr.createHint': 'Ծառայությունը կստեղծվի անջատված՝ կարգավորեք և միացրեք։',
|
||||
'pr.created': 'Ծառայությունը ստեղծված է',
|
||||
'pr.questions': 'Հարցեր պատվերի ժամանակ',
|
||||
'pr.questionsHint': 'Ինչ է ընտրում հաճախորդը պատվերի ձևում։',
|
||||
'pr.qLabel': 'Հարց',
|
||||
'pr.qType.select': 'ընտրություն',
|
||||
'pr.qType.int': 'թիվ',
|
||||
'pr.qType.text': 'տեքստ',
|
||||
'pr.qType.bool': 'այո / ոչ',
|
||||
'pr.qOption': 'Տարբերակ {n}',
|
||||
'pr.qAddOption': 'տարբերակ',
|
||||
'pr.qFrom': 'սկսած',
|
||||
'pr.qTo': 'մինչև',
|
||||
'pr.qRequired': 'պարտադիր',
|
||||
'pr.qAdd': 'Հարց',
|
||||
'pr.qNone': 'Հարցեր չկան։',
|
||||
'pr.delete': 'Ջնջել ծառայությունը',
|
||||
'pr.deleteConfirm': 'Ջնջե՞լ ծառայությունը։',
|
||||
'err.SERVICE_HAS_ORDERS': 'Ծառայությունն ունի պատվերներ՝ անջատեք այն։',
|
||||
'err.OPTIONS_REQUIRED': 'Անհրաժեշտ է նվազագույնը 2 տարբերակ։',
|
||||
'err.PARAMS_LOCKED': 'Այս ծառայության հարցերը փոխում է ծրագրավորողը։',
|
||||
'audit.bo.products.createService': 'ստեղծեց ծառայություն',
|
||||
'audit.bo.products.saveParams': 'փոխեց ծառայության հարցերը',
|
||||
'audit.bo.products.deleteService': 'ջնջեց ծառայությունը',
|
||||
'audit.bo.products.moveService': 'փոխեց հերթականությունը',
|
||||
'audit.bo.crm.addTask': 'ավելացրեց առաջադրանք',
|
||||
'audit.bo.crm.setTaskDone': 'նշեց առաջադրանքը',
|
||||
'audit.bo.crm.removeTask': 'ջնջեց առաջադրանքը',
|
||||
@@ -3214,6 +3337,12 @@ export function BoI18nProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = locale;
|
||||
}, [locale]);
|
||||
// тексты, изменённые в «Продуктах» (названия услуг и т.п.), — перерисовать, когда подгрузились
|
||||
const [textsVersion, setTextsVersion] = useState(textOverridesVersion());
|
||||
useEffect(() => {
|
||||
const off = onTextOverrides(() => setTextsVersion(textOverridesVersion()));
|
||||
return () => void off();
|
||||
}, []);
|
||||
const value = useMemo<Ctx>(() => {
|
||||
const sharedT = createSharedT(locale as SharedLocale);
|
||||
const intl = locale === 'en' ? 'en-GB' : locale === 'hy' ? 'hy-AM' : 'ru-RU';
|
||||
@@ -3237,7 +3366,8 @@ export function BoI18nProvider({ children }: { children: ReactNode }) {
|
||||
dateTime: (d) => formatDateTime(locale as SharedLocale, d, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }),
|
||||
time: (d) => formatDateTime(locale as SharedLocale, d, { hour: '2-digit', minute: '2-digit' }),
|
||||
};
|
||||
}, [locale]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [locale, textsVersion]);
|
||||
return <I18nCtx.Provider value={value}>{children}</I18nCtx.Provider>;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,15 @@ import './chats.css';
|
||||
import './orders.css';
|
||||
import './crm.css';
|
||||
import './admin5.css';
|
||||
import { ApiProvider, ToastProvider } from '@barev/web';
|
||||
import { ApiProvider, loadTextOverrides, ToastProvider } from '@barev/web';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App.tsx';
|
||||
import { BoI18nProvider } from './i18n.tsx';
|
||||
|
||||
// названия услуг и другие тексты, изменённые в «Продуктах»
|
||||
void loadTextOverrides();
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ApiProvider withCredentials>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { navigate, useLocation, useToast, useTRPC } from '@barev/web';
|
||||
import { loadTextOverrides, 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';
|
||||
@@ -36,7 +36,7 @@ export function Products({ me }: { me: Me }) {
|
||||
))}
|
||||
</div>
|
||||
{!canEdit && <p className="bo-hint" style={{ marginBottom: 12 }}>{t('pr.readOnly')}</p>}
|
||||
{tab === 'services' && <Services />}
|
||||
{tab === 'services' && <Services canEdit={canEdit} />}
|
||||
{tab === 'plans' && <Plans canEdit={canEdit} />}
|
||||
{tab === 'promos' && <Promos canEdit={canEdit} />}
|
||||
{tab === 'relocation' && <Relocation canEdit={canEdit} />}
|
||||
@@ -46,13 +46,26 @@ export function Products({ me }: { me: Me }) {
|
||||
|
||||
// ───── услуги ─────
|
||||
|
||||
function Services() {
|
||||
function Services({ canEdit }: { canEdit: boolean }) {
|
||||
const { t, shared, amd } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const cat = useQuery(trpc.bo.products.catalog.queryOptions());
|
||||
const [creating, setCreating] = useState(false);
|
||||
const move = useMutation(trpc.bo.products.moveService.mutationOptions({ onSuccess: () => qc.invalidateQueries({ queryKey: trpc.bo.products.catalog.queryKey() }) }));
|
||||
if (!cat.data) return <Skeleton h={300} />;
|
||||
return (
|
||||
<div className="bo-col" style={{ gap: 18 }}>
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button type="button" className="bo-btn bo-btn--accent" onClick={() => setCreating(true)}>
|
||||
<I name="plus" size={16} />
|
||||
{t('pr.newService')}
|
||||
</button>
|
||||
<span className="bo-hint">{t('pr.newServiceHint')}</span>
|
||||
</div>
|
||||
)}
|
||||
{creating && <NewServiceDialog catalog={cat.data} onClose={() => setCreating(false)} />}
|
||||
{cat.data.map((c) => (
|
||||
<section key={c.id} className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
@@ -62,6 +75,16 @@ function Services() {
|
||||
<ul className="bo-list">
|
||||
{c.services.map((s) => (
|
||||
<li key={s.id} style={{ opacity: s.active ? 1 : 0.55 }}>
|
||||
{canEdit && (
|
||||
<span className="bo-stages__arrows">
|
||||
<button type="button" onClick={() => move.mutate({ id: s.id, dir: -1 })} aria-label="↑">
|
||||
<I name="arrowUp" size={14} />
|
||||
</button>
|
||||
<button type="button" onClick={() => move.mutate({ id: s.id, dir: 1 })} aria-label="↓">
|
||||
<I name="arrowDown" size={14} />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
<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' }}>
|
||||
@@ -136,6 +159,7 @@ export function ServiceEditor({ id, me }: { id: string; me: Me }) {
|
||||
<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) : []} />
|
||||
{s.pricing.kind === 'fixed' && <ParamsPanel service={s} canEdit={canEdit} />}
|
||||
</div>
|
||||
<div className="bo-col">
|
||||
<SettingsPanel service={s} canEdit={canEdit} />
|
||||
@@ -146,6 +170,7 @@ export function ServiceEditor({ id, me }: { id: string; me: Me }) {
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<PricePanel service={s} canEdit={canEdit} />
|
||||
</div>
|
||||
{canEdit && <DeleteService id={s.id} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -159,6 +184,7 @@ function useSaved() {
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.products.catalog.queryKey() });
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.products.texts.queryKey() });
|
||||
void loadTextOverrides();
|
||||
toast(t('prv.saved'));
|
||||
},
|
||||
onError: (e: unknown) => toast(errText(t, e), 'error'),
|
||||
@@ -835,3 +861,270 @@ function RelocationDialog({ kind, item, onClose }: { kind: 'STEP' | 'DOC'; item?
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── новая услуга ─────
|
||||
|
||||
const NEW_ROLES = ['CLEANER', 'DRIVER', 'CAREER_CONSULTANT', 'DOCS_AGENT'] as const;
|
||||
|
||||
function NewServiceDialog({ catalog, onClose }: { catalog: Catalog; onClose: () => void }) {
|
||||
const { t, shared } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [categoryId, setCategoryId] = useState(catalog[0]?.id ?? '');
|
||||
const [mode, setMode] = useState<'new' | 'copy'>('new');
|
||||
const [copyFromId, setCopyFrom] = useState(catalog[0]?.services[0]?.id ?? '');
|
||||
const [role, setRole] = useState<(typeof NEW_ROLES)[number]>('CLEANER');
|
||||
const [isOnline, setOnline] = useState(false);
|
||||
const [price, setPrice] = useState('10000');
|
||||
const [minutes, setMinutes] = useState('60');
|
||||
const [name, setName] = useState<L5>({ ...EMPTY });
|
||||
const [desc, setDesc] = useState<L5>({ ...EMPTY });
|
||||
const create = useMutation(
|
||||
trpc.bo.products.createService.mutationOptions({
|
||||
onSuccess: async (r) => {
|
||||
await loadTextOverrides();
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.products.catalog.queryKey() });
|
||||
toast(t('pr.created'));
|
||||
onClose();
|
||||
navigate(`/products/${r.id}`);
|
||||
},
|
||||
onError: (e) => toast(errText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
const all = catalog.flatMap((c) => c.services);
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
wide
|
||||
onClose={onClose}
|
||||
title={t('pr.newService')}
|
||||
footer={
|
||||
<>
|
||||
<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={!name.ru.trim() || !categoryId || create.isPending}
|
||||
onClick={() =>
|
||||
create.mutate({
|
||||
categoryId,
|
||||
name,
|
||||
description: desc,
|
||||
...(mode === 'copy' ? { copyFromId } : { providerRole: role, isOnline, price: Number(price) || 0, minutes: Math.max(15, Number(minutes) || 60) }),
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('pr.create')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="bo-chtabs">
|
||||
<button type="button" aria-pressed={mode === 'new'} onClick={() => setMode('new')}>
|
||||
{t('pr.fromScratch')}
|
||||
</button>
|
||||
<button type="button" aria-pressed={mode === 'copy'} onClick={() => setMode('copy')}>
|
||||
{t('pr.copyOf')}
|
||||
</button>
|
||||
</div>
|
||||
<Field label={t('pr.category')}>
|
||||
<select className="bo-select" value={categoryId} onChange={(e) => setCategoryId(e.target.value)}>
|
||||
{catalog.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{shared(`categories.${c.code}.name`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
{mode === 'copy' ? (
|
||||
<Field label={t('pr.copyFrom')} hint={t('pr.copyHint')}>
|
||||
<select className="bo-select" value={copyFromId} onChange={(e) => setCopyFrom(e.target.value)}>
|
||||
{all.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{shared(`services.${x.code}.name`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<span className="bo-label">{t('pr.who')}</span>
|
||||
<div className="bo-roles" style={{ marginTop: 6 }}>
|
||||
{NEW_ROLES.map((r) => (
|
||||
<button key={r} type="button" className="bo-role" aria-pressed={role === r} onClick={() => setRole(r)}>
|
||||
<b>{shared(`roles.${r}`)}</b>
|
||||
<span className="bo-small">{t(`pr.flowHint.${r}`)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="bo-hint">{t('pr.whoHint')}</span>
|
||||
</div>
|
||||
<div className="bo-row2">
|
||||
<Field label={t('pr.fixedPrice')}>
|
||||
<input className="bo-input" inputMode="numeric" value={price} onChange={(e) => setPrice(num(e.target.value))} />
|
||||
</Field>
|
||||
<Field label={t('pr.minutes')}>
|
||||
<input className="bo-input" inputMode="numeric" value={minutes} onChange={(e) => setMinutes(num(e.target.value))} />
|
||||
</Field>
|
||||
</div>
|
||||
<label className="bo-check">
|
||||
<input type="checkbox" checked={isOnline} onChange={(e) => setOnline(e.target.checked)} />
|
||||
{t('pr.online')}
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<L5Editor label={t('pr.name')} value={name} defaults={{ ru: t('pr.namePh') }} onChange={setName} />
|
||||
<L5Editor label={t('pr.description')} value={desc} defaults={{}} onChange={setDesc} multiline />
|
||||
<p className="bo-hint">{t('pr.createHint')}</p>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
type QField = { key?: string; type: 'select' | 'int' | 'text' | 'bool'; label: string; required: boolean; options: { key?: string; label: string }[]; min: number; max: number };
|
||||
|
||||
/** Вопросы клиенту при заказе — для услуг с фиксированной ценой */
|
||||
function ParamsPanel({ service: s, canEdit }: { service: Service; canEdit: boolean }) {
|
||||
const { t, shared } = useT();
|
||||
const trpc = useTRPC();
|
||||
const label = (k: string) => {
|
||||
const v = shared(k);
|
||||
return v === k ? '' : v;
|
||||
};
|
||||
const initial = (): QField[] =>
|
||||
s.paramsSchema.fields
|
||||
.filter((f) => f.type !== 'extras')
|
||||
.map((f) => ({
|
||||
key: f.key,
|
||||
type: f.type as QField['type'],
|
||||
label: label(`params.${s.code}.${f.key}.label`) || f.key,
|
||||
required: 'required' in f ? !!f.required : false,
|
||||
options: f.type === 'select' ? f.options.map((o) => ({ key: o, label: label(`params.${s.code}.${f.key}.${o}`) || o })) : [],
|
||||
min: f.type === 'int' ? f.min : 1,
|
||||
max: f.type === 'int' ? f.max : 10,
|
||||
}));
|
||||
const [fields, setFields] = useState<QField[]>(initial);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => setFields(initial()), [s.paramsSchema]);
|
||||
const save = useMutation(trpc.bo.products.saveParams.mutationOptions(useSaved()));
|
||||
const set = (i: number, patch: Partial<QField>) => setFields(fields.map((f, k) => (k === i ? { ...f, ...patch } : f)));
|
||||
return (
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<h2 className="bo-h2">{t('pr.questions')}</h2>
|
||||
</div>
|
||||
<p className="bo-hint" style={{ marginBottom: 12 }}>
|
||||
{t('pr.questionsHint')}
|
||||
</p>
|
||||
<div className="bo-col" style={{ gap: 10 }}>
|
||||
{fields.map((f, i) => (
|
||||
<div key={f.key ?? `n${i}`} className="bo-qfield">
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input className="bo-input" disabled={!canEdit} value={f.label} maxLength={120} onChange={(e) => set(i, { label: e.target.value })} placeholder={t('pr.qLabel')} />
|
||||
<select
|
||||
className="bo-select"
|
||||
style={{ width: 160 }}
|
||||
disabled={!canEdit}
|
||||
value={f.type}
|
||||
onChange={(e) => set(i, { type: e.target.value as QField['type'], options: e.target.value === 'select' && f.options.length < 2 ? [{ label: '' }, { label: '' }] : f.options })}
|
||||
>
|
||||
{(['select', 'int', 'text', 'bool'] as const).map((x) => (
|
||||
<option key={x} value={x}>
|
||||
{t(`pr.qType.${x}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{canEdit && (
|
||||
<button type="button" className="bo-iconbtn" onClick={() => setFields(fields.filter((_, k) => k !== i))} aria-label={t('chat.tplDelete')}>
|
||||
<I name="close" size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{f.type === 'select' && (
|
||||
<div className="bo-col" style={{ gap: 6, marginTop: 8, paddingLeft: 12 }}>
|
||||
{f.options.map((o, k) => (
|
||||
<div key={o.key ?? `o${k}`} style={{ display: 'flex', gap: 6 }}>
|
||||
<input
|
||||
className="bo-input"
|
||||
style={{ minHeight: 34, padding: '4px 10px' }}
|
||||
disabled={!canEdit}
|
||||
value={o.label}
|
||||
maxLength={120}
|
||||
placeholder={t('pr.qOption', { n: k + 1 })}
|
||||
onChange={(e) => set(i, { options: f.options.map((x, j) => (j === k ? { ...x, label: e.target.value } : x)) })}
|
||||
/>
|
||||
{canEdit && f.options.length > 2 && (
|
||||
<button type="button" className="bo-iconbtn" onClick={() => set(i, { options: f.options.filter((_, j) => j !== k) })} aria-label={t('chat.tplDelete')}>
|
||||
<I name="close" size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{canEdit && (
|
||||
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-accent)', padding: 0, alignSelf: 'flex-start' }} onClick={() => set(i, { options: [...f.options, { label: '' }] })}>
|
||||
+ {t('pr.qAddOption')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{f.type === 'int' && (
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8, alignItems: 'center' }} className="bo-small">
|
||||
{t('pr.qFrom')}
|
||||
<input className="bo-input" style={{ width: 80, minHeight: 34 }} disabled={!canEdit} inputMode="numeric" value={String(f.min)} onChange={(e) => set(i, { min: Number(num(e.target.value)) || 0 })} />
|
||||
{t('pr.qTo')}
|
||||
<input className="bo-input" style={{ width: 80, minHeight: 34 }} disabled={!canEdit} inputMode="numeric" value={String(f.max)} onChange={(e) => set(i, { max: Number(num(e.target.value)) || 1 })} />
|
||||
</div>
|
||||
)}
|
||||
{f.type !== 'bool' && (
|
||||
<label className="bo-check bo-small" style={{ marginTop: 6 }}>
|
||||
<input type="checkbox" disabled={!canEdit} checked={f.required} onChange={(e) => set(i, { required: e.target.checked })} />
|
||||
{t('pr.qRequired')}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{fields.length === 0 && <p className="bo-hint">{t('pr.qNone')}</p>}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12, flexWrap: 'wrap' }}>
|
||||
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setFields([...fields, { type: 'select', label: '', required: true, options: [{ label: '' }, { label: '' }], min: 1, max: 10 }])}>
|
||||
<I name="plus" size={16} />
|
||||
{t('pr.qAdd')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="bo-btn bo-btn--sm"
|
||||
disabled={save.isPending || fields.some((f) => !f.label.trim() || (f.type === 'select' && f.options.filter((o) => o.label.trim()).length < 2))}
|
||||
onClick={() => save.mutate({ serviceId: s.id, fields: fields.map((f) => ({ ...f, options: f.type === 'select' ? f.options.filter((o) => o.label.trim()) : [] })) })}
|
||||
>
|
||||
{t('ui.save')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteService({ id }: { id: string }) {
|
||||
const { t } = useT();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const del = useMutation(
|
||||
trpc.bo.products.deleteService.mutationOptions({
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: trpc.bo.products.catalog.queryKey() });
|
||||
navigate('/products');
|
||||
},
|
||||
onError: (e) => toast(errText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
return (
|
||||
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-danger)', marginTop: 16 }} onClick={() => confirm(t('pr.deleteConfirm')) && del.mutate({ id })}>
|
||||
{t('pr.delete')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { ParamField, ParamValues } from '@barev/core';
|
||||
import { Button, Card, Center, Chip, Field, Icon, Spinner, Stepper } from '@barev/ui';
|
||||
import { dateKeyToDate, errorText, haptic, navigate, nextDays, useI18n, useLocation, useToast, useTRPC } from '@barev/web';
|
||||
import { tOptional, dateKeyToDate, errorText, haptic, navigate, nextDays, useI18n, useLocation, useToast, useTRPC } from '@barev/web';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { PageHead } from '../lib.tsx';
|
||||
|
||||
@@ -127,7 +127,7 @@ export function OrderForm({ code }: { code: string }) {
|
||||
<main className="app app--plain">
|
||||
<PageHead title={t(`services.${code}.name`)} />
|
||||
<p className="bc-muted" style={{ margin: '-4px 0 0' }}>
|
||||
{t(`services.${code}.description`)}
|
||||
{tOptional(t, `services.${code}.description`)}
|
||||
</p>
|
||||
|
||||
{/* параметры услуги */}
|
||||
@@ -335,7 +335,7 @@ function ParamInput({
|
||||
<button key={o} type="button" className="option" role="radio" aria-checked={value === o} aria-pressed={value === o} onClick={() => onChange(o)}>
|
||||
<span style={{ fontWeight: 700 }}>{t(`params.${serviceCode}.${field.key}.${o}`)}</span>
|
||||
<span className="bc-small bc-muted">
|
||||
{prices?.[o] !== undefined ? t('common.from', { price: amd(prices[o]!) }) : t(`params.${serviceCode}.${field.key}.${o}_hint`)}
|
||||
{prices?.[o] !== undefined ? t('common.from', { price: amd(prices[o]!) }) : tOptional(t, `params.${serviceCode}.${field.key}.${o}_hint`)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button, Card, Field, Icon } from '@barev/ui';
|
||||
import { errorText, haptic, useI18n, useToast, useTRPC } from '@barev/web';
|
||||
import { tOptional, errorText, haptic, useI18n, useToast, useTRPC } from '@barev/web';
|
||||
import { useState } from 'react';
|
||||
import { levelDiscount, PageHead, toneClass, type Level } from '../lib.tsx';
|
||||
|
||||
@@ -31,7 +31,7 @@ export function ServiceRequest({ code }: { code: string }) {
|
||||
<main className="app app--plain">
|
||||
<PageHead title={t(`services.${code}.name`)} />
|
||||
<Card className={`bc-stack ${category ? toneClass(category.code) : ''}`}>
|
||||
<p style={{ margin: 0 }}>{t(`services.${code}.description`)}</p>
|
||||
<p style={{ margin: 0 }}>{tOptional(t, `services.${code}.description`)}</p>
|
||||
{pct > 0 && <div style={{ fontWeight: 700 }}>{t('client.request.yourDiscount', { pct })}</div>}
|
||||
</Card>
|
||||
{sent ? (
|
||||
|
||||
Reference in new issue
Block a user