@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.
- package/package.json +1 -1
- 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/useFinalize.ts +24 -4
- package/src/index.ts +4 -0
- package/src/types/models.ts +7 -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/index.ts +11 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/shell/TribeNestApp.tsx +16 -1
- package/src/ui/shell/shellGating.spec.ts +26 -1
- package/src/ui/shell/shellGating.ts +16 -0
- 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/InvoicePayment.tsx +65 -11
- package/src/utils/_tests/bookingSecret.spec.ts +115 -0
- package/src/utils/bookingSecret.ts +100 -0
|
@@ -1,85 +1,216 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
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={() =>
|
|
204
|
+
onClick={() => classroom.selectLesson(lesson.id)}
|
|
74
205
|
style={{
|
|
75
206
|
textAlign: "left",
|
|
76
207
|
padding: "8px 10px",
|
|
77
|
-
borderRadius:
|
|
208
|
+
borderRadius: t.cornerRadius,
|
|
78
209
|
border: "none",
|
|
79
210
|
cursor: "pointer",
|
|
80
211
|
fontSize: 14,
|
|
81
|
-
background: isSelected ? `${
|
|
82
|
-
color: isSelected ?
|
|
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={
|
|
107
|
-
style={{ width: "100%", aspectRatio: "16/9", background: "#000", borderRadius:
|
|
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: `${
|
|
118
|
-
borderRadius:
|
|
119
|
-
|
|
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
|
|
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:
|
|
143
|
-
border: `1px solid ${
|
|
144
|
-
background: `${
|
|
145
|
-
color:
|
|
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={{
|
|
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={{
|
|
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
|
-
/**
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
)}
|
|
@@ -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
|
+
});
|