@tribe-nest/forge 3.54.0 → 3.57.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.
Files changed (40) hide show
  1. package/package.json +1 -1
  2. package/src/data/queries/useCoachingProducts.ts +15 -1
  3. package/src/data/queries/useCourses.ts +15 -1
  4. package/src/data/queries/useEvents.ts +12 -1
  5. package/src/data/queries/useMembership.ts +176 -4
  6. package/src/data/queries/useProducts.ts +15 -1
  7. package/src/data/queries/useSubscriptions.ts +21 -1
  8. package/src/i18n/de.json +74 -0
  9. package/src/i18n/en.json +74 -0
  10. package/src/server/index.ts +74 -0
  11. package/src/server/platformEvents.generated.ts +60 -0
  12. package/src/types/models.ts +127 -0
  13. package/src/ui/headless/index.ts +23 -0
  14. package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
  15. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
  16. package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
  17. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
  18. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
  19. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
  20. package/src/ui/headless/membership/membershipApplication.ts +109 -0
  21. package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
  22. package/src/ui/headless/membership/membershipTrial.ts +53 -0
  23. package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
  24. package/src/ui/index.ts +9 -0
  25. package/src/ui/payment/ForgePaymentProvider.tsx +14 -0
  26. package/src/ui/payment/ForgeStripePayment.tsx +78 -7
  27. package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
  28. package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
  29. package/src/ui/payment/stripeIntentKind.ts +32 -0
  30. package/src/ui/styled/AccountDashboard.tsx +160 -2
  31. package/src/ui/styled/LoginForm.tsx +5 -3
  32. package/src/ui/styled/MembershipCheckout.tsx +596 -256
  33. package/src/ui/styled/MembershipTierCallout.tsx +17 -3
  34. package/src/ui/styled/MembershipTiers.tsx +148 -6
  35. package/src/ui/styled/ProductGrid.tsx +14 -2
  36. package/src/ui/styled/SignupForm.tsx +4 -2
  37. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
  38. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
  39. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
  40. package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
@@ -1,12 +1,18 @@
1
1
  import { useEffect, useState, type CSSProperties } from "react";
2
2
  import { useMembershipCheckout } from "../headless/membership/useMembershipCheckout";
3
- import { useForgeT } from "../../i18n";
3
+ import { useForgeLocale, useForgeT } from "../../i18n";
4
4
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
5
5
  import { useAmountFormatter, useFormatCurrency, getCurrencySymbol } from "../format/useFormatCurrency";
6
6
  import { priceTaxCaption } from "../format/PriceDisplay";
7
7
  import { usePricesIncludeTax, useSiteConfig } from "../../data/queries/useWebsite";
8
8
  import { usePublicAuth } from "../../contexts/PublicAuthContext";
9
9
  import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
10
+ import { isMembershipInviteDead, useMembershipInviteInfo } from "../../data/queries/useMembership";
11
+ import {
12
+ tierCardAction,
13
+ type MembershipApplicationOutcome,
14
+ } from "../headless/membership/membershipApplication";
15
+ import { formatTrialDate, tierTrialDays } from "../headless/membership/membershipTrial";
10
16
  import { readableTextOn } from "../theme/contrast";
11
17
  import { cycleFloor, cycleIsFree, offeredCycles, type BillingCycle } from "../format/membershipPwyw";
12
18
  import type { MembershipTier } from "../../types/models";
@@ -31,8 +37,20 @@ export interface MembershipCheckoutProps {
31
37
  successPath?: string;
32
38
  /** Where the "View memberships" link points when the artist sells no tiers. Default `/i/membership`. */
33
39
  membershipsPath?: string;
40
+ /**
41
+ * The fan's account page, where their applications are listed. The link
42
+ * after applying points here. Default `/i/account`.
43
+ */
44
+ accountPath?: string;
34
45
  /** Format a numeric amount for display. Defaults to the runtime currency formatter. */
35
46
  formatAmount?: (amount: number) => string;
47
+ /**
48
+ * A membership invite token. Omitted, the `invite` search param of the current
49
+ * url is used, so the invite email's link works with no wiring. The page then
50
+ * names the tier and the invited email before anything else, or says plainly
51
+ * that the invite is no longer valid.
52
+ */
53
+ inviteToken?: string | null;
36
54
  className?: string;
37
55
  style?: CSSProperties;
38
56
  }
