@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
@@ -1,5 +1,5 @@
1
1
  import type { CSSProperties } from "react";
2
- import { useGetMembershipTiers } from "../../data/queries/useMembership";
2
+ import { useGetMembershipTiers, useMembershipInviteInfo } from "../../data/queries/useMembership";
3
3
  import { useForgeT } from "../../i18n";
4
4
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
5
5
  import { useAmountFormatter } from "../format/useFormatCurrency";
@@ -10,6 +10,13 @@ export interface MembershipTierCalloutProps {
10
10
  membershipTierId?: string;
11
11
  /** How many benefits to list before summarising the rest. Default 3. */
12
12
  maxBenefits?: number;
13
+ /**
14
+ * The membership invite the visitor is holding (read off the same redirect as
15
+ * the tier id). An invite-only tier is only listed with it, and the callout
16
+ * then also names the email the invite was sent to, so the fan signs in with
17
+ * the right account.
18
+ */
19
+ inviteToken?: string;
13
20
  className?: string;
14
21
  style?: CSSProperties;
15
22
  }
@@ -31,6 +38,7 @@ export interface MembershipTierCalloutProps {
31
38
  export function MembershipTierCallout({
32
39
  membershipTierId,
33
40
  maxBenefits = 3,
41
+ inviteToken,
34
42
  className,
35
43
  style,
36
44
  }: MembershipTierCalloutProps) {
@@ -39,7 +47,8 @@ export function MembershipTierCallout({
39
47
  const fmt = useAmountFormatter();
40
48
  // The tier list is one cached query per profile, so this shares the fetch the
41
49
  // membership page and the checkout already make rather than adding a request.
42
- const { data: tiers } = useGetMembershipTiers();
50
+ const { data: tiers } = useGetMembershipTiers(inviteToken ? { inviteToken } : {});
51
+ const { data: invite } = useMembershipInviteInfo(inviteToken);
43
52
  const tier = membershipTierId ? tiers?.find((candidate) => candidate.id === membershipTierId) : undefined;
44
53
 
45
54
  if (!tier) return null;
@@ -67,7 +76,12 @@ export function MembershipTierCallout({
67
76
  <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 8, fontFamily: tokens.headingFontFamily }}>
68
77
  {t("forge.membership_tier_callout.join_title", { tier_name: tier.name })}
69
78
  </h2>
70
- <p style={{ fontSize: 14, marginBottom: shown.length ? 12 : 0 }}>{membershipPriceLabel(tier, t, fmt)}</p>
79
+ <p style={{ fontSize: 14, marginBottom: shown.length || invite ? 12 : 0 }}>{membershipPriceLabel(tier, t, fmt)}</p>
80
+ {invite && invite.membershipTierId === tier.id && (
81
+ <p style={{ fontSize: 14, fontWeight: 600, marginBottom: shown.length ? 12 : 0 }}>
82
+ {t("forge.membership_tier_callout.invite_for_email", { email: invite.email })}
83
+ </p>
84
+ )}
71
85
  {!!shown.length && (
72
86
  <div style={{ fontSize: 14 }}>
73
87
  <p style={{ fontWeight: 500, marginBottom: 4 }}>{t("forge.membership_tier_callout.benefits_label")}</p>
@@ -1,10 +1,18 @@
1
1
  import type { MembershipTier } from "../../types/models";
2
- import { useGetMembershipTiers } from "../../data/queries/useMembership";
2
+ import { useContext, useState } from "react";
3
+ import {
4
+ readMembershipInviteFromUrl,
5
+ useGetMembershipTiers,
6
+ useWithdrawMembershipApplication,
7
+ } from "../../data/queries/useMembership";
8
+ import { tierCardAction } from "../headless/membership/membershipApplication";
9
+ import { tierTrialDays } from "../headless/membership/membershipTrial";
3
10
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
4
11
  import { useAmountFormatter } from "../format/useFormatCurrency";
5
12
  import { priceTaxCaption } from "../format/PriceDisplay";
6
13
  import { usePricesIncludeTax } from "../../data/queries/useWebsite";
7
14
  import { useForgeT } from "../../i18n";
15
+ import { PublicAuthContext } from "../../contexts/PublicAuthContext";
8
16
  import { membershipPriceLabel } from "../format/membershipPriceLabel";
9
17
  import { Button } from "./Button";
10
18
  import { Loading } from "./Loading";
@@ -17,7 +25,12 @@ export interface MembershipTiersProps {
17
25
  * `membershipTierId` query param. Default `/i/membership/checkout`.
18
26
  */
19
27
  checkoutPath?: string;
20
- /** Optional override for tier selection (e.g. router navigation). */
28
+ /**
29
+ * Optional override for tier selection (e.g. router navigation). Also what
30
+ * "Complete your membership" calls on an approved application whose card was
31
+ * declined. A host that overrides it should carry an `invite` search param
32
+ * through to the checkout.
33
+ */
21
34
  onSelect?: (tier: MembershipTier) => void;
22
35
  /** Optional page heading. Pass `null` to omit. Default `"Membership"`. */
23
36
  heading?: string | null;
@@ -30,6 +43,15 @@ export interface MembershipTiersProps {
30
43
  * responsive card row (a single tier sits at a natural card width rather than
31
44
  * stretching full-screen). "Select" routes to `checkoutPath` with the tier id,
32
45
  * or calls `onSelect` when provided.
46
+ *
47
+ * Curated access: an invite-only tier is only returned when the page was opened
48
+ * with its invite (`?invite=`), and reads "Accept invite"; an application tier
49
+ * reads "Apply to join". When the signed-in fan already applied, the card shows
50
+ * where that application stands instead of the button.
51
+ *
52
+ * Free trials: a paid tier with a trial the fan is still eligible for carries a
53
+ * "{days}-day free trial" badge, and an open tier's button reads "Start free
54
+ * trial". An application tier keeps "Apply to join" (approval starts the trial).
33
55
  */
34
56
  export function MembershipTiers({
35
57
  formatAmount,
@@ -46,6 +68,8 @@ export function MembershipTiers({
46
68
  const pricesIncludeTax = usePricesIncludeTax();
47
69
  const taxCaption = priceTaxCaption({ pricesIncludeTax });
48
70
  const { data: tiers, isLoading } = useGetMembershipTiers();
71
+ const withdraw = useWithdrawMembershipApplication();
72
+ const [withdrawError, setWithdrawError] = useState<string | null>(null);
49
73
  // `null` still means "no heading"; anything else falls back to the default.
50
74
  const resolvedHeading = heading === null ? null : (heading ?? t("forge.membership_tiers.heading"));
51
75
  const freeLabel = t("forge.membership_tiers.price_free");
@@ -62,7 +86,10 @@ export function MembershipTiers({
62
86
  if (onSelect) return onSelect(tier);
63
87
  if (typeof window !== "undefined") {
64
88
  const sep = checkoutPath.includes("?") ? "&" : "?";
65
- window.location.href = `${checkoutPath}${sep}membershipTierId=${encodeURIComponent(tier.id)}`;
89
+ const invite = readMembershipInviteFromUrl();
90
+ window.location.href = `${checkoutPath}${sep}membershipTierId=${encodeURIComponent(tier.id)}${
91
+ invite ? `&invite=${encodeURIComponent(invite)}` : ""
92
+ }`;
66
93
  }
67
94
  };
68
95
 
@@ -99,6 +126,7 @@ export function MembershipTiers({
99
126
  </span>
100
127
  )}
101
128
  </div>
129
+ <TierTrialBadge tier={tier} />
102
130
  {tier.description && <p style={{ opacity: 0.75 }}>{tier.description}</p>}
103
131
  {!!tier.benefits?.length && (
104
132
  <ul style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 6 }}>
@@ -110,12 +138,126 @@ export function MembershipTiers({
110
138
  ))}
111
139
  </ul>
112
140
  )}
113
- <Button fullWidth style={{ marginTop: "auto" }} onClick={() => select(tier)}>
114
- {t("forge.membership_tiers.select")}
115
- </Button>
141
+ <TierAction
142
+ tier={tier}
143
+ onSelect={() => select(tier)}
144
+ onWithdraw={async (applicationId) => {
145
+ setWithdrawError(null);
146
+ try {
147
+ await withdraw.mutateAsync({ applicationId });
148
+ } catch (error) {
149
+ const detail = (error as { response?: { data?: { message?: string } } })?.response?.data?.message;
150
+ setWithdrawError(detail || t("forge.membership_tiers.withdraw_error"));
151
+ }
152
+ }}
153
+ isWithdrawing={withdraw.isPending}
154
+ withdrawError={withdraw.variables?.applicationId === tier.myApplication?.id ? withdrawError : null}
155
+ />
116
156
  </div>
117
157
  ))}
118
158
  </div>
119
159
  </div>
120
160
  );
121
161
  }
162
+
163
+ /** "{days}-day free trial" on a tier that gives this fan a trial; nothing otherwise. Exported for the spec. */
164
+ export function TierTrialBadge({ tier }: { tier: MembershipTier }) {
165
+ const t = useForgeT();
166
+ const tokens = useThemeTokens();
167
+ // Read softly: the card also renders outside a signed-in tree.
168
+ const user = useContext(PublicAuthContext)?.user;
169
+ const days = tierTrialDays(tier, user?.membership);
170
+ if (days <= 0) return null;
171
+ return (
172
+ <span
173
+ data-testid="membership-tier-trial-badge"
174
+ style={{
175
+ alignSelf: "flex-start",
176
+ padding: "2px 10px",
177
+ borderRadius: 999,
178
+ fontSize: 12,
179
+ fontWeight: 700,
180
+ border: `1px solid ${tokens.primary}`,
181
+ color: tokens.primary,
182
+ }}
183
+ >
184
+ {t("forge.membership_tiers.trial_badge", { days })}
185
+ </span>
186
+ );
187
+ }
188
+
189
+ /**
190
+ * The bottom of a tier card: the join button in the tier's own words, or where
191
+ * the fan's application stands. Exported for the card-state spec.
192
+ */
193
+ export function TierAction({
194
+ tier,
195
+ onSelect,
196
+ onWithdraw,
197
+ isWithdrawing,
198
+ withdrawError,
199
+ }: {
200
+ tier: MembershipTier;
201
+ onSelect: () => void;
202
+ onWithdraw: (applicationId: string) => void;
203
+ isWithdrawing?: boolean;
204
+ withdrawError?: string | null;
205
+ }) {
206
+ const t = useForgeT();
207
+ const tokens = useThemeTokens();
208
+ // Read softly: the card also renders outside a signed-in tree.
209
+ const user = useContext(PublicAuthContext)?.user;
210
+ const action = tierCardAction(tier);
211
+ const stateLine = (text: string) => (
212
+ <p data-testid="membership-tier-application-state" style={{ fontWeight: 600, color: tokens.text }}>
213
+ {text}
214
+ </p>
215
+ );
216
+
217
+ switch (action) {
218
+ case "pending":
219
+ return (
220
+ <div style={{ marginTop: "auto", display: "flex", flexDirection: "column", gap: 8 }}>
221
+ {stateLine(t("forge.membership_tiers.application_pending"))}
222
+ <Button
223
+ fullWidth
224
+ variant="outline"
225
+ disabled={isWithdrawing}
226
+ onClick={() => tier.myApplication && onWithdraw(tier.myApplication.id)}
227
+ >
228
+ {isWithdrawing ? t("forge.membership_tiers.withdrawing") : t("forge.membership_tiers.withdraw")}
229
+ </Button>
230
+ {withdrawError && (
231
+ <p role="alert" style={{ fontSize: 13 }}>
232
+ {withdrawError}
233
+ </p>
234
+ )}
235
+ </div>
236
+ );
237
+ case "approved":
238
+ return <div style={{ marginTop: "auto" }}>{stateLine(t("forge.membership_tiers.application_approved"))}</div>;
239
+ case "declined":
240
+ return <div style={{ marginTop: "auto" }}>{stateLine(t("forge.membership_tiers.application_declined"))}</div>;
241
+ case "complete":
242
+ return (
243
+ <div style={{ marginTop: "auto", display: "flex", flexDirection: "column", gap: 8 }}>
244
+ {stateLine(t("forge.membership_tiers.application_payment_failed"))}
245
+ <Button fullWidth onClick={onSelect}>
246
+ {t("forge.membership_tiers.complete_membership")}
247
+ </Button>
248
+ </div>
249
+ );
250
+ default:
251
+ return (
252
+ <Button fullWidth style={{ marginTop: "auto" }} onClick={onSelect}>
253
+ {action === "accept_invite"
254
+ ? t("forge.membership_tiers.accept_invite")
255
+ : action === "apply"
256
+ ? t("forge.membership_tiers.apply")
257
+ : tierTrialDays(tier, user?.membership) > 0
258
+ ? t("forge.membership_tiers.start_free_trial")
259
+ : t("forge.membership_tiers.select")}
260
+ </Button>
261
+ );
262
+ }
263
+ }
@@ -0,0 +1,127 @@
1
+ import React from "react";
2
+ import { useForgeT } from "../../i18n";
3
+ import { useForgeTheme } from "../theme/ForgeThemeProvider";
4
+ import { usePhoneInput, type UsePhoneInputOptions, type UsePhoneInputReturn } from "../headless/forms/usePhoneInput";
5
+
6
+ export interface PhoneInputProps extends UsePhoneInputOptions {
7
+ /** Drive the field from a hook the parent already owns (checkout does this). */
8
+ controller?: UsePhoneInputReturn;
9
+ label?: string;
10
+ required?: boolean;
11
+ disabled?: boolean;
12
+ id?: string;
13
+ }
14
+
15
+ /**
16
+ * Country select plus a national-format number field.
17
+ *
18
+ * The visitor picks a flag and types their number the way they would say it
19
+ * aloud, with no country code and no `+`. The E.164 string the rest of the
20
+ * platform needs is derived, never typed.
21
+ *
22
+ * The hint sits in the placeholder rather than under the field. A real national
23
+ * example for the selected country (`07700 900123` under GB) says more than a
24
+ * sentence would, and a permanent muted line under every phone field is exactly
25
+ * the kind of copy people stop reading. The "without the country code" wording
26
+ * appears only when someone has typed a `+` anyway, which is the moment it is
27
+ * worth saying.
28
+ */
29
+ export function PhoneInput({ controller, label, required, disabled, id, ...opts }: PhoneInputProps) {
30
+ const theme = useForgeTheme();
31
+ const t = useForgeT();
32
+ const own = usePhoneInput(opts);
33
+ const f = controller ?? own;
34
+
35
+ const fieldId = id ?? "forge-phone";
36
+ const errorId = `${fieldId}-error`;
37
+ const alpha = (a: number) =>
38
+ Math.round(a * 255)
39
+ .toString(16)
40
+ .padStart(2, "0");
41
+
42
+ const shared: React.CSSProperties = {
43
+ padding: "8px 12px",
44
+ border: `1px solid ${f.error ? "#ef4444" : `${theme.colors.primary}${alpha(0.3)}`}`,
45
+ backgroundColor: theme.colors.background,
46
+ color: theme.colors.text,
47
+ borderRadius: `${theme.cornerRadius}px`,
48
+ fontSize: 14,
49
+ };
50
+
51
+ const errorText =
52
+ f.error === "required"
53
+ ? t("forge.phone_input.error_required")
54
+ : f.error === "no_country"
55
+ ? t("forge.phone_input.error_no_country")
56
+ : f.error === "invalid"
57
+ ? t("forge.phone_input.error_invalid")
58
+ : null;
59
+
60
+ return (
61
+ <div className="fg:flex fg:flex-col fg:gap-1 fg:w-full">
62
+ {label && (
63
+ <label htmlFor={fieldId} style={{ color: theme.colors.text, fontSize: 14, fontWeight: 500 }}>
64
+ {label}
65
+ {required && <span style={{ color: "#ef4444" }}> *</span>}
66
+ </label>
67
+ )}
68
+
69
+ <div className="fg:flex fg:flex-row fg:gap-2 fg:w-full">
70
+ <select
71
+ aria-label={t("forge.phone_input.country_label")}
72
+ value={f.country ?? ""}
73
+ disabled={disabled || !f.isReady}
74
+ onChange={(e) => f.setCountry(e.target.value as never)}
75
+ style={{ ...shared, flex: "0 0 auto", maxWidth: 140, cursor: disabled ? "default" : "pointer" }}
76
+ >
77
+ {!f.country && <option value="">{t("forge.phone_input.country_placeholder")}</option>}
78
+ {f.countries.map((c) => (
79
+ <option key={c.country} value={c.country}>
80
+ {c.flag} {c.country} {c.callingCode}
81
+ </option>
82
+ ))}
83
+ </select>
84
+
85
+ <input
86
+ id={fieldId}
87
+ type="tel"
88
+ inputMode="tel"
89
+ autoComplete="tel-national"
90
+ disabled={disabled}
91
+ // Until the metadata lands there is no national example to show, so
92
+ // fall back to the generic hint rather than an empty field.
93
+ placeholder={f.placeholder || t("forge.phone_input.placeholder_fallback")}
94
+ value={f.value}
95
+ onChange={(e) => f.setValue(e.target.value)}
96
+ onBlur={f.markTouched}
97
+ aria-invalid={!!f.error}
98
+ aria-describedby={f.error ? errorId : undefined}
99
+ style={{ ...shared, flex: "1 1 auto", minWidth: 0 }}
100
+ />
101
+ </div>
102
+
103
+ {errorText && (
104
+ <p id={errorId} role="alert" style={{ color: "#ef4444", fontSize: 13 }}>
105
+ {errorText}
106
+ </p>
107
+ )}
108
+
109
+ {/* A landline can be delivered to by a courier but cannot receive a text.
110
+ Worth saying, never worth blocking on: the line type is a guess from a
111
+ numbering plan, and refusing a real number on a guess is worse than
112
+ spending one credit to find out. */}
113
+ {!errorText && f.warning === "landline" && (
114
+ <p style={{ color: theme.colors.text, opacity: 0.7, fontSize: 13 }}>
115
+ {t("forge.phone_input.warning_landline")}
116
+ </p>
117
+ )}
118
+
119
+ {/* Only once they have typed a country code anyway. */}
120
+ {!errorText && f.value.trim().startsWith("+") && (
121
+ <p style={{ color: theme.colors.text, opacity: 0.7, fontSize: 13 }}>
122
+ {t("forge.phone_input.hint_no_country_code")}
123
+ </p>
124
+ )}
125
+ </div>
126
+ );
127
+ }
@@ -1,6 +1,6 @@
1
1
  import { Lock } from "lucide-react";
2
2
  import { useGetProducts, useGetProductsByIds } from "../../data/queries/useProducts";
3
- import type { IPublicProduct, ProductType } from "../../types/models";
3
+ import type { IPublicProduct, PaginatedData, ProductType } from "../../types/models";
4
4
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
5
5
  import { useAmountFormatter } from "../format/useFormatCurrency";
6
6
  import { PriceDisplay } from "../format/PriceDisplay";
@@ -9,6 +9,15 @@ import { Loading } from "./Loading";
9
9
 
10
10
  export interface ProductGridProps {
11
11
  columns?: number;
12
+ /**
13
+ * The first page of the shop listing, from a server render
14
+ * (`fetchProductsServer`).
15
+ *
16
+ * Seeds the query so the grid arrives with products in it instead of
17
+ * rendering a spinner into the document. Only applies to the plain listing:
18
+ * a grid showing `productIds` is a different query and ignores it.
19
+ */
20
+ initialProducts?: PaginatedData<IPublicProduct>;
12
21
  limit?: number;
13
22
  /**
14
23
  * Show these specific products, in this order, instead of the shop listing.
@@ -49,6 +58,7 @@ function minPrice(p: IPublicProduct): number {
49
58
  /** Themed product grid, built on `useGetProducts`. */
50
59
  export function ProductGrid({
51
60
  columns = 3,
61
+ initialProducts,
52
62
  limit,
53
63
  productIds,
54
64
  categoryId,
@@ -67,7 +77,9 @@ export function ProductGrid({
67
77
  const byIds = !!productIds?.length;
68
78
  // Both hooks are called unconditionally (rules of hooks); each disables
69
79
  // itself when it isn't the one in use.
70
- const listQuery = useGetProducts({ page: 1, categoryId, collectionId, productType }, !byIds);
80
+ const listQuery = useGetProducts({ page: 1, categoryId, collectionId, productType }, !byIds, {
81
+ initialData: initialProducts,
82
+ });
71
83
  const idsQuery = useGetProductsByIds(byIds ? productIds! : []);
72
84
 
73
85
  const isLoading = byIds ? idsQuery.isLoading : listQuery.isLoading;
@@ -15,11 +15,13 @@ export interface SignupFormProps {
15
15
  * create-account request, where a coupon can turn it into a membership.
16
16
  */
17
17
  membershipTierId?: string;
18
+ /** The membership invite token riding on the same redirect, if any. See `MembershipTierCallout`. */
19
+ membershipInviteToken?: string;
18
20
  couponCode?: string;
19
21
  }
20
22
 
21
23
  /** Themed account signup form, built on `useSignupForm`. */
22
- export function SignupForm({ onSuccess, loginHref, membershipTierId, couponCode }: SignupFormProps) {
24
+ export function SignupForm({ onSuccess, loginHref, membershipTierId, membershipInviteToken, couponCode }: SignupFormProps) {
23
25
  const t = useForgeT();
24
26
  const theme = useForgeTheme();
25
27
  const form = useSignupForm({ onSuccess, membershipTierId, couponCode });
@@ -73,7 +75,7 @@ export function SignupForm({ onSuccess, loginHref, membershipTierId, couponCode
73
75
  <div style={card}>
74
76
  {/* What the fan was doing when they hit this wall, kept in view through
75
77
  the emailed-code step as well. Nothing is drawn without a tier. */}
76
- <MembershipTierCallout membershipTierId={membershipTierId} />
78
+ <MembershipTierCallout membershipTierId={membershipTierId} inviteToken={membershipInviteToken} />
77
79
  <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>
78
80
  {form.awaitingCode ? t("forge.signup_form.verify_title") : t("forge.signup_form.title")}
79
81
  </h1>
@@ -192,3 +192,39 @@ describe("AccountDashboard: the way into a coaching video call", () => {
192
192
  expect(html).not.toContain("the room opens");
193
193
  });
194
194
  });
195
+
196
+ describe("AccountDashboard: the reschedule rule a session was sold under", () => {
197
+ it("shows a reschedule rule of the booking's own", () => {
198
+ const html = render([
199
+ booking({
200
+ location: null,
201
+ reschedulePolicy: {
202
+ policy: "until",
203
+ cutoffHours: 12,
204
+ followsCancellation: false,
205
+ description: "You can reschedule up to 12 hours before this starts.",
206
+ },
207
+ }),
208
+ ]);
209
+ expect(html).toContain("You can reschedule up to 12 hours before this starts.");
210
+ });
211
+
212
+ it("repeats nothing when moves follow the cancellation window", () => {
213
+ const html = render([
214
+ booking({
215
+ location: null,
216
+ reschedulePolicy: {
217
+ policy: "anytime",
218
+ cutoffHours: null,
219
+ followsCancellation: true,
220
+ description: "You can reschedule any time before this starts.",
221
+ },
222
+ }),
223
+ ]);
224
+ expect(html).not.toContain("You can reschedule");
225
+ });
226
+
227
+ it("renders a row from an older API that sends no reschedule rule", () => {
228
+ expect(() => render([booking({ location: null })])).not.toThrow();
229
+ });
230
+ });
@@ -0,0 +1,105 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, afterEach } from "vitest";
3
+ import { render, screen, fireEvent, cleanup } from "@testing-library/react";
4
+ import axios, { type AxiosAdapter, type InternalAxiosRequestConfig } from "axios";
5
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
6
+ import { ForgeClientProvider } from "../../../provider/ForgeProvider";
7
+ import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
8
+ import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
9
+ import { AccountDashboard } from "../AccountDashboard";
10
+
11
+ /**
12
+ * The account page during a free trial: the date the trial ends and what is
13
+ * charged after it replace "Renews on", and cancelling says access lasts to the
14
+ * trial end with nothing charged.
15
+ */
16
+
17
+ const DAY = 24 * 60 * 60 * 1000;
18
+ const inDays = (n: number) => new Date(Date.now() + n * DAY).toISOString();
19
+ const fmt = (iso: string) =>
20
+ new Date(iso).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
21
+
22
+ const memberWith = (membership: Record<string, unknown>) => ({
23
+ id: "account-1",
24
+ email: "fan@example.com",
25
+ firstName: "Fan",
26
+ lastName: "Person",
27
+ kind: "fan",
28
+ status: "active",
29
+ createdAt: "2026-01-01T00:00:00.000Z",
30
+ updatedAt: "2026-01-01T00:00:00.000Z",
31
+ membership: {
32
+ id: "membership-1",
33
+ membershipTierId: "tier-1",
34
+ status: "active",
35
+ startDate: "2026-09-17T00:00:00.000Z",
36
+ subscriptionAmount: 10,
37
+ subscriptionCurrency: "USD",
38
+ billingCycle: "month",
39
+ membershipTier: { id: "tier-1", name: "Inner Circle", benefits: [] },
40
+ ...membership,
41
+ },
42
+ });
43
+
44
+ let realAdapter: AxiosAdapter | undefined;
45
+
46
+ function mount(user: unknown) {
47
+ // Every request answers empty, so the tab's side queries never reach a network.
48
+ realAdapter = axios.defaults.adapter as AxiosAdapter | undefined;
49
+ axios.defaults.adapter = (async (config: InternalAxiosRequestConfig) =>
50
+ ({ data: [], status: 200, statusText: "OK", headers: {}, config }) as never) as AxiosAdapter;
51
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
52
+ return render(
53
+ <QueryClientProvider client={queryClient}>
54
+ <ForgeClientProvider apiUrl="http://api.test" profileId="profile-1">
55
+ <PublicAuthContext.Provider
56
+ value={{ user, currencies: null, userSelectedCurrency: "USD", logout: () => {} } as never}
57
+ >
58
+ <ForgeThemeProvider theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}>
59
+ <AccountDashboard tab="membership" onNavigateMembership={() => {}} />
60
+ </ForgeThemeProvider>
61
+ </PublicAuthContext.Provider>
62
+ </ForgeClientProvider>
63
+ </QueryClientProvider>,
64
+ );
65
+ }
66
+
67
+ afterEach(() => {
68
+ cleanup();
69
+ if (realAdapter) axios.defaults.adapter = realAdapter;
70
+ });
71
+
72
+ describe("AccountDashboard: a membership in its free trial", () => {
73
+ it("shows the trial end and what is charged after it, instead of Renews on", () => {
74
+ const ends = inDays(10);
75
+ mount(memberWith({ trialEndsAt: ends, endDate: ends }));
76
+
77
+ const state = screen.getByTestId("membership-trial-state");
78
+ expect(state.textContent).toContain(`Free trial until ${fmt(ends)}`);
79
+ expect(state.textContent).toMatch(new RegExp(`Then .*10.* per month from ${fmt(ends)}\\.`));
80
+ expect(document.body.textContent).not.toContain("Renews on");
81
+ });
82
+
83
+ it("says access lasts to the trial end with nothing charged when cancelling", () => {
84
+ const ends = inDays(10);
85
+ mount(memberWith({ trialEndsAt: ends, endDate: ends }));
86
+
87
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
88
+ const dialog = screen.getByRole("alertdialog");
89
+ expect(dialog.textContent).toContain(
90
+ `Your free trial will not turn into a paid membership. You keep your benefits until ${fmt(ends)}, and nothing is charged.`,
91
+ );
92
+ });
93
+
94
+ it("says nothing will be charged once a trial is cancelled", () => {
95
+ const ends = inDays(10);
96
+ mount(memberWith({ trialEndsAt: ends, endDate: ends, cancelAtPeriodEnd: true }));
97
+ expect(screen.getByTestId("membership-trial-state").textContent).toContain("Cancelled. Nothing will be charged.");
98
+ });
99
+
100
+ it("shows no trial line once the trial has ended", () => {
101
+ mount(memberWith({ trialEndsAt: inDays(-5), endDate: inDays(25) }));
102
+ expect(screen.queryByTestId("membership-trial-state")).toBeNull();
103
+ expect(document.body.textContent).toContain("Renews on");
104
+ });
105
+ });