@tribe-nest/forge 3.50.0 → 3.52.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 +2 -2
- package/src/data/queries/useCohortEnrolment.ts +87 -0
- package/src/i18n/de.json +45 -0
- package/src/i18n/en.json +45 -0
- package/src/index.ts +1 -0
- package/src/types/models.ts +20 -0
- package/src/ui/headless/cohort/useCohortCheckout.ts +180 -0
- package/src/ui/index.ts +7 -0
- package/src/ui/styled/BlogPost.tsx +1 -1
- package/src/ui/styled/ChatRoom.tsx +2 -2
- package/src/ui/styled/Checkout.tsx +125 -125
- package/src/ui/styled/CoachingBooking.tsx +1 -1
- package/src/ui/styled/CoachingDetail.tsx +4 -4
- package/src/ui/styled/CohortCheckout.tsx +450 -0
- package/src/ui/styled/CohortConfirmation.tsx +259 -0
- package/src/ui/styled/CohortPage.tsx +16 -0
- package/src/ui/styled/CourseDetail.tsx +4 -4
- package/src/ui/styled/EventDetail.tsx +4 -4
- package/src/ui/styled/PaystackPayButton.tsx +1 -1
- package/src/ui/styled/PodcastEpisode.tsx +1 -1
- package/src/ui/styled/ProductDetail.tsx +4 -4
- package/src/ui/styled/_tests/CohortCheckout.spec.tsx +232 -0
- package/src/ui/styled/_tests/packagedCssIsCurrent.spec.ts +43 -0
- package/src/ui/styled/_tests/utilityPrefix.spec.ts +156 -0
- package/src/ui/styled/broadcast/BroadcastMoqStage.tsx +1 -1
- package/src/ui/styled/chat/ChatThreadPanel.tsx +1 -1
- package/src/ui/styled/forge-utilities.css +463 -731
- package/src/ui/styled/members/MemberCallsCalendar.tsx +2 -2
- package/src/ui/styled/members/MemberHomeLayout.tsx +4 -4
- package/src/ui/styled/members/MemberPostsFeed.tsx +1 -1
- package/src/ui/styled/members/PostCollectionsGrid.tsx +1 -1
- package/src/ui/styled/musicLinkStyles.tsx +4 -4
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { AlertTriangle, ArrowRight, CalendarDays, Mail } from "lucide-react";
|
|
2
|
+
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
3
|
+
import { readableTextOn } from "../theme/contrast";
|
|
4
|
+
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
5
|
+
import { Loading } from "./Loading";
|
|
6
|
+
import {
|
|
7
|
+
ConfirmationStage,
|
|
8
|
+
ConfirmationCard,
|
|
9
|
+
CheckSeal,
|
|
10
|
+
WarnSeal,
|
|
11
|
+
Perforation,
|
|
12
|
+
ConfirmationRow,
|
|
13
|
+
ConfirmationLostCheckout,
|
|
14
|
+
alpha,
|
|
15
|
+
} from "./Confirmation";
|
|
16
|
+
import { useForgeT } from "../../i18n";
|
|
17
|
+
import { useCohortEnrolmentFinalize } from "../../data/queries/useCohortEnrolment";
|
|
18
|
+
import { useCohortPage } from "../../data/queries/useCohortPage";
|
|
19
|
+
import { useBookingSecret } from "../headless/booking/useBookingSecret";
|
|
20
|
+
|
|
21
|
+
export interface CohortConfirmationProps {
|
|
22
|
+
cohortId: string;
|
|
23
|
+
bookingId?: string;
|
|
24
|
+
/** Formats a monetary amount for display. Defaults to a plain USD-style `$0.00`. */
|
|
25
|
+
formatAmount?: (n: number, currency?: string) => string;
|
|
26
|
+
/** Where the "browse more" action links. */
|
|
27
|
+
explorePath?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Where "start over" goes when this browser has no credential for the
|
|
30
|
+
* booking, i.e. back to the cohort to enrol again. Defaults to
|
|
31
|
+
* `/i/cohorts/:cohortId`.
|
|
32
|
+
*/
|
|
33
|
+
restartPath?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The page a cohort buyer lands on after paying.
|
|
38
|
+
*
|
|
39
|
+
* The pair to `CohortCheckout`. Both had to exist together: the checkout's
|
|
40
|
+
* Stripe `return_url` points here, so shipping the buy control without this
|
|
41
|
+
* would have sent every paying buyer to a 404 holding a charge and no
|
|
42
|
+
* confirmation.
|
|
43
|
+
*
|
|
44
|
+
* The booking's credential is read from sessionStorage rather than the URL. It
|
|
45
|
+
* was stashed there by the checkout before the redirect, because a secret in a
|
|
46
|
+
* return URL reaches history, referrers and anything the buyer pastes.
|
|
47
|
+
*/
|
|
48
|
+
export function CohortConfirmation({
|
|
49
|
+
cohortId,
|
|
50
|
+
bookingId,
|
|
51
|
+
formatAmount,
|
|
52
|
+
explorePath = "/i/courses",
|
|
53
|
+
restartPath,
|
|
54
|
+
}: CohortConfirmationProps) {
|
|
55
|
+
const theme = useForgeTheme();
|
|
56
|
+
const t = useForgeT();
|
|
57
|
+
const fmt = useAmountFormatter(formatAmount);
|
|
58
|
+
// `resolved` gates the finalise: storage can only be read on the client, so
|
|
59
|
+
// firing before then would send the call without a secret it actually has and
|
|
60
|
+
// get a 404.
|
|
61
|
+
const { secret, resolved, isMissing } = useBookingSecret(bookingId);
|
|
62
|
+
const { data, isLoading, error } = useCohortEnrolmentFinalize(
|
|
63
|
+
cohortId,
|
|
64
|
+
resolved ? bookingId : undefined,
|
|
65
|
+
secret ?? undefined,
|
|
66
|
+
);
|
|
67
|
+
const { data: page } = useCohortPage(cohortId);
|
|
68
|
+
|
|
69
|
+
const text = theme.colors.text;
|
|
70
|
+
const primary = theme.colors.primary;
|
|
71
|
+
const cohort = page?.data?.cohort;
|
|
72
|
+
const course = page?.data?.course;
|
|
73
|
+
|
|
74
|
+
if (!resolved || isLoading) {
|
|
75
|
+
return (
|
|
76
|
+
<ConfirmationStage>
|
|
77
|
+
<Loading label={t("forge.cohort_confirmation.loading")} />
|
|
78
|
+
</ConfirmationStage>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (error || !data) {
|
|
83
|
+
// A booking with no credential in THIS browser is indistinguishable
|
|
84
|
+
// server-side from a stranger holding a leaked id, so it 404s on purpose.
|
|
85
|
+
// Explain that rather than implying the enrolment never existed.
|
|
86
|
+
if (isMissing) {
|
|
87
|
+
return (
|
|
88
|
+
<ConfirmationStage>
|
|
89
|
+
<ConfirmationLostCheckout
|
|
90
|
+
title={t("forge.cohort_confirmation.lost_title")}
|
|
91
|
+
body={t("forge.cohort_confirmation.lost_body")}
|
|
92
|
+
restartPath={restartPath ?? `/i/cohorts/${cohortId}`}
|
|
93
|
+
restartLabel={t("forge.cohort_confirmation.lost_restart")}
|
|
94
|
+
/>
|
|
95
|
+
</ConfirmationStage>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return (
|
|
99
|
+
<ConfirmationStage>
|
|
100
|
+
<p style={{ color: text }}>{t("forge.cohort_confirmation.not_found")}</p>
|
|
101
|
+
</ConfirmationStage>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const confirmed = data.status === "confirmed";
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<ConfirmationStage>
|
|
109
|
+
<ConfirmationCard>
|
|
110
|
+
<div style={{ padding: "38px 30px 26px", textAlign: "center" }}>
|
|
111
|
+
{confirmed ? <CheckSeal /> : <WarnSeal icon={<AlertTriangle size={34} color="#f59e0b" />} />}
|
|
112
|
+
<p
|
|
113
|
+
className="cf-rise"
|
|
114
|
+
style={{
|
|
115
|
+
margin: "22px 0 6px",
|
|
116
|
+
fontSize: 12,
|
|
117
|
+
fontWeight: 700,
|
|
118
|
+
letterSpacing: "0.22em",
|
|
119
|
+
textTransform: "uppercase",
|
|
120
|
+
color: confirmed ? primary : "#f59e0b",
|
|
121
|
+
animationDelay: "0.15s",
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
{confirmed
|
|
125
|
+
? t("forge.cohort_confirmation.eyebrow_confirmed")
|
|
126
|
+
: t("forge.cohort_confirmation.eyebrow_action_needed")}
|
|
127
|
+
</p>
|
|
128
|
+
<h1
|
|
129
|
+
className="cf-rise"
|
|
130
|
+
style={{ fontSize: 29, fontWeight: 800, lineHeight: 1.12, margin: "0 0 8px", animationDelay: "0.22s" }}
|
|
131
|
+
>
|
|
132
|
+
{confirmed
|
|
133
|
+
? t("forge.cohort_confirmation.title_confirmed")
|
|
134
|
+
: t("forge.cohort_confirmation.title_incomplete")}
|
|
135
|
+
</h1>
|
|
136
|
+
<p
|
|
137
|
+
className="cf-rise"
|
|
138
|
+
style={{ fontSize: 15, color: alpha(text, 0.7), margin: "0 auto", maxWidth: 340, animationDelay: "0.3s" }}
|
|
139
|
+
>
|
|
140
|
+
{confirmed
|
|
141
|
+
? data.firstName
|
|
142
|
+
? t("forge.cohort_confirmation.body_confirmed", { first_name: data.firstName })
|
|
143
|
+
: t("forge.cohort_confirmation.body_confirmed_no_name")
|
|
144
|
+
: t("forge.cohort_confirmation.body_incomplete", { status: data.status })}
|
|
145
|
+
</p>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
{cohort && (
|
|
149
|
+
<div className="cf-rise" style={{ padding: "0 30px 24px", animationDelay: "0.36s" }}>
|
|
150
|
+
<div
|
|
151
|
+
style={{
|
|
152
|
+
display: "flex",
|
|
153
|
+
alignItems: "center",
|
|
154
|
+
gap: 12,
|
|
155
|
+
borderRadius: theme.cornerRadius,
|
|
156
|
+
border: `1px solid ${alpha(primary, 0.18)}`,
|
|
157
|
+
background: alpha(primary, 0.04),
|
|
158
|
+
padding: 12,
|
|
159
|
+
}}
|
|
160
|
+
>
|
|
161
|
+
<div
|
|
162
|
+
style={{
|
|
163
|
+
width: 56,
|
|
164
|
+
height: 56,
|
|
165
|
+
borderRadius: theme.cornerRadius,
|
|
166
|
+
background: alpha(primary, 0.12),
|
|
167
|
+
display: "grid",
|
|
168
|
+
placeItems: "center",
|
|
169
|
+
flexShrink: 0,
|
|
170
|
+
}}
|
|
171
|
+
>
|
|
172
|
+
<CalendarDays size={26} color={primary} />
|
|
173
|
+
</div>
|
|
174
|
+
<div style={{ minWidth: 0 }}>
|
|
175
|
+
<div style={{ fontSize: 16, fontWeight: 800 }}>{course?.title ?? cohort.title}</div>
|
|
176
|
+
<div style={{ fontSize: 13, color: alpha(text, 0.6) }}>{cohort.title}</div>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
|
|
182
|
+
<Perforation />
|
|
183
|
+
|
|
184
|
+
<div className="cf-rise" style={{ padding: "26px 30px 8px", animationDelay: "0.44s" }}>
|
|
185
|
+
{confirmed && (
|
|
186
|
+
<div
|
|
187
|
+
style={{
|
|
188
|
+
display: "flex",
|
|
189
|
+
alignItems: "flex-start",
|
|
190
|
+
gap: 10,
|
|
191
|
+
fontSize: 14,
|
|
192
|
+
color: alpha(text, 0.8),
|
|
193
|
+
background: alpha(primary, 0.05),
|
|
194
|
+
border: `1px solid ${alpha(primary, 0.16)}`,
|
|
195
|
+
borderRadius: theme.cornerRadius,
|
|
196
|
+
padding: "12px 14px",
|
|
197
|
+
marginBottom: 16,
|
|
198
|
+
}}
|
|
199
|
+
>
|
|
200
|
+
<Mail size={16} color={primary} style={{ marginTop: 2, flexShrink: 0 }} />
|
|
201
|
+
<span style={{ lineHeight: 1.5 }}>
|
|
202
|
+
{t("forge.cohort_confirmation.emailed", { email: data.email ?? "" })}
|
|
203
|
+
</span>
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
206
|
+
<dl style={{ display: "grid", gridTemplateColumns: "1fr auto", rowGap: 12, columnGap: 16, margin: 0 }}>
|
|
207
|
+
<ConfirmationRow
|
|
208
|
+
label={t("forge.cohort_confirmation.row_student")}
|
|
209
|
+
value={`${data.firstName} ${data.lastName}`.trim() || t("forge.cohort_confirmation.not_given")}
|
|
210
|
+
/>
|
|
211
|
+
<ConfirmationRow
|
|
212
|
+
label={t("forge.cohort_confirmation.row_reference")}
|
|
213
|
+
value={`#${data.id.slice(0, 8).toUpperCase()}`}
|
|
214
|
+
mono
|
|
215
|
+
/>
|
|
216
|
+
{/*
|
|
217
|
+
WHAT WAS ACTUALLY TAKEN, which on an instalment plan is the first
|
|
218
|
+
payment and not the plan's total. Reading the booking's charged
|
|
219
|
+
amount rather than the plan keeps this honest either way.
|
|
220
|
+
*/}
|
|
221
|
+
<ConfirmationRow
|
|
222
|
+
label={t("forge.cohort_confirmation.row_paid")}
|
|
223
|
+
value={
|
|
224
|
+
Number(data.totalAmountInChargedCurrency ?? data.totalAmount) > 0
|
|
225
|
+
? data.totalAmountInChargedCurrency
|
|
226
|
+
? fmt(Number(data.totalAmountInChargedCurrency), data.chargedCurrency || undefined)
|
|
227
|
+
: fmt(Number(data.totalAmount))
|
|
228
|
+
: t("forge.cohort_confirmation.free")
|
|
229
|
+
}
|
|
230
|
+
accent
|
|
231
|
+
strong
|
|
232
|
+
/>
|
|
233
|
+
</dl>
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
<div className="cf-rise" style={{ padding: "20px 30px 30px", animationDelay: "0.52s" }}>
|
|
237
|
+
<a
|
|
238
|
+
href={explorePath}
|
|
239
|
+
style={{
|
|
240
|
+
display: "inline-flex",
|
|
241
|
+
alignItems: "center",
|
|
242
|
+
justifyContent: "center",
|
|
243
|
+
gap: 8,
|
|
244
|
+
width: "100%",
|
|
245
|
+
padding: "13px 18px",
|
|
246
|
+
borderRadius: theme.cornerRadius,
|
|
247
|
+
background: primary,
|
|
248
|
+
color: readableTextOn(primary),
|
|
249
|
+
fontWeight: 700,
|
|
250
|
+
textDecoration: "none",
|
|
251
|
+
}}
|
|
252
|
+
>
|
|
253
|
+
{t("forge.cohort_confirmation.explore")} <ArrowRight size={16} />
|
|
254
|
+
</a>
|
|
255
|
+
</div>
|
|
256
|
+
</ConfirmationCard>
|
|
257
|
+
</ConfirmationStage>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
@@ -2,6 +2,7 @@ import { useCohortPage } from "../../data/queries/useCohortPage";
|
|
|
2
2
|
import { useForgeT } from "../../i18n";
|
|
3
3
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
4
4
|
import { CourseCallsSection } from "./CourseCallsSection";
|
|
5
|
+
import { CohortCheckout } from "./CohortCheckout";
|
|
5
6
|
import { Loading } from "./Loading";
|
|
6
7
|
|
|
7
8
|
export interface CohortPageProps {
|
|
@@ -42,6 +43,21 @@ export function CohortPage({ cohortId }: CohortPageProps) {
|
|
|
42
43
|
{(cohort.startDate || cohort.endDate) && <p style={{ opacity: 0.7, marginBottom: 16 }}>{dateLabel}</p>}
|
|
43
44
|
{cohort.description && <p style={{ marginBottom: 24, lineHeight: 1.6 }}>{cohort.description}</p>}
|
|
44
45
|
|
|
46
|
+
{/*
|
|
47
|
+
THE WAY IN. Until 2026-09-07 this page described a cohort and gave a
|
|
48
|
+
reader no way to buy a seat on it: the checkout existed on the backend
|
|
49
|
+
and nothing on any storefront called it, so a creator could build a
|
|
50
|
+
cohort, price it and publish it with no way to sell it.
|
|
51
|
+
|
|
52
|
+
It renders NOTHING when the cohort has no pricing plans, which is what
|
|
53
|
+
keeps a not-yet-priced cohort from showing a dead button, and nothing
|
|
54
|
+
for a student who is already enrolled would be wrong to see - the
|
|
55
|
+
backend refuses a second seat on the same cohort for the same email.
|
|
56
|
+
*/}
|
|
57
|
+
<div style={{ marginBottom: 32 }}>
|
|
58
|
+
<CohortCheckout cohortId={cohort.id} />
|
|
59
|
+
</div>
|
|
60
|
+
|
|
45
61
|
{course && (
|
|
46
62
|
<section style={{ marginBottom: 24 }}>
|
|
47
63
|
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 6 }}>{course.title}</h2>
|
|
@@ -62,7 +62,7 @@ export function CourseDetail({
|
|
|
62
62
|
|
|
63
63
|
return (
|
|
64
64
|
<div
|
|
65
|
-
className={`pb-32 md:pb-0${className ? ` ${className}` : ""}`}
|
|
65
|
+
className={`fg:pb-32 fg:md:pb-0${className ? ` ${className}` : ""}`}
|
|
66
66
|
style={{ maxWidth: 1040, margin: "0 auto", color: tokens.text, ...style }}
|
|
67
67
|
>
|
|
68
68
|
{introVideo ? (
|
|
@@ -81,8 +81,8 @@ export function CourseDetail({
|
|
|
81
81
|
)
|
|
82
82
|
)}
|
|
83
83
|
|
|
84
|
-
<div className="flex flex-col md:flex-row md:items-start gap-6 mt-6">
|
|
85
|
-
<div className="flex-1 min-w-0">
|
|
84
|
+
<div className="fg:flex fg:flex-col fg:md:flex-row fg:md:items-start fg:gap-6 fg:mt-6">
|
|
85
|
+
<div className="fg:flex-1 fg:min-w-0">
|
|
86
86
|
<h1 style={{ fontSize: 32, fontWeight: 800, margin: "0 0 12px", fontFamily: tokens.headingFontFamily }}>{course.title}</h1>
|
|
87
87
|
|
|
88
88
|
{course.description && (
|
|
@@ -127,7 +127,7 @@ export function CourseDetail({
|
|
|
127
127
|
</div>
|
|
128
128
|
|
|
129
129
|
<aside
|
|
130
|
-
className="fixed bottom-0 left-0 right-0 z-40 w-full md:sticky md:top-4 md:bottom-auto md:left-auto md:right-auto md:w-[300px] md:shrink-0 flex flex-col gap-3"
|
|
130
|
+
className="fg:fixed fg:bottom-0 fg:left-0 fg:right-0 fg:z-40 fg:w-full fg:md:sticky fg:md:top-4 fg:md:bottom-auto fg:md:left-auto fg:md:right-auto fg:md:w-[300px] fg:md:shrink-0 fg:flex fg:flex-col fg:gap-3"
|
|
131
131
|
style={{
|
|
132
132
|
padding: 16,
|
|
133
133
|
border: `1px solid ${tokens.primary}80`,
|
|
@@ -81,7 +81,7 @@ export function EventDetail({
|
|
|
81
81
|
|
|
82
82
|
return (
|
|
83
83
|
<div
|
|
84
|
-
className={`pb-32 md:pb-0${className ? ` ${className}` : ""}`}
|
|
84
|
+
className={`fg:pb-32 fg:md:pb-0${className ? ` ${className}` : ""}`}
|
|
85
85
|
style={{ maxWidth: 1040, margin: "0 auto", color: t.text, ...style }}
|
|
86
86
|
>
|
|
87
87
|
{cover && (
|
|
@@ -98,8 +98,8 @@ export function EventDetail({
|
|
|
98
98
|
/>
|
|
99
99
|
)}
|
|
100
100
|
|
|
101
|
-
<div className="flex flex-col md:flex-row md:items-start gap-6 mt-6">
|
|
102
|
-
<div className="flex-1 min-w-0">
|
|
101
|
+
<div className="fg:flex fg:flex-col fg:md:flex-row fg:md:items-start fg:gap-6 fg:mt-6">
|
|
102
|
+
<div className="fg:flex-1 fg:min-w-0">
|
|
103
103
|
<div style={{ marginBottom: 20 }}>
|
|
104
104
|
<EventCountdown dateTime={event.dateTime} timezone={event.timezone} />
|
|
105
105
|
</div>
|
|
@@ -152,7 +152,7 @@ export function EventDetail({
|
|
|
152
152
|
which is the shape most presales are announced in. */}
|
|
153
153
|
{(event.tickets.length > 0 || presale.visible) && (
|
|
154
154
|
<aside
|
|
155
|
-
className="fixed bottom-0 left-0 right-0 z-40 w-full md:sticky md:top-4 md:bottom-auto md:left-auto md:right-auto md:w-[300px] md:shrink-0 flex flex-col gap-3"
|
|
155
|
+
className="fg:fixed fg:bottom-0 fg:left-0 fg:right-0 fg:z-40 fg:w-full fg:md:sticky fg:md:top-4 fg:md:bottom-auto fg:md:left-auto fg:md:right-auto fg:md:w-[300px] fg:md:shrink-0 fg:flex fg:flex-col fg:gap-3"
|
|
156
156
|
style={{
|
|
157
157
|
padding: 16,
|
|
158
158
|
border: `1px solid ${t.primary}80`,
|
|
@@ -51,7 +51,7 @@ export function PaystackPayButton({ onPay, amount, currency, label, disabled, cl
|
|
|
51
51
|
setIsOpening(false);
|
|
52
52
|
}
|
|
53
53
|
}}
|
|
54
|
-
className={className ?? "w-full px-4 py-3 font-semibold"}
|
|
54
|
+
className={className ?? "fg:w-full fg:px-4 fg:py-3 fg:font-semibold"}
|
|
55
55
|
style={{
|
|
56
56
|
backgroundColor: theme.colors.primary,
|
|
57
57
|
color: readableTextOn(theme.colors.primary),
|
|
@@ -99,7 +99,7 @@ export function PodcastEpisode({
|
|
|
99
99
|
{/* Show notes. `rich-text` = shared baseline content styles (WYSIWYG
|
|
100
100
|
with the editor), bundled with Forge via the ./rich-text.css import. */}
|
|
101
101
|
<div
|
|
102
|
-
className="rich-text"
|
|
102
|
+
className="fg:rich-text"
|
|
103
103
|
style={{ lineHeight: 1.7, marginTop: 8, borderTop: `1px solid ${border}`, paddingTop: 18 }}
|
|
104
104
|
dangerouslySetInnerHTML={{ __html: episode.content }}
|
|
105
105
|
/>
|
|
@@ -246,7 +246,7 @@ function StandardDetail({
|
|
|
246
246
|
return (
|
|
247
247
|
<>
|
|
248
248
|
<div
|
|
249
|
-
className={`grid grid-cols-1 md:grid-cols-2 gap-8${className ? ` ${className}` : ""}`}
|
|
249
|
+
className={`fg:grid fg:grid-cols-1 fg:md:grid-cols-2 fg:gap-8${className ? ` ${className}` : ""}`}
|
|
250
250
|
style={{ color: t.text, fontFamily: t.fontFamily, ...style }}
|
|
251
251
|
>
|
|
252
252
|
{/* Gallery */}
|
|
@@ -469,7 +469,7 @@ function StandardDetail({
|
|
|
469
469
|
{/* Mobile sticky Add-to-cart (only when the inline one is scrolled past). */}
|
|
470
470
|
{showSticky && !isCartOpen && (
|
|
471
471
|
<div
|
|
472
|
-
className="md:hidden"
|
|
472
|
+
className="fg:md:hidden"
|
|
473
473
|
style={{
|
|
474
474
|
position: "fixed",
|
|
475
475
|
bottom: 0,
|
|
@@ -583,10 +583,10 @@ function MusicDetail({
|
|
|
583
583
|
<div style={{ border, borderRadius: t.cornerRadius }}>
|
|
584
584
|
{/* Header */}
|
|
585
585
|
<div style={{ padding: 24, borderBottom: border }}>
|
|
586
|
-
<div className="flex flex-col md:flex-row" style={{ gap: 24 }}>
|
|
586
|
+
<div className="fg:flex fg:flex-col fg:md:flex-row" style={{ gap: 24 }}>
|
|
587
587
|
{/* Cover + play overlay */}
|
|
588
588
|
<div style={{ position: "relative", flexShrink: 0 }}>
|
|
589
|
-
<div className="w-full md:w-64 md:h-64" style={{ aspectRatio: "1 / 1", borderRadius: t.cornerRadius, overflow: "hidden", background: t.primary }}>
|
|
589
|
+
<div className="fg:w-full fg:md:w-64 fg:md:h-64" style={{ aspectRatio: "1 / 1", borderRadius: t.cornerRadius, overflow: "hidden", background: t.primary }}>
|
|
590
590
|
{cover ? (
|
|
591
591
|
<img src={cover} alt={product.title} style={{ width: "100%", height: "100%", objectFit: "cover", objectPosition: "top" }} />
|
|
592
592
|
) : (
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What is under test is the WIRING, and specifically the two things a cohort
|
|
10
|
+
* checkout gets wrong when nobody pins them.
|
|
11
|
+
*
|
|
12
|
+
* 1. THE PLAN IS THE PRICE, so the buyer chooses one before anything else and
|
|
13
|
+
* the creator's default is preselected. Guessing the plan would charge a
|
|
14
|
+
* buyer for a cadence they did not pick.
|
|
15
|
+
* 2. THE PAYMENT STEP SHOWS WHAT IS TAKEN TODAY, not the plan total. On a
|
|
16
|
+
* three-payment plan those are different numbers, and showing the total
|
|
17
|
+
* tells the buyer they are about to be charged three times what they will
|
|
18
|
+
* be.
|
|
19
|
+
*
|
|
20
|
+
* The modal, the plan cards and the payment step are all driven from
|
|
21
|
+
* `useCohortCheckout`, so the hook is mocked and the component is exercised.
|
|
22
|
+
*/
|
|
23
|
+
type Plan = {
|
|
24
|
+
id: string;
|
|
25
|
+
title: string;
|
|
26
|
+
currency: string;
|
|
27
|
+
totalAmount: string | number;
|
|
28
|
+
installmentCount: number;
|
|
29
|
+
intervalDays: number;
|
|
30
|
+
isDefault: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const PLANS: Plan[] = [
|
|
34
|
+
{
|
|
35
|
+
id: "p1",
|
|
36
|
+
title: "Pay in full",
|
|
37
|
+
currency: "USD",
|
|
38
|
+
totalAmount: 600,
|
|
39
|
+
installmentCount: 1,
|
|
40
|
+
intervalDays: 0,
|
|
41
|
+
isDefault: true,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: "p2",
|
|
45
|
+
title: "Three payments",
|
|
46
|
+
currency: "USD",
|
|
47
|
+
totalAmount: 660,
|
|
48
|
+
installmentCount: 3,
|
|
49
|
+
intervalDays: 30,
|
|
50
|
+
isDefault: false,
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const state: Record<string, unknown> = {};
|
|
55
|
+
const choosePlan = vi.fn();
|
|
56
|
+
const setStep = vi.fn();
|
|
57
|
+
const enrol = vi.fn();
|
|
58
|
+
|
|
59
|
+
vi.mock("../../headless/cohort/useCohortCheckout", () => ({
|
|
60
|
+
useCohortCheckout: () => state,
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
/*
|
|
64
|
+
* Both reached through `useAmountFormatter`, which resolves the viewer's
|
|
65
|
+
* currency. Neither is what this file is about, and standing real providers up
|
|
66
|
+
* here would be scaffolding around the two assertions that matter.
|
|
67
|
+
*/
|
|
68
|
+
vi.mock("../../../contexts/PublicAuthContext", () => ({
|
|
69
|
+
usePublicAuth: () => ({ isAuthenticated: false, isInitialized: true, user: null }),
|
|
70
|
+
}));
|
|
71
|
+
/*
|
|
72
|
+
* The NARROW seam, deliberately. Mocking `ForgeProvider` itself also replaced
|
|
73
|
+
* `useForge` for every other spec sharing this worker, and took
|
|
74
|
+
* `paymentFlowReturnUrl.spec` down with it. `useSiteConfig` is the only thing
|
|
75
|
+
* the formatter actually needs from that direction.
|
|
76
|
+
*/
|
|
77
|
+
vi.mock("../../../data/queries/useWebsite", () => ({
|
|
78
|
+
useSiteConfig: () => ({ data: { currency: "USD" }, isLoading: false }),
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
const { CohortCheckout } = await import("../CohortCheckout");
|
|
82
|
+
|
|
83
|
+
const base = () => ({
|
|
84
|
+
cohort: { id: "c1", title: "Spring cohort" },
|
|
85
|
+
course: { id: "co1", title: "The programme" },
|
|
86
|
+
isLoading: false,
|
|
87
|
+
plans: PLANS,
|
|
88
|
+
selectedPlan: PLANS[0],
|
|
89
|
+
planId: "p1",
|
|
90
|
+
choosePlan,
|
|
91
|
+
step: "plan",
|
|
92
|
+
setStep,
|
|
93
|
+
firstName: "",
|
|
94
|
+
setFirstName: vi.fn(),
|
|
95
|
+
lastName: "",
|
|
96
|
+
setLastName: vi.fn(),
|
|
97
|
+
email: "",
|
|
98
|
+
setEmail: vi.fn(),
|
|
99
|
+
questionnaire: undefined,
|
|
100
|
+
setQuestionnaire: vi.fn(),
|
|
101
|
+
enrol,
|
|
102
|
+
clientSecret: undefined,
|
|
103
|
+
isPaystack: false,
|
|
104
|
+
canOpenPaystack: false,
|
|
105
|
+
openPaystackCheckout: vi.fn(),
|
|
106
|
+
totalAmount: 600,
|
|
107
|
+
installmentAmount: undefined,
|
|
108
|
+
installmentCount: 1,
|
|
109
|
+
setupRequiresAction: false,
|
|
110
|
+
setupClientSecret: null,
|
|
111
|
+
taxQuote: null,
|
|
112
|
+
returnUrl: "",
|
|
113
|
+
isProcessing: false,
|
|
114
|
+
error: null,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/** The provider takes no default, so every spec here supplies one. */
|
|
118
|
+
const THEME = {
|
|
119
|
+
colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" },
|
|
120
|
+
cornerRadius: 8,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A REAL query client rather than a fourth mock. `useAmountFormatter` reaches a
|
|
125
|
+
* currency query, and stubbing every provider between here and it would leave a
|
|
126
|
+
* spec that passes because of its scaffolding. No retries, so a query that does
|
|
127
|
+
* fire fails fast instead of holding the test open.
|
|
128
|
+
*/
|
|
129
|
+
const wrap = (node: React.ReactNode) => (
|
|
130
|
+
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
|
131
|
+
<ForgeThemeProvider theme={THEME}>{node}</ForgeThemeProvider>
|
|
132
|
+
</QueryClientProvider>
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* `formatAmount` is supplied rather than left to `useAmountFormatter`, which
|
|
137
|
+
* resolves the viewer's currency through a query this spec has no server for.
|
|
138
|
+
* What is under test is WHICH figure is shown, not how it is punctuated.
|
|
139
|
+
*/
|
|
140
|
+
const fmt = (n: number) => `$${n}`;
|
|
141
|
+
|
|
142
|
+
const draw = () => render(wrap(<CohortCheckout cohortId="c1" formatAmount={fmt} />));
|
|
143
|
+
|
|
144
|
+
const open = () => fireEvent.click(screen.getByText("Enrol"));
|
|
145
|
+
|
|
146
|
+
beforeEach(() => {
|
|
147
|
+
Object.keys(state).forEach((k) => delete state[k]);
|
|
148
|
+
Object.assign(state, base());
|
|
149
|
+
choosePlan.mockClear();
|
|
150
|
+
setStep.mockClear();
|
|
151
|
+
enrol.mockClear();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
afterEach(cleanup);
|
|
155
|
+
|
|
156
|
+
describe("CohortCheckout", () => {
|
|
157
|
+
it("renders nothing at all when the cohort has no pricing plans", () => {
|
|
158
|
+
// Not an error state: a cohort that is not priced yet. A dead "Enrol"
|
|
159
|
+
// button would be worse than no button.
|
|
160
|
+
Object.assign(state, { plans: [], selectedPlan: undefined });
|
|
161
|
+
const { queryByText } = draw();
|
|
162
|
+
// The theme provider injects its own :root style block, so the absence to
|
|
163
|
+
// assert is the TRIGGER, not an empty container.
|
|
164
|
+
expect(queryByText("Enrol")).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("opens on plan choice, and offers every plan the creator priced", () => {
|
|
168
|
+
draw();
|
|
169
|
+
open();
|
|
170
|
+
expect(screen.getByText("Choose a plan")).toBeTruthy();
|
|
171
|
+
// "Pay in full" is both the plan's TITLE and, for a one-payment plan, its
|
|
172
|
+
// terms line, so it legitimately appears twice on the same card.
|
|
173
|
+
expect(screen.getAllByText("Pay in full").length).toBeGreaterThan(0);
|
|
174
|
+
expect(screen.getByText("Three payments")).toBeTruthy();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("spells out an instalment plan's terms rather than only its total", () => {
|
|
178
|
+
draw();
|
|
179
|
+
open();
|
|
180
|
+
// The per-payment figure and the cadence, because "660" alone does not tell
|
|
181
|
+
// a buyer what leaves their account or when.
|
|
182
|
+
expect(screen.getByText(/3 payments of/)).toBeTruthy();
|
|
183
|
+
expect(screen.getByText(/every 30 days/)).toBeTruthy();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("passes the chosen plan up rather than assuming one", () => {
|
|
187
|
+
draw();
|
|
188
|
+
open();
|
|
189
|
+
fireEvent.click(screen.getByText("Three payments"));
|
|
190
|
+
expect(choosePlan).toHaveBeenCalledWith("p2");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("shows what is taken TODAY on an instalment plan, not the plan total", () => {
|
|
194
|
+
Object.assign(state, {
|
|
195
|
+
step: "payment",
|
|
196
|
+
selectedPlan: PLANS[1],
|
|
197
|
+
planId: "p2",
|
|
198
|
+
totalAmount: 660,
|
|
199
|
+
installmentAmount: 220,
|
|
200
|
+
installmentCount: 3,
|
|
201
|
+
});
|
|
202
|
+
const { baseElement } = draw();
|
|
203
|
+
open();
|
|
204
|
+
|
|
205
|
+
expect(screen.getByText("Due now")).toBeTruthy();
|
|
206
|
+
// 220 is the charge; 660 is the commitment. Both appear, and the figure
|
|
207
|
+
// beside "Due now" is the one about to leave the buyer's card. Read off the
|
|
208
|
+
// whole dialog because the formatter splits the amount across nodes.
|
|
209
|
+
expect(baseElement.textContent).toContain("220");
|
|
210
|
+
expect(baseElement.textContent).toContain("660");
|
|
211
|
+
expect(screen.getByText(/3 payments in total/)).toBeTruthy();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("asks for the off-session consent when Stripe needs it confirmed", () => {
|
|
215
|
+
// Instalments 2..n are charged months later by a cron with nobody present.
|
|
216
|
+
// A checkout that skips this takes payment one and silently fails the rest.
|
|
217
|
+
const renderPayment = vi.fn(() => <div>card form</div>);
|
|
218
|
+
Object.assign(state, {
|
|
219
|
+
step: "payment",
|
|
220
|
+
selectedPlan: PLANS[1],
|
|
221
|
+
installmentCount: 3,
|
|
222
|
+
installmentAmount: 220,
|
|
223
|
+
setupRequiresAction: true,
|
|
224
|
+
setupClientSecret: "seti_123_secret",
|
|
225
|
+
});
|
|
226
|
+
render(wrap(<CohortCheckout cohortId="c1" formatAmount={fmt} renderPayment={renderPayment} />));
|
|
227
|
+
open();
|
|
228
|
+
|
|
229
|
+
expect(screen.getByText(/Authorise the later payments/)).toBeTruthy();
|
|
230
|
+
expect(renderPayment).toHaveBeenCalledWith(expect.objectContaining({ clientSecret: "seti_123_secret" }));
|
|
231
|
+
});
|
|
232
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The shipped stylesheet must match the components that need it.
|
|
8
|
+
*
|
|
9
|
+
* Forge packages its own Tailwind output (`forge-utilities.css`) because a
|
|
10
|
+
* published site cannot scan Forge's source: the build sandbox hoists forge to
|
|
11
|
+
* a workspace-root node_modules, the starter's `@source` paths miss, and
|
|
12
|
+
* Tailwind skips a missing `@source` SILENTLY. So the packaged file is the only
|
|
13
|
+
* thing standing between a Forge component and an unstyled published page.
|
|
14
|
+
*
|
|
15
|
+
* Which makes staleness invisible in exactly the same way. Add a class to a
|
|
16
|
+
* component, forget `npm run build:css`, and it works locally for ever (the
|
|
17
|
+
* monorepo `@source` reaches Forge's source) while every published site renders
|
|
18
|
+
* without it. This runs the build and fails if the committed file would change.
|
|
19
|
+
*/
|
|
20
|
+
describe("Forge's packaged CSS", () => {
|
|
21
|
+
it("is up to date with the components that use it", () => {
|
|
22
|
+
const root = join(__dirname, "..", "..", "..", "..");
|
|
23
|
+
const cssPath = join(root, "src", "ui", "styled", "forge-utilities.css");
|
|
24
|
+
const before = readFileSync(cssPath, "utf-8");
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
execFileSync("npm", ["run", "build:css"], { cwd: root, stdio: "pipe" });
|
|
28
|
+
} catch (err) {
|
|
29
|
+
throw new Error(`build:css failed, so the packaged CSS cannot be verified: ${String(err)}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const after = readFileSync(cssPath, "utf-8");
|
|
33
|
+
// Put the committed file back before asserting, so a failing run does not
|
|
34
|
+
// leave a rebuilt artifact in the tree and turn one red test into a dirty
|
|
35
|
+
// working copy.
|
|
36
|
+
if (before !== after) writeFileSync(cssPath, before);
|
|
37
|
+
|
|
38
|
+
expect(
|
|
39
|
+
after === before,
|
|
40
|
+
"forge-utilities.css is stale. A class was added to a component without rebuilding, so it is missing from every PUBLISHED site while working locally. Run `npm run build:css` in packages/forge and commit the result.",
|
|
41
|
+
).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|