@@ -67,18 +85,21 @@ export function MembershipCheckout({
67
85
  onComplete,
68
86
  successPath = "/i/account?tab=membership&confirmSubscription=true",
69
87
  membershipsPath = "/i/membership",
88
+ accountPath = "/i/account",
70
89
  formatAmount,
90
+ inviteToken,
71
91
  className,
72
92
  style,
73
93
  }: MembershipCheckoutProps) {
74
94
  const t = useForgeT();
95
+ const locale = useForgeLocale();
75
96
  const theme = useForgeTheme();
76
97
  const fmt = useAmountFormatter(formatAmount);
77
98
  const { data: siteConfig } = useSiteConfig();
78
99
  /** Settlement currency: the currency every tier number is already in. */
79
100
  const settlementCurrency = siteConfig?.currency;
80
101
  const { convertCurrency } = useFormatCurrency();
81
- const { userSelectedCurrency } = usePublicAuth();
102
+ const { userSelectedCurrency, user } = usePublicAuth();
82
103
  /** What the visitor is reading prices in. Falls back to settlement when they
83
104
  * have not picked one, in which case both conversions below are identities. */
84
105
  const displayCurrency = (userSelectedCurrency as string | undefined) ?? settlementCurrency;
@@ -124,7 +145,9 @@ export function MembershipCheckout({
124
145
  returnPath: successPath,
125
146
  freeReturnPath: successPath,
126
147
  onComplete,
148
+ inviteToken,
127
149
  });
150
+ const inviteInfo = useMembershipInviteInfo(checkout.inviteToken);
128
151
 
129
152
  /**
130
153
  * The digits in the box, in the visitor's currency.
@@ -170,292 +193,609 @@ export function MembershipCheckout({
170
193
 
171
194
  if (checkout.isLoading) return <Loading fullPage />;
172
195
 
173
- const tier = checkout.selectedTier;
196
+ // ---- Invite banner: the tier and the invited email, before anything else ---
197
+ // State the fan acts on (who the invite is for, whether it still works), so it
198
+ // is visible content rather than a hint.
199
+ const inviteDead = !!checkout.inviteToken && isMembershipInviteDead(inviteInfo.error);
200
+ const invite = inviteInfo.data;
201
+ const emailMismatch =
202
+ !!invite && !!user?.email && user.email.trim().toLowerCase() !== invite.email.trim().toLowerCase();
203
+ const banner =
204
+ inviteDead || invite ? (
205
+ <div
206
+ role={inviteDead || emailMismatch ? "alert" : "status"}
207
+ data-testid="membership-invite-banner"
208
+ style={{ ...box, maxWidth: 560, margin: "0 auto 24px", color: theme.colors.text }}
209
+ >
210
+ {inviteDead ? (
211
+ <>
212
+ <p style={{ fontWeight: 700 }}>{t("forge.membership_checkout.invite_dead_title")}</p>
213
+ <p style={{ fontSize: 14, opacity: 0.85, marginTop: 4 }}>{t("forge.membership_checkout.invite_dead_body")}</p>
214
+ </>
215
+ ) : (
216
+ invite && (
217
+ <>
218
+ <p style={{ fontWeight: 700 }}>
219
+ {t("forge.membership_checkout.invite_title", { tier_name: invite.membershipTierName })}
220
+ </p>
221
+ <p style={{ fontSize: 14, opacity: 0.85, marginTop: 4 }}>
222
+ {t("forge.membership_checkout.invite_for_email", { email: invite.email })}
223
+ </p>
224
+ {emailMismatch && (
225
+ <p style={{ fontSize: 14, marginTop: 8, fontWeight: 600 }}>
226
+ {t("forge.membership_checkout.invite_email_mismatch", {
227
+ signed_in_email: user?.email ?? "",
228
+ email: invite.email,
229
+ })}
230
+ </p>
231
+ )}
232
+ </>
233
+ )
234
+ )}
235
+ </div>
236
+ ) : null;
237
+
238
+ const content = renderStage();
239
+ return banner ? (
240
+ <>
241
+ {banner}
242
+ {content}
243
+ </>
244
+ ) : (
245
+ content
246
+ );
247
+
248
+ function renderStage() {
249
+ const tier = checkout.selectedTier;
174
250
 
175
- // ---- Stage 1: pick a tier -------------------------------------------------
176
- if (!tier) {
177
- const tiers = checkout.tiers ?? [];
178
- if (!tiers.length) {
251
+ // ---- Application submitted --------------------------------------------------
252
+ if (tier && checkout.applicationOutcome) {
179
253
  return (
180
- <div className={className} style={card}>
181
- <h1 style={{ fontSize: 26, fontWeight: 800, marginBottom: 8 }}>
182
- {t("forge.membership_checkout.empty_title")}
183
- </h1>
184
- <p>{t("forge.membership_checkout.no_tier_body")}</p>
185
- <a
186
- href={membershipsPath}
187
- style={{ ...button, marginTop: 16, display: "inline-block", textAlign: "center", textDecoration: "none" }}
188
- >
189
- {t("forge.membership_checkout.view_memberships")}
190
- </a>
254
+ <div className={className} style={card} data-testid="membership-application-outcome">
255
+ <div style={box}>
256
+ <h1 style={{ fontSize: 24, fontWeight: 800, marginBottom: 8 }}>
257
+ {t(APPLICATION_OUTCOME_TITLE[checkout.applicationOutcome], { tier_name: tier.name })}
258
+ </h1>
259
+ <p style={{ opacity: 0.85 }}>
260
+ {checkout.applicationOutcome === "received" && checkout.application?.amount
261
+ ? t("forge.membership_checkout.application_received_paid_body", {
262
+ tier_name: tier.name,
263
+ amount: fmt(checkout.application.amount),
264
+ cycle:
265
+ checkout.application.billingCycle === "year"
266
+ ? t("forge.membership_checkout.per_year")
267
+ : t("forge.membership_checkout.per_month"),
268
+ })
269
+ : t(APPLICATION_OUTCOME_BODY[checkout.applicationOutcome], { tier_name: tier.name })}
270
+ </p>
271
+ </div>
272
+ {/* A lodged or approved application is followed on the account page.
273
+ One that closed leaves the fan choosing again, so that goes back
274
+ to the tiers. */}
275
+ {checkout.applicationOutcome === "received" || checkout.applicationOutcome === "approved" ? (
276
+ <a
277
+ href={accountPath}
278
+ data-testid="membership-application-account-link"
279
+ style={{ ...ghostButton, display: "block", textAlign: "center", textDecoration: "none", boxSizing: "border-box" }}
280
+ >
281
+ {t("forge.membership_checkout.go_to_account")}
282
+ </a>
283
+ ) : (
284
+ <a
285
+ href={membershipsPath}
286
+ style={{ ...ghostButton, display: "block", textAlign: "center", textDecoration: "none", boxSizing: "border-box" }}
287
+ >
288
+ {t("forge.membership_checkout.view_memberships")}
289
+ </a>
290
+ )}
191
291
  </div>
