@tribe-nest/forge 3.14.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 +1 -1
- package/src/contexts/CartContext.tsx +25 -1
- package/src/data/queries/useCheckouts.ts +14 -0
- package/src/data/queries/useEvents.ts +6 -0
- package/src/index.ts +3 -0
- package/src/types/models.ts +34 -1
- 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/index.ts +16 -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/ProductDetail.tsx +8 -0
package/package.json
CHANGED
|
@@ -29,7 +29,22 @@ export type CartItem = {
|
|
|
29
29
|
quantity: number;
|
|
30
30
|
recipientMessage?: string;
|
|
31
31
|
payWhatYouWant: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* WHICH version this line is — "Format: FLAC", "Size: L".
|
|
34
|
+
*
|
|
35
|
+
* The cart used to show a colour swatch and a size string, which is all a
|
|
36
|
+
* variant could be. Anything else was unnameable: a buyer who chose FLAC over
|
|
37
|
+
* MP3 saw two identical lines at two prices and no way to tell which was
|
|
38
|
+
* which, right at the moment they are deciding whether to pay.
|
|
39
|
+
*
|
|
40
|
+
* Optional because a product sold one way has no versions to distinguish, and
|
|
41
|
+
* because carts persist — a line saved before this existed still has to
|
|
42
|
+
* render.
|
|
43
|
+
*/
|
|
44
|
+
options?: { axis: string; value: string; swatchHex?: string | null }[];
|
|
45
|
+
/** @deprecated Superseded by `options`. Still read for carts saved earlier. */
|
|
32
46
|
color?: string;
|
|
47
|
+
/** @deprecated Superseded by `options`. Still read for carts saved earlier. */
|
|
33
48
|
size?: string;
|
|
34
49
|
/**
|
|
35
50
|
* REQUIRED, and deliberately so.
|
|
@@ -67,7 +82,16 @@ export type TicketCartItem = {
|
|
|
67
82
|
/** ticketId → quantity, matching `useEventCheckout`'s `selectedTickets`. */
|
|
68
83
|
tickets: Record<string, number>;
|
|
69
84
|
/** Display data per ticket id, so the cart can render lines without refetching. */
|
|
70
|
-
|
|
85
|
+
/**
|
|
86
|
+
* `price` is the per-unit figure the cart DISPLAYS — on a pay-what-you-want
|
|
87
|
+
* tier that is the buyer's chosen amount, not the tier's floor, so returning
|
|
88
|
+
* from an add-on page doesn't silently forget what they picked.
|
|
89
|
+
*
|
|
90
|
+
* `pwywAmount` is the same number carried explicitly, because the bundle
|
|
91
|
+
* endpoint treats a line's `price` as display-only and never charges it; the
|
|
92
|
+
* chosen amount has to arrive in a field the server actually reads.
|
|
93
|
+
*/
|
|
94
|
+
ticketMeta: Record<string, { title: string; price: number; pwywAmount?: number }>;
|
|
71
95
|
};
|
|
72
96
|
|
|
73
97
|
interface CartContextType {
|
|
@@ -25,7 +25,14 @@ export type CheckoutLineInput =
|
|
|
25
25
|
eventId: string;
|
|
26
26
|
ticketId: string;
|
|
27
27
|
quantity: number;
|
|
28
|
+
/** Display only — the bundle never charges this. */
|
|
28
29
|
price: number;
|
|
30
|
+
/**
|
|
31
|
+
* The buyer's chosen per-unit amount on a pay-what-you-want tier. Unlike
|
|
32
|
+
* `price`, this IS forwarded into the ticket order and charged (after the
|
|
33
|
+
* server clamps it against the tier's floor).
|
|
34
|
+
*/
|
|
35
|
+
pwywAmount?: number;
|
|
29
36
|
title: string;
|
|
30
37
|
coverImage?: string;
|
|
31
38
|
};
|
|
@@ -45,6 +52,13 @@ export function cartToCheckoutLines(cartItems: CartItem[], ticketItems: TicketCa
|
|
|
45
52
|
ticketId,
|
|
46
53
|
quantity: qty,
|
|
47
54
|
price: t.ticketMeta[ticketId]?.price ?? 0,
|
|
55
|
+
// Sent SEPARATELY from `price` above, which the bundle treats as
|
|
56
|
+
// display-only and never charges. This is the field the server forwards
|
|
57
|
+
// into the ticket order — dropping it would charge the tier's floor and
|
|
58
|
+
// still return 200, so the buyer would be quietly undercharged.
|
|
59
|
+
...(t.ticketMeta[ticketId]?.pwywAmount != null
|
|
60
|
+
? { pwywAmount: t.ticketMeta[ticketId]?.pwywAmount }
|
|
61
|
+
: {}),
|
|
48
62
|
title: t.ticketMeta[ticketId]?.title ?? t.eventTitle,
|
|
49
63
|
coverImage: t.coverImage,
|
|
50
64
|
})),
|
|
@@ -43,6 +43,12 @@ export function useEvent(id?: string, options?: { initialData?: IEvent }) {
|
|
|
43
43
|
|
|
44
44
|
export type CreateEventOrderInput = {
|
|
45
45
|
items: Record<string, number>;
|
|
46
|
+
/**
|
|
47
|
+
* ticketId → the buyer's chosen amount, PER UNIT, on a pay-what-you-want
|
|
48
|
+
* tier. Ignored server-side for any tier that is not PWYW, and clamped up to
|
|
49
|
+
* the tier's own `price` — it can raise what is charged, never lower it.
|
|
50
|
+
*/
|
|
51
|
+
amounts?: Record<string, number>;
|
|
46
52
|
email: string;
|
|
47
53
|
firstName?: string;
|
|
48
54
|
lastName?: string;
|
package/src/index.ts
CHANGED
|
@@ -30,6 +30,9 @@ export { CartProvider, useCart } from "./contexts/CartContext";
|
|
|
30
30
|
// way the cart and audio player are — so a code site and a Craft site cannot
|
|
31
31
|
// disagree about which version a buyer picked.
|
|
32
32
|
export { useVariantSelection } from "./ui/headless/useVariantSelection";
|
|
33
|
+
// Which version a cart line is. Exported from the package root because the
|
|
34
|
+
// Craft themes render their own cart and must resolve it the same way.
|
|
35
|
+
export { resolveCartLineOptions } from "./ui/styled/CartLineOptions";
|
|
33
36
|
export type { VariantAxis, VariantAxisValue } from "./ui/headless/useVariantSelection";
|
|
34
37
|
export type { CartItem, TicketCartItem, AttachedTo } from "./contexts/CartContext";
|
|
35
38
|
export {
|
package/src/types/models.ts
CHANGED
|
@@ -582,6 +582,15 @@ export type IMedia = {
|
|
|
582
582
|
type: MediaType;
|
|
583
583
|
previewUrl?: string | null;
|
|
584
584
|
previewStatus?: string | null;
|
|
585
|
+
/**
|
|
586
|
+
* Which version this file is for, when it is a song's file.
|
|
587
|
+
*
|
|
588
|
+
* Null — and absent everywhere else — means every version gets it. Present so
|
|
589
|
+
* an EDITOR can round-trip a per-version file; a storefront never needs it,
|
|
590
|
+
* because the server has already filtered the list to the version being
|
|
591
|
+
* viewed.
|
|
592
|
+
*/
|
|
593
|
+
productOptionValueId?: string | null;
|
|
585
594
|
};
|
|
586
595
|
|
|
587
596
|
/**
|
|
@@ -1097,11 +1106,22 @@ export type ITicket = {
|
|
|
1097
1106
|
id: string;
|
|
1098
1107
|
title: string;
|
|
1099
1108
|
description: string;
|
|
1109
|
+
/**
|
|
1110
|
+
* On a pay-what-you-want tier this is the MINIMUM, not a fixed price — there
|
|
1111
|
+
* is no separate minimum field. Every "from $X" on the storefront reads it,
|
|
1112
|
+
* which is exactly why it stays the floor.
|
|
1113
|
+
*/
|
|
1100
1114
|
price: number;
|
|
1101
1115
|
// Optional display-only "compare-at" price (higher than `price`). When
|
|
1102
1116
|
// present the storefront strikes it through next to `price`. Charging uses
|
|
1103
|
-
// `price`.
|
|
1117
|
+
// `price`. Mutually exclusive with `payWhatYouWant` — the API refuses both.
|
|
1104
1118
|
compareAtPrice?: number | string | null;
|
|
1119
|
+
/** Buyer chooses the amount, at or above `price`. */
|
|
1120
|
+
payWhatYouWant?: boolean;
|
|
1121
|
+
/** Pre-fills the buyer's amount box. Presentation only — never a floor. */
|
|
1122
|
+
pwywSuggestedAmount?: number | string | null;
|
|
1123
|
+
/** Enforced server-side at checkout. */
|
|
1124
|
+
payWhatYouWantMaximum?: number | string | null;
|
|
1105
1125
|
quantity: number;
|
|
1106
1126
|
order: number;
|
|
1107
1127
|
sold: number;
|
|
@@ -1268,7 +1288,20 @@ export type IPublicOrderItem = {
|
|
|
1268
1288
|
quantity: number;
|
|
1269
1289
|
recipientMessage?: string;
|
|
1270
1290
|
payWhatYouWant: boolean;
|
|
1291
|
+
/**
|
|
1292
|
+
* What this line WAS, recorded when it was sold.
|
|
1293
|
+
*
|
|
1294
|
+
* Not resolved through `productVariantId` on read: the creator can rename an
|
|
1295
|
+
* option value or delete the version, and an order that re-resolves would
|
|
1296
|
+
* quietly start describing itself differently from how it was bought.
|
|
1297
|
+
*
|
|
1298
|
+
* Null for a product sold one way, and for every order placed before the
|
|
1299
|
+
* snapshot existed — those fall back to `size` below.
|
|
1300
|
+
*/
|
|
1301
|
+
variantOptions?: { axis: string; value: string; swatchHex?: string | null }[] | null;
|
|
1302
|
+
/** @deprecated Superseded by `variantOptions`. The only record for older orders. */
|
|
1271
1303
|
color: string;
|
|
1304
|
+
/** @deprecated Superseded by `variantOptions`. The only record for older orders. */
|
|
1272
1305
|
size: string;
|
|
1273
1306
|
};
|
|
1274
1307
|
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import type { ITicket } from "../../../types/models";
|
|
3
|
+
import {
|
|
4
|
+
clampChosenAmount,
|
|
5
|
+
isPayWhatYouWant,
|
|
6
|
+
pwywDefaultAmount,
|
|
7
|
+
pwywMaximum,
|
|
8
|
+
resolveUnitPrice,
|
|
9
|
+
ticketSubtotals,
|
|
10
|
+
} from "../pwyw";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The pay-what-you-want arithmetic shared by BOTH storefront stacks (Forge and
|
|
14
|
+
* the legacy client). It is shared rather than written twice precisely so the
|
|
15
|
+
* two cannot drift from each other or from the server — these tests pin the
|
|
16
|
+
* behaviour they both depend on.
|
|
17
|
+
*
|
|
18
|
+
* The clamp here is UX, not enforcement: the server clamps again and is what
|
|
19
|
+
* decides the charge. What matters is that the number on screen matches the
|
|
20
|
+
* number that will be charged.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const tier = (over: Partial<ITicket> = {}): ITicket => ({
|
|
24
|
+
id: over.id ?? "t1",
|
|
25
|
+
title: "GA",
|
|
26
|
+
description: "",
|
|
27
|
+
price: 20,
|
|
28
|
+
quantity: 100,
|
|
29
|
+
order: 0,
|
|
30
|
+
sold: 0,
|
|
31
|
+
maxPerPerson: 10,
|
|
32
|
+
...over,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("pwyw helpers", () => {
|
|
36
|
+
describe("isPayWhatYouWant", () => {
|
|
37
|
+
it("is false when the flag is absent — every pre-feature tier", () => {
|
|
38
|
+
expect(isPayWhatYouWant(tier())).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("is true only when explicitly set", () => {
|
|
42
|
+
expect(isPayWhatYouWant(tier({ payWhatYouWant: true }))).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("pwywMaximum", () => {
|
|
47
|
+
it("is null on a non-PWYW tier even if a maximum is stored", () => {
|
|
48
|
+
// A stale ceiling on a tier whose toggle was turned off must not start
|
|
49
|
+
// constraining a fixed price.
|
|
50
|
+
expect(pwywMaximum(tier({ payWhatYouWantMaximum: 50 }))).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("parses a numeric string, as the API returns for decimals", () => {
|
|
54
|
+
expect(pwywMaximum(tier({ payWhatYouWant: true, payWhatYouWantMaximum: "50.00" }))).toBe(50);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("is null for an unparseable value rather than NaN", () => {
|
|
58
|
+
expect(pwywMaximum(tier({ payWhatYouWant: true, payWhatYouWantMaximum: "nonsense" }))).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("pwywDefaultAmount", () => {
|
|
63
|
+
it("falls back to the floor with no suggestion", () => {
|
|
64
|
+
expect(pwywDefaultAmount(tier({ payWhatYouWant: true }))).toBe(20);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("uses the operator's suggestion", () => {
|
|
68
|
+
expect(pwywDefaultAmount(tier({ payWhatYouWant: true, pwywSuggestedAmount: 35 }))).toBe(35);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("never pre-fills below the floor", () => {
|
|
72
|
+
// Otherwise the form opens with a value its own validation rejects.
|
|
73
|
+
expect(pwywDefaultAmount(tier({ payWhatYouWant: true, pwywSuggestedAmount: 5 }))).toBe(20);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("never pre-fills above the maximum", () => {
|
|
77
|
+
expect(
|
|
78
|
+
pwywDefaultAmount(tier({ payWhatYouWant: true, pwywSuggestedAmount: 80, payWhatYouWantMaximum: 50 })),
|
|
79
|
+
).toBe(50);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("clampChosenAmount", () => {
|
|
84
|
+
const t = tier({ payWhatYouWant: true });
|
|
85
|
+
|
|
86
|
+
it("keeps a figure above the floor", () => {
|
|
87
|
+
expect(clampChosenAmount(t, 75)).toBe(75);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("raises a figure below the floor — the one-directional property", () => {
|
|
91
|
+
expect(clampChosenAmount(t, 1)).toBe(20);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it.each([
|
|
95
|
+
["null", null],
|
|
96
|
+
["undefined", undefined],
|
|
97
|
+
["NaN", NaN],
|
|
98
|
+
["negative", -50],
|
|
99
|
+
["zero", 0],
|
|
100
|
+
])("falls back to the floor for %s", (_label, value) => {
|
|
101
|
+
expect(clampChosenAmount(t, value as number | null | undefined)).toBe(20);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("caps at the maximum", () => {
|
|
105
|
+
expect(clampChosenAmount(tier({ payWhatYouWant: true, payWhatYouWantMaximum: 50 }), 5000)).toBe(50);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("resolveUnitPrice", () => {
|
|
110
|
+
it("ignores a chosen amount on a non-PWYW tier, matching the server", () => {
|
|
111
|
+
expect(resolveUnitPrice(tier(), 500)).toBe(20);
|
|
112
|
+
expect(resolveUnitPrice(tier(), 1)).toBe(20);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("honours a chosen amount on a PWYW tier", () => {
|
|
116
|
+
expect(resolveUnitPrice(tier({ payWhatYouWant: true }), 75)).toBe(75);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("ticketSubtotals", () => {
|
|
121
|
+
it("returns identical paid and floor figures with no PWYW tier", () => {
|
|
122
|
+
// The property that makes every existing surface's numbers unchanged.
|
|
123
|
+
const result = ticketSubtotals({
|
|
124
|
+
tickets: [tier({ id: "a", price: 20 }), tier({ id: "b", price: 30 })],
|
|
125
|
+
quantities: { a: 2, b: 1 },
|
|
126
|
+
});
|
|
127
|
+
expect(result).toEqual({ paid: 70, floor: 70 });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("separates what is paid from what the fee is computed on", () => {
|
|
131
|
+
const result = ticketSubtotals({
|
|
132
|
+
tickets: [tier({ id: "a", price: 20, payWhatYouWant: true })],
|
|
133
|
+
quantities: { a: 2 },
|
|
134
|
+
amounts: { a: 75 },
|
|
135
|
+
});
|
|
136
|
+
expect(result).toEqual({ paid: 150, floor: 40 });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("mixes PWYW and fixed tiers correctly", () => {
|
|
140
|
+
const result = ticketSubtotals({
|
|
141
|
+
tickets: [tier({ id: "a", price: 10, payWhatYouWant: true }), tier({ id: "b", price: 40 })],
|
|
142
|
+
quantities: { a: 2, b: 1 },
|
|
143
|
+
amounts: { a: 35 },
|
|
144
|
+
});
|
|
145
|
+
expect(result).toEqual({ paid: 110, floor: 60 });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("ignores tiers with no quantity selected", () => {
|
|
149
|
+
const result = ticketSubtotals({
|
|
150
|
+
tickets: [tier({ id: "a", price: 20, payWhatYouWant: true })],
|
|
151
|
+
quantities: {},
|
|
152
|
+
amounts: { a: 500 },
|
|
153
|
+
});
|
|
154
|
+
expect(result).toEqual({ paid: 0, floor: 0 });
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { ITicket } from "../../types/models";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pay-what-you-want, as a buyer-facing surface has to handle it.
|
|
5
|
+
*
|
|
6
|
+
* ## The floor is the ticket, everything above it is a tip
|
|
7
|
+
*
|
|
8
|
+
* On a PWYW tier `ticket.price` IS the minimum — there is no separate minimum
|
|
9
|
+
* field — so every existing "from $X" already reads the right number. What
|
|
10
|
+
* changes is that the buyer may choose to pay MORE, and two totals then exist
|
|
11
|
+
* at once:
|
|
12
|
+
*
|
|
13
|
+
* - the **paid** subtotal, which is what the card is charged, and
|
|
14
|
+
* - the **floor** subtotal, which is what discounts and the artist's booking
|
|
15
|
+
* fee are calculated on.
|
|
16
|
+
*
|
|
17
|
+
* The server computes both the same way (`PublicEventsService.createOrder`).
|
|
18
|
+
* These helpers exist so a storefront cannot accidentally show a fee estimate
|
|
19
|
+
* derived from the paid figure and then be charged one derived from the floor.
|
|
20
|
+
*
|
|
21
|
+
* ## The client is not the guard
|
|
22
|
+
*
|
|
23
|
+
* `clampChosenAmount` is UX, not enforcement. The server reads the PWYW flag
|
|
24
|
+
* off the ticket row and refuses anything below `price` regardless of what a
|
|
25
|
+
* client sends — a buyer cannot pay less than the floor by editing anything
|
|
26
|
+
* here. The clamp exists so the number on screen is the number that will be
|
|
27
|
+
* charged, not to make the request safe.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const toNumber = (value: number | string | null | undefined): number | null => {
|
|
31
|
+
if (value == null) return null;
|
|
32
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
33
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const isPayWhatYouWant = (ticket: ITicket): boolean => !!ticket.payWhatYouWant;
|
|
37
|
+
|
|
38
|
+
/** The tier's ceiling, or `null` for no limit. */
|
|
39
|
+
export const pwywMaximum = (ticket: ITicket): number | null =>
|
|
40
|
+
isPayWhatYouWant(ticket) ? toNumber(ticket.payWhatYouWantMaximum) : null;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What the amount box should start at: the operator's suggestion when there is
|
|
44
|
+
* one, else the floor. Never below the floor — a suggestion under the minimum
|
|
45
|
+
* would pre-fill the form with a value the buyer is not allowed to pay.
|
|
46
|
+
*/
|
|
47
|
+
export const pwywDefaultAmount = (ticket: ITicket): number => {
|
|
48
|
+
const floor = ticket.price;
|
|
49
|
+
const suggested = toNumber(ticket.pwywSuggestedAmount);
|
|
50
|
+
if (suggested == null || suggested < floor) return floor;
|
|
51
|
+
const maximum = pwywMaximum(ticket);
|
|
52
|
+
return maximum != null ? Math.min(suggested, maximum) : suggested;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Pin a buyer-entered figure into [floor, maximum]. Mirrors the server clamp. */
|
|
56
|
+
export const clampChosenAmount = (ticket: ITicket, chosen: number | null | undefined): number => {
|
|
57
|
+
const floor = ticket.price;
|
|
58
|
+
if (chosen == null || !Number.isFinite(chosen) || chosen < floor) return floor;
|
|
59
|
+
const maximum = pwywMaximum(ticket);
|
|
60
|
+
return maximum != null ? Math.min(chosen, maximum) : chosen;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The per-unit price this tier will actually be charged at.
|
|
65
|
+
*
|
|
66
|
+
* A non-PWYW tier ignores `chosen` entirely, exactly as the server does — so a
|
|
67
|
+
* stale amount left in state after the operator turns PWYW off cannot change
|
|
68
|
+
* what is shown.
|
|
69
|
+
*/
|
|
70
|
+
export const resolveUnitPrice = (ticket: ITicket, chosen: number | null | undefined): number =>
|
|
71
|
+
isPayWhatYouWant(ticket) ? clampChosenAmount(ticket, chosen) : ticket.price;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Both subtotals for a selection, in one pass.
|
|
75
|
+
*
|
|
76
|
+
* `paid` is what the buyer owes for the tickets; `floor` is the base the
|
|
77
|
+
* booking-fee estimate and any percentage discount must be computed against.
|
|
78
|
+
* They are equal on any cart with no PWYW tier, which is why every existing
|
|
79
|
+
* surface keeps its current numbers untouched.
|
|
80
|
+
*/
|
|
81
|
+
export function ticketSubtotals(input: {
|
|
82
|
+
tickets: ITicket[];
|
|
83
|
+
quantities: Record<string, number>;
|
|
84
|
+
amounts?: Record<string, number>;
|
|
85
|
+
}): { paid: number; floor: number } {
|
|
86
|
+
let paid = 0;
|
|
87
|
+
let floor = 0;
|
|
88
|
+
for (const ticket of input.tickets) {
|
|
89
|
+
const quantity = input.quantities[ticket.id] ?? 0;
|
|
90
|
+
if (quantity <= 0) continue;
|
|
91
|
+
paid += resolveUnitPrice(ticket, input.amounts?.[ticket.id]) * quantity;
|
|
92
|
+
floor += ticket.price * quantity;
|
|
93
|
+
}
|
|
94
|
+
return { paid, floor };
|
|
95
|
+
}
|
|
@@ -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/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";
|
|
@@ -153,6 +157,18 @@ export { PriceDisplay, priceTaxCaption, summarizeTaxQuote, type PriceDisplayProp
|
|
|
153
157
|
// the styled blocks. Resolution stays on the server (`IEvent.bookingFee`); these
|
|
154
158
|
// only do the arithmetic and the wording.
|
|
155
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";
|
|
156
172
|
export { usePricesIncludeTax } from "../data/queries/useWebsite";
|
|
157
173
|
|
|
158
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})
|
|
@@ -5,6 +5,7 @@ import { useEventCheckout } from "../headless/event/useEventCheckout";
|
|
|
5
5
|
import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
|
|
6
6
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
7
7
|
import { PriceDisplay, summarizeTaxQuote } from "../format/PriceDisplay";
|
|
8
|
+
import { clampChosenAmount, isPayWhatYouWant, pwywDefaultAmount, pwywMaximum } from "../format/pwyw";
|
|
8
9
|
import { usePricesIncludeTax } from "../../data/queries/useWebsite";
|
|
9
10
|
import { Loading } from "./Loading";
|
|
10
11
|
import { usePaymentRenderer, type PaymentRenderProps } from "../payment/ForgePaymentProvider";
|
|
@@ -282,6 +283,12 @@ function TicketStep({
|
|
|
282
283
|
{fmt(Number(ticket.compareAtPrice))}
|
|
283
284
|
</span>
|
|
284
285
|
)}
|
|
286
|
+
{/* On a PWYW tier `price` is the FLOOR, so it is labelled as
|
|
287
|
+
one — showing it bare would read as a fixed price the
|
|
288
|
+
buyer is about to be charged. */}
|
|
289
|
+
{isPayWhatYouWant(ticket) && (
|
|
290
|
+
<span style={{ fontSize: 14, fontWeight: 500, opacity: 0.7, marginRight: 6 }}>from</span>
|
|
291
|
+
)}
|
|
285
292
|
<PriceDisplay amount={Number(ticket.price)} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={theme.colors.text} captionStyle={{ opacity: 0.65 }} />
|
|
286
293
|
</div>
|
|
287
294
|
</div>
|
|
@@ -305,6 +312,20 @@ function TicketStep({
|
|
|
305
312
|
</button>
|
|
306
313
|
</div>
|
|
307
314
|
</div>
|
|
315
|
+
{/* The amount box appears only once a seat is actually selected —
|
|
316
|
+
before that there is nothing to price, and a row of empty
|
|
317
|
+
inputs down the tier list reads as a form to fill in rather
|
|
318
|
+
than a choice to make. */}
|
|
319
|
+
{isPayWhatYouWant(ticket) && selectedQty > 0 && !isSoldOut && !isExpired && (
|
|
320
|
+
<PwywAmountField
|
|
321
|
+
ticket={ticket}
|
|
322
|
+
quantity={selectedQty}
|
|
323
|
+
value={c.pwywAmounts[ticket.id]}
|
|
324
|
+
onChange={(amount) => c.setTicketAmount(ticket.id, amount)}
|
|
325
|
+
fmt={fmt}
|
|
326
|
+
theme={theme}
|
|
327
|
+
/>
|
|
328
|
+
)}
|
|
308
329
|
{ticket.description && (
|
|
309
330
|
<p dangerouslySetInnerHTML={{ __html: ticket.description }} style={{ marginTop: 12, fontSize: 14 }} />
|
|
310
331
|
)}
|
|
@@ -669,6 +690,99 @@ const closeButtonStyle = (t: ForgeTheme): CSSProperties => ({
|
|
|
669
690
|
opacity: 0.7,
|
|
670
691
|
});
|
|
671
692
|
|
|
693
|
+
/**
|
|
694
|
+
* The buyer's "what will you pay?" box for one pay-what-you-want tier.
|
|
695
|
+
*
|
|
696
|
+
* Holds its own STRING state rather than binding the number straight through.
|
|
697
|
+
* Two reasons, both of which are broken inputs if you skip them: an empty box
|
|
698
|
+
* has to stay empty while the buyer retypes (a number-bound input snaps it back
|
|
699
|
+
* to the floor on every keystroke), and "1" on the way to "15" must not be
|
|
700
|
+
* clamped up to the minimum the instant it is typed.
|
|
701
|
+
*
|
|
702
|
+
* Correction happens on BLUR, when the buyer has finished — and the server
|
|
703
|
+
* clamps again regardless, so nothing here is load-bearing for money.
|
|
704
|
+
*/
|
|
705
|
+
function PwywAmountField({
|
|
706
|
+
ticket,
|
|
707
|
+
quantity,
|
|
708
|
+
value,
|
|
709
|
+
onChange,
|
|
710
|
+
fmt,
|
|
711
|
+
theme,
|
|
712
|
+
}: {
|
|
713
|
+
ticket: ITicket;
|
|
714
|
+
quantity: number;
|
|
715
|
+
value: number | undefined;
|
|
716
|
+
onChange: (amount: number | null) => void;
|
|
717
|
+
fmt: (amount: number) => string;
|
|
718
|
+
theme: ForgeTheme;
|
|
719
|
+
}) {
|
|
720
|
+
const floor = Number(ticket.price);
|
|
721
|
+
const maximum = pwywMaximum(ticket);
|
|
722
|
+
const suggested = pwywDefaultAmount(ticket);
|
|
723
|
+
const [draft, setDraft] = useState<string>(String(value ?? suggested));
|
|
724
|
+
|
|
725
|
+
// Follow the tier's own default when the buyer hasn't typed anything — an
|
|
726
|
+
// operator's suggested amount arriving late (or the selection being cleared
|
|
727
|
+
// and remade) should re-seed the box rather than leave a stale figure.
|
|
728
|
+
useEffect(() => {
|
|
729
|
+
if (value == null) setDraft(String(suggested));
|
|
730
|
+
}, [value, suggested]);
|
|
731
|
+
|
|
732
|
+
const parsed = Number(draft);
|
|
733
|
+
const effective = Number.isFinite(parsed) ? clampChosenAmount(ticket, parsed) : floor;
|
|
734
|
+
const belowFloor = draft.trim() !== "" && Number.isFinite(parsed) && parsed < floor;
|
|
735
|
+
const aboveMax = maximum != null && Number.isFinite(parsed) && parsed > maximum;
|
|
736
|
+
|
|
737
|
+
const commit = () => {
|
|
738
|
+
if (draft.trim() === "" || !Number.isFinite(parsed)) {
|
|
739
|
+
onChange(null);
|
|
740
|
+
setDraft(String(suggested));
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const clamped = clampChosenAmount(ticket, parsed);
|
|
744
|
+
onChange(clamped);
|
|
745
|
+
setDraft(String(clamped));
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
return (
|
|
749
|
+
<div style={{ marginTop: 12 }}>
|
|
750
|
+
<label
|
|
751
|
+
htmlFor={`pwyw-${ticket.id}`}
|
|
752
|
+
style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 6 }}
|
|
753
|
+
>
|
|
754
|
+
Name your price
|
|
755
|
+
<span style={{ fontWeight: 400, opacity: 0.7 }}>
|
|
756
|
+
{" "}
|
|
757
|
+
— minimum {fmt(floor)}
|
|
758
|
+
{maximum != null ? `, up to ${fmt(maximum)}` : ""}
|
|
759
|
+
</span>
|
|
760
|
+
</label>
|
|
761
|
+
<input
|
|
762
|
+
id={`pwyw-${ticket.id}`}
|
|
763
|
+
type="number"
|
|
764
|
+
inputMode="decimal"
|
|
765
|
+
min={floor}
|
|
766
|
+
{...(maximum != null ? { max: maximum } : {})}
|
|
767
|
+
step="0.01"
|
|
768
|
+
value={draft}
|
|
769
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
770
|
+
onBlur={commit}
|
|
771
|
+
style={{ ...inputStyle(theme), maxWidth: 200 }}
|
|
772
|
+
/>
|
|
773
|
+
<p style={{ fontSize: 12, opacity: 0.7, marginTop: 6 }}>
|
|
774
|
+
{belowFloor
|
|
775
|
+
? `The minimum is ${fmt(floor)} — we'll use that.`
|
|
776
|
+
: aboveMax
|
|
777
|
+
? `The most you can pay is ${fmt(maximum!)} — we'll use that.`
|
|
778
|
+
: quantity > 1
|
|
779
|
+
? `${fmt(effective)} each · ${fmt(effective * quantity)} for ${quantity}`
|
|
780
|
+
: "Per ticket."}
|
|
781
|
+
</p>
|
|
782
|
+
</div>
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
|
|
672
786
|
const stepperStyle = (t: ForgeTheme, filled: boolean): CSSProperties => ({
|
|
673
787
|
width: 40,
|
|
674
788
|
height: 40,
|
|
@@ -112,6 +112,14 @@ function useBuy(product: IPublicProduct, cover: string | undefined, checkoutPath
|
|
|
112
112
|
quantity: opts?.quantity ?? 1,
|
|
113
113
|
canIncreaseQuantity: opts?.canIncreaseQuantity ?? true,
|
|
114
114
|
payWhatYouWant: !!variant.payWhatYouWant,
|
|
115
|
+
// Which version was chosen, carried onto the line so the cart, the
|
|
116
|
+
// checkout and the receipt can all say so. `color`/`size` stay for a
|
|
117
|
+
// moment longer: they are what a cart saved before this holds.
|
|
118
|
+
options: (variant.options ?? []).map((option) => ({
|
|
119
|
+
axis: option.axis,
|
|
120
|
+
value: option.value,
|
|
121
|
+
swatchHex: option.swatchHex,
|
|
122
|
+
})),
|
|
115
123
|
color: variant.color,
|
|
116
124
|
size: variant.size,
|
|
117
125
|
deliveryType: variant.deliveryType,
|