@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.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/src/contexts/CartContext.tsx +25 -1
  3. package/src/data/queries/useCheckouts.ts +14 -0
  4. package/src/data/queries/useCoachingAvailability.ts +13 -0
  5. package/src/data/queries/useCourseAccess.ts +81 -0
  6. package/src/data/queries/useCourses.ts +19 -1
  7. package/src/data/queries/useEvents.ts +6 -0
  8. package/src/data/queries/useFinalize.ts +24 -4
  9. package/src/index.ts +7 -0
  10. package/src/types/models.ts +41 -1
  11. package/src/ui/format/_tests/pwyw.spec.ts +157 -0
  12. package/src/ui/format/pwyw.ts +95 -0
  13. package/src/ui/headless/booking/useBookingSecret.ts +41 -0
  14. package/src/ui/headless/coaching/useCoachingBooking.ts +26 -3
  15. package/src/ui/headless/course/_tests/courseAccessGate.spec.ts +135 -0
  16. package/src/ui/headless/course/useCourseCheckout.ts +18 -1
  17. package/src/ui/headless/course/useCourseClassroom.ts +242 -0
  18. package/src/ui/headless/event/useEventCheckout.ts +96 -6
  19. package/src/ui/headless/index.ts +11 -0
  20. package/src/ui/index.ts +17 -0
  21. package/src/ui/styled/AccountDashboard.tsx +7 -0
  22. package/src/ui/styled/Cart.tsx +11 -8
  23. package/src/ui/styled/CartLineOptions.tsx +107 -0
  24. package/src/ui/styled/Checkout.tsx +5 -0
  25. package/src/ui/styled/CheckoutConfirmation.tsx +4 -6
  26. package/src/ui/styled/CoachingBooking.tsx +9 -1
  27. package/src/ui/styled/CoachingConfirmation.tsx +44 -3
  28. package/src/ui/styled/Confirmation.tsx +94 -0
  29. package/src/ui/styled/CourseAccess.tsx +191 -51
  30. package/src/ui/styled/CourseCheckout.tsx +9 -1
  31. package/src/ui/styled/CourseConfirmation.tsx +42 -3
  32. package/src/ui/styled/EventTickets.tsx +114 -0
  33. package/src/ui/styled/InvoicePayment.tsx +65 -11
  34. package/src/ui/styled/ProductDetail.tsx +8 -0
  35. package/src/utils/_tests/bookingSecret.spec.ts +115 -0
  36. 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 { useAmountFormatter } from "../format/useFormatCurrency";
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
- /** Currency formatter (e.g. `(n) => formatCurrency(n, CURRENCY)`). Falls back to the invoice's own currency. */
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
- const money = (n: number) => fmt(n, inv.currency);
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>Billed to: {inv.clientName}{inv.clientCompany ? ` · ${inv.clientCompany}` : ""}</div>
76
- <div>Issued: {formatDate(inv.issueDate)}{inv.dueDate ? ` · Due: ${formatDate(inv.dueDate)}` : ""}</div>
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 style={{ border: `1px solid ${theme.colors.primary}20`, borderRadius: theme.cornerRadius, overflow: "hidden", marginBottom: 16 }}>
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={{ display: "flex", justifyContent: "space-between", padding: "10px 14px", borderBottom: `1px solid ${theme.colors.primary}10` }}
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 style={{ display: "flex", justifyContent: "space-between", padding: "10px 14px", borderBottom: `1px solid ${theme.colors.primary}10`, opacity: 0.8 }}>
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 style={{ display: "block", fontSize: 12, fontWeight: 400, color: theme.colors.text, opacity: 0.65 }}>
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
- <>{isStarting ? <Loading label="Preparing payment…" size={22} /> : <p style={{ opacity: 0.7 }}>Payment is not available right now.</p>}</>
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
+ }