192
292
  );
193
293
  }
194
294
 
195
- return (
196
- <div className={className} style={wide}>
197
- <h1 style={{ fontSize: 26, fontWeight: 800, marginBottom: 24, textAlign: "center" }}>
198
- {t("forge.membership_checkout.choose_title")}
199
- </h1>
200
- <div style={{ display: "flex", flexWrap: "wrap", gap: 20, justifyContent: "center" }}>
201
- {tiers.map((option) => {
202
- const isCurrent = option.id === checkout.currentTierId;
203
- return (
204
- <div
205
- key={option.id}
206
- style={{
207
- ...box,
208
- flex: "1 1 260px",
209
- maxWidth: 340,
210
- width: "100%",
211
- display: "flex",
212
- flexDirection: "column",
213
- gap: 12,
214
- opacity: isCurrent ? 0.6 : 1,
215
- borderColor: isCurrent ? `${theme.colors.primary}99` : `${theme.colors.primary}30`,
216
- }}
217
- >
218
- {isCurrent && (
219
- <span
220
- style={{
221
- alignSelf: "center",
222
- padding: "2px 10px",
223
- borderRadius: 999,
224
- fontSize: 12,
225
- fontWeight: 700,
226
- background: theme.colors.primary,
227
- color: readableTextOn(theme.colors.primary),
228
- }}
229
- >
230
- {t("forge.membership_checkout.current_plan")}
231
- </span>
232
- )}
233
- <h2 style={{ fontSize: 20, fontWeight: 700, textAlign: "center" }}>{option.name}</h2>
234
- <p style={{ fontSize: 18, fontWeight: 700, textAlign: "center", color: theme.colors.primary }}>
235
- {tierPriceLabel(option, t, fmt)}
236
- </p>
237
- {!!option.benefits?.length && (
238
- <ul
239
- style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 6 }}
240
- >
241
- {option.benefits.slice(0, 3).map((b) => (
242
- <li key={b.id} style={{ display: "flex", gap: 8, fontSize: 14 }}>
243
- <span style={{ color: theme.colors.primary }}>✓</span>
244
- {b.title}
245
- </li>
246
- ))}
247
- {option.benefits.length > 3 && (
248
- <li style={{ fontSize: 12, opacity: 0.75 }}>
249
- {t("forge.membership_checkout.more_benefits", { count: option.benefits.length - 3 })}
250
- </li>
251
- )}
252
- </ul>
253
- )}
254
- <div style={{ marginTop: "auto" }}>
255
- {isCurrent ? (
256
- <p style={{ fontSize: 13, opacity: 0.75, textAlign: "center" }}>
257
- {t("forge.membership_checkout.on_this_plan")}
258
- </p>
259
- ) : (
260
- <button onClick={() => checkout.selectTier(option)} style={button}>
261
- {t("forge.membership_checkout.select_tier")}
262
- </button>
295
+ // ---- Stage 1: pick a tier -------------------------------------------------
296
+ if (!tier) {
297
+ const tiers = checkout.tiers ?? [];
298
+ if (!tiers.length) {
299
+ return (
300
+ <div className={className} style={card}>
301
+ <h1 style={{ fontSize: 26, fontWeight: 800, marginBottom: 8 }}>
302
+ {t("forge.membership_checkout.empty_title")}
303
+ </h1>
304
+ <p>{t("forge.membership_checkout.no_tier_body")}</p>
305
+ <a
306
+ href={membershipsPath}
307
+ style={{ ...button, marginTop: 16, display: "inline-block", textAlign: "center", textDecoration: "none" }}
308
+ >
309
+ {t("forge.membership_checkout.view_memberships")}
310
+ </a>
311
+ </div>
312
+ );
313
+ }
314
+
315
+ return (
316
+ <div className={className} style={wide}>
317
+ <h1 style={{ fontSize: 26, fontWeight: 800, marginBottom: 24, textAlign: "center" }}>
318
+ {t("forge.membership_checkout.choose_title")}
319
+ </h1>
320
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 20, justifyContent: "center" }}>
321
+ {tiers.map((option) => {
322
+ const isCurrent = option.id === checkout.currentTierId;
323
+ return (
324
+ <div
325
+ key={option.id}
326
+ style={{
327
+ ...box,
328
+ flex: "1 1 260px",
329
+ maxWidth: 340,
330
+ width: "100%",
331
+ display: "flex",
332
+ flexDirection: "column",
333
+ gap: 12,
334
+ opacity: isCurrent ? 0.6 : 1,
335
+ borderColor: isCurrent ? `${theme.colors.primary}99` : `${theme.colors.primary}30`,
336
+ }}
337
+ >
338
+ {isCurrent && (
339
+ <span
340
+ style={{
341
+ alignSelf: "center",
342
+ padding: "2px 10px",
343
+ borderRadius: 999,
344
+ fontSize: 12,
345
+ fontWeight: 700,
346
+ background: theme.colors.primary,
347
+ color: readableTextOn(theme.colors.primary),
348
+ }}
349
+ >
350
+ {t("forge.membership_checkout.current_plan")}
351
+ </span>
352
+ )}
353
+ <h2 style={{ fontSize: 20, fontWeight: 700, textAlign: "center" }}>{option.name}</h2>
354
+ <p style={{ fontSize: 18, fontWeight: 700, textAlign: "center", color: theme.colors.primary }}>
355
+ {tierPriceLabel(option, t, fmt)}
356
+ </p>
357
+ {tierTrialDays(option, user?.membership) > 0 && (
358
+ <span
359
+ data-testid="membership-tier-trial-badge"
360
+ style={{
361
+ alignSelf: "center",
362
+ padding: "2px 10px",
363
+ borderRadius: 999,
364
+ fontSize: 12,
365
+ fontWeight: 700,
366
+ border: `1px solid ${theme.colors.primary}`,
367
+ color: theme.colors.primary,
368
+ }}
369
+ >
370
+ {t("forge.membership_tiers.trial_badge", { days: tierTrialDays(option, user?.membership) })}
371
+ </span>
263
372
  )}
373
+ {!!option.benefits?.length && (
374
+ <ul
375
+ style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 6 }}
376
+ >
377
+ {option.benefits.slice(0, 3).map((b) => (
378
+ <li key={b.id} style={{ display: "flex", gap: 8, fontSize: 14 }}>
379
+ <span style={{ color: theme.colors.primary }}>✓</span>
380
+ {b.title}
381
+ </li>
382
+ ))}
383
+ {option.benefits.length > 3 && (
384
+ <li style={{ fontSize: 12, opacity: 0.75 }}>
385
+ {t("forge.membership_checkout.more_benefits", { count: option.benefits.length - 3 })}
386
+ </li>
387
+ )}
388
+ </ul>
389
+ )}
390
+ <div style={{ marginTop: "auto" }}>
391
+ {isCurrent ? (
392
+ <p style={{ fontSize: 13, opacity: 0.75, textAlign: "center" }}>
393
+ {t("forge.membership_checkout.on_this_plan")}
394
+ </p>
395
+ ) : (
396
+ <GridTierAction
397
+ tier={option}
398
+ trialDays={tierTrialDays(option, user?.membership)}
399
+ onSelect={() => checkout.selectTier(option)}
400
+ buttonStyle={button}
401
+ t={t}
402
+ />
403
+ )}
404
+ </div>
264
405
  </div>
265
- </div>
266
- );
267
- })}
406
+ );
407
+ })}
408
+ </div>
268
409
  </div>
