@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
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
3
|
+
import { renderHook, act } from "@testing-library/react";
|
|
4
|
+
import { useMembershipCheckout } from "../useMembershipCheckout";
|
|
5
|
+
import type { MembershipTier } from "../../../../types/models";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Free trials in the headless checkout: the selected tier's trial is exposed
|
|
9
|
+
* only when the fan is eligible, and a paid subscribe that answers with `trial`
|
|
10
|
+
* and a SetupIntent secret routes the payment step to a card SAVE that finishes
|
|
11
|
+
* like a paid subscription (to the return page's confirm-latest), never to the
|
|
12
|
+
* application confirm.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const auth = vi.hoisted(() => ({
|
|
16
|
+
user: { id: "account-1", email: "fan@example.com", membership: undefined as unknown },
|
|
17
|
+
refetchUser: vi.fn(async () => {}),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const api = vi.hoisted(() => ({
|
|
21
|
+
confirm: vi.fn(async (_body: Record<string, unknown>) => ({}) as Record<string, unknown>),
|
|
22
|
+
createPaid: vi.fn(async (_body: Record<string, unknown>) => ({}) as Record<string, unknown>),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
let tiers: MembershipTier[] = [];
|
|
26
|
+
|
|
27
|
+
vi.mock("../../../../contexts/PublicAuthContext", async (importOriginal) => ({
|
|
28
|
+
...(await importOriginal<typeof import("../../../../contexts/PublicAuthContext")>()),
|
|
29
|
+
usePublicAuth: () => ({ user: auth.user, refetchUser: auth.refetchUser }),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
vi.mock("../../../../data/queries/useMembership", async (importOriginal) => ({
|
|
33
|
+
...(await importOriginal<typeof import("../../../../data/queries/useMembership")>()),
|
|
34
|
+
useGetMembershipTiers: () => ({ data: tiers, isLoading: false }),
|
|
35
|
+
useApplyForMembership: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
|
36
|
+
useConfirmMembershipApplication: () => ({ mutateAsync: api.confirm, isPending: false }),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
vi.mock("../../../../data/queries/useSubscriptions", async (importOriginal) => ({
|
|
40
|
+
...(await importOriginal<typeof import("../../../../data/queries/useSubscriptions")>()),
|
|
41
|
+
useCreateSubscription: () => ({ mutateAsync: api.createPaid, isPending: false }),
|
|
42
|
+
useCreateFreeSubscription: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
|
43
|
+
useConfirmLatestSubscription: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
const tier = (overrides: Partial<MembershipTier>): MembershipTier => ({
|
|
47
|
+
id: "tier-1",
|
|
48
|
+
name: "Inner Circle",
|
|
49
|
+
description: "",
|
|
50
|
+
payWhatYouWant: false,
|
|
51
|
+
benefits: [],
|
|
52
|
+
priceMonthly: 12,
|
|
53
|
+
...overrides,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const onComplete = vi.fn();
|
|
57
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
api.confirm.mockReset();
|
|
61
|
+
api.createPaid.mockReset();
|
|
62
|
+
onComplete.mockReset();
|
|
63
|
+
auth.user.membership = undefined;
|
|
64
|
+
window.history.replaceState(null, "", "/i/membership/checkout");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("useMembershipCheckout: trial exposure", () => {
|
|
68
|
+
it("exposes trialDays and a first-charge date of today + trialDays for an eligible fan", () => {
|
|
69
|
+
tiers = [tier({ trialDays: 14, trialEligible: true })];
|
|
70
|
+
const before = Date.now();
|
|
71
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1", onComplete }));
|
|
72
|
+
|
|
73
|
+
expect(result.current.trialDays).toBe(14);
|
|
74
|
+
expect(result.current.trial).toBeNull();
|
|
75
|
+
const ends = new Date(result.current.trialEndsAt as string).getTime();
|
|
76
|
+
expect(ends).toBeGreaterThanOrEqual(before + 14 * DAY);
|
|
77
|
+
expect(ends).toBeLessThanOrEqual(Date.now() + 14 * DAY);
|
|
78
|
+
// The consent line's other inputs.
|
|
79
|
+
expect(result.current.amount).toBe(12);
|
|
80
|
+
expect(result.current.billingCycle).toBe("month");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("exposes no trial to an ineligible fan, on a tier without one, or on a free tier", () => {
|
|
84
|
+
tiers = [
|
|
85
|
+
tier({ id: "used", trialDays: 14, trialEligible: false }),
|
|
86
|
+
tier({ id: "none", trialDays: null }),
|
|
87
|
+
tier({ id: "free", priceMonthly: 0, trialDays: 14 }),
|
|
88
|
+
];
|
|
89
|
+
for (const id of ["used", "none", "free"]) {
|
|
90
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: id, onComplete }));
|
|
91
|
+
expect(result.current.trialDays, id).toBe(0);
|
|
92
|
+
expect(result.current.trialEndsAt, id).toBeNull();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("exposes no trial to a member changing tier", () => {
|
|
97
|
+
auth.user.membership = { paymentProviderSubscriptionId: "sub_1", status: "active", membershipTierId: "other" };
|
|
98
|
+
tiers = [tier({ trialDays: 14 })];
|
|
99
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1", onComplete }));
|
|
100
|
+
expect(result.current.isChange).toBe(true);
|
|
101
|
+
expect(result.current.trialDays).toBe(0);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("useMembershipCheckout: a trial's SetupIntent secret", () => {
|
|
106
|
+
it("routes a seti_ secret from the PAID subscribe to a setup, and finishes like a paid subscription", async () => {
|
|
107
|
+
tiers = [tier({ trialDays: 14 })];
|
|
108
|
+
api.createPaid.mockResolvedValue({
|
|
109
|
+
membershipId: "m-1",
|
|
110
|
+
subscriptionId: "s-1",
|
|
111
|
+
requiresPayment: true,
|
|
112
|
+
clientSecret: "seti_trial_secret_abc",
|
|
113
|
+
trial: { days: 14, endsAt: "2026-10-01T00:00:00.000Z" },
|
|
114
|
+
});
|
|
115
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1", onComplete }));
|
|
116
|
+
|
|
117
|
+
await act(async () => {
|
|
118
|
+
await result.current.subscribe();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(api.createPaid).toHaveBeenCalledTimes(1);
|
|
122
|
+
expect(result.current.step).toBe("payment");
|
|
123
|
+
expect(result.current.clientSecret).toBe("seti_trial_secret_abc");
|
|
124
|
+
expect(result.current.paymentKind).toBe("setup");
|
|
125
|
+
expect(result.current.setupPurpose).toBe("trial");
|
|
126
|
+
expect(result.current.trial).toEqual({ days: 14, endsAt: "2026-10-01T00:00:00.000Z" });
|
|
127
|
+
// The server's date wins over the local estimate.
|
|
128
|
+
expect(result.current.trialEndsAt).toBe("2026-10-01T00:00:00.000Z");
|
|
129
|
+
|
|
130
|
+
await act(async () => {
|
|
131
|
+
await result.current.onPaymentSucceeded();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(onComplete).toHaveBeenCalledWith({ requiresConfirmation: true });
|
|
135
|
+
expect(api.confirm).not.toHaveBeenCalled();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("keys setup off the SECRET: a seti_ secret with no trial field still confirms as a setup", async () => {
|
|
139
|
+
tiers = [tier({})];
|
|
140
|
+
api.createPaid.mockResolvedValue({ requiresPayment: true, clientSecret: "seti_x_secret_y" });
|
|
141
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1", onComplete }));
|
|
142
|
+
await act(async () => {
|
|
143
|
+
await result.current.subscribe();
|
|
144
|
+
});
|
|
145
|
+
expect(result.current.paymentKind).toBe("setup");
|
|
146
|
+
expect(result.current.setupPurpose).toBe("trial");
|
|
147
|
+
expect(result.current.trial).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("keeps an ordinary paid subscription on the payment confirm", async () => {
|
|
151
|
+
tiers = [tier({})];
|
|
152
|
+
api.createPaid.mockResolvedValue({ requiresPayment: true, clientSecret: "pi_1_secret_2", trial: null });
|
|
153
|
+
const { result } = renderHook(() => useMembershipCheckout({ initialTierId: "tier-1", onComplete }));
|
|
154
|
+
await act(async () => {
|
|
155
|
+
await result.current.subscribe();
|
|
156
|
+
});
|
|
157
|
+
expect(result.current.paymentKind).toBe("payment");
|
|
158
|
+
expect(result.current.setupPurpose).toBeNull();
|
|
159
|
+
expect(result.current.trial).toBeNull();
|
|
160
|
+
await act(async () => {
|
|
161
|
+
await result.current.onPaymentSucceeded();
|
|
162
|
+
});
|
|
163
|
+
expect(onComplete).toHaveBeenCalledWith({ requiresConfirmation: true });
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -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
|
+
}
|