@tribe-nest/forge 3.48.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/data/queries/useMembershipCalls.ts +58 -8
- package/src/i18n/de.json +46 -0
- package/src/i18n/en.json +46 -0
- package/src/index.ts +1 -0
- package/src/types/models.ts +78 -5
- package/src/ui/headless/cohort/useCohortCheckout.ts +180 -0
- package/src/ui/headless/memberHome/useMemberCalls.ts +13 -0
- package/src/ui/index.ts +10 -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 +31 -1
- package/src/ui/styled/CourseAccess.tsx +19 -6
- package/src/ui/styled/CourseCallsSection.tsx +99 -0
- package/src/ui/styled/CourseDetail.tsx +4 -4
- package/src/ui/styled/EventDetail.tsx +4 -4
- package/src/ui/styled/FormRenderer.tsx +14 -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/CourseCallsSection.spec.tsx +163 -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 +21 -3
- 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
|
@@ -352,7 +352,7 @@ function SlotStep({ c, product, fmt }: { c: Booking; product: CoachingProduct; f
|
|
|
352
352
|
{dayName}, {dateString}
|
|
353
353
|
</h3>
|
|
354
354
|
</div>
|
|
355
|
-
<div className="grid grid-cols-2 md:grid-cols-4" style={{ gap: 10 }}>
|
|
355
|
+
<div className="fg:grid fg:grid-cols-2 fg:md:grid-cols-4" style={{ gap: 10 }}>
|
|
356
356
|
{daySlots.map((slot) => {
|
|
357
357
|
const selected = slot.slotId === c.selectedSlotId;
|
|
358
358
|
return (
|
|
@@ -50,7 +50,7 @@ export function CoachingDetail({
|
|
|
50
50
|
|
|
51
51
|
return (
|
|
52
52
|
<div
|
|
53
|
-
className={`pb-32 md:pb-0${className ? ` ${className}` : ""}`}
|
|
53
|
+
className={`fg:pb-32 fg:md:pb-0${className ? ` ${className}` : ""}`}
|
|
54
54
|
style={{ maxWidth: 1040, margin: "0 auto", color: tokens.text, ...style }}
|
|
55
55
|
>
|
|
56
56
|
{cover && (
|
|
@@ -61,8 +61,8 @@ export function CoachingDetail({
|
|
|
61
61
|
/>
|
|
62
62
|
)}
|
|
63
63
|
|
|
64
|
-
<div className="flex flex-col md:flex-row md:items-start gap-6 mt-6">
|
|
65
|
-
<div className="flex-1 min-w-0">
|
|
64
|
+
<div className="fg:flex fg:flex-col fg:md:flex-row fg:md:items-start fg:gap-6 fg:mt-6">
|
|
65
|
+
<div className="fg:flex-1 fg:min-w-0">
|
|
66
66
|
<h1 style={{ fontSize: 32, fontWeight: 800, margin: "0 0 12px", fontFamily: tokens.headingFontFamily }}>{product.title}</h1>
|
|
67
67
|
|
|
68
68
|
<div style={{ display: "flex", flexDirection: "column", gap: 8, fontSize: 14 }}>
|
|
@@ -82,7 +82,7 @@ export function CoachingDetail({
|
|
|
82
82
|
</div>
|
|
83
83
|
|
|
84
84
|
<aside
|
|
85
|
-
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"
|
|
85
|
+
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"
|
|
86
86
|
style={{
|
|
87
87
|
padding: 16,
|
|
88
88
|
border: `1px solid ${tokens.primary}80`,
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode, useState } from "react";
|
|
2
|
+
import { X } from "lucide-react";
|
|
3
|
+
import { useCohortCheckout } from "../headless/cohort/useCohortCheckout";
|
|
4
|
+
import { useForgeT } from "../../i18n";
|
|
5
|
+
import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
|
|
6
|
+
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
7
|
+
import { summarizeTaxQuote } from "../format/PriceDisplay";
|
|
8
|
+
import { Loading } from "./Loading";
|
|
9
|
+
import { PaystackPayButton } from "./PaystackPayButton";
|
|
10
|
+
import { usePaymentRenderer, type PaymentRenderProps } from "../payment/ForgePaymentProvider";
|
|
11
|
+
import { buttonStyle } from "./Button";
|
|
12
|
+
import {
|
|
13
|
+
DialogRoot,
|
|
14
|
+
DialogTrigger,
|
|
15
|
+
DialogPortal,
|
|
16
|
+
DialogOverlay,
|
|
17
|
+
DialogContent,
|
|
18
|
+
DialogTitle,
|
|
19
|
+
DialogClose,
|
|
20
|
+
} from "../headless/dialog";
|
|
21
|
+
|
|
22
|
+
export interface CohortCheckoutProps {
|
|
23
|
+
/** The cohort being sold. */
|
|
24
|
+
cohortId?: string;
|
|
25
|
+
/** Trigger button label. */
|
|
26
|
+
triggerText?: string;
|
|
27
|
+
/** Format a numeric amount for display. */
|
|
28
|
+
formatAmount?: (amount: number) => string;
|
|
29
|
+
/** Optional payment renderer - falls back to a registered <ForgePaymentProvider>. */
|
|
30
|
+
renderPayment?: (props: PaymentRenderProps) => ReactNode;
|
|
31
|
+
/**
|
|
32
|
+
* Stripe return URL for a PAID enrolment. Default
|
|
33
|
+
* `/i/cohorts/:cohortId/finalise`.
|
|
34
|
+
*
|
|
35
|
+
* Put ONLY the bookingId in this URL. The booking's secret is handled for you
|
|
36
|
+
* by the hook, which stashes it in sessionStorage for the leg after the
|
|
37
|
+
* redirect. Putting it in the URL would hand it to history, referrers and
|
|
38
|
+
* shared links.
|
|
39
|
+
*/
|
|
40
|
+
finalisePath?: (cohortId: string, bookingId: string) => string;
|
|
41
|
+
/** Host-owned navigation on FREE completion (e.g. router navigate). */
|
|
42
|
+
onComplete?: (info: { cohortId: string; bookingId: string }) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Append an alpha to a 6-digit hex color (mirrors frontend-shared addAlphaToHexCode). */
|
|
46
|
+
const a = (hex: string, alpha: number) =>
|
|
47
|
+
hex +
|
|
48
|
+
Math.round(alpha * 255)
|
|
49
|
+
.toString(16)
|
|
50
|
+
.padStart(2, "0");
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Themed cohort enrolment: an "Enrol" trigger and a modal running plan choice,
|
|
54
|
+
* buyer details, then payment, on `useCohortCheckout`.
|
|
55
|
+
*
|
|
56
|
+
* THE MISSING HALF. The cohort checkout has existed on the backend
|
|
57
|
+
* (`/public/cohorts/:id/enrollments`) with nothing on any storefront calling it,
|
|
58
|
+
* so a creator could build a cohort, price it, publish it, and have no way to
|
|
59
|
+
* sell a seat. The shared `PricingPlans` block drew the prices as text with no
|
|
60
|
+
* action behind them.
|
|
61
|
+
*
|
|
62
|
+
* WHY PLAN CHOICE IS ITS OWN STEP. A cohort is priced from its plans, so "pay in
|
|
63
|
+
* full or three instalments" is the decision the buyer came to make. It is
|
|
64
|
+
* shown first, with the creator's default preselected.
|
|
65
|
+
*/
|
|
66
|
+
export function CohortCheckout({
|
|
67
|
+
cohortId,
|
|
68
|
+
triggerText,
|
|
69
|
+
formatAmount,
|
|
70
|
+
renderPayment,
|
|
71
|
+
finalisePath,
|
|
72
|
+
onComplete,
|
|
73
|
+
}: CohortCheckoutProps) {
|
|
74
|
+
const t = useForgeT();
|
|
75
|
+
const theme = useForgeTheme();
|
|
76
|
+
const registered = usePaymentRenderer();
|
|
77
|
+
const fmt = useAmountFormatter(formatAmount);
|
|
78
|
+
const c = useCohortCheckout(cohortId, { finalisePath, onComplete });
|
|
79
|
+
const renderPay = renderPayment ?? registered ?? undefined;
|
|
80
|
+
const [open, setOpen] = useState(false);
|
|
81
|
+
|
|
82
|
+
const tryClose = (next: boolean) => {
|
|
83
|
+
if (!next && c.step === "payment" && typeof window !== "undefined") {
|
|
84
|
+
if (!window.confirm(t("forge.cohort_checkout.confirm_leave"))) return;
|
|
85
|
+
}
|
|
86
|
+
setOpen(next);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Nothing to sell is not an error, it is a cohort with no plans yet. Drawing a
|
|
90
|
+
// dead "Enrol" button would be worse than drawing nothing.
|
|
91
|
+
if (!c.isLoading && c.plans.length === 0) return null;
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<DialogRoot open={open} onOpenChange={tryClose}>
|
|
95
|
+
<DialogTrigger asChild>
|
|
96
|
+
<button style={triggerButtonStyle(theme)}>{triggerText ?? t("forge.cohort_checkout.trigger")}</button>
|
|
97
|
+
</DialogTrigger>
|
|
98
|
+
<DialogPortal>
|
|
99
|
+
<DialogOverlay style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", zIndex: 50 }} />
|
|
100
|
+
<DialogContent
|
|
101
|
+
style={{
|
|
102
|
+
position: "fixed",
|
|
103
|
+
top: "50%",
|
|
104
|
+
left: "50%",
|
|
105
|
+
transform: "translate(-50%, -50%)",
|
|
106
|
+
width: "min(640px, calc(100vw - 24px))",
|
|
107
|
+
maxHeight: "88vh",
|
|
108
|
+
overflowY: "auto",
|
|
109
|
+
background: theme.colors.background,
|
|
110
|
+
color: theme.colors.text,
|
|
111
|
+
borderRadius: theme.cornerRadius,
|
|
112
|
+
padding: 24,
|
|
113
|
+
zIndex: 51,
|
|
114
|
+
fontFamily: theme.fontFamily,
|
|
115
|
+
}}
|
|
116
|
+
aria-describedby={undefined}
|
|
117
|
+
>
|
|
118
|
+
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
|
|
119
|
+
<DialogTitle style={{ fontSize: 18, fontWeight: 700, margin: 0 }}>
|
|
120
|
+
{c.cohort?.title ?? c.course?.title ?? t("forge.cohort_checkout.title")}
|
|
121
|
+
</DialogTitle>
|
|
122
|
+
<DialogClose asChild>
|
|
123
|
+
<button aria-label={t("forge.cohort_checkout.aria_close")} style={closeButtonStyle(theme)}>
|
|
124
|
+
<X size={18} />
|
|
125
|
+
</button>
|
|
126
|
+
</DialogClose>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
{c.isLoading && <Loading />}
|
|
130
|
+
{!c.isLoading && c.plans.length === 0 && (
|
|
131
|
+
<p style={{ marginTop: 8 }}>{t("forge.cohort_checkout.no_plans")}</p>
|
|
132
|
+
)}
|
|
133
|
+
{!c.isLoading && c.plans.length > 0 && <Body c={c} fmt={fmt} renderPayment={renderPay} />}
|
|
134
|
+
</DialogContent>
|
|
135
|
+
</DialogPortal>
|
|
136
|
+
</DialogRoot>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
type Checkout = ReturnType<typeof useCohortCheckout>;
|
|
141
|
+
|
|
142
|
+
function Body({
|
|
143
|
+
c,
|
|
144
|
+
fmt,
|
|
145
|
+
renderPayment,
|
|
146
|
+
}: {
|
|
147
|
+
c: Checkout;
|
|
148
|
+
fmt: (n: number) => string;
|
|
149
|
+
renderPayment?: (props: PaymentRenderProps) => ReactNode;
|
|
150
|
+
}) {
|
|
151
|
+
const stepIndex = c.step === "plan" ? 0 : c.step === "details" ? 1 : 2;
|
|
152
|
+
return (
|
|
153
|
+
<div style={{ width: "100%" }}>
|
|
154
|
+
<Progress current={stepIndex} total={3} />
|
|
155
|
+
{c.step === "plan" && <PlanStep c={c} fmt={fmt} />}
|
|
156
|
+
{c.step === "details" && <DetailsStep c={c} fmt={fmt} />}
|
|
157
|
+
{c.step === "payment" && <PaymentStep c={c} fmt={fmt} renderPayment={renderPayment} />}
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function Progress({ current, total }: { current: number; total: number }) {
|
|
163
|
+
const theme = useForgeTheme();
|
|
164
|
+
const steps = Array.from({ length: total }, (_, i) => i);
|
|
165
|
+
return (
|
|
166
|
+
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 24 }}>
|
|
167
|
+
{steps.map((s, index) => (
|
|
168
|
+
<div key={s} style={{ display: "flex", alignItems: "center", flex: index < steps.length - 1 ? 1 : "0 0 auto" }}>
|
|
169
|
+
<div
|
|
170
|
+
style={{
|
|
171
|
+
width: 32,
|
|
172
|
+
height: 32,
|
|
173
|
+
borderRadius: "9999px",
|
|
174
|
+
display: "flex",
|
|
175
|
+
alignItems: "center",
|
|
176
|
+
justifyContent: "center",
|
|
177
|
+
fontSize: 14,
|
|
178
|
+
fontWeight: 600,
|
|
179
|
+
border: `1px solid ${index <= current ? theme.colors.primary : a(theme.colors.primary, 0.25)}`,
|
|
180
|
+
background: index <= current ? theme.colors.primary : "transparent",
|
|
181
|
+
color: index <= current ? "#fff" : theme.colors.primary,
|
|
182
|
+
}}
|
|
183
|
+
>
|
|
184
|
+
{index + 1}
|
|
185
|
+
</div>
|
|
186
|
+
{index < steps.length - 1 && (
|
|
187
|
+
<div
|
|
188
|
+
style={{
|
|
189
|
+
flex: 1,
|
|
190
|
+
height: 2,
|
|
191
|
+
margin: "0 16px",
|
|
192
|
+
background: index < current ? theme.colors.primary : a(theme.colors.primary, 0.13),
|
|
193
|
+
}}
|
|
194
|
+
/>
|
|
195
|
+
)}
|
|
196
|
+
</div>
|
|
197
|
+
))}
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** One line of plan copy: "3 payments of $200, every 30 days" or "Pay in full". */
|
|
203
|
+
function planTerms(
|
|
204
|
+
t: ReturnType<typeof useForgeT>,
|
|
205
|
+
plan: Checkout["plans"][number],
|
|
206
|
+
fmt: (n: number) => string,
|
|
207
|
+
): string {
|
|
208
|
+
const total = Number(plan.totalAmount);
|
|
209
|
+
if (plan.installmentCount > 1) {
|
|
210
|
+
return t("forge.cohort_checkout.plan_installments", {
|
|
211
|
+
count: String(plan.installmentCount),
|
|
212
|
+
amount: fmt(total / plan.installmentCount),
|
|
213
|
+
days: String(plan.intervalDays),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
return t("forge.cohort_checkout.plan_in_full");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Step 1: which plan ────────────────────────────────────────────────────────
|
|
220
|
+
function PlanStep({ c, fmt }: { c: Checkout; fmt: (n: number) => string }) {
|
|
221
|
+
const t = useForgeT();
|
|
222
|
+
const theme = useForgeTheme();
|
|
223
|
+
return (
|
|
224
|
+
<div>
|
|
225
|
+
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>{t("forge.cohort_checkout.plan_title")}</h2>
|
|
226
|
+
<p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>{t("forge.cohort_checkout.plan_subtitle")}</p>
|
|
227
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
228
|
+
{c.plans.map((plan) => {
|
|
229
|
+
const selected = plan.id === c.planId;
|
|
230
|
+
return (
|
|
231
|
+
<button
|
|
232
|
+
key={plan.id}
|
|
233
|
+
type="button"
|
|
234
|
+
onClick={() => c.choosePlan(plan.id)}
|
|
235
|
+
style={{
|
|
236
|
+
textAlign: "left",
|
|
237
|
+
padding: 16,
|
|
238
|
+
cursor: "pointer",
|
|
239
|
+
border: `${selected ? 2 : 1}px solid ${
|
|
240
|
+
selected ? theme.colors.primary : a(theme.colors.primary, 0.25)
|
|
241
|
+
}`,
|
|
242
|
+
borderRadius: theme.cornerRadius,
|
|
243
|
+
background: "transparent",
|
|
244
|
+
color: theme.colors.text,
|
|
245
|
+
fontFamily: theme.fontFamily,
|
|
246
|
+
}}
|
|
247
|
+
>
|
|
248
|
+
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 12 }}>
|
|
249
|
+
<span style={{ fontWeight: 600 }}>{plan.title}</span>
|
|
250
|
+
<span style={{ fontWeight: 700, color: theme.colors.primary }}>{fmt(Number(plan.totalAmount))}</span>
|
|
251
|
+
</div>
|
|
252
|
+
<div style={{ fontSize: 13, opacity: 0.7, marginTop: 4 }}>{planTerms(t, plan, fmt)}</div>
|
|
253
|
+
</button>
|
|
254
|
+
);
|
|
255
|
+
})}
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── Step 2: buyer details ─────────────────────────────────────────────────────
|
|
262
|
+
function DetailsStep({ c, fmt }: { c: Checkout; fmt: (n: number) => string }) {
|
|
263
|
+
const t = useForgeT();
|
|
264
|
+
const theme = useForgeTheme();
|
|
265
|
+
const plan = c.selectedPlan;
|
|
266
|
+
const total = plan ? Number(plan.totalAmount) : 0;
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
<div>
|
|
270
|
+
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>{t("forge.cohort_checkout.details_title")}</h2>
|
|
271
|
+
<p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>{t("forge.cohort_checkout.details_subtitle")}</p>
|
|
272
|
+
|
|
273
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
274
|
+
<div style={{ display: "flex", gap: 12 }}>
|
|
275
|
+
<input
|
|
276
|
+
style={inputStyle(theme)}
|
|
277
|
+
placeholder={t("forge.cohort_checkout.first_name_placeholder")}
|
|
278
|
+
value={c.firstName}
|
|
279
|
+
onChange={(e) => c.setFirstName(e.target.value)}
|
|
280
|
+
/>
|
|
281
|
+
<input
|
|
282
|
+
style={inputStyle(theme)}
|
|
283
|
+
placeholder={t("forge.cohort_checkout.last_name_placeholder")}
|
|
284
|
+
value={c.lastName}
|
|
285
|
+
onChange={(e) => c.setLastName(e.target.value)}
|
|
286
|
+
/>
|
|
287
|
+
</div>
|
|
288
|
+
<input
|
|
289
|
+
style={inputStyle(theme)}
|
|
290
|
+
type="email"
|
|
291
|
+
placeholder={t("forge.cohort_checkout.email_placeholder")}
|
|
292
|
+
value={c.email}
|
|
293
|
+
onChange={(e) => c.setEmail(e.target.value)}
|
|
294
|
+
/>
|
|
295
|
+
</div>
|
|
296
|
+
|
|
297
|
+
{c.error && <p style={{ color: "#ef4444", fontSize: 13, marginTop: 8 }}>{c.error}</p>}
|
|
298
|
+
|
|
299
|
+
<div
|
|
300
|
+
style={{
|
|
301
|
+
marginTop: 20,
|
|
302
|
+
padding: 16,
|
|
303
|
+
border: `2px solid ${a(theme.colors.primary, 0.25)}`,
|
|
304
|
+
borderRadius: theme.cornerRadius,
|
|
305
|
+
}}
|
|
306
|
+
>
|
|
307
|
+
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
308
|
+
<span style={{ fontSize: 16 }}>{plan?.title}</span>
|
|
309
|
+
<span style={{ fontSize: 22, fontWeight: 700, color: theme.colors.primary }}>{fmt(total)}</span>
|
|
310
|
+
</div>
|
|
311
|
+
{plan && plan.installmentCount > 1 && (
|
|
312
|
+
<div style={{ fontSize: 13, opacity: 0.75, marginTop: 6 }}>{planTerms(t, plan, fmt)}</div>
|
|
313
|
+
)}
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
<div style={{ display: "flex", gap: 12, marginTop: 20 }}>
|
|
317
|
+
<button onClick={() => c.setStep("plan")} style={secondaryButtonStyle(theme)}>
|
|
318
|
+
{t("forge.cohort_checkout.back")}
|
|
319
|
+
</button>
|
|
320
|
+
<button onClick={() => void c.enrol()} disabled={c.isProcessing} style={primaryButtonStyle(theme)}>
|
|
321
|
+
{c.isProcessing ? t("forge.cohort_checkout.working") : t("forge.cohort_checkout.continue_to_payment")}
|
|
322
|
+
</button>
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── Step 3: payment ───────────────────────────────────────────────────────────
|
|
329
|
+
function PaymentStep({
|
|
330
|
+
c,
|
|
331
|
+
fmt,
|
|
332
|
+
renderPayment,
|
|
333
|
+
}: {
|
|
334
|
+
c: Checkout;
|
|
335
|
+
fmt: (n: number) => string;
|
|
336
|
+
renderPayment?: (props: PaymentRenderProps) => ReactNode;
|
|
337
|
+
}) {
|
|
338
|
+
const t = useForgeT();
|
|
339
|
+
const theme = useForgeTheme();
|
|
340
|
+
/**
|
|
341
|
+
* WHAT IS CHARGED NOW, not what the plan costs. On a three-payment plan the
|
|
342
|
+
* total is what they commit to and the instalment is what leaves their card
|
|
343
|
+
* today, and showing only the total tells them they are about to be charged
|
|
344
|
+
* three times too much.
|
|
345
|
+
*/
|
|
346
|
+
const dueNow = c.installmentAmount ?? c.totalAmount ?? 0;
|
|
347
|
+
const total = c.totalAmount ?? dueNow;
|
|
348
|
+
const isInstalments = (c.installmentCount ?? 1) > 1;
|
|
349
|
+
const taxSummary = summarizeTaxQuote(c.taxQuote);
|
|
350
|
+
|
|
351
|
+
return (
|
|
352
|
+
<div>
|
|
353
|
+
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>{t("forge.cohort_checkout.payment_title")}</h2>
|
|
354
|
+
<p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>{t("forge.cohort_checkout.payment_subtitle")}</p>
|
|
355
|
+
|
|
356
|
+
<div
|
|
357
|
+
style={{
|
|
358
|
+
padding: 20,
|
|
359
|
+
border: `2px solid ${a(theme.colors.primary, 0.25)}`,
|
|
360
|
+
borderRadius: theme.cornerRadius,
|
|
361
|
+
marginBottom: 16,
|
|
362
|
+
}}
|
|
363
|
+
>
|
|
364
|
+
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
365
|
+
<span style={{ fontSize: 16 }}>{t("forge.cohort_checkout.due_now")}</span>
|
|
366
|
+
<span style={{ fontSize: 22, fontWeight: 700, color: theme.colors.primary }}>
|
|
367
|
+
{fmt(dueNow)}
|
|
368
|
+
{taxSummary && (
|
|
369
|
+
<span
|
|
370
|
+
style={{ display: "block", fontSize: 12, fontWeight: 400, color: theme.colors.text, opacity: 0.65 }}
|
|
371
|
+
>
|
|
372
|
+
{taxSummary.mode === "inclusive"
|
|
373
|
+
? taxSummary.label
|
|
374
|
+
: t("forge.cohort_checkout.tax_added", { amount: taxSummary.formattedTax })}
|
|
375
|
+
</span>
|
|
376
|
+
)}
|
|
377
|
+
</span>
|
|
378
|
+
</div>
|
|
379
|
+
{isInstalments && (
|
|
380
|
+
<p style={{ fontSize: 13, opacity: 0.75, marginTop: 8 }}>
|
|
381
|
+
{t("forge.cohort_checkout.installment_note", {
|
|
382
|
+
count: String(c.installmentCount),
|
|
383
|
+
total: fmt(total),
|
|
384
|
+
})}
|
|
385
|
+
</p>
|
|
386
|
+
)}
|
|
387
|
+
</div>
|
|
388
|
+
|
|
389
|
+
{/*
|
|
390
|
+
The SECOND consent, on an instalment plan: agreeing to be charged again
|
|
391
|
+
with nobody present. Stripe sometimes needs it confirmed in the browser,
|
|
392
|
+
and a checkout that skips it takes payment one and silently fails the
|
|
393
|
+
rest.
|
|
394
|
+
*/}
|
|
395
|
+
{c.setupRequiresAction && c.setupClientSecret && renderPayment && (
|
|
396
|
+
<div style={{ marginBottom: 16 }}>
|
|
397
|
+
<p style={{ fontSize: 13, opacity: 0.8, marginBottom: 8 }}>
|
|
398
|
+
{t("forge.cohort_checkout.authorise_future_payments")}
|
|
399
|
+
</p>
|
|
400
|
+
{renderPayment({ clientSecret: c.setupClientSecret, returnUrl: c.returnUrl, amount: 0, mode: "redirect" })}
|
|
401
|
+
</div>
|
|
402
|
+
)}
|
|
403
|
+
|
|
404
|
+
{c.isPaystack ? (
|
|
405
|
+
c.canOpenPaystack ? (
|
|
406
|
+
<PaystackPayButton
|
|
407
|
+
onPay={c.openPaystackCheckout}
|
|
408
|
+
label={t("forge.cohort_checkout.pay", { amount: fmt(dueNow) })}
|
|
409
|
+
/>
|
|
410
|
+
) : (
|
|
411
|
+
<p style={{ opacity: 0.7 }}>{t("forge.cohort_checkout.preparing_payment")}</p>
|
|
412
|
+
)
|
|
413
|
+
) : !renderPayment ? (
|
|
414
|
+
<p style={{ color: "#ef4444", fontSize: 14 }}>{t("forge.cohort_checkout.payment_not_configured")}</p>
|
|
415
|
+
) : c.clientSecret ? (
|
|
416
|
+
renderPayment({ clientSecret: c.clientSecret, returnUrl: c.returnUrl, amount: dueNow, mode: "redirect" })
|
|
417
|
+
) : (
|
|
418
|
+
<p style={{ opacity: 0.7 }}>{t("forge.cohort_checkout.preparing_payment")}</p>
|
|
419
|
+
)}
|
|
420
|
+
|
|
421
|
+
<button onClick={() => c.setStep("details")} style={{ ...secondaryButtonStyle(theme), marginTop: 16 }}>
|
|
422
|
+
{t("forge.cohort_checkout.back")}
|
|
423
|
+
</button>
|
|
424
|
+
</div>
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const triggerButtonStyle = (t: ForgeTheme): CSSProperties => buttonStyle(t, { variant: "primary", fullWidth: true });
|
|
429
|
+
const primaryButtonStyle = (t: ForgeTheme): CSSProperties => buttonStyle(t, { variant: "primary" });
|
|
430
|
+
const secondaryButtonStyle = (t: ForgeTheme): CSSProperties => buttonStyle(t, { variant: "outline" });
|
|
431
|
+
|
|
432
|
+
const closeButtonStyle = (t: ForgeTheme): CSSProperties => ({
|
|
433
|
+
background: "transparent",
|
|
434
|
+
border: "none",
|
|
435
|
+
color: t.colors.text,
|
|
436
|
+
cursor: "pointer",
|
|
437
|
+
fontSize: 16,
|
|
438
|
+
lineHeight: 1,
|
|
439
|
+
opacity: 0.7,
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
const inputStyle = (t: ForgeTheme): CSSProperties => ({
|
|
443
|
+
width: "100%",
|
|
444
|
+
padding: 10,
|
|
445
|
+
border: `1px solid ${a(t.colors.primary, 0.25)}`,
|
|
446
|
+
borderRadius: t.cornerRadius,
|
|
447
|
+
background: t.colors.background,
|
|
448
|
+
color: t.colors.text,
|
|
449
|
+
fontFamily: t.fontFamily,
|
|
450
|
+
});
|