@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
@@ -0,0 +1,294 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import type { CountryCode, Examples } from "libphonenumber-js";
3
+
4
+ /**
5
+ * Phone entry that produces E.164, because the input is the only place the
6
+ * country is knowable.
7
+ *
8
+ * ## Why this exists at all
9
+ *
10
+ * `services/admin/consent/phone.ts` refuses to infer a country code from a bare
11
+ * national number, and it is right to: guessing `+1` for a ten-digit string is
12
+ * wrong for most of the world, and a wrong guess writes a phantom suppression
13
+ * against a number that never opted out. The consequence it accepts is that a
14
+ * bare national number simply never matches an E.164 one.
15
+ *
16
+ * That consequence is a live compliance hole on the way IN. Someone types
17
+ * `07700 900123` into a signup form, it is stored verbatim, they later text
18
+ * STOP from `+447700900123`, and the suppression lookup misses. We keep texting
19
+ * someone who said stop, which is the single most expensive failure in SMS.
20
+ *
21
+ * Nothing downstream can fix that, because by then the country is gone. So the
22
+ * fix belongs here: ask for the country, and emit E.164 from the start.
23
+ *
24
+ * ## Metadata cost, measured
25
+ *
26
+ * `libphonenumber-js/max` is 55.5 KB gzip / 48.1 KB brotli once bundled, and the
27
+ * metadata is effectively the whole of it: a build importing only `AsYouType`
28
+ * and `parsePhoneNumberFromString` measured 35.5 KB gzip against 35.7 KB for the
29
+ * full `min` import, so there is nothing to win by trimming the API surface.
30
+ *
31
+ * `max` rather than the smaller tiers, and the reason is not the line-type
32
+ * warning. `mobile` is a mobile-ONLY dataset: under it a landline is not typed
33
+ * as a landline, it is INVALID. That would refuse a perfectly good courier
34
+ * number at checkout, and would refuse real customers in the many countries
35
+ * whose numbers are typed FIXED_LINE_OR_MOBILE. `min` accepts everything but
36
+ * reports the type inconsistently (a GB landline resolves, a DE one does not),
37
+ * so a warning built on it would appear or vanish by country for no reason the
38
+ * visitor could see. `max` is the only tier where both modes below are correct.
39
+ *
40
+ * It is loaded with a dynamic `import()` so none of it sits in the first paint.
41
+ * A phone field is never the thing a visitor sees first, and until the metadata
42
+ * lands the input still accepts typing: `isReady` is false, validation holds
43
+ * off, and nothing is blocked.
44
+ */
45
+
46
+ export type PhoneNumberType = "mobile" | "fixedLine" | "other" | "unknown";
47
+
48
+ export interface UsePhoneInputOptions {
49
+ /**
50
+ * Where to start the country select. Pass the most confident source you have:
51
+ * checkout knows the shipping country, a storefront knows the visitor's geo.
52
+ * Never derive this from the digits someone is typing.
53
+ */
54
+ defaultCountry?: CountryCode;
55
+ /**
56
+ * `messaging` is for a number we will text: it demands a valid number and
57
+ * warns on a line type that cannot receive SMS. `shipping` is for a number a
58
+ * courier will ring, where a landline is perfectly good and the bar is only
59
+ * that the number is real.
60
+ */
61
+ mode?: "messaging" | "shipping";
62
+ /** Seed value, E.164 or national. An E.164 seed also sets the country. */
63
+ initialValue?: string;
64
+ /** Called with E.164 (or null while incomplete) on every change. */
65
+ onChange?: (e164: string | null) => void;
66
+ /**
67
+ * Set false when the field is not being shown. Hooks cannot be called
68
+ * conditionally, so a form with an optional phone field has to call this
69
+ * every render; this is what stops it fetching ~48 KB of metadata for a field
70
+ * nobody is going to see.
71
+ */
72
+ enabled?: boolean;
73
+ }
74
+
75
+ interface Metadata {
76
+ AsYouType: typeof import("libphonenumber-js/max").AsYouType;
77
+ parsePhoneNumberFromString: typeof import("libphonenumber-js/max").parsePhoneNumberFromString;
78
+ getCountries: typeof import("libphonenumber-js/max").getCountries;
79
+ getCountryCallingCode: typeof import("libphonenumber-js/max").getCountryCallingCode;
80
+ getExampleNumber: typeof import("libphonenumber-js/max").getExampleNumber;
81
+ examples: Examples;
82
+ }
83
+
84
+ let metadataPromise: Promise<Metadata> | null = null;
85
+
86
+ /**
87
+ * One shared load for the whole page. Two phone fields on one checkout must not
88
+ * fetch the metadata twice, and the promise is cached rather than the result so
89
+ * concurrent callers await the same request.
90
+ */
91
+ function loadMetadata(): Promise<Metadata> {
92
+ if (!metadataPromise) {
93
+ metadataPromise = Promise.all([
94
+ import("libphonenumber-js/max"),
95
+ import("libphonenumber-js/examples.mobile.json"),
96
+ ]).then(([lib, ex]) => ({
97
+ AsYouType: lib.AsYouType,
98
+ parsePhoneNumberFromString: lib.parsePhoneNumberFromString,
99
+ getCountries: lib.getCountries,
100
+ getCountryCallingCode: lib.getCountryCallingCode,
101
+ getExampleNumber: lib.getExampleNumber,
102
+ examples: ((ex as { default?: Examples }).default ?? ex) as Examples,
103
+ }));
104
+ }
105
+ return metadataPromise;
106
+ }
107
+
108
+ /** Let a host warm the metadata before the field is reached (on route enter, say). */
109
+ export const preloadPhoneMetadata = loadMetadata;
110
+
111
+ export interface CountryOption {
112
+ country: CountryCode;
113
+ callingCode: string;
114
+ flag: string;
115
+ }
116
+
117
+ /** Regional-indicator pair. "GB" becomes the flag, with no icon set to ship. */
118
+ function flagOf(country: string): string {
119
+ if (country.length !== 2) return "";
120
+ return String.fromCodePoint(
121
+ ...[...country.toUpperCase()].map((c) => 0x1f1a5 + c.charCodeAt(0)),
122
+ );
123
+ }
124
+
125
+ export function usePhoneInput(opts: UsePhoneInputOptions = {}) {
126
+ const { defaultCountry, mode = "messaging", initialValue, onChange, enabled = true } = opts;
127
+
128
+ const [meta, setMeta] = useState<Metadata | null>(null);
129
+ const [country, setCountryState] = useState<CountryCode | undefined>(defaultCountry);
130
+ const [nationalValue, setNationalValue] = useState(initialValue ?? "");
131
+ const [touched, setTouched] = useState(false);
132
+ const onChangeRef = useRef(onChange);
133
+ onChangeRef.current = onChange;
134
+
135
+ useEffect(() => {
136
+ if (!enabled) return;
137
+ let alive = true;
138
+ loadMetadata().then((m) => {
139
+ if (alive) setMeta(m);
140
+ });
141
+ return () => {
142
+ alive = false;
143
+ };
144
+ }, [enabled]);
145
+
146
+ /**
147
+ * A `defaultCountry` that resolves late still has to land. Checkout learns the
148
+ * shipping country after the address is filled, so a prop that arrives second
149
+ * must move the select, but only while the visitor has not chosen for
150
+ * themselves. Overriding a deliberate choice would be worse than being wrong.
151
+ */
152
+ useEffect(() => {
153
+ if (defaultCountry && !touched && !country) setCountryState(defaultCountry);
154
+ }, [defaultCountry, touched, country]);
155
+
156
+ const parsed = useMemo(() => {
157
+ if (!meta || !nationalValue.trim()) return null;
158
+ return meta.parsePhoneNumberFromString(nationalValue, country);
159
+ }, [meta, nationalValue, country]);
160
+
161
+ const e164 = parsed?.isValid() ? (parsed.number as string) : null;
162
+
163
+ const numberType: PhoneNumberType = useMemo(() => {
164
+ if (!parsed?.isValid()) return "unknown";
165
+ const t = parsed.getType();
166
+ if (t === "MOBILE") return "mobile";
167
+ if (t === "FIXED_LINE") return "fixedLine";
168
+ if (t === "FIXED_LINE_OR_MOBILE" || t === undefined) return "unknown";
169
+ return "other";
170
+ }, [parsed]);
171
+
172
+ /**
173
+ * A landline is a warning, never an error. The line type is a best guess from
174
+ * a numbering plan that many countries do not partition cleanly, and blocking
175
+ * a real number on a guess is worse than spending one credit finding out.
176
+ */
177
+ const warning = useMemo(() => {
178
+ if (mode !== "messaging" || !e164) return null;
179
+ return numberType === "fixedLine" ? ("landline" as const) : null;
180
+ }, [mode, e164, numberType]);
181
+
182
+ const error = useMemo(() => {
183
+ if (!touched || !meta) return null;
184
+ const raw = nationalValue.trim();
185
+ if (!raw) return "required" as const;
186
+ if (!country && !raw.startsWith("+")) return "no_country" as const;
187
+ if (!parsed) return "invalid" as const;
188
+ if (!parsed.isValid()) return "invalid" as const;
189
+ return null;
190
+ }, [touched, meta, nationalValue, country, parsed]);
191
+
192
+ /**
193
+ * Format as they type, in the national style of the selected country, so the
194
+ * value on screen matches the shape the placeholder promised.
195
+ *
196
+ * The exception is a pasted international number. The field says "without the
197
+ * country code" and a good share of people will paste `+44…` regardless. Left
198
+ * alone under a `GB` formatter that would read as a national string and come
199
+ * out mangled, so a leading `+` switches to international formatting and
200
+ * re-syncs the flag to whatever was pasted.
201
+ */
202
+ const setValue = useCallback(
203
+ (raw: string) => {
204
+ if (!meta) {
205
+ setNationalValue(raw);
206
+ return;
207
+ }
208
+ const isInternational = raw.trim().startsWith("+");
209
+ if (isInternational) {
210
+ const formatter = new meta.AsYouType();
211
+ const formatted = formatter.input(raw);
212
+ const detected = formatter.getCountry();
213
+ if (detected && detected !== country) {
214
+ setCountryState(detected);
215
+ setTouched(true);
216
+ }
217
+ setNationalValue(formatted);
218
+ return;
219
+ }
220
+ const formatter = new meta.AsYouType(country);
221
+ setNationalValue(formatter.input(raw));
222
+ },
223
+ [meta, country],
224
+ );
225
+
226
+ const setCountry = useCallback(
227
+ (next: CountryCode) => {
228
+ setCountryState(next);
229
+ setTouched(true);
230
+ // Re-format what is already typed under the new country rather than
231
+ // clearing it: someone correcting the flag after typing should not lose
232
+ // their number.
233
+ setNationalValue((current) => {
234
+ if (!meta || !current.trim() || current.trim().startsWith("+")) return current;
235
+ const digits = current.replace(/\D/g, "");
236
+ return new meta.AsYouType(next).input(digits);
237
+ });
238
+ },
239
+ [meta],
240
+ );
241
+
242
+ useEffect(() => {
243
+ onChangeRef.current?.(e164);
244
+ }, [e164]);
245
+
246
+ const countries: CountryOption[] = useMemo(() => {
247
+ if (!meta) return [];
248
+ return meta
249
+ .getCountries()
250
+ .map((c) => ({ country: c, callingCode: `+${meta.getCountryCallingCode(c)}`, flag: flagOf(c) }))
251
+ .sort((a, b) => a.country.localeCompare(b.country));
252
+ }, [meta]);
253
+
254
+ /**
255
+ * The placeholder is the real national format for the chosen country, which
256
+ * teaches the shape better than any sentence under the field: GB shows
257
+ * `07700 900123`, US shows `(201) 555-0123`.
258
+ */
259
+ const placeholder = useMemo(() => {
260
+ if (!meta || !country) return "";
261
+ const example = meta.getExampleNumber(country, meta.examples);
262
+ return example ? example.formatNational() : "";
263
+ }, [meta, country]);
264
+
265
+ return {
266
+ /** Loaded-metadata flag. Until true, validation holds off and typing still works. */
267
+ isReady: !!meta,
268
+ country,
269
+ setCountry,
270
+ countries,
271
+ callingCode: meta && country ? `+${meta.getCountryCallingCode(country)}` : "",
272
+ /** What the visitor sees: national format, no country code. */
273
+ value: nationalValue,
274
+ setValue,
275
+ placeholder,
276
+ /** What to submit. Null until the number is valid. */
277
+ e164,
278
+ isValid: !!e164,
279
+ numberType,
280
+ /** "landline" when messaging a number that cannot receive SMS. Never blocks. */
281
+ warning,
282
+ /** "required" | "no_country" | "invalid", or null. Only after blur. */
283
+ error,
284
+ touched,
285
+ markTouched: useCallback(() => setTouched(true), []),
286
+ reset: useCallback(() => {
287
+ setNationalValue("");
288
+ setTouched(false);
289
+ setCountryState(defaultCountry);
290
+ }, [defaultCountry]),
291
+ };
292
+ }
293
+
294
+ export type UsePhoneInputReturn = ReturnType<typeof usePhoneInput>;
@@ -111,7 +111,7 @@ function buildModel(form?: FormData) {
111
111
 
112
112
  /**
113
113
  * Headless multi-section form flow: a paginated wizard over `usePublicForm` that
114
- * mirrors the server walk (docs/forms-sections.md). Handles current-section
114
+ * mirrors the server walk (docs/archive/shipped/forms-sections.md). Handles current-section
115
115
  * state, in-page field visibility (show/hide), per-section required validation,
116
116
  * section→section branching (incl. submit), Back navigation, and submit. Bring
117
117
  * your own markup (or use the styled `<SectionedFormRenderer>`).
@@ -135,7 +135,7 @@ export function useSectionedForm(formId?: string) {
135
135
  const [error, setError] = useState("");
136
136
  const [isCompleted, setIsCompleted] = useState(false);
137
137
  // Graded quiz result (null until submit + only for quiz-mode forms). Shaped by
138
- // the server's reveal policy — see docs/quizzes-certification-initiative.md.
138
+ // the server's reveal policy — see docs/archive/shipped/quizzes-certification-initiative.md.
139
139
  const [quizResult, setQuizResult] = useState<Record<string, unknown> | null>(null);
140
140
 
141
141
  // Start at the first section once the form loads.
@@ -7,6 +7,14 @@ export { MembershipGate, type MembershipGateProps, type MembershipGateState } fr
7
7
  // S.4 members-only gates: the notice a storefront draws BEFORE the buyer pays.
8
8
  export { useMembershipGateNotice, type UseMembershipGateNoticeInput } from "./membership/useMembershipGateNotice";
9
9
  export { useEmailListForm, type UseEmailListFormOptions, type FormStatus } from "./forms/useEmailListForm";
10
+ export {
11
+ usePhoneInput,
12
+ preloadPhoneMetadata,
13
+ type UsePhoneInputOptions,
14
+ type UsePhoneInputReturn,
15
+ type PhoneNumberType,
16
+ type CountryOption,
17
+ } from "./forms/usePhoneInput";
10
18
  export { useContactForm } from "./forms/useContactForm";
11
19
  export { useSectionedForm } from "./forms/useSectionedForm";
12
20
  export { usePostEngagement, type UsePostEngagementOptions } from "./engagement/usePostEngagement";
@@ -52,7 +60,23 @@ export { useSignupForm, type UseSignupFormOptions } from "./auth/useSignupForm";
52
60
 
53
61
  // Flow primitives (Part A2) — multi-step purchase/booking/subscribe/chat flows,
54
62
  // each composed from the data hooks so a creator can rebuild any page.
55
- export { useEventCheckout, type UseEventCheckoutOptions, type EventCheckoutStep } from "./event/useEventCheckout";
63
+ export {
64
+ useEventCheckout,
65
+ type UseEventCheckoutOptions,
66
+ type EventCheckoutStep,
67
+ type ToggleSeatResult,
68
+ } from "./event/useEventCheckout";
69
+ // Reserved seating (wave 2): the joined seating read and the pure pieces a
70
+ // host needs to drive a picker of its own.
71
+ export {
72
+ useEventSeating,
73
+ bestAvailableSeats,
74
+ indexEventSeats,
75
+ seatLabel,
76
+ type EventSeat,
77
+ type EventSeatingState,
78
+ type UseEventSeatingOptions,
79
+ } from "./event/useEventSeating";
56
80
  // Ticket requests (curated access): the pure decisions behind the picker and
57
81
  // the post-order routing, for a host that drives the hook from its own UI.
58
82
  export {
@@ -100,6 +124,29 @@ export {
100
124
  type MembershipCheckoutStep,
101
125
  type BillingCycle,
102
126
  } from "./membership/useMembershipCheckout";
127
+ // Curated access (invite-only and apply-to-join tiers): the pure decisions the
128
+ // tier card, the checkout and the account page share.
129
+ export {
130
+ canWithdrawMembershipApplication,
131
+ MEMBERSHIP_APPLICATION_MESSAGE_MAX,
132
+ membershipApplicationOutcome,
133
+ tierCardAction,
134
+ tierUsesApplication,
135
+ type MembershipApplicationOutcome,
136
+ type MembershipTierCardAction,
137
+ } from "./membership/membershipApplication";
138
+ export {
139
+ formatTrialDate,
140
+ isMembershipInTrial,
141
+ tierTrialDays,
142
+ trialEndDate,
143
+ } from "./membership/membershipTrial";
144
+ // Tier questionnaires: which questions a checkout asks and what it sends.
145
+ export {
146
+ buildQuestionnaireAnswers,
147
+ firstMissingAnswer,
148
+ tierQuestionnaire,
149
+ } from "./membership/membershipQuestionnaire";
103
150
  export { useInvoicePayment, type UseInvoicePaymentOptions } from "./invoice/useInvoicePayment";
104
151
  export { usePaymentLinkPayment, type UsePaymentLinkPaymentOptions } from "./paymentLink/usePaymentLinkPayment";
105
152
  export { useBroadcastWatch } from "./broadcast/useBroadcastWatch";
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ canWithdrawMembershipApplication,
4
+ membershipApplicationOutcome,
5
+ tierCardAction,
6
+ tierUsesApplication,
7
+ } from "../membershipApplication";
8
+ import type { MembershipApplicationStatus, MembershipTier } from "../../../../types/models";
9
+
10
+ const app = (status: MembershipApplicationStatus) => ({ id: "app-1", status, paymentWindowExpiresAt: null });
11
+
12
+ describe("tierCardAction", () => {
13
+ it("offers the tier in its own words when the fan has no application", () => {
14
+ expect(tierCardAction({})).toBe("join");
15
+ expect(tierCardAction({ joinMode: "open" })).toBe("join");
16
+ expect(tierCardAction({ joinMode: "invite" })).toBe("accept_invite");
17
+ expect(tierCardAction({ joinMode: "application" })).toBe("apply");
18
+ expect(tierCardAction({ joinMode: "application", myApplication: null })).toBe("apply");
19
+ });
20
+
21
+ it("shows where the fan's application stands instead of the button", () => {
22
+ expect(tierCardAction({ joinMode: "application", myApplication: app("pending") })).toBe("pending");
23
+ expect(tierCardAction({ joinMode: "application", myApplication: app("approved") })).toBe("approved");
24
+ expect(tierCardAction({ joinMode: "application", myApplication: app("payment_failed") })).toBe("complete");
25
+ expect(tierCardAction({ joinMode: "application", myApplication: app("declined") })).toBe("declined");
26
+ });
27
+
28
+ it("lets a fan pick an unfinished card step back up", () => {
29
+ expect(tierCardAction({ joinMode: "application", myApplication: app("card_pending") })).toBe("apply");
30
+ });
31
+ });
32
+
33
+ describe("tierUsesApplication", () => {
34
+ it("is true on an application tier", () => {
35
+ expect(tierUsesApplication({ joinMode: "application" })).toBe(true);
36
+ expect(tierUsesApplication({ joinMode: "application", myApplication: app("pending") })).toBe(true);
37
+ });
38
+
39
+ it("EXCEPTION: a payment_failed application completes through the ordinary paid checkout", () => {
40
+ expect(tierUsesApplication({ joinMode: "application", myApplication: app("payment_failed") })).toBe(false);
41
+ });
42
+
43
+ it("is false on open and invite tiers, and with no tier", () => {
44
+ expect(tierUsesApplication({ joinMode: "open" } as Pick<MembershipTier, "joinMode">)).toBe(false);
45
+ expect(tierUsesApplication({ joinMode: "invite" })).toBe(false);
46
+ expect(tierUsesApplication({})).toBe(false);
47
+ expect(tierUsesApplication(undefined)).toBe(false);
48
+ });
49
+ });
50
+
51
+ describe("canWithdrawMembershipApplication", () => {
52
+ it("allows withdrawing only before a decision", () => {
53
+ expect(canWithdrawMembershipApplication("card_pending")).toBe(true);
54
+ expect(canWithdrawMembershipApplication("pending")).toBe(true);
55
+ for (const status of ["approved", "payment_failed", "joined", "declined", "withdrawn", "expired"] as const) {
56
+ expect(canWithdrawMembershipApplication(status)).toBe(false);
57
+ }
58
+ expect(canWithdrawMembershipApplication(undefined)).toBe(false);
59
+ });
60
+ });
61
+
62
+ describe("membershipApplicationOutcome", () => {
63
+ const outcome = (status: MembershipApplicationStatus, closedReason: string | null = null, confirmed = false) =>
64
+ membershipApplicationOutcome({ status, closedReason }, confirmed);
65
+
66
+ it("is null before anything was submitted, and while the card step is still ahead", () => {
67
+ expect(membershipApplicationOutcome(null)).toBeNull();
68
+ expect(outcome("card_pending")).toBeNull();
69
+ });
70
+
71
+ it("reads a confirmed card the server has not caught up with as received", () => {
72
+ expect(outcome("card_pending", null, true)).toBe("received");
73
+ });
74
+
75
+ it("maps every other status", () => {
76
+ expect(outcome("pending")).toBe("received");
77
+ expect(outcome("approved")).toBe("approved");
78
+ expect(outcome("joined")).toBe("approved");
79
+ expect(outcome("payment_failed")).toBe("approved");
80
+ expect(outcome("declined")).toBe("declined");
81
+ expect(outcome("withdrawn", "tier_full")).toBe("tier_full");
82
+ expect(outcome("withdrawn")).toBe("closed");
83
+ expect(outcome("expired", "expired")).toBe("closed");
84
+ });
85
+ });
@@ -39,6 +39,8 @@ vi.mock("../../../../contexts/PublicAuthContext", async (importOriginal) => ({
39
39
  vi.mock("../../../../data/queries/useMembership", async (importOriginal) => ({
40
40
  ...(await importOriginal<typeof import("../../../../data/queries/useMembership")>()),
41
41
  useGetMembershipTiers: () => ({ data: tiers, isLoading: false }),
42
+ useApplyForMembership: () => ({ mutateAsync: vi.fn(), isPending: false }),
43
+ useConfirmMembershipApplication: () => ({ mutateAsync: vi.fn(), isPending: false }),
42
44
  }));
43
45
 
44
46
  vi.mock("../../../../data/queries/useSubscriptions", async (importOriginal) => ({
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { formatTrialDate, isMembershipInTrial, tierTrialDays, trialEndDate } from "../membershipTrial";
3
+
4
+ /**
5
+ * Free trials on paid membership tiers: who gets one, when it ends, and whether
6
+ * a membership is still inside it. Every surface reads these, so they are pinned
7
+ * as pure functions.
8
+ */
9
+ describe("tierTrialDays", () => {
10
+ it("gives the tier's trial to an eligible fan, and to a signed-out visitor (no trialEligible)", () => {
11
+ expect(tierTrialDays({ trialDays: 14, trialEligible: true })).toBe(14);
12
+ expect(tierTrialDays({ trialDays: 7 })).toBe(7);
13
+ });
14
+
15
+ it("gives none to a fan who already had a trial with this business", () => {
16
+ expect(tierTrialDays({ trialDays: 14, trialEligible: false })).toBe(0);
17
+ });
18
+
19
+ it("gives none when the tier has no trial", () => {
20
+ expect(tierTrialDays({ trialDays: null })).toBe(0);
21
+ expect(tierTrialDays({ trialDays: 0 })).toBe(0);
22
+ expect(tierTrialDays({ trialDays: -3 })).toBe(0);
23
+ expect(tierTrialDays({})).toBe(0);
24
+ expect(tierTrialDays(undefined)).toBe(0);
25
+ });
26
+
27
+ it("gives none to a member with a live paid subscription (that is a tier change)", () => {
28
+ expect(
29
+ tierTrialDays({ trialDays: 14 }, { paymentProviderSubscriptionId: "sub_1", status: "active" }),
30
+ ).toBe(0);
31
+ // A lapsed or free membership does not block the trial.
32
+ expect(tierTrialDays({ trialDays: 14 }, { paymentProviderSubscriptionId: "sub_1", status: "cancelled" })).toBe(14);
33
+ expect(tierTrialDays({ trialDays: 14 }, { status: "active" })).toBe(14);
34
+ });
35
+ });
36
+
37
+ describe("trialEndDate", () => {
38
+ it("is today plus the trial days", () => {
39
+ const now = new Date("2026-09-17T10:00:00.000Z");
40
+ expect(trialEndDate(14, now).toISOString()).toBe("2026-10-01T10:00:00.000Z");
41
+ });
42
+ });
43
+
44
+ describe("isMembershipInTrial", () => {
45
+ const now = new Date("2026-09-17T10:00:00.000Z");
46
+ it("is true only while trialEndsAt is in the future", () => {
47
+ expect(isMembershipInTrial({ trialEndsAt: "2026-09-30T00:00:00.000Z" }, now)).toBe(true);
48
+ expect(isMembershipInTrial({ trialEndsAt: "2026-09-01T00:00:00.000Z" }, now)).toBe(false);
49
+ expect(isMembershipInTrial({ trialEndsAt: null }, now)).toBe(false);
50
+ expect(isMembershipInTrial({}, now)).toBe(false);
51
+ expect(isMembershipInTrial(undefined, now)).toBe(false);
52
+ expect(isMembershipInTrial({ trialEndsAt: "not a date" }, now)).toBe(false);
53
+ });
54
+ });
55
+
56
+ describe("formatTrialDate", () => {
57
+ it("formats per locale", () => {
58
+ expect(formatTrialDate("2026-10-01T12:00:00.000Z", "en")).toBe("Oct 1, 2026");
59
+ expect(formatTrialDate("2026-10-01T12:00:00.000Z", "de")).toContain("2026");
60
+ });
61
+ });