@tribe-nest/forge 3.53.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 (74) hide show
  1. package/package.json +1 -1
  2. package/src/contexts/AppAuthContext.tsx +3 -2
  3. package/src/contexts/AudioPlayerContext.tsx +3 -2
  4. package/src/contexts/CartContext.tsx +3 -2
  5. package/src/contexts/PublicAuthContext.tsx +3 -2
  6. package/src/data/queries/useCoachingProducts.ts +15 -1
  7. package/src/data/queries/useCourses.ts +15 -1
  8. package/src/data/queries/useEvents.ts +26 -1
  9. package/src/data/queries/useMembership.ts +176 -4
  10. package/src/data/queries/useMyTickets.ts +49 -0
  11. package/src/data/queries/usePaymentFlow.ts +8 -0
  12. package/src/data/queries/useProducts.ts +15 -1
  13. package/src/data/queries/useSubscriptions.ts +21 -1
  14. package/src/i18n/de.json +116 -1
  15. package/src/i18n/en.json +116 -1
  16. package/src/i18n/index.ts +3 -2
  17. package/src/index.ts +22 -0
  18. package/src/provider/ForgeAppProvider.tsx +8 -1
  19. package/src/provider/ForgeProvider.tsx +3 -2
  20. package/src/provider/SiteConfigProvider.tsx +3 -2
  21. package/src/runtime/RemotePage.tsx +110 -0
  22. package/src/runtime/hostRuntime.ts +84 -0
  23. package/src/runtime/pages.spec.ts +41 -0
  24. package/src/runtime/pages.ts +183 -0
  25. package/src/runtime/pagesClient.ts +127 -0
  26. package/src/runtime/registry.spec.ts +45 -0
  27. package/src/runtime/registry.ts +102 -0
  28. package/src/server/index.ts +74 -0
  29. package/src/server/jobs.ts +4 -1
  30. package/src/server/platformEvents.generated.ts +152 -0
  31. package/src/types/models.ts +185 -0
  32. package/src/ui/headless/event/_tests/ticketApproval.spec.ts +137 -0
  33. package/src/ui/headless/event/_tests/useEventCheckoutApproval.spec.tsx +250 -0
  34. package/src/ui/headless/event/ticketApproval.ts +109 -0
  35. package/src/ui/headless/event/useEventCheckout.ts +74 -4
  36. package/src/ui/headless/index.ts +34 -0
  37. package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
  38. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
  39. package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
  40. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
  41. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
  42. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
  43. package/src/ui/headless/membership/membershipApplication.ts +109 -0
  44. package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
  45. package/src/ui/headless/membership/membershipTrial.ts +53 -0
  46. package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
  47. package/src/ui/index.ts +27 -0
  48. package/src/ui/payment/ForgePaymentProvider.tsx +29 -0
  49. package/src/ui/payment/ForgeStripePayment.tsx +110 -5
  50. package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
  51. package/src/ui/payment/_tests/stripeConfirmOutcome.spec.ts +61 -0
  52. package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
  53. package/src/ui/payment/stripeConfirmOutcome.ts +50 -0
  54. package/src/ui/payment/stripeIntentKind.ts +32 -0
  55. package/src/ui/shell/TribeNestApp.tsx +6 -0
  56. package/src/ui/styled/AccountDashboard.tsx +239 -15
  57. package/src/ui/styled/DocumentSigningPage.tsx +516 -0
  58. package/src/ui/styled/EventConfirmation.tsx +106 -9
  59. package/src/ui/styled/EventTickets.tsx +107 -13
  60. package/src/ui/styled/LoginForm.tsx +5 -3
  61. package/src/ui/styled/MembershipCheckout.tsx +596 -256
  62. package/src/ui/styled/MembershipTierCallout.tsx +17 -3
  63. package/src/ui/styled/MembershipTiers.tsx +148 -6
  64. package/src/ui/styled/ProductGrid.tsx +14 -2
  65. package/src/ui/styled/ReviewRequestPage.tsx +214 -0
  66. package/src/ui/styled/SignupForm.tsx +4 -2
  67. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
  68. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
  69. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
  70. package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
  71. package/src/ui/styled/forge-utilities.css +310 -0
  72. package/src/ui/theme/ForgeThemeProvider.tsx +3 -2
  73. package/src/utils/_tests/ticketOrderOutcome.spec.ts +82 -0
  74. package/src/utils/ticketOrderOutcome.ts +57 -1
@@ -1,6 +1,11 @@
1
1
  import { useCallback, useEffect, useMemo, useState } from "react";
2
2
  import { usePublicAuth } from "../../../contexts/PublicAuthContext";