269
- </div>
270
- );
271
- }
410
+ );
411
+ }
412
+
413
+ // ---- Stage 3: payment -----------------------------------------------------
414
+ // The registered payment UI (Stripe) in manual mode, or the Paystack modal
415
+ // behind a pay button. The Paystack branch is also what a member who closed
416
+ // the popup comes back to, so it must render whether or not a Stripe secret
417
+ // was ever issued.
418
+ if (checkout.step === "payment" && (checkout.clientSecret || checkout.canOpenPaystack)) {
419
+ return (
420
+ <div className={className} style={card}>
421
+ <h1 style={{ fontSize: 26, fontWeight: 800, marginBottom: 8 }}>
422
+ {checkout.setupPurpose === "application"
423
+ ? t("forge.membership_checkout.application_card_title")
424
+ : checkout.setupPurpose === "trial"
425
+ ? t("forge.membership_checkout.trial_card_title")
426
+ : t("forge.membership_checkout.payment_title")}
427
+ </h1>
428
+ <p style={{ opacity: 0.75, marginBottom: 24 }}>
429
+ {t("forge.membership_checkout.payment_summary", {
430
+ tier_name: tier.name,
431
+ amount: fmt(checkout.amount),
432
+ cycle: checkout.billingCycle,
433
+ })}
434
+ </p>
435
+ <div style={box}>
436
+ {checkout.isPaystack ? (
437
+ <PaystackPayButton
438
+ onPay={checkout.openPaystackCheckout}
439
+ label={t("forge.membership_checkout.pay", { amount: fmt(checkout.amount) })}
440
+ />
441
+ ) : (
442
+ checkout.clientSecret &&
443
+ renderPayment({
444
+ clientSecret: checkout.clientSecret,
445
+ returnUrl:
446
+ checkout.setupPurpose === "application" && checkout.applicationReturnUrl
447
+ ? checkout.applicationReturnUrl
448
+ : `${typeof window !== "undefined" ? window.location.origin : ""}${successPath}`,
449
+ amount: checkout.amount,
450
+ currency: settlementCurrency,
451
+ mode: "manual",
452
+ // A trial saves the card (a SetupIntent) for a subscription that
453
+ // starts now: the renderer says what is charged and when.
454
+ trial:
455
+ checkout.setupPurpose === "trial" && checkout.trialEndsAt
456
+ ? { endsAt: checkout.trialEndsAt, billingCycle: checkout.billingCycle }
457
+ : null,
458
+ // An application's card was SAVED, not charged: tell the server,
459
+ // which moves it in front of the business. Otherwise (a charged
460
+ // card, or a trial's saved card) the account page still has to
461
+ // reconcile the subscription, so this completion asks for
462
+ // confirmation.
463
+ onSucceeded: () => void checkout.onPaymentSucceeded(),
464
+ onFailed: () => {},
465
+ })
466
+ )}
467
+ </div>
468
+ {checkout.error && <p style={errorText}>{checkout.error}</p>}
469
+ <button onClick={() => checkout.setStep("select")} style={ghostButton}>
470
+ {t("forge.membership_checkout.back")}
471
+ </button>
472
+ </div>
473
+ );
474
+ }
475
+
476
+ // ---- Stage 2: tier detail, billing cycle, pay-what-you-want ---------------
477
+ const cycles = checkout.cycles;
478
+ const showBillingToggle = cycles.month && cycles.year;
479
+ const tierAction = tierCardAction(tier);
480
+ /** An application already waiting, approved or declined: nothing to submit. */
481
+ const applicationBlocked = tierAction === "pending" || tierAction === "approved" || tierAction === "declined";
482
+ const applicationStateLine =
483
+ tierAction === "pending"
484
+ ? t("forge.membership_checkout.application_state_pending")
485
+ : tierAction === "approved"
486
+ ? t("forge.membership_checkout.application_state_approved")
487
+ : tierAction === "declined"
488
+ ? t("forge.membership_checkout.application_state_declined")
489
+ : tierAction === "complete"
490
+ ? t("forge.membership_checkout.application_state_complete")
491
+ : null;
492
+ const submitLabel = checkout.isApplication
493
+ ? t("forge.membership_checkout.apply")
494
+ : tierAction === "complete"
495
+ ? t("forge.membership_checkout.complete_membership")
496
+ : checkout.isChange
497
+ ? t("forge.membership_checkout.confirm_change")
498
+ : tierAction === "accept_invite"
499
+ ? t("forge.membership_checkout.accept_invite")
500
+ : checkout.trialDays > 0
501
+ ? t("forge.membership_checkout.start_free_trial")
502
+ : t("forge.membership_checkout.submit");
272
503
 
