@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.
- package/package.json +2 -2
- package/src/contexts/CartContext.tsx +38 -2
- package/src/data/queries/useCheckouts.ts +14 -0
- package/src/data/queries/useEvents.ts +6 -0
- package/src/data/queries/useProducts.ts +104 -10
- package/src/index.ts +8 -0
- package/src/types/models.ts +87 -5
- package/src/ui/format/_tests/pwyw.spec.ts +157 -0
- package/src/ui/format/pwyw.ts +95 -0
- package/src/ui/headless/event/useEventCheckout.ts +96 -6
- package/src/ui/headless/index.ts +1 -0
- package/src/ui/headless/useVariantSelection.ts +138 -0
- package/src/ui/index.ts +17 -0
- package/src/ui/styled/AccountDashboard.tsx +7 -0
- package/src/ui/styled/Cart.tsx +11 -8
- package/src/ui/styled/CartLineOptions.tsx +107 -0
- package/src/ui/styled/Checkout.tsx +5 -0
- package/src/ui/styled/CheckoutConfirmation.tsx +4 -6
- package/src/ui/styled/EventTickets.tsx +114 -0
- package/src/ui/styled/ProductBrowseNav.tsx +212 -0
- package/src/ui/styled/ProductDetail.tsx +158 -91
- package/src/ui/styled/ProductGrid.tsx +16 -2
|
@@ -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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
package/src/ui/headless/index.ts
CHANGED
|
@@ -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>
|
package/src/ui/styled/Cart.tsx
CHANGED
|
@@ -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
|
-
{
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
{
|
|
423
|
-
|
|
424
|
-
|
|
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})
|