"use client"; import { useMemo, useState } from "react"; import { useOrderReceipt, useOrderReview, useParticipants, useSetOrderReview, useSetSplits, useClearSplits, type ItemOpinion, type ItemVerdict, type OrderReceipt, type OrderRating, } from "@/lib/hooks"; const PLATFORM_LABEL: Record = { doordash: "DoorDash", ubereats: "Uber Eats", uber: "Uber", }; /** * Who records verdicts. A two-person household with one primary user: the * owner records almost everything, and the only other consumer is Sonu (user, * 2026-07-28). Mirrors OWNER_PARTICIPANT_ID / SECOND_CONSUMER_ID in * `lib/order-reviews.ts` — duplicated rather than imported because that module * pulls in the database client and this is a client component. */ const OWNER_PARTICIPANT_ID = 1; const SECOND_CONSUMER_ID = 4; const RATING_LABEL: Record = { loved: "Loved it", liked: "Liked it", ok: "OK", bad: "Bad", never: "Never again", }; const RATING_STYLE: Record = { loved: "border-emerald-600 bg-emerald-950 text-emerald-300", liked: "border-emerald-800 bg-emerald-950/50 text-emerald-400", ok: "border-zinc-600 bg-zinc-800 text-zinc-300", bad: "border-amber-800 bg-amber-950 text-amber-300", never: "border-red-800 bg-red-950 text-red-300", }; const RATING_ORDER: OrderRating[] = ["loved", "liked", "ok", "bad", "never"]; /** * The receipt behind a delivery order: what was actually bought, where it went, * and what we thought of it. * * The receipt half is read-only — it is what a provider sent, not something to * edit. The verdict half is the only part of an order that changes, and it is * the reason the receipts are ingested at all (ING-9): the ledger already knew * we had ordered from here, but not that it was bad, so orders got repeated * from places we disliked because nobody remembered. */ export function OrderDetails({ transactionId, currency, bare = false, }: { transactionId: number; currency: string | null; /** Drop the top border and heading spacing when embedded in a table row. */ bare?: boolean; }) { const { data: receipt, isLoading } = useOrderReceipt(transactionId); const { data: review } = useOrderReview(transactionId); const [reviewer, setReviewer] = useState(OWNER_PARTICIPANT_ID); if (isLoading || !receipt) return null; const cur = receipt.currency ?? currency ?? "AUD"; const fmt = (n: number) => (cur === "AUD" ? `$${n.toFixed(2)}` : `${cur} ${n.toFixed(2)}`); const items: OrderReceipt["line_items"] = receipt.line_items ?? []; const route: OrderReceipt["route"] = receipt.route ?? []; const mine = review?.reviews.find((r) => r.participant_id === reviewer); return (

Order details {receipt.platform && ( {PLATFORM_LABEL[receipt.platform] ?? receipt.platform} )}

{receipt.card_last4 && ( card ••••{receipt.card_last4} )}
{items.length > 0 ? (
    {items.map((it, i) => (
  • {it.qty}× {it.description} {it.options && it.options.length > 0 && ( {it.options.join(" · ")} )} {fmt(Number(it.amount))}
  • ))}
) : ( // Uber itemises groceries but not restaurant orders, and orders taken // before this was parsed have none either. Say which, rather than // showing an empty list that reads like a bug.

No itemised list on this receipt

)} {route.length > 0 && (
{route.map((pt, i) => (
{pt.label} {pt.time && {pt.time}} {pt.address}
))}
)} {receipt.order_reference && !receipt.order_reference.startsWith("msg:") && (

{receipt.order_reference}

)}
); } /** * Loved / never on a single line item, for the currently selected reviewer. * * Only the two poles are offered. A per-item "OK" is noise: the question at the * next order is "what should I get, what should I avoid", and a middling dish * answers neither. * * Every press sends the whole item array plus the current rating and note, * because the endpoint upserts a row rather than patching fields — sending a * partial would blank whatever it omitted. */ function ItemVerdictToggle({ transactionId, reviewer, item, current, rating, note, }: { transactionId: number; reviewer: number; item: string; current: ItemOpinion[]; rating: OrderRating | null; note: string | null; }) { const save = useSetOrderReview(); const existing = current.find( (v) => v.item.trim().toLowerCase() === item.trim().toLowerCase() ); const toggle = (verdict: ItemVerdict) => { const rest = current.filter( (v) => v.item.trim().toLowerCase() !== item.trim().toLowerCase() ); // Pressing the active verdict clears it — a mis-tap must be reversible, and // there is no other route back to "no opinion on this dish". const next = existing?.verdict === verdict ? rest : [...rest, { item, verdict }]; save.mutate({ transactionId, participantId: reviewer, rating, note, itemVerdicts: next, }); }; return ( {(["loved", "never"] as ItemVerdict[]).map((v) => ( ))} ); } /** * Was this order shared? One tap, and the split is the answer. * * "Shared" means shared in both senses — we both ate it and we both pay for it * — so this writes a real 50/50 `transaction_splits` row rather than a * decorative flag (user, 2026-07-28: the split was part of the original * requirement). There is no separate "shared" column precisely because the * split already IS that record, and two records of one fact drift apart. * * Unsharing clears the splits. That is safe on an order because an ingested * order is post-cutover by construction — the DB CHECK forbids credits orders * before 2026-01-09 — so no settled historical obligation can be sitting on it * to lose. */ function SharedToggle({ transactionId, splits, otherName, }: { transactionId: number; splits: { participant_id: number; share_percent: string }[]; otherName: string; }) { const setSplits = useSetSplits(); const clearSplits = useClearSplits(); const shared = splits.some((s) => s.participant_id === SECOND_CONSUMER_ID); return (
{shared && ( ask {otherName} for her verdict too )}
); } /** * The overall verdict, whose it is, and this merchant's track record. * * The history sits above the buttons deliberately: it is read before the next * order, not after, and burying it under the form is how you re-order from a * place you already rejected. */ function OrderVerdict({ transactionId, reviewer, onReviewerChange, }: { transactionId: number; reviewer: number; onReviewerChange: (id: number) => void; }) { const { data, isLoading } = useOrderReview(transactionId); const { data: participants } = useParticipants(); const save = useSetOrderReview(); const [noteDraft, setNoteDraft] = useState(null); const reviewers = useMemo( () => [OWNER_PARTICIPANT_ID, SECOND_CONSUMER_ID].map((id) => ({ id, name: id === OWNER_PARTICIPANT_ID ? "Me" : participants?.find((p) => p.id === id)?.name ?? "Them", })), [participants] ); // No merchant means no receipt behind this row — nothing to have a view on. if (isLoading || !data?.merchant) return null; const mine = data.reviews.find((r) => r.participant_id === reviewer); const current = mine?.rating ?? null; const noteValue = noteDraft ?? mine?.note ?? ""; const { history, warn, items } = data.merchant; const others = data.reviews.filter((r) => r.participant_id !== reviewer && r.rating); const set = (rating: OrderRating) => save.mutate({ transactionId, participantId: reviewer, // Pressing the active rating clears it — otherwise a mis-tap is // permanent, and there is no other way back to "no opinion". rating: rating === current ? null : rating, note: noteValue.trim() || null, }); return (
{warn && (

Marked “never again” here before.

)} {items.length > 0 && (

{items .filter((i) => i.loved > i.never) .slice(0, 3) .map((i) => `👍 ${i.item}`) .concat( items .filter((i) => i.never > 0) .slice(0, 3) .map((i) => `👎 ${i.item}`) ) .join(" · ")}

)}
{reviewers.map((r) => ( ))}
{RATING_ORDER.map((r) => ( ))}
setNoteDraft(e.target.value)} onBlur={() => { const next = noteValue.trim() || null; if (next !== (mine?.note ?? null)) { save.mutate({ transactionId, participantId: reviewer, rating: current, note: next, }); } }} className="mt-2 w-full rounded border border-zinc-800 bg-zinc-900 px-2 py-1 text-xs text-zinc-300 placeholder:text-zinc-700 focus:border-zinc-600 focus:outline-none" /> {others.map((o) => (

{o.participant_name}:{" "} {o.rating && RATING_LABEL[o.rating]} {o.note && — {o.note}}

))} {history.length > 0 && (
    {history.map((h) => (
  • {h.transaction_date} {h.participant_name} {h.rating ? ` · ${RATING_LABEL[h.rating]}` : ""} {h.note && {h.note}}
  • ))}
)}
); }