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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user