@tribe-nest/forge 3.17.0 → 3.20.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.
@@ -1,85 +1,216 @@
1
- import { useRef, useState } from "react";
2
- import { useCourseAccess, useUpdateCourseProgress } from "../../data/queries/useCourseAccess";
3
- import { useForgeTheme } from "../theme/ForgeThemeProvider";
1
+ import type { CSSProperties } from "react";
2
+ import { useThemeTokens } from "../theme/ForgeThemeProvider";
3
+ import { useCourseClassroom, type UseCourseClassroomOptions } from "../headless/course/useCourseClassroom";
4
4
  import { Loading } from "./Loading";
5
5
 
6
- export interface CourseAccessProps {
6
+ export interface CourseAccessProps extends UseCourseClassroomOptions {
7
7
  /** Course access id (route param). */
8
8
  accessId: string;
9
+ /** SPA navigation adapter for the sign-in / secure-access links. Defaults to a full-page visit. */
10
+ navigate?: (href: string) => void;
11
+ className?: string;
12
+ style?: CSSProperties;
9
13
  }
10
14
 
11
- /** Enrolled course player — module/lesson sidebar, video progress tracking. Built on `useCourseAccess` + `useUpdateCourseProgress`. */
12
- export function CourseAccess({ accessId }: CourseAccessProps) {
13
- const theme = useForgeTheme();
14
- const { data, isLoading, error } = useCourseAccess(accessId);
15
- const updateProgress = useUpdateCourseProgress(accessId);
16
- const lastSent = useRef(0);
15
+ /**
16
+ * Enrolled course player module/lesson sidebar, video progress tracking, and
17
+ * the account-binding gate that stands in front of both. Built on the headless
18
+ * `useCourseClassroom`.
19
+ *
20
+ * The three states worth naming, because each one strands somebody if it is
21
+ * drawn as the others:
22
+ *
23
+ * - **401** — a bound grant opened with no session. Almost always the buyer,
24
+ * on an old email link, on a device that never signed in. Gets a sign-in
25
+ * button that returns HERE, never a dead end.
26
+ * - **403** — a bound grant opened as somebody else. Gets "this belongs to a
27
+ * different account" and a way OUT of the current session. Never the word
28
+ * "sign in", which they have already done.
29
+ * - **legacy** — no `account_id`, so the link alone is the credential. Opens
30
+ * normally, with a dismissible note offering to put it behind an account.
31
+ */
32
+ export function CourseAccess({
33
+ accessId,
34
+ navigate,
35
+ className,
36
+ style,
37
+ ...classroomOptions
38
+ }: CourseAccessProps) {
39
+ const t = useThemeTokens();
40
+ const classroom = useCourseClassroom(accessId, classroomOptions);
41
+ const go = navigate ?? ((href: string) => window.location.assign(href));
17
42
 
18
- const modules = data?.course.modules ?? [];
19
- const allLessons = modules.flatMap((m) => m.lessons);
20
- const initialLessonId = data?.access.currentLessonId || allLessons[0]?.id || null;
21
- const [selectedLessonId, setSelectedLessonId] = useState<string | null>(initialLessonId);
43
+ if (classroom.isLoading) return <Loading fullPage />;
22
44
 
23
- if (isLoading) return <Loading fullPage />;
24
- if (error || !data) return <p style={{ color: theme.colors.text }}>Course access not found.</p>;
45
+ const panel: CSSProperties = {
46
+ maxWidth: 520,
47
+ margin: "0 auto",
48
+ padding: 28,
49
+ borderRadius: t.cornerRadius,
50
+ border: `1px solid ${t.border}`,
51
+ background: t.surface,
52
+ color: t.text,
53
+ textAlign: "center",
54
+ display: "flex",
55
+ flexDirection: "column",
56
+ gap: 12,
57
+ fontFamily: t.fontFamily,
58
+ };
59
+
60
+ const primaryButton: CSSProperties = {
61
+ alignSelf: "center",
62
+ padding: "10px 20px",
63
+ borderRadius: t.cornerRadius,
64
+ border: "none",
65
+ background: t.primary,
66
+ color: t.textPrimary,
67
+ fontSize: 14,
68
+ fontWeight: 700,
69
+ cursor: "pointer",
70
+ textDecoration: "none",
71
+ };
72
+
73
+ const gate = classroom.gate;
74
+ if (gate) {
75
+ return (
76
+ <div className={className} style={{ ...panel, ...style }}>
77
+ <h1 style={{ fontSize: 20, fontWeight: 800, fontFamily: t.headingFontFamily || t.fontFamily }}>
78
+ {gate.title}
79
+ </h1>
80
+ <p style={{ fontSize: 14, color: t.muted, lineHeight: 1.6 }}>{gate.body}</p>
81
+
82
+ {/* Naming the session they are in is what makes "wrong account"
83
+ actionable — otherwise the reader has no idea which of their
84
+ addresses they are currently signed in with. */}
85
+ {gate.kind === "wrong_account" && classroom.signedInEmail && (
86
+ <p style={{ fontSize: 13, color: t.muted }}>
87
+ Signed in as <strong style={{ color: t.text }}>{classroom.signedInEmail}</strong>
88
+ </p>
89
+ )}
90
+
91
+ {gate.actionLabel && gate.actionHref && (
92
+ <button type="button" style={primaryButton} onClick={() => go(gate.actionHref!)}>
93
+ {gate.actionLabel}
94
+ </button>
95
+ )}
25
96
 
26
- const selected = allLessons.find((l) => l.id === selectedLessonId) ?? allLessons[0];
97
+ {gate.actionLabel && !gate.actionHref && (
98
+ <button type="button" style={primaryButton} onClick={() => void classroom.signOutAndRetry()}>
99
+ {gate.actionLabel}
100
+ </button>
101
+ )}
102
+ </div>
103
+ );
104
+ }
105
+
106
+ const data = classroom.data;
107
+ if (!data) return null;
108
+
109
+ const selected = classroom.selected;
27
110
  const videoUrl = selected?.media?.find((m) => m.type === "video")?.url;
28
111
  const files = selected?.media?.filter((m) => m.type !== "video") ?? [];
29
- const completedCount = allLessons.filter((l) => l.progress?.isCompleted).length;
30
-
31
- const handleTimeUpdate = (e: React.SyntheticEvent<HTMLVideoElement>) => {
32
- const el = e.currentTarget;
33
- if (!selected || !el.duration) return;
34
- const now = Date.now();
35
- if (now - lastSent.current < 1000) return;
36
- lastSent.current = now;
37
- const percent = (el.currentTime / el.duration) * 100;
38
- const isCompleted = percent >= 95;
39
- updateProgress.mutate({ lessonId: selected.id, videoProgress: percent, isCompleted });
40
- };
112
+ const securePrompt = classroom.securePrompt;
41
113
 
42
114
  return (
43
115
  <div
116
+ className={className}
44
117
  style={{
45
118
  display: "flex",
46
119
  flexDirection: "column",
47
120
  gap: 24,
48
121
  maxWidth: 1100,
49
122
  margin: "0 auto",
50
- color: theme.colors.text,
123
+ color: t.text,
124
+ fontFamily: t.fontFamily,
125
+ ...style,
51
126
  }}
52
127
  >
128
+ {/* Legacy grant, opened fine. An offer, not an obstacle — inline, muted,
129
+ dismissible, and never in front of the course the reader came for. */}
130
+ {securePrompt && (
131
+ <div
132
+ style={{
133
+ display: "flex",
134
+ flexWrap: "wrap",
135
+ alignItems: "center",
136
+ gap: 12,
137
+ padding: "10px 14px",
138
+ borderRadius: t.cornerRadius,
139
+ border: `1px solid ${t.border}`,
140
+ background: `${t.primary}0d`,
141
+ fontSize: 13,
142
+ }}
143
+ >
144
+ <span style={{ flex: "1 1 260px", color: t.muted, lineHeight: 1.5 }}>
145
+ Anyone with this link can open your course. Create an account with{" "}
146
+ <strong style={{ color: t.text }}>{securePrompt.email}</strong> to keep it to yourself.
147
+ </span>
148
+ <button
149
+ type="button"
150
+ onClick={() => go(securePrompt.href)}
151
+ style={{
152
+ padding: "6px 14px",
153
+ borderRadius: t.cornerRadius,
154
+ border: `1px solid ${t.primary}`,
155
+ background: "transparent",
156
+ color: t.primary,
157
+ fontSize: 13,
158
+ fontWeight: 600,
159
+ cursor: "pointer",
160
+ }}
161
+ >
162
+ Secure my access
163
+ </button>
164
+ <button
165
+ type="button"
166
+ onClick={classroom.dismissSecurePrompt}
167
+ aria-label="Dismiss"
168
+ style={{
169
+ padding: "6px 10px",
170
+ border: "none",
171
+ background: "transparent",
172
+ color: t.muted,
173
+ fontSize: 13,
174
+ cursor: "pointer",
175
+ }}
176
+ >
177
+ Not now
178
+ </button>
179
+ </div>
180
+ )}
181
+
53
182
  <header>
54
- <h1 style={{ fontSize: 26, fontWeight: 800 }}>{data.course.title}</h1>
55
- <p style={{ fontSize: 14, opacity: 0.7 }}>
56
- {completedCount} of {allLessons.length} lessons completed
183
+ <h1 style={{ fontSize: 26, fontWeight: 800, fontFamily: t.headingFontFamily || t.fontFamily }}>
184
+ {data.course.title}
185
+ </h1>
186
+ <p style={{ fontSize: 14, color: t.muted }}>
187
+ {classroom.completedCount} of {classroom.lessons.length} lessons completed
57
188
  </p>
58
189
  </header>
59
190
 
60
191
  <div style={{ display: "flex", gap: 24, flexWrap: "wrap" }}>
61
192
  {/* Sidebar */}
62
193
  <aside style={{ flex: "1 1 260px", minWidth: 240 }}>
63
- {modules.map((module) => (
194
+ {classroom.modules.map((module) => (
64
195
  <div key={module.id} style={{ marginBottom: 16 }}>
65
196
  <h2 style={{ fontSize: 14, fontWeight: 700, marginBottom: 6 }}>{module.title}</h2>
66
197
  <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
67
- {module.lessons.map((lesson) => {
198
+ {(module.lessons ?? []).map((lesson) => {
68
199
  const isSelected = lesson.id === selected?.id;
69
200
  const done = lesson.progress?.isCompleted;
70
201
  return (
71
202
  <button
72
203
  key={lesson.id}
73
- onClick={() => setSelectedLessonId(lesson.id)}
204
+ onClick={() => classroom.selectLesson(lesson.id)}
74
205
  style={{
75
206
  textAlign: "left",
76
207
  padding: "8px 10px",
77
- borderRadius: theme.cornerRadius,
208
+ borderRadius: t.cornerRadius,
78
209
  border: "none",
79
210
  cursor: "pointer",
80
211
  fontSize: 14,
81
- background: isSelected ? `${theme.colors.primary}20` : "transparent",
82
- color: isSelected ? theme.colors.primary : theme.colors.text,
212
+ background: isSelected ? `${t.primary}20` : "transparent",
213
+ color: isSelected ? t.primary : t.text,
83
214
  fontWeight: isSelected ? 600 : 400,
84
215
  }}
85
216
  >
@@ -103,8 +234,8 @@ export function CourseAccess({ accessId }: CourseAccessProps) {
103
234
  src={videoUrl}
104
235
  controls
105
236
  playsInline
106
- onTimeUpdate={handleTimeUpdate}
107
- style={{ width: "100%", aspectRatio: "16/9", background: "#000", borderRadius: theme.cornerRadius }}
237
+ onTimeUpdate={(e) => classroom.reportProgress(e.currentTarget.currentTime, e.currentTarget.duration)}
238
+ style={{ width: "100%", aspectRatio: "16/9", background: "#000", borderRadius: t.cornerRadius }}
108
239
  />
109
240
  ) : (
110
241
  <div
@@ -114,16 +245,25 @@ export function CourseAccess({ accessId }: CourseAccessProps) {
114
245
  display: "flex",
115
246
  alignItems: "center",
116
247
  justifyContent: "center",
117
- background: `${theme.colors.text}0d`,
118
- borderRadius: theme.cornerRadius,
119
- opacity: 0.7,
248
+ background: `${t.text}0d`,
249
+ borderRadius: t.cornerRadius,
250
+ color: t.muted,
120
251
  }}
121
252
  >
122
253
  No video available for this lesson
123
254
  </div>
124
255
  )}
125
256
 
126
- <h2 style={{ fontSize: 20, fontWeight: 700, margin: "16px 0 8px" }}>{selected.title}</h2>
257
+ <h2
258
+ style={{
259
+ fontSize: 20,
260
+ fontWeight: 700,
261
+ margin: "16px 0 8px",
262
+ fontFamily: t.headingFontFamily || t.fontFamily,
263
+ }}
264
+ >
265
+ {selected.title}
266
+ </h2>
127
267
 
128
268
  {files.length > 0 && (
129
269
  <div style={{ marginBottom: 16 }}>
@@ -139,16 +279,16 @@ export function CourseAccess({ accessId }: CourseAccessProps) {
139
279
  display: "flex",
140
280
  justifyContent: "space-between",
141
281
  padding: "10px 12px",
142
- borderRadius: theme.cornerRadius,
143
- border: `1px solid ${theme.colors.primary}40`,
144
- background: `${theme.colors.primary}10`,
145
- color: theme.colors.text,
282
+ borderRadius: t.cornerRadius,
283
+ border: `1px solid ${t.border}`,
284
+ background: `${t.primary}10`,
285
+ color: t.text,
146
286
  textDecoration: "none",
147
287
  fontSize: 14,
148
288
  }}
149
289
  >
150
290
  <span>{file.filename}</span>
151
- <span style={{ opacity: 0.6 }}>{(file.size / 1024 / 1024).toFixed(2)} MB</span>
291
+ <span style={{ color: t.muted }}>{(file.size / 1024 / 1024).toFixed(2)} MB</span>
152
292
  </a>
153
293
  ))}
154
294
  </div>
@@ -160,7 +300,7 @@ export function CourseAccess({ accessId }: CourseAccessProps) {
160
300
  )}
161
301
  </>
162
302
  ) : (
163
- <p style={{ opacity: 0.7 }}>No lesson selected.</p>
303
+ <p style={{ color: t.muted }}>No lesson selected.</p>
164
304
  )}
165
305
  </main>
166
306
  </div>
@@ -28,7 +28,15 @@ export interface CourseCheckoutProps {
28
28
  formatAmount?: (amount: number) => string;
29
29
  /** Optional payment renderer — falls back to a registered <ForgePaymentProvider>. */
30
30
  renderPayment?: (props: PaymentRenderProps) => ReactNode;
31
- /** Stripe return URL for a PAID enrollment. Default `/i/courses/:slug/finalise`. */
31
+ /**
32
+ * Stripe return URL for a PAID enrollment. Default `/i/courses/:slug/finalise`.
33
+ *
34
+ * Put ONLY the bookingId in this URL. The booking's secret is handled for you
35
+ * by the hook (stashed in `sessionStorage`, read back by `CourseConfirmation`)
36
+ * — putting it in the URL would hand it to history, referrers and shared
37
+ * links, which is the exact leak it exists to close. See
38
+ * `utils/bookingSecret`.
39
+ */
32
40
  finalisePath?: (slug: string, bookingId: string) => string;
33
41
  /** Host-owned navigation on FREE completion (e.g. router navigate). */
34
42
  onComplete?: (info: { slug: string; bookingId: string }) => void;
@@ -3,9 +3,19 @@ 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 { ConfirmationStage, ConfirmationCard, CheckSeal, WarnSeal, Perforation, ConfirmationRow, alpha } from "./Confirmation";
6
+ import {
7
+ ConfirmationStage,
8
+ ConfirmationCard,
9
+ CheckSeal,
10
+ WarnSeal,
11
+ Perforation,
12
+ ConfirmationRow,
13
+ ConfirmationLostCheckout,
14
+ alpha,
15
+ } from "./Confirmation";
7
16
  import { useCourseBookingFinalize } from "../../data/queries/useFinalize";
8
17
  import { useGetCourse } from "../../data/queries/useCourses";
18
+ import { useBookingSecret } from "../headless/booking/useBookingSecret";
9
19
 
10
20
  export interface CourseConfirmationProps {
11
21
  slug: string;
@@ -14,6 +24,11 @@ export interface CourseConfirmationProps {
14
24
  formatAmount?: (n: number) => string;
15
25
  /** Where the "browse more courses" 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 course, to enroll again. Defaults to `/i/courses/:slug`.
30
+ */
31
+ restartPath?: string;
17
32
  }
18
33
 
19
34
  export function CourseConfirmation({
@@ -21,16 +36,25 @@ export function CourseConfirmation({
21
36
  bookingId,
22
37
  formatAmount,
23
38
  explorePath = "/i/courses",
39
+ restartPath,
24
40
  }: CourseConfirmationProps) {
25
41
  const theme = useForgeTheme();
26
42
  const fmt = useAmountFormatter(formatAmount);
27
- const { data, isLoading, error } = useCourseBookingFinalize(slug, bookingId);
43
+ // The credential the checkout stashed before the payment redirect. `resolved`
44
+ // gates the finalise: storage can only be read on the client, so firing before
45
+ // then would send the call without a secret it actually has and get a 404.
46
+ const { secret, resolved, isMissing } = useBookingSecret(bookingId);
47
+ const { data, isLoading, error } = useCourseBookingFinalize(
48
+ slug,
49
+ resolved ? bookingId : undefined,
50
+ secret ?? undefined,
51
+ );
28
52
  const { data: course } = useGetCourse(slug);
29
53
 
30
54
  const text = theme.colors.text;
31
55
  const primary = theme.colors.primary;
32
56
 
33
- if (isLoading) {
57
+ if (!resolved || isLoading) {
34
58
  return (
35
59
  <ConfirmationStage>
36
60
  <Loading label="Confirming your enrollment…" />
@@ -39,6 +63,21 @@ export function CourseConfirmation({
39
63
  }
40
64
 
41
65
  if (error || !data) {
66
+ // A booking with no credential in THIS browser is indistinguishable
67
+ // server-side from a stranger holding a leaked id, so it 404s on purpose.
68
+ // Explain that rather than implying the enrollment never existed.
69
+ if (isMissing) {
70
+ return (
71
+ <ConfirmationStage>
72
+ <ConfirmationLostCheckout
73
+ title="We can’t confirm your enrollment in this browser"
74
+ body="Confirming an enrollment 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 enrollment email, so check your inbox before paying again. Otherwise, start the enrollment over."
75
+ restartPath={restartPath ?? `/i/courses/${slug}`}
76
+ restartLabel="Start over"
77
+ />
78
+ </ConfirmationStage>
79
+ );
80
+ }
42
81
  return (
43
82
  <ConfirmationStage>
44
83
  <p style={{ color: text }}>We couldn’t find your enrollment.</p>
@@ -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
  )}
@@ -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
+ });