@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
@@ -0,0 +1,109 @@
1
+ import type { MembershipApplication, MembershipApplicationStatus, MembershipTier } from "../../../types/models";
2
+
3
+ /**
4
+ * Curated access for memberships: the pure decisions behind the tier card, the
5
+ * checkout and the account page, so every surface reads a tier the same way.
6
+ */
7
+
8
+ /** The longest applicant note the server accepts. */
9
+ export const MEMBERSHIP_APPLICATION_MESSAGE_MAX = 1000;
10
+
11
+ /**
12
+ * Does subscribing to this tier go through an APPLICATION?
13
+ *
14
+ * True for an `application` tier, except when the fan's own application is
15
+ * `payment_failed`: they were approved and the saved card was declined, so the
16
+ * ordinary paid checkout runs and the server accepts it. That is the "complete
17
+ * your membership" path.
18
+ */
19
+ export function tierUsesApplication(tier: Pick<MembershipTier, "joinMode" | "myApplication"> | undefined): boolean {
20
+ if (!tier || tier.joinMode !== "application") return false;
21
+ return tier.myApplication?.status !== "payment_failed";
22
+ }
23
+
24
+ /**
25
+ * What the tier card offers instead of (or as) its join button.
26
+ *
27
+ * join an open tier
28
+ * accept_invite an invite tier (only listed when the visitor holds its invite)
29
+ * apply an application tier with nothing open
30
+ * pending an application waiting on a decision (withdrawable)
31
+ * approved approved, the membership is being set up
32
+ * complete approved but the card was declined: finish at checkout
33
+ * declined not approved; nothing to press
34
+ *
35
+ * A card-step-in-progress (`card_pending`) application reads as `apply`, so the
36
+ * fan can pick the card step up again; the server hands back the same one.
37
+ */
38
+ export type MembershipTierCardAction =
39
+ | "join"
40
+ | "accept_invite"
41
+ | "apply"
42
+ | "pending"
43
+ | "approved"
44
+ | "complete"
45
+ | "declined";
46
+
47
+ export function tierCardAction(tier: Pick<MembershipTier, "joinMode" | "myApplication">): MembershipTierCardAction {
48
+ const status = tier.myApplication?.status;
49
+ switch (status) {
50
+ case "pending":
51
+ return "pending";
52
+ case "approved":
53
+ return "approved";
54
+ case "payment_failed":
55
+ return "complete";
56
+ case "declined":
57
+ return "declined";
58
+ default:
59
+ break;
60
+ }
61
+ if (tier.joinMode === "invite") return "accept_invite";
62
+ if (tier.joinMode === "application") return "apply";
63
+ return "join";
64
+ }
65
+
66
+ /** Can the fan still withdraw an application in this state? */
67
+ export function canWithdrawMembershipApplication(status: MembershipApplicationStatus | null | undefined): boolean {
68
+ return status === "card_pending" || status === "pending";
69
+ }
70
+
71
+ /**
72
+ * What the checkout shows after an application was submitted or confirmed.
73
+ *
74
+ * received lodged (or its card is confirmed and the server is catching up)
75
+ * approved already approved or joined (a repeat submit returned it)
76
+ * tier_full the last seat went while the card was being confirmed
77
+ * declined a repeat submit returned a declined application
78
+ * closed withdrawn or expired for any other reason
79
+ *
80
+ * `null` while the card step is still ahead (`card_pending` before confirm).
81
+ * `cardConfirmed` says the renderer reported success, in which case a
82
+ * `card_pending` answer only means the server has not heard from the provider
83
+ * yet (the webhook finishes it), so it reads as received.
84
+ */
85
+ export type MembershipApplicationOutcome = "received" | "approved" | "tier_full" | "declined" | "closed";
86
+
87
+ export function membershipApplicationOutcome(
88
+ application: Pick<MembershipApplication, "status" | "closedReason"> | null | undefined,
89
+ cardConfirmed = false,
90
+ ): MembershipApplicationOutcome | null {
91
+ if (!application) return null;
92
+ switch (application.status) {
93
+ case "card_pending":
94
+ return cardConfirmed ? "received" : null;
95
+ case "pending":
96
+ return "received";
97
+ case "approved":
98
+ case "payment_failed":
99
+ case "joined":
100
+ return "approved";
101
+ case "declined":
102
+ return "declined";
103
+ case "withdrawn":
104
+ return application.closedReason === "tier_full" ? "tier_full" : "closed";
105
+ case "expired":
106
+ default:
107
+ return "closed";
108
+ }
109
+ }
@@ -0,0 +1,44 @@
1
+ import type { MembershipTier, QuestionnaireAnswer, QuestionnaireQuestion } from "../../../types/models";
2
+
3
+ /**
4
+ * Tier questionnaires: the pure decisions behind asking a tier's questions at
5
+ * checkout, so the hook and any custom renderer read them the same way.
6
+ */
7
+
8
+ /**
9
+ * The questions the checkout asks for this tier, or `[]`.
10
+ *
11
+ * Every join mode asks (open, invite and application). The one exception is a
12
+ * fan completing an approved application whose card was declined
13
+ * (`myApplication.status === "payment_failed"`): they answered when they
14
+ * applied, and the server uses those answers.
15
+ */
16
+ export function tierQuestionnaire(
17
+ tier: Pick<MembershipTier, "questionnaire" | "myApplication"> | undefined,
18
+ ): QuestionnaireQuestion[] {
19
+ if (!tier?.questionnaire?.length) return [];
20
+ if (tier.myApplication?.status === "payment_failed") return [];
21
+ return tier.questionnaire;
22
+ }
23
+
24
+ /** The first required question with a blank answer, or null when every required one is answered. */
25
+ export function firstMissingAnswer(
26
+ questions: QuestionnaireQuestion[],
27
+ answers: Record<string, string>,
28
+ ): QuestionnaireQuestion | null {
29
+ return questions.find((q) => !q.optional && !(answers[q.id] ?? "").trim()) ?? null;
30
+ }
31
+
32
+ /** The request body's `questionnaire`: every question, in order, with its trimmed answer (`""` when blank). */
33
+ export function buildQuestionnaireAnswers(
34
+ questions: QuestionnaireQuestion[],
35
+ answers: Record<string, string>,
36
+ ): QuestionnaireAnswer[] {
37
+ return questions.map((q) => ({
38
+ id: q.id,
39
+ question: q.question,
40
+ type: q.type,
41
+ ...(q.options ? { options: q.options } : {}),
42
+ answer: (answers[q.id] ?? "").trim(),
43
+ }));
44
+ }
@@ -0,0 +1,53 @@
1
+ import type { Membership, MembershipTier } from "../../../types/models";
2
+
3
+ /**
4
+ * Free trials on paid membership tiers: the pure decisions behind the tier card,
5
+ * the checkout, the payment step and the account page, so every surface reads a
6
+ * trial the same way.
7
+ */
8
+
9
+ const DAY_MS = 24 * 60 * 60 * 1000;
10
+
11
+ /**
12
+ * The trial this fan would get on this tier, in days, or 0 for none.
13
+ *
14
+ * A tier offers a trial when `trialDays` is positive. The fan gets it unless the
15
+ * server said `trialEligible: false` (they already had a trial with this
16
+ * business). Anonymous visitors carry no `trialEligible`, which reads as
17
+ * eligible: the server decides again at subscribe time.
18
+ *
19
+ * Pass the fan's current membership where it is known: a member with a live
20
+ * paid subscription picking another tier is CHANGING tier, which moves the
21
+ * subscription they have and starts no trial.
22
+ */
23
+ export function tierTrialDays(
24
+ tier: Pick<MembershipTier, "trialDays" | "trialEligible"> | null | undefined,
25
+ membership?: Pick<Membership, "paymentProviderSubscriptionId" | "status"> | null,
26
+ ): number {
27
+ if (!tier) return 0;
28
+ if (membership?.paymentProviderSubscriptionId && membership.status === "active") return 0;
29
+ const days = typeof tier.trialDays === "number" ? Math.floor(tier.trialDays) : 0;
30
+ if (days <= 0) return 0;
31
+ return tier.trialEligible === false ? 0 : days;
32
+ }
33
+
34
+ /** When a trial of `days` started now ends: the date of the first charge. */
35
+ export function trialEndDate(days: number, now: Date = new Date()): Date {
36
+ return new Date(now.getTime() + days * DAY_MS);
37
+ }
38
+
39
+ /** Is this membership still inside its free trial? */
40
+ export function isMembershipInTrial(
41
+ membership: Pick<Membership, "trialEndsAt"> | null | undefined,
42
+ now: Date = new Date(),
43
+ ): boolean {
44
+ if (!membership?.trialEndsAt) return false;
45
+ const ends = new Date(membership.trialEndsAt).getTime();
46
+ return Number.isFinite(ends) && ends > now.getTime();
47
+ }
48
+
49
+ /** A short, locale-aware date for trial copy ("Oct 1, 2026" / "1. Okt. 2026"). */
50
+ export function formatTrialDate(value: string | number | Date, locale: string = "en"): string {
51
+ const tag = locale === "de" ? "de-DE" : "en-US";
52
+ return new Date(value).toLocaleDateString(tag, { year: "numeric", month: "short", day: "numeric" });
53
+ }
@@ -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`.
@@ -82,6 +83,14 @@ export {
82
83
  isManualConfirmSuccess,
83
84
  MANUAL_CONFIRM_SUCCESS_STATUSES,
84
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";
85
94
  export { Cart, type CartProps } from "./styled/Cart";
86
95
  /** The page a cart-recovery email lands on. Drop in at `/i/checkout/resume`. */
87
96
  export { ResumeCart, type ResumeCartProps } from "./styled/ResumeCart";
@@ -31,6 +31,20 @@ export interface PaymentRenderProps {
31
31
  * nothing until approval. Only sent with `settlement: "deferred"`.
32
32
  */
33
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";
34
48
  }
35
49
 
36
50
  export type PaymentRenderer = (props: PaymentRenderProps) => ReactNode;