-
-
- {t('client.home.levelUntil', { plan: LEVEL_NAME[level], date: dt(me.data.membership.endsAt, { day: 'numeric', month: 'long' }) })}
-
+ {orders.isPending ? (
+
+ ) : now ? (
+
+ ) : (
+
)}
- {!plus && (
+ {toRate && (
+
+
+ {t('client.rate.prompt')}
+
+ {t(`services.${toRate.serviceCode}.name`)} · {dt(toRate.scheduledAt, { day: 'numeric', month: 'short' })}
+
+
+
+
+ )}
+
+
+
+ {level === 'BAREV' && (
{t('client.home.upsellTitle', { pct: me.data?.plans.eliteMaxPct ?? 0 })}
{t('client.home.upsellText')}
)}
-
-
- {orders.isPending ? (
-
- ) : orders.data && orders.data.active.length > 0 ? (
-
- {orders.data.active.slice(0, 4).map((o) => (
-
- ))}
-
- ) : (
-
-
- {t('client.home.noOrders')}
-
-
- )}
-
-
-
- navigate('/order/cleaning')}>
-
- {t('services.cleaning.name')}
-
- {lastDone && (
- navigate(`/order/${lastDone.serviceCode}?repeat=${lastDone.id}`)}>
-
- {t('client.home.repeatLast')}
-
- )}
- {!me.data?.agencyName && (
- navigate('/profile?code=1')}>
-
- {t('client.home.enterCode')}
-
- )}
-
-
-
+
+
+ {t('client.home.chatTitle')}
+ {t('client.home.chatText')}
+
+
+
+ {t('client.nav.chat')}
+
+
);
}
diff --git a/apps/miniapp-client/src/screens/OrderDetail.tsx b/apps/miniapp-client/src/screens/OrderDetail.tsx
index 20c0cc8..d296af2 100644
--- a/apps/miniapp-client/src/screens/OrderDetail.tsx
+++ b/apps/miniapp-client/src/screens/OrderDetail.tsx
@@ -1,8 +1,8 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import { Button, Card, Center, Icon, Sheet, Spinner, Steps } from '@barev/ui';
-import { confirmDialog, dateKeyToDate, errorText, haptic, navigate, nextDays, paramsExtras, useI18n, useToast, useTRPC } from '@barev/web';
-import { useMemo, useState } from 'react';
-import { PageHead, toneClass, useParamsSummary } from '../lib.tsx';
+import { Button, Center, Icon, Sheet, Spinner } from '@barev/ui';
+import { confirmDialog, dateKeyToDate, errorText, haptic, navigate, nextDays, paramsExtras, useI18n, useLocation, useToast, useTRPC } from '@barev/web';
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { Stars, Timeline, toneClass, useOrderTimeline, useParamsSummary } from '../lib.tsx';
export function OrderDetail({ id }: { id: string }) {
const { t, amd, dt, locale } = useI18n();
@@ -10,6 +10,7 @@ export function OrderDetail({ id }: { id: string }) {
const qc = useQueryClient();
const toast = useToast();
const summary = useParamsSummary();
+ const timeline = useOrderTimeline();
const order = useQuery({ ...trpc.client.orders.get.queryOptions({ id }), refetchInterval: 15_000 });
const preview = useQuery({ ...trpc.client.orders.cancelPreview.queryOptions({ id }), enabled: !!order.data?.active });
const [reschedule, setReschedule] = useState(false);
@@ -32,22 +33,8 @@ export function OrderDetail({ id }: { id: string }) {
);
const o = order.data;
-
- // шкала прогресса: пройденные статусы по истории, текущий, остальные
- const passed = new Set(o.history.map((h) => h.to));
- const currentIdx = o.steps.indexOf(o.status);
- const steps =
- o.status === 'CANCELLED'
- ? [...o.steps.filter((s) => passed.has(s)).map((s) => ({ label: t(`status.${o.flow}.${s}`), state: 'done' as const })), { label: t(`status.${o.flow}.CANCELLED`), state: 'current' as const }]
- : o.steps.map((s, i) => {
- const at = o.history.find((h) => h.to === s && !h.event)?.at ?? o.history.find((h) => h.to === s)?.at;
- return {
- label: t(`status.${o.flow}.${s}`),
- state: i < currentIdx ? ('done' as const) : i === currentIdx ? ('current' as const) : ('todo' as const),
- meta: at && i <= currentIdx ? dt(at, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) : undefined,
- };
- });
const arrived = o.history.some((h) => h.event === 'arrived');
+ const extras = paramsExtras(t, o.params);
async function onCancel() {
const p = preview.data?.policy;
@@ -58,47 +45,63 @@ export function OrderDetail({ id }: { id: string }) {
return (
-
+
+
+
+ #{o.number}
+
+
+ {t(`status.${o.flow}.${o.status}`)}
+ {arrived && o.status === 'EN_ROUTE' ? ` · ${t('client.detail.arrived')}` : ''}
+
+ {t(`services.${o.serviceCode}.name`)}
+
+ {summary(o.serviceCode, o.params)}
+ {extras.length > 0 && ` + ${extras.map((e) => e.label).join(', ')}`}
+
+
-
-
-
-
{t(`services.${o.serviceCode}.name`)}
- {t(`status.${o.flow}.${o.status}`)}
-
-
{summary(o.serviceCode, o.params)}
- {paramsExtras(t, o.params).length > 0 &&
+ {paramsExtras(t, o.params).map((e) => e.label).join(', ')}
}
-
+
+
+
-
- {dt(o.scheduledAt, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })}
-
- {o.addressText && (
-
+ {t('client.detail.when')}
+
+
- {dt(o.scheduledAt, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })}
+
+ {o.addressText && (
+
+
-
+ {t('client.detail.where')}
+
+ -
{o.districtCode ? `${t(`districts.${o.districtCode}`)}, ` : ''}
{o.addressText}
-
- )}
- {o.providerName && (
-
-
- {t(`client.detail.providerLabel.${o.flow}`)}: {o.providerName}
- {arrived && o.status === 'EN_ROUTE' && {t('client.detail.arrived')}}
-
- )}
+
+
+ )}
+
+
+
+ {t(`client.detail.providerLabel.${o.flow}`)}
+
+ {o.providerName ?? {t('client.detail.whoPending')}}
-
+
-
-
- {t('client.detail.progress')}
-
-
-
+ {o.done &&
}
+
+
+ {t('client.detail.progress')}
+
+
{o.photos.length > 0 &&
}
-
+
{t('client.order.price')}
{amd(o.basePrice)}
@@ -123,16 +126,31 @@ export function OrderDetail({ id }: { id: string }) {
{t(`payment.${o.paymentStatus}`)}
{o.paymentMethod ? ` · ${o.paymentMethod.name[locale] || o.paymentMethod.name.ru}` : ''}
-
+
{o.note && (
-
+
{t('client.order.note')}
{o.note}
-
+
)}
+ {!o.active && o.status !== 'CANCELLED' && (
+
+ )}
+ {o.status === 'CANCELLED' && (
+
+ )}
+
{o.active && preview.data?.canReschedule && (
setReschedule(false)} order={o} />
@@ -166,20 +173,76 @@ export function OrderDetail({ id }: { id: string }) {
);
}
+/** Оценка после выполнения: звёзды и комментарий; уже оценённый заказ показывает оценку с кнопкой «Изменить» */
+function RateBlock({ id, rating }: { id: string; rating: { score: number; comment: string | null } | null }) {
+ const { t } = useI18n();
+ const trpc = useTRPC();
+ const qc = useQueryClient();
+ const toast = useToast();
+ const { query } = useLocation();
+ const ref = useRef(null);
+ const [editing, setEditing] = useState(!rating);
+ const [score, setScore] = useState(rating?.score ?? 0);
+ const [comment, setComment] = useState(rating?.comment ?? '');
+ useEffect(() => {
+ if (query.get('rate') && ref.current) ref.current.scrollIntoView({ block: 'center' });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+ const rate = useMutation(
+ trpc.client.orders.rate.mutationOptions({
+ onSuccess: () => {
+ haptic.ok();
+ toast(t('client.rate.thanks'));
+ setEditing(false);
+ void qc.invalidateQueries({ queryKey: trpc.client.orders.pathKey() });
+ },
+ onError: (e) => toast(errorText(t, e), 'error'),
+ }),
+ );
+
+ if (!editing && rating)
+ return (
+
+
+ {t('client.rate.yours')}
+ setEditing(true)}>
+ {t('client.rate.change')}
+
+
+
+ {rating.comment && {rating.comment}
}
+
+ );
+
+ return (
+
+ {t('client.rate.title')}
+ {t('client.rate.text')}
+
+ {score > 0 && (
+ <>
+
+ );
+}
+
function PhotoReport({ photos }: { photos: { code: string; fileId: string }[] }) {
const { t } = useI18n();
const trpc = useTRPC();
return (
-
-
- {t('client.detail.photoReport')}
-
+
+ {t('client.detail.photoReport')}
-
+
);
}
diff --git a/apps/miniapp-client/src/screens/Orders.tsx b/apps/miniapp-client/src/screens/Orders.tsx
index 9268336..674ca06 100644
--- a/apps/miniapp-client/src/screens/Orders.tsx
+++ b/apps/miniapp-client/src/screens/Orders.tsx
@@ -1,8 +1,8 @@
import { useQuery } from '@tanstack/react-query';
-import { Center, LinkButton, Segmented, Spinner } from '@barev/ui';
+import { Center, Icon, Segmented, Spinner } from '@barev/ui';
import { useI18n, useTRPC } from '@barev/web';
import { useState } from 'react';
-import { OrderRow, PageHead } from '../lib.tsx';
+import { OrderRow } from '../lib.tsx';
export function Orders() {
const { t } = useI18n();
@@ -18,7 +18,12 @@ export function Orders() {
const list = orders.data[tab];
return (
-
+
+ {t('client.orders.title')}
+
+
+
+
{list.length === 0 ? (
-
-
{t(tab === 'active' ? 'client.home.noOrders' : 'client.orders.noHistory')}
-
{t('client.orders.order')}
+
) : (
-
+
{list.map((o) => (
))}
diff --git a/apps/miniapp-client/src/screens/Perks.tsx b/apps/miniapp-client/src/screens/Perks.tsx
index 62a5038..8ebdc10 100644
--- a/apps/miniapp-client/src/screens/Perks.tsx
+++ b/apps/miniapp-client/src/screens/Perks.tsx
@@ -1,8 +1,8 @@
import { useQuery } from '@tanstack/react-query';
-import { Card } from '@barev/ui';
+import { Card, Icon } from '@barev/ui';
import { useI18n, useTRPC } from '@barev/web';
import { useEffect, useState } from 'react';
-import { LEVEL_NAME, LEVEL_TONE, PageHead, type Level } from '../lib.tsx';
+import { LEVEL_NAME, type Level } from '../lib.tsx';
import { Qr } from '../qr.tsx';
/**
@@ -16,27 +16,33 @@ export function Perks() {
const qr = useQuery({ ...trpc.client.cardQr.queryOptions(), refetchInterval: 20_000, refetchIntervalInBackground: false });
const partners = useQuery(trpc.client.partners.queryOptions());
const level = (me.data?.membership.level ?? 'BAREV') as Level;
- const tone = LEVEL_TONE[level];
const left = useSecondsLeft(qr.data?.expiresAt);
const name = (l: Record
) => l[locale] || l.ru || '';
return (
-
-
-
-
- Barev Club · {LEVEL_NAME[level]}
- {me.data?.firstName}
-
-
-
-
{me.data?.cardCode}
-
{t('client.partners.qrRefresh', { seconds: left })}
+
+
+ {t('client.card.title')}
+
+
+ Barev Club
+ {LEVEL_NAME[level]}
+
+
+
+
+ {me.data?.firstName} · {me.data?.cardCode}
+
+ {t('client.partners.qrRefresh', { seconds: left })}
+
+ {t('client.partners.howTo')}
+
+ {t('client.card.plans')}
+
+
-
- {t('client.partners.howTo')}
-
+ {t('client.card.partners')}
{partners.data?.length === 0 ? (
{t('client.perks.soonTitle')}
diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json
index 9350e93..f6bd19b 100644
--- a/packages/i18n/locales/en.json
+++ b/packages/i18n/locales/en.json
@@ -455,7 +455,10 @@
"perks": "Perks",
"profile": "Profile",
"plan": "Plan",
- "docs": "Documents"
+ "docs": "Documents",
+ "orders": "Orders",
+ "card": "Card",
+ "chat": "Chat"
},
"openInTelegram": {
"title": "Open in Telegram",
@@ -480,7 +483,14 @@
"discount": "−{{pct}}% with Barev+",
"upsellTitle": "Up to −{{pct}}% with Barev Elite",
"upsellText": "Cleaning, repairs, transfers, paperwork and partner gifts",
- "levelUntil": "{{plan}} active until {{date}}"
+ "levelUntil": "{{plan}} active until {{date}}",
+ "now": "Now",
+ "moreActive": "More active: {{count}}",
+ "orderTitle": "Book a service",
+ "cardShow": "Show QR",
+ "free": "Free level",
+ "chatTitle": "Any question — ask in chat",
+ "chatText": "We reply in your language"
},
"services": {
"title": "Services",
@@ -547,7 +557,13 @@
"LANGUAGE": "Teacher",
"CAREER": "Coach",
"DOCUMENTS": "Specialist"
- }
+ },
+ "when": "When",
+ "where": "Where",
+ "who": "Provider",
+ "whoPending": "being assigned",
+ "payment": "Payment",
+ "ask": "Question about this order"
},
"perks": {
"title": "Perks",
@@ -670,6 +686,22 @@
"team": "Barev Club",
"fileTooBig": "The file is larger than 10 MB",
"file": "File"
+ },
+ "rate": {
+ "title": "How did it go?",
+ "text": "Your rating helps us keep the quality high",
+ "comment": "Comment — optional",
+ "send": "Rate",
+ "thanks": "Thanks for your rating!",
+ "yours": "Your rating",
+ "change": "Change",
+ "star": "{{count}} of 5",
+ "prompt": "Rate your order"
+ },
+ "card": {
+ "title": "Club card",
+ "partners": "Club partners",
+ "plans": "Levels and discounts"
}
},
"pro": {
diff --git a/packages/i18n/locales/es.json b/packages/i18n/locales/es.json
index 81bd5aa..bc583aa 100644
--- a/packages/i18n/locales/es.json
+++ b/packages/i18n/locales/es.json
@@ -455,7 +455,10 @@
"perks": "Descuentos",
"profile": "Perfil",
"plan": "Plan",
- "docs": "Documentos"
+ "docs": "Documentos",
+ "orders": "Pedidos",
+ "card": "Tarjeta",
+ "chat": "Chat"
},
"openInTelegram": {
"title": "Abre en Telegram",
@@ -480,7 +483,14 @@
"discount": "−{{pct}}% con Barev+",
"upsellTitle": "Hasta −{{pct}}% con Barev Elite",
"upsellText": "Limpieza, reparaciones, traslados, trámites y regalos de socios",
- "levelUntil": "{{plan}} activo hasta el {{date}}"
+ "levelUntil": "{{plan}} activo hasta el {{date}}",
+ "now": "Ahora",
+ "moreActive": "Más activos: {{count}}",
+ "orderTitle": "Reservar",
+ "cardShow": "Mostrar QR",
+ "free": "Nivel gratuito",
+ "chatTitle": "Cualquier pregunta, al chat",
+ "chatText": "Respondemos en tu idioma"
},
"services": {
"title": "Servicios",
@@ -547,7 +557,13 @@
"LANGUAGE": "Profesor/a",
"CAREER": "Asesor",
"DOCUMENTS": "Especialista"
- }
+ },
+ "when": "Cuándo",
+ "where": "Dónde",
+ "who": "Profesional",
+ "whoPending": "asignando",
+ "payment": "Pago",
+ "ask": "Pregunta sobre el pedido"
},
"perks": {
"title": "Descuentos",
@@ -670,6 +686,22 @@
"team": "Barev Club",
"fileTooBig": "El archivo supera los 10 MB",
"file": "Archivo"
+ },
+ "rate": {
+ "title": "¿Qué tal fue?",
+ "text": "Tu valoración nos ayuda a cuidar la calidad",
+ "comment": "Comentario (opcional)",
+ "send": "Valorar",
+ "thanks": "¡Gracias por tu valoración!",
+ "yours": "Tu valoración",
+ "change": "Cambiar",
+ "star": "{{count}} de 5",
+ "prompt": "Valora tu pedido"
+ },
+ "card": {
+ "title": "Tarjeta del club",
+ "partners": "Socios del club",
+ "plans": "Niveles y descuentos"
}
},
"pro": {
diff --git a/packages/i18n/locales/hy.json b/packages/i18n/locales/hy.json
index 2aaad81..e588a33 100644
--- a/packages/i18n/locales/hy.json
+++ b/packages/i18n/locales/hy.json
@@ -455,7 +455,10 @@
"perks": "Զեղչեր",
"profile": "Պրոֆիլ",
"plan": "Պլան",
- "docs": "Փաստաթղթեր"
+ "docs": "Փաստաթղթեր",
+ "orders": "Պատվերներ",
+ "card": "Քարտ",
+ "chat": "Չատ"
},
"openInTelegram": {
"title": "Բացեք Telegram-ում",
@@ -480,7 +483,14 @@
"discount": "−{{pct}}% Barev+-ով",
"upsellTitle": "Մինչև −{{pct}}% Barev Elite-ով",
"upsellText": "Մաքրում, վերանորոգում, տրանսֆեր, փաստաթղթեր և նվերներ գործընկերներից",
- "levelUntil": "{{plan}}-ը գործում է մինչև {{date}}"
+ "levelUntil": "{{plan}}-ը գործում է մինչև {{date}}",
+ "now": "Հիմա",
+ "moreActive": "Եվս ակտիվ՝ {{count}}",
+ "orderTitle": "Պատվիրել",
+ "cardShow": "Ցույց տալ QR",
+ "free": "Անվճար մակարդակ",
+ "chatTitle": "Ցանկացած հարց՝ չատում",
+ "chatText": "Կպատասխանենք ձեր լեզվով"
},
"services": {
"title": "Ծառայություններ",
@@ -547,7 +557,13 @@
"LANGUAGE": "Դասավանդող",
"CAREER": "Խորհրդատու",
"DOCUMENTS": "Մասնագետ"
- }
+ },
+ "when": "Երբ",
+ "where": "Որտեղ",
+ "who": "Կատարող",
+ "whoPending": "ընտրում ենք",
+ "payment": "Վճարում",
+ "ask": "Հարց պատվերի վերաբերյալ"
},
"perks": {
"title": "Զեղչեր",
@@ -670,6 +686,22 @@
"team": "Barev Club",
"fileTooBig": "Ֆայլը 10 ՄԲ-ից մեծ է",
"file": "Ֆայլ"
+ },
+ "rate": {
+ "title": "Ինչպե՞ս անցավ",
+ "text": "Գնահատականը կօգնի մեզ հետևել որակին",
+ "comment": "Մեկնաբանություն՝ ըստ ցանկության",
+ "send": "Գնահատել",
+ "thanks": "Շնորհակալություն գնահատականի համար",
+ "yours": "Ձեր գնահատականը",
+ "change": "Փոխել",
+ "star": "{{count}}՝ 5-ից",
+ "prompt": "Գնահատեք պատվերը"
+ },
+ "card": {
+ "title": "Ակումբային քարտ",
+ "partners": "Ակումբի գործընկերներ",
+ "plans": "Մակարդակներ և զեղչեր"
}
},
"pro": {
diff --git a/packages/i18n/locales/ru.json b/packages/i18n/locales/ru.json
index 6c3af7b..331809d 100644
--- a/packages/i18n/locales/ru.json
+++ b/packages/i18n/locales/ru.json
@@ -455,7 +455,10 @@
"perks": "Скидки",
"profile": "Профиль",
"plan": "План",
- "docs": "Документы"
+ "docs": "Документы",
+ "orders": "Заказы",
+ "card": "Карта",
+ "chat": "Чат"
},
"openInTelegram": {
"title": "Открой в Telegram",
@@ -480,7 +483,14 @@
"discount": "−{{pct}}% с Barev+",
"upsellTitle": "Скидки до −{{pct}}% с Barev Elite",
"upsellText": "Клининг, ремонт, трансфер, документы и подарки партнёров",
- "levelUntil": "{{plan}} активен до {{date}}"
+ "levelUntil": "{{plan}} активен до {{date}}",
+ "now": "Сейчас",
+ "moreActive": "Ещё активных: {{count}}",
+ "orderTitle": "Заказать",
+ "cardShow": "Показать QR",
+ "free": "Бесплатный уровень",
+ "chatTitle": "Любой вопрос — в чат",
+ "chatText": "Ответим на вашем языке"
},
"services": {
"title": "Услуги",
@@ -547,7 +557,13 @@
"LANGUAGE": "Преподаватель",
"CAREER": "Консультант",
"DOCUMENTS": "Специалист"
- }
+ },
+ "when": "Когда",
+ "where": "Где",
+ "who": "Исполнитель",
+ "whoPending": "подбираем",
+ "payment": "Оплата",
+ "ask": "Вопрос по заказу"
},
"perks": {
"title": "Скидки",
@@ -670,6 +686,22 @@
"team": "Barev Club",
"fileTooBig": "Файл больше 10 МБ",
"file": "Файл"
+ },
+ "rate": {
+ "title": "Как всё прошло?",
+ "text": "Оценка поможет нам следить за качеством",
+ "comment": "Комментарий — по желанию",
+ "send": "Оценить",
+ "thanks": "Спасибо за оценку!",
+ "yours": "Ваша оценка",
+ "change": "Изменить",
+ "star": "{{count}} из 5",
+ "prompt": "Оцените заказ"
+ },
+ "card": {
+ "title": "Клубная карта",
+ "partners": "Партнёры клуба",
+ "plans": "Уровни и скидки"
}
},
"pro": {
diff --git a/packages/i18n/locales/zh.json b/packages/i18n/locales/zh.json
index 4b06d44..a1d8f42 100644
--- a/packages/i18n/locales/zh.json
+++ b/packages/i18n/locales/zh.json
@@ -455,7 +455,10 @@
"perks": "折扣",
"profile": "我的",
"plan": "计划",
- "docs": "证件"
+ "docs": "证件",
+ "orders": "订单",
+ "card": "会员卡",
+ "chat": "聊天"
},
"openInTelegram": {
"title": "请在 Telegram 中打开",
@@ -480,7 +483,14 @@
"discount": "Barev+ 享 −{{pct}}%",
"upsellTitle": "Barev Elite 最高 −{{pct}}%",
"upsellText": "保洁、维修、接送、证件和合作伙伴礼品",
- "levelUntil": "{{plan}} 有效期至 {{date}}"
+ "levelUntil": "{{plan}} 有效期至 {{date}}",
+ "now": "进行中",
+ "moreActive": "其他进行中:{{count}}",
+ "orderTitle": "预订服务",
+ "cardShow": "出示二维码",
+ "free": "免费等级",
+ "chatTitle": "有任何问题,请在聊天中提问",
+ "chatText": "我们用您的语言回复"
},
"services": {
"title": "服务",
@@ -547,7 +557,13 @@
"LANGUAGE": "老师",
"CAREER": "顾问",
"DOCUMENTS": "专员"
- }
+ },
+ "when": "时间",
+ "where": "地点",
+ "who": "服务人员",
+ "whoPending": "正在分配",
+ "payment": "付款",
+ "ask": "关于此订单的问题"
},
"perks": {
"title": "折扣",
@@ -670,6 +686,22 @@
"team": "Barev Club",
"fileTooBig": "文件超过 10 MB",
"file": "文件"
+ },
+ "rate": {
+ "title": "服务体验如何?",
+ "text": "您的评价帮助我们保证服务质量",
+ "comment": "评论(可选)",
+ "send": "提交评价",
+ "thanks": "感谢您的评价!",
+ "yours": "您的评价",
+ "change": "修改",
+ "star": "{{count}} / 5",
+ "prompt": "请评价订单"
+ },
+ "card": {
+ "title": "俱乐部会员卡",
+ "partners": "俱乐部合作伙伴",
+ "plans": "等级与折扣"
}
},
"pro": {
diff --git a/packages/services/src/index.ts b/packages/services/src/index.ts
index 365a607..9541cf1 100644
--- a/packages/services/src/index.ts
+++ b/packages/services/src/index.ts
@@ -19,3 +19,4 @@ export * from './sales.ts';
export * from './texts.ts';
export * from './promo.ts';
export * from './partners.ts';
+export * from './ratings.ts';
diff --git a/packages/services/src/queries.ts b/packages/services/src/queries.ts
index ce3bee5..8f4bf77 100644
--- a/packages/services/src/queries.ts
+++ b/packages/services/src/queries.ts
@@ -12,6 +12,7 @@ const clientOrderInclude = {
history: { orderBy: { createdAt: 'asc' } },
checklist: { orderBy: { sortOrder: 'asc' } },
paymentMethod: true,
+ rating: { select: { score: true, comment: true } },
} satisfies Prisma.OrderInclude;
type ClientOrderRow = Prisma.OrderGetPayload<{ include: typeof clientOrderInclude }>;
@@ -27,6 +28,9 @@ function clientOrderView(o: ClientOrderRow) {
flow,
status: o.status,
active: !isTerminal(flow, o.status),
+ /** заказ выполнен — можно оценить */
+ done: getFlow(flow).done.includes(o.status),
+ rating: o.rating,
scheduledAt: o.scheduledAt,
durationMinutes: o.durationMinutes,
isOnline: o.isOnline,
diff --git a/packages/services/src/ratings.ts b/packages/services/src/ratings.ts
new file mode 100644
index 0000000..1ff88a1
--- /dev/null
+++ b/packages/services/src/ratings.ts
@@ -0,0 +1,29 @@
+import { getFlow, type ServiceFlow } from '@barev/core';
+import { DomainError, type Ctx } from './context.ts';
+
+/**
+ * Оценка клиента после выполненного заказа: 1–5 звёзд и комментарий.
+ * Повторная оценка заменяет прежнюю; рейтинг исполнителя (ratingSum/ratingCount) пересчитывается.
+ */
+export async function rateOrder(ctx: Ctx, input: { orderId: string; clientId: string; score: number; comment?: string | null }) {
+ const score = Math.round(input.score);
+ if (score < 1 || score > 5) throw new DomainError('INVALID_PARAMS');
+ const comment = input.comment?.trim() || null;
+ return ctx.db.$transaction(async (tx) => {
+ const order = await tx.order.findUnique({ where: { id: input.orderId }, include: { service: true, rating: true } });
+ if (!order || order.clientId !== input.clientId) throw new DomainError('NOT_FOUND');
+ if (!getFlow(order.service.flow as ServiceFlow).done.includes(order.status)) throw new DomainError('TRANSITION_NOT_ALLOWED');
+ const prev = order.rating;
+ const providerId = prev?.providerId ?? order.providerId;
+ const rating = prev
+ ? await tx.rating.update({ where: { id: prev.id }, data: { score, comment } })
+ : await tx.rating.create({ data: { orderId: order.id, clientId: input.clientId, providerId, score, comment } });
+ if (providerId) {
+ await tx.provider.update({
+ where: { id: providerId },
+ data: prev ? { ratingSum: { increment: score - prev.score } } : { ratingSum: { increment: score }, ratingCount: { increment: 1 } },
+ });
+ }
+ return { score: rating.score, comment: rating.comment };
+ });
+}
diff --git a/packages/services/test/ratings.test.ts b/packages/services/test/ratings.test.ts
new file mode 100644
index 0000000..6d5c518
--- /dev/null
+++ b/packages/services/test/ratings.test.ts
@@ -0,0 +1,38 @@
+import { afterAll, beforeEach, describe, expect, it } from 'vitest';
+import { createOrder, DomainError, giveConsent, rateOrder, startClient } from '../src/index.ts';
+import { db, fixtures, makeCtx, NOW, slotAt, tgUser } from './helpers.ts';
+
+let fx: Awaited>;
+beforeEach(async () => {
+ fx = await fixtures();
+});
+afterAll(() => db.$disconnect());
+
+const code = (p: Promise) => p.then(() => null, (e: unknown) => (e instanceof DomainError ? e.code : String(e)));
+
+describe('оценка заказа клиентом', () => {
+ it('только после выполнения; повторная оценка заменяет прежнюю и пересчитывает рейтинг исполнителя', async () => {
+ const { ctx } = makeCtx(NOW);
+ const s = await startClient(ctx, tgUser(901));
+ await giveConsent(ctx, s.clientId);
+ const o = await createOrder(ctx, { clientId: s.clientId, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2 }, scheduledAt: slotAt(2, 11), addressText: 'Pushkin 10', districtId: fx.kentron.id });
+ await db.order.update({ where: { id: o.id }, data: { providerId: fx.maria.id } });
+ const before = await db.provider.findUniqueOrThrow({ where: { id: fx.maria.id } });
+
+ expect(await code(rateOrder(ctx, { orderId: o.id, clientId: s.clientId, score: 5 }))).toBe('TRANSITION_NOT_ALLOWED');
+
+ await db.order.update({ where: { id: o.id }, data: { status: 'DONE' } });
+ const other = await startClient(ctx, tgUser(902));
+ expect(await code(rateOrder(ctx, { orderId: o.id, clientId: other.clientId, score: 5 }))).toBe('NOT_FOUND');
+ expect(await code(rateOrder(ctx, { orderId: o.id, clientId: s.clientId, score: 7 }))).toBe('INVALID_PARAMS');
+
+ expect(await rateOrder(ctx, { orderId: o.id, clientId: s.clientId, score: 3, comment: ' поздно пришла ' })).toEqual({ score: 3, comment: 'поздно пришла' });
+ let p = await db.provider.findUniqueOrThrow({ where: { id: fx.maria.id } });
+ expect([p.ratingSum - before.ratingSum, p.ratingCount - before.ratingCount]).toEqual([3, 1]);
+
+ await rateOrder(ctx, { orderId: o.id, clientId: s.clientId, score: 5 });
+ p = await db.provider.findUniqueOrThrow({ where: { id: fx.maria.id } });
+ expect([p.ratingSum - before.ratingSum, p.ratingCount - before.ratingCount]).toEqual([5, 1]);
+ expect(await db.rating.count({ where: { orderId: o.id } })).toBe(1);
+ });
+});