Stage 6 (part 1): new client mini-app look and order ratings
Plum hero with a club-card ticket, a 'Now' block with the live order timeline, new order cards and order screen, QR club card screen, new nav (Home / Orders / Chat / Card / Profile). Clients rate completed orders (1-5 stars + comment); provider rating is recalculated. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
8866616782
commit
ff527ad6d2
18 files changed
+1536
-205
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -19,3 +19,4 @@ export * from './sales.ts';
|
||||
export * from './texts.ts';
|
||||
export * from './promo.ts';
|
||||
export * from './partners.ts';
|
||||
export * from './ratings.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,
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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<ReturnType<typeof fixtures>>;
|
||||
beforeEach(async () => {
|
||||
fx = await fixtures();
|
||||
});
|
||||
afterAll(() => db.$disconnect());
|
||||
|
||||
const code = (p: Promise<unknown>) => 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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user