@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.
Files changed (102) hide show
  1. package/package.json +4 -2
  2. package/src/_tests/workspaceAliases.ts +1 -0
  3. package/src/contexts/CartContext.tsx +88 -5
  4. package/src/data/queries/useCallBreakouts.ts +112 -0
  5. package/src/data/queries/useCheckouts.ts +15 -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 +80 -1
  9. package/src/data/queries/useMembership.ts +176 -4
  10. package/src/data/queries/useMyBookings.ts +16 -0
  11. package/src/data/queries/useNotifications.ts +1 -1
  12. package/src/data/queries/useProducts.ts +15 -1
  13. package/src/data/queries/useSubscriptions.ts +21 -1
  14. package/src/i18n/de.json +153 -0
  15. package/src/i18n/en.json +153 -0
  16. package/src/index.ts +1 -0
  17. package/src/server/_tests/buildPwaHead.spec.ts +1 -1
  18. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  19. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +1 -1
  20. package/src/server/index.ts +75 -1
  21. package/src/server/platformEvents.generated.ts +60 -0
  22. package/src/server/pwa.ts +1 -1
  23. package/src/types/models.ts +137 -0
  24. package/src/ui/analytics/ForgeAnalytics.tsx +55 -1
  25. package/src/ui/analytics/_tests/ForgeAnalytics.engagement.spec.tsx +78 -0
  26. package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +3 -1
  27. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +3 -10
  28. package/src/ui/headless/checkout/_tests/seatConflict.spec.ts +187 -0
  29. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +3 -11
  30. package/src/ui/headless/checkout/_tests/useCheckoutSeats.spec.tsx +313 -0
  31. package/src/ui/headless/checkout/seatConflict.ts +117 -0
  32. package/src/ui/headless/checkout/useCheckout.ts +178 -55
  33. package/src/ui/headless/event/_tests/seatingFixture.ts +111 -0
  34. package/src/ui/headless/event/_tests/useEventCheckoutSeats.spec.tsx +443 -0
  35. package/src/ui/headless/event/_tests/useEventSeating.spec.tsx +154 -0
  36. package/src/ui/headless/event/useEventCheckout.ts +362 -22
  37. package/src/ui/headless/event/useEventSeating.ts +233 -0
  38. package/src/ui/headless/forms/_tests/usePhoneInput.spec.ts +187 -0
  39. package/src/ui/headless/forms/useEmailListForm.ts +37 -6
  40. package/src/ui/headless/forms/usePhoneInput.ts +294 -0
  41. package/src/ui/headless/forms/useSectionedForm.ts +2 -2
  42. package/src/ui/headless/index.ts +48 -1
  43. package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
  44. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
  45. package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
  46. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
  47. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
  48. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
  49. package/src/ui/headless/membership/membershipApplication.ts +109 -0
  50. package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
  51. package/src/ui/headless/membership/membershipTrial.ts +53 -0
  52. package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
  53. package/src/ui/index.ts +16 -0
  54. package/src/ui/media/CallStage.tsx +107 -10
  55. package/src/ui/media/_tests/CallBreakoutsPanel.spec.tsx +271 -0
  56. package/src/ui/media/_tests/CallStageBreakouts.spec.tsx +212 -0
  57. package/src/ui/media/_tests/callState.spec.ts +103 -1
  58. package/src/ui/media/callBreakouts.ts +57 -0
  59. package/src/ui/media/callState.ts +83 -23
  60. package/src/ui/media/index.ts +11 -0
  61. package/src/ui/media/panels/CallBreakoutsPanel.tsx +395 -0
  62. package/src/ui/media/panels/CallSidePanel.tsx +37 -4
  63. package/src/ui/media/stage/CallCenterNotice.tsx +5 -3
  64. package/src/ui/media/stage/CallControlsBar.tsx +46 -0
  65. package/src/ui/media/stage/CallTopBar.tsx +1 -1
  66. package/src/ui/payment/ForgePaymentProvider.tsx +14 -0
  67. package/src/ui/payment/ForgeStripePayment.tsx +78 -7
  68. package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
  69. package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
  70. package/src/ui/payment/stripeIntentKind.ts +32 -0
  71. package/src/ui/shell/TribeNestApp.tsx +1 -1
  72. package/src/ui/styled/AccountDashboard.tsx +165 -2
  73. package/src/ui/styled/BundleConfirmation.tsx +47 -2
  74. package/src/ui/styled/Cart.tsx +211 -135
  75. package/src/ui/styled/Checkout.tsx +163 -32
  76. package/src/ui/styled/EmailListForm.tsx +7 -9
  77. package/src/ui/styled/EventTickets.tsx +143 -4
  78. package/src/ui/styled/LoginForm.tsx +5 -3
  79. package/src/ui/styled/MembershipCheckout.tsx +596 -256
  80. package/src/ui/styled/MembershipTierCallout.tsx +17 -3
  81. package/src/ui/styled/MembershipTiers.tsx +148 -6
  82. package/src/ui/styled/PhoneInput.tsx +127 -0
  83. package/src/ui/styled/ProductGrid.tsx +14 -2
  84. package/src/ui/styled/SignupForm.tsx +4 -2
  85. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +36 -0
  86. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
  87. package/src/ui/styled/_tests/CartSeats.spec.tsx +109 -0
  88. package/src/ui/styled/_tests/CheckoutSeatsConflict.spec.tsx +194 -0
  89. package/src/ui/styled/_tests/EventSeatPicker.spec.tsx +199 -0
  90. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
  91. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
  92. package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
  93. package/src/ui/styled/forge-utilities.css +67 -0
  94. package/src/ui/styled/seating/EventSeatPicker.tsx +190 -0
  95. package/src/ui/styled/seating/SeatLegend.tsx +73 -0
  96. package/src/ui/styled/seating/SeatMapCanvas.tsx +647 -0
  97. package/src/ui/styled/seating/paint.ts +79 -0
  98. package/src/ui/styled/seating/useSeatMapViewport.ts +248 -0
  99. package/src/ui/styled/seating/viewport.ts +86 -0
  100. package/src/utils/_tests/engagementTracker.spec.ts +111 -0
  101. package/src/utils/engagementTracker.ts +73 -0
  102. package/src/utils/landing.ts +1 -1
