@tribe-nest/forge 3.26.0 → 3.31.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 (129) hide show
  1. package/package.json +6 -3
  2. package/src/_tests/publishedResolvability.spec.ts +184 -0
  3. package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
  4. package/src/_tests/workspaceAliases.ts +40 -0
  5. package/src/client/_tests/localeHeader.spec.tsx +98 -0
  6. package/src/client/activeLocale.ts +34 -0
  7. package/src/client/createForgeClient.ts +13 -0
  8. package/src/contexts/CartContext.tsx +76 -0
  9. package/src/contexts/PublicAuthContext.tsx +34 -5
  10. package/src/contexts/_tests/CartContext.spec.tsx +210 -0
  11. package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
  12. package/src/data/queries/useBroadcasts.ts +151 -0
  13. package/src/data/queries/useCheckouts.ts +83 -0
  14. package/src/data/queries/useMyBookings.ts +9 -1
  15. package/src/data/queries/useWebsite.ts +7 -0
  16. package/src/i18n/CONVENTION.md +184 -0
  17. package/src/i18n/_tests/translationKeys.spec.ts +153 -0
  18. package/src/i18n/de.json +1019 -0
  19. package/src/i18n/en.json +1019 -0
  20. package/src/i18n/index.ts +125 -0
  21. package/src/index.ts +21 -0
  22. package/src/provider/ForgeAppProvider.tsx +2 -1
  23. package/src/provider/SiteConfigProvider.tsx +11 -0
  24. package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
  25. package/src/ui/format/_tests/pwyw.spec.ts +65 -8
  26. package/src/ui/format/membershipPwyw.ts +164 -0
  27. package/src/ui/format/pwyw.ts +37 -0
  28. package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
  29. package/src/ui/headless/broadcast/broadcastState.ts +158 -0
  30. package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
  31. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
  32. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
  33. package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
  34. package/src/ui/headless/checkout/useCartResume.ts +340 -0
  35. package/src/ui/headless/checkout/useCheckout.ts +9 -2
  36. package/src/ui/headless/event/useEventCheckout.ts +8 -13
  37. package/src/ui/headless/index.ts +32 -0
  38. package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
  39. package/src/ui/index.ts +38 -0
  40. package/src/ui/media/CallHelpHint.tsx +87 -0
  41. package/src/ui/media/CallStage.tsx +542 -0
  42. package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
  43. package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
  44. package/src/ui/media/_tests/callState.spec.ts +452 -0
  45. package/src/ui/media/_tests/fakeNode.ts +178 -0
  46. package/src/ui/media/bookingSession.tsx +194 -0
  47. package/src/ui/media/callState.ts +341 -0
  48. package/src/ui/media/index.ts +135 -0
  49. package/src/ui/shell/TribeNestApp.tsx +36 -27
  50. package/src/ui/styled/AccountDashboard.tsx +289 -96
  51. package/src/ui/styled/AiAgentWidget.tsx +10 -6
  52. package/src/ui/styled/AudioPlayer.tsx +14 -6
  53. package/src/ui/styled/BlogCategory.tsx +4 -2
  54. package/src/ui/styled/BlogComments.tsx +27 -20
  55. package/src/ui/styled/BlogList.tsx +5 -3
  56. package/src/ui/styled/BlogPost.tsx +14 -11
  57. package/src/ui/styled/BroadcastWatch.tsx +107 -0
  58. package/src/ui/styled/BundleConfirmation.tsx +32 -23
  59. package/src/ui/styled/CancellationTerms.tsx +3 -1
  60. package/src/ui/styled/Cart.tsx +32 -14
  61. package/src/ui/styled/ChatRoom.tsx +65 -20
  62. package/src/ui/styled/Checkout.tsx +151 -89
  63. package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
  64. package/src/ui/styled/CoachingBooking.tsx +71 -44
  65. package/src/ui/styled/CoachingConfirmation.tsx +56 -19
  66. package/src/ui/styled/CoachingDetail.tsx +15 -11
  67. package/src/ui/styled/CohortPage.tsx +10 -8
  68. package/src/ui/styled/ConfirmSubscription.tsx +14 -8
  69. package/src/ui/styled/ContactForm.tsx +31 -12
  70. package/src/ui/styled/CookieConsent.tsx +19 -15
  71. package/src/ui/styled/CourseAccess.tsx +54 -47
  72. package/src/ui/styled/CourseCheckout.tsx +56 -38
  73. package/src/ui/styled/CourseConfirmation.tsx +39 -17
  74. package/src/ui/styled/CourseDetail.tsx +21 -15
  75. package/src/ui/styled/CoursesGrid.tsx +4 -2
  76. package/src/ui/styled/CurrencySwitcher.tsx +4 -1
  77. package/src/ui/styled/DiscountCode.tsx +22 -18
  78. package/src/ui/styled/DonationButton.tsx +52 -17
  79. package/src/ui/styled/DonationPage.tsx +4 -4
  80. package/src/ui/styled/EmailListForm.tsx +22 -9
  81. package/src/ui/styled/EventConfirmation.tsx +37 -14
  82. package/src/ui/styled/EventCountdown.tsx +10 -6
  83. package/src/ui/styled/EventDetail.tsx +8 -6
  84. package/src/ui/styled/EventSeriesDetail.tsx +18 -13
  85. package/src/ui/styled/EventTickets.tsx +232 -108
  86. package/src/ui/styled/EventWaitlist.tsx +42 -33
  87. package/src/ui/styled/EventsList.tsx +15 -13
  88. package/src/ui/styled/ForgotPasswordForm.tsx +17 -12
  89. package/src/ui/styled/FormRenderer.tsx +12 -7
  90. package/src/ui/styled/InstallBanner.tsx +21 -18
  91. package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
  92. package/src/ui/styled/InvoicePayment.tsx +37 -16
  93. package/src/ui/styled/LeadMagnet.tsx +9 -7
  94. package/src/ui/styled/LiveBroadcastList.tsx +171 -0
  95. package/src/ui/styled/Loading.tsx +7 -1
  96. package/src/ui/styled/LoginForm.tsx +32 -14
  97. package/src/ui/styled/MembershipCheckout.tsx +341 -49
  98. package/src/ui/styled/MembershipTiers.tsx +34 -18
  99. package/src/ui/styled/OfferButton.tsx +33 -13
  100. package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
  101. package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
  102. package/src/ui/styled/PodcastEpisode.tsx +20 -9
  103. package/src/ui/styled/PodcastList.tsx +9 -3
  104. package/src/ui/styled/PodcastShow.tsx +29 -7
  105. package/src/ui/styled/PostsFeed.tsx +10 -8
  106. package/src/ui/styled/PresaleCode.tsx +8 -11
  107. package/src/ui/styled/ProductBrowseNav.tsx +21 -11
  108. package/src/ui/styled/ProductDetail.tsx +58 -28
  109. package/src/ui/styled/ReactionBar.tsx +4 -2
  110. package/src/ui/styled/ReplayList.tsx +24 -13
  111. package/src/ui/styled/ResetPasswordForm.tsx +32 -15
  112. package/src/ui/styled/ResumeCart.tsx +289 -0
  113. package/src/ui/styled/ReviewForm.tsx +52 -58
  114. package/src/ui/styled/SignupForm.tsx +34 -16
  115. package/src/ui/styled/TicketTransfer.tsx +55 -54
  116. package/src/ui/styled/UserMenu.tsx +16 -14
  117. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
  118. package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
  119. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
  120. package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
  121. package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
  122. package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
  123. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
  124. package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
  125. package/src/ui/styled/community/CommunityComposer.tsx +182 -3
  126. package/src/ui/styled/community/CommunityFeed.tsx +36 -51
  127. package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
  128. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
  129. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
