@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.
- package/package.json +1 -1
- package/src/contexts/AppAuthContext.tsx +3 -2
- package/src/contexts/AudioPlayerContext.tsx +3 -2
- package/src/contexts/CartContext.tsx +3 -2
- package/src/contexts/PublicAuthContext.tsx +3 -2
- package/src/data/queries/useCoachingProducts.ts +15 -1
- package/src/data/queries/useCourses.ts +15 -1
- package/src/data/queries/useEvents.ts +26 -1
- package/src/data/queries/useMembership.ts +176 -4
- package/src/data/queries/useMyTickets.ts +49 -0
- package/src/data/queries/usePaymentFlow.ts +8 -0
- package/src/data/queries/useProducts.ts +15 -1
- package/src/data/queries/useSubscriptions.ts +21 -1
- package/src/i18n/de.json +116 -1
- package/src/i18n/en.json +116 -1
- package/src/i18n/index.ts +3 -2
- package/src/index.ts +22 -0
- package/src/provider/ForgeAppProvider.tsx +8 -1
- package/src/provider/ForgeProvider.tsx +3 -2
- package/src/provider/SiteConfigProvider.tsx +3 -2
- package/src/runtime/RemotePage.tsx +110 -0
- package/src/runtime/hostRuntime.ts +84 -0
- package/src/runtime/pages.spec.ts +41 -0
- package/src/runtime/pages.ts +183 -0
- package/src/runtime/pagesClient.ts +127 -0
- package/src/runtime/registry.spec.ts +45 -0
- package/src/runtime/registry.ts +102 -0
- package/src/server/index.ts +74 -0
- package/src/server/jobs.ts +4 -1
- package/src/server/platformEvents.generated.ts +152 -0
- package/src/types/models.ts +185 -0
- package/src/ui/headless/event/_tests/ticketApproval.spec.ts +137 -0
- package/src/ui/headless/event/_tests/useEventCheckoutApproval.spec.tsx +250 -0
- package/src/ui/headless/event/ticketApproval.ts +109 -0
- package/src/ui/headless/event/useEventCheckout.ts +74 -4
- package/src/ui/headless/index.ts +34 -0
- package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
- package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
- package/src/ui/headless/membership/membershipApplication.ts +109 -0
- package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
- package/src/ui/headless/membership/membershipTrial.ts +53 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
- package/src/ui/index.ts +27 -0
- package/src/ui/payment/ForgePaymentProvider.tsx +29 -0
- package/src/ui/payment/ForgeStripePayment.tsx +110 -5
- package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
- package/src/ui/payment/_tests/stripeConfirmOutcome.spec.ts +61 -0
- package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
- package/src/ui/payment/stripeConfirmOutcome.ts +50 -0
- package/src/ui/payment/stripeIntentKind.ts +32 -0
- package/src/ui/shell/TribeNestApp.tsx +6 -0
- package/src/ui/styled/AccountDashboard.tsx +239 -15
- package/src/ui/styled/DocumentSigningPage.tsx +516 -0
- package/src/ui/styled/EventConfirmation.tsx +106 -9
- package/src/ui/styled/EventTickets.tsx +107 -13
- package/src/ui/styled/LoginForm.tsx +5 -3
- package/src/ui/styled/MembershipCheckout.tsx +596 -256
- package/src/ui/styled/MembershipTierCallout.tsx +17 -3
- package/src/ui/styled/MembershipTiers.tsx +148 -6
- package/src/ui/styled/ProductGrid.tsx +14 -2
- package/src/ui/styled/ReviewRequestPage.tsx +214 -0
- package/src/ui/styled/SignupForm.tsx +4 -2
- package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
- package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
- package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
- package/src/ui/styled/forge-utilities.css +310 -0
- package/src/ui/theme/ForgeThemeProvider.tsx +3 -2
- package/src/utils/_tests/ticketOrderOutcome.spec.ts +82 -0
- 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 {
|
|
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
|
|
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
|
-
|
|
214
|
-
|
|
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({
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|