@@ -5,9 +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 { useForgeT } from "../../i18n";
8
+ import { useForgeLocale, useForgeT } from "../../i18n";
9
+ import { formatTrialDate } from "../headless/membership/membershipTrial";
9
10
  import type { PaymentRenderProps } from "./ForgePaymentProvider";
10
11
  import { deferredChargeNotice, isDeferredSettlement, isManualConfirmSuccess } from "./stripeConfirmOutcome";
12
+ import { isSetupConfirmSuccess, stripeIntentKind, type StripeIntentKind } from "./stripeIntentKind";
11
13
 
12
14
  function alpha(hex: number) {
13
15
  return Math.round(hex * 255)
@@ -23,6 +25,13 @@ function alpha(hex: number) {
23
25
  * `useSiteConfig()` (Stripe Connect: `loadStripe(platformKey, { stripeAccount })`),
24
26
  * the appearance from `ForgeThemeProvider`, and the pay-button amount from
25
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.
26
35
  */
27
36
  export function ForgeStripePayment({
28
37
  clientSecret,
@@ -34,6 +43,7 @@ export function ForgeStripePayment({
34
43
  onFailed,
35
44
  settlement,
36
45
  deferredStrategy,
46
+ trial,
37
47
  }: PaymentRenderProps) {
38
48
  const theme = useForgeTheme();
39
49
  const { data: siteConfig } = useSiteConfig();
@@ -96,6 +106,7 @@ export function ForgeStripePayment({
96
106
  return (
97
107
  <Elements options={{ clientSecret, appearance, loader: "auto" }} stripe={stripePromise}>
98
108
  <Inner
109
+ intentKind={stripeIntentKind(clientSecret)}
99
110
  returnUrl={returnUrl}
100
111
  amount={amount}
101
112
  currency={currency}
@@ -104,12 +115,14 @@ export function ForgeStripePayment({
104
115
  onFailed={onFailed}
105
116
  settlement={settlement}
106
117
  deferredStrategy={deferredStrategy}
118
+ trial={trial}
107
119
  />
108
120
  </Elements>
109
121
  );
110
122
  }
111
123
 
112
124
  function Inner({
125
+ intentKind,
113
126
  returnUrl,
114
127
  amount,
115
128
  currency,
@@ -118,9 +131,11 @@ function Inner({
118
131
  onFailed,
119
132
  settlement,
120
133
  deferredStrategy,
121
- }: Omit<PaymentRenderProps, "clientSecret">) {
134
+ trial,
135
+ }: Omit<PaymentRenderProps, "clientSecret"> & { intentKind: StripeIntentKind }) {
122
136
  const theme = useForgeTheme();
123
137
  const t = useForgeT();
138
+ const locale = useForgeLocale();
124
139
  const { formatCurrency } = useFormatCurrency();
125
140
  const stripe = useStripe();
126
141
  const elements = useElements();
@@ -133,15 +148,54 @@ function Inner({
133
148
  // it has been told their card was committed and nothing about why.
134
149
  const deferred = isDeferredSettlement(settlement);
135
150
  const formattedAmount = formatCurrency(amount, currency || undefined);
136
- const buttonText = deferred
137
- ? t("forge.forge_stripe_payment.authorize", { amount: formattedAmount })
138
- : `Pay ${formattedAmount} Now`;
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`;
139
163
 
140
164
  const handleSubmit = useCallback(async () => {
141
165
  if (!stripe || !elements) return;
142
166
  setIsLoading(true);
143
167
  setMessage("");
144
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
+
145
199
  if (mode === "manual") {
146
200
  const { error, paymentIntent } = await stripe.confirmPayment({
147
201
  elements,
@@ -170,7 +224,7 @@ function Inner({
170
224
  const { error } = await stripe.confirmPayment({ elements, confirmParams: { return_url: returnUrl } });
171
225
  setMessage(error.message ?? "An unexpected error occurred.");
172
226
  setIsLoading(false);
173
- }, [stripe, elements, mode, returnUrl, onSucceeded, onFailed]);
227
+ }, [stripe, elements, mode, returnUrl, onSucceeded, onFailed, isSetup, t]);
174
228
 
175
229
  return (
176
230
  <form onSubmit={(e) => e.preventDefault()} style={{ display: "flex", flexDirection: "column", gap: 12 }}>
@@ -192,7 +246,24 @@ function Inner({
192
246
  >
193
247
  {isLoading ? "Processing…" : buttonText}
194
248
  </button>
195
- {deferred && (
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 && (
196
267
  <p data-testid="deferred-charge-notice" style={{ margin: 0, fontSize: 13, opacity: 0.8, lineHeight: 1.5 }}>
197
268
  {t("forge.forge_stripe_payment.deferred_charged_on_approval")}
198
269
  {deferredChargeNotice(deferredStrategy) === "authorization" && (
@@ -0,0 +1,101 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
+ import type { ReactNode } from "react";
4
+ import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
5
+ import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
6
+ import { ForgeStripePayment } from "../ForgeStripePayment";
7
+
8
+ /**
9
+ * The built-in Stripe renderer routes on the SECRET: a SetupIntent secret is
10
+ * confirmed with `confirmSetup` whatever started it. A free trial on a paid
11
+ * membership reads "Start free trial" with the first-charge consent line under
12
+ * it; an application keeps its own copy; a PaymentIntent still pays.
13
+ */
14
+
15
+ const stripe = vi.hoisted(() => ({
16
+ confirmSetup: vi.fn(async (_args: unknown) => ({ setupIntent: { status: "succeeded" } }) as Record<string, unknown>),
17
+ confirmPayment: vi.fn(async (_args: unknown) => ({ paymentIntent: { status: "succeeded" } }) as Record<string, unknown>),
18
+ }));
19
+
20
+ vi.mock("@stripe/stripe-js", () => ({ loadStripe: () => Promise.resolve({}) }));
21
+ vi.mock("@stripe/react-stripe-js", () => ({
22
+ Elements: ({ children }: { children: ReactNode }) => <>{children}</>,
23
+ PaymentElement: () => null,
24
+ useStripe: () => stripe,
25
+ useElements: () => ({}),
26
+ }));
27
+ vi.mock("../../../data/queries/useWebsite", async (importOriginal) => ({
28
+ ...(await importOriginal<typeof import("../../../data/queries/useWebsite")>()),
29
+ useSiteConfig: () => ({ data: { stripePublishableKey: "pk_test", stripeAccountId: "acct_1", currency: "USD" } }),
30
+ }));
31
+
32
+ function mount(props: Partial<Parameters<typeof ForgeStripePayment>[0]>) {
33
+ const onSucceeded = vi.fn();
34
+ render(
35
+ <PublicAuthContext.Provider value={{ user: null, currencies: null, userSelectedCurrency: "USD" } as never}>
36
+ <ForgeStripePayment
37
+ clientSecret="seti_1_secret_2"
38
+ returnUrl="https://site.test/i/account?tab=membership&confirmSubscription=true"
39
+ amount={12}
40
+ currency="USD"
41
+ mode="manual"
42
+ onSucceeded={onSucceeded}
43
+ {...props}
44
+ />
45
+ </PublicAuthContext.Provider>,
46
+ );
47
+ return { onSucceeded };
48
+ }
49
+
50
+ beforeEach(() => {
51
+ stripe.confirmSetup.mockClear();
52
+ stripe.confirmPayment.mockClear();
53
+ });
54
+ afterEach(() => cleanup());
55
+
56
+ describe("ForgeStripePayment: a free trial's SetupIntent", () => {
57
+ it("reads Start free trial, shows the consent line, and confirms with confirmSetup", async () => {
58
+ const { onSucceeded } = mount({ trial: { endsAt: "2026-10-01T12:00:00.000Z", billingCycle: "month" } });
59
+
60
+ const button = await screen.findByRole("button", { name: "Start free trial" });
61
+ const notice = screen.getByTestId("trial-consent-notice");
62
+ expect(notice.textContent).toMatch(
63
+ /^Nothing is charged today\. .*12.* per month from Oct 1, 2026\. Cancel before then and you will not be charged\.$/,
64
+ );
65
+ expect(screen.queryByTestId("setup-consent-notice")).toBeNull();
66
+
67
+ fireEvent.click(button);
68
+ await waitFor(() => expect(onSucceeded).toHaveBeenCalledTimes(1));
69
+ expect(stripe.confirmSetup).toHaveBeenCalledTimes(1);
70
+ expect(stripe.confirmSetup.mock.calls[0][0]).toMatchObject({
71
+ confirmParams: { return_url: "https://site.test/i/account?tab=membership&confirmSubscription=true" },
72
+ redirect: "if_required",
73
+ });
74
+ expect(stripe.confirmPayment).not.toHaveBeenCalled();
75
+ });
76
+
77
+ it("names a yearly cycle", async () => {
78
+ mount({ trial: { endsAt: "2026-10-01T12:00:00.000Z", billingCycle: "year" } });
79
+ expect((await screen.findByTestId("trial-consent-notice")).textContent).toContain(" per year from ");
80
+ });
81
+
82
+ it("keeps the application copy for a SetupIntent with no trial", async () => {
83
+ mount({ trial: null });
84
+ expect(await screen.findByRole("button", { name: "Submit application" })).toBeTruthy();
85
+ expect(screen.getByTestId("setup-consent-notice")).toBeTruthy();
86
+ expect(screen.queryByTestId("trial-consent-notice")).toBeNull();
87
+ });
88
+
89
+ it("ignores trial on a PaymentIntent and pays", async () => {
90
+ const { onSucceeded } = mount({
91
+ clientSecret: "pi_1_secret_2",
92
+ trial: { endsAt: "2026-10-01T12:00:00.000Z", billingCycle: "month" },
93
+ });
94
+ expect(screen.queryByTestId("trial-consent-notice")).toBeNull();
95
+ const button = await screen.findByRole("button", { name: /^Pay / });
96
+ fireEvent.click(button);
97
+ await waitFor(() => expect(onSucceeded).toHaveBeenCalledTimes(1));
98
+ expect(stripe.confirmPayment).toHaveBeenCalledTimes(1);
99
+ expect(stripe.confirmSetup).not.toHaveBeenCalled();
100
+ });
101
+ });
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { isSetupConfirmSuccess, SETUP_CONFIRM_SUCCESS_STATUSES, stripeIntentKind } from "../stripeIntentKind";
3
+
4
+ /**
5
+ * An application to a membership tier saves the card (a SetupIntent) instead of
6
+ * charging it. The renderer routes on the secret, and confirming a setup secret
7
+ * through `confirmPayment` fails with an error the fan cannot act on, so the
8
+ * routing is a pure function pinned here.
9
+ */
10
+ describe("stripeIntentKind", () => {
11
+ it("routes a SetupIntent secret to the setup confirm", () => {
12
+ expect(stripeIntentKind("seti_1AbC_secret_xyz")).toBe("setup");
13
+ });
14
+
15
+ it("keeps every other secret on the payment confirm", () => {
16
+ expect(stripeIntentKind("pi_1AbC_secret_xyz")).toBe("payment");
17
+ // Only the prefix counts, never a substring.
18
+ expect(stripeIntentKind("pi_seti_secret")).toBe("payment");
19
+ expect(stripeIntentKind("")).toBe("payment");
20
+ expect(stripeIntentKind(undefined)).toBe("payment");
21
+ expect(stripeIntentKind(null)).toBe("payment");
22
+ });
23
+ });
24
+
25
+ describe("isSetupConfirmSuccess", () => {
26
+ it("counts a saved card, and a delayed method still processing, as the fan being done", () => {
27
+ expect(isSetupConfirmSuccess("succeeded")).toBe(true);
28
+ expect(isSetupConfirmSuccess("processing")).toBe(true);
29
+ });
30
+
31
+ it("refuses every status that means the card is not saved", () => {
32
+ for (const status of ["requires_payment_method", "requires_confirmation", "requires_action", "canceled"]) {
33
+ expect(isSetupConfirmSuccess(status)).toBe(false);
34
+ }
35
+ expect(isSetupConfirmSuccess(undefined)).toBe(false);
36
+ expect(isSetupConfirmSuccess(null)).toBe(false);
37
+ expect(isSetupConfirmSuccess("")).toBe(false);
38
+ });
39
+
40
+ it("exposes the set the renderer reads", () => {
41
+ expect([...SETUP_CONFIRM_SUCCESS_STATUSES].sort()).toEqual(["processing", "succeeded"]);
42
+ });
43
+ });
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Which kind of Stripe intent a client secret belongs to.
3
+ *
4
+ * A PaymentIntent secret (`pi_..._secret_...`) takes money now; a SetupIntent
5
+ * secret (`seti_..._secret_...`) only saves the card for a later charge, which
6
+ * is what an application to a membership tier does: nothing is charged unless
7
+ * the business approves. The two confirm through different Stripe calls
8
+ * (`confirmPayment` vs `confirmSetup`), and calling the wrong one fails with an
9
+ * error the fan cannot act on, so the renderer routes on the secret itself.
10
+ */
11
+ export type StripeIntentKind = "payment" | "setup";
12
+
13
+ export function stripeIntentKind(clientSecret: string | null | undefined): StripeIntentKind {
14
+ return typeof clientSecret === "string" && clientSecret.startsWith("seti_") ? "setup" : "payment";
15
+ }
16
+
17
+ /**
18
+ * The SetupIntent statuses `confirmSetup` can leave behind that mean "the fan's
19
+ * part is done".
20
+ *
21
+ * succeeded the card is saved
22
+ * processing a delayed method; the provider finishes it and the webhook reports it
23
+ *
24
+ * Anything else (`requires_payment_method`, an unresolved `requires_action`,
25
+ * `canceled`) is not done.
26
+ */
27
+ export const SETUP_CONFIRM_SUCCESS_STATUSES: ReadonlySet<string> = new Set(["succeeded", "processing"]);
28
+
29
+ /** Does this post-confirm SetupIntent status count as the fan having finished? */
30
+ export function isSetupConfirmSuccess(status: string | null | undefined): boolean {
31
+ return !!status && SETUP_CONFIRM_SUCCESS_STATUSES.has(status);
32
+ }
@@ -28,7 +28,7 @@ registerHostRuntime();
28
28
  // params + referrer, kept in the URL for page-level ad pixels). On a FRESH
29
29
  // landing it also fires the visit-level beacon so the sale-less visit is counted.
30
30
  // Client-only, renders null. Lives in the shell so every code website gets ad
31
- // attribution without editing the tenant-owned __root. See docs/ads-attribution-initiative.md.
31
+ // attribution without editing the tenant-owned __root. See docs/archive/shipped/ads-attribution-initiative.md.
32
32
  function AttributionCapture({ enabled, apiUrl, profileId }: { enabled: boolean; apiUrl?: string; profileId?: string }) {
33
33
  useEffect(() => {
34
34
  if (!enabled) return;
@@ -25,6 +25,11 @@ import {
25
25
  type MyCourse,
26
26
  useCancelMembership,
27
27
  useMembershipAccess,
28
+ useMyMembershipApplications,
29
+ useWithdrawMembershipApplication,
30
+ useConfirmMembershipApplication,
31
+ type MembershipApplication,
32
+ type MembershipApplicationStatus,
28
33
  useOpenBillingPortal,
29
34
  useUpdateAccount,
30
35
  useChangePassword,
@@ -47,6 +52,8 @@ import { WalletPassButtons } from "./WalletPassButtons";
47
52
  import { TicketTransferPanel } from "./TicketTransfer";
48
53
  import { CartLineOptions } from "./CartLineOptions";
49
54
  import { FilmRentalList } from "./FilmLibrary";
55
+ import { canWithdrawMembershipApplication } from "../headless/membership/membershipApplication";
56
+ import { isMembershipInTrial } from "../headless/membership/membershipTrial";
50
57
 
51
58
  export const ACCOUNT_TABS = [
52
59
  "membership",
@@ -367,6 +374,14 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
367
374
  const [cancelError, setCancelError] = useState<string | null>(null);
368
375
 
369
376
  const membership = user?.membership;
377
+ /**
378
+ * Inside a free trial: nothing has been charged yet. The date the first
379
+ * charge falls and what it will be are state the member acts on (cancel
380
+ * before then, or not), so they are visible, not behind a hint.
381
+ */
382
+ const inTrial = isMembershipInTrial(membership);
383
+ const cycleWord = (cycle: string | undefined) =>
384
+ cycle === "year" ? tr("forge.account_dashboard.cycle_year") : tr("forge.account_dashboard.cycle_month");
370
385
  // Two separate questions, deliberately answered separately:
371
386
  // `access.hasAccess` → are the benefits live (true through past_due/grace)
372
387
  // `message` → what the member is told, including a failed payment
@@ -447,7 +462,25 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
447
462
  ))}
448
463
  </ul>
449
464
  )}
450
- {membership.endDate && (
465
+ {inTrial && membership.trialEndsAt && (
466
+ <div data-testid="membership-trial-state">
467
+ <p style={{ fontSize: 14, fontWeight: 600 }}>
468
+ {tr("forge.account_dashboard.membership_trial_until", { date: formatDate(membership.trialEndsAt) })}
469
+ </p>
470
+ <p style={{ fontSize: 14, opacity: 0.85, marginTop: 2 }}>
471
+ {access.cancelAtPeriodEnd
472
+ ? tr("forge.account_dashboard.membership_trial_cancelled")
473
+ : membership.subscriptionAmount
474
+ ? tr("forge.account_dashboard.membership_trial_then", {
475
+ amount: formatAmount(membership.subscriptionAmount, membership.subscriptionCurrency || currency),
476
+ cycle: cycleWord(membership.billingCycle),
477
+ date: formatDate(membership.trialEndsAt),
478
+ })
479
+ : null}
480
+ </p>
481
+ </div>
482
+ )}
483
+ {membership.endDate && !inTrial && (
451
484
  <p style={{ fontSize: 14, opacity: 0.8 }}>
452
485
  {access.billingState === "active"
453
486
  ? tr("forge.account_dashboard.membership_renews_on", { date: formatDate(membership.endDate) })
@@ -504,7 +537,9 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
504
537
  <div role="alertdialog" aria-modal="true" style={{ ...card, marginTop: 16 }}>
505
538
  <p style={{ fontWeight: 700 }}>{tr("forge.account_dashboard.cancel_confirm_title")}</p>
506
539
  <p style={{ fontSize: 14, opacity: 0.85, marginTop: 8 }}>
507
- {membership?.subscriptionAmount
540
+ {inTrial && membership?.trialEndsAt
541
+ ? tr("forge.account_dashboard.cancel_confirm_trial", { date: formatDate(membership.trialEndsAt) })
542
+ : membership?.subscriptionAmount
508
543
  ? membership.endDate
509
544
  ? tr("forge.account_dashboard.cancel_confirm_paid_until", { date: formatDate(membership.endDate) })
510
545
  : tr("forge.account_dashboard.cancel_confirm_paid")
@@ -530,6 +565,129 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
530
565
  </div>
531
566
  </div>
532
567
  )}
568
+
569
+ <MembershipApplicationsSection ctx={ctx} />
570
+ </div>
571
+ );
572
+ }
573
+
574
+ const APPLICATION_STATUS_LABEL: Record<MembershipApplicationStatus, string> = {
575
+ card_pending: "forge.account_dashboard.application_status_card_pending",
576
+ pending: "forge.account_dashboard.application_status_pending",
577
+ approved: "forge.account_dashboard.application_status_approved",
578
+ payment_failed: "forge.account_dashboard.application_status_payment_failed",
579
+ joined: "forge.account_dashboard.application_status_joined",
580
+ declined: "forge.account_dashboard.application_status_declined",
581
+ withdrawn: "forge.account_dashboard.application_status_withdrawn",
582
+ expired: "forge.account_dashboard.application_status_expired",
583
+ };
584
+
585
+ /**
586
+ * The fan's applications to membership tiers, with where each stands, so a fan
587
+ * who lost the email still knows. Renders nothing when there are none.
588
+ *
589
+ * A card that needed a bank redirect comes back here with `?confirmApplication=`:
590
+ * that application is confirmed once on arrival (idempotent on the server, and
591
+ * the provider's webhook reports it too, so this only makes the list current
592
+ * sooner).
593
+ */
594
+ function MembershipApplicationsSection({ ctx }: { ctx: TabContext }) {
595
+ const { user } = usePublicAuth();
596
+ const tr = useForgeT();
597
+ const { t, ghostButton, button } = useCardStyles();
598
+ const { formatAmount, formatDate, currency } = ctx;
599
+ const { data: applications } = useMyMembershipApplications(user?.id);
600
+ const withdraw = useWithdrawMembershipApplication();
601
+ const confirm = useConfirmMembershipApplication();
602
+ const [error, setError] = useState<string | null>(null);
603
+
604
+ useEffect(() => {
605
+ if (typeof window === "undefined") return;
606
+ const id = new URLSearchParams(window.location.search).get("confirmApplication");
607
+ if (!id || !user?.id) return;
608
+ confirm.mutate({ applicationId: id });
609
+ // Once per arrival.
610
+ // eslint-disable-next-line react-hooks/exhaustive-deps
611
+ }, [user?.id]);
612
+
613
+ if (!applications?.length) return null;
614
+
615
+ const onWithdraw = async (application: MembershipApplication) => {
616
+ setError(null);
617
+ try {
618
+ await withdraw.mutateAsync({ applicationId: application.id });
619
+ } catch (e) {
620
+ const detail = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
621
+ setError(detail || tr("forge.account_dashboard.application_withdraw_error"));
622
+ }
623
+ };
624
+
625
+ return (
626
+ <div style={{ marginTop: 24, paddingTop: 16, borderTop: `1px solid ${t.primary}20` }}>
627
+ <h3 style={{ fontWeight: 700, marginBottom: 12, fontFamily: t.headingFontFamily }}>
628
+ {tr("forge.account_dashboard.applications_title")}
629
+ </h3>
630
+ <ul
631
+ data-testid="membership-applications"
632
+ style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 12 }}
633
+ >
634
+ {applications.map((application) => (
635
+ <li
636
+ key={application.id}
637
+ style={{ display: "flex", gap: 12, justifyContent: "space-between", alignItems: "center", flexWrap: "wrap" }}
638
+ >
639
+ <div style={{ minWidth: 0 }}>
640
+ <p style={{ fontWeight: 600 }}>{application.membershipTierName ?? ""}</p>
641
+ <p style={{ fontSize: 13, opacity: 0.85 }}>{tr(APPLICATION_STATUS_LABEL[application.status])}</p>
642
+ {application.amount != null && application.billingCycle && (
643
+ <p style={{ fontSize: 13, opacity: 0.8 }}>
644
+ {tr("forge.account_dashboard.membership_price", {
645
+ amount: formatAmount(application.amount, application.currency || currency),
646
+ cycle: application.billingCycle,
647
+ })}
648
+ </p>
649
+ )}
650
+ {application.status === "payment_failed" && application.paymentWindowExpiresAt && (
651
+ <p style={{ fontSize: 13, fontWeight: 600 }}>
652
+ {tr("forge.account_dashboard.application_complete_by", {
653
+ date: formatDate(application.paymentWindowExpiresAt),
654
+ })}
655
+ </p>
656
+ )}
657
+ {application.status === "declined" && application.decisionNote && (
658
+ <p style={{ fontSize: 13, opacity: 0.8, maxWidth: 480 }}>{application.decisionNote}</p>
659
+ )}
660
+ <p style={{ fontSize: 12, opacity: 0.65 }}>
661
+ {tr("forge.account_dashboard.application_submitted_on", { date: formatDate(application.createdAt) })}
662
+ </p>
663
+ </div>
664
+ {canWithdrawMembershipApplication(application.status) && (
665
+ <button
666
+ onClick={() => void onWithdraw(application)}
667
+ disabled={withdraw.isPending}
668
+ style={ghostButton}
669
+ >
670
+ {withdraw.isPending && withdraw.variables?.applicationId === application.id
671
+ ? tr("forge.account_dashboard.application_withdrawing")
672
+ : tr("forge.account_dashboard.application_withdraw")}
673
+ </button>
674
+ )}
675
+ {application.status === "payment_failed" && (
676
+ <a
677
+ href={`/i/membership/checkout?membershipTierId=${encodeURIComponent(application.membershipTierId)}`}
678
+ style={{ ...button, textDecoration: "none" }}
679
+ >
680
+ {tr("forge.account_dashboard.application_complete")}
681
+ </a>
682
+ )}
683
+ </li>
684
+ ))}
685
+ </ul>
686
+ {error && (
687
+ <p role="alert" style={{ fontSize: 13, marginTop: 8, color: t.primary }}>
688
+ {error}
689
+ </p>
690
+ )}
533
691
  </div>
534
692
  );
535
693
  }
@@ -1193,6 +1351,11 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
1193
1351
  {booking.cancellation.terms && (
1194
1352
  <div style={{ fontSize: 13, opacity: 0.75, marginTop: 4 }}>{booking.cancellation.terms}</div>
1195
1353
  )}
1354
+ {/* A reschedule rule of its own. When it follows the cancellation window
1355
+ the sentence above already says it, so nothing is repeated. */}
1356
+ {booking.reschedulePolicy && !booking.reschedulePolicy.followsCancellation && booking.reschedulePolicy.description && (
1357
+ <div style={{ fontSize: 13, opacity: 0.75, marginTop: 4 }}>{booking.reschedulePolicy.description}</div>
1358
+ )}
1196
1359
 
1197
1360
  {/*
1198
1361
  The action row. Both controls are drawn from the SERVER's flags —
@@ -1,4 +1,4 @@
1
- import { useEffect, type ReactNode } from "react";
1
+ import { useEffect, useMemo, useState, type ReactNode } from "react";
2
2
  import { Clock, Loader2, Ticket, XCircle } from "lucide-react";
3
3
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
4
4
  import { readableTextOn } from "../theme/contrast";
@@ -40,7 +40,31 @@ export function BundleConfirmation({
40
40
  }: BundleConfirmationProps) {
41
41
  const theme = useForgeTheme();
42
42
  const t = useForgeT();
43
- const { clearCart } = useCart();
43
+ const { clearCart, ticketItems } = useCart();
44
+ /**
45
+ * The reserved seats this bundle bought, by name, read off the cart lines
46
+ * BEFORE the cart is emptied below. The finalize answer names the child
47
+ * orders and nothing in them, and the cart is the one place the labels the
48
+ * buyer chose on the map still exist on this page.
49
+ */
50
+ const seatLines = useMemo(
51
+ () =>
52
+ ticketItems.flatMap((item) =>
53
+ Object.entries(item.ticketMeta)
54
+ .filter(([ticketId, meta]) => (meta.seats?.length ?? 0) > 0 && (item.tickets[ticketId] ?? 0) > 0)
55
+ .map(([ticketId, meta]) => ({
56
+ key: `${item.eventId}-${ticketId}`,
57
+ eventTitle: item.eventTitle,
58
+ title: meta.title,
59
+ labels: (meta.seats ?? []).map((seat) => seat.label),
60
+ })),
61
+ ),
62
+ [ticketItems],
63
+ );
64
+ const [seatsBought, setSeatsBought] = useState<typeof seatLines>([]);
65
+ useEffect(() => {
66
+ if (seatLines.length > 0) setSeatsBought(seatLines);
67
+ }, [seatLines]);
44
68
  // A guest has no account screen behind "View your orders": the link would
45
69
  // land them on a login wall for an order their EMAIL is the receipt for.
46
70
  const { user } = usePublicAuth();
@@ -190,6 +214,27 @@ export function BundleConfirmation({
190
214
  </div>
191
215
  )}
192
216
 
217
+ {seatsBought.length > 0 && (
218
+ <div
219
+ data-testid="confirmation-seats"
220
+ style={{ textAlign: "left", margin: "0 auto 20px", maxWidth: 360, fontSize: 14 }}
221
+ >
222
+ <div style={{ fontWeight: 600, marginBottom: 4 }}>{t("forge.bundle_confirmation.seats_title")}</div>
223
+ {seatsBought.map((line) => (
224
+ <div key={line.key} style={{ marginBottom: 6 }}>
225
+ <div style={{ opacity: 0.75 }}>
226
+ {line.eventTitle} · {line.title}
227
+ </div>
228
+ {line.labels.map((label) => (
229
+ <div key={label} style={{ paddingLeft: 12 }}>
230
+ {label}
231
+ </div>
232
+ ))}
233
+ </div>
234
+ ))}
235
+ </div>
236
+ )}
237
+
193
238
  <div style={{ display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap" }}>
194
239
  {user && link(accountPath, t("forge.bundle_confirmation.view_orders"), true)}
195
240
  {link(explorePath, t("forge.bundle_confirmation.continue_shopping"), !user)}