Files
BarevClub/apps/miniapp-client/src/screens/OrderDetail.tsx
T
2026-10-25 22:57:03 +04:00

335 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
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();
const trpc = useTRPC();
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);
const cancel = useMutation(
trpc.client.orders.cancel.mutationOptions({
onSuccess: (r) => {
haptic.ok();
toast(r.fee > 0 ? t('client.detail.cancelledWithFee', { fee: amd(r.fee) }) : t('client.detail.cancelled'));
void qc.invalidateQueries();
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
if (!order.data)
return (
<Center>
<Spinner />
</Center>
);
const o = order.data;
const arrived = o.history.some((h) => h.event === 'arrived');
const extras = paramsExtras(t, o.params);
async function onCancel() {
const p = preview.data?.policy;
if (!p) return;
const text = p.kind === 'free' ? t('client.detail.confirmCancelFree') : p.kind === 'late' ? t('client.detail.confirmCancelLate', { fee: amd(p.fee), pct: p.feePct }) : '';
if (await confirmDialog(text)) cancel.mutate({ id });
}
return (
<main className="app app--plain">
<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>
<dl className="facts">
<div>
<dt>
<Icon name="clock" size={18} />
{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}
</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>
</dl>
{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} />}
<section className="panel price-lines">
<div className="bc-between">
<span>{t('client.order.price')}</span>
<span>{amd(o.basePrice)}</span>
</div>
{o.discountAmount > 0 && (
<div className="bc-between" style={{ color: 'var(--bc-accent)', fontWeight: 600 }}>
<span>{t('client.order.discount', { pct: o.discountPct })}</span>
<span>−{amd(o.discountAmount)}</span>
</div>
)}
{o.pointsUsed > 0 && (
<div className="bc-between" style={{ color: 'var(--bc-accent)', fontWeight: 600 }}>
<span>{t('client.points.paidLine')}</span>
<span>−{amd(o.pointsUsed)}</span>
</div>
)}
<div className="bc-between total">
<span>{t('client.order.total')}</span>
<span>{amd(o.totalPrice - o.pointsUsed)}</span>
</div>
{o.cancelFee > 0 && (
<div className="bc-between">
<span>{t('client.detail.cancelFee')}</span>
<span>{amd(o.cancelFee)}</span>
</div>
)}
<div className="bc-small bc-muted">
{t(`payment.${o.paymentStatus}`)}
{o.paymentMethod ? ` · ${o.paymentMethod.name[locale] || o.paymentMethod.name.ru}` : ''}
</div>
</section>
{o.note && (
<section className="panel">
<div className="bc-small bc-muted">{t('client.order.note')}</div>
<div>{o.note}</div>
</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')}
</Button>
)}
{o.active && preview.data?.cancellable && preview.data.policy.kind !== 'operator_only' && (
<Button variant="danger" block onClick={onCancel} disabled={cancel.isPending}>
{t('client.detail.cancel')}
</Button>
)}
{o.active && preview.data && (!preview.data.cancellable || preview.data.policy.kind === 'operator_only' || !preview.data.canReschedule) && (
<p className="bc-small bc-muted" style={{ margin: 0, textAlign: 'center' }}>
{t('client.detail.viaOperator', { hours: preview.data.freeHours })}
</p>
)}
</div>
<RescheduleSheet open={reschedule} onClose={() => setReschedule(false)} order={o} />
</main>
);
}
/** Оценка после выполнения: звёзды и комментарий; уже оценённый заказ показывает оценку с кнопкой «Изменить» */
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 (
<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>
</section>
);
}
function Photo({ fileId, alt, trpc }: { fileId: string; alt: string; trpc: ReturnType<typeof useTRPC> }) {
const url = useQuery({ ...trpc.client.fileUrl.queryOptions({ fileId }), staleTime: 4 * 60_000 });
return url.data ? (
<a href={url.data.url} target="_blank" rel="noreferrer">
<img src={url.data.url} alt={alt} loading="lazy" />
</a>
) : (
<div style={{ aspectRatio: 1, borderRadius: 16, background: 'var(--bc-bg)' }} />
);
}
/** ORD-6: перенос — выбор нового слота */
function RescheduleSheet({ open, onClose, order }: { open: boolean; onClose: () => void; order: { id: string; serviceCode: string; params: Record<string, unknown> } }) {
const { t, dt } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const days = useMemo(() => nextDays(14), []);
const [day, setDay] = useState(days[1] ?? days[0]!);
const [slot, setSlot] = useState<Date | null>(null);
const slots = useQuery({
...trpc.client.slots.queryOptions({ serviceCode: order.serviceCode, params: order.params as Record<string, string | number | boolean>, dateKey: day, excludeOrderId: order.id }),
enabled: open,
});
const mutate = useMutation(
trpc.client.orders.reschedule.mutationOptions({
onSuccess: () => {
toast(t('client.detail.rescheduled'));
void qc.invalidateQueries();
onClose();
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
return (
<Sheet open={open} onClose={onClose} label={t('client.detail.reschedule')}>
<div className="bc-stack">
<div className="bc-between">
<h2 className="bc-h2">{t('client.detail.reschedule')}</h2>
<button className="back" onClick={onClose} aria-label={t('common.close')}>
<Icon name="close" />
</button>
</div>
<div className="days">
{days.map((d) => (
<button
key={d}
type="button"
className="day"
aria-pressed={d === day}
onClick={() => {
setDay(d);
setSlot(null);
}}
>
<span className="bc-small">{dt(dateKeyToDate(d), { weekday: 'short' })}</span>
<span className="day__num">{dt(dateKeyToDate(d), { day: 'numeric' })}</span>
</button>
))}
</div>
{slots.isPending ? (
<Spinner />
) : slots.data?.length ? (
<div className="slots">
{slots.data.map((s) => (
<button key={s.start.toISOString()} type="button" className="slot" aria-pressed={slot?.getTime() === s.start.getTime()} onClick={() => setSlot(s.start)}>
{dt(s.start, { hour: '2-digit', minute: '2-digit' })}
</button>
))}
</div>
) : (
<p className="bc-muted">{t('client.order.noSlots')}</p>
)}
<Button block disabled={!slot || mutate.isPending} onClick={() => slot && mutate.mutate({ id: order.id, scheduledAt: slot })}>
{t('client.detail.rescheduleConfirm')}
</Button>
</div>
</Sheet>
);
}