@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
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { useInvoicePayment } from "../headless/invoice/useInvoicePayment";
|
|
2
2
|
import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
|
|
3
3
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
4
|
-
import {
|
|
4
|
+
import { getCurrencySymbol } from "../format/useFormatCurrency";
|
|
5
5
|
import { summarizeTaxQuote } from "../format/PriceDisplay";
|
|
6
6
|
import { Loading } from "./Loading";
|
|
7
7
|
|
|
8
8
|
export interface InvoicePaymentProps {
|
|
9
9
|
invoiceId: string;
|
|
10
|
-
/**
|
|
10
|
+
/** Optional presentation override. Left unset, amounts render in the
|
|
11
|
+
* invoice's OWN currency, unconverted — see `money` below. */
|
|
11
12
|
formatAmount?: (n: number) => string;
|
|
12
13
|
/** Return path after payment. Forwarded to `useInvoicePayment`. */
|
|
13
14
|
returnPath?: string;
|
|
@@ -24,7 +25,6 @@ const formatDate = (value: string | number | Date): string =>
|
|
|
24
25
|
*/
|
|
25
26
|
export function InvoicePayment({ invoiceId, formatAmount, returnPath }: InvoicePaymentProps) {
|
|
26
27
|
const theme = useForgeTheme();
|
|
27
|
-
const fmt = useAmountFormatter(formatAmount);
|
|
28
28
|
const renderPayment = usePaymentRenderer();
|
|
29
29
|
const { invoice, downloadPdf, clientSecret, returnUrl, isStarting, error, taxQuote } = useInvoicePayment(invoiceId, {
|
|
30
30
|
returnPath,
|
|
@@ -35,7 +35,27 @@ export function InvoicePayment({ invoiceId, formatAmount, returnPath }: InvoiceP
|
|
|
35
35
|
|
|
36
36
|
const inv = invoice.data;
|
|
37
37
|
const isPaid = inv.status === "paid" || !!inv.paidAt;
|
|
38
|
-
|
|
38
|
+
/**
|
|
39
|
+
* An invoice is a DEBT of a stated amount in a stated currency, not a price
|
|
40
|
+
* list — so it must show what the client owes, in the currency they owe it
|
|
41
|
+
* in, and never a converted approximation.
|
|
42
|
+
*
|
|
43
|
+
* This used to run every figure through the storefront formatter, whose job
|
|
44
|
+
* is to convert a tenant-currency price into whatever currency the VISITOR
|
|
45
|
+
* has selected. On an invoice that turned a £120.00 bill into "$159.91" —
|
|
46
|
+
* a different number, in a different currency, from both the document the
|
|
47
|
+
* client was emailed and the amount the pay link actually charges.
|
|
48
|
+
*
|
|
49
|
+
* `formatAmount` is still honoured for a caller that wants its own
|
|
50
|
+
* presentation; it just is not the storefront converter any more.
|
|
51
|
+
*/
|
|
52
|
+
const money = (n: number) =>
|
|
53
|
+
formatAmount
|
|
54
|
+
? formatAmount(n)
|
|
55
|
+
: `${getCurrencySymbol(inv.currency)}${Number(Number(n).toFixed(2)).toLocaleString("en-US", {
|
|
56
|
+
minimumFractionDigits: 2,
|
|
57
|
+
maximumFractionDigits: 2,
|
|
58
|
+
})}`;
|
|
39
59
|
|
|
40
60
|
const handleDownload = async () => {
|
|
41
61
|
const blob = await downloadPdf.mutateAsync();
|
|
@@ -72,15 +92,33 @@ export function InvoicePayment({ invoiceId, formatAmount, returnPath }: InvoiceP
|
|
|
72
92
|
</div>
|
|
73
93
|
|
|
74
94
|
<div style={{ fontSize: 14, opacity: 0.85, marginBottom: 16 }}>
|
|
75
|
-
<div>
|
|
76
|
-
|
|
95
|
+
<div>
|
|
96
|
+
Billed to: {inv.clientName}
|
|
97
|
+
{inv.clientCompany ? ` · ${inv.clientCompany}` : ""}
|
|
98
|
+
</div>
|
|
99
|
+
<div>
|
|
100
|
+
Issued: {formatDate(inv.issueDate)}
|
|
101
|
+
{inv.dueDate ? ` · Due: ${formatDate(inv.dueDate)}` : ""}
|
|
102
|
+
</div>
|
|
77
103
|
</div>
|
|
78
104
|
|
|
79
|
-
<div
|
|
105
|
+
<div
|
|
106
|
+
style={{
|
|
107
|
+
border: `1px solid ${theme.colors.primary}20`,
|
|
108
|
+
borderRadius: theme.cornerRadius,
|
|
109
|
+
overflow: "hidden",
|
|
110
|
+
marginBottom: 16,
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
80
113
|
{inv.lineItems.map((li) => (
|
|
81
114
|
<div
|
|
82
115
|
key={li.id}
|
|
83
|
-
style={{
|
|
116
|
+
style={{
|
|
117
|
+
display: "flex",
|
|
118
|
+
justifyContent: "space-between",
|
|
119
|
+
padding: "10px 14px",
|
|
120
|
+
borderBottom: `1px solid ${theme.colors.primary}10`,
|
|
121
|
+
}}
|
|
84
122
|
>
|
|
85
123
|
<span>
|
|
86
124
|
{li.description}
|
|
@@ -94,7 +132,15 @@ export function InvoicePayment({ invoiceId, formatAmount, returnPath }: InvoiceP
|
|
|
94
132
|
// columns (no re-quote) — surfaced via the start-payment taxQuote.
|
|
95
133
|
const taxSummary = summarizeTaxQuote(taxQuote, money);
|
|
96
134
|
return taxSummary?.mode === "exclusive" ? (
|
|
97
|
-
<div
|
|
135
|
+
<div
|
|
136
|
+
style={{
|
|
137
|
+
display: "flex",
|
|
138
|
+
justifyContent: "space-between",
|
|
139
|
+
padding: "10px 14px",
|
|
140
|
+
borderBottom: `1px solid ${theme.colors.primary}10`,
|
|
141
|
+
opacity: 0.8,
|
|
142
|
+
}}
|
|
143
|
+
>
|
|
98
144
|
<span>Tax</span>
|
|
99
145
|
<span>{taxSummary.formattedTax}</span>
|
|
100
146
|
</div>
|
|
@@ -107,7 +153,9 @@ export function InvoicePayment({ invoiceId, formatAmount, returnPath }: InvoiceP
|
|
|
107
153
|
{(() => {
|
|
108
154
|
const taxSummary = summarizeTaxQuote(taxQuote, money);
|
|
109
155
|
return taxSummary?.mode === "inclusive" ? (
|
|
110
|
-
<span
|
|
156
|
+
<span
|
|
157
|
+
style={{ display: "block", fontSize: 12, fontWeight: 400, color: theme.colors.text, opacity: 0.65 }}
|
|
158
|
+
>
|
|
111
159
|
{taxSummary.label}
|
|
112
160
|
</span>
|
|
113
161
|
) : null;
|
|
@@ -132,7 +180,13 @@ export function InvoicePayment({ invoiceId, formatAmount, returnPath }: InvoiceP
|
|
|
132
180
|
mode: "redirect",
|
|
133
181
|
})
|
|
134
182
|
) : (
|
|
135
|
-
<>
|
|
183
|
+
<>
|
|
184
|
+
{isStarting ? (
|
|
185
|
+
<Loading label="Preparing payment…" size={22} />
|
|
186
|
+
) : (
|
|
187
|
+
<p style={{ opacity: 0.7 }}>Payment is not available right now.</p>
|
|
188
|
+
)}
|
|
189
|
+
</>
|
|
136
190
|
)}
|
|
137
191
|
</div>
|
|
138
192
|
)}
|
|
@@ -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,
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { rememberBookingSecret, readBookingSecret, forgetBookingSecret } from "../bookingSecret";
|
|
3
|
+
|
|
4
|
+
// A tiny in-memory `sessionStorage`. The real one isn't available in a node
|
|
5
|
+
// environment, and half the behaviour under test is precisely what happens when
|
|
6
|
+
// it is and isn't there.
|
|
7
|
+
function fakeStorage(): Storage {
|
|
8
|
+
const map = new Map<string, string>();
|
|
9
|
+
return {
|
|
10
|
+
get length() {
|
|
11
|
+
return map.size;
|
|
12
|
+
},
|
|
13
|
+
key: (i: number) => Array.from(map.keys())[i] ?? null,
|
|
14
|
+
getItem: (k: string) => map.get(k) ?? null,
|
|
15
|
+
setItem: (k: string, v: string) => void map.set(k, v),
|
|
16
|
+
removeItem: (k: string) => void map.delete(k),
|
|
17
|
+
clear: () => map.clear(),
|
|
18
|
+
} as Storage;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const g = globalThis as unknown as { window?: unknown };
|
|
22
|
+
|
|
23
|
+
function withStorage(storage: Storage | null) {
|
|
24
|
+
g.window = storage
|
|
25
|
+
? { sessionStorage: storage }
|
|
26
|
+
: {
|
|
27
|
+
get sessionStorage(): Storage {
|
|
28
|
+
// What a browser does when storage is blocked (private mode, or a
|
|
29
|
+
// cookie policy that denies it) — a throwing getter, not undefined.
|
|
30
|
+
throw new Error("access denied");
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const SECRET_A = "a".repeat(64);
|
|
36
|
+
const SECRET_B = "b".repeat(64);
|
|
37
|
+
|
|
38
|
+
describe("booking secret storage", () => {
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
delete g.window;
|
|
41
|
+
vi.restoreAllMocks();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("with storage", () => {
|
|
45
|
+
let storage: Storage;
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
storage = fakeStorage();
|
|
48
|
+
withStorage(storage);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("round-trips a secret for its booking", () => {
|
|
52
|
+
rememberBookingSecret("booking-1", SECRET_A);
|
|
53
|
+
expect(readBookingSecret("booking-1")).toBe(SECRET_A);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("keys by bookingId so two checkouts in one tab don't clobber each other", () => {
|
|
57
|
+
// A buyer can have a course and a coaching checkout open at once. A single
|
|
58
|
+
// shared key would leave one of them holding the other's credential, and
|
|
59
|
+
// the secret is unrecoverable once overwritten.
|
|
60
|
+
rememberBookingSecret("booking-1", SECRET_A);
|
|
61
|
+
rememberBookingSecret("booking-2", SECRET_B);
|
|
62
|
+
expect(readBookingSecret("booking-1")).toBe(SECRET_A);
|
|
63
|
+
expect(readBookingSecret("booking-2")).toBe(SECRET_B);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("returns null for a booking it never stored, and for no booking at all", () => {
|
|
67
|
+
expect(readBookingSecret("unknown")).toBeNull();
|
|
68
|
+
expect(readBookingSecret(undefined)).toBeNull();
|
|
69
|
+
expect(readBookingSecret(null)).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("ignores a missing secret rather than storing an empty credential", () => {
|
|
73
|
+
// The backend omits the field for grandfathered bookings. Writing "" here
|
|
74
|
+
// would later read back as a present-but-wrong secret.
|
|
75
|
+
rememberBookingSecret("booking-1", undefined);
|
|
76
|
+
rememberBookingSecret("booking-2", null);
|
|
77
|
+
rememberBookingSecret("booking-3", "");
|
|
78
|
+
expect(readBookingSecret("booking-1")).toBeNull();
|
|
79
|
+
expect(readBookingSecret("booking-2")).toBeNull();
|
|
80
|
+
expect(readBookingSecret("booking-3")).toBeNull();
|
|
81
|
+
expect(storage.length).toBe(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("forgets one booking without touching the others", () => {
|
|
85
|
+
rememberBookingSecret("booking-1", SECRET_A);
|
|
86
|
+
rememberBookingSecret("booking-2", SECRET_B);
|
|
87
|
+
forgetBookingSecret("booking-1");
|
|
88
|
+
expect(readBookingSecret("booking-1")).toBeNull();
|
|
89
|
+
expect(readBookingSecret("booking-2")).toBe(SECRET_B);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("does not put the secret under a guessable bare-id key", () => {
|
|
93
|
+
// Namespacing keeps it out of the way of anything else the host site
|
|
94
|
+
// keeps in sessionStorage under the raw booking id.
|
|
95
|
+
rememberBookingSecret("booking-1", SECRET_A);
|
|
96
|
+
expect(storage.getItem("booking-1")).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("without storage", () => {
|
|
101
|
+
it("degrades quietly when storage throws — a live checkout must not break", () => {
|
|
102
|
+
withStorage(null);
|
|
103
|
+
expect(() => rememberBookingSecret("booking-1", SECRET_A)).not.toThrow();
|
|
104
|
+
expect(readBookingSecret("booking-1")).toBeNull();
|
|
105
|
+
expect(() => forgetBookingSecret("booking-1")).not.toThrow();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("degrades quietly during SSR, where there is no window at all", () => {
|
|
109
|
+
delete g.window;
|
|
110
|
+
expect(() => rememberBookingSecret("booking-1", SECRET_A)).not.toThrow();
|
|
111
|
+
expect(readBookingSecret("booking-1")).toBeNull();
|
|
112
|
+
expect(() => forgetBookingSecret("booking-1")).not.toThrow();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Per-booking checkout credential, held across the payment-provider redirect.
|
|
2
|
+
//
|
|
3
|
+
// ## What this is
|
|
4
|
+
//
|
|
5
|
+
// Course and coaching bookings used to be addressable by their id alone. That
|
|
6
|
+
// id is a random uuid but it is NOT a secret: it rides in the finalise URL, so
|
|
7
|
+
// it reaches browser history, `Referer` headers, shared links and support
|
|
8
|
+
// screenshots. Anyone who learned one could move the purchase onto their own
|
|
9
|
+
// address. The backend now mints a per-booking secret when the booking is
|
|
10
|
+
// created and requires it on `booking/update`, `start-payment` and `finalize`.
|
|
11
|
+
//
|
|
12
|
+
// ## Why sessionStorage, and NOT the URL
|
|
13
|
+
//
|
|
14
|
+
// The confirmation page is reached by a redirect back from Stripe/Paystack, so
|
|
15
|
+
// every bit of in-memory React state is gone by the time it renders. `bookingId`
|
|
16
|
+
// survives that because it is a query param on the return URL — and that is
|
|
17
|
+
// exactly the leak the secret exists to close, so the secret must not travel the
|
|
18
|
+
// same way. It also rules out anything the server sees: a query param is logged,
|
|
19
|
+
// a cookie is sent on every request to the origin.
|
|
20
|
+
//
|
|
21
|
+
// `sessionStorage` is the narrowest thing that still survives a full-page
|
|
22
|
+
// navigation: same tab, same origin, unreadable by another site, never
|
|
23
|
+
// transmitted, and destroyed when the tab closes. A discarded checkout does not
|
|
24
|
+
// outlive the session that started it, which matches the lifetime of the secret
|
|
25
|
+
// itself. `localStorage` would persist a live purchase credential on a shared
|
|
26
|
+
// machine indefinitely, for no benefit — the redirect never leaves the tab.
|
|
27
|
+
//
|
|
28
|
+
// Keyed by bookingId so two checkouts open in the same tab (a course and a
|
|
29
|
+
// coaching session, or two courses in sequence) cannot overwrite each other's
|
|
30
|
+
// credential.
|
|
31
|
+
//
|
|
32
|
+
// ## The secret is returned exactly once
|
|
33
|
+
//
|
|
34
|
+
// The raw value comes back only from the call that CREATES the booking and can
|
|
35
|
+
// never be re-read. If it is lost, that booking is unreachable and the buyer
|
|
36
|
+
// must start again — which is why every write here is best-effort but every
|
|
37
|
+
// read is treated as load-bearing by the callers.
|
|
38
|
+
|
|
39
|
+
const KEY_PREFIX = "tn:booking_secret:";
|
|
40
|
+
|
|
41
|
+
function safeStorage(): Storage | null {
|
|
42
|
+
if (typeof window === "undefined") return null;
|
|
43
|
+
try {
|
|
44
|
+
return window.sessionStorage;
|
|
45
|
+
} catch {
|
|
46
|
+
// Private mode / storage disabled by policy.
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const keyFor = (bookingId: string) => `${KEY_PREFIX}${bookingId}`;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Persist the secret for `bookingId`. Call this the moment a booking is created
|
|
55
|
+
* — the value is never returned again.
|
|
56
|
+
*
|
|
57
|
+
* Best-effort: a storage failure (quota, disabled storage) must not break the
|
|
58
|
+
* checkout that is already in flight. The in-memory copy carries the rest of
|
|
59
|
+
* THIS page's calls; only the post-redirect leg degrades, and that surfaces as
|
|
60
|
+
* the "we lost track of your checkout" path rather than a bare not-found.
|
|
61
|
+
*/
|
|
62
|
+
export function rememberBookingSecret(bookingId: string, secret?: string | null): void {
|
|
63
|
+
if (!bookingId || !secret) return;
|
|
64
|
+
const storage = safeStorage();
|
|
65
|
+
if (!storage) return;
|
|
66
|
+
try {
|
|
67
|
+
storage.setItem(keyFor(bookingId), secret);
|
|
68
|
+
} catch {
|
|
69
|
+
// See above — never throw into a live checkout.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The stored secret for `bookingId`, or null when it was never stored / is gone. */
|
|
74
|
+
export function readBookingSecret(bookingId?: string | null): string | null {
|
|
75
|
+
if (!bookingId) return null;
|
|
76
|
+
const storage = safeStorage();
|
|
77
|
+
if (!storage) return null;
|
|
78
|
+
try {
|
|
79
|
+
return storage.getItem(keyFor(bookingId));
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Drop the secret for a booking that no longer exists (e.g. a released coaching
|
|
87
|
+
* hold). Deliberately NOT called on a successful finalise: that page is
|
|
88
|
+
* idempotent and gets reloaded and polled, and clearing the credential would
|
|
89
|
+
* turn a refresh into a not-found.
|
|
90
|
+
*/
|
|
91
|
+
export function forgetBookingSecret(bookingId?: string | null): void {
|
|
92
|
+
if (!bookingId) return;
|
|
93
|
+
const storage = safeStorage();
|
|
94
|
+
if (!storage) return;
|
|
95
|
+
try {
|
|
96
|
+
storage.removeItem(keyFor(bookingId));
|
|
97
|
+
} catch {
|
|
98
|
+
// Nothing to clear.
|
|
99
|
+
}
|
|
100
|
+
}
|