@tribe-nest/forge 3.54.0 → 3.58.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 +4 -2
- package/src/_tests/workspaceAliases.ts +1 -0
- package/src/contexts/CartContext.tsx +88 -5
- package/src/data/queries/useCallBreakouts.ts +112 -0
- package/src/data/queries/useCheckouts.ts +15 -2
- package/src/data/queries/useCoachingProducts.ts +15 -1
- package/src/data/queries/useCourses.ts +15 -1
- package/src/data/queries/useEvents.ts +80 -1
- package/src/data/queries/useMembership.ts +176 -4
- package/src/data/queries/useMyBookings.ts +16 -0
- package/src/data/queries/useNotifications.ts +1 -1
- package/src/data/queries/useProducts.ts +15 -1
- package/src/data/queries/useSubscriptions.ts +21 -1
- package/src/i18n/de.json +153 -0
- package/src/i18n/en.json +153 -0
- package/src/index.ts +1 -0
- package/src/server/_tests/buildPwaHead.spec.ts +1 -1
- package/src/server/_tests/buildWebManifest.spec.ts +1 -1
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +1 -1
- package/src/server/index.ts +75 -1
- package/src/server/platformEvents.generated.ts +60 -0
- package/src/server/pwa.ts +1 -1
- package/src/types/models.ts +137 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +55 -1
- package/src/ui/analytics/_tests/ForgeAnalytics.engagement.spec.tsx +78 -0
- package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +3 -1
- package/src/ui/headless/checkout/_tests/cartResume.spec.ts +3 -10
- package/src/ui/headless/checkout/_tests/seatConflict.spec.ts +187 -0
- package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +3 -11
- package/src/ui/headless/checkout/_tests/useCheckoutSeats.spec.tsx +313 -0
- package/src/ui/headless/checkout/seatConflict.ts +117 -0
- package/src/ui/headless/checkout/useCheckout.ts +178 -55
- package/src/ui/headless/event/_tests/seatingFixture.ts +111 -0
- package/src/ui/headless/event/_tests/useEventCheckoutSeats.spec.tsx +443 -0
- package/src/ui/headless/event/_tests/useEventSeating.spec.tsx +154 -0
- package/src/ui/headless/event/useEventCheckout.ts +362 -22
- package/src/ui/headless/event/useEventSeating.ts +233 -0
- package/src/ui/headless/forms/_tests/usePhoneInput.spec.ts +187 -0
- package/src/ui/headless/forms/useEmailListForm.ts +37 -6
- package/src/ui/headless/forms/usePhoneInput.ts +294 -0
- package/src/ui/headless/forms/useSectionedForm.ts +2 -2
- package/src/ui/headless/index.ts +48 -1
- 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 +16 -0
- package/src/ui/media/CallStage.tsx +107 -10
- package/src/ui/media/_tests/CallBreakoutsPanel.spec.tsx +271 -0
- package/src/ui/media/_tests/CallStageBreakouts.spec.tsx +212 -0
- package/src/ui/media/_tests/callState.spec.ts +103 -1
- package/src/ui/media/callBreakouts.ts +57 -0
- package/src/ui/media/callState.ts +83 -23
- package/src/ui/media/index.ts +11 -0
- package/src/ui/media/panels/CallBreakoutsPanel.tsx +395 -0
- package/src/ui/media/panels/CallSidePanel.tsx +37 -4
- package/src/ui/media/stage/CallCenterNotice.tsx +5 -3
- package/src/ui/media/stage/CallControlsBar.tsx +46 -0
- package/src/ui/media/stage/CallTopBar.tsx +1 -1
- package/src/ui/payment/ForgePaymentProvider.tsx +14 -0
- package/src/ui/payment/ForgeStripePayment.tsx +78 -7
- package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
- package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
- package/src/ui/payment/stripeIntentKind.ts +32 -0
- package/src/ui/shell/TribeNestApp.tsx +1 -1
- package/src/ui/styled/AccountDashboard.tsx +165 -2
- package/src/ui/styled/BundleConfirmation.tsx +47 -2
- package/src/ui/styled/Cart.tsx +211 -135
- package/src/ui/styled/Checkout.tsx +163 -32
- package/src/ui/styled/EmailListForm.tsx +7 -9
- package/src/ui/styled/EventTickets.tsx +143 -4
- 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/PhoneInput.tsx +127 -0
- package/src/ui/styled/ProductGrid.tsx +14 -2
- package/src/ui/styled/SignupForm.tsx +4 -2
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +36 -0
- package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
- package/src/ui/styled/_tests/CartSeats.spec.tsx +109 -0
- package/src/ui/styled/_tests/CheckoutSeatsConflict.spec.tsx +194 -0
- package/src/ui/styled/_tests/EventSeatPicker.spec.tsx +199 -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 +67 -0
- package/src/ui/styled/seating/EventSeatPicker.tsx +190 -0
- package/src/ui/styled/seating/SeatLegend.tsx +73 -0
- package/src/ui/styled/seating/SeatMapCanvas.tsx +647 -0
- package/src/ui/styled/seating/paint.ts +79 -0
- package/src/ui/styled/seating/useSeatMapViewport.ts +248 -0
- package/src/ui/styled/seating/viewport.ts +86 -0
- package/src/utils/_tests/engagementTracker.spec.ts +111 -0
- package/src/utils/engagementTracker.ts +73 -0
- package/src/utils/landing.ts +1 -1
|
@@ -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 {
|
|
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`.
|
|
@@ -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";
|
|
@@ -92,6 +101,7 @@ export { CartLineOptions, resolveCartLineOptions } from "./styled/CartLineOption
|
|
|
92
101
|
export { CurrencySwitcher, type CurrencySwitcherProps } from "./styled/CurrencySwitcher";
|
|
93
102
|
export { UserMenu, type UserMenuProps } from "./styled/UserMenu";
|
|
94
103
|
export { EmailListForm, type EmailListFormProps } from "./styled/EmailListForm";
|
|
104
|
+
export { PhoneInput, type PhoneInputProps } from "./styled/PhoneInput";
|
|
95
105
|
export { BlogComments, type BlogCommentsProps } from "./styled/BlogComments";
|
|
96
106
|
export { PageActions, type PageActionsProps } from "./styled/PageActions";
|
|
97
107
|
export { MusicLinkPage, type MusicLinkPageProps } from "./styled/MusicLinkPage";
|
|
@@ -109,6 +119,12 @@ export { MembershipTierCallout, type MembershipTierCalloutProps } from "./styled
|
|
|
109
119
|
export { MembershipTiers, type MembershipTiersProps } from "./styled/MembershipTiers";
|
|
110
120
|
export { EventsList, type EventsListProps } from "./styled/EventsList";
|
|
111
121
|
export { EventTickets, type EventTicketsProps } from "./styled/EventTickets";
|
|
122
|
+
// Reserved seating (wave 2): the buyer's seat picker, on its own for a host
|
|
123
|
+
// that composes the ticket flow from `useEventCheckout`.
|
|
124
|
+
export { EventSeatPicker, type EventSeatPickerProps } from "./styled/seating/EventSeatPicker";
|
|
125
|
+
export { SeatMapCanvas, type SeatMapCanvasProps } from "./styled/seating/SeatMapCanvas";
|
|
126
|
+
export { SeatLegend, type SeatLegendProps, type SeatLegendTier } from "./styled/seating/SeatLegend";
|
|
127
|
+
export { tierColorMap, TIER_PALETTE } from "./styled/seating/paint";
|
|
112
128
|
export { EventCountdown, type EventCountdownProps } from "./styled/EventCountdown";
|
|
113
129
|
export { EventWaitlist, type EventWaitlistProps, formatCountdown } from "./styled/EventWaitlist";
|
|
114
130
|
export { EventDetail, type EventDetailProps } from "./styled/EventDetail";
|