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
+1536 -205

No files matched your search

+132 -69
View File
@@ -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 }) {
</Center>
);
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 (
<main className="app app--plain">
<PageHead title={`#${o.number}`} />
<section className={`od-hero ${toneClass(o.category)}`}>
<div className="od-hero__bar">
<button className="back" onClick={() => window.history.back()} aria-label={t('common.back')}>
<Icon name="back" />
</button>
<span className="od-hero__num">#{o.number}</span>
</div>
<span className="od-hero__status" data-status={o.status === 'CANCELLED' ? 'cancelled' : o.active ? 'active' : 'done'}>
{t(`status.${o.flow}.${o.status}`)}
{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>
<Card className={toneClass(o.category)} style={{ padding: 20 }}>
<div className="bc-stack" style={{ gap: 8 }}>
<div className="bc-between" style={{ alignItems: 'flex-start' }}>
<h2 className="bc-h2">{t(`services.${o.serviceCode}.name`)}</h2>
<span className="bc-badge">{t(`status.${o.flow}.${o.status}`)}</span>
</div>
<div>{summary(o.serviceCode, o.params)}</div>
{paramsExtras(t, o.params).length > 0 && <div className="bc-small">+ {paramsExtras(t, o.params).map((e) => e.label).join(', ')}</div>}
<div className="bc-row" style={{ fontWeight: 600 }}>
<dl className="facts">
<div>
<dt>
<Icon name="clock" size={18} />
{dt(o.scheduledAt, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })}
</div>
{o.addressText && (
<div className="bc-row">
{t('client.detail.when')}
</dt>
<dd>{dt(o.scheduledAt, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })}</dd>
</div>
{o.addressText && (
<div>
<dt>
<Icon name="pin" size={18} />
{t('client.detail.where')}
</dt>
<dd>
{o.districtCode ? `${t(`districts.${o.districtCode}`)}, ` : ''}
{o.addressText}
</div>
)}
{o.providerName && (
<div className="bc-row">
<Icon name="user" size={18} />
{t(`client.detail.providerLabel.${o.flow}`)}: {o.providerName}
{arrived && o.status === 'EN_ROUTE' && <span className="bc-badge">{t('client.detail.arrived')}</span>}
</div>
)}
</dd>
</div>
)}
<div>
<dt>
<Icon name="user" size={18} />
{t(`client.detail.providerLabel.${o.flow}`)}
</dt>
<dd>{o.providerName ?? <span className="bc-muted">{t('client.detail.whoPending')}</span>}</dd>
</div>
</Card>
</dl>
<Card>
<h2 className="bc-h3" style={{ marginBottom: 12 }}>
{t('client.detail.progress')}
</h2>
<Steps items={steps} />
</Card>
{o.done && <RateBlock id={o.id} rating={o.rating} />}
<section className="panel">
<h2 className="panel__title">{t('client.detail.progress')}</h2>
<Timeline items={timeline(o)} />
</section>
{o.photos.length > 0 && <PhotoReport photos={o.photos} />}
<Card className="price-lines">
<section className="panel price-lines">
<div className="bc-between">
<span>{t('client.order.price')}</span>
<span>{amd(o.basePrice)}</span>
@@ -123,16 +126,31 @@ export function OrderDetail({ id }: { id: string }) {
{t(`payment.${o.paymentStatus}`)}
{o.paymentMethod ? ` · ${o.paymentMethod.name[locale] || o.paymentMethod.name.ru}` : ''}
</div>
</Card>
</section>
{o.note && (
<Card tight>
<section className="panel">
<div className="bc-small bc-muted">{t('client.order.note')}</div>
<div>{o.note}</div>
</Card>
</section>
)}
<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 && (
<Button variant="light" block onClick={() => setReschedule(true)}>
{t('client.detail.reschedule')}
@@ -148,17 +166,6 @@ export function OrderDetail({ id }: { id: string }) {
{t('client.detail.viaOperator', { hours: preview.data.freeHours })}
</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>
<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 }[] }) {
const { t } = useI18n();
const trpc = useTRPC();
return (
<Card>
<h2 className="bc-h3" style={{ marginBottom: 10 }}>
{t('client.detail.photoReport')}
</h2>
<section className="panel">
<h2 className="panel__title">{t('client.detail.photoReport')}</h2>
<div className="photos">
{photos.map((p) => (
<Photo key={p.fileId} fileId={p.fileId} alt={t(`checklist.${p.code}`)} trpc={trpc} />
))}
</div>
</Card>
</section>
);
}