3
- import { useGetMembershipTiers } from "../../../data/queries/useMembership";
3
+ import {
4
+ readMembershipInviteFromUrl,
5
+ useApplyForMembership,
6
+ useConfirmMembershipApplication,
7
+ useGetMembershipTiers,
8
+ } from "../../../data/queries/useMembership";
4
9
  import {
5
10
  useCreateSubscription,
6
11
  useCreateFreeSubscription,
@@ -14,7 +19,15 @@ import {
14
19
  type PaystackCheckoutOutcome,
15
20
  type PaystackCheckoutSession,
16
21
  } from "../../../utils/paystackCheckout";
17
- import type { MembershipTier } from "../../../types/models";
22
+ import type { MembershipApplication, MembershipTier, QuestionnaireQuestion } from "../../../types/models";
23
+ import { stripeIntentKind } from "../../payment/stripeIntentKind";
24
+ import { tierTrialDays, trialEndDate } from "./membershipTrial";
25
+ import { buildQuestionnaireAnswers, firstMissingAnswer, tierQuestionnaire } from "./membershipQuestionnaire";
26
+ import {
27
+ membershipApplicationOutcome,
28
+ tierUsesApplication,
29
+ type MembershipApplicationOutcome,
30
+ } from "./membershipApplication";
18
31
  import {
19
32
  cycleCeiling,
20
33
  cycleFloor,
@@ -54,6 +67,19 @@ export interface UseMembershipCheckoutOptions {
54
67
  * is no provider payment for the account page to reconcile.
55
68
  */
56
69
  onComplete?: (result: { requiresConfirmation: boolean }) => void;
70
+ /**
71
+ * A membership invite token, sent with the subscribe call and the tier list so
72
+ * an invite-only tier can be joined. Omitted, the `invite` search param of
73
+ * the current url is used; `null` sends none.
74
+ */
75
+ inviteToken?: string | null;
76
+ /**
77
+ * Where Stripe returns after a redirect-based card confirmation on an
78
+ * APPLICATION (a card that needed a bank redirect). Default
79
+ * `/i/account?tab=membership`, where the fan's applications are listed. The
80
+ * application id is appended as `confirmApplication`.
81
+ */
82
+ applicationReturnPath?: string;
57
83
  }
58
84
 
59
85
  const errMessage = (e: unknown) =>
@@ -80,10 +106,13 @@ const errMessage = (e: unknown) =>
80
106
  */
81
107
  export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
82
108
  const { user, refetchUser } = usePublicAuth();
83
- const { data: tiers, isLoading } = useGetMembershipTiers();
109
+ const inviteToken = opts.inviteToken === undefined ? readMembershipInviteFromUrl() : (opts.inviteToken ?? undefined);
110
+ const { data: tiers, isLoading } = useGetMembershipTiers({ inviteToken: inviteToken ?? null });
84
111
  const createPaid = useCreateSubscription();
85
112
  const createFree = useCreateFreeSubscription();
86
113
  const confirmLatest = useConfirmLatestSubscription();
114
+ const applyMutation = useApplyForMembership();
115
+ const confirmApplicationMutation = useConfirmMembershipApplication();
87
116
 
88
117
  const [step, setStep] = useState<MembershipCheckoutStep>(opts.initialTierId ? "select" : "tier");
89
118
  const [selectedTierId, setSelectedTierId] = useState<string | undefined>(opts.initialTierId);
@@ -94,8 +123,20 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
94
123
  /** The started Paystack checkout, kept so the modal can be re-opened. */
95
124
  const [paystackSession, setPaystackSession] = useState<PaystackCheckoutSession | null>(null);
96
125
  const [error, setError] = useState<string | null>(null);
126
+ /** Answers to the selected tier's questions, keyed by question id. */
127
+ const [questionnaireAnswers, setQuestionnaireAnswers] = useState<Record<string, string>>({});
128
+ /** Set once subscribe() was pressed on this tier, so blank required answers read as refused from then on. */
129
+ const [questionnaireAttempted, setQuestionnaireAttempted] = useState(false);
130
+ /** The application this checkout submitted, once it has. */
131
+ const [application, setApplication] = useState<MembershipApplication | null>(null);
132
+ /** Set once the card renderer reported the SetupIntent confirmed. */
133
+ const [cardConfirmed, setCardConfirmed] = useState(false);
134
+ /** The free trial the paid subscribe call started, if any. */
135
+ const [trial, setTrial] = useState<{ days: number; endsAt: string } | null>(null);
97
136
 
98
137
  const selectedTier = useMemo(() => tiers?.find((t) => t.id === selectedTierId), [tiers, selectedTierId]);
138
+ /** The questions this checkout asks: none when completing a `payment_failed` application. */
139
+ const questionnaire = useMemo(() => tierQuestionnaire(selectedTier), [selectedTier]);
99
140
 
100
141
  /**
101
142
  * Seed the cycle and the amount box from the tier itself.
@@ -109,6 +150,9 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
109
150
  * keying on that would wipe the amount a fan had just typed.
110
151
  */
111
152
  useEffect(() => {
153
+ // Answers belong to one tier's questions: a different tier starts blank.
154
+ setQuestionnaireAnswers({});
155
+ setQuestionnaireAttempted(false);
112
156
  if (!selectedTier) return;
113
157
  const cycle = defaultCycle(selectedTier);
114
158
  setBillingCycleState(cycle);
@@ -129,6 +173,9 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
129
173
 
130
174
  const selectTier = useCallback((tier: MembershipTier) => {
131
175
  setSelectedTierId(tier.id);
176
+ setApplication(null);
177
+ setTrial(null);
178
+ setCardConfirmed(false);
132
179
  setError(null);
133
180
  setAmountRefusal(null);
134
181
  setStep("select");
@@ -136,15 +183,36 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
136
183
 
137
184
  const backToTierList = useCallback(() => {
138
185
  setSelectedTierId(undefined);
186
+ setApplication(null);
187
+ setTrial(null);
188
+ setCardConfirmed(false);
139
189
  setError(null);
140
190
  setAmountRefusal(null);
141
191
  setStep("tier");
142
192
  }, []);
143
193
 
194
+ const setQuestionnaireAnswer = useCallback((questionId: string, value: string) => {
195
+ setQuestionnaireAnswers((prev) => ({ ...prev, [questionId]: value }));
196
+ }, []);
197
+ /**
198
+ * Derived, not stored: once a subscribe attempt was made it follows the
199
+ * answers, so the message beside the questions never names one the fan has
200
+ * just answered.
201
+ */
202
+ const questionnaireRefusal: QuestionnaireQuestion | null = questionnaireAttempted
203
+ ? firstMissingAnswer(questionnaire, questionnaireAnswers)
204
+ : null;
205
+
144
206
  const minimumAmount = selectedTier ? cycleFloor(selectedTier, billingCycle) : 0;
145
207
  const maximumAmount = selectedTier ? cycleCeiling(selectedTier, billingCycle) : null;
146
208
  const cycles = selectedTier ? offeredCycles(selectedTier) : { month: false, year: false };
147
209
  const isFreeCycle = selectedTier ? cycleIsFree(selectedTier, billingCycle) : false;
210
+ /**
211
+ * True when subscribe() APPLIES rather than joins: an `application` tier,
212
+ * except when the fan's own application is `payment_failed`, where the
213
+ * ordinary paid checkout completes the membership they were approved for.
214
+ */
215
+ const isApplication = tierUsesApplication(selectedTier);
148
216
 
149
217
  /**
150
218
  * What will be charged, and what will be SENT. A fixed tier ignores the
@@ -167,6 +235,14 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
167
235
  const isChange =
168
236
  !!membership?.paymentProviderSubscriptionId && membership.status === "active" && !isFreeCycle && !!selectedTier;
169
237
 
238
+ /**
239
+ * The free trial this fan would start on the selected tier, in days, or 0.
240
+ * Zero on a free cycle (nothing to trial) and on a tier change (the server
241
+ * moves an existing subscription, it does not start a new one). An
242
+ * application tier still reports it: approval starts the trial.
243
+ */
244
+ const trialDays = isFreeCycle || isChange ? 0 : tierTrialDays(selectedTier);
245
+
170
246
  /**
171
247
  * `await`ed by every caller, because of the refetch below.
172
248
  *
@@ -195,9 +271,14 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
195
271
  }
196
272
  };
197
273
 
274
+ /** `{ questionnaire }` for the request body, or nothing when this checkout asks no questions. */
275
+ const questionnaireBody = () =>
276
+ questionnaire.length ? { questionnaire: buildQuestionnaireAnswers(questionnaire, questionnaireAnswers) } : {};
277
+
198
278
  const subscribe = async () => {
199
279
  setError(null);
200
280
  setAmountRefusal(null);
281
+ setTrial(null);
201
282
  if (!selectedTier) {
202
283
  setError("Select a membership tier.");
203
284
  return;
@@ -210,17 +291,31 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
210
291
  // (or still on its initial zero) is a message beside the field rather than
211
292
  // an `amount_must_be_positive` 400 with nowhere to land.
212
293
  const refusal = refuseMembershipAmount(selectedTier, billingCycle, customAmount);
213
- if (refusal) {
214
- setAmountRefusal(refusal);
294
+ // Same for the tier's questions: a blank required answer is refused beside
295
+ // the questions, before any request, rather than as a 400 from the server.
296
+ const missing = firstMissingAnswer(questionnaire, questionnaireAnswers);
297
+ setQuestionnaireAttempted(true);
298
+ if (refusal) setAmountRefusal(refusal);
299
+ if (refusal || missing) return;
300
+ const origin = typeof window !== "undefined" ? window.location.origin : "";
301
+ if (isApplication) {
302
+ await submitApplication(origin);
215
303
  return;
216
304
  }
217
- const origin = typeof window !== "undefined" ? window.location.origin : "";
218
305
  const returnUrl = `${origin}${opts.returnPath ?? "/i/account?tab=membership&confirmSubscription=true"}`;
219
306
  const attributionRefId = readAttributionRef() ?? undefined;
220
307
  const landing = readLanding() ?? {};
308
+ const invite = inviteToken ? { inviteToken } : {};
309
+ const answers = questionnaireBody();
221
310
  try {
222
311
  if (isFreeCycle) {
223
- await createFree.mutateAsync({ membershipTierId: selectedTier.id, attributionRefId, ...landing });
312
+ await createFree.mutateAsync({
313
+ membershipTierId: selectedTier.id,
314
+ attributionRefId,
315
+ ...landing,
316
+ ...invite,
317
+ ...answers,
318
+ });
224
319
  await finish(false);
225
320
  return;
226
321
  }
@@ -231,6 +326,8 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
231
326
  returnUrl,
232
327
  attributionRefId,
233
328
  ...landing,
329
+ ...invite,
330
+ ...answers,
234
331
  });
235
332
 
236
333
  // A member who already subscribes is CHANGING TIER: the server moved their
@@ -265,6 +362,10 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
265
362
  return;
266
363
  }
267
364
 
365
+ // A trial saves the card instead of charging it: the secret is a
366
+ // SetupIntent, the renderer confirms it with `confirmSetup`, and the return
367
+ // page's `confirmLatest` activates the membership like any paid one.
368
+ setTrial(data.trial ?? null);
268
369
  setClientSecret(data.clientSecret);
269
370
  setStep("payment");
270
371
  } catch (e) {
@@ -272,6 +373,69 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
272
373
  }
273
374
  };
274
375
 
376
+ /**
377
+ * Apply to an `application` tier. Free: the application is lodged and the
378
+ * outcome is set. Paid: the server hands back a SetupIntent secret, the step
379
+ * moves to "payment" and the renderer saves the card; `confirmApplication()`
380
+ * runs after it succeeds. Nothing is charged until the business approves.
381
+ */
382
+ const applicationReturnBase = opts.applicationReturnPath ?? "/i/account?tab=membership";
383
+ const submitApplication = async (origin: string) => {
384
+ if (!selectedTier) return;
385
+ try {
386
+ const data = await applyMutation.mutateAsync({
387
+ membershipTierId: selectedTier.id,
388
+ ...(isFreeCycle ? {} : { billingCycle, amount }),
389
+ ...questionnaireBody(),
390
+ returnUrl: `${origin}${applicationReturnBase}`,
391
+ });
392
+ setApplication(data.application);
393
+ setCardConfirmed(false);
394
+ if (data.requiresCard && data.clientSecret) {
395
+ setClientSecret(data.clientSecret);
396
+ setStep("payment");
397
+ }
398
+ } catch (e) {
399
+ setError(errMessage(e));
400
+ }
401
+ };
402
+
403
+ /** Call after the card renderer confirmed the SetupIntent. Idempotent on the server. */
404
+ const confirmApplication = async () => {
405
+ if (!application) return;
406
+ setError(null);
407
+ setCardConfirmed(true);
408
+ try {
409
+ const data = await confirmApplicationMutation.mutateAsync({ applicationId: application.id });
410
+ setApplication(data.application);
411
+ } catch (e) {
412
+ setError(errMessage(e));
413
+ }
414
+ };
415
+
416
+ const applicationOutcome: MembershipApplicationOutcome | null = membershipApplicationOutcome(
417
+ application,
418
+ cardConfirmed,
419
+ );
420
+ /**
421
+ * What the payment step is confirming, read off the SECRET, not off the tier:
422
+ * a `seti_` secret is a card save whatever started it (an application, or a
423
+ * paid subscription that opens with a free trial), and confirming it as a
424
+ * payment fails with an error the fan cannot act on.
425
+ */
426
+ const paymentKind: "setup" | "payment" = paystackSession ? "payment" : stripeIntentKind(clientSecret);
427
+ /** Why a card is being saved: `application`, `trial`, or null when it is a payment. */
428
+ const setupPurpose: "application" | "trial" | null =
429
+ paymentKind !== "setup" ? null : isApplication && application ? "application" : "trial";
430
+ /** When the first charge falls: the server's date once subscribed, else today + trialDays. */
431
+ const trialEndsAt: string | null = trial?.endsAt ?? (trialDays > 0 ? trialEndDate(trialDays).toISOString() : null);
432
+
433
+ const applicationReturnUrl = application
434
+ ? `${typeof window !== "undefined" ? window.location.origin : ""}${applicationReturnBase}${
435
+ applicationReturnBase.includes("?") ? "&" : "?"
436
+ }confirmApplication=${encodeURIComponent(application.id)}`
437
+ : undefined;
438
+
275
439
  return {
276
440
  tiers,
277
441
  isLoading,
@@ -314,11 +478,66 @@ export function useMembershipCheckout(opts: UseMembershipCheckoutOptions = {}) {
314
478
  onError: (message) => setError(message ?? "The payment could not be completed. Please try again."),
315
479
  })
316
480
  : Promise.resolve("unavailable" as PaystackCheckoutOutcome),
317
- isProcessing: createPaid.isPending || createFree.isPending,
481
+ isProcessing:
482
+ createPaid.isPending || createFree.isPending || applyMutation.isPending || confirmApplicationMutation.isPending,
318
483
  error,
319
484
  /** Call on the return page to reconcile the latest subscription. */
320
485
  confirmLatest,
321
486
  /** Signals a completed paid payment (the Stripe `onSucceeded` leg). */
322
487
  finish,
488
+ /** The invite token this checkout forwards, if any. */
489
+ inviteToken,
490
+ /** True when subscribe() applies to the tier instead of joining it. */
491
+ isApplication,
492
+ /**
493
+ * The selected tier's questions, asked before paying, joining or applying.
494
+ * Empty when the tier asks none, and when completing an approved
495
+ * application whose card was declined (its answers were already given).
496
+ */
497
+ questionnaire,
498
+ /** The answers so far, keyed by question id. Reset when the tier changes. */
499
+ questionnaireAnswers,
500
+ /** Record the answer to one question. */
501
+ setQuestionnaireAnswer,
502
+ /**
503
+ * The first required question left blank, once subscribe() refused for it;
504
+ * null otherwise. Nothing was sent. Show it beside the questions.
505
+ */
506
+ questionnaireRefusal,
507
+ /** The application this checkout submitted, as the server last returned it. */
508
+ application,
509
+ /**
510
+ * Where the application stands once submitted: `received`, `approved`,
511
+ * `tier_full`, `declined` or `closed`; null before submitting and while the
512
+ * card step is still ahead.
513
+ */
514
+ applicationOutcome,
515
+ /**
516
+ * `"setup"` while the payment step is saving a card for an application
517
+ * (the secret is a SetupIntent), else `"payment"`.
518
+ */
519
+ paymentKind,
520
+ /** Why the payment step saves a card: `application`, `trial`, or null when it charges. */
521
+ setupPurpose,
522
+ /**
523
+ * The free trial the selected tier gives THIS fan, in days (0 for none: no
524
+ * trial on the tier, already had one with this business, a free cycle, or a
525
+ * tier change).
526
+ */
527
+ trialDays,
528
+ /** The trial the subscribe call started (`{ days, endsAt }`), or null. */
529
+ trial,
530
+ /** When the first charge falls for a trial (ISO), or null without one. */
531
+ trialEndsAt,
532
+ /**
533
+ * Call once the card renderer reports success. An application's saved card
534
+ * goes to `confirmApplication`; a paid subscription (charged, or a trial's
535
+ * saved card) finishes to the return page, which runs `confirmLatest`.
536
+ */
537
+ onPaymentSucceeded: () => (setupPurpose === "application" ? confirmApplication() : finish(true)),
538
+ /** The renderer's `returnUrl` for an application's card step. */
539
+ applicationReturnUrl,
540
+ /** Call after the card renderer confirmed an application's SetupIntent. */
541
+ confirmApplication,
323
542
  };
324
543
  }
package/src/ui/index.ts CHANGED
@@ -41,6 +41,7 @@ export {
41
41
  usePaymentRenderer,
42
42
  type PaymentRenderer,
43
43
  type PaymentRenderProps,
44
+ type PaymentTrial,
44
45
  } from "./payment/ForgePaymentProvider";
45
46
 
46
47
  // Re-export the headless layer so consumers can reach both tiers from `forge/ui`.
@@ -73,6 +74,23 @@ export {
73
74
  export { PresaleCodeField, type PresaleCodeFieldProps } from "./styled/PresaleCode";
74
75
  export { MembershipCheckout, type MembershipCheckoutProps } from "./styled/MembershipCheckout";
75
76
  export { ForgeStripePayment } from "./payment/ForgeStripePayment";
77
+ // For a custom payment renderer: which post-confirm statuses count as done
78
+ // (a deferred charge stops at `requires_capture`), and the deferred-charge
79
+ // wording decision the built-in renderer makes.
80
+ export {
81
+ deferredChargeNotice,
82
+ isDeferredSettlement,
83
+ isManualConfirmSuccess,
84
+ MANUAL_CONFIRM_SUCCESS_STATUSES,
85
+ } from "./payment/stripeConfirmOutcome";
86
+ // For a custom payment renderer: a SetupIntent secret (an application to a
87
+ // membership tier, or a paid membership starting with a free trial) saves the card through `confirmSetup` instead of charging it.
88
+ export {
89
+ isSetupConfirmSuccess,
90
+ SETUP_CONFIRM_SUCCESS_STATUSES,
91
+ stripeIntentKind,
92
+ type StripeIntentKind,
93
+ } from "./payment/stripeIntentKind";
76
94
  export { Cart, type CartProps } from "./styled/Cart";
77
95
  /** The page a cart-recovery email lands on. Drop in at `/i/checkout/resume`. */
78
96
  export { ResumeCart, type ResumeCartProps } from "./styled/ResumeCart";
@@ -346,3 +364,12 @@ export * from "./styled/work";
346
364
 
347
365
  // Unified member home (`/i/members`) styled blocks.
348
366
  export * from "./styled/members";
367
+
368
+ // The review page a "how did we do?" email lands on. Here rather than in a
369
+ // storefront because both the hosted `/i/reviews/$token` page and every site's
370
+ // root-level `/reviews/$token` alias render it.
371
+ export * from "./styled/ReviewRequestPage";
372
+ // The contract signing page. Here for the same reason: both storefronts render
373
+ // it, at BOTH the canonical `/i/documents/$token` and the older
374
+ // `/documents/$token` that already-sent links point at.
375
+ export * from "./styled/DocumentSigningPage";
@@ -1,3 +1,4 @@
1
+ import type { DeferredChargeStrategy, PaymentSettlement } from "../../types/models";
1
2
  import { createContext, useContext, type ReactNode } from "react";
2
3
  import { ForgeStripePayment } from "./ForgeStripePayment";
3
4
 
@@ -16,6 +17,34 @@ export interface PaymentRenderProps {
16
17
  mode?: "redirect" | "manual";
17
18
  onSucceeded?: () => void;
18
19
  onFailed?: (message?: string) => void;
20
+ /**
21
+ * `"deferred"` when the charge is committed now and taken only if the
22
+ * organiser approves (a ticket request). The built-in renderer then labels
23
+ * the button "Authorize {amount}" and says under it that the card is charged
24
+ * only on approval. Absent on every ordinary sale, so a custom renderer that
25
+ * ignores it keeps working exactly as before.
26
+ */
27
+ settlement?: PaymentSettlement;
28
+ /**
29
+ * How the provider defers: `"authorization"` shows a temporary hold on the
30
+ * fan's statement and the renderer says so; `"saved_instrument"` takes
31
+ * nothing until approval. Only sent with `settlement: "deferred"`.
32
+ */
33
+ deferredStrategy?: DeferredChargeStrategy | null;
34
+ /**
35
+ * Set when a SetupIntent secret starts a FREE TRIAL on a paid membership
36
+ * rather than an application. The built-in renderer then reads "Start free
37
+ * trial" and says under the button that nothing is charged today, what is
38
+ * charged per cycle, and from which date. Ignored on a payment secret.
39
+ */
40
+ trial?: PaymentTrial | null;
41
+ }
42
+
43
+ /** The free trial a card save starts: when the first charge falls, and its cycle. */
44
+ export interface PaymentTrial {
45
+ /** ISO date of the first charge. */
46
+ endsAt: string;
47
+ billingCycle: "month" | "year";
19
48
  }
20
49
 
21
50
  export type PaymentRenderer = (props: PaymentRenderProps) => ReactNode;
@@ -5,7 +5,11 @@ import { useForgeTheme } from "../theme/ForgeThemeProvider";
5
5
  import { readableTextOn } from "../theme/contrast";
6
6
  import { useFormatCurrency } from "../format/useFormatCurrency";
7
7
  import { useSiteConfig } from "../../data/queries/useWebsite";
8
+ import { useForgeLocale, useForgeT } from "../../i18n";
9
+ import { formatTrialDate } from "../headless/membership/membershipTrial";
8
10
  import type { PaymentRenderProps } from "./ForgePaymentProvider";
11
+ import { deferredChargeNotice, isDeferredSettlement, isManualConfirmSuccess } from "./stripeConfirmOutcome";
12
+ import { isSetupConfirmSuccess, stripeIntentKind, type StripeIntentKind } from "./stripeIntentKind";
9
13
 
10
14
  function alpha(hex: number) {
11
15
  return Math.round(hex * 255)
@@ -21,6 +25,13 @@ function alpha(hex: number) {
21
25
  * `useSiteConfig()` (Stripe Connect: `loadStripe(platformKey, { stripeAccount })`),
22
26
  * the appearance from `ForgeThemeProvider`, and the pay-button amount from
23
27
  * `useFormatCurrency()`. The consuming site wires nothing.
28
+ *
29
+ * A SetupIntent secret (`seti_...`) saves the card instead of charging it (an
30
+ * application to a membership tier): the same Element renders, the confirm goes
31
+ * through `stripe.confirmSetup`, and the button and the line under it say that
32
+ * the card is charged only on approval. A setup that starts a free trial on a
33
+ * paid membership (`trial` set) reads "Start free trial" and says nothing is
34
+ * charged today, then what is charged from which date.
24
35
  */
25
36
  export function ForgeStripePayment({
26
37
  clientSecret,
@@ -30,6 +41,9 @@ export function ForgeStripePayment({
30
41
  mode = "redirect",
31
42
  onSucceeded,
32
43
  onFailed,
44
+ settlement,
45
+ deferredStrategy,
46
+ trial,
33
47
  }: PaymentRenderProps) {
34
48
  const theme = useForgeTheme();
35
49
  const { data: siteConfig } = useSiteConfig();
@@ -91,33 +105,97 @@ export function ForgeStripePayment({
91
105
 
92
106
  return (
93
107
  <Elements options={{ clientSecret, appearance, loader: "auto" }} stripe={stripePromise}>
94
- <Inner returnUrl={returnUrl} amount={amount} currency={currency} mode={mode} onSucceeded={onSucceeded} onFailed={onFailed} />
108
+ <Inner
109
+ intentKind={stripeIntentKind(clientSecret)}
110
+ returnUrl={returnUrl}
111
+ amount={amount}
112
+ currency={currency}
113
+ mode={mode}
114
+ onSucceeded={onSucceeded}
115
+ onFailed={onFailed}
116
+ settlement={settlement}
117
+ deferredStrategy={deferredStrategy}
118
+ trial={trial}
119
+ />
95
120
  </Elements>
96
121
  );
97
122
  }
98
123
 
99
124
  function Inner({
125
+ intentKind,
100
126
  returnUrl,
101
127
  amount,
102
128
  currency,
103
129
  mode = "redirect",
104
130
  onSucceeded,
105
131
  onFailed,
106
- }: Omit<PaymentRenderProps, "clientSecret">) {
132
+ settlement,
133
+ deferredStrategy,
134
+ trial,
135
+ }: Omit<PaymentRenderProps, "clientSecret"> & { intentKind: StripeIntentKind }) {
107
136
  const theme = useForgeTheme();
137
+ const t = useForgeT();
138
+ const locale = useForgeLocale();
108
139
  const { formatCurrency } = useFormatCurrency();
109
140
  const stripe = useStripe();
110
141
  const elements = useElements();
111
142
  const [message, setMessage] = useState("");
112
143
  const [isLoading, setIsLoading] = useState(false);
113
144
 
114
- const buttonText = `Pay ${formatCurrency(amount, currency || undefined)} Now`;
145
+ // A deferred charge is not a payment yet: the button says what actually
146
+ // happens when it is pressed, and the line under it says when the money
147
+ // moves. That line is content, not help: a fan reading "Authorize" without
148
+ // it has been told their card was committed and nothing about why.
149
+ const deferred = isDeferredSettlement(settlement);
150
+ const formattedAmount = formatCurrency(amount, currency || undefined);
151
+ // A setup takes nothing now: the button submits the application, and the
152
+ // consent line under it is the thing the fan is agreeing to, so it is content.
153
+ const isSetup = intentKind === "setup";
154
+ // A trial also saves the card, but for a subscription the fan is starting now.
155
+ const isTrial = isSetup && !!trial;
156
+ const buttonText = isTrial
157
+ ? t("forge.forge_stripe_payment.start_free_trial")
158
+ : isSetup
159
+ ? t("forge.forge_stripe_payment.submit_application")
160
+ : deferred
161
+ ? t("forge.forge_stripe_payment.authorize", { amount: formattedAmount })
162
+ : `Pay ${formattedAmount} Now`;
115
163
 
116
164
  const handleSubmit = useCallback(async () => {
117
165
  if (!stripe || !elements) return;
118
166
  setIsLoading(true);
119
167
  setMessage("");
120
168
 
169
+ if (isSetup) {
170
+ if (mode === "manual") {
171
+ const { error, setupIntent } = await stripe.confirmSetup({
172
+ elements,
173
+ confirmParams: { return_url: returnUrl },
174
+ redirect: "if_required",
175
+ });
176
+ if (error) {
177
+ const msg = error.message ?? t("forge.forge_stripe_payment.setup_failed");
178
+ setMessage(msg);
179
+ setIsLoading(false);
180
+ onFailed?.(msg);
181
+ return;
182
+ }
183
+ setIsLoading(false);
184
+ if (isSetupConfirmSuccess(setupIntent?.status)) {
185
+ onSucceeded?.();
186
+ return;
187
+ }
188
+ const msg = t("forge.forge_stripe_payment.setup_incomplete");
189
+ setMessage(msg);
190
+ onFailed?.(msg);
191
+ return;
192
+ }
193
+ const { error } = await stripe.confirmSetup({ elements, confirmParams: { return_url: returnUrl } });
194
+ setMessage(error.message ?? t("forge.forge_stripe_payment.setup_failed"));
195
+ setIsLoading(false);
196
+ return;
197
+ }
198
+
121
199
  if (mode === "manual") {
122
200
  const { error, paymentIntent } = await stripe.confirmPayment({
123
201
  elements,
@@ -131,7 +209,9 @@ function Inner({
131
209
  onFailed?.(msg);
132
210
  return;
133
211
  }
134
- if (paymentIntent?.status === "succeeded" || paymentIntent?.status === "processing") {
212
+ // `requires_capture` is a deferred charge that went through: the card is
213
+ // authorised and the platform captures it on approval. See the helper.
214
+ if (isManualConfirmSuccess(paymentIntent?.status)) {
135
215
  setIsLoading(false);
136
216
  onSucceeded?.();
137
217
  return;
@@ -144,7 +224,7 @@ function Inner({
144
224
  const { error } = await stripe.confirmPayment({ elements, confirmParams: { return_url: returnUrl } });
145
225
  setMessage(error.message ?? "An unexpected error occurred.");
146
226
  setIsLoading(false);
147
- }, [stripe, elements, mode, returnUrl, onSucceeded, onFailed]);
227
+ }, [stripe, elements, mode, returnUrl, onSucceeded, onFailed, isSetup, t]);
148
228
 
149
229
  return (
150
230
  <form onSubmit={(e) => e.preventDefault()} style={{ display: "flex", flexDirection: "column", gap: 12 }}>
@@ -166,6 +246,31 @@ function Inner({
166
246
  >
167
247
  {isLoading ? "Processing…" : buttonText}
168
248
  </button>
249
+ {isTrial && trial && (
250
+ <p data-testid="trial-consent-notice" style={{ margin: 0, fontSize: 13, opacity: 0.8, lineHeight: 1.5 }}>
251
+ {t("forge.forge_stripe_payment.trial_consent", {
252
+ amount: formattedAmount,
253
+ cycle:
254
+ trial.billingCycle === "year"
255
+ ? t("forge.forge_stripe_payment.cycle_year")
256
+ : t("forge.forge_stripe_payment.cycle_month"),
257
+ date: formatTrialDate(trial.endsAt, locale),
258
+ })}
259
+ </p>
260
+ )}
261
+ {isSetup && !isTrial && (
262
+ <p data-testid="setup-consent-notice" style={{ margin: 0, fontSize: 13, opacity: 0.8, lineHeight: 1.5 }}>
263
+ {t("forge.forge_stripe_payment.setup_charged_on_approval", { amount: formattedAmount })}
264
+ </p>
265
+ )}
266
+ {!isSetup && deferred && (
267
+ <p data-testid="deferred-charge-notice" style={{ margin: 0, fontSize: 13, opacity: 0.8, lineHeight: 1.5 }}>
268
+ {t("forge.forge_stripe_payment.deferred_charged_on_approval")}
269
+ {deferredChargeNotice(deferredStrategy) === "authorization" && (
270
+ <> {t("forge.forge_stripe_payment.deferred_hold_on_statement")}</>
271
+ )}
272
+ </p>
273
+ )}
169
274
  </form>
170
275
  );
171
276
  }