@tribe-nest/forge 3.26.0 → 3.29.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 (90) hide show
  1. package/package.json +1 -1
  2. package/src/client/_tests/localeHeader.spec.tsx +98 -0
  3. package/src/client/activeLocale.ts +34 -0
  4. package/src/client/createForgeClient.ts +13 -0
  5. package/src/contexts/CartContext.tsx +76 -0
  6. package/src/contexts/_tests/CartContext.spec.tsx +210 -0
  7. package/src/data/queries/useCheckouts.ts +83 -0
  8. package/src/data/queries/useWebsite.ts +7 -0
  9. package/src/i18n/CONVENTION.md +184 -0
  10. package/src/i18n/_tests/translationKeys.spec.ts +153 -0
  11. package/src/i18n/de.json +960 -0
  12. package/src/i18n/en.json +960 -0
  13. package/src/i18n/index.ts +125 -0
  14. package/src/index.ts +21 -0
  15. package/src/provider/ForgeAppProvider.tsx +2 -1
  16. package/src/provider/SiteConfigProvider.tsx +11 -0
  17. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
  18. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
  19. package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
  20. package/src/ui/headless/checkout/useCartResume.ts +340 -0
  21. package/src/ui/headless/checkout/useCheckout.ts +9 -2
  22. package/src/ui/headless/index.ts +18 -0
  23. package/src/ui/index.ts +2 -0
  24. package/src/ui/shell/TribeNestApp.tsx +36 -27
  25. package/src/ui/styled/AccountDashboard.tsx +197 -93
  26. package/src/ui/styled/AiAgentWidget.tsx +10 -6
  27. package/src/ui/styled/AudioPlayer.tsx +14 -6
  28. package/src/ui/styled/BlogCategory.tsx +4 -2
  29. package/src/ui/styled/BlogComments.tsx +27 -20
  30. package/src/ui/styled/BlogList.tsx +5 -3
  31. package/src/ui/styled/BlogPost.tsx +14 -11
  32. package/src/ui/styled/BundleConfirmation.tsx +32 -23
  33. package/src/ui/styled/CancellationTerms.tsx +3 -1
  34. package/src/ui/styled/Cart.tsx +32 -14
  35. package/src/ui/styled/ChatRoom.tsx +65 -20
  36. package/src/ui/styled/Checkout.tsx +151 -89
  37. package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
  38. package/src/ui/styled/CoachingBooking.tsx +71 -44
  39. package/src/ui/styled/CoachingConfirmation.tsx +56 -19
  40. package/src/ui/styled/CoachingDetail.tsx +15 -11
  41. package/src/ui/styled/CohortPage.tsx +10 -8
  42. package/src/ui/styled/ConfirmSubscription.tsx +14 -8
  43. package/src/ui/styled/ContactForm.tsx +31 -12
  44. package/src/ui/styled/CookieConsent.tsx +19 -15
  45. package/src/ui/styled/CourseAccess.tsx +54 -47
  46. package/src/ui/styled/CourseCheckout.tsx +56 -38
  47. package/src/ui/styled/CourseConfirmation.tsx +39 -17
  48. package/src/ui/styled/CourseDetail.tsx +21 -15
  49. package/src/ui/styled/CoursesGrid.tsx +4 -2
  50. package/src/ui/styled/CurrencySwitcher.tsx +4 -1
  51. package/src/ui/styled/DiscountCode.tsx +22 -18
  52. package/src/ui/styled/DonationButton.tsx +52 -17
  53. package/src/ui/styled/DonationPage.tsx +4 -4
  54. package/src/ui/styled/EmailListForm.tsx +22 -9
  55. package/src/ui/styled/EventConfirmation.tsx +37 -14
  56. package/src/ui/styled/EventCountdown.tsx +10 -6
  57. package/src/ui/styled/EventDetail.tsx +8 -6
  58. package/src/ui/styled/EventSeriesDetail.tsx +18 -13
  59. package/src/ui/styled/EventTickets.tsx +232 -108
  60. package/src/ui/styled/EventWaitlist.tsx +42 -33
  61. package/src/ui/styled/EventsList.tsx +15 -13
  62. package/src/ui/styled/ForgotPasswordForm.tsx +12 -12
  63. package/src/ui/styled/FormRenderer.tsx +12 -7
  64. package/src/ui/styled/InstallBanner.tsx +21 -18
  65. package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
  66. package/src/ui/styled/InvoicePayment.tsx +37 -16
  67. package/src/ui/styled/LeadMagnet.tsx +9 -7
  68. package/src/ui/styled/Loading.tsx +7 -1
  69. package/src/ui/styled/LoginForm.tsx +22 -14
  70. package/src/ui/styled/MembershipCheckout.tsx +32 -13
  71. package/src/ui/styled/MembershipTiers.tsx +27 -18
  72. package/src/ui/styled/OfferButton.tsx +33 -13
  73. package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
  74. package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
  75. package/src/ui/styled/PodcastEpisode.tsx +20 -9
  76. package/src/ui/styled/PodcastList.tsx +9 -3
  77. package/src/ui/styled/PodcastShow.tsx +29 -7
  78. package/src/ui/styled/PostsFeed.tsx +10 -8
  79. package/src/ui/styled/PresaleCode.tsx +8 -11
  80. package/src/ui/styled/ProductBrowseNav.tsx +21 -11
  81. package/src/ui/styled/ProductDetail.tsx +58 -28
  82. package/src/ui/styled/ReactionBar.tsx +4 -2
  83. package/src/ui/styled/ReplayList.tsx +24 -13
  84. package/src/ui/styled/ResetPasswordForm.tsx +27 -15
  85. package/src/ui/styled/ResumeCart.tsx +289 -0
  86. package/src/ui/styled/ReviewForm.tsx +52 -58
  87. package/src/ui/styled/SignupForm.tsx +29 -16
  88. package/src/ui/styled/TicketTransfer.tsx +55 -54
  89. package/src/ui/styled/UserMenu.tsx +16 -14
  90. package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -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,9 +13,15 @@ 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 = {
19
26
  maxWidth: 420,
20
27
  margin: "40px auto 0",
@@ -53,7 +60,7 @@ export function SignupForm({ onSuccess, loginHref, membershipTierId, couponCode
53
60
  return (
54
61
  <div style={card}>
55
62
  <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>
56
- {form.awaitingCode ? "Check your email" : "Create your account"}
63
+ {form.awaitingCode ? t("forge.signup_form.verify_title") : t("forge.signup_form.title")}
57
64
  </h1>
58
65
  {form.error && <p style={{ color: "#ef4444", marginBottom: 16 }}>{form.error}</p>}
59
66
  <form
@@ -68,10 +75,12 @@ export function SignupForm({ onSuccess, loginHref, membershipTierId, couponCode
68
75
  {form.awaitingCode ? (
69
76
  <>
70
77
  <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.
78
+ {codeSentParts[0]}
79
+ <strong>{form.email}</strong>
80
+ {codeSentParts[1]}
72
81
  </p>
73
82
  {field(
74
- "Verification code",
83
+ t("forge.signup_form.code_label"),
75
84
  <input
76
85
  inputMode="numeric"
77
86
  autoComplete="one-time-code"
@@ -86,42 +95,42 @@ export function SignupForm({ onSuccess, loginHref, membershipTierId, couponCode
86
95
  <>
87
96
  <div style={{ display: "flex", gap: 12 }}>
88
97
  {field(
89
- "First name",
98
+ t("forge.signup_form.first_name_label"),
90
99
  <input
91
100
  value={form.firstName}
92
101
  onChange={(e) => form.setFirstName(e.target.value)}
93
102
  style={input}
94
- placeholder="First name"
103
+ placeholder={t("forge.signup_form.first_name_placeholder")}
95
104
  />,
96
105
  )}
97
106
  {field(
98
- "Last name",
107
+ t("forge.signup_form.last_name_label"),
99
108
  <input
100
109
  value={form.lastName}
101
110
  onChange={(e) => form.setLastName(e.target.value)}
102
111
  style={input}
103
- placeholder="Last name"
112
+ placeholder={t("forge.signup_form.last_name_placeholder")}
104
113
  />,
105
114
  )}
106
115
  </div>
107
116
  {field(
108
- "Email",
117
+ t("forge.signup_form.email_label"),
109
118
  <input
110
119
  type="email"
111
120
  value={form.email}
112
121
  onChange={(e) => form.setEmail(e.target.value)}
113
122
  style={input}
114
- placeholder="Email"
123
+ placeholder={t("forge.signup_form.email_placeholder")}
115
124
  />,
116
125
  )}
117
126
  {field(
118
- "Password",
127
+ t("forge.signup_form.password_label"),
119
128
  <input
120
129
  type="password"
121
130
  value={form.password}
122
131
  onChange={(e) => form.setPassword(e.target.value)}
123
132
  style={input}
124
- placeholder="At least 8 characters"
133
+ placeholder={t("forge.signup_form.password_placeholder")}
125
134
  />,
126
135
  )}
127
136
  <label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13 }}>
@@ -131,12 +140,16 @@ export function SignupForm({ onSuccess, loginHref, membershipTierId, couponCode
131
140
  onChange={(e) => form.setAcceptedTerms(e.target.checked)}
132
141
  style={{ accentColor: theme.colors.primary }}
133
142
  />
134
- I accept the Terms and Privacy Policy
143
+ {t("forge.signup_form.accept_terms")}
135
144
  </label>
136
145
  </>
137
146
  )}
138
147
  <button type="submit" disabled={form.isSubmitting} style={button}>
139
- {form.isSubmitting ? "Working…" : form.awaitingCode ? "Verify and continue" : "Sign up"}
148
+ {form.isSubmitting
149
+ ? t("forge.signup_form.submitting")
150
+ : form.awaitingCode
151
+ ? t("forge.signup_form.verify_submit")
152
+ : t("forge.signup_form.submit")}
140
153
  </button>
141
154
  {form.awaitingCode && (
142
155
  <button
@@ -145,14 +158,14 @@ export function SignupForm({ onSuccess, loginHref, membershipTierId, couponCode
145
158
  disabled={form.isSubmitting}
146
159
  style={{ background: "none", border: "none", color: theme.colors.primary, cursor: "pointer", fontSize: 13 }}
147
160
  >
148
- Resend code
161
+ {t("forge.signup_form.resend_code")}
149
162
  </button>
150
163
  )}
151
164
  </form>
152
165
  <p style={{ marginTop: 16, fontSize: 14, textAlign: "center" }}>
153
- Already have an account?{" "}
166
+ {t("forge.signup_form.login_prompt")}{" "}
154
167
  <a href={loginHref} style={{ color: theme.colors.primary, textDecoration: "underline" }}>
155
- Login
168
+ {t("forge.signup_form.login_link")}
156
169
  </a>
157
170
  </p>
158
171
  </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
  )}