@tribe-nest/forge 3.53.0 → 3.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/package.json +1 -1
  2. package/src/contexts/AppAuthContext.tsx +3 -2
  3. package/src/contexts/AudioPlayerContext.tsx +3 -2
  4. package/src/contexts/CartContext.tsx +3 -2
  5. package/src/contexts/PublicAuthContext.tsx +3 -2
  6. package/src/data/queries/useCoachingProducts.ts +15 -1
  7. package/src/data/queries/useCourses.ts +15 -1
  8. package/src/data/queries/useEvents.ts +26 -1
  9. package/src/data/queries/useMembership.ts +176 -4
  10. package/src/data/queries/useMyTickets.ts +49 -0
  11. package/src/data/queries/usePaymentFlow.ts +8 -0
  12. package/src/data/queries/useProducts.ts +15 -1
  13. package/src/data/queries/useSubscriptions.ts +21 -1
  14. package/src/i18n/de.json +116 -1
  15. package/src/i18n/en.json +116 -1
  16. package/src/i18n/index.ts +3 -2
  17. package/src/index.ts +22 -0
  18. package/src/provider/ForgeAppProvider.tsx +8 -1
  19. package/src/provider/ForgeProvider.tsx +3 -2
  20. package/src/provider/SiteConfigProvider.tsx +3 -2
  21. package/src/runtime/RemotePage.tsx +110 -0
  22. package/src/runtime/hostRuntime.ts +84 -0
  23. package/src/runtime/pages.spec.ts +41 -0
  24. package/src/runtime/pages.ts +183 -0
  25. package/src/runtime/pagesClient.ts +127 -0
  26. package/src/runtime/registry.spec.ts +45 -0
  27. package/src/runtime/registry.ts +102 -0
  28. package/src/server/index.ts +74 -0
  29. package/src/server/jobs.ts +4 -1
  30. package/src/server/platformEvents.generated.ts +152 -0
  31. package/src/types/models.ts +185 -0
  32. package/src/ui/headless/event/_tests/ticketApproval.spec.ts +137 -0
  33. package/src/ui/headless/event/_tests/useEventCheckoutApproval.spec.tsx +250 -0
  34. package/src/ui/headless/event/ticketApproval.ts +109 -0
  35. package/src/ui/headless/event/useEventCheckout.ts +74 -4
  36. package/src/ui/headless/index.ts +34 -0
  37. package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
  38. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
  39. package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
  40. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
  41. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
  42. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
  43. package/src/ui/headless/membership/membershipApplication.ts +109 -0
  44. package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
  45. package/src/ui/headless/membership/membershipTrial.ts +53 -0
  46. package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
  47. package/src/ui/index.ts +27 -0
  48. package/src/ui/payment/ForgePaymentProvider.tsx +29 -0
  49. package/src/ui/payment/ForgeStripePayment.tsx +110 -5
  50. package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
  51. package/src/ui/payment/_tests/stripeConfirmOutcome.spec.ts +61 -0
  52. package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
  53. package/src/ui/payment/stripeConfirmOutcome.ts +50 -0
  54. package/src/ui/payment/stripeIntentKind.ts +32 -0
  55. package/src/ui/shell/TribeNestApp.tsx +6 -0
  56. package/src/ui/styled/AccountDashboard.tsx +239 -15
  57. package/src/ui/styled/DocumentSigningPage.tsx +516 -0
  58. package/src/ui/styled/EventConfirmation.tsx +106 -9
  59. package/src/ui/styled/EventTickets.tsx +107 -13
  60. package/src/ui/styled/LoginForm.tsx +5 -3
  61. package/src/ui/styled/MembershipCheckout.tsx +596 -256
  62. package/src/ui/styled/MembershipTierCallout.tsx +17 -3
  63. package/src/ui/styled/MembershipTiers.tsx +148 -6
  64. package/src/ui/styled/ProductGrid.tsx +14 -2
  65. package/src/ui/styled/ReviewRequestPage.tsx +214 -0
  66. package/src/ui/styled/SignupForm.tsx +4 -2
  67. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
  68. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
  69. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
  70. package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
  71. package/src/ui/styled/forge-utilities.css +310 -0
  72. package/src/ui/theme/ForgeThemeProvider.tsx +3 -2
  73. package/src/utils/_tests/ticketOrderOutcome.spec.ts +82 -0
  74. package/src/utils/ticketOrderOutcome.ts +57 -1
@@ -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
+ }