@tribe-nest/forge 3.11.0 → 3.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,7 @@ import { useEvent, useCreateEventOrder } from "../../../data/queries/useEvents";
5
5
  import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
6
6
  import { useCouponField } from "../coupon/useCouponField";
7
7
  import { computeBookingFeeAmount } from "../../format/bookingFee";
8
+ import { isPayWhatYouWant, pwywDefaultAmount, resolveUnitPrice, ticketSubtotals } from "../../format/pwyw";
8
9
  import { readAttributionRef } from "../../../utils/attribution";
9
10
  import { readLanding } from "../../../utils/landing";
10
11
 
@@ -42,6 +43,12 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
42
43
 
43
44
  const [step, setStep] = useState<EventCheckoutStep>("tickets");
44
45
  const [selectedTickets, setSelectedTickets] = useState<Record<string, number>>({});
46
+ /**
47
+ * ticketId → the amount the buyer chose, per unit, on a pay-what-you-want
48
+ * tier. Absent means "hasn't touched the box", which resolves to the tier's
49
+ * suggested amount (or its floor) rather than to zero.
50
+ */
51
+ const [pwywAmounts, setPwywAmounts] = useState<Record<string, number>>({});
45
52
  const [firstName, setFirstName] = useState(user?.firstName ?? "");
46
53
  const [lastName, setLastName] = useState(user?.lastName ?? "");
47
54
  const [email, setEmail] = useState(user?.email ?? "");
@@ -70,10 +77,39 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
70
77
  */
71
78
  const coupon = useCouponField();
72
79
 
73
- const totalAmount = useMemo(() => {
74
- if (!event) return 0;
75
- return event.tickets.reduce((sum, t) => sum + t.price * (selectedTickets[t.id] ?? 0), 0);
76
- }, [event, selectedTickets]);
80
+ /**
81
+ * The buyer's effective per-unit choice for every selected PWYW tier —
82
+ * their typed figure where they have one, the tier's suggested amount where
83
+ * they have not. This is what gets SENT, so an untouched box is not the same
84
+ * as choosing the minimum: the suggestion is the default offer.
85
+ */
86
+ const effectiveAmounts = useMemo(() => {
87
+ if (!event) return {} as Record<string, number>;
88
+ const out: Record<string, number> = {};
89
+ for (const ticket of event.tickets) {
90
+ if (!isPayWhatYouWant(ticket) || (selectedTickets[ticket.id] ?? 0) <= 0) continue;
91
+ out[ticket.id] =
92
+ pwywAmounts[ticket.id] != null
93
+ ? resolveUnitPrice(ticket, pwywAmounts[ticket.id])
94
+ : pwywDefaultAmount(ticket);
95
+ }
96
+ return out;
97
+ }, [event, selectedTickets, pwywAmounts]);
98
+
99
+ /**
100
+ * `paid` is what the buyer owes for tickets; `floor` is the same cart valued
101
+ * at every tier's minimum. Identical on any cart without a PWYW tier.
102
+ */
103
+ const subtotals = useMemo(() => {
104
+ if (!event) return { paid: 0, floor: 0 };
105
+ return ticketSubtotals({
106
+ tickets: event.tickets,
107
+ quantities: selectedTickets,
108
+ amounts: effectiveAmounts,
109
+ });
110
+ }, [event, selectedTickets, effectiveAmounts]);
111
+
112
+ const totalAmount = subtotals.paid;
77
113
 
78
114
  const ticketCount = useMemo(
79
115
  () => Object.values(selectedTickets).reduce((a, b) => a + b, 0),
@@ -100,8 +136,13 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
100
136
  * honest while the buyer is still choosing quantities, which is precisely when
101
137
  * the disclosure has to happen.
102
138
  */
139
+ //
140
+ // Estimated off the FLOOR subtotal, never the paid one — the server computes
141
+ // it that way, so a fan choosing to pay ten times the minimum must not see
142
+ // (or be quoted) ten times the fee. On a cart with no PWYW tier the two
143
+ // subtotals are the same number and this is byte-for-byte the old behaviour.
103
144
  const bookingFeeAmount =
104
- serverFeeAmount ?? computeBookingFeeAmount({ subtotal: totalAmount, fee: bookingFee });
145
+ serverFeeAmount ?? computeBookingFeeAmount({ subtotal: subtotals.floor, fee: bookingFee });
105
146
 
106
147
  // Keep only positive quantities in state — a ticket dropped to 0 is removed
107
148
  // entirely, so `items` never carries a 0-quantity entry (the orders endpoint
@@ -114,6 +155,26 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
114
155
  return next;
115
156
  });
