@tribe-nest/forge 3.14.0 → 3.19.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/useCoachingAvailability.ts +13 -0
- package/src/data/queries/useCourseAccess.ts +81 -0
- package/src/data/queries/useCourses.ts +19 -1
- package/src/data/queries/useEvents.ts +6 -0
- package/src/data/queries/useFinalize.ts +24 -4
- package/src/index.ts +7 -0
- package/src/types/models.ts +41 -1
- package/src/ui/format/_tests/pwyw.spec.ts +157 -0
- package/src/ui/format/pwyw.ts +95 -0
- package/src/ui/headless/booking/useBookingSecret.ts +41 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +26 -3
- package/src/ui/headless/course/_tests/courseAccessGate.spec.ts +135 -0
- package/src/ui/headless/course/useCourseCheckout.ts +18 -1
- package/src/ui/headless/course/useCourseClassroom.ts +242 -0
- package/src/ui/headless/event/useEventCheckout.ts +96 -6
- package/src/ui/headless/index.ts +11 -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/CoachingBooking.tsx +9 -1
- package/src/ui/styled/CoachingConfirmation.tsx +44 -3
- package/src/ui/styled/Confirmation.tsx +94 -0
- package/src/ui/styled/CourseAccess.tsx +191 -51
- package/src/ui/styled/CourseCheckout.tsx +9 -1
- package/src/ui/styled/CourseConfirmation.tsx +42 -3
- package/src/ui/styled/EventTickets.tsx +114 -0
- package/src/ui/styled/InvoicePayment.tsx +65 -11
- package/src/ui/styled/ProductDetail.tsx +8 -0
- package/src/utils/_tests/bookingSecret.spec.ts +115 -0
- package/src/utils/bookingSecret.ts +100 -0
|
@@ -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
|
@@ -31,11 +31,22 @@ export {
|
|
|
31
31
|
type CouponQuoteFn,
|
|
32
32
|
} from "./coupon/useCouponField";
|
|
33
33
|
export { useCourseCheckout, type UseCourseCheckoutOptions, type CourseCheckoutStep } from "./course/useCourseCheckout";
|
|
34
|
+
export {
|
|
35
|
+
useCourseClassroom,
|
|
36
|
+
buildCourseAccessGate,
|
|
37
|
+
buildCourseAccessSecurePrompt,
|
|
38
|
+
courseSignInHref,
|
|
39
|
+
type UseCourseClassroomOptions,
|
|
40
|
+
type CourseAccessGate,
|
|
41
|
+
type CourseAccessGateKind,
|
|
42
|
+
type CourseAccessSecurePrompt,
|
|
43
|
+
} from "./course/useCourseClassroom";
|
|
34
44
|
export {
|
|
35
45
|
useCoachingBooking,
|
|
36
46
|
type UseCoachingBookingOptions,
|
|
37
47
|
type CoachingBookingStep,
|
|
38
48
|
} from "./coaching/useCoachingBooking";
|
|
49
|
+
export { useBookingSecret, type BookingSecretLookup } from "./booking/useBookingSecret";
|
|
39
50
|
export {
|
|
40
51
|
useMembershipCheckout,
|
|
41
52
|
type UseMembershipCheckoutOptions,
|
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";
|
|
@@ -112,6 +116,7 @@ export {
|
|
|
112
116
|
CheckSeal,
|
|
113
117
|
WarnSeal,
|
|
114
118
|
Perforation,
|
|
119
|
+
ConfirmationLostCheckout,
|
|
115
120
|
alpha,
|
|
116
121
|
} from "./styled/Confirmation";
|
|
117
122
|
export { PlayButton, type PlayButtonProps } from "./styled/PlayButton";
|
|
@@ -153,6 +158,18 @@ export { PriceDisplay, priceTaxCaption, summarizeTaxQuote, type PriceDisplayProp
|
|
|
153
158
|
// the styled blocks. Resolution stays on the server (`IEvent.bookingFee`); these
|
|
154
159
|
// only do the arithmetic and the wording.
|
|
155
160
|
export { computeBookingFeeAmount, hasBookingFee, describeBookingFee } from "./format/bookingFee";
|
|
161
|
+
// Pay-what-you-want, for a site rendering its own ticket UI. `price` is the
|
|
162
|
+
// FLOOR on a PWYW tier, so an existing "from $X" is already correct; these only
|
|
163
|
+
// resolve what a buyer's chosen amount costs. The clamp here is UX — the server
|
|
164
|
+
// clamps again and is the one that decides what is charged.
|
|
165
|
+
export {
|
|
166
|
+
isPayWhatYouWant,
|
|
167
|
+
pwywMaximum,
|
|
168
|
+
pwywDefaultAmount,
|
|
169
|
+
clampChosenAmount,
|
|
170
|
+
resolveUnitPrice,
|
|
171
|
+
ticketSubtotals,
|
|
172
|
+
} from "./format/pwyw";
|
|
156
173
|
export { usePricesIncludeTax } from "../data/queries/useWebsite";
|
|
157
174
|
|
|
158
175
|
// 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})
|
|
@@ -29,7 +29,15 @@ export interface CoachingBookingProps {
|
|
|
29
29
|
formatAmount?: (amount: number) => string;
|
|
30
30
|
/** Optional payment renderer — falls back to a registered <ForgePaymentProvider>. */
|
|
31
31
|
renderPayment?: (props: PaymentRenderProps) => ReactNode;
|
|
32
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Stripe return URL for a PAID booking. Default `/i/coaching/:slug/finalise`.
|
|
34
|
+
*
|
|
35
|
+
* Put ONLY the bookingId in this URL. The booking's secret is handled for you
|
|
36
|
+
* by the hook (stashed in `sessionStorage`, read back by
|
|
37
|
+
* `CoachingConfirmation`) — putting it in the URL would hand it to history,
|
|
38
|
+
* referrers and shared links, which is the exact leak it exists to close. See
|
|
39
|
+
* `utils/bookingSecret`.
|
|
40
|
+
*/
|
|
33
41
|
finalisePath?: (slug: string, bookingId: string) => string;
|
|
34
42
|
/** Host-owned navigation on FREE completion (e.g. router navigate). */
|
|
35
43
|
onComplete?: (info: { slug: string; bookingId: string }) => void;
|
|
@@ -3,8 +3,18 @@ import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
|
3
3
|
import { readableTextOn } from "../theme/contrast";
|
|
4
4
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
5
5
|
import { Loading } from "./Loading";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
ConfirmationStage,
|
|
8
|
+
ConfirmationCard,
|
|
9
|
+
CheckSeal,
|
|
10
|
+
WarnSeal,
|
|
11
|
+
Perforation,
|
|
12
|
+
ConfirmationRow,
|
|
13
|
+
ConfirmationLostCheckout,
|
|
14
|
+
alpha,
|
|
15
|
+
} from "./Confirmation";
|
|
7
16
|
import { useCoachingBookingFinalize } from "../../data/queries/useFinalize";
|
|
17
|
+
import { useBookingSecret } from "../headless/booking/useBookingSecret";
|
|
8
18
|
import { AddToCalendar } from "./AddToCalendar";
|
|
9
19
|
|
|
10
20
|
export interface CoachingConfirmationProps {
|
|
@@ -14,6 +24,11 @@ export interface CoachingConfirmationProps {
|
|
|
14
24
|
formatAmount?: (n: number) => string;
|
|
15
25
|
/** Where the "explore more" action links. */
|
|
16
26
|
explorePath?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Where "start over" goes when this browser has no credential for the booking
|
|
29
|
+
* — i.e. back to the session, to book again. Defaults to `/i/coaching/:slug`.
|
|
30
|
+
*/
|
|
31
|
+
restartPath?: string;
|
|
17
32
|
}
|
|
18
33
|
|
|
19
34
|
// e.g. "Fri, Jul 4 · 2:30 PM EDT" in the visitor's own timezone — matching how
|
|
@@ -30,15 +45,25 @@ export function CoachingConfirmation({
|
|
|
30
45
|
orderId,
|
|
31
46
|
formatAmount,
|
|
32
47
|
explorePath = "/i/coaching",
|
|
48
|
+
restartPath,
|
|
33
49
|
}: CoachingConfirmationProps) {
|
|
34
50
|
const theme = useForgeTheme();
|
|
35
51
|
const fmt = useAmountFormatter(formatAmount);
|
|
36
|
-
|
|
52
|
+
// The credential the booking flow stashed before the payment redirect.
|
|
53
|
+
// `resolved` gates the finalise: storage can only be read on the client, so
|
|
54
|
+
// firing before then would send the call without a secret it actually has and
|
|
55
|
+
// get a 404.
|
|
56
|
+
const { secret, resolved, isMissing } = useBookingSecret(orderId);
|
|
57
|
+
const { data, isLoading, error } = useCoachingBookingFinalize(
|
|
58
|
+
slug,
|
|
59
|
+
resolved ? orderId : undefined,
|
|
60
|
+
secret ?? undefined,
|
|
61
|
+
);
|
|
37
62
|
|
|
38
63
|
const primary = theme.colors.primary;
|
|
39
64
|
const text = theme.colors.text;
|
|
40
65
|
|
|
41
|
-
if (isLoading) {
|
|
66
|
+
if (!resolved || isLoading) {
|
|
42
67
|
return (
|
|
43
68
|
<ConfirmationStage>
|
|
44
69
|
<Loading label="Confirming your booking…" />
|
|
@@ -46,6 +71,22 @@ export function CoachingConfirmation({
|
|
|
46
71
|
);
|
|
47
72
|
}
|
|
48
73
|
|
|
74
|
+
// A booking with no credential in THIS browser is indistinguishable
|
|
75
|
+
// server-side from a stranger holding a leaked id, so it 404s on purpose.
|
|
76
|
+
// Explain that rather than implying the booking never existed.
|
|
77
|
+
if ((error || !data) && isMissing) {
|
|
78
|
+
return (
|
|
79
|
+
<ConfirmationStage>
|
|
80
|
+
<ConfirmationLostCheckout
|
|
81
|
+
title="We can’t confirm your booking in this browser"
|
|
82
|
+
body="Confirming a booking needs the checkout details saved in the tab you started in — and they aren’t here. That usually means the tab was closed, this link was opened on a different device or browser, or private browsing cleared them. If your payment went through you’ll still get your confirmation email, so check your inbox before booking again. Otherwise, pick a new time and start over."
|
|
83
|
+
restartPath={restartPath ?? `/i/coaching/${slug}`}
|
|
84
|
+
restartLabel="Pick a new time"
|
|
85
|
+
/>
|
|
86
|
+
</ConfirmationStage>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
49
90
|
const confirmed = !error && data?.status === "confirmed";
|
|
50
91
|
|
|
51
92
|
return (
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
+
import { AlertTriangle, ArrowRight } from "lucide-react";
|
|
2
3
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
4
|
+
import { readableTextOn } from "../theme/contrast";
|
|
3
5
|
|
|
4
6
|
// Shared building blocks for post-purchase "ticket" confirmation pages
|
|
5
7
|
// (coaching / event / course / checkout / invoice / payment-link). All colors
|
|
@@ -149,6 +151,98 @@ export function ConfirmationRow({
|
|
|
149
151
|
);
|
|
150
152
|
}
|
|
151
153
|
|
|
154
|
+
/**
|
|
155
|
+
* The "this browser can't confirm that purchase" screen.
|
|
156
|
+
*
|
|
157
|
+
* A checkout is now credentialed by a per-booking secret that is issued once
|
|
158
|
+
* and kept in `sessionStorage` (see `utils/bookingSecret`). If the buyer lands
|
|
159
|
+
* on a confirmation URL WITHOUT it — the original tab was closed, the link was
|
|
160
|
+
* forwarded or reopened on another device, storage is blocked — the server has
|
|
161
|
+
* no way to tell them apart from a stranger holding a leaked id, and answers
|
|
162
|
+
* 404 by design.
|
|
163
|
+
*
|
|
164
|
+
* That is a legitimate state, not a bug, so it gets its own screen rather than
|
|
165
|
+
* a bare "not found": say plainly what happened, reassure about the money (a
|
|
166
|
+
* completed payment is still completed — the receipt email is the proof, and
|
|
167
|
+
* this page failing does not undo it), and offer the one action that actually
|
|
168
|
+
* works, which is to start again.
|
|
169
|
+
*/
|
|
170
|
+
export function ConfirmationLostCheckout({
|
|
171
|
+
title,
|
|
172
|
+
body,
|
|
173
|
+
restartPath,
|
|
174
|
+
restartLabel,
|
|
175
|
+
}: {
|
|
176
|
+
title: string;
|
|
177
|
+
body: string;
|
|
178
|
+
restartPath: string;
|
|
179
|
+
restartLabel: string;
|
|
180
|
+
}) {
|
|
181
|
+
const t = useForgeTheme();
|
|
182
|
+
const primary = t.colors.primary;
|
|
183
|
+
const text = t.colors.text;
|
|
184
|
+
return (
|
|
185
|
+
<ConfirmationCard width={460}>
|
|
186
|
+
<div style={{ padding: "40px 32px 12px", textAlign: "center" }}>
|
|
187
|
+
<WarnSeal icon={<AlertTriangle size={34} color="#f59e0b" />} />
|
|
188
|
+
<p
|
|
189
|
+
className="cf-rise"
|
|
190
|
+
style={{
|
|
191
|
+
margin: "22px 0 6px",
|
|
192
|
+
fontSize: 12,
|
|
193
|
+
fontWeight: 700,
|
|
194
|
+
letterSpacing: "0.22em",
|
|
195
|
+
textTransform: "uppercase",
|
|
196
|
+
color: "#f59e0b",
|
|
197
|
+
animationDelay: "0.15s",
|
|
198
|
+
}}
|
|
199
|
+
>
|
|
200
|
+
Can’t confirm here
|
|
201
|
+
</p>
|
|
202
|
+
<h1
|
|
203
|
+
className="cf-rise"
|
|
204
|
+
style={{ fontSize: 27, fontWeight: 800, lineHeight: 1.14, margin: "0 0 10px", animationDelay: "0.22s" }}
|
|
205
|
+
>
|
|
206
|
+
{title}
|
|
207
|
+
</h1>
|
|
208
|
+
<p
|
|
209
|
+
className="cf-rise"
|
|
210
|
+
style={{
|
|
211
|
+
fontSize: 15,
|
|
212
|
+
lineHeight: 1.55,
|
|
213
|
+
color: alpha(text, 0.7),
|
|
214
|
+
margin: "0 auto",
|
|
215
|
+
maxWidth: 360,
|
|
216
|
+
animationDelay: "0.3s",
|
|
217
|
+
}}
|
|
218
|
+
>
|
|
219
|
+
{body}
|
|
220
|
+
</p>
|
|
221
|
+
</div>
|
|
222
|
+
<div className="cf-rise" style={{ padding: "22px 32px 32px", animationDelay: "0.38s" }}>
|
|
223
|
+
<a
|
|
224
|
+
href={restartPath}
|
|
225
|
+
style={{
|
|
226
|
+
display: "inline-flex",
|
|
227
|
+
alignItems: "center",
|
|
228
|
+
justifyContent: "center",
|
|
229
|
+
gap: 8,
|
|
230
|
+
width: "100%",
|
|
231
|
+
padding: "13px 18px",
|
|
232
|
+
borderRadius: t.cornerRadius,
|
|
233
|
+
background: primary,
|
|
234
|
+
color: readableTextOn(primary),
|
|
235
|
+
fontWeight: 700,
|
|
236
|
+
textDecoration: "none",
|
|
237
|
+
}}
|
|
238
|
+
>
|
|
239
|
+
{restartLabel} <ArrowRight size={16} />
|
|
240
|
+
</a>
|
|
241
|
+
</div>
|
|
242
|
+
</ConfirmationCard>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
152
246
|
const KEYFRAMES = `
|
|
153
247
|
@keyframes cf-rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
|
|
154
248
|
@keyframes cf-pop { 0% { transform: scale(.5); opacity: 0; } 55% { transform: scale(1.08); } 100% { transform: scale(1); opacity: 1; } }
|