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:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 03:51:58 +04:00
1 parent 8866616782
commit ff527ad6d2
18 files changed
+1529 -198

No files matched your search

+5 -1
View File
@@ -1,5 +1,5 @@
import { defaultParams, discountFor, LOCALES } from '@barev/core'; import { defaultParams, discountFor, LOCALES } from '@barev/core';
import { import { rateOrder,
applyAgencyCode, applyAgencyCode,
availableSlots, availableSlots,
cancelByClient, cancelByClient,
@@ -229,6 +229,10 @@ export const clientRouter = router({
reschedule: clientProcedure reschedule: clientProcedure
.input(z.object({ id: z.string(), scheduledAt: z.date() })) .input(z.object({ id: z.string(), scheduledAt: z.date() }))
.mutation(({ ctx, input }) => rescheduleByClient(ctx.svc, { orderId: input.id, clientId: ctx.client.id, scheduledAt: input.scheduledAt })), .mutation(({ ctx, input }) => rescheduleByClient(ctx.svc, { orderId: input.id, clientId: ctx.client.id, scheduledAt: input.scheduledAt })),
/** Оценка после выполнения: звёзды и комментарий */
rate: consentedClientProcedure
.input(z.object({ id: z.string(), score: z.number().int().min(1).max(5), comment: z.string().max(1000).optional() }))
.mutation(({ ctx, input }) => rateOrder(ctx.svc, { orderId: input.id, clientId: ctx.client.id, score: input.score, comment: input.comment })),
/** ORD-7 */ /** ORD-7 */
repeat: clientProcedure.input(z.object({ id: z.string() })).query(({ ctx, input }) => repeatDraft(ctx.db, input.id, ctx.client.id)), repeat: clientProcedure.input(z.object({ id: z.string() })).query(({ ctx, input }) => repeatDraft(ctx.db, input.id, ctx.client.id)),
}), }),
+7 -7
View File
@@ -17,7 +17,7 @@ import { Profile } from './screens/Profile.tsx';
import { Services } from './screens/Services.tsx'; import { Services } from './screens/Services.tsx';
import { Chat } from './screens/Chat.tsx'; import { Chat } from './screens/Chat.tsx';
const ROOTS = ['/', '/services', '/perks', '/profile', '/docs']; const ROOTS = ['/', '/orders', '/services', '/perks', '/profile', '/docs'];
export function App({ hasInitData }: { hasInitData: boolean }) { export function App({ hasInitData }: { hasInitData: boolean }) {
const trpc = useTRPC(); const trpc = useTRPC();
@@ -66,7 +66,7 @@ function Routes({ segment }: { segment: 'MOVING' | 'LIVING' }) {
else if (path === '/membership') screen = <Membership />; else if (path === '/membership') screen = <Membership />;
else if (path === '/chat') screen = <Chat />; else if (path === '/chat') screen = <Chat />;
else screen = segment === 'MOVING' ? <Move /> : <Home />; else screen = segment === 'MOVING' ? <Move /> : <Home />;
const withNav = ROOTS.includes(path) || path === '/orders'; const withNav = ROOTS.includes(path);
return ( return (
<> <>
{screen} {screen}
@@ -97,7 +97,7 @@ function MoveNav({ path }: { path: string }) {
); );
} }
/** ORD-1: Главная / Услуги / + / Скидки / Профиль */ /** Главная / Заказы / Чат (в центре) / Клубная карта / Профиль; услуги — с главной */
function BottomNav({ path }: { path: string }) { function BottomNav({ path }: { path: string }) {
const { t } = useI18n(); const { t } = useI18n();
const item = (href: string, icon: Parameters<typeof Icon>[0]['name'], label: string) => ( const item = (href: string, icon: Parameters<typeof Icon>[0]['name'], label: string) => (
@@ -109,11 +109,11 @@ function BottomNav({ path }: { path: string }) {
return ( return (
<nav className="bc-bottomnav" aria-label={t('client.nav.label')}> <nav className="bc-bottomnav" aria-label={t('client.nav.label')}>
{item('/', 'home', t('client.nav.home'))} {item('/', 'home', t('client.nav.home'))}
{item('/services', 'grid', t('client.nav.services'))} {item('/orders', 'clock', t('client.nav.orders'))}
<a href="#/order/cleaning" className="bc-bottomnav__plus" aria-label={t('client.nav.newOrder')}> <a href="#/chat" className="bc-bottomnav__plus" aria-label={t('client.nav.chat')}>
<Icon name="plus" size={26} strokeWidth={2.6} /> <Icon name="chat" size={24} strokeWidth={2.4} />
</a> </a>
{item('/perks', 'gift', t('client.nav.perks'))} {item('/perks', 'gift', t('client.nav.card'))}
{item('/profile', 'user', t('client.nav.profile'))} {item('/profile', 'user', t('client.nav.profile'))}
</nav> </nav>
); );
+139 -11
View File
@@ -1,4 +1,4 @@
import { CATEGORY_TONE, Icon, type CategoryCode } from '@barev/ui'; import { CATEGORY_ICON, CATEGORY_TONE, Icon, type CategoryCode } from '@barev/ui';
import { paramsSummary, useI18n } from '@barev/web'; import { paramsSummary, useI18n } from '@barev/web';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
@@ -38,19 +38,31 @@ export function useParamsSummary() {
return (serviceCode: string, params: Record<string, unknown>) => paramsSummary(t, serviceCode, params); return (serviceCode: string, params: Record<string, unknown>) => paramsSummary(t, serviceCode, params);
} }
/** Строка заявки как в превью «Мои заявки» макета */ /** Карточка заказа в списке: иконка направления, услуга, время, статус и полоска прогресса */
export function OrderRow({ o }: { o: OrderLike }) { export function OrderRow({ o }: { o: OrderLike & { active?: boolean; steps?: readonly string[]; rating?: { score: number } | null; done?: boolean } }) {
const { t, dt } = useI18n(); const { t, dt } = useI18n();
const summary = useParamsSummary(); const summary = useParamsSummary();
const idx = o.steps ? o.steps.indexOf(o.status) : -1;
const progress = o.active && o.steps && idx >= 0 ? (idx + 1) / o.steps.length : null;
return ( return (
<a href={`#/orders/${o.id}`} className={`order-row ${toneClass(o.category)}`}> <a href={`#/orders/${o.id}`} className="order-card" data-status={o.status === 'CANCELLED' ? 'cancelled' : o.active ? 'active' : 'done'}>
<div className="order-row__main"> <span className={`order-card__icon ${toneClass(o.category)}`}>
<div className="order-row__title"> <Icon name={CATEGORY_ICON[o.category as CategoryCode] ?? 'home'} size={22} />
{t(`services.${o.serviceCode}.name`)} · {summary(o.serviceCode, o.params)} </span>
</div> <span className="order-card__main">
<div className="bc-small">{dt(o.scheduledAt, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</div> <span className="order-card__title">{t(`services.${o.serviceCode}.name`)}</span>
</div> <span className="order-card__sub">{summary(o.serviceCode, o.params)}</span>
<span className="bc-badge">{t(`status.${o.flow}.${o.status}`)}</span> <span className="order-card__when">{dt(o.scheduledAt, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</span>
{progress !== null && (
<span className="order-card__bar" aria-hidden="true">
<span style={{ width: `${Math.round(progress * 100)}%` }} />
</span>
)}
</span>
<span className="order-card__side">
<span className="order-card__status">{t(`status.${o.flow}.${o.status}`)}</span>
{o.rating ? <Stars value={o.rating.score} size={12} /> : o.done ? <span className="order-card__rate">{t('client.rate.prompt')}</span> : null}
</span>
</a> </a>
); );
} }
@@ -93,3 +105,119 @@ export function itemHint(names: RelocationNames | undefined, kind: 'STEP' | 'DOC
const h = names?.[`${kind}:${item.code}`]?.hint; const h = names?.[`${kind}:${item.code}`]?.hint;
return h?.[locale] || h?.ru || null; return h?.[locale] || h?.ru || null;
} }
export interface TimelineItem {
label: ReactNode;
state: 'done' | 'current' | 'todo';
meta?: ReactNode;
}
/** Вертикальная шкала как на сайте: зелёные точки — пройдено, розовая — сейчас */
export function Timeline({ items, compact }: { items: TimelineItem[]; compact?: boolean }) {
return (
<ol className={`tl${compact ? ' tl--compact' : ''}`}>
{items.map((s, i) => (
<li key={i} className={`tl__item tl__item--${s.state}`}>
<span className="tl__dot" aria-hidden="true">
{s.state === 'done' && <Icon name="check" size={12} strokeWidth={3} />}
</span>
<span className="tl__body">
<span className="tl__label">{s.label}</span>
{s.meta && <span className="tl__meta">{s.meta}</span>}
</span>
</li>
))}
</ol>
);
}
export interface OrderSteps {
flow: string;
status: string;
/** false — заказ завершён: последний шаг тоже зелёный */
active?: boolean;
steps: readonly string[];
history: { to: string; event: string | null; at: Date }[];
}
/** Шаги заказа по истории статусов: пройденные, текущий, остальные */
export function useOrderTimeline() {
const { t, dt } = useI18n();
return (o: OrderSteps): TimelineItem[] => {
const passed = new Set(o.history.map((h) => h.to));
if (o.status === 'CANCELLED')
return [...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 }];
const currentIdx = o.steps.indexOf(o.status);
return 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 || (i === currentIdx && o.active === false) ? 'done' : i === currentIdx ? 'current' : 'todo',
meta: at && i <= currentIdx ? dt(at, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) : undefined,
};
});
};
}
const STAR = 'M12 3l2.7 5.6 6.1.9-4.4 4.3 1 6.1L12 17l-5.4 2.9 1-6.1L3.2 9.5l6.1-.9z';
/** Звёзды 1–5; без onChange — только показ */
export function Stars({ value, onChange, size = 34 }: { value: number; onChange?: (v: number) => void; size?: number }) {
const { t } = useI18n();
return (
<div className="stars" role={onChange ? 'radiogroup' : 'img'} aria-label={onChange ? undefined : t('client.rate.star', { count: value })}>
{[1, 2, 3, 4, 5].map((n) => {
const svg = (
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden="true">
<path d={STAR} fill={n <= value ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth={1.8} strokeLinejoin="round" />
</svg>
);
return onChange ? (
<button key={n} type="button" role="radio" aria-checked={n === value} aria-label={t('client.rate.star', { count: n })} data-on={n <= value || undefined} onClick={() => onChange(n)}>
{svg}
</button>
) : (
<span key={n} data-on={n <= value || undefined}>
{svg}
</span>
);
})}
</div>
);
}
/** Клубная карта-«билет» в стиле карт на сайте */
export function ClubTicket({ level, name, code, until, href, action }: { level: Level; name?: string; code?: string | null; until?: ReactNode; href?: string; action?: ReactNode }) {
const id = level === 'BAREV' ? 'free' : level === 'BAREV_PLUS' ? 'plus' : 'elite';
const body = (
<>
<span className="ticket__main">
<span className="ticket__club">Barev Club</span>
<span className="ticket__level">{LEVEL_NAME[level]}</span>
<span className="ticket__meta">
{name && <b>{name}</b>}
{code && <span className="bc-num">{code}</span>}
</span>
{until && <span className="ticket__until">{until}</span>}
</span>
{action && <span className="ticket__stub">{action}</span>}
</>
);
return href ? (
<a className="ticket" data-plan={id} href={href}>
{body}
</a>
) : (
<div className="ticket" data-plan={id}>
{body}
</div>
);
}
export function QrGlyph({ size = 28 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinejoin="round" aria-hidden="true">
<path d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h2v2h-2zM18 14h2M14 18v2M18 18h2v2h-2z" />
</svg>
);
}
+1
View File
@@ -1,5 +1,6 @@
import '@barev/ui/styles.css'; import '@barev/ui/styles.css';
import './app.css'; import './app.css';
import './redesign.css';
import { ApiProvider, loadTextOverrides, getInitData, initTelegram, ToastProvider } from '@barev/web'; import { ApiProvider, loadTextOverrides, getInitData, initTelegram, ToastProvider } from '@barev/web';
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
+845
View File
@@ -0,0 +1,845 @@
/* Этап 6: новый вид клиентского приложения — в стиле barevclub.com (слива, розовый, зелёные точки) */
:root {
--plum-2: #5e1a36;
--plum-line: rgba(246, 195, 211, 0.22);
--green: #a8c38a;
--green-ink: #1f3311;
}
/* ───── сливовый верх, как на сайте ───── */
.app--hero {
padding-top: 0;
}
.hero {
margin: 0 -16px 4px;
padding: 18px 16px 22px;
background: var(--bc-plum);
color: #fff;
border-radius: 0 0 32px 32px;
display: flex;
flex-direction: column;
gap: 14px;
position: relative;
overflow: hidden;
}
.hero__word {
position: absolute;
right: -10px;
top: -18px;
font-family: var(--bc-font-head);
font-weight: 900;
font-style: italic;
font-size: 112px;
line-height: 1;
letter-spacing: -0.04em;
color: var(--bc-pink);
opacity: 0.16;
pointer-events: none;
}
.hero__hello {
position: relative;
margin: 6px 0 0;
font-family: var(--bc-font-head);
font-weight: 800;
font-size: 30px;
line-height: 1.05;
letter-spacing: -0.01em;
}
.hero__hello span {
color: var(--bc-pink);
}
.hero__hello small {
display: block;
font-family: var(--bc-font-body);
font-weight: 500;
font-size: 15px;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 2px;
}
.hero__note {
margin: 0;
font-size: 14px;
line-height: 1.4;
color: rgba(255, 255, 255, 0.78);
}
/* ───── клубная карта-«билет» ───── */
.ticket {
position: relative;
display: flex;
align-items: stretch;
min-height: 132px;
border-radius: 24px;
text-decoration: none;
color: var(--bc-plum);
background: var(--bc-pink);
overflow: hidden;
}
.ticket[data-plan='free'] {
background: var(--plum-2);
color: #fff;
box-shadow: inset 0 0 0 1.5px var(--plum-line);
}
.ticket[data-plan='elite'] {
background: var(--bc-perks);
}
.ticket__main {
flex: 1;
min-width: 0;
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 2px;
}
.ticket__club {
font-size: 12px;
font-weight: 600;
opacity: 0.75;
}
.ticket__level {
font-family: var(--bc-font-head);
font-weight: 900;
font-style: italic;
font-size: 34px;
line-height: 1;
letter-spacing: -0.02em;
margin-top: 2px;
}
.ticket__meta {
display: flex;
gap: 8px;
margin-top: auto;
padding-top: 10px;
font-size: 13px;
font-weight: 600;
}
.ticket__until {
font-size: 12px;
opacity: 0.8;
}
/* корешок: пунктир и вырезы, как у билета */
.ticket__stub {
flex: none;
width: 92px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
text-align: center;
font-size: 11px;
font-weight: 700;
line-height: 1.15;
padding: 10px 8px;
border-left: 2px dashed currentColor;
border-left-color: color-mix(in srgb, currentColor 35%, transparent);
position: relative;
}
.ticket__stub::before,
.ticket__stub::after {
content: '';
position: absolute;
left: -11px;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--bc-plum);
}
.ticket__stub::before {
top: -10px;
}
.ticket__stub::after {
bottom: -10px;
}
/* ───── секции ───── */
.sect {
display: flex;
flex-direction: column;
gap: 10px;
}
.sect__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding: 0 4px;
}
.sect__title {
margin: 4px 0 0;
display: flex;
align-items: center;
gap: 8px;
font-family: var(--bc-font-head);
font-weight: 800;
font-size: 22px;
letter-spacing: -0.01em;
}
.sect__link {
font-size: 14px;
font-weight: 600;
color: var(--bc-accent);
text-decoration: none;
}
.live-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--green);
box-shadow: 0 0 0 4px rgba(168, 195, 138, 0.3);
animation: live 2s ease-in-out infinite;
}
@keyframes live {
50% {
box-shadow: 0 0 0 7px rgba(168, 195, 138, 0.1);
}
}
/* ───── «Сейчас» ───── */
.now-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border-radius: 28px;
background: var(--bc-surface);
text-decoration: none;
color: var(--bc-ink);
box-shadow: var(--bc-shadow);
}
.now-card__top {
display: flex;
align-items: center;
gap: 12px;
}
.now-card__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.now-card__main b {
font-family: var(--bc-font-head);
font-size: 18px;
line-height: 1.15;
}
.now-card__main span {
font-size: 13px;
color: var(--bc-ink-2);
}
.now-card__when {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 16px;
background: var(--bc-bg);
font-weight: 600;
font-size: 14px;
}
.now-card__when::first-letter {
text-transform: uppercase;
}
.more-link {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 4px;
font-size: 14px;
font-weight: 600;
color: var(--bc-ink-2);
text-decoration: none;
}
.empty-now {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
padding: 18px;
border-radius: 28px;
background: var(--bc-surface);
}
.empty-now p {
margin: 0;
color: var(--bc-ink-2);
}
/* ───── шкала как на сайте ───── */
.tl {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.tl__item {
position: relative;
display: flex;
gap: 12px;
padding-bottom: 14px;
}
.tl__item:last-child {
padding-bottom: 0;
}
.tl__item::before {
content: '';
position: absolute;
left: 10px;
top: 22px;
bottom: 0;
width: 2px;
background: var(--bc-line);
}
.tl__item--done::before {
background: var(--green);
}
.tl__item:last-child::before {
display: none;
}
.tl__dot {
flex: none;
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid var(--bc-line);
background: var(--bc-surface);
display: grid;
place-items: center;
color: var(--green-ink);
position: relative;
z-index: 1;
}
.tl__item--done .tl__dot {
background: var(--green);
border-color: var(--green);
}
.tl__item--current .tl__dot {
border: 6px solid var(--bc-accent);
box-shadow: 0 0 0 4px rgba(184, 35, 90, 0.15);
}
.tl__body {
display: flex;
flex-direction: column;
padding-top: 1px;
min-width: 0;
}
.tl__label {
font-weight: 600;
font-size: 15px;
line-height: 1.3;
}
.tl__item--todo .tl__label {
color: var(--bc-ink-3);
font-weight: 500;
}
.tl__item--current .tl__label {
color: var(--bc-accent);
}
.tl__meta {
font-size: 12px;
color: var(--bc-ink-3);
}
/* компактно на главной — шаги в строку */
.tl--compact {
flex-direction: row;
}
.tl--compact .tl__item {
flex: 1;
flex-direction: column;
align-items: flex-start;
gap: 6px;
padding: 0;
min-width: 0;
}
.tl--compact .tl__item::before {
left: 22px;
right: 0;
top: 10px;
bottom: auto;
width: auto;
height: 2px;
}
.tl--compact .tl__item:last-child {
flex: none;
}
.tl--compact .tl__body {
display: none;
}
.tl--compact .tl__item--current .tl__body {
display: flex;
position: absolute;
top: 28px;
left: 0;
white-space: nowrap;
}
.tl--compact .tl__item--current .tl__meta {
display: none;
}
.tl--compact {
padding-bottom: 24px;
}
.tl--compact .tl__item:last-child.tl__item--current .tl__body {
left: auto;
right: 0;
}
/* ───── карточки заказов ───── */
.order-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.order-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px;
border-radius: 24px;
background: var(--bc-surface);
text-decoration: none;
color: var(--bc-ink);
}
.order-card[data-status='cancelled'] {
opacity: 0.6;
}
.order-card__icon {
flex: none;
width: 44px;
height: 44px;
border-radius: 16px;
display: grid;
place-items: center;
}
.order-card__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.order-card__title {
font-family: var(--bc-font-head);
font-weight: 700;
font-size: 16px;
}
.order-card__sub,
.order-card__when {
font-size: 13px;
color: var(--bc-ink-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.order-card__when {
color: var(--bc-ink);
font-weight: 500;
}
.order-card__bar {
margin-top: 8px;
height: 4px;
border-radius: 4px;
background: var(--bc-line);
overflow: hidden;
}
.order-card__bar span {
display: block;
height: 100%;
border-radius: 4px;
background: var(--green);
}
.order-card__side {
flex: none;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.order-card__status {
padding: 5px 10px;
border-radius: var(--bc-pill);
background: var(--bc-bg);
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.order-card[data-status='active'] .order-card__status {
background: var(--bc-plum);
color: #fff;
}
.order-card__rate {
font-size: 12px;
font-weight: 600;
color: var(--bc-accent);
}
.order-card .stars {
color: #e9a400;
}
/* ───── карточка заказа ───── */
.od-hero {
margin: -16px -16px 0;
padding: 12px 16px 22px;
border-radius: 0 0 32px 32px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.od-hero__bar {
align-self: stretch;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.od-hero__num {
font-family: var(--bc-font-head);
font-weight: 700;
opacity: 0.6;
}
.od-hero__status {
padding: 6px 12px;
border-radius: var(--bc-pill);
background: var(--bc-plum);
color: #fff;
font-size: 13px;
font-weight: 600;
}
.od-hero__status[data-status='done'] {
background: var(--bc-surface);
color: var(--green-ink);
}
.od-hero__status[data-status='cancelled'] {
background: var(--bc-surface);
color: var(--bc-danger);
}
.od-hero__title {
margin: 4px 0 0;
font-family: var(--bc-font-head);
font-weight: 800;
font-size: 32px;
line-height: 1.02;
letter-spacing: -0.02em;
}
.od-hero__sub {
font-size: 15px;
opacity: 0.85;
}
.facts {
margin: 0;
padding: 4px 16px;
border-radius: 24px;
background: var(--bc-surface);
}
.facts > div {
display: flex;
flex-direction: column;
gap: 2px;
padding: 12px 0;
border-top: 1px solid var(--bc-line);
}
.facts > div:first-child {
border-top: 0;
}
.facts dt {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--bc-ink-3);
}
.facts dd {
margin: 0 0 0 24px;
font-weight: 600;
}
.facts dd::first-letter {
text-transform: uppercase;
}
.panel {
padding: 16px;
border-radius: 24px;
background: var(--bc-surface);
}
.panel__title {
margin: 0 0 12px;
font-family: var(--bc-font-head);
font-weight: 700;
font-size: 18px;
}
/* ───── оценка ───── */
.rate {
padding: 18px;
border-radius: 28px;
background: var(--bc-plum);
color: #fff;
display: flex;
flex-direction: column;
gap: 10px;
}
.rate__title {
margin: 0;
font-family: var(--bc-font-head);
font-weight: 800;
font-size: 22px;
}
.rate__text {
margin: -4px 0 0;
font-size: 14px;
color: rgba(255, 255, 255, 0.75);
}
.rate .stars {
color: var(--bc-perks);
}
.rate__input {
width: 100%;
min-height: 64px;
padding: 12px 14px;
border-radius: 18px;
border: 0;
font: inherit;
font-size: 15px;
resize: vertical;
box-sizing: border-box;
}
.rate--done {
background: var(--bc-surface);
color: var(--bc-ink);
}
.rate--done .stars {
color: #e9a400;
}
.rate__label {
font-size: 13px;
font-weight: 600;
color: var(--bc-ink-3);
}
.rate__change {
border: 0;
background: none;
font: inherit;
font-size: 14px;
font-weight: 600;
color: var(--bc-accent);
cursor: pointer;
}
.rate__comment {
margin: 0;
color: var(--bc-ink-2);
}
.rate-nudge {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-radius: 24px;
background: var(--bc-perks);
color: var(--bc-perks-ink);
text-decoration: none;
}
.rate-nudge > span {
display: flex;
flex-direction: column;
min-width: 0;
font-size: 13px;
}
.rate-nudge b {
font-family: var(--bc-font-head);
font-size: 16px;
}
.stars {
display: flex;
gap: 4px;
}
.stars button {
padding: 2px;
border: 0;
background: none;
color: inherit;
cursor: pointer;
line-height: 0;
transition: transform 0.12s;
}
.stars button:active {
transform: scale(0.88);
}
.stars [data-on] svg {
filter: none;
}
/* ───── кнопки-«таблетки» как на сайте ───── */
.pill {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 46px;
padding: 0 20px;
border-radius: var(--bc-pill);
border: 0;
font: inherit;
font-weight: 600;
font-size: 15px;
text-decoration: none;
cursor: pointer;
white-space: nowrap;
}
.pill:disabled {
opacity: 0.6;
}
.pill--block {
width: 100%;
}
.pill--dark {
background: var(--bc-plum);
color: #fff;
}
.pill--pink {
background: var(--bc-pink);
color: var(--bc-plum);
}
.pill--green {
background: var(--green);
color: var(--green-ink);
}
.screen-head .pill--dark {
width: 46px;
padding: 0;
}
/* ───── заголовок экрана ───── */
.screen-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-top: 8px;
}
.screen-head__title {
margin: 0;
font-family: var(--bc-font-head);
font-weight: 800;
font-size: 32px;
letter-spacing: -0.02em;
}
/* ───── направления ───── */
.tiles .tile {
min-height: 118px;
border-radius: 26px;
justify-content: flex-start;
gap: 6px;
}
.tiles .tile .tile__title {
margin-top: auto;
font-size: 18px;
}
.tile__note {
font-size: 12px;
opacity: 0.8;
}
/* ───── чат ───── */
.chat-cta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 18px;
border-radius: 28px;
background: var(--bc-plum);
color: #fff;
text-decoration: none;
}
.chat-cta > span:first-child {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 13px;
color: rgba(255, 255, 255, 0.75);
}
.chat-cta b {
font-family: var(--bc-font-head);
font-size: 17px;
color: #fff;
}
/* ───── QR-карта ───── */
.hero--card {
align-items: stretch;
padding-top: 22px;
}
.hero--card .pill {
align-self: flex-start;
}
.qr-card {
border-radius: 28px;
padding: 16px;
background: var(--bc-pink);
color: var(--bc-plum);
display: flex;
flex-direction: column;
gap: 12px;
}
.qr-card[data-plan='free'] {
background: var(--plum-2);
color: #fff;
box-shadow: inset 0 0 0 1.5px var(--plum-line);
}
.qr-card[data-plan='elite'] {
background: var(--bc-perks);
}
.qr-card__top {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 13px;
font-weight: 600;
}
.qr-card__top b {
font-family: var(--bc-font-head);
font-weight: 900;
font-style: italic;
font-size: 26px;
}
.qr-card__code {
align-self: center;
padding: 12px;
border-radius: 22px;
background: #fff;
line-height: 0;
}
.qr-card__meta {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 13px;
}
.qr-card__timer {
opacity: 0.75;
}
.bc-segmented button[aria-pressed='true'] {
background: var(--bc-plum);
color: #fff;
}
/* ───── нижнее меню — слива и розовая кнопка чата ───── */
.bc-bottomnav {
background: var(--bc-plum);
box-shadow: 0 10px 30px rgba(74, 16, 40, 0.35);
}
.bc-bottomnav a,
.bc-bottomnav button {
color: rgba(246, 195, 211, 0.6);
}
.bc-bottomnav [aria-current='page'] {
color: var(--bc-pink);
}
.bc-bottomnav .bc-bottomnav__plus {
background: var(--bc-pink);
color: var(--bc-plum);
}
+122 -77
View File
@@ -1,103 +1,148 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { CATEGORY_ICON, Card, Chip, Icon, Spinner, type CategoryCode } from '@barev/ui'; import { CATEGORY_ICON, Icon, Spinner, type CategoryCode } from '@barev/ui';
import { navigate, useI18n, useTRPC } from '@barev/web'; import { useI18n, useTRPC } from '@barev/web';
import { LEVEL_NAME, LevelBadge, levelDiscount, OrderRow, toneClass, type Level } from '../lib.tsx'; import { ClubTicket, LEVEL_NAME, levelDiscount, QrGlyph, Stars, Timeline, toneClass, useOrderTimeline, useParamsSummary, type Level } from '../lib.tsx';
/** Главная для тех, кто уже живёт в Армении: заказы, быстрые кнопки, направления со скидкой по уровню */ /**
* Главная для тех, кто уже живёт в Армении.
* Сверху — сливовый блок как на сайте: приветствие и клубная карта.
* Дальше — «Сейчас» (ближайший заказ со шкалой), просьба оценить, направления, чат.
*/
export function Home() { export function Home() {
const { t, dt } = useI18n(); const { t, dt } = useI18n();
const trpc = useTRPC(); const trpc = useTRPC();
const me = useQuery(trpc.client.me.queryOptions()); const me = useQuery(trpc.client.me.queryOptions());
const orders = useQuery({ ...trpc.client.orders.list.queryOptions(), refetchInterval: 20_000 }); const orders = useQuery({ ...trpc.client.orders.list.queryOptions(), refetchInterval: 20_000 });
const catalog = useQuery(trpc.client.catalog.queryOptions()); const catalog = useQuery(trpc.client.catalog.queryOptions());
const timeline = useOrderTimeline();
const summary = useParamsSummary();
const level = (me.data?.membership.level ?? 'BAREV') as Level; const level = (me.data?.membership.level ?? 'BAREV') as Level;
const plus = level !== 'BAREV'; const now = orders.data?.active[0];
const lastDone = orders.data?.history.find((o) => o.status !== 'CANCELLED'); const moreActive = (orders.data?.active.length ?? 0) - 1;
const toRate = orders.data?.history.find((o) => o.done && !o.rating);
return ( return (
<main className="app"> <main className="app app--hero">
<header className="bc-between" style={{ alignItems: 'flex-start' }}> <section className="hero">
<div> <div className="hero__word" aria-hidden="true">
<div className="bc-small bc-muted">{t('client.home.hello')}</div> Barev
<h1 className="bc-h2">{me.data?.firstName ?? t('client.home.guest')}</h1>
</div> </div>
<LevelBadge level={level} /> <h1 className="hero__hello">
</header> <small>{t('client.home.hello')}</small>
<span>{me.data?.firstName ?? t('client.home.guest')}</span>
</h1>
<ClubTicket
level={level}
code={me.data?.cardCode}
href="#/perks"
until={
level !== 'BAREV' && me.data?.membership.endsAt
? t('client.home.levelUntil', { plan: LEVEL_NAME[level], date: dt(me.data.membership.endsAt, { day: 'numeric', month: 'long' }) })
: t('client.home.free')
}
action={
<>
<QrGlyph />
<span>{t('client.home.cardShow')}</span>
</>
}
/>
</section>
{plus && me.data?.membership.endsAt && ( {orders.isPending ? (
<Card tone="perks" tight> <Spinner />
<div className="bc-row"> ) : now ? (
<Icon name="gift" /> <section className="sect">
<div className="bc-small" style={{ fontWeight: 600 }}> <div className="sect__head">
{t('client.home.levelUntil', { plan: LEVEL_NAME[level], date: dt(me.data.membership.endsAt, { day: 'numeric', month: 'long' }) })} <h2 className="sect__title">
<span className="live-dot" aria-hidden="true" />
{t('client.home.now')}
</h2>
<a href="#/orders" className="sect__link">
{t('client.home.allOrders')}
</a>
</div> </div>
</div> <a href={`#/orders/${now.id}`} className="now-card">
</Card> <span className="now-card__top">
<span className={`order-card__icon ${toneClass(now.category)}`}>
<Icon name={CATEGORY_ICON[now.category as CategoryCode] ?? 'home'} size={22} />
</span>
<span className="now-card__main">
<b>{t(`services.${now.serviceCode}.name`)}</b>
<span>{summary(now.serviceCode, now.params)}</span>
</span>
<Icon name="arrow" size={20} />
</span>
<span className="now-card__when">
<Icon name="clock" size={18} />
{dt(now.scheduledAt, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })}
</span>
<Timeline items={timeline(now)} compact />
</a>
{moreActive > 0 && (
<a href="#/orders" className="more-link">
{t('client.home.moreActive', { count: moreActive })}
<Icon name="arrow" size={16} />
</a>
)}
</section>
) : (
<section className="empty-now">
<p>{t('client.home.noOrders')}</p>
<a href="#/order/cleaning" className="pill pill--dark">
<Icon name="plus" size={18} strokeWidth={2.6} />
{t('services.cleaning.name')}
</a>
</section>
)} )}
{!plus && ( {toRate && (
<a href={`#/orders/${toRate.id}?rate=1`} className="rate-nudge">
<span>
<b>{t('client.rate.prompt')}</b>
<span>
{t(`services.${toRate.serviceCode}.name`)} · {dt(toRate.scheduledAt, { day: 'numeric', month: 'short' })}
</span>
</span>
<Stars value={0} size={22} />
</a>
)}
<section className="sect">
<div className="sect__head">
<h2 className="sect__title">{t('client.home.orderTitle')}</h2>
<a href="#/services" className="sect__link">
{t('client.nav.services')}
</a>
</div>
<div className="tiles">
{(catalog.data?.categories ?? []).map((c) => (
<a key={c.code} href={c.code === 'PERKS' ? '#/perks' : `#/services?c=${c.code}`} className={`tile ${toneClass(c.code)}`}>
<Icon name={CATEGORY_ICON[c.code as CategoryCode]} size={26} />
<span className="tile__title">{t(`categories.${c.code}.name`)}</span>
<span className="tile__note">{levelDiscount(level, c) > 0 ? t('client.services.yourDiscount', { pct: levelDiscount(level, c) }) : t(`categories.${c.code}.short`)}</span>
</a>
))}
</div>
</section>
{level === 'BAREV' && (
<a href="#/membership" className="upsell"> <a href="#/membership" className="upsell">
<b>{t('client.home.upsellTitle', { pct: me.data?.plans.eliteMaxPct ?? 0 })}</b> <b>{t('client.home.upsellTitle', { pct: me.data?.plans.eliteMaxPct ?? 0 })}</b>
<span className="bc-small">{t('client.home.upsellText')}</span> <span className="bc-small">{t('client.home.upsellText')}</span>
</a> </a>
)} )}
<Card style={{ padding: 16 }}> <a href="#/chat" className="chat-cta">
<div className="bc-between" style={{ padding: '0 4px 8px' }}> <span>
<h2 className="bc-h3">{t('client.home.myOrders')}</h2> <b>{t('client.home.chatTitle')}</b>
<a href="#/orders" className="bc-small bc-muted"> <span>{t('client.home.chatText')}</span>
{t('client.home.allOrders')} </span>
<span className="pill pill--green">
<Icon name="chat" size={18} />
{t('client.nav.chat')}
</span>
</a> </a>
</div>
{orders.isPending ? (
<Spinner />
) : orders.data && orders.data.active.length > 0 ? (
<div className="bc-stack" style={{ gap: 8 }}>
{orders.data.active.slice(0, 4).map((o) => (
<OrderRow key={o.id} o={o} />
))}
</div>
) : (
<div className="bc-stack" style={{ padding: '4px 4px 6px' }}>
<p className="bc-muted" style={{ margin: 0 }}>
{t('client.home.noOrders')}
</p>
</div>
)}
</Card>
<div className="quick">
<Chip onClick={() => navigate('/order/cleaning')}>
<Icon name="home" size={18} />
{t('services.cleaning.name')}
</Chip>
{lastDone && (
<Chip onClick={() => navigate(`/order/${lastDone.serviceCode}?repeat=${lastDone.id}`)}>
<Icon name="repeat" size={18} />
{t('client.home.repeatLast')}
</Chip>
)}
{!me.data?.agencyName && (
<Chip onClick={() => navigate('/profile?code=1')}>
<Icon name="gift" size={18} />
{t('client.home.enterCode')}
</Chip>
)}
</div>
<div className="tiles">
{(catalog.data?.categories ?? []).map((c) => (
<a key={c.code} href={c.code === 'PERKS' ? '#/perks' : `#/services?c=${c.code}`} className={`tile ${toneClass(c.code)}`}>
<div className="bc-between" style={{ alignItems: 'flex-start' }}>
<div className="tile__title">{t(`categories.${c.code}.name`)}</div>
<Icon name={CATEGORY_ICON[c.code as CategoryCode]} size={24} />
</div>
<div className="bc-small">
{levelDiscount(level, c) > 0 ? t('client.services.yourDiscount', { pct: levelDiscount(level, c) }) : t(`categories.${c.code}.short`)}
</div>
</a>
))}
</div>
</main> </main>
); );
} }
+127 -64
View File
@@ -1,8 +1,8 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Button, Card, Center, Icon, Sheet, Spinner, Steps } from '@barev/ui'; import { Button, Center, Icon, Sheet, Spinner } from '@barev/ui';
import { confirmDialog, dateKeyToDate, errorText, haptic, navigate, nextDays, paramsExtras, useI18n, useToast, useTRPC } from '@barev/web'; import { confirmDialog, dateKeyToDate, errorText, haptic, navigate, nextDays, paramsExtras, useI18n, useLocation, useToast, useTRPC } from '@barev/web';
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { PageHead, toneClass, useParamsSummary } from '../lib.tsx'; import { Stars, Timeline, toneClass, useOrderTimeline, useParamsSummary } from '../lib.tsx';
export function OrderDetail({ id }: { id: string }) { export function OrderDetail({ id }: { id: string }) {
const { t, amd, dt, locale } = useI18n(); const { t, amd, dt, locale } = useI18n();
@@ -10,6 +10,7 @@ export function OrderDetail({ id }: { id: string }) {
const qc = useQueryClient(); const qc = useQueryClient();
const toast = useToast(); const toast = useToast();
const summary = useParamsSummary(); const summary = useParamsSummary();
const timeline = useOrderTimeline();
const order = useQuery({ ...trpc.client.orders.get.queryOptions({ id }), refetchInterval: 15_000 }); 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 preview = useQuery({ ...trpc.client.orders.cancelPreview.queryOptions({ id }), enabled: !!order.data?.active });
const [reschedule, setReschedule] = useState(false); const [reschedule, setReschedule] = useState(false);
@@ -32,22 +33,8 @@ export function OrderDetail({ id }: { id: string }) {
</Center> </Center>
); );
const o = order.data; 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 arrived = o.history.some((h) => h.event === 'arrived');
const extras = paramsExtras(t, o.params);
async function onCancel() { async function onCancel() {
const p = preview.data?.policy; const p = preview.data?.policy;
@@ -58,47 +45,63 @@ export function OrderDetail({ id }: { id: string }) {
return ( return (
<main className="app app--plain"> <main className="app app--plain">
<PageHead title={`#${o.number}`} /> <section className={`od-hero ${toneClass(o.category)}`}>
<div className="od-hero__bar">
<Card className={toneClass(o.category)} style={{ padding: 20 }}> <button className="back" onClick={() => window.history.back()} aria-label={t('common.back')}>
<div className="bc-stack" style={{ gap: 8 }}> <Icon name="back" />
<div className="bc-between" style={{ alignItems: 'flex-start' }}> </button>
<h2 className="bc-h2">{t(`services.${o.serviceCode}.name`)}</h2> <span className="od-hero__num">#{o.number}</span>
<span className="bc-badge">{t(`status.${o.flow}.${o.status}`)}</span>
</div> </div>
<div>{summary(o.serviceCode, o.params)}</div> <span className="od-hero__status" data-status={o.status === 'CANCELLED' ? 'cancelled' : o.active ? 'active' : 'done'}>
{paramsExtras(t, o.params).length > 0 && <div className="bc-small">+ {paramsExtras(t, o.params).map((e) => e.label).join(', ')}</div>} {t(`status.${o.flow}.${o.status}`)}
<div className="bc-row" style={{ fontWeight: 600 }}> {arrived && o.status === 'EN_ROUTE' ? ` · ${t('client.detail.arrived')}` : ''}
</span>
<h1 className="od-hero__title">{t(`services.${o.serviceCode}.name`)}</h1>
<div className="od-hero__sub">
{summary(o.serviceCode, o.params)}
{extras.length > 0 && ` + ${extras.map((e) => e.label).join(', ')}`}
</div>
</section>
<dl className="facts">
<div>
<dt>
<Icon name="clock" size={18} /> <Icon name="clock" size={18} />
{dt(o.scheduledAt, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })} {t('client.detail.when')}
</dt>
<dd>{dt(o.scheduledAt, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })}</dd>
</div> </div>
{o.addressText && ( {o.addressText && (
<div className="bc-row"> <div>
<dt>
<Icon name="pin" size={18} /> <Icon name="pin" size={18} />
{t('client.detail.where')}
</dt>
<dd>
{o.districtCode ? `${t(`districts.${o.districtCode}`)}, ` : ''} {o.districtCode ? `${t(`districts.${o.districtCode}`)}, ` : ''}
{o.addressText} {o.addressText}
</dd>
</div> </div>
)} )}
{o.providerName && ( <div>
<div className="bc-row"> <dt>
<Icon name="user" size={18} /> <Icon name="user" size={18} />
{t(`client.detail.providerLabel.${o.flow}`)}: {o.providerName} {t(`client.detail.providerLabel.${o.flow}`)}
{arrived && o.status === 'EN_ROUTE' && <span className="bc-badge">{t('client.detail.arrived')}</span>} </dt>
<dd>{o.providerName ?? <span className="bc-muted">{t('client.detail.whoPending')}</span>}</dd>
</div> </div>
)} </dl>
</div>
</Card>
<Card> {o.done && <RateBlock id={o.id} rating={o.rating} />}
<h2 className="bc-h3" style={{ marginBottom: 12 }}>
{t('client.detail.progress')} <section className="panel">
</h2> <h2 className="panel__title">{t('client.detail.progress')}</h2>
<Steps items={steps} /> <Timeline items={timeline(o)} />
</Card> </section>
{o.photos.length > 0 && <PhotoReport photos={o.photos} />} {o.photos.length > 0 && <PhotoReport photos={o.photos} />}
<Card className="price-lines"> <section className="panel price-lines">
<div className="bc-between"> <div className="bc-between">
<span>{t('client.order.price')}</span> <span>{t('client.order.price')}</span>
<span>{amd(o.basePrice)}</span> <span>{amd(o.basePrice)}</span>
@@ -123,16 +126,31 @@ export function OrderDetail({ id }: { id: string }) {
{t(`payment.${o.paymentStatus}`)} {t(`payment.${o.paymentStatus}`)}
{o.paymentMethod ? ` · ${o.paymentMethod.name[locale] || o.paymentMethod.name.ru}` : ''} {o.paymentMethod ? ` · ${o.paymentMethod.name[locale] || o.paymentMethod.name.ru}` : ''}
</div> </div>
</Card> </section>
{o.note && ( {o.note && (
<Card tight> <section className="panel">
<div className="bc-small bc-muted">{t('client.order.note')}</div> <div className="bc-small bc-muted">{t('client.order.note')}</div>
<div>{o.note}</div> <div>{o.note}</div>
</Card> </section>
)} )}
<div className="bc-stack" style={{ gap: 8 }}> <div className="bc-stack" style={{ gap: 8 }}>
{!o.active && o.status !== 'CANCELLED' && (
<Button variant="dark" block onClick={() => navigate(`/order/${o.serviceCode}?repeat=${o.id}`)}>
<Icon name="repeat" size={18} />
{t('client.detail.repeat')}
</Button>
)}
{o.status === 'CANCELLED' && (
<Button variant="dark" block onClick={() => navigate(`/order/${o.serviceCode}?repeat=${o.id}`)}>
{t('client.detail.orderAgain')}
</Button>
)}
<Button variant="light" block onClick={() => navigate('/chat')}>
<Icon name="chat" size={18} />
{t('client.detail.ask')}
</Button>
{o.active && preview.data?.canReschedule && ( {o.active && preview.data?.canReschedule && (
<Button variant="light" block onClick={() => setReschedule(true)}> <Button variant="light" block onClick={() => setReschedule(true)}>
{t('client.detail.reschedule')} {t('client.detail.reschedule')}
@@ -148,17 +166,6 @@ export function OrderDetail({ id }: { id: string }) {
{t('client.detail.viaOperator', { hours: preview.data.freeHours })} {t('client.detail.viaOperator', { hours: preview.data.freeHours })}
</p> </p>
)} )}
{!o.active && o.status !== 'CANCELLED' && (
<Button variant="dark" block onClick={() => navigate(`/order/${o.serviceCode}?repeat=${o.id}`)}>
<Icon name="repeat" size={18} />
{t('client.detail.repeat')}
</Button>
)}
{o.status === 'CANCELLED' && (
<Button variant="light" block onClick={() => navigate(`/order/${o.serviceCode}?repeat=${o.id}`)}>
{t('client.detail.orderAgain')}
</Button>
)}
</div> </div>
<RescheduleSheet open={reschedule} onClose={() => setReschedule(false)} order={o} /> <RescheduleSheet open={reschedule} onClose={() => 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<HTMLElement>(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 (
<section className="rate rate--done" ref={ref}>
<div className="bc-between">
<span className="rate__label">{t('client.rate.yours')}</span>
<button type="button" className="rate__change" onClick={() => setEditing(true)}>
{t('client.rate.change')}
</button>
</div>
<Stars value={rating.score} size={26} />
{rating.comment && <p className="rate__comment">{rating.comment}</p>}
</section>
);
return (
<section className="rate" ref={ref}>
<h2 className="rate__title">{t('client.rate.title')}</h2>
<p className="rate__text">{t('client.rate.text')}</p>
<Stars value={score} onChange={setScore} size={40} />
{score > 0 && (
<>
<textarea className="rate__input" rows={2} maxLength={1000} placeholder={t('client.rate.comment')} value={comment} onChange={(e) => setComment(e.target.value)} />
<button type="button" className="pill pill--pink pill--block" disabled={rate.isPending} onClick={() => rate.mutate({ id, score, comment: comment.trim() || undefined })}>
{t('client.rate.send')}
</button>
</>
)}
</section>
);
}
function PhotoReport({ photos }: { photos: { code: string; fileId: string }[] }) { function PhotoReport({ photos }: { photos: { code: string; fileId: string }[] }) {
const { t } = useI18n(); const { t } = useI18n();
const trpc = useTRPC(); const trpc = useTRPC();
return ( return (
<Card> <section className="panel">
<h2 className="bc-h3" style={{ marginBottom: 10 }}> <h2 className="panel__title">{t('client.detail.photoReport')}</h2>
{t('client.detail.photoReport')}
</h2>
<div className="photos"> <div className="photos">
{photos.map((p) => ( {photos.map((p) => (
<Photo key={p.fileId} fileId={p.fileId} alt={t(`checklist.${p.code}`)} trpc={trpc} /> <Photo key={p.fileId} fileId={p.fileId} alt={t(`checklist.${p.code}`)} trpc={trpc} />
))} ))}
</div> </div>
</Card> </section>
); );
} }
+14 -7
View File
@@ -1,8 +1,8 @@
import { useQuery } from '@tanstack/react-query'; 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 { useI18n, useTRPC } from '@barev/web';
import { useState } from 'react'; import { useState } from 'react';
import { OrderRow, PageHead } from '../lib.tsx'; import { OrderRow } from '../lib.tsx';
export function Orders() { export function Orders() {
const { t } = useI18n(); const { t } = useI18n();
@@ -18,7 +18,12 @@ export function Orders() {
const list = orders.data[tab]; const list = orders.data[tab];
return ( return (
<main className="app"> <main className="app">
<PageHead title={t('client.orders.title')} /> <header className="screen-head">
<h1 className="screen-head__title">{t('client.orders.title')}</h1>
<a href="#/services" className="pill pill--dark" aria-label={t('client.orders.order')}>
<Icon name="plus" size={18} strokeWidth={2.6} />
</a>
</header>
<Segmented <Segmented
value={tab} value={tab}
onChange={setTab} onChange={setTab}
@@ -28,12 +33,14 @@ export function Orders() {
]} ]}
/> />
{list.length === 0 ? ( {list.length === 0 ? (
<div className="bc-stack" style={{ alignItems: 'center', padding: 24 }}> <div className="empty-now">
<p className="bc-muted">{t(tab === 'active' ? 'client.home.noOrders' : 'client.orders.noHistory')}</p> <p>{t(tab === 'active' ? 'client.home.noOrders' : 'client.orders.noHistory')}</p>
<LinkButton href="#/services">{t('client.orders.order')}</LinkButton> <a href="#/services" className="pill pill--dark">
{t('client.orders.order')}
</a>
</div> </div>
) : ( ) : (
<div className="bc-stack" style={{ gap: 8 }}> <div className="order-list">
{list.map((o) => ( {list.map((o) => (
<OrderRow key={o.id} o={o} /> <OrderRow key={o.id} o={o} />
))} ))}
+22 -16
View File
@@ -1,8 +1,8 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Card } from '@barev/ui'; import { Card, Icon } from '@barev/ui';
import { useI18n, useTRPC } from '@barev/web'; import { useI18n, useTRPC } from '@barev/web';
import { useEffect, useState } from 'react'; 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'; 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 qr = useQuery({ ...trpc.client.cardQr.queryOptions(), refetchInterval: 20_000, refetchIntervalInBackground: false });
const partners = useQuery(trpc.client.partners.queryOptions()); const partners = useQuery(trpc.client.partners.queryOptions());
const level = (me.data?.membership.level ?? 'BAREV') as Level; const level = (me.data?.membership.level ?? 'BAREV') as Level;
const tone = LEVEL_TONE[level];
const left = useSecondsLeft(qr.data?.expiresAt); const left = useSecondsLeft(qr.data?.expiresAt);
const name = (l: Record<string, string>) => l[locale] || l.ru || ''; const name = (l: Record<string, string>) => l[locale] || l.ru || '';
return ( return (
<main className="app"> <main className="app app--hero">
<PageHead title={t('client.perks.title')} back={false} /> <section className="hero hero--card">
<section className="card-qr" style={{ background: level === 'BAREV' ? 'var(--bc-plum)' : tone.bg, color: level === 'BAREV' ? '#fff' : tone.ink }}> <h1 className="hero__hello">{t('client.card.title')}</h1>
<div className="card-qr__top"> <div className="qr-card" data-plan={level === 'BAREV' ? 'free' : level === 'BAREV_PLUS' ? 'plus' : 'elite'}>
<span>Barev Club · {LEVEL_NAME[level]}</span> <div className="qr-card__top">
<b>{me.data?.firstName}</b> <span>Barev Club</span>
<b>{LEVEL_NAME[level]}</b>
</div> </div>
<div className="card-qr__code">{qr.data ? <Qr value={qr.data.url} size={208} label={t('client.partners.qrLabel')} /> : <div style={{ width: 208, height: 208 }} />}</div> <div className="qr-card__code">{qr.data ? <Qr value={qr.data.url} size={200} label={t('client.partners.qrLabel')} /> : <div style={{ width: 200, height: 200 }} />}</div>
<div className="card-qr__meta"> <div className="qr-card__meta">
<span className="bc-num">{me.data?.cardCode}</span> <span>
<span>{t('client.partners.qrRefresh', { seconds: left })}</span> <b>{me.data?.firstName}</b> · <span className="bc-num">{me.data?.cardCode}</span>
</span>
<span className="qr-card__timer">{t('client.partners.qrRefresh', { seconds: left })}</span>
</div> </div>
</div>
<p className="hero__note">{t('client.partners.howTo')}</p>
<a href="#/membership" className="pill pill--pink">
{t('client.card.plans')}
<Icon name="arrow" size={18} />
</a>
</section> </section>
<p className="bc-small bc-muted" style={{ margin: 0 }}> <h2 className="sect__title">{t('client.card.partners')}</h2>
{t('client.partners.howTo')}
</p>
{partners.data?.length === 0 ? ( {partners.data?.length === 0 ? (
<Card tone="perks" className="bc-stack"> <Card tone="perks" className="bc-stack">
<h2 className="bc-h3">{t('client.perks.soonTitle')}</h2> <h2 className="bc-h3">{t('client.perks.soonTitle')}</h2>
+35 -3
View File
@@ -455,7 +455,10 @@
"perks": "Perks", "perks": "Perks",
"profile": "Profile", "profile": "Profile",
"plan": "Plan", "plan": "Plan",
"docs": "Documents" "docs": "Documents",
"orders": "Orders",
"card": "Card",
"chat": "Chat"
}, },
"openInTelegram": { "openInTelegram": {
"title": "Open in Telegram", "title": "Open in Telegram",
@@ -480,7 +483,14 @@
"discount": "−{{pct}}% with Barev+", "discount": "−{{pct}}% with Barev+",
"upsellTitle": "Up to −{{pct}}% with Barev Elite", "upsellTitle": "Up to −{{pct}}% with Barev Elite",
"upsellText": "Cleaning, repairs, transfers, paperwork and partner gifts", "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": { "services": {
"title": "Services", "title": "Services",
@@ -547,7 +557,13 @@
"LANGUAGE": "Teacher", "LANGUAGE": "Teacher",
"CAREER": "Coach", "CAREER": "Coach",
"DOCUMENTS": "Specialist" "DOCUMENTS": "Specialist"
} },
"when": "When",
"where": "Where",
"who": "Provider",
"whoPending": "being assigned",
"payment": "Payment",
"ask": "Question about this order"
}, },
"perks": { "perks": {
"title": "Perks", "title": "Perks",
@@ -670,6 +686,22 @@
"team": "Barev Club", "team": "Barev Club",
"fileTooBig": "The file is larger than 10 MB", "fileTooBig": "The file is larger than 10 MB",
"file": "File" "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": { "pro": {
+35 -3
View File
@@ -455,7 +455,10 @@
"perks": "Descuentos", "perks": "Descuentos",
"profile": "Perfil", "profile": "Perfil",
"plan": "Plan", "plan": "Plan",
"docs": "Documentos" "docs": "Documentos",
"orders": "Pedidos",
"card": "Tarjeta",
"chat": "Chat"
}, },
"openInTelegram": { "openInTelegram": {
"title": "Abre en Telegram", "title": "Abre en Telegram",
@@ -480,7 +483,14 @@
"discount": "−{{pct}}% con Barev+", "discount": "−{{pct}}% con Barev+",
"upsellTitle": "Hasta −{{pct}}% con Barev Elite", "upsellTitle": "Hasta −{{pct}}% con Barev Elite",
"upsellText": "Limpieza, reparaciones, traslados, trámites y regalos de socios", "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": { "services": {
"title": "Servicios", "title": "Servicios",
@@ -547,7 +557,13 @@
"LANGUAGE": "Profesor/a", "LANGUAGE": "Profesor/a",
"CAREER": "Asesor", "CAREER": "Asesor",
"DOCUMENTS": "Especialista" "DOCUMENTS": "Especialista"
} },
"when": "Cuándo",
"where": "Dónde",
"who": "Profesional",
"whoPending": "asignando",
"payment": "Pago",
"ask": "Pregunta sobre el pedido"
}, },
"perks": { "perks": {
"title": "Descuentos", "title": "Descuentos",
@@ -670,6 +686,22 @@
"team": "Barev Club", "team": "Barev Club",
"fileTooBig": "El archivo supera los 10 MB", "fileTooBig": "El archivo supera los 10 MB",
"file": "Archivo" "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": { "pro": {
+35 -3
View File
@@ -455,7 +455,10 @@
"perks": "Զեղչեր", "perks": "Զեղչեր",
"profile": "Պրոֆիլ", "profile": "Պրոֆիլ",
"plan": "Պլան", "plan": "Պլան",
"docs": "Փաստաթղթեր" "docs": "Փաստաթղթեր",
"orders": "Պատվերներ",
"card": "Քարտ",
"chat": "Չատ"
}, },
"openInTelegram": { "openInTelegram": {
"title": "Բացեք Telegram-ում", "title": "Բացեք Telegram-ում",
@@ -480,7 +483,14 @@
"discount": "−{{pct}}% Barev+-ով", "discount": "−{{pct}}% Barev+-ով",
"upsellTitle": "Մինչև −{{pct}}% Barev Elite-ով", "upsellTitle": "Մինչև −{{pct}}% Barev Elite-ով",
"upsellText": "Մաքրում, վերանորոգում, տրանսֆեր, փաստաթղթեր և նվերներ գործընկերներից", "upsellText": "Մաքրում, վերանորոգում, տրանսֆեր, փաստաթղթեր և նվերներ գործընկերներից",
"levelUntil": "{{plan}}-ը գործում է մինչև {{date}}" "levelUntil": "{{plan}}-ը գործում է մինչև {{date}}",
"now": "Հիմա",
"moreActive": "Եվս ակտիվ՝ {{count}}",
"orderTitle": "Պատվիրել",
"cardShow": "Ցույց տալ QR",
"free": "Անվճար մակարդակ",
"chatTitle": "Ցանկացած հարց՝ չատում",
"chatText": "Կպատասխանենք ձեր լեզվով"
}, },
"services": { "services": {
"title": "Ծառայություններ", "title": "Ծառայություններ",
@@ -547,7 +557,13 @@
"LANGUAGE": "Դասավանդող", "LANGUAGE": "Դասավանդող",
"CAREER": "Խորհրդատու", "CAREER": "Խորհրդատու",
"DOCUMENTS": "Մասնագետ" "DOCUMENTS": "Մասնագետ"
} },
"when": "Երբ",
"where": "Որտեղ",
"who": "Կատարող",
"whoPending": "ընտրում ենք",
"payment": "Վճարում",
"ask": "Հարց պատվերի վերաբերյալ"
}, },
"perks": { "perks": {
"title": "Զեղչեր", "title": "Զեղչեր",
@@ -670,6 +686,22 @@
"team": "Barev Club", "team": "Barev Club",
"fileTooBig": "Ֆայլը 10 ՄԲ-ից մեծ է", "fileTooBig": "Ֆայլը 10 ՄԲ-ից մեծ է",
"file": "Ֆայլ" "file": "Ֆայլ"
},
"rate": {
"title": "Ինչպե՞ս անցավ",
"text": "Գնահատականը կօգնի մեզ հետևել որակին",
"comment": "Մեկնաբանություն՝ ըստ ցանկության",
"send": "Գնահատել",
"thanks": "Շնորհակալություն գնահատականի համար",
"yours": "Ձեր գնահատականը",
"change": "Փոխել",
"star": "{{count}}՝ 5-ից",
"prompt": "Գնահատեք պատվերը"
},
"card": {
"title": "Ակումբային քարտ",
"partners": "Ակումբի գործընկերներ",
"plans": "Մակարդակներ և զեղչեր"
} }
}, },
"pro": { "pro": {
+35 -3
View File
@@ -455,7 +455,10 @@
"perks": "Скидки", "perks": "Скидки",
"profile": "Профиль", "profile": "Профиль",
"plan": "План", "plan": "План",
"docs": "Документы" "docs": "Документы",
"orders": "Заказы",
"card": "Карта",
"chat": "Чат"
}, },
"openInTelegram": { "openInTelegram": {
"title": "Открой в Telegram", "title": "Открой в Telegram",
@@ -480,7 +483,14 @@
"discount": "−{{pct}}% с Barev+", "discount": "−{{pct}}% с Barev+",
"upsellTitle": "Скидки до −{{pct}}% с Barev Elite", "upsellTitle": "Скидки до −{{pct}}% с Barev Elite",
"upsellText": "Клининг, ремонт, трансфер, документы и подарки партнёров", "upsellText": "Клининг, ремонт, трансфер, документы и подарки партнёров",
"levelUntil": "{{plan}} активен до {{date}}" "levelUntil": "{{plan}} активен до {{date}}",
"now": "Сейчас",
"moreActive": "Ещё активных: {{count}}",
"orderTitle": "Заказать",
"cardShow": "Показать QR",
"free": "Бесплатный уровень",
"chatTitle": "Любой вопрос — в чат",
"chatText": "Ответим на вашем языке"
}, },
"services": { "services": {
"title": "Услуги", "title": "Услуги",
@@ -547,7 +557,13 @@
"LANGUAGE": "Преподаватель", "LANGUAGE": "Преподаватель",
"CAREER": "Консультант", "CAREER": "Консультант",
"DOCUMENTS": "Специалист" "DOCUMENTS": "Специалист"
} },
"when": "Когда",
"where": "Где",
"who": "Исполнитель",
"whoPending": "подбираем",
"payment": "Оплата",
"ask": "Вопрос по заказу"
}, },
"perks": { "perks": {
"title": "Скидки", "title": "Скидки",
@@ -670,6 +686,22 @@
"team": "Barev Club", "team": "Barev Club",
"fileTooBig": "Файл больше 10 МБ", "fileTooBig": "Файл больше 10 МБ",
"file": "Файл" "file": "Файл"
},
"rate": {
"title": "Как всё прошло?",
"text": "Оценка поможет нам следить за качеством",
"comment": "Комментарий — по желанию",
"send": "Оценить",
"thanks": "Спасибо за оценку!",
"yours": "Ваша оценка",
"change": "Изменить",
"star": "{{count}} из 5",
"prompt": "Оцените заказ"
},
"card": {
"title": "Клубная карта",
"partners": "Партнёры клуба",
"plans": "Уровни и скидки"
} }
}, },
"pro": { "pro": {
+35 -3
View File
@@ -455,7 +455,10 @@
"perks": "折扣", "perks": "折扣",
"profile": "我的", "profile": "我的",
"plan": "计划", "plan": "计划",
"docs": "证件" "docs": "证件",
"orders": "订单",
"card": "会员卡",
"chat": "聊天"
}, },
"openInTelegram": { "openInTelegram": {
"title": "请在 Telegram 中打开", "title": "请在 Telegram 中打开",
@@ -480,7 +483,14 @@
"discount": "Barev+ 享 −{{pct}}%", "discount": "Barev+ 享 −{{pct}}%",
"upsellTitle": "Barev Elite 最高 −{{pct}}%", "upsellTitle": "Barev Elite 最高 −{{pct}}%",
"upsellText": "保洁、维修、接送、证件和合作伙伴礼品", "upsellText": "保洁、维修、接送、证件和合作伙伴礼品",
"levelUntil": "{{plan}} 有效期至 {{date}}" "levelUntil": "{{plan}} 有效期至 {{date}}",
"now": "进行中",
"moreActive": "其他进行中:{{count}}",
"orderTitle": "预订服务",
"cardShow": "出示二维码",
"free": "免费等级",
"chatTitle": "有任何问题,请在聊天中提问",
"chatText": "我们用您的语言回复"
}, },
"services": { "services": {
"title": "服务", "title": "服务",
@@ -547,7 +557,13 @@
"LANGUAGE": "老师", "LANGUAGE": "老师",
"CAREER": "顾问", "CAREER": "顾问",
"DOCUMENTS": "专员" "DOCUMENTS": "专员"
} },
"when": "时间",
"where": "地点",
"who": "服务人员",
"whoPending": "正在分配",
"payment": "付款",
"ask": "关于此订单的问题"
}, },
"perks": { "perks": {
"title": "折扣", "title": "折扣",
@@ -670,6 +686,22 @@
"team": "Barev Club", "team": "Barev Club",
"fileTooBig": "文件超过 10 MB", "fileTooBig": "文件超过 10 MB",
"file": "文件" "file": "文件"
},
"rate": {
"title": "服务体验如何?",
"text": "您的评价帮助我们保证服务质量",
"comment": "评论(可选)",
"send": "提交评价",
"thanks": "感谢您的评价!",
"yours": "您的评价",
"change": "修改",
"star": "{{count}} / 5",
"prompt": "请评价订单"
},
"card": {
"title": "俱乐部会员卡",
"partners": "俱乐部合作伙伴",
"plans": "等级与折扣"
} }
}, },
"pro": { "pro": {
+1
View File
@@ -19,3 +19,4 @@ export * from './sales.ts';
export * from './texts.ts'; export * from './texts.ts';
export * from './promo.ts'; export * from './promo.ts';
export * from './partners.ts'; export * from './partners.ts';
export * from './ratings.ts';
+4
View File
@@ -12,6 +12,7 @@ const clientOrderInclude = {
history: { orderBy: { createdAt: 'asc' } }, history: { orderBy: { createdAt: 'asc' } },
checklist: { orderBy: { sortOrder: 'asc' } }, checklist: { orderBy: { sortOrder: 'asc' } },
paymentMethod: true, paymentMethod: true,
rating: { select: { score: true, comment: true } },
} satisfies Prisma.OrderInclude; } satisfies Prisma.OrderInclude;
type ClientOrderRow = Prisma.OrderGetPayload<{ include: typeof clientOrderInclude }>; type ClientOrderRow = Prisma.OrderGetPayload<{ include: typeof clientOrderInclude }>;
@@ -27,6 +28,9 @@ function clientOrderView(o: ClientOrderRow) {
flow, flow,
status: o.status, status: o.status,
active: !isTerminal(flow, o.status), active: !isTerminal(flow, o.status),
/** заказ выполнен — можно оценить */
done: getFlow(flow).done.includes(o.status),
rating: o.rating,
scheduledAt: o.scheduledAt, scheduledAt: o.scheduledAt,
durationMinutes: o.durationMinutes, durationMinutes: o.durationMinutes,
isOnline: o.isOnline, isOnline: o.isOnline,
+29
View File
@@ -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 };
});
}
+38
View File
@@ -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);
});
});