116
157
 
158
+ /**
159
+ * The buyer's chosen amount for one PWYW tier, PER UNIT.
160
+ *
161
+ * `null` forgets the choice and falls back to the tier's suggested amount —
162
+ * which is what an emptied input has to mean, since the alternative is
163
+ * treating a cleared box as a decision to pay zero.
164
+ *
165
+ * Deliberately NOT clamped as the buyer types: clamping on every keystroke
166
+ * makes "1" become the floor the moment it is typed and the buyer can never
167
+ * reach "15". The clamp is applied when the figure is read
168
+ * (`effectiveAmounts`), and again by the server, which is the one that counts.
169
+ */
170
+ const setTicketAmount = (ticketId: string, amount: number | null) =>
171
+ setPwywAmounts((prev) => {
172
+ const next = { ...prev };
173
+ if (amount != null && Number.isFinite(amount)) next[ticketId] = amount;
174
+ else delete next[ticketId];
175
+ return next;
176
+ });
177
+
117
178
  /**
118
179
  * Put this selection in the cart instead of paying for it now — the exit
119
180
  * that makes add-ons possible, because the buyer has to be able to leave for
@@ -139,7 +200,20 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
139
200
  ticketMeta: Object.fromEntries(
140
201
  event.tickets
141
202
  .filter((t) => (selectedTickets[t.id] ?? 0) > 0)
142
- .map((t) => [t.id, { title: t.title, price: t.price }]),
203
+ // `price` is the per-unit figure the cart displays and the checkout
204
+ // line carries — so on a PWYW tier it has to be the CHOSEN amount,
205
+ // not the floor, or the buyer's cart silently forgets what they
206
+ // picked on the way to an add-on and back. `pwywAmount` travels
207
+ // beside it because the bundle path sends the two separately: one is
208
+ // display, one is charged.
209
+ .map((t) => [
210
+ t.id,
211
+ {
212
+ title: t.title,
213
+ price: resolveUnitPrice(t, effectiveAmounts[t.id]),
214
+ ...(isPayWhatYouWant(t) ? { pwywAmount: effectiveAmounts[t.id] } : {}),
215
+ },
216
+ ]),
143
217
  ),
144
218
  });
145
219
  return true;
@@ -166,6 +240,9 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
166
240
  try {
167
241
  const data = await createOrder.mutateAsync({
168
242
  items: selectedTickets,
243
+ // Omitted entirely when nothing in the cart is PWYW, so an ordinary
244
+ // purchase posts the same body it always did.
245
+ ...(Object.keys(effectiveAmounts).length > 0 ? { amounts: effectiveAmounts } : {}),
169
246
  email,
170
247
  firstName,
171
248
  lastName,
@@ -236,6 +313,14 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
236
313
  setStep,
237
314
  selectedTickets,
238
315
  setTicketQty,
316
+ /**
317
+ * Raw buyer input per PWYW tier — only the tiers whose box has been touched.
318
+ * Bind an input to this; read `effectiveAmounts` to know what will be sent.
319
+ */
320
+ pwywAmounts,
321
+ setTicketAmount,
322
+ /** What each selected PWYW tier will actually be charged, per unit. */
323
+ effectiveAmounts,
239
324
  ticketCount,
240
325
  // The server's figure wins the moment there is one: start-payment first,
241
326
  // then the order response (which is already net of any discount), and only
@@ -249,6 +334,11 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
249
334
  flow.result?.totalAmount ?? coupon.quote?.totalAmount ?? totalAmount + bookingFeeAmount,
250
335
  /** GROSS ticket total, before any discount OR booking fee. */
251
336
  subTotal: coupon.quote?.subTotal ?? totalAmount,