273
- // ---- Stage 3: payment -----------------------------------------------------
274
- // The registered payment UI (Stripe) in manual mode, or the Paystack modal
275
- // behind a pay button. The Paystack branch is also what a member who closed
276
- // the popup comes back to, so it must render whether or not a Stripe secret
277
- // was ever issued.
278
- if (checkout.step === "payment" && (checkout.clientSecret || checkout.canOpenPaystack)) {
279
504
  return (
280
505
  <div className={className} style={card}>
281
- <h1 style={{ fontSize: 26, fontWeight: 800, marginBottom: 8 }}>
282
- {t("forge.membership_checkout.payment_title")}
506
+ <h1 style={{ fontSize: 26, fontWeight: 800, marginBottom: 24 }}>
507
+ {t("forge.membership_checkout.title", { tier_name: tier.name })}
283
508
  </h1>
284
- <p style={{ opacity: 0.75, marginBottom: 24 }}>
285
- {t("forge.membership_checkout.payment_summary", {
286
- tier_name: tier.name,
287
- amount: fmt(checkout.amount),
288
- cycle: checkout.billingCycle,
289
- })}
290
- </p>
509
+
291
510
  <div style={box}>
292
- {checkout.isPaystack ? (
293
- <PaystackPayButton
294
- onPay={checkout.openPaystackCheckout}
295
- label={t("forge.membership_checkout.pay", { amount: fmt(checkout.amount) })}
296
- />
297
- ) : (
298
- checkout.clientSecret &&
299
- renderPayment({
300
- clientSecret: checkout.clientSecret,
301
- returnUrl: `${typeof window !== "undefined" ? window.location.origin : ""}${successPath}`,
302
- amount: checkout.amount,
303
- currency: settlementCurrency,
304
- mode: "manual",
305
- // A card that has just been charged still needs the account page
306
- // to reconcile the subscription, so this is the one completion
307
- // that asks for confirmation.
308
- onSucceeded: () => checkout.finish(true),
309
- onFailed: () => {},
310
- })
511
+ {tier.description && <p style={{ marginBottom: 16 }}>{tier.description}</p>}
512
+ {!!tier.benefits?.length && (
513
+ <ul style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 8 }}>
514
+ {tier.benefits.map((b) => (
515
+ <li key={b.id} style={{ display: "flex", gap: 8 }}>
516
+ <span style={{ color: theme.colors.primary }}>✓</span>
517
+ {b.title}
518
+ </li>
519
+ ))}
520
+ </ul>
311
521
  )}
312
522
  </div>
313
- {checkout.error && <p style={errorText}>{checkout.error}</p>}
314
- <button onClick={() => checkout.setStep("select")} style={ghostButton}>
315
- {t("forge.membership_checkout.back")}
316
- </button>
317
- </div>
318
- );
319
- }
320
523
 
321
- // ---- Stage 2: tier detail, billing cycle, pay-what-you-want ---------------
322
- const cycles = checkout.cycles;
323
- const showBillingToggle = cycles.month && cycles.year;
324
-
325
- return (
326
- <div className={className} style={card}>
327
- <h1 style={{ fontSize: 26, fontWeight: 800, marginBottom: 24 }}>
328
- {t("forge.membership_checkout.title", { tier_name: tier.name })}
329
- </h1>
330
-
331
- <div style={box}>
332
- {tier.description && <p style={{ marginBottom: 16 }}>{tier.description}</p>}
333
- {!!tier.benefits?.length && (
334
- <ul style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 8 }}>
335
- {tier.benefits.map((b) => (
336
- <li key={b.id} style={{ display: "flex", gap: 8 }}>
337
- <span style={{ color: theme.colors.primary }}>✓</span>
338
- {b.title}
339
- </li>
340
- ))}
341
- </ul>
342
- )}
343
- </div>
524
+ <div style={box}>
525
+ {showBillingToggle && (
526
+ <div style={{ marginBottom: 16 }}>
527
+ <p style={{ fontSize: 14, marginBottom: 8 }}>{t("forge.membership_checkout.billing_cycle_label")}</p>
528
+ <div style={{ display: "flex", gap: 16, flexWrap: "wrap" }}>
529
+ {(["month", "year"] as const).map((cycle) => (
530
+ <label key={cycle} style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer" }}>
531
+ <input
532
+ type="radio"
533
+ name="billingCycle"
534
+ checked={checkout.billingCycle === cycle}
535
+ onChange={() => checkout.setBillingCycle(cycle)}
536
+ style={{ accentColor: theme.colors.primary }}
537
+ />
538
+ {cycleLabel(tier, cycle, t, fmt)}
539
+ </label>
540
+ ))}
541
+ </div>
542
+ </div>
543
+ )}
344
544
 
