merchantKey("Bigals Pizza") contains "iga", so GROCERY_PATTERN filed a pizza
shop as a grocer and merchantMatches matched it against every IGA on the same
date and total. Short names in a substring list collide with ordinary words,
and the collision reads as a correct classification rather than a bug.
GROCERY_PATTERN is now a word-boundary alternation; merchantMatches asks
whether one name's words appear as a contiguous run inside the other's, which
keeps Coles <-> Coles (Wyndham Vale) and Order - Coles (Wyndham Vale).
Also collapses the third PLATFORM_LABEL copy: order-ingestion's was exported
and read nowhere, order-details.tsx had its own, and the intake one — the only
live copy — was missing `uber`. One map, in intake-rules.
478 lines
16 KiB
TypeScript
478 lines
16 KiB
TypeScript
"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";
|
||
// One platform vocabulary, shared with the intake cards — three copies had
|
||
// already drifted apart (the intake one was missing `uber`).
|
||
import { platformLabel } from "@/lib/intake-rules";
|
||
|
||
/**
|
||
* 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<OrderRating, string> = {
|
||
loved: "Loved it",
|
||
liked: "Liked it",
|
||
ok: "OK",
|
||
bad: "Bad",
|
||
never: "Never again",
|
||
};
|
||
|
||
const RATING_STYLE: Record<OrderRating, string> = {
|
||
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);
|
||
|
||
// `platform` is the marker of a real receipt. Since phase 2 the endpoint also
|
||
// answers with `{ order_link }` alone for a transaction that is merely LINKED
|
||
// to an order (a BNPL leg has no receipt of its own), and that object is
|
||
// truthy — without this check the panel would render a receipt shell with no
|
||
// merchant, no items and no total.
|
||
if (isLoading || !receipt || !receipt.platform) 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);
|
||
|
||
// Verdicts are the MEAL lane's feedback loop — "loved / never again" exists
|
||
// to steer the next restaurant order, and the nudge card warns on re-order.
|
||
// Spine-bridged retail (Amazon boxes, eBay, Officeworks) has no such loop:
|
||
// rating a roller-shade motor "order again?" is noise, so the whole review
|
||
// surface stays hidden for bridge rows.
|
||
const rateable = receipt.source !== "order-bridge";
|
||
|
||
return (
|
||
<div className={bare ? "" : "border-t border-zinc-800 pt-4"}>
|
||
<div className="flex items-baseline justify-between mb-2">
|
||
<p className="text-xs text-zinc-500">
|
||
Order details
|
||
{receipt.platform && (
|
||
<span className="ml-1.5 text-zinc-400">{platformLabel(receipt.platform)}</span>
|
||
)}
|
||
</p>
|
||
{receipt.card_last4 && (
|
||
<span className="text-xs text-zinc-600">card ••••{receipt.card_last4}</span>
|
||
)}
|
||
</div>
|
||
|
||
{items.length > 0 ? (
|
||
<ul className="space-y-1.5 mb-3">
|
||
{items.map((it, i) => (
|
||
<li key={i} className="flex gap-2 text-xs items-start">
|
||
<span className="text-zinc-600 tabular-nums shrink-0">{it.qty ?? 1}×</span>
|
||
<span className="text-zinc-300 flex-1 min-w-0">
|
||
{it.description}
|
||
{it.options && it.options.length > 0 && (
|
||
<span className="block text-zinc-600">{it.options.join(" · ")}</span>
|
||
)}
|
||
</span>
|
||
{rateable && (
|
||
<ItemVerdictToggle
|
||
transactionId={transactionId}
|
||
reviewer={reviewer}
|
||
item={it.description}
|
||
current={mine?.item_verdicts ?? []}
|
||
rating={mine?.rating ?? null}
|
||
note={mine?.note ?? null}
|
||
/>
|
||
)}
|
||
{/* Spine-bridged receipts (Amazon shipment notices, eBay) often
|
||
name the goods without per-item prices — a null here is "the
|
||
mail didn't say", and printing $0.00 would assert it did. */}
|
||
{it.amount != null && (
|
||
<span className="text-zinc-400 tabular-nums shrink-0">{fmt(Number(it.amount))}</span>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : (
|
||
// 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.
|
||
<p className="text-xs text-zinc-600 italic mb-3">
|
||
No itemised list on this receipt
|
||
</p>
|
||
)}
|
||
|
||
{route.length > 0 && (
|
||
<div className="space-y-1">
|
||
{route.map((pt, i) => (
|
||
<div key={i} className="flex gap-2 text-xs">
|
||
<span className="text-zinc-600 shrink-0 w-24">
|
||
{pt.label}
|
||
{pt.time && <span className="block text-zinc-700">{pt.time}</span>}
|
||
</span>
|
||
<span className="text-zinc-400 flex-1">{pt.address}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{receipt.order_reference && !receipt.order_reference.startsWith("msg:") && (
|
||
<p className="mt-3 text-[11px] text-zinc-700 font-mono break-all">
|
||
{receipt.order_reference}
|
||
</p>
|
||
)}
|
||
|
||
{rateable && (
|
||
<OrderVerdict
|
||
transactionId={transactionId}
|
||
reviewer={reviewer}
|
||
onReviewerChange={setReviewer}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 (
|
||
<span className="flex gap-0.5 shrink-0">
|
||
{(["loved", "never"] as ItemVerdict[]).map((v) => (
|
||
<button
|
||
key={v}
|
||
type="button"
|
||
onClick={() => toggle(v)}
|
||
disabled={save.isPending}
|
||
title={v === "loved" ? "Loved this item" : "Never order this again"}
|
||
className={`rounded px-1 leading-none transition-opacity disabled:opacity-40 ${
|
||
existing?.verdict === v
|
||
? "opacity-100"
|
||
: "opacity-25 hover:opacity-60"
|
||
}`}
|
||
>
|
||
{v === "loved" ? "👍" : "👎"}
|
||
</button>
|
||
))}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 (
|
||
<div className="mb-2 flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
disabled={setSplits.isPending || clearSplits.isPending}
|
||
onClick={() =>
|
||
// Both halves on the way in, and a real delete on the way out. This
|
||
// used to post one 50% row to share and an empty array to un-share,
|
||
// and the endpoint rejected both — the toggle did nothing either way.
|
||
shared
|
||
? clearSplits.mutate(transactionId)
|
||
: setSplits.mutate({
|
||
transactionId,
|
||
splits: [
|
||
{ participant_id: OWNER_PARTICIPANT_ID, share_percent: 50 },
|
||
{ participant_id: SECOND_CONSUMER_ID, share_percent: 50 },
|
||
],
|
||
})
|
||
}
|
||
className={`rounded border px-2 py-1 text-xs transition-colors disabled:opacity-50 ${
|
||
shared
|
||
? "border-sky-700 bg-sky-950 text-sky-300"
|
||
: "border-zinc-700 text-zinc-500 hover:border-zinc-600 hover:text-zinc-300"
|
||
}`}
|
||
>
|
||
{shared ? `Shared 50/50 with ${otherName}` : "Just me"}
|
||
</button>
|
||
{shared && (
|
||
<span className="text-[11px] text-zinc-600">
|
||
ask {otherName} for her verdict too
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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<string | null>(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 (
|
||
<div className="mt-4 border-t border-zinc-800 pt-3">
|
||
{warn && (
|
||
<p className="mb-2 text-xs text-red-400">
|
||
Marked “never again” here before.
|
||
</p>
|
||
)}
|
||
|
||
{items.length > 0 && (
|
||
<p className="mb-2 text-[11px] text-zinc-500">
|
||
{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(" · ")}
|
||
</p>
|
||
)}
|
||
|
||
<SharedToggle
|
||
transactionId={transactionId}
|
||
splits={data.splits}
|
||
otherName={reviewers[1].name}
|
||
/>
|
||
|
||
<div className="flex items-center gap-2 mb-2">
|
||
{reviewers.map((r) => (
|
||
<button
|
||
key={r.id}
|
||
type="button"
|
||
onClick={() => {
|
||
setNoteDraft(null); // the draft belongs to the person who typed it
|
||
onReviewerChange(r.id);
|
||
}}
|
||
className={`text-xs transition-colors ${
|
||
reviewer === r.id
|
||
? "text-zinc-200 underline underline-offset-4"
|
||
: "text-zinc-600 hover:text-zinc-400"
|
||
}`}
|
||
>
|
||
{r.name}
|
||
{data.reviews.some((v) => v.participant_id === r.id && v.rating) && " ✓"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{RATING_ORDER.map((r) => (
|
||
<button
|
||
key={r}
|
||
type="button"
|
||
onClick={() => set(r)}
|
||
disabled={save.isPending}
|
||
className={`rounded border px-2 py-1 text-xs transition-colors disabled:opacity-50 ${
|
||
current === r
|
||
? RATING_STYLE[r]
|
||
: "border-zinc-700 text-zinc-500 hover:border-zinc-600 hover:text-zinc-300"
|
||
}`}
|
||
>
|
||
{RATING_LABEL[r]}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<input
|
||
type="text"
|
||
value={noteValue}
|
||
placeholder="What was wrong (or right)?"
|
||
onChange={(e) => 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) => (
|
||
<p key={o.participant_id} className="mt-1.5 text-[11px] text-zinc-500">
|
||
<span className="text-zinc-400">{o.participant_name}:</span>{" "}
|
||
{o.rating && RATING_LABEL[o.rating]}
|
||
{o.note && <span className="italic"> — {o.note}</span>}
|
||
</p>
|
||
))}
|
||
|
||
{history.length > 0 && (
|
||
<ul className="mt-2 space-y-1">
|
||
{history.map((h) => (
|
||
<li
|
||
key={`${h.transaction_id}-${h.participant_id}`}
|
||
className="text-[11px] text-zinc-600"
|
||
>
|
||
<span className="tabular-nums">{h.transaction_date}</span>
|
||
<span className="ml-1.5 text-zinc-500">
|
||
{h.participant_name}
|
||
{h.rating ? ` · ${RATING_LABEL[h.rating]}` : ""}
|
||
</span>
|
||
{h.note && <span className="ml-1.5 italic">{h.note}</span>}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|