337
+ /**
338
+ * The same cart at every tier's MINIMUM. Equal to `subTotal` unless a PWYW
339
+ * tier is selected; the difference is what the buyer chose to add on top.
340
+ */
341
+ floorSubTotal: subtotals.floor,
252
342
  /**
253
343
  * The resolved fee RULE, for surfaces that quote a price with no cart behind
254
344
  * it (an event page's "From $25"). `null` when the artist charges none.
@@ -76,3 +76,4 @@ export * from "./work";
76
76
  // Funnel instrumentation — wrap a multi-step flow to record step-through and
77
77
  // drop-off on the first-party analytics feed.
78
78
  export * from "./funnel";
79
+ export * from "./useVariantSelection";
@@ -0,0 +1,138 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import type { IPublicProductVariant } from "../../types/models";
3
+
4
+ /**
5
+ * Picking a version of a product, on any number of axes.
6
+ *
7
+ * This replaces a hardcoded colour → size cascade. That cascade could not
8
+ * express a third axis, so an album sold as MP3 / WAV / Stems had nowhere to put
9
+ * "Format", and it read `variant.color` — a column one writer fills with a hex
10
+ * and another with a name.
11
+ *
12
+ * The cascade itself is kept, generalised: each axis is narrowed by the axes
13
+ * BEFORE it. Picking "Black" leaves only the sizes that exist in black, exactly
14
+ * as before, and picking "FLAC" would leave only the editions pressed in FLAC.
15
+ * Axis order comes from the server, which sorts by the axis's own position, so
16
+ * every variant of a product presents its options the same way round.
17
+ *
18
+ * Shared by BOTH rendering surfaces. The Craft themes in `frontend-shared`
19
+ * import it from here, the way they already import the cart and audio player —
20
+ * the dependency runs one way, so the selection rule cannot drift between a
21
+ * code site and a Craft site even though their markup is unrelated.
22
+ */
23
+
24
+ export type VariantAxisValue = {
25
+ optionValueId: string;
26
+ value: string;
27
+ /** Only set when the value genuinely is a colour. Render a swatch if present. */
28
+ swatchHex: string | null;
29
+ /** False when every variant carrying it is out of stock. */
30
+ isAvailable: boolean;
31
+ };
32
+
33
+ export type VariantAxis = {
34
+ optionTypeId: string;
35
+ /** "Colour", "Size", "Format" — as the creator named it. */
36
+ axis: string;
37
+ displayType: string;
38
+ values: VariantAxisValue[];
39
+ };
40
+
41
+ type Selection = Record<string, string | undefined>;
42
+
43
+ const optionFor = (variant: IPublicProductVariant, optionTypeId: string) =>
44
+ variant.options?.find((option) => option.optionTypeId === optionTypeId);
45
+
46
+ /** Does this variant match every selection made on the axes listed? */
47
+ const matches = (variant: IPublicProductVariant, selection: Selection, upToAxes: string[]) =>
48
+ upToAxes.every((optionTypeId) => {
49
+ const chosen = selection[optionTypeId];
50
+ if (!chosen) return true;
51
+ return optionFor(variant, optionTypeId)?.optionValueId === chosen;
52
+ });
53
+
54
+ export function useVariantSelection(variants: IPublicProductVariant[]) {
55
+ const [selection, setSelection] = useState<Selection>({});
56
+
57
+ // The axes this product uses, in server order, deduped. Built from the
58
+ // variants themselves rather than a separate list, so a product can never
59
+ // advertise an axis none of its versions sits on.
60
+ const axisOrder = useMemo(() => {
61
+ const seen: { optionTypeId: string; axis: string; displayType: string }[] = [];
62
+ for (const variant of variants ?? []) {
63
+ for (const option of variant.options ?? []) {
64
+ if (!seen.some((s) => s.optionTypeId === option.optionTypeId)) {
65
+ seen.push({ optionTypeId: option.optionTypeId, axis: option.axis, displayType: option.displayType });
66
+ }
67
+ }
68
+ }
69
+ return seen;
70
+ }, [variants]);
71
+
72
+ const axes: VariantAxis[] = useMemo(() => {
73
+ return axisOrder.map((axis, index) => {
74
+ // Narrowed by the axes before it, and only those — an axis must not be
75
+ // constrained by a LATER pick, or choosing a size would start hiding
76
+ // colours and the buyer could reach a state with nothing selectable.
77
+ const earlier = axisOrder.slice(0, index).map((a) => a.optionTypeId);
78
+ const candidates = (variants ?? []).filter((variant) => matches(variant, selection, earlier));
79
+
80
+ const values: VariantAxisValue[] = [];
81
+ for (const variant of candidates) {
82
+ const option = optionFor(variant, axis.optionTypeId);
83
+ if (!option) continue;
84
+ const existing = values.find((v) => v.optionValueId === option.optionValueId);
85
+ const isActive = variant.availabilityStatus === "active";
86
+ if (existing) {
87
+ existing.isAvailable = existing.isAvailable || isActive;
88
+ } else {
89
+ values.push({
90
+ optionValueId: option.optionValueId,
91
+ value: option.value,
92
+ swatchHex: option.swatchHex,
93
+ isAvailable: isActive,
94
+ });
95
+ }
96
+ }
97
+ return { ...axis, values };
98
+ });
99
+ }, [axisOrder, variants, selection]);
100
+
101
+ const select = useCallback(
102
+ (optionTypeId: string, optionValueId: string) => {
103
+ setSelection((current) => {
104
+ const index = axisOrder.findIndex((a) => a.optionTypeId === optionTypeId);
105
+ const next: Selection = { ...current, [optionTypeId]: optionValueId };
106
+ // Changing an axis invalidates everything narrowed by it. Keeping a
107
+ // stale later pick is how you end up "selecting" a variant that does
108
+ // not exist — the buyer sees Black/XL highlighted and the button dead.
109
+ for (const later of axisOrder.slice(index + 1)) next[later.optionTypeId] = undefined;
110
+ return next;
111
+ });
112
+ },
113
+ [axisOrder],
114
+ );
115
+
116
+ const selectedVariant = useMemo(() => {
117
+ // A product with no axes has exactly one version — every digital release in
118
+ // the catalogue before formats existed.
119
+ if (axisOrder.length === 0) {
120
+ return variants?.find((v) => v.isDefault) ?? variants?.[0];
121
+ }
122
+ if (axisOrder.some((a) => !selection[a.optionTypeId])) return undefined;
123
+ return (variants ?? []).find((variant) =>
124
+ axisOrder.every((a) => optionFor(variant, a.optionTypeId)?.optionValueId === selection[a.optionTypeId]),
125
+ );
126
+ }, [axisOrder, selection, variants]);
127
+
128
+ // Walk the axes in order and pre-pick the first value that is actually in
129
+ // stock, falling back to the first at all so the page is never blank.
130
+ useEffect(() => {
131
+ const missing = axes.find((axis) => !selection[axis.optionTypeId] && axis.values.length > 0);
132
+ if (!missing) return;
133
+ const first = missing.values.find((v) => v.isAvailable) ?? missing.values[0];
134
+ setSelection((current) => ({ ...current, [missing.optionTypeId]: first.optionValueId }));
135
+ }, [axes, selection]);
136
+
137
+ return { axes, selection, select, selectedVariant, hasOptions: axisOrder.length > 0 };
138
+ }
package/src/ui/index.ts CHANGED
@@ -54,6 +54,10 @@ export {
54
54
  export { MembershipCheckout, type MembershipCheckoutProps } from "./styled/MembershipCheckout";
55
55
  export { ForgeStripePayment } from "./payment/ForgeStripePayment";
56
56
  export { Cart, type CartProps } from "./styled/Cart";
57
+ // The resolver is exported alongside the component because the Craft themes
58
+ // are a separate rendering stack (Tailwind, not inline styles) and cannot use
59
+ // the component — but the legacy `color`/`size` fallback must not diverge.
60
+ export { CartLineOptions, resolveCartLineOptions } from "./styled/CartLineOptions";
57
61
  export { CurrencySwitcher, type CurrencySwitcherProps } from "./styled/CurrencySwitcher";
58
62
  export { UserMenu, type UserMenuProps } from "./styled/UserMenu";
59
63
  export { EmailListForm, type EmailListFormProps } from "./styled/EmailListForm";
@@ -65,6 +69,7 @@ export { ContactForm, type ContactFormProps } from "./styled/ContactForm";
65
69
  export { Paywall, type PaywallProps } from "./styled/Paywall";
66
70
  export { ReactionBar, type ReactionBarProps } from "./styled/ReactionBar";
67
71
  export { ProductGrid, type ProductGridProps } from "./styled/ProductGrid";
72
+ export { ProductBrowseNav, type ProductBrowseNavProps } from "./styled/ProductBrowseNav";
68
73
  export { Addons, type AddonsProps } from "./styled/Addons";
69
74
  export { BundleConfirmation, type BundleConfirmationProps } from "./styled/BundleConfirmation";
70
75
  export { ProductDetail, type ProductDetailProps } from "./styled/ProductDetail";
@@ -152,6 +157,18 @@ export { PriceDisplay, priceTaxCaption, summarizeTaxQuote, type PriceDisplayProp
152
157
  // the styled blocks. Resolution stays on the server (`IEvent.bookingFee`); these
153
158
  // only do the arithmetic and the wording.
154
159
  export { computeBookingFeeAmount, hasBookingFee, describeBookingFee } from "./format/bookingFee";
160
+ // Pay-what-you-want, for a site rendering its own ticket UI. `price` is the
161
+ // FLOOR on a PWYW tier, so an existing "from $X" is already correct; these only
162
+ // resolve what a buyer's chosen amount costs. The clamp here is UX — the server
163
+ // clamps again and is the one that decides what is charged.
164
+ export {
165
+ isPayWhatYouWant,
166
+ pwywMaximum,
167
+ pwywDefaultAmount,
168
+ clampChosenAmount,
169
+ resolveUnitPrice,
170
+ ticketSubtotals,
171
+ } from "./format/pwyw";
155
172
  export { usePricesIncludeTax } from "../data/queries/useWebsite";
156
173
 
157
174
  // PWA (installable code sites) — SW registration + install/push UX (Tier 2).
@@ -36,6 +36,7 @@ import { Loading } from "./Loading";
36
36
  import { formatCountdown } from "./EventWaitlist";
37
37
  import { WalletPassButtons } from "./WalletPassButtons";
38
38
  import { TicketTransferPanel } from "./TicketTransfer";
39
+ import { CartLineOptions } from "./CartLineOptions";
39
40
 
40
41
  export const ACCOUNT_TABS = [
41
42
  "membership",
@@ -924,6 +925,12 @@ function OrdersTab({ ctx }: { ctx: TabContext }) {
924
925
  <div key={`${item.productVariantId}-${i}`} style={{ display: "flex", justifyContent: "space-between", fontSize: 14, padding: "4px 0" }}>
925
926
  <span>
926
927
  {item.title} × {item.quantity}
928
+ {/* Which version. Two versions of one release carry the
929
+ same title, so without this a buyer's own order
930
+ history shows what looks like the same thing bought
931
+ twice at two prices. Read from the snapshot taken at
932
+ the sale, not re-resolved. */}
933
+ <CartLineOptions item={item} accent={t.primary} size="small" style={{ marginTop: 2 }} />
927
934
  </span>
928
935
  <span>{formatAmount(item.price * item.quantity, order.currency || currency)}</span>
929
936
  </div>
@@ -6,6 +6,7 @@ import { useThemeTokens } from "../theme/ForgeThemeProvider";
6
6
  import { readableTextOn } from "../theme/contrast";
7
7
  import { useAmountFormatter } from "../format/useFormatCurrency";
8
8
  import { usePricesIncludeTax } from "../../data/queries/useWebsite";
9
+ import { CartLineOptions } from "./CartLineOptions";
9
10
 
10
11
  export interface CartProps {
11
12
  /** Custom cart icon (defaults to a shopping bag). */
@@ -220,6 +221,11 @@ export function Cart({ icon, checkoutPath = "/i/checkout", productHref, formatAm
220
221
  {cartItems.map((item) => (
221
222
  <div
222
223
  key={item.productId + item.productVariantId + String(item.isGift) + (item.recipientEmail || "")}
224
+ // Named so a test can assert on the LINE rather than the
225
+ // page: the picker behind the drawer renders the same option
226
+ // words, so an unscoped match passes whether or not the cart
227
+ // shows anything.
228
+ data-testid="cart-line"
223
229
  style={{
224
230
  display: "flex",
225
231
  gap: 12,
@@ -257,14 +263,11 @@ export function Cart({ icon, checkoutPath = "/i/checkout", productHref, formatAm
257
263
  {item.title}
258
264
  </a>
259
265
  <div style={{ fontSize: 14, opacity: 0.8 }}>{fmt(item.price)}</div>
260
- {item.color && item.size && (
261
- <div
262
- style={{ fontSize: 12, marginTop: 4, display: "flex", alignItems: "center", gap: 8, color: t.primary }}
263
- >
264
- <span style={{ width: 14, height: 14, borderRadius: "50%", background: item.color, display: "inline-block" }} />
265
- {item.size}
266
- </div>
267
- )}
266
+ {/* Which version — "Format: FLAC". The old block required
267
+ BOTH a colour and a size, so a line that was neither
268
+ showed nothing at all and two versions of one release
269
+ read as duplicates at two prices. */}
270
+ <CartLineOptions item={item} accent={t.primary} />
268
271
  {item.isGift && (
269
272
  <div style={{ fontSize: 12, marginTop: 4, color: t.primary }}>
270
273
  Gift for {item.recipientName} ({item.recipientEmail})
@@ -0,0 +1,107 @@
1
+ import type { CSSProperties } from "react";
2
+
3
+ import type { CartItem } from "../../contexts/CartContext";
4
+
5
+ /**
6
+ * Which version a cart line is.
7
+ *
8
+ * Every surface that lists what someone is buying renders this, so the answer
9
+ * to "which one did I pick" is the same in the drawer, on the checkout page and
10
+ * on the receipt. They drifted before: the drawer and the confirmation each had
11
+ * their own copy of a colour-swatch-and-size block, and neither could name a
12
+ * "Format: FLAC" — so a buyer choosing between two versions of one release saw
13
+ * two identical lines at two prices.
14
+ *
15
+ * Renders nothing when there is nothing to say. A product sold one way has no
16
+ * versions to tell apart, and an empty row of chrome reads as a loading state.
17
+ */
18
+ export function CartLineOptions({
19
+ item,
20
+ accent,
21
+ size = "normal",
22
+ style,
23
+ }: {
24
+ item: Parameters<typeof resolveCartLineOptions>[0];
25
+ /** The theme's accent, so this sits with the surface rather than on it. */
26
+ accent: string;
27
+ size?: "normal" | "small";
28
+ style?: CSSProperties;
29
+ }) {
30
+ const options = resolveCartLineOptions(item);
31
+ if (!options.length) return null;
32
+
33
+ const fontSize = size === "small" ? 11 : 12;
34
+ const dot = size === "small" ? 10 : 12;
35
+
36
+ return (
37
+ <div
38
+ style={{
39
+ fontSize,
40
+ marginTop: 4,
41
+ display: "flex",
42
+ flexWrap: "wrap",
43
+ alignItems: "center",
44
+ gap: 8,
45
+ color: accent,
46
+ ...style,
47
+ }}
48
+ >
49
+ {options.map((option) => (
50
+ <span key={`${option.axis}:${option.value}`} style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
51
+ {option.swatchHex && (
52
+ <span
53
+ aria-hidden
54
+ style={{
55
+ width: dot,
56
+ height: dot,
57
+ borderRadius: "50%",
58
+ background: option.swatchHex,
59
+ display: "inline-block",
60
+ }}
61
+ />
62
+ )}
63
+ {/* The axis is named, not implied. "L" alone is ambiguous the moment a
64
+ product has more than one axis, and the creator chose the word. */}
65
+ <span style={{ opacity: 0.7 }}>{option.axis}{option.value ? ":" : ""}</span>
66
+ {/* A legacy colour is a bare hex with no name — the swatch says it
67
+ better than the string "#1a1a1a" does to someone about to pay. */}
68
+ {option.value && <span>{option.value}</span>}
69
+ </span>
70
+ ))}
71
+ </div>
72
+ );
73
+ }
74
+
75
+ /**
76
+ * A line's options, whether it is a CART line or an ORDER line.
77
+ *
78
+ * Both answer the same question — which version is this — under two names. A
79
+ * cart line carries `options`, chosen a moment ago; an order line carries
80
+ * `variantOptions`, the snapshot taken when it was sold. Accepting both is what
81
+ * lets one component serve the drawer, the checkout, the receipt, the buyer's
82
+ * order history and the admin order page.
83
+ *
84
+ * ⚠️ Reading only `options` here TYPE-CHECKS against an order item, because
85
+ * both fields are optional — and silently renders the legacy fallback for every
86
+ * order ever placed.
87
+ *
88
+ * `color`/`size` last because carts persist in the browser and orders persist
89
+ * forever: a line saved before either field existed carries nothing else, and
90
+ * dropping it would blank the very detail this exists to show.
91
+ */
92
+ export function resolveCartLineOptions(
93
+ item: Pick<CartItem, "options" | "color" | "size"> & {
94
+ variantOptions?: { axis: string; value: string; swatchHex?: string | null }[] | null;
95
+ },
96
+ ): { axis: string; value: string; swatchHex?: string | null }[] {
97
+ if (item.options?.length) return item.options;
98
+ if (item.variantOptions?.length) return item.variantOptions;
99
+
100
+ const legacy: { axis: string; value: string; swatchHex?: string | null }[] = [];
101
+ // A hex in `color` is a swatch with no name — which is exactly why the column
102
+ // was replaced. Shown as a swatch alone rather than printing "#1a1a1a" at
103
+ // someone about to pay.
104
+ if (item.color) legacy.push({ axis: "Colour", value: "", swatchHex: item.color });
105
+ if (item.size) legacy.push({ axis: "Size", value: item.size });
106
+ return legacy;
107
+ }
@@ -11,6 +11,7 @@ import { summarizeTaxQuote } from "../format/PriceDisplay";
11
11
  import { usePricesIncludeTax } from "../../data/queries/useWebsite";
12
12
  import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
13
13
  import { Loading } from "./Loading";
14
+ import { CartLineOptions } from "./CartLineOptions";
14
15
 
15
16
  export interface CheckoutProps {
16
17
  /** Path used to build the post-payment return URL. Defaults to `/checkout/finalise`. */
@@ -628,6 +629,10 @@ export function Checkout({
628
629
  <p className="font-medium" style={{ color: theme.colors.text }}>
629
630
  {item.title}
630
631
  </p>
632
+ {/* Which version. This page never showed it at all, so a
633
+ cart holding the MP3 and the FLAC of one release
634
+ reached payment as two identical rows. */}
635
+ <CartLineOptions item={item} accent={theme.colors.primary} size="small" />
631
636
  {item.isGift && (
632
637
  <p className="text-xs" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
633
638
  Gift for {item.recipientName}
@@ -20,6 +20,7 @@ import { BundleConfirmation } from "./BundleConfirmation";
20
20
  import { useCart } from "../../contexts/CartContext";
21
21
  import { usePublicAuth } from "../../contexts/PublicAuthContext";
22
22
  import { OrderStatus, ProductDeliveryType, type IPublicOrder } from "../../types/models";
23
+ import { CartLineOptions } from "./CartLineOptions";
23
24
 
24
25
  export interface CheckoutConfirmationProps {
25
26
  orderId?: string;
@@ -419,12 +420,9 @@ function GroupBlock({
419
420
  <div style={{ fontSize: 12, color: alpha(text, 0.6), marginTop: 2 }}>
420
421
  {fmt(item.price)} · Qty {item.quantity}
421
422
  </div>
422
- {item.color && item.size && (
423
- <div style={{ fontSize: 12, marginTop: 4, display: "flex", alignItems: "center", gap: 6, color: alpha(text, 0.7) }}>
424
- <span style={{ width: 12, height: 12, borderRadius: "50%", background: item.color, display: "inline-block", border: `1px solid ${alpha(text, 0.2)}` }} />
425
- {item.size}
426
- </div>
427
- )}
423
+ {/* Which version the receipt has to say, or a buyer cannot check
424
+ they were charged for the one they picked. */}
425
+ <CartLineOptions item={item} accent={primary} size="small" />
428
426
  {item.isGift && (
429
427
  <div style={{ fontSize: 12, marginTop: 4, color: primary }}>
430
428
  🎁 Gift for {item.recipientName} ({item.recipientEmail})