345
- <div style={box}>
346
- {showBillingToggle && (
347
- <div style={{ marginBottom: 16 }}>
348
- <p style={{ fontSize: 14, marginBottom: 8 }}>{t("forge.membership_checkout.billing_cycle_label")}</p>
349
- <div style={{ display: "flex", gap: 16, flexWrap: "wrap" }}>
350
- {(["month", "year"] as const).map((cycle) => (
351
- <label key={cycle} style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer" }}>
352
- <input
353
- type="radio"
354
- name="billingCycle"
355
- checked={checkout.billingCycle === cycle}
356
- onChange={() => checkout.setBillingCycle(cycle)}
357
- style={{ accentColor: theme.colors.primary }}
358
- />
359
- {cycleLabel(tier, cycle, t, fmt)}
360
- </label>
361
- ))}
545
+ {tier.payWhatYouWant && (
546
+ <div style={{ marginBottom: 16 }}>
547
+ {/* The floor is printed in the SETTLEMENT currency because that is
548
+ the currency of the number this box sends. A unit that changes
549
+ what you type belongs in the label. */}
550
+ <label htmlFor="forge-pwyw-amount" style={{ fontSize: 14, display: "block", marginBottom: 8 }}>
551
+ {t("forge.membership_checkout.pwyw_label")}{" "}
552
+ {t("forge.membership_checkout.pwyw_min", { amount: fmt(checkout.minimumAmount) })}
553
+ </label>
554
+ <input
555
+ id="forge-pwyw-amount"
556
+ type="number"
557
+ min={Number(toDisplay(checkout.minimumAmount).toFixed(2))}
558
+ step="any"
559
+ value={typedAmount}
560
+ onChange={(e) => {
561
+ setTypedAmount(e.target.value);
562
+ // Converted back on the way out. The floor is checked against
563
+ // THIS number, not the typed one, so a rate that rounds down
564
+ // cannot slip a fan under the artist's minimum.
565
+ checkout.setCustomAmount(toSettlement(Number(e.target.value)));
566
+ }}
567
+ aria-invalid={!!checkout.amountRefusal}
568
+ style={{
569
+ width: "100%",
570
+ height: 40,
571
+ padding: "8px 12px",
572
+ borderRadius: theme.cornerRadius,
573
+ border: `1px solid ${checkout.amountRefusal ? "#ef4444" : `${theme.colors.primary}40`}`,
574
+ background: "transparent",
575
+ color: theme.colors.text,
576
+ outline: "none",
577
+ }}
578
+ />
579
+ {/* What will ACTUALLY be taken, when rounding to a whole settlement
580
+ unit means it is not quite what was typed. Shown only when the
581
+ two differ, because a line that always appears is a line nobody
582
+ reads. This is state the fan has to act on, not an explanation,
583
+ so it stays visible rather than going behind a `?`. */}
584
+ {!checkout.amountRefusal && Number(typedAmount) > 0 && (() => {
585
+ const settled = toSettlement(Number(typedAmount));
586
+ const backInDisplay = Number(toDisplay(settled).toFixed(2));
587
+ return Math.abs(backInDisplay - Number(typedAmount)) >= 0.01 ? (
588
+ <p style={{ fontSize: 13, opacity: 0.75, marginTop: 6 }}>
589
+ {t("forge.membership_checkout.pwyw_charged_as", { amount: fmt(settled) })}
590
+ </p>
591
+ ) : null;
592
+ })()}
593
+ {checkout.amountRefusal && (
594
+ <p role="alert" style={errorText}>
595
+ {checkout.amountRefusal.reason === "below_minimum"
596
+ ? t("forge.membership_checkout.amount_below_minimum", {
597
+ minimum: fmt(checkout.amountRefusal.minimum),
598
+ })
599
+ : t("forge.membership_checkout.amount_not_positive")}
600
+ </p>
601
+ )}
362
602
  </div>
603
+ )}
604
+
605
+ <div style={{ textAlign: "center", paddingTop: 16, borderTop: `1px solid ${theme.colors.primary}20` }}>
606
+ <p style={{ fontSize: 14, opacity: 0.75 }}>{t("forge.membership_checkout.total")}</p>
607
+ <p style={{ fontSize: 28, fontWeight: 800 }}>
608
+ {checkout.isFreeCycle ? t("forge.membership_checkout.free") : fmt(checkout.amount)}
609
+ </p>
610
+ <p style={{ fontSize: 14, opacity: 0.75 }}>
611
+ {checkout.billingCycle === "month"
612
+ ? t("forge.membership_checkout.per_month")
613
+ : t("forge.membership_checkout.per_year")}
614
+ </p>
615
+ {taxCaption && !checkout.isFreeCycle && (
616
+ <p style={{ fontSize: 12, opacity: 0.65, marginTop: 2 }}>{taxCaption}</p>
617
+ )}
618
+ {/* When the first charge falls is what the fan is agreeing to, so it
619
+ is content, not a hint. */}
620
+ {checkout.trialDays > 0 && checkout.trialEndsAt && (
621
+ <p data-testid="membership-checkout-trial-line" style={{ fontSize: 14, fontWeight: 600, marginTop: 8 }}>
622
+ {t("forge.membership_checkout.trial_line", {
623
+ days: checkout.trialDays,
624
+ date: formatTrialDate(checkout.trialEndsAt, locale),
625
+ })}
626
+ </p>
627
+ )}
363
628
  </div>
364
- )}
629
+ </div>
365
630
 