@@ -1,4 +1,5 @@
1
1
  import { useSignupForm } from "../headless/auth/useSignupForm";
2
+ import { useForgeT } from "../../i18n";
2
3
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
3
4
 
4
5
  export interface SignupFormProps {
@@ -12,10 +13,21 @@ export interface SignupFormProps {
12
13
 
13
14
  /** Themed account signup form, built on `useSignupForm`. */
14
15
  export function SignupForm({ onSuccess, loginHref, membershipTierId, couponCode }: SignupFormProps) {
16
+ const t = useForgeT();
15
17
  const theme = useForgeTheme();
16
18
  const form = useSignupForm({ onSuccess, membershipTierId, couponCode });
17
19
 
20
+ // One key for the whole sentence, split on its own `{email}` slot so the
21
+ // address keeps its <strong> without the sentence being assembled from two
22
+ // separately translated fragments.
23
+ const codeSentParts = t("forge.signup_form.code_sent").split("{email}");
24
+
18
25
  const card: React.CSSProperties = {
26
+ // See `LoginForm`. The host layout is a COLUMN flex container, and an auto
27
+ // cross-axis margin disables `align-items: stretch`, so `maxWidth` alone
28
+ // leaves the card shrink-to-fit. The cap is unchanged: this fills the
29
+ // container UP TO 420 and no further.
30
+ width: "100%",
19
31
  maxWidth: 420,
20
32
  margin: "40px auto 0",
21
33
  padding: 24,
@@ -53,7 +65,7 @@ export function SignupForm({ onSuccess, loginHref, membershipTierId, couponCode
53
65
  return (
54
66
  <div style={card}>
55
67
  <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>
56
- {form.awaitingCode ? "Check your email" : "Create your account"}
68
+ {form.awaitingCode ? t("forge.signup_form.verify_title") : t("forge.signup_form.title")}
57
69
  </h1>
58
70
  {form.error && <p style={{ color: "#ef4444", marginBottom: 16 }}>{form.error}</p>}
59
71
  <form
@@ -68,10 +80,12 @@ export function SignupForm({ onSuccess, loginHref, membershipTierId, couponCode
68
80
  {form.awaitingCode ? (
69
81
  <>
70
82
  <p style={{ fontSize: 14, margin: 0 }}>
71
- We emailed a 6-digit code to <strong>{form.email}</strong>. Enter it to finish creating your account.
83
+ {codeSentParts[0]}
84
+ <strong>{form.email}</strong>
85
+ {codeSentParts[1]}
72
86
  </p>
73
87
  {field(
74
- "Verification code",
88
+ t("forge.signup_form.code_label"),
75
89
  <input
76
90
  inputMode="numeric"
77
91
  autoComplete="one-time-code"
@@ -86,42 +100,42 @@ export function SignupForm({ onSuccess, loginHref, membershipTierId, couponCode
86
100
  <>
87
101
  <div style={{ display: "flex", gap: 12 }}>
88
102
  {field(
89
- "First name",
103
+ t("forge.signup_form.first_name_label"),
90
104
  <input
91
105
  value={form.firstName}
92
106
  onChange={(e) => form.setFirstName(e.target.value)}
93
107
  style={input}
94
- placeholder="First name"
108
+ placeholder={t("forge.signup_form.first_name_placeholder")}
95
109
  />,
96
110
  )}
97
111
  {field(
98
- "Last name",
112
+ t("forge.signup_form.last_name_label"),
99
113
  <input
100
114
  value={form.lastName}
101
115
  onChange={(e) => form.setLastName(e.target.value)}
102
116
  style={input}
103
- placeholder="Last name"
117
+ placeholder={t("forge.signup_form.last_name_placeholder")}
104
118
  />,
105
119
  )}
106
120
  </div>
107
121
  {field(
108
- "Email",
122
+ t("forge.signup_form.email_label"),
109
123
  <input
110
124
  type="email"
111
125
  value={form.email}
112
126
  onChange={(e) => form.setEmail(e.target.value)}
113
127
  style={input}
114
- placeholder="Email"
128
+ placeholder={t("forge.signup_form.email_placeholder")}
115
129
  />,
116
130
  )}
117
131
  {field(
118
- "Password",
132
+ t("forge.signup_form.password_label"),
119
133
  <input
120
134
  type="password"
121
135
  value={form.password}
122
136
  onChange={(e) => form.setPassword(e.target.value)}
123
137
  style={input}
124
- placeholder="At least 8 characters"
138
+ placeholder={t("forge.signup_form.password_placeholder")}
125
139
  />,
126
140
  )}
127
141
  <label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13 }}>
@@ -131,12 +145,16 @@ export function SignupForm({ onSuccess, loginHref, membershipTierId, couponCode
131
145
  onChange={(e) => form.setAcceptedTerms(e.target.checked)}
132
146
  style={{ accentColor: theme.colors.primary }}
133
147
  />
134
- I accept the Terms and Privacy Policy
148
+ {t("forge.signup_form.accept_terms")}
135
149
  </label>
136
150
  </>
137
151
  )}
138
152
  <button type="submit" disabled={form.isSubmitting} style={button}>
139
- {form.isSubmitting ? "Working…" : form.awaitingCode ? "Verify and continue" : "Sign up"}
153
+ {form.isSubmitting
154
+ ? t("forge.signup_form.submitting")
155
+ : form.awaitingCode
156
+ ? t("forge.signup_form.verify_submit")
157
+ : t("forge.signup_form.submit")}
140
158
  </button>
141
159
  {form.awaitingCode && (
142
160
  <button
@@ -145,14 +163,14 @@ export function SignupForm({ onSuccess, loginHref, membershipTierId, couponCode
145
163
  disabled={form.isSubmitting}
146
164
  style={{ background: "none", border: "none", color: theme.colors.primary, cursor: "pointer", fontSize: 13 }}
147
165
  >
148
- Resend code
166
+ {t("forge.signup_form.resend_code")}
149
167
  </button>
150
168
  )}
151
169
  </form>
152
170
  <p style={{ marginTop: 16, fontSize: 14, textAlign: "center" }}>
153
- Already have an account?{" "}
171
+ {t("forge.signup_form.login_prompt")}{" "}
154
172
  <a href={loginHref} style={{ color: theme.colors.primary, textDecoration: "underline" }}>
155
- Login
173
+ {t("forge.signup_form.login_link")}
156
174
  </a>
157
175
  </p>
158
176
  </div>
@@ -9,6 +9,7 @@ import {
9
9
  } from "../../data/queries/usePassTransfers";
10
10
  import type { MyTicketPass, MyTicketPendingTransfer } from "../../data/queries/useMyTickets";
11
11
  import { useThemeTokens, type ResolvedThemeTokens } from "../theme/ForgeThemeProvider";
12
+ import { useForgeT } from "../../i18n";
12
13
 
13
14
  /**
14
15
  * Events 2.2 — handing a ticket to somebody else, and taking one.
@@ -131,7 +132,7 @@ export interface TicketTransferPanelProps {
131
132
  * bridge across the refetch that follows a send.
132
133
  */
133
134
  export function TicketTransferPanel({ passIds, passes, className, style }: TicketTransferPanelProps) {
134
- const t = useThemeTokens();
135
+ const tokens = useThemeTokens();
135
136
  const [openFor, setOpenFor] = useState<string | null>(null);
136
137
 
137
138
  if (passIds.length === 0) return null;
@@ -150,7 +151,7 @@ export function TicketTransferPanel({ passIds, passes, className, style }: Ticke
150
151
  key={passId}
151
152
  passId={passId}
152
153
  serverTransfer={byId.get(passId)?.pendingTransfer ?? null}
153
- t={t}
154
+ tokens={tokens}
154
155
  showPassId={!single}
155
156
  isOpen={openFor === passId}
156
157
  onToggle={() => setOpenFor((current) => (current === passId ? null : passId))}
@@ -163,18 +164,19 @@ export function TicketTransferPanel({ passIds, passes, className, style }: Ticke
163
164
  function TicketTransferRow({
164
165
  passId,
165
166
  serverTransfer,
166
- t,
167
+ tokens,
167
168
  showPassId,
168
169
  isOpen,
169
170
  onToggle,
170
171
  }: {
171
172
  passId: string;
172
173
  serverTransfer: MyTicketPendingTransfer | null;
173
- t: ResolvedThemeTokens;
174
+ tokens: ResolvedThemeTokens;
174
175
  showPassId: boolean;
175
176
  isOpen: boolean;
176
177
  onToggle: () => void;
177
178
  }) {
179
+ const t = useForgeT();
178
180
  const send = useSendPassTransfer();
179
181
  const cancel = useCancelPassTransfer();
180
182
  const remembered = usePendingPassTransfers(passId);
@@ -199,7 +201,7 @@ function TicketTransferRow({
199
201
  // The server distinguishes "already has a live transfer", "you have
200
202
  // checked in", "the show was cancelled" and several more. Its wording,
201
203
  // not ours.
202
- setError(serverMessage(err, "We could not send this ticket. Please try again."));
204
+ setError(serverMessage(err, t("forge.ticket_transfer.send_error")));
203
205
  }
204
206
  };
205
207
 
@@ -209,27 +211,27 @@ function TicketTransferRow({
209
211
  try {
210
212
  await cancel.mutateAsync({ transferId });
211
213
  } catch (err) {
212
- setError(serverMessage(err, "We could not withdraw this transfer. Please try again."));
214
+ setError(serverMessage(err, t("forge.ticket_transfer.withdraw_error")));
213
215
  }
214
216
  };
215
217
 
216
218
  return (
217
219
  <div
218
220
  style={{
219
- border: `1px solid ${t.primary}20`,
220
- borderRadius: t.cornerRadius,
221
+ border: `1px solid ${tokens.primary}20`,
222
+ borderRadius: tokens.cornerRadius,
221
223
  padding: 12,
222
- color: t.text,
223
- fontFamily: t.fontFamily,
224
+ color: tokens.text,
225
+ fontFamily: tokens.fontFamily,
224
226
  }}
225
227
  >
226
228
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
227
229
  <div>
228
- <div style={{ fontWeight: 600, fontSize: 14 }}>Transfer this ticket</div>
229
- {showPassId && <div style={{ fontSize: 12, color: t.muted }}>{passId}</div>}
230
+ <div style={{ fontWeight: 600, fontSize: 14 }}>{t("forge.ticket_transfer.row_title")}</div>
231
+ {showPassId && <div style={{ fontSize: 12, color: tokens.muted }}>{passId}</div>}
230
232
  </div>
231
- <button type="button" onClick={onToggle} style={ghostButton(t, false)} aria-expanded={isOpen}>
232
- {isOpen ? "Close" : "Send to someone"}
233
+ <button type="button" onClick={onToggle} style={ghostButton(tokens, false)} aria-expanded={isOpen}>
234
+ {isOpen ? t("forge.ticket_transfer.close") : t("forge.ticket_transfer.open_send")}
233
235
  </button>
234
236
  </div>
235
237
 
@@ -240,8 +242,8 @@ function TicketTransferRow({
240
242
  style={{
241
243
  marginTop: 12,
242
244
  padding: 12,
243
- borderRadius: t.cornerRadius,
244
- border: `1px solid ${t.primary}4d`,
245
+ borderRadius: tokens.cornerRadius,
246
+ border: `1px solid ${tokens.primary}4d`,
245
247
  fontSize: 14,
246
248
  }}
247
249
  >
@@ -251,21 +253,21 @@ function TicketTransferRow({
251
253
  ? `. The link works until ${formatWhen(pending.expiresAt)}.`
252
254
  : "."}
253
255
  </div>
254
- <div style={{ fontSize: 13, color: t.muted, marginTop: 4 }}>
256
+ <div style={{ fontSize: 13, color: tokens.muted, marginTop: 4 }}>
255
257
  {pending.open
256
- ? "Your QR code keeps working until they claim it."
257
- : /* Lapsed but not yet swept. Still `pending`, still withdrawable
258
+ ? t("forge.ticket_transfer.pending_open")
259
+ : /* Lapsed but not yet swept. Still `pending`, still withdrawable -
258
260
  hiding it would leave the sender with a live row and no way to
259
261
  address it. */
260
- "That link has expired. Withdraw it to send this ticket to someone else."}
262
+ t("forge.ticket_transfer.pending_expired")}
261
263
  </div>
262
264
  <button
263
265
  type="button"
264
266
  onClick={() => void onCancel(pending.transferId)}
265
267
  disabled={cancel.isPending}
266
- style={{ ...ghostButton(t, cancel.isPending), marginTop: 10 }}
268
+ style={{ ...ghostButton(tokens, cancel.isPending), marginTop: 10 }}
267
269
  >
268
- {cancel.isPending ? "Withdrawing…" : "Withdraw transfer"}
270
+ {cancel.isPending ? t("forge.ticket_transfer.withdrawing") : t("forge.ticket_transfer.withdraw")}
269
271
  </button>
270
272
  </div>
271
273
  )}
@@ -277,7 +279,7 @@ function TicketTransferRow({
277
279
  )}
278
280
 
279
281
  {error && (
280
- <p style={{ fontSize: 13, marginTop: 10, color: t.primary }} role="alert">
282
+ <p style={{ fontSize: 13, marginTop: 10, color: tokens.primary }} role="alert">
281
283
  {error}
282
284
  </p>
283
285
  )}
@@ -289,24 +291,22 @@ function TicketTransferRow({
289
291
  required
290
292
  value={toEmail}
291
293
  onChange={(event) => setToEmail(event.target.value)}
292
- placeholder="Their email address"
293
- aria-label="Recipient email address"
294
- style={inputStyle(t)}
294
+ placeholder={t("forge.ticket_transfer.recipient_email_placeholder")}
295
+ aria-label={t("forge.ticket_transfer.recipient_email_label")}
296
+ style={inputStyle(tokens)}
295
297
  />
296
298
  <input
297
299
  type="text"
298
300
  maxLength={240}
299
301
  value={toName}
300
302
  onChange={(event) => setToName(event.target.value)}
301
- placeholder="Their name (optional)"
302
- aria-label="Recipient name"
303
- style={inputStyle(t)}
303
+ placeholder={t("forge.ticket_transfer.recipient_name_placeholder")}
304
+ aria-label={t("forge.ticket_transfer.recipient_name_label")}
305
+ style={inputStyle(tokens)}
304
306
  />
305
- <p style={{ fontSize: 12, color: t.muted, margin: 0 }}>
306
- They get a link to claim it. Once they do, this ticket becomes theirs and your QR code stops working.
307
- </p>
308
- <button type="submit" disabled={send.isPending} style={primaryButton(t, send.isPending)}>
309
- {send.isPending ? "Sending…" : "Send ticket"}
307
+ <p style={{ fontSize: 12, color: tokens.muted, margin: 0 }}>{t("forge.ticket_transfer.send_note")}</p>
308
+ <button type="submit" disabled={send.isPending} style={primaryButton(tokens, send.isPending)}>
309
+ {send.isPending ? t("forge.ticket_transfer.sending") : t("forge.ticket_transfer.send")}
310
310
  </button>
311
311
  </form>
312
312
  )}
@@ -339,7 +339,8 @@ export interface ClaimTicketTransferProps {
339
339
  * was cancelled since — comes back from the server with its own wording.
340
340
  */
341
341
  export function ClaimTicketTransfer({ token, onClaimed, className, style }: ClaimTicketTransferProps) {
342
- const t = useThemeTokens();
342
+ const tokens = useThemeTokens();
343
+ const t = useForgeT();
343
344
  const claim = useClaimPassTransfer();
344
345
  const [name, setName] = useState("");
345
346
  const [error, setError] = useState<string | null>(null);
@@ -348,18 +349,18 @@ export function ClaimTicketTransfer({ token, onClaimed, className, style }: Clai
348
349
  const card: CSSProperties = {
349
350
  maxWidth: 460,
350
351
  margin: "0 auto",
351
- color: t.text,
352
- fontFamily: t.fontFamily,
352
+ color: tokens.text,
353
+ fontFamily: tokens.fontFamily,
353
354
  ...style,
354
355
  };
355
356
 
356
357
  if (!token) {
357
358
  return (
358
359
  <div className={className} style={{ ...card, textAlign: "center" }}>
359
- <h1 style={{ fontSize: 24, fontWeight: 700, fontFamily: t.headingFontFamily }}>Invalid claim link</h1>
360
- <p style={{ color: t.muted, marginTop: 8 }}>
361
- Your link is missing its code. Please use the link from your email.
362
- </p>
360
+ <h1 style={{ fontSize: 24, fontWeight: 700, fontFamily: tokens.headingFontFamily }}>
361
+ {t("forge.ticket_transfer.invalid_title")}
362
+ </h1>
363
+ <p style={{ color: tokens.muted, marginTop: 8 }}>{t("forge.ticket_transfer.invalid_body")}</p>
363
364
  </div>
364
365
  );
365
366
  }
@@ -367,7 +368,9 @@ export function ClaimTicketTransfer({ token, onClaimed, className, style }: Clai
367
368
  if (claimed) {
368
369
  return (
369
370
  <div className={className} style={card}>
370
- <h1 style={{ fontSize: 24, fontWeight: 700, fontFamily: t.headingFontFamily }}>The ticket is yours</h1>
371
+ <h1 style={{ fontSize: 24, fontWeight: 700, fontFamily: tokens.headingFontFamily }}>
372
+ {t("forge.ticket_transfer.claimed_title")}
373
+ </h1>
371
374
  <p style={{ marginTop: 8 }}>
372
375
  Ticket <strong>{claimed.eventPassId}</strong> is now in your name. We have emailed it to{" "}
373
376
  <strong>{claimed.toEmail}</strong>. Bring that QR code to the door.
@@ -386,35 +389,33 @@ export function ClaimTicketTransfer({ token, onClaimed, className, style }: Clai
386
389
  } catch (err) {
387
390
  // "Already claimed" and "expired" are materially different answers to
388
391
  // somebody standing outside a venue. Never collapsed into one sentence.
389
- setError(serverMessage(err, "We could not claim this ticket. Your link may have expired or already been used."));
392
+ setError(serverMessage(err, t("forge.ticket_transfer.claim_error")));
390
393
  }
391
394
  };
392
395
 
393
396
  return (
394
397
  <div className={className} style={card}>
395
- <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 8, fontFamily: t.headingFontFamily }}>
396
- Claim your ticket
398
+ <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 8, fontFamily: tokens.headingFontFamily }}>
399
+ {t("forge.ticket_transfer.claim_title")}
397
400
  </h1>
398
- <p style={{ color: t.muted, marginBottom: 24 }}>
399
- Someone sent you their ticket. Tell us what to put on it. You do not need an account.
400
- </p>
401
+ <p style={{ color: tokens.muted, marginBottom: 24 }}>{t("forge.ticket_transfer.claim_subtitle")}</p>
401
402
  <form onSubmit={onSubmit} style={{ display: "flex", flexDirection: "column", gap: 16 }}>
402
403
  <input
403
404
  type="text"
404
405
  maxLength={240}
405
406
  value={name}
406
407
  onChange={(event) => setName(event.target.value)}
407
- placeholder="Your name (optional)"
408
- aria-label="Your name"
409
- style={inputStyle(t)}
408
+ placeholder={t("forge.ticket_transfer.claim_name_placeholder")}
409
+ aria-label={t("forge.ticket_transfer.claim_name_label")}
410
+ style={inputStyle(tokens)}
410
411
  />
411
412
  {error && (
412
- <p style={{ color: t.primary, fontSize: 14 }} role="alert">
413
+ <p style={{ color: tokens.primary, fontSize: 14 }} role="alert">
413
414
  {error}
414
415
  </p>
415
416
  )}
416
- <button type="submit" disabled={claim.isPending} style={primaryButton(t, claim.isPending)}>
417
- {claim.isPending ? "Claiming…" : "Claim ticket"}
417
+ <button type="submit" disabled={claim.isPending} style={primaryButton(tokens, claim.isPending)}>
418
+ {claim.isPending ? t("forge.ticket_transfer.claiming") : t("forge.ticket_transfer.claim_submit")}
418
419
  </button>
419
420
  </form>
420
421
  </div>
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
2
2
  import { User, LogIn, LogOut } from "lucide-react";
3
3
  import { usePublicAuth } from "../../contexts/PublicAuthContext";
4
4
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
5
+ import { useForgeT } from "../../i18n";
5
6
 
6
7
  export interface UserMenuProps {
7
8
  /** Custom trigger icon (defaults to a user icon). */
@@ -41,7 +42,8 @@ export function UserMenu({
41
42
  className,
42
43
  style,
43
44
  }: UserMenuProps) {
44
- const t = useThemeTokens();
45
+ const tokens = useThemeTokens();
46
+ const t = useForgeT();
45
47
  const { user, logout } = usePublicAuth();
46
48
  const [open, setOpen] = useState(false);
47
49
  const ref = useRef<HTMLDivElement>(null);
@@ -63,7 +65,7 @@ export function UserMenu({
63
65
  }, [open]);
64
66
 
65
67
  const item: React.CSSProperties = {
66
- color: t.primary,
68
+ color: tokens.primary,
67
69
  background: "transparent",
68
70
  border: "none",
69
71
  cursor: "pointer",
@@ -81,13 +83,13 @@ export function UserMenu({
81
83
  <button
82
84
  type="button"
83
85
  onClick={() => setOpen((v) => !v)}
84
- aria-label="Account"
86
+ aria-label={t("forge.user_menu.aria_trigger")}
85
87
  aria-expanded={open}
86
88
  style={{
87
89
  background: "transparent",
88
90
  border: "none",
89
91
  cursor: "pointer",
90
- color: t.text,
92
+ color: tokens.text,
91
93
  display: "inline-flex",
92
94
  alignItems: "center",
93
95
  padding: 4,
@@ -105,14 +107,14 @@ export function UserMenu({
105
107
  ...(align === "left" ? { left: 0 } : { right: 0 }),
106
108
  minWidth: 220,
107
109
  maxWidth: "calc(100vw - 32px)",
108
- background: t.background,
109
- color: t.text,
110
- border: `1px solid ${t.border}`,
111
- borderRadius: t.cornerRadius,
110
+ background: tokens.background,
111
+ color: tokens.text,
112
+ border: `1px solid ${tokens.border}`,
113
+ borderRadius: tokens.cornerRadius,
112
114
  boxShadow: "0 10px 30px -12px rgba(0,0,0,0.4)",
113
115
  padding: 16,
114
116
  zIndex: 60,
115
- fontFamily: t.fontFamily,
117
+ fontFamily: tokens.fontFamily,
116
118
  }}
117
119
  >
118
120
  {user ? (
@@ -126,7 +128,7 @@ export function UserMenu({
126
128
  <div
127
129
  style={{
128
130
  paddingTop: 8,
129
- borderTop: `1px solid ${t.border}`,
131
+ borderTop: `1px solid ${tokens.border}`,
130
132
  display: "flex",
131
133
  flexDirection: "column",
132
134
  gap: 10,
@@ -134,7 +136,7 @@ export function UserMenu({
134
136
  }}
135
137
  >
136
138
  <a href={accountHref} role="menuitem" style={item} onClick={() => setOpen(false)}>
137
- My Account
139
+ {t("forge.user_menu.account")}
138
140
  </a>
139
141
  <button
140
142
  type="button"
@@ -145,17 +147,17 @@ export function UserMenu({
145
147
  void logout();
146
148
  }}
147
149
  >
148
- <LogOut size={14} /> Log out
150
+ <LogOut size={14} /> {t("forge.user_menu.log_out")}
149
151
  </button>
150
152
  </div>
151
153
  </div>
152
154
  ) : (
153
155
  <div style={{ display: "flex", flexDirection: "column", gap: 10, alignItems: "flex-start" }}>
154
156
  <a href={loginHref} role="menuitem" style={item} onClick={() => setOpen(false)}>
155
- <LogIn size={16} /> Log in
157
+ <LogIn size={16} /> {t("forge.user_menu.log_in")}
156
158
  </a>
157
159
  <a href={signupHref} role="menuitem" style={item} onClick={() => setOpen(false)}>
158
- Create account
160
+ {t("forge.user_menu.create_account")}
159
161
  </a>
160
162
  </div>
161
163
  )}
@@ -0,0 +1,134 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
+ import { ForgeClientProvider } from "../../../provider/ForgeProvider";
5
+ import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
6
+ import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
7
+ import { communityKeys, type CommunitySpace } from "../../../data/queries/useCommunity";
8
+ import { AccountDashboard } from "../AccountDashboard";
9
+
10
+ /**
11
+ * The account page's way into the members corner.
12
+ *
13
+ * Before this, a signed-in member landed on the membership card and the only
14
+ * doors on it led back to billing: change the plan, cancel it, fix the card.
15
+ * The community they were paying for was reachable only by typing the URL.
16
+ *
17
+ * The gate is the server's own answer. `/public/community/spaces` returns the
18
+ * open spaces PLUS the ones gated to a tier the caller holds, so the list being
19
+ * empty is the server saying "there is nothing here you can open". Drawing the
20
+ * link anyway would walk a member into a bounce, which is why the empty case is
21
+ * asserted as hard as the happy one.
22
+ *
23
+ * Rendered through `react-dom/server`: no DOM, no effects, so the spaces query
24
+ * is SEEDED into the react-query cache rather than mocked. That keeps the real
25
+ * `useCommunitySpaces` in the path, and a rename of its query key fails here.
26
+ */
27
+
28
+ const PROFILE_ID = "profile-1";
29
+
30
+ const space = (overrides: Partial<CommunitySpace> = {}): CommunitySpace => ({
31
+ id: "space-1",
32
+ profileId: PROFILE_ID,
33
+ name: "Backstage",
34
+ slug: "backstage",
35
+ description: null,
36
+ type: "feed",
37
+ courseId: null,
38
+ order: 0,
39
+ archivedAt: null,
40
+ createdAt: "2026-01-01T00:00:00.000Z",
41
+ tiers: [],
42
+ ...overrides,
43
+ });
44
+
45
+ /** A signed-in member holding a paid tier that renews. */
46
+ const member = {
47
+ id: "account-1",
48
+ email: "fan@example.com",
49
+ firstName: "Fan",
50
+ lastName: "Person",
51
+ kind: "fan",
52
+ status: "active",
53
+ createdAt: "2026-01-01T00:00:00.000Z",
54
+ updatedAt: "2026-01-01T00:00:00.000Z",
55
+ membership: {
56
+ id: "membership-1",
57
+ membershipTierId: "tier-1",
58
+ endDate: "2026-12-01T00:00:00.000Z",
59
+ status: "active",
60
+ startDate: "2026-01-01T00:00:00.000Z",
61
+ subscriptionAmount: 10,
62
+ subscriptionCurrency: "USD",
63
+ billingCycle: "month",
64
+ membershipTier: { id: "tier-1", name: "Inner Circle", benefits: [] },
65
+ },
66
+ };
67
+
68
+ /**
69
+ * The membership tab as a Forge code website draws it.
70
+ *
71
+ * `spaces === null` stands for the query never having answered (the member has
72
+ * no accessible spaces cached), which is the state a non-member sits in.
73
+ */
74
+ function render(opts: { spaces: CommunitySpace[] | null; onNavigateCommunity?: () => void }): string {
75
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
76
+ if (opts.spaces) queryClient.setQueryData(communityKeys.spaces(PROFILE_ID), opts.spaces);
77
+
78
+ return renderToStaticMarkup(
79
+ <QueryClientProvider client={queryClient}>
80
+ <ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
81
+ <PublicAuthContext.Provider
82
+ value={{ user: member, currencies: null, userSelectedCurrency: "USD", logout: () => {} } as never}
83
+ >
84
+ <ForgeThemeProvider
85
+ theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
86
+ >
87
+ <AccountDashboard
88
+ tab="membership"
89
+ onNavigateMembership={() => {}}
90
+ onNavigateCommunity={opts.onNavigateCommunity}
91
+ />
92
+ </ForgeThemeProvider>
93
+ </PublicAuthContext.Provider>
94
+ </ForgeClientProvider>
95
+ </QueryClientProvider>,
96
+ );
97
+ }
98
+
99
+ describe("AccountDashboard: the way into the members corner", () => {
100
+ it("REGRESSION: offers the community to a member who has a space to open", () => {
101
+ // The gap this closes. Without it the membership card's only exits are
102
+ // Change and Cancel, and the community is unreachable from the account page.
103
+ const html = render({ spaces: [space()], onNavigateCommunity: () => {} });
104
+
105
+ expect(html).toContain("Community spaces");
106
+ });
107
+
108
+ it("REGRESSION: says nothing when the member can open no space", () => {
109
+ // A link here would promise a door that is locked: the server returned no
110
+ // space this account may enter, so the honest account page is silent.
111
+ const html = render({ spaces: [], onNavigateCommunity: () => {} });
112
+
113
+ expect(html).not.toContain("Community spaces");
114
+ // The billing actions are untouched, so this is the entry point missing and
115
+ // not the whole card failing to draw.
116
+ expect(html).toContain("Change");
117
+ });
118
+
119
+ it("draws nothing when the host site has no community route to send anyone to", () => {
120
+ // `onNavigateCommunity` is optional. A site that leaves it out gets no
121
+ // button, even with spaces in the cache, rather than a dead one.
122
+ const html = render({ spaces: [space()] });
123
+
124
+ expect(html).not.toContain("Community spaces");
125
+ });
126
+
127
+ it("stays quiet while the spaces query has not answered yet", () => {
128
+ // First paint has no cached answer. Guessing "yes" here would flash a link
129
+ // and then withdraw it from members who never had access.
130
+ const html = render({ spaces: null, onNavigateCommunity: () => {} });
131
+
132
+ expect(html).not.toContain("Community spaces");
133
+ });
134
+ });