@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,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>
|
|
@@ -5,6 +5,7 @@ import { useEventCheckout } from "../headless/event/useEventCheckout";
|
|
|
5
5
|
import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
|
|
6
6
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
7
7
|
import { PriceDisplay, summarizeTaxQuote } from "../format/PriceDisplay";
|
|
8
|
+
import { clampChosenAmount, isPayWhatYouWant, pwywDefaultAmount, pwywMaximum } from "../format/pwyw";
|
|
8
9
|
import { usePricesIncludeTax } from "../../data/queries/useWebsite";
|
|
9
10
|
import { Loading } from "./Loading";
|
|
10
11
|
import { usePaymentRenderer, type PaymentRenderProps } from "../payment/ForgePaymentProvider";
|
|
@@ -282,6 +283,12 @@ function TicketStep({
|
|
|
282
283
|
{fmt(Number(ticket.compareAtPrice))}
|
|
283
284
|
</span>
|
|
284
285
|
)}
|
|
286
|
+
{/* On a PWYW tier `price` is the FLOOR, so it is labelled as
|
|
287
|
+
one — showing it bare would read as a fixed price the
|
|
288
|
+
buyer is about to be charged. */}
|
|
289
|
+
{isPayWhatYouWant(ticket) && (
|
|
290
|
+
<span style={{ fontSize: 14, fontWeight: 500, opacity: 0.7, marginRight: 6 }}>from</span>
|
|
291
|
+
)}
|
|
285
292
|
<PriceDisplay amount={Number(ticket.price)} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={theme.colors.text} captionStyle={{ opacity: 0.65 }} />
|
|
286
293
|
</div>
|
|
287
294
|
</div>
|
|
@@ -305,6 +312,20 @@ function TicketStep({
|
|
|
305
312
|
</button>
|
|
306
313
|
</div>
|
|
307
314
|
</div>
|
|
315
|
+
{/* The amount box appears only once a seat is actually selected —
|
|
316
|
+
before that there is nothing to price, and a row of empty
|
|
317
|
+
inputs down the tier list reads as a form to fill in rather
|
|
318
|
+
than a choice to make. */}
|
|
319
|
+
{isPayWhatYouWant(ticket) && selectedQty > 0 && !isSoldOut && !isExpired && (
|
|
320
|
+
<PwywAmountField
|
|
321
|
+
ticket={ticket}
|
|
322
|
+
quantity={selectedQty}
|
|
323
|
+
value={c.pwywAmounts[ticket.id]}
|
|
324
|
+
onChange={(amount) => c.setTicketAmount(ticket.id, amount)}
|
|
325
|
+
fmt={fmt}
|
|
326
|
+
theme={theme}
|
|
327
|
+
/>
|
|
328
|
+
)}
|
|
308
329
|
{ticket.description && (
|
|
309
330
|
<p dangerouslySetInnerHTML={{ __html: ticket.description }} style={{ marginTop: 12, fontSize: 14 }} />
|
|
310
331
|
)}
|
|
@@ -669,6 +690,99 @@ const closeButtonStyle = (t: ForgeTheme): CSSProperties => ({
|
|
|
669
690
|
opacity: 0.7,
|
|
670
691
|
});
|
|
671
692
|
|
|
693
|
+
/**
|
|
694
|
+
* The buyer's "what will you pay?" box for one pay-what-you-want tier.
|
|
695
|
+
*
|
|
696
|
+
* Holds its own STRING state rather than binding the number straight through.
|
|
697
|
+
* Two reasons, both of which are broken inputs if you skip them: an empty box
|
|
698
|
+
* has to stay empty while the buyer retypes (a number-bound input snaps it back
|
|
699
|
+
* to the floor on every keystroke), and "1" on the way to "15" must not be
|
|
700
|
+
* clamped up to the minimum the instant it is typed.
|
|
701
|
+
*
|
|
702
|
+
* Correction happens on BLUR, when the buyer has finished — and the server
|
|
703
|
+
* clamps again regardless, so nothing here is load-bearing for money.
|
|
704
|
+
*/
|
|
705
|
+
function PwywAmountField({
|
|
706
|
+
ticket,
|
|
707
|
+
quantity,
|
|
708
|
+
value,
|
|
709
|
+
onChange,
|
|
710
|
+
fmt,
|
|
711
|
+
theme,
|
|
712
|
+
}: {
|
|
713
|
+
ticket: ITicket;
|
|
714
|
+
quantity: number;
|
|
715
|
+
value: number | undefined;
|
|
716
|
+
onChange: (amount: number | null) => void;
|
|
717
|
+
fmt: (amount: number) => string;
|
|
718
|
+
theme: ForgeTheme;
|
|
719
|
+
}) {
|
|
720
|
+
const floor = Number(ticket.price);
|
|
721
|
+
const maximum = pwywMaximum(ticket);
|
|
722
|
+
const suggested = pwywDefaultAmount(ticket);
|
|
723
|
+
const [draft, setDraft] = useState<string>(String(value ?? suggested));
|
|
724
|
+
|
|
725
|
+
// Follow the tier's own default when the buyer hasn't typed anything — an
|
|
726
|
+
// operator's suggested amount arriving late (or the selection being cleared
|
|
727
|
+
// and remade) should re-seed the box rather than leave a stale figure.
|
|
728
|
+
useEffect(() => {
|
|
729
|
+
if (value == null) setDraft(String(suggested));
|
|
730
|
+
}, [value, suggested]);
|
|
731
|
+
|
|
732
|
+
const parsed = Number(draft);
|
|
733
|
+
const effective = Number.isFinite(parsed) ? clampChosenAmount(ticket, parsed) : floor;
|
|
734
|
+
const belowFloor = draft.trim() !== "" && Number.isFinite(parsed) && parsed < floor;
|
|
735
|
+
const aboveMax = maximum != null && Number.isFinite(parsed) && parsed > maximum;
|
|
736
|
+
|
|
737
|
+
const commit = () => {
|
|
738
|
+
if (draft.trim() === "" || !Number.isFinite(parsed)) {
|
|
739
|
+
onChange(null);
|
|
740
|
+
setDraft(String(suggested));
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const clamped = clampChosenAmount(ticket, parsed);
|
|
744
|
+
onChange(clamped);
|
|
745
|
+
setDraft(String(clamped));
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
return (
|
|
749
|
+
<div style={{ marginTop: 12 }}>
|
|
750
|
+
<label
|
|
751
|
+
htmlFor={`pwyw-${ticket.id}`}
|
|
752
|
+
style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 6 }}
|
|
753
|
+
>
|
|
754
|
+
Name your price
|
|
755
|
+
<span style={{ fontWeight: 400, opacity: 0.7 }}>
|
|
756
|
+
{" "}
|
|
757
|
+
— minimum {fmt(floor)}
|
|
758
|
+
{maximum != null ? `, up to ${fmt(maximum)}` : ""}
|
|
759
|
+
</span>
|
|
760
|
+
</label>
|
|
761
|
+
<input
|
|
762
|
+
id={`pwyw-${ticket.id}`}
|
|
763
|
+
type="number"
|
|
764
|
+
inputMode="decimal"
|
|
765
|
+
min={floor}
|
|
766
|
+
{...(maximum != null ? { max: maximum } : {})}
|
|
767
|
+
step="0.01"
|
|
768
|
+
value={draft}
|
|
769
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
770
|
+
onBlur={commit}
|
|
771
|
+
style={{ ...inputStyle(theme), maxWidth: 200 }}
|
|
772
|
+
/>
|
|
773
|
+
<p style={{ fontSize: 12, opacity: 0.7, marginTop: 6 }}>
|
|
774
|
+
{belowFloor
|
|
775
|
+
? `The minimum is ${fmt(floor)} — we'll use that.`
|
|
776
|
+
: aboveMax
|
|
777
|
+
? `The most you can pay is ${fmt(maximum!)} — we'll use that.`
|
|
778
|
+
: quantity > 1
|
|
779
|
+
? `${fmt(effective)} each · ${fmt(effective * quantity)} for ${quantity}`
|
|
780
|
+
: "Per ticket."}
|
|
781
|
+
</p>
|
|
782
|
+
</div>
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
|
|
672
786
|
const stepperStyle = (t: ForgeTheme, filled: boolean): CSSProperties => ({
|
|
673
787
|
width: 40,
|
|
674
788
|
height: 40,
|