366
- {tier.payWhatYouWant && (
367
- <div style={{ marginBottom: 16 }}>
368
- {/* The floor is printed in the SETTLEMENT currency because that is
369
- the currency of the number this box sends. A unit that changes
370
- what you type belongs in the label. */}
371
- <label htmlFor="forge-pwyw-amount" style={{ fontSize: 14, display: "block", marginBottom: 8 }}>
372
- {t("forge.membership_checkout.pwyw_label")}{" "}
373
- {t("forge.membership_checkout.pwyw_min", { amount: fmt(checkout.minimumAmount) })}
374
- </label>
375
- <input
376
- id="forge-pwyw-amount"
377
- type="number"
378
- min={Number(toDisplay(checkout.minimumAmount).toFixed(2))}
379
- step="any"
380
- value={typedAmount}
381
- onChange={(e) => {
382
- setTypedAmount(e.target.value);
383
- // Converted back on the way out. The floor is checked against
384
- // THIS number, not the typed one, so a rate that rounds down
385
- // cannot slip a fan under the artist's minimum.
386
- checkout.setCustomAmount(toSettlement(Number(e.target.value)));
387
- }}
388
- aria-invalid={!!checkout.amountRefusal}
389
- style={{
390
- width: "100%",
391
- height: 40,
392
- padding: "8px 12px",
393
- borderRadius: theme.cornerRadius,
394
- border: `1px solid ${checkout.amountRefusal ? "#ef4444" : `${theme.colors.primary}40`}`,
395
- background: "transparent",
396
- color: theme.colors.text,
397
- outline: "none",
398
- }}
399
- />
400
- {/* What will ACTUALLY be taken, when rounding to a whole settlement
401
- unit means it is not quite what was typed. Shown only when the
402
- two differ, because a line that always appears is a line nobody
403
- reads. This is state the fan has to act on, not an explanation,
404
- so it stays visible rather than going behind a `?`. */}
405
- {!checkout.amountRefusal && Number(typedAmount) > 0 && (() => {
406
- const settled = toSettlement(Number(typedAmount));
407
- const backInDisplay = Number(toDisplay(settled).toFixed(2));
408
- return Math.abs(backInDisplay - Number(typedAmount)) >= 0.01 ? (
409
- <p style={{ fontSize: 13, opacity: 0.75, marginTop: 6 }}>
410
- {t("forge.membership_checkout.pwyw_charged_as", { amount: fmt(settled) })}
411
- </p>
412
- ) : null;
413
- })()}
414
- {checkout.amountRefusal && (
415
- <p role="alert" style={errorText}>
416
- {checkout.amountRefusal.reason === "below_minimum"
417
- ? t("forge.membership_checkout.amount_below_minimum", {
418
- minimum: fmt(checkout.amountRefusal.minimum),
419
- })
420
- : t("forge.membership_checkout.amount_not_positive")}
631
+ {/* The tier's questions, on every join mode. Answering them is part of
632
+ joining, and a refusal is content the fan acts on, so both stay
633
+ visible. */}
634
+ {checkout.questionnaire.length > 0 && !applicationBlocked && (
635
+ <div style={{ ...box, marginBottom: 16 }} data-testid="membership-questionnaire">
636
+ <h2 style={{ fontSize: 17, fontWeight: 700, marginBottom: 12 }}>
637
+ {t("forge.membership_checkout.questions_title")}
638
+ </h2>
639
+ <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
640
+ {checkout.questionnaire.map((q) => {
641
+ const value = checkout.questionnaireAnswers[q.id] ?? "";
642
+ const missing = !!checkout.questionnaireRefusal && !q.optional && !value.trim();
643
+ const fieldId = `forge-membership-question-${q.id}`;
644
+ const fieldStyle: CSSProperties = {
645
+ width: "100%",
646
+ padding: "8px 12px",
647
+ borderRadius: theme.cornerRadius,
648
+ border: `1px solid ${missing ? "#ef4444" : `${theme.colors.primary}40`}`,
649
+ background: "transparent",
650
+ color: theme.colors.text,
651
+ outline: "none",
652
+ boxSizing: "border-box",
653
+ };
654
+ return (
655
+ <div key={q.id} style={{ display: "flex", flexDirection: "column", gap: 6, fontSize: 14 }}>
656
+ <label htmlFor={fieldId}>
657
+ {q.question}
658
+ {!q.optional && (
659
+ <span aria-hidden="true" style={{ color: "#ef4444" }}>
660
+ {" "}
661
+ *
662
+ </span>
663
+ )}
664
+ </label>
665
+ {q.type === "select" && q.options && q.options.length > 0 ? (
666
+ <select
667
+ id={fieldId}
668
+ value={value}
669
+ required={!q.optional}
670
+ aria-invalid={missing}
671
+ onChange={(e) => checkout.setQuestionnaireAnswer(q.id, e.target.value)}
672
+ style={fieldStyle}
673
+ >
674
+ <option value="">{t("forge.membership_checkout.select_placeholder")}</option>
675
+ {q.options.map((opt) => (
676
+ <option key={opt} value={opt}>
677
+ {opt}
678
+ </option>
679
+ ))}
680
+ </select>
681
+ ) : q.type === "textarea" ? (
682
+ <textarea
683
+ id={fieldId}
684
+ value={value}
685
+ rows={3}
686
+ required={!q.optional}
687
+ aria-invalid={missing}
688
+ onChange={(e) => checkout.setQuestionnaireAnswer(q.id, e.target.value)}
689
+ style={{ ...fieldStyle, resize: "vertical" }}
690
+ />
691
+ ) : (
692
+ <input
693
+ id={fieldId}
694
+ value={value}
695
+ required={!q.optional}
696
+ aria-invalid={missing}
697
+ onChange={(e) => checkout.setQuestionnaireAnswer(q.id, e.target.value)}
698
+ style={{ ...fieldStyle, height: 40 }}
699
+ />
700
+ )}
701
+ {missing && (
702
+ <p style={{ color: "#ef4444", fontSize: 13 }}>{t("forge.membership_checkout.field_required")}</p>
703
+ )}
704
+ </div>
705
+ );
706
+ })}
707
+ </div>
708
+ {checkout.questionnaireRefusal && (
709
+ <p role="alert" data-testid="membership-questionnaire-refusal" style={errorText}>
710
+ {t("forge.membership_checkout.answer_required", {
711
+ question: checkout.questionnaireRefusal.question,
712
+ })}
421
713
  </p>
422
714
  )}
423
715
  </div>
424
716
  )}
425
717
 
426
- <div style={{ textAlign: "center", paddingTop: 16, borderTop: `1px solid ${theme.colors.primary}20` }}>
427
- <p style={{ fontSize: 14, opacity: 0.75 }}>{t("forge.membership_checkout.total")}</p>
428
- <p style={{ fontSize: 28, fontWeight: 800 }}>
429
- {checkout.isFreeCycle ? t("forge.membership_checkout.free") : fmt(checkout.amount)}
430
- </p>
431
- <p style={{ fontSize: 14, opacity: 0.75 }}>
432
- {checkout.billingCycle === "month"
433
- ? t("forge.membership_checkout.per_month")
434
- : t("forge.membership_checkout.per_year")}
718
+ {applicationStateLine && (
719
+ <p role="status" data-testid="membership-application-state" style={{ fontWeight: 600, marginBottom: 16 }}>
720
+ {applicationStateLine}
435
721
  </p>
436
- {taxCaption && !checkout.isFreeCycle && (
437
- <p style={{ fontSize: 12, opacity: 0.65, marginTop: 2 }}>{taxCaption}</p>
722
+ )}
723
+
724
+ {checkout.error && <p style={{ ...errorText, marginBottom: 16 }}>{checkout.error}</p>}
725
+
726
+ <div style={{ display: "flex", gap: 12 }}>
727
+ {!initialTierId && (
728
+ <button onClick={checkout.backToTierList} disabled={checkout.isProcessing} style={ghostButton}>
729
+ {t("forge.membership_checkout.back")}
730
+ </button>
731
+ )}
732
+ {!applicationBlocked && (
733
+ <button onClick={() => checkout.subscribe()} disabled={checkout.isProcessing} style={button}>
734
+ {checkout.isProcessing
735
+ ? t("forge.membership_checkout.submitting")
736
+ : submitLabel}
737
+ </button>
438
738
  )}
439
739
  </div>
440
740
  </div>
741
+ );
742
+ }
743
+ }
441
744
 
442
- {checkout.error && <p style={{ ...errorText, marginBottom: 16 }}>{checkout.error}</p>}
745
+ const APPLICATION_OUTCOME_TITLE: Record<MembershipApplicationOutcome, string> = {
746
+ received: "forge.membership_checkout.application_received_title",
747
+ approved: "forge.membership_checkout.application_approved_title",
748
+ tier_full: "forge.membership_checkout.application_tier_full_title",
749
+ declined: "forge.membership_checkout.application_declined_title",
750
+ closed: "forge.membership_checkout.application_closed_title",
751
+ };
443
752
 
444
- <div style={{ display: "flex", gap: 12 }}>
445
- {!initialTierId && (
446
- <button onClick={checkout.backToTierList} disabled={checkout.isProcessing} style={ghostButton}>
447
- {t("forge.membership_checkout.back")}
448
- </button>
449
- )}
450
- <button onClick={() => checkout.subscribe()} disabled={checkout.isProcessing} style={button}>
451
- {checkout.isProcessing
452
- ? t("forge.membership_checkout.submitting")
453
- : checkout.isChange
454
- ? t("forge.membership_checkout.confirm_change")
455
- : t("forge.membership_checkout.submit")}
456
- </button>
457
- </div>
458
- </div>
753
+ const APPLICATION_OUTCOME_BODY: Record<MembershipApplicationOutcome, string> = {
754
+ received: "forge.membership_checkout.application_received_body",
755
+ approved: "forge.membership_checkout.application_approved_body",
756
+ tier_full: "forge.membership_checkout.application_tier_full_body",
757
+ declined: "forge.membership_checkout.application_declined_body",
758
+ closed: "forge.membership_checkout.application_closed_body",
759
+ };
760
+
761
+ /** A tier card's action in the grid: a select button, or where the fan's application stands. */
762
+ function GridTierAction({
763
+ tier,
764
+ trialDays,
765
+ onSelect,
766
+ buttonStyle,
767
+ t,
768
+ }: {
769
+ tier: MembershipTier;
770
+ trialDays: number;
771
+ onSelect: () => void;
772
+ buttonStyle: CSSProperties;
773
+ t: (key: string, vars?: Record<string, string | number>) => string;
774
+ }) {
775
+ const action = tierCardAction(tier);
776
+ if (action === "pending" || action === "approved" || action === "declined") {
777
+ return (
778
+ <p style={{ fontSize: 13, fontWeight: 600, textAlign: "center" }}>
779
+ {action === "pending"
780
+ ? t("forge.membership_tiers.application_pending")
781
+ : action === "approved"
782
+ ? t("forge.membership_tiers.application_approved")
783
+ : t("forge.membership_tiers.application_declined")}
784
+ </p>
785
+ );
786
+ }
787
+ return (
788
+ <button onClick={onSelect} style={buttonStyle}>
789
+ {action === "accept_invite"
790
+ ? t("forge.membership_tiers.accept_invite")
791
+ : action === "apply"
792
+ ? t("forge.membership_tiers.apply")
793
+ : action === "complete"
794
+ ? t("forge.membership_tiers.complete_membership")
795
+ : trialDays > 0
796
+ ? t("forge.membership_tiers.start_free_trial")
797
+ : t("forge.membership_checkout.select_tier")}
798
+ </button>
459
799
  );
460
800
  }
461
801