@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
@@ -21,6 +21,7 @@ import {
21
21
  useCancelMembership,
22
22
  useMembershipAccess,
23
23
  useOpenBillingPortal,
24
+ useCommunitySpaces,
24
25
  useUpdateAccount,
25
26
  useChangePassword,
26
27
  useExportAccountData,
@@ -30,6 +31,7 @@ import {
30
31
  OrderStatus,
31
32
  type NotificationPreferences,
32
33
  } from "../../index";
34
+ import { useForgeT, type ForgeT } from "../../i18n";
33
35
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
34
36
  import { useSiteConfig } from "../../data/queries/useWebsite";
35
37
  import { useAmountFormatter } from "../format/useFormatCurrency";
@@ -57,6 +59,21 @@ export const ACCOUNT_TABS = [
57
59
  ] as const;
58
60
  export type AccountTabKey = (typeof ACCOUNT_TABS)[number];
59
61
 
62
+ /**
63
+ * The tab strip's visible labels. The tab keys themselves are route values, so
64
+ * the copy is looked up rather than read off the key.
65
+ */
66
+ const TAB_LABEL_KEYS: Record<AccountTabKey, string> = {
67
+ membership: "forge.account_dashboard.tab_membership",
68
+ orders: "forge.account_dashboard.tab_orders",
69
+ tickets: "forge.account_dashboard.tab_tickets",
70
+ waitlist: "forge.account_dashboard.tab_waitlist",
71
+ bookings: "forge.account_dashboard.tab_bookings",
72
+ saved: "forge.account_dashboard.tab_saved",
73
+ account: "forge.account_dashboard.tab_account",
74
+ notifications: "forge.account_dashboard.tab_notifications",
75
+ };
76
+
60
77
  export interface AccountDashboardProps {
61
78
  className?: string;
62
79
  style?: React.CSSProperties;
@@ -69,6 +86,17 @@ export interface AccountDashboardProps {
69
86
  * this to its router (e.g. `/i/membership`).
70
87
  */
71
88
  onNavigateMembership?: () => void;
89
+ /**
90
+ * Navigate to the community spaces, the member home's Spaces tab (host maps
91
+ * this to its router, e.g. `/i/members/spaces`).
92
+ *
93
+ * Optional, and drawn only when the signed-in member has at least one space
94
+ * they can actually open: `/public/community/spaces` returns open spaces plus
95
+ * the spaces gated to a tier they hold, so an empty list is the server saying
96
+ * there is no door here. Without that check the account page would offer a
97
+ * member a room they get bounced out of, which is worse than no link at all.
98
+ */
99
+ onNavigateCommunity?: () => void;
72
100
  /**
73
101
  * Optional currency formatter override. Defaults to Forge's `useFormatCurrency`
74
102
  * (which honors the visitor's selected currency + tenant exchange rates), so the
@@ -134,6 +162,7 @@ interface TabContext {
134
162
  formatDate: (value: string | number | Date) => string;
135
163
  currency?: string;
136
164
  onNavigateMembership?: () => void;
165
+ onNavigateCommunity?: () => void;
137
166
  }
138
167
 
139
168
  // ---- page -------------------------------------------------------------------
@@ -147,6 +176,7 @@ export function AccountDashboard({
147
176
  tab,
148
177
  onTabChange,
149
178
  onNavigateMembership,
179
+ onNavigateCommunity,
150
180
  formatAmount,
151
181
  formatDate,
152
182
  currency,
@@ -155,6 +185,7 @@ export function AccountDashboard({
155
185
  }: AccountDashboardProps) {
156
186
  const { user, logout } = usePublicAuth();
157
187
  const { t } = useCardStyles();
188
+ const tr = useForgeT();
158
189
  const { data: siteConfig } = useSiteConfig();
159
190
 
160
191
  const fmtAmount = useAmountFormatter(formatAmount);
@@ -167,6 +198,7 @@ export function AccountDashboard({
167
198
  // settlement currency (single source), unless the host overrides it.
168
199
  currency: currency ?? siteConfig?.currency,
169
200
  onNavigateMembership,
201
+ onNavigateCommunity,
170
202
  };
171
203
 
172
204
  if (!user) return <Loading fullPage />;
@@ -175,14 +207,16 @@ export function AccountDashboard({
175
207
  <div className={className} style={{ maxWidth: 900, margin: "0 auto", color: t.text, ...style }}>
176
208
  <div style={{ marginBottom: 24, display: "flex", justifyContent: "space-between", alignItems: "center", gap: 16 }}>
177
209
  <div>
178
- <h1 style={{ fontSize: 28, fontWeight: 800, fontFamily: t.headingFontFamily }}>My Account</h1>
210
+ <h1 style={{ fontSize: 28, fontWeight: 800, fontFamily: t.headingFontFamily }}>
211
+ {tr("forge.account_dashboard.title")}
212
+ </h1>
179
213
  <p style={{ opacity: 0.7 }}>{user.email}</p>
180
214
  </div>
181
215
  <button
182
216
  onClick={() => logout()}
183
217
  style={{ padding: "8px 14px", borderRadius: t.cornerRadius, background: "transparent", color: t.text, border: `1px solid ${t.primary}40`, cursor: "pointer" }}
184
218
  >
185
- Log out
219
+ {tr("forge.account_dashboard.log_out")}
186
220
  </button>
187
221
  </div>
188
222
 
@@ -202,7 +236,7 @@ export function AccountDashboard({
202
236
  fontWeight: 600,
203
237
  }}
204
238
  >
205
- {tabKey}
239
+ {tr(TAB_LABEL_KEYS[tabKey])}
206
240
  </button>
207
241
  ))}
208
242
  </div>
@@ -224,9 +258,24 @@ export function AccountDashboard({
224
258
  function MembershipTab({ ctx }: { ctx: TabContext }) {
225
259
  const { user } = usePublicAuth();
226
260
  const { t, card, button, ghostButton } = useCardStyles();
227
- const { formatAmount, formatDate, currency, onNavigateMembership } = ctx;
261
+ const tr = useForgeT();
262
+ const { formatAmount, formatDate, currency, onNavigateMembership, onNavigateCommunity } = ctx;
228
263
  const cancelMembership = useCancelMembership();
264
+ // The way into the community, which the account page had no link to at all.
265
+ // The list is already access-filtered by the server (open spaces plus the
266
+ // ones gated to a tier this member holds), so its emptiness is the whole
267
+ // gate: no spaces, no link, and nobody is sent at a locked door.
268
+ const { data: communitySpaces } = useCommunitySpaces();
269
+ const canOpenCommunity = !!onNavigateCommunity && (communitySpaces?.length ?? 0) > 0;
229
270
  const [isCancelling, setIsCancelling] = useState(false);
271
+ /**
272
+ * Cancelling is destructive and irreversible from here, so it asks first.
273
+ * CLAUDE.md puts a destructive confirmation in the category that must stay
274
+ * VISIBLE rather than behind a `?`, and until now one click on Cancel simply
275
+ * ended the membership. The client app has always asked (`EditorModal` in
276
+ * `MembershipTab.tsx`); this surface was ported without it.
277
+ */
278
+ const [confirmingCancel, setConfirmingCancel] = useState(false);
230
279
  const [cancelError, setCancelError] = useState<string | null>(null);
231
280
 
232
281
  const membership = user?.membership;
@@ -252,7 +301,7 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
252
301
  window.location.reload();
253
302
  } catch (error) {
254
303
  const detail = (error as { response?: { data?: { message?: string } } })?.response?.data?.message;
255
- setCancelError(detail || "We couldn't cancel your membership. Please try again or contact us.");
304
+ setCancelError(detail || tr("forge.account_dashboard.membership_cancel_error"));
256
305
  } finally {
257
306
  setIsCancelling(false);
258
307
  }
@@ -265,13 +314,15 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
265
314
  window.location.href = url;
266
315
  } catch (error) {
267
316
  const detail = (error as { response?: { data?: { message?: string } } })?.response?.data?.message;
268
- setBillingError(detail || "We couldn't open the billing page. Please contact us.");
317
+ setBillingError(detail || tr("forge.account_dashboard.billing_portal_error"));
269
318
  }
270
319
  };
271
320
 
272
321
  return (
273
322
  <div style={card}>
274
- <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>Current Membership</h2>
323
+ <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
324
+ {tr("forge.account_dashboard.membership_title")}
325
+ </h2>
275
326
  {membership ? (
276
327
  <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
277
328
  <div>
@@ -291,7 +342,10 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
291
342
  )}
292
343
  {!!membership.subscriptionAmount && (
293
344
  <p style={{ fontSize: 14, marginTop: 4 }}>
294
- {formatAmount(membership.subscriptionAmount, membership.subscriptionCurrency || currency)} / {membership.billingCycle}
345
+ {tr("forge.account_dashboard.membership_price", {
346
+ amount: formatAmount(membership.subscriptionAmount, membership.subscriptionCurrency || currency),
347
+ cycle: membership.billingCycle,
348
+ })}
295
349
  </p>
296
350
  )}
297
351
  </div>
@@ -307,7 +361,9 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
307
361
  )}
308
362
  {membership.endDate && (
309
363
  <p style={{ fontSize: 14, opacity: 0.8 }}>
310
- {access.billingState === "active" ? "Renews" : "Ends"} on {formatDate(membership.endDate)}
364
+ {access.billingState === "active"
365
+ ? tr("forge.account_dashboard.membership_renews_on", { date: formatDate(membership.endDate) })
366
+ : tr("forge.account_dashboard.membership_ends_on", { date: formatDate(membership.endDate) })}
311
367
  </p>
312
368
  )}
313
369
  {billingError && <p style={{ fontSize: 13, opacity: 0.9 }}>{billingError}</p>}
@@ -318,13 +374,22 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
318
374
  )}
319
375
  </div>
320
376
  ) : (
321
- <p style={{ opacity: 0.8 }}>You don&apos;t have an active membership.</p>
377
+ <p style={{ opacity: 0.8 }}>{tr("forge.account_dashboard.membership_empty")}</p>
322
378
  )}
323
379
 
324
- <div style={{ display: "flex", gap: 12, justifyContent: "flex-end", marginTop: 24 }}>
380
+ <div style={{ display: "flex", gap: 12, justifyContent: "flex-end", marginTop: 24, flexWrap: "wrap" }}>
381
+ {/* Kept hard left, apart from the billing actions: this is the door to
382
+ what the membership is FOR, not another thing to do to the plan. */}
383
+ {canOpenCommunity && (
384
+ <button onClick={() => onNavigateCommunity?.()} style={{ ...ghostButton, marginRight: "auto" }}>
385
+ {tr("forge.account_dashboard.community_spaces")}
386
+ </button>
387
+ )}
325
388
  {message.showUpdatePayment && (
326
389
  <button onClick={onUpdatePayment} disabled={openBillingPortal.isPending} style={button}>
327
- {openBillingPortal.isPending ? "Opening…" : "Update payment method"}
390
+ {openBillingPortal.isPending
391
+ ? tr("forge.account_dashboard.billing_portal_opening")
392
+ : tr("forge.account_dashboard.update_payment_method")}
328
393
  </button>
329
394
  )}
330
395
  {/* Cancelling stays available for anyone who still HAS access — a
@@ -332,14 +397,51 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
332
397
  but not leave. Hidden once it is already scheduled ("Ending"), where
333
398
  the only honest label would be "Cancel again". */}
334
399
  {access.hasAccess && !access.cancelAtPeriodEnd && (
335
- <button onClick={onCancel} disabled={isCancelling} style={ghostButton}>
336
- {isCancelling ? "Cancelling…" : "Cancel"}
400
+ <button onClick={() => setConfirmingCancel(true)} disabled={isCancelling} style={ghostButton}>
401
+ {isCancelling ? tr("forge.account_dashboard.cancelling") : tr("forge.account_dashboard.cancel")}
337
402
  </button>
338
403
  )}
339
404
  <button onClick={() => onNavigateMembership?.()} style={button}>
340
- {membership ? "Change" : "Join"}
405
+ {membership
406
+ ? tr("forge.account_dashboard.membership_change")
407
+ : tr("forge.account_dashboard.membership_join")}
341
408
  </button>
342
409
  </div>
410
+
411
+ {/* Says WHICH of the two cancellations this is. A paid membership runs to
412
+ the end of the period already paid for; a free one stops now. A flat
413
+ "are you sure" tells a member who paid this month that they have lost
414
+ the rest of it, which is not true and reads as a threat. */}
415
+ {confirmingCancel && (
416
+ <div role="alertdialog" aria-modal="true" style={{ ...card, marginTop: 16 }}>
417
+ <p style={{ fontWeight: 700 }}>{tr("forge.account_dashboard.cancel_confirm_title")}</p>
418
+ <p style={{ fontSize: 14, opacity: 0.85, marginTop: 8 }}>
419
+ {membership?.subscriptionAmount
420
+ ? membership.endDate
421
+ ? tr("forge.account_dashboard.cancel_confirm_paid_until", { date: formatDate(membership.endDate) })
422
+ : tr("forge.account_dashboard.cancel_confirm_paid")
423
+ : tr("forge.account_dashboard.cancel_confirm_free")}
424
+ </p>
425
+ <div style={{ display: "flex", gap: 12, justifyContent: "flex-end", marginTop: 16 }}>
426
+ <button onClick={() => setConfirmingCancel(false)} disabled={isCancelling} style={ghostButton}>
427
+ {tr("forge.account_dashboard.cancel_confirm_keep")}
428
+ </button>
429
+ <button
430
+ onClick={() => {
431
+ // Closed FIRST, so the dialog cannot be double-submitted while
432
+ // the request is in flight. The row behind it already renders
433
+ // "Cancelling" from `isCancelling`.
434
+ setConfirmingCancel(false);
435
+ void onCancel();
436
+ }}
437
+ disabled={isCancelling}
438
+ style={button}
439
+ >
440
+ {tr("forge.account_dashboard.cancel_confirm_confirm")}
441
+ </button>
442
+ </div>
443
+ </div>
444
+ )}
343
445
  </div>
344
446
  );
345
447
  }
@@ -369,6 +471,7 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
369
471
  function TicketsTab({ ctx }: { ctx: TabContext }) {
370
472
  const { user } = usePublicAuth();
371
473
  const { t, card } = useCardStyles();
474
+ const tr = useForgeT();
372
475
  const { formatAmount, formatDate, currency } = ctx;
373
476
  const { data, isLoading } = useMyTickets(user?.id);
374
477
  const cancelTicket = useCancelMyTicket();
@@ -385,7 +488,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
385
488
  } catch (err) {
386
489
  const message =
387
490
  (err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
388
- "We could not cancel this ticket. Please try again.";
491
+ tr("forge.account_dashboard.ticket_cancel_error");
389
492
  setError(message);
390
493
  } finally {
391
494
  setPendingId(null);
@@ -394,7 +497,9 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
394
497
 
395
498
  return (
396
499
  <div style={card}>
397
- <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>My Tickets</h2>
500
+ <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
501
+ {tr("forge.account_dashboard.tickets_title")}
502
+ </h2>
398
503
  {error && (
399
504
  <div style={{ marginBottom: 16, padding: 12, borderRadius: t.cornerRadius, border: `1px solid ${t.primary}40`, fontSize: 14 }}>
400
505
  {error}
@@ -418,19 +523,26 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
418
523
  <div style={{ fontWeight: 600 }}>{ticket.eventTitle}</div>
419
524
  <div style={{ fontSize: 13, opacity: 0.7 }}>{formatDate(ticket.eventDateTime)}</div>
420
525
  </div>
421
- {cancelled && <span style={{ fontSize: 13, color: t.primary }}>Cancelled</span>}
526
+ {cancelled && (
527
+ <span style={{ fontSize: 13, color: t.primary }}>{tr("forge.account_dashboard.cancelled")}</span>
528
+ )}
422
529
  {/* Says WHY this is in the list at all. Without it a
423
530
  transferred ticket looks like a purchase the buyer cannot
424
531
  remember making, with no prices on it. */}
425
532
  {!cancelled && gifted && (
426
- <span style={{ fontSize: 13, color: t.primary }}>Sent to you</span>
533
+ <span style={{ fontSize: 13, color: t.primary }}>
534
+ {tr("forge.account_dashboard.ticket_transferred")}
535
+ </span>
427
536
  )}
428
537
  </div>
429
538
 
430
539
  {ticket.items.map((item) => (
431
540
  <div key={item.id} style={{ display: "flex", justifyContent: "space-between", fontSize: 14, padding: "4px 0" }}>
432
541
  <span>
433
- {item.ticketTitle ?? "Ticket"} × {item.quantity}
542
+ {tr("forge.account_dashboard.ticket_line", {
543
+ title: item.ticketTitle ?? tr("forge.account_dashboard.ticket_fallback_title"),
544
+ quantity: item.quantity,
545
+ })}
434
546
  </span>
435
547
  {/* A holder is not shown a price, because the server did
436
548
  not send one — `Number(null)` is 0, and a confident
@@ -443,7 +555,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
443
555
 
444
556
  {!gifted && (
445
557
  <div style={{ display: "flex", justifyContent: "space-between", fontWeight: 700, marginTop: 12, paddingTop: 12, borderTop: `1px solid ${t.primary}20` }}>
446
- <span>Total</span>
558
+ <span>{tr("forge.account_dashboard.total")}</span>
447
559
  <span style={{ color: t.primary }}>{formatAmount(Number(ticket.totalAmount), ticket.currency || currency)}</span>
448
560
  </div>
449
561
  )}
@@ -522,7 +634,9 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
522
634
  fontWeight: 600,
523
635
  }}
524
636
  >
525
- {pendingId === ticket.id ? "Cancelling…" : "Cancel ticket"}
637
+ {pendingId === ticket.id
638
+ ? tr("forge.account_dashboard.cancelling")
639
+ : tr("forge.account_dashboard.cancel_ticket")}
526
640
  </button>
527
641
  )}
528
642
  </div>
@@ -530,7 +644,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
530
644
  })}
531
645
  </div>
532
646
  ) : (
533
- <p style={{ fontSize: 14, opacity: 0.7 }}>You have no tickets yet.</p>
647
+ <p style={{ fontSize: 14, opacity: 0.7 }}>{tr("forge.account_dashboard.tickets_empty")}</p>
534
648
  )}
535
649
  </div>
536
650
  );
@@ -555,12 +669,15 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
555
669
  function WaitlistTab({ ctx }: { ctx: TabContext }) {
556
670
  const { user } = usePublicAuth();
557
671
  const { t, card } = useCardStyles();
672
+ const tr = useForgeT();
558
673
  const { formatDate } = ctx;
559
674
  const { places, isLoading } = useMyWaitlistPlaces(user?.id);
560
675
 
561
676
  return (
562
677
  <div style={card}>
563
- <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>My Waitlists</h2>
678
+ <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
679
+ {tr("forge.account_dashboard.waitlists_title")}
680
+ </h2>
564
681
  {isLoading ? (
565
682
  <Loading />
566
683
  ) : places.length > 0 ? (
@@ -570,7 +687,7 @@ function WaitlistTab({ ctx }: { ctx: TabContext }) {
570
687
  ))}
571
688
  </div>
572
689
  ) : (
573
- <p style={{ fontSize: 14, opacity: 0.7 }}>You&apos;re not on any waitlists.</p>
690
+ <p style={{ fontSize: 14, opacity: 0.7 }}>{tr("forge.account_dashboard.waitlists_empty")}</p>
574
691
  )}
575
692
  </div>
576
693
  );
@@ -584,6 +701,7 @@ function WaitlistPlaceRow({
584
701
  formatDate: (value: string | number | Date) => string;
585
702
  }) {
586
703
  const { t } = useCardStyles();
704
+ const tr = useForgeT();
587
705
  const leave = useLeaveEventWaitlist(place.eventId);
588
706
  const [error, setError] = useState<string | null>(null);
589
707
 
@@ -594,7 +712,7 @@ function WaitlistPlaceRow({
594
712
  } catch (err) {
595
713
  const message =
596
714
  (err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
597
- "We could not remove you from this waitlist. Please try again.";
715
+ tr("forge.account_dashboard.waitlist_leave_error");
598
716
  setError(message);
599
717
  }
600
718
  };
@@ -611,22 +729,35 @@ function WaitlistPlaceRow({
611
729
  >
612
730
  <div style={{ display: "flex", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
613
731
  <div>
614
- <div style={{ fontWeight: 600 }}>{place.event?.title ?? "Event"}</div>
732
+ <div style={{ fontWeight: 600 }}>
733
+ {place.event?.title ?? tr("forge.account_dashboard.waitlist_event_fallback")}
734
+ </div>
615
735
  <div style={{ fontSize: 13, opacity: 0.7 }}>
616
- {place.ticket?.title ?? "Ticket"} · {place.quantity} {place.quantity === 1 ? "ticket" : "tickets"}
736
+ {tr(
737
+ place.quantity === 1
738
+ ? "forge.account_dashboard.waitlist_ticket_line_one"
739
+ : "forge.account_dashboard.waitlist_ticket_line_other",
740
+ {
741
+ title: place.ticket?.title ?? tr("forge.account_dashboard.waitlist_ticket_fallback"),
742
+ quantity: place.quantity,
743
+ },
744
+ )}
617
745
  </div>
618
746
  {place.event?.dateTime && (
619
747
  <div style={{ fontSize: 13, opacity: 0.7 }}>{formatDate(place.event.dateTime)}</div>
620
748
  )}
621
749
  </div>
622
750
  {place.offer.offerOpen ? (
623
- <span style={{ fontWeight: 700, color: t.primary }}>Tickets available</span>
751
+ <span style={{ fontWeight: 700, color: t.primary }}>
752
+ {tr("forge.account_dashboard.waitlist_offer_open")}
753
+ </span>
624
754
  ) : place.position != null ? (
625
755
  <span style={{ fontSize: 14, opacity: 0.85 }}>
626
- Position <strong style={{ color: t.primary }}>#{place.position}</strong>
756
+ {tr("forge.account_dashboard.waitlist_position_label")}{" "}
757
+ <strong style={{ color: t.primary }}>#{place.position}</strong>
627
758
  </span>
628
759
  ) : (
629
- <span style={{ fontSize: 14, opacity: 0.85 }}>You&apos;re on the list</span>
760
+ <span style={{ fontSize: 14, opacity: 0.85 }}>{tr("forge.account_dashboard.waitlist_on_list")}</span>
630
761
  )}
631
762
  </div>
632
763
 
@@ -640,15 +771,21 @@ function WaitlistPlaceRow({
640
771
  fontSize: 14,
641
772
  }}
642
773
  >
643
- <strong>Buy within {formatCountdown(place.offer.secondsRemaining ?? 0)}.</strong> Your offer closes{" "}
644
- {deadline.toLocaleString(undefined, {
645
- weekday: "short",
646
- day: "numeric",
647
- month: "short",
648
- hour: "numeric",
649
- minute: "2-digit",
650
- })}
651
- . Tickets are not reserved until you buy them.
774
+ <strong>
775
+ {tr("forge.account_dashboard.waitlist_offer_deadline", {
776
+ countdown: formatCountdown(place.offer.secondsRemaining ?? 0),
777
+ })}
778
+ </strong>{" "}
779
+ {tr("forge.account_dashboard.waitlist_offer_closes", {
780
+ datetime: deadline.toLocaleString(undefined, {
781
+ weekday: "short",
782
+ day: "numeric",
783
+ month: "short",
784
+ hour: "numeric",
785
+ minute: "2-digit",
786
+ }),
787
+ })}{" "}
788
+ {tr("forge.account_dashboard.waitlist_offer_not_reserved")}
652
789
  </div>
653
790
  )}
654
791
 
@@ -668,7 +805,9 @@ function WaitlistPlaceRow({
668
805
  fontWeight: 600,
669
806
  }}
670
807
  >
671
- {leave.isPending ? "Leaving…" : "Leave waitlist"}
808
+ {leave.isPending
809
+ ? tr("forge.account_dashboard.waitlist_leaving")
810
+ : tr("forge.account_dashboard.leave_waitlist")}
672
811
  </button>
673
812
  </div>
674
813
  );
@@ -700,13 +839,16 @@ function WaitlistPlaceRow({
700
839
  function BookingsTab({ ctx }: { ctx: TabContext }) {
701
840
  const { user } = usePublicAuth();
702
841
  const { t, card } = useCardStyles();
842
+ const tr = useForgeT();
703
843
  const { data, isLoading } = useMyBookings(user?.id);
704
844
 
705
845
  const bookings = data?.data ?? [];
706
846
 
707
847
  return (
708
848
  <div style={card}>
709
- <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>My Sessions</h2>
849
+ <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
850
+ {tr("forge.account_dashboard.sessions_title")}
851
+ </h2>
710
852
  {isLoading ? (
711
853
  <Loading />
712
854
  ) : bookings.length > 0 ? (
@@ -716,14 +858,14 @@ function BookingsTab({ ctx }: { ctx: TabContext }) {
716
858
  ))}
717
859
  </div>
718
860
  ) : (
719
- <p style={{ fontSize: 14, opacity: 0.7 }}>You have no sessions booked.</p>
861
+ <p style={{ fontSize: 14, opacity: 0.7 }}>{tr("forge.account_dashboard.sessions_empty")}</p>
720
862
  )}
721
863
  </div>
722
864
  );
723
865
  }
724
866
 
725
867
  /** The session's window, in the coach's timezone where one is set. */
726
- function formatSessionWindow(booking: MyBooking): string {
868
+ function formatSessionWindow(booking: MyBooking, tr: ForgeT): string {
727
869
  const zone = booking.coachingProductTimezone ?? undefined;
728
870
  const start = new Date(booking.sessionStartTime);
729
871
  const end = new Date(booking.sessionEndTime);
@@ -736,7 +878,9 @@ function formatSessionWindow(booking: MyBooking): string {
736
878
  });
737
879
  const from = start.toLocaleString(undefined, { hour: "numeric", minute: "2-digit", timeZone: zone });
738
880
  const to = end.toLocaleString(undefined, { hour: "numeric", minute: "2-digit", timeZone: zone });
739
- return `${day}, ${from} to ${to}${zone ? ` (${zone})` : ""}`;
881
+ return zone
882
+ ? tr("forge.account_dashboard.session_window_zoned", { day, start: from, end: to, timezone: zone })
883
+ : tr("forge.account_dashboard.session_window", { day, start: from, end: to });
740
884
  }
741
885
 
742
886
  /**
@@ -766,12 +910,13 @@ function BookingLocationLine({
766
910
  location: MyBooking["location"];
767
911
  primary: string;
768
912
  }) {
913
+ const tr = useForgeT();
769
914
  if (!location) return null;
770
915
 
771
916
  if (location.type === "online") {
772
917
  return (
773
918
  <div style={{ fontSize: 13, marginTop: 4 }}>
774
- <span style={{ opacity: 0.7 }}>Join: </span>
919
+ <span style={{ opacity: 0.7 }}>{tr("forge.account_dashboard.booking_join_label")}{" "}</span>
775
920
  <a
776
921
  href={location.url}
777
922
  target="_blank"
@@ -784,9 +929,27 @@ function BookingLocationLine({
784
929
  );
785
930
  }
786
931
 
932
+ /**
933
+ * A call on the platform's own media network. A LINE, never the call itself.
934
+ *
935
+ * There is no URL to render and there never will be: entry is a ticket minted
936
+ * per attempt and valid for minutes. Mounting the room here is also the wrong
937
+ * place mechanically - the call UI lives behind `@tribe-nest/forge/media`,
938
+ * whose media-client dependency is an OPTIONAL peer, and importing it from
939
+ * this entry point would break every site that has not installed it.
940
+ */
941
+ if (location.type === "video") {
942
+ return (
943
+ <div style={{ fontSize: 13, opacity: 0.7, marginTop: 4 }}>
944
+ <span style={{ fontWeight: 600 }}>{tr("forge.account_dashboard.booking_join_label")}{" "}</span>
945
+ {tr("forge.account_dashboard.booking_video_call")}
946
+ </div>
947
+ );
948
+ }
949
+
787
950
  return (
788
951
  <div style={{ fontSize: 13, opacity: 0.7, marginTop: 4 }}>
789
- <span style={{ fontWeight: 600 }}>Where: </span>
952
+ <span style={{ fontWeight: 600 }}>{tr("forge.account_dashboard.booking_where_label")}{" "}</span>
790
953
  {location.address}
791
954
  </div>
792
955
  );
@@ -794,6 +957,7 @@ function BookingLocationLine({
794
957
 
795
958
  function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
796
959
  const { t } = useCardStyles();
960
+ const tr = useForgeT();
797
961
  const { formatAmount, currency } = ctx;
798
962
  const cancelBooking = useCancelMyBooking();
799
963
  const [picking, setPicking] = useState(false);
@@ -820,7 +984,7 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
820
984
  // wording, not ours.
821
985
  const message =
822
986
  (err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
823
- "We could not cancel this session. Please try again.";
987
+ tr("forge.account_dashboard.booking_cancel_error");
824
988
  setError(message);
825
989
  }
826
990
  };
@@ -837,14 +1001,18 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
837
1001
  <div style={{ display: "flex", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
838
1002
  <div>
839
1003
  <div style={{ fontWeight: 600 }}>{booking.coachingProductTitle}</div>
840
- <div style={{ fontSize: 13, opacity: 0.7 }}>{formatSessionWindow(booking)}</div>
1004
+ <div style={{ fontSize: 13, opacity: 0.7 }}>{formatSessionWindow(booking, tr)}</div>
841
1005
  {booking.coachingProductDurationMinutes != null && (
842
- <div style={{ fontSize: 13, opacity: 0.7 }}>{booking.coachingProductDurationMinutes} minutes</div>
1006
+ <div style={{ fontSize: 13, opacity: 0.7 }}>
1007
+ {tr("forge.account_dashboard.booking_duration", { minutes: booking.coachingProductDurationMinutes })}
1008
+ </div>
843
1009
  )}
844
1010
  <BookingLocationLine location={booking.location} primary={t.primary} />
845
1011
  </div>
846
1012
  <div style={{ textAlign: "right" }}>
847
- {cancelled && <div style={{ fontSize: 13, color: t.primary }}>Cancelled</div>}
1013
+ {cancelled && (
1014
+ <div style={{ fontSize: 13, color: t.primary }}>{tr("forge.account_dashboard.cancelled")}</div>
1015
+ )}
848
1016
  {booking.totalAmount != null && (
849
1017
  <div style={{ fontWeight: 700, color: t.primary }}>
850
1018
  {formatAmount(booking.totalAmount, booking.currency || currency)}
@@ -888,7 +1056,7 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
888
1056
  <div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 12 }}>
889
1057
  {booking.canReschedule && !picking && (
890
1058
  <button onClick={() => setPicking(true)} style={actionButton}>
891
- Reschedule
1059
+ {tr("forge.account_dashboard.reschedule")}
892
1060
  </button>
893
1061
  )}
894
1062
  {booking.cancellation.canCancel && (
@@ -897,7 +1065,9 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
897
1065
  disabled={cancelBooking.isPending}
898
1066
  style={{ ...actionButton, cursor: cancelBooking.isPending ? "wait" : "pointer" }}
899
1067
  >
900
- {cancelBooking.isPending ? "Cancelling…" : "Cancel session"}
1068
+ {cancelBooking.isPending
1069
+ ? tr("forge.account_dashboard.cancelling")
1070
+ : tr("forge.account_dashboard.cancel_session")}
901
1071
  </button>
902
1072
  )}
903
1073
  </div>
@@ -919,6 +1089,7 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
919
1089
  */
920
1090
  function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone: () => void }) {
921
1091
  const { t } = useCardStyles();
1092
+ const tr = useForgeT();
922
1093
  const reschedule = useRescheduleBooking();
923
1094
  const [error, setError] = useState<string | null>(null);
924
1095
  const [pendingSlotId, setPendingSlotId] = useState<string | null>(null);
@@ -943,7 +1114,7 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
943
1114
  } catch (err) {
944
1115
  const message =
945
1116
  (err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
946
- "We could not move this session. Please try another time.";
1117
+ tr("forge.account_dashboard.reschedule_error");
947
1118
  setError(message);
948
1119
  } finally {
949
1120
  setPendingSlotId(null);
@@ -955,7 +1126,7 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
955
1126
  return (
956
1127
  <div style={{ marginTop: 12, padding: 12, borderRadius: t.cornerRadius, border: `1px solid ${t.primary}4d` }}>
957
1128
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12 }}>
958
- <strong style={{ fontSize: 14 }}>Choose a new time</strong>
1129
+ <strong style={{ fontSize: 14 }}>{tr("forge.account_dashboard.reschedule_title")}</strong>
959
1130
  <button
960
1131
  onClick={onDone}
961
1132
  style={{
@@ -968,7 +1139,7 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
968
1139
  fontSize: 13,
969
1140
  }}
970
1141
  >
971
- Close
1142
+ {tr("forge.account_dashboard.close")}
972
1143
  </button>
973
1144
  </div>
974
1145
 
@@ -1001,7 +1172,7 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
1001
1172
  }}
1002
1173
  >
1003
1174
  {busy
1004
- ? "Moving…"
1175
+ ? tr("forge.account_dashboard.reschedule_moving")
1005
1176
  : start.toLocaleString(undefined, {
1006
1177
  weekday: "short",
1007
1178
  day: "numeric",
@@ -1016,7 +1187,7 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
1016
1187
  </div>
1017
1188
  ) : (
1018
1189
  <p style={{ fontSize: 13, opacity: 0.7, marginTop: 12 }}>
1019
- No other times are open in the next two weeks.
1190
+ {tr("forge.account_dashboard.reschedule_empty")}
1020
1191
  </p>
1021
1192
  )}
1022
1193
  </div>
@@ -1028,6 +1199,7 @@ function RescheduleSlotPicker({ booking, onDone }: { booking: MyBooking; onDone:
1028
1199
  function OrdersTab({ ctx }: { ctx: TabContext }) {
1029
1200
  const { user } = usePublicAuth();
1030
1201
  const { t, card } = useCardStyles();
1202
+ const tr = useForgeT();
1031
1203
  const { formatAmount, formatDate, currency } = ctx;
1032
1204
  const { data: orders, isLoading } = useUserOrders(user?.id);
1033
1205
 
@@ -1035,7 +1207,9 @@ function OrdersTab({ ctx }: { ctx: TabContext }) {
1035
1207
 
1036
1208
  return (
1037
1209
  <div style={card}>
1038
- <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>My Orders</h2>
1210
+ <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
1211
+ {tr("forge.account_dashboard.orders_title")}
1212
+ </h2>
1039
1213
  {isLoading ? (
1040
1214
  <Loading />
1041
1215
  ) : orders && orders.length > 0 ? (
@@ -1046,7 +1220,9 @@ function OrdersTab({ ctx }: { ctx: TabContext }) {
1046
1220
  <div key={order.id} style={{ border: `1px solid ${t.primary}20`, borderRadius: t.cornerRadius, padding: 16 }}>
1047
1221
  <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 12 }}>
1048
1222
  <div>
1049
- <div style={{ fontWeight: 600 }}>Order #{order.id}</div>
1223
+ <div style={{ fontWeight: 600 }}>
1224
+ {tr("forge.account_dashboard.order_number", { order_id: order.id })}
1225
+ </div>
1050
1226
  <div style={{ fontSize: 13, opacity: 0.7 }}>{formatDate(order.createdAt)}</div>
1051
1227
  </div>
1052
1228
  <span style={{ fontSize: 13, color: t.primary }}>{formatStatus(order.status)}</span>
@@ -1055,7 +1231,7 @@ function OrdersTab({ ctx }: { ctx: TabContext }) {
1055
1231
  group.items.map((item, i) => (
1056
1232
  <div key={`${item.productVariantId}-${i}`} style={{ display: "flex", justifyContent: "space-between", fontSize: 14, padding: "4px 0" }}>
1057
1233
  <span>
1058
- {item.title} × {item.quantity}
1234
+ {tr("forge.account_dashboard.order_line", { title: item.title, quantity: item.quantity })}
1059
1235
  {/* Which version. Two versions of one release carry the
1060
1236
  same title, so without this a buyer's own order
1061
1237
  history shows what looks like the same thing bought
@@ -1068,14 +1244,14 @@ function OrdersTab({ ctx }: { ctx: TabContext }) {
1068
1244
  )),
1069
1245
  )}
1070
1246
  <div style={{ display: "flex", justifyContent: "space-between", fontWeight: 700, marginTop: 12, paddingTop: 12, borderTop: `1px solid ${t.primary}20` }}>
1071
- <span>Total</span>
1247
+ <span>{tr("forge.account_dashboard.total")}</span>
1072
1248
  <span style={{ color: t.primary }}>{formatAmount(order.totalAmount, order.currency || currency)}</span>
1073
1249
  </div>
1074
1250
  </div>
1075
1251
  ))}
1076
1252
  </div>
1077
1253
  ) : (
1078
- <p style={{ opacity: 0.8 }}>You haven&apos;t placed any orders yet.</p>
1254
+ <p style={{ opacity: 0.8 }}>{tr("forge.account_dashboard.orders_empty")}</p>
1079
1255
  )}
1080
1256
  </div>
1081
1257
  );
@@ -1086,12 +1262,15 @@ function OrdersTab({ ctx }: { ctx: TabContext }) {
1086
1262
  function SavedTab() {
1087
1263
  const { user } = usePublicAuth();
1088
1264
  const { t, card } = useCardStyles();
1265
+ const tr = useForgeT();
1089
1266
  const { data, isLoading } = useSavedPosts(user?.id);
1090
1267
  const posts = data?.data;
1091
1268
 
1092
1269
  return (
1093
1270
  <div style={card}>
1094
- <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>Saved Posts</h2>
1271
+ <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
1272
+ {tr("forge.account_dashboard.saved_title")}
1273
+ </h2>
1095
1274
  {isLoading ? (
1096
1275
  <Loading />
1097
1276
  ) : posts && posts.length > 0 ? (
@@ -1100,13 +1279,13 @@ function SavedTab() {
1100
1279
  <div key={post.id} style={{ border: `1px solid ${t.primary}20`, borderRadius: t.cornerRadius, padding: 16 }}>
1101
1280
  <p style={{ fontSize: 14, marginBottom: 8 }}>{post.caption}</p>
1102
1281
  <p style={{ fontSize: 12, opacity: 0.6 }}>
1103
- {post.likes} likes · {post.comments} comments
1282
+ {tr("forge.account_dashboard.saved_post_stats", { likes: post.likes, comments: post.comments })}
1104
1283
  </p>
1105
1284
  </div>
1106
1285
  ))}
1107
1286
  </div>
1108
1287
  ) : (
1109
- <p style={{ opacity: 0.8 }}>You haven&apos;t saved any posts yet.</p>
1288
+ <p style={{ opacity: 0.8 }}>{tr("forge.account_dashboard.saved_empty")}</p>
1110
1289
  )}
1111
1290
  </div>
1112
1291
  );
@@ -1117,6 +1296,7 @@ function SavedTab() {
1117
1296
  function AccountTab({ ctx }: { ctx: TabContext }) {
1118
1297
  const { user, refetchUser, logout } = usePublicAuth();
1119
1298
  const { t, card, button, ghostButton, input } = useCardStyles();
1299
+ const tr = useForgeT();
1120
1300
  const { formatDate } = ctx;
1121
1301
 
1122
1302
  const updateAccount = useUpdateAccount();
@@ -1144,22 +1324,22 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
1144
1324
  try {
1145
1325
  await updateAccount.mutateAsync({ firstName, lastName });
1146
1326
  await refetchUser();
1147
- setProfileMsg("Profile updated.");
1327
+ setProfileMsg(tr("forge.account_dashboard.profile_update_success"));
1148
1328
  } catch {
1149
- setProfileMsg("Failed to update profile.");
1329
+ setProfileMsg(tr("forge.account_dashboard.profile_update_error"));
1150
1330
  }
1151
1331
  };
1152
1332
 
1153
1333
  const onChangePassword = async (e: React.FormEvent) => {
1154
1334
  e.preventDefault();
1155
1335
  setPasswordMsg("");
1156
- if (newPassword.length < 8) return setPasswordMsg("New password must be at least 8 characters.");
1157
- if (newPassword !== confirmPassword) return setPasswordMsg("Passwords don't match.");
1336
+ if (newPassword.length < 8) return setPasswordMsg(tr("forge.account_dashboard.password_too_short"));
1337
+ if (newPassword !== confirmPassword) return setPasswordMsg(tr("forge.account_dashboard.password_mismatch"));
1158
1338
  try {
1159
1339
  await changePassword.mutateAsync({ currentPassword, newPassword });
1160
1340
  logout();
1161
1341
  } catch {
1162
- setPasswordMsg("Failed to update password.");
1342
+ setPasswordMsg(tr("forge.account_dashboard.password_update_error"));
1163
1343
  }
1164
1344
  };
1165
1345
 
@@ -1185,81 +1365,83 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
1185
1365
  <div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
1186
1366
  {/* Profile */}
1187
1367
  <form onSubmit={onUpdateProfile} style={card}>
1188
- <h2 style={sectionTitle}>Profile Information</h2>
1368
+ <h2 style={sectionTitle}>{tr("forge.account_dashboard.profile_section_title")}</h2>
1189
1369
  <div style={{ display: "flex", gap: 12, marginBottom: 12 }}>
1190
1370
  <div style={{ flex: 1 }}>
1191
- <label style={label}>First name</label>
1371
+ <label style={label}>{tr("forge.account_dashboard.first_name")}</label>
1192
1372
  <input value={firstName} onChange={(e) => setFirstName(e.target.value)} style={input} />
1193
1373
  </div>
1194
1374
  <div style={{ flex: 1 }}>
1195
- <label style={label}>Last name</label>
1375
+ <label style={label}>{tr("forge.account_dashboard.last_name")}</label>
1196
1376
  <input value={lastName} onChange={(e) => setLastName(e.target.value)} style={input} />
1197
1377
  </div>
1198
1378
  </div>
1199
- <p style={{ fontSize: 13, opacity: 0.7, marginBottom: 12 }}>Email: {user?.email} (cannot be changed)</p>
1379
+ <p style={{ fontSize: 13, opacity: 0.7, marginBottom: 12 }}>
1380
+ {tr("forge.account_dashboard.email_readonly", { email: user?.email ?? "" })}
1381
+ </p>
1200
1382
  {profileMsg && <p style={{ fontSize: 13, marginBottom: 12 }}>{profileMsg}</p>}
1201
1383
  <button type="submit" style={button}>
1202
- Update Profile
1384
+ {tr("forge.account_dashboard.update_profile")}
1203
1385
  </button>
1204
1386
  </form>
1205
1387
 
1206
1388
  {/* Password */}
1207
1389
  <form onSubmit={onChangePassword} style={card}>
1208
- <h2 style={sectionTitle}>Change Password</h2>
1390
+ <h2 style={sectionTitle}>{tr("forge.account_dashboard.password_section_title")}</h2>
1209
1391
  {passwordMsg && <p style={{ color: "#ef4444", fontSize: 13, marginBottom: 12 }}>{passwordMsg}</p>}
1210
1392
  <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
1211
1393
  <div>
1212
- <label style={label}>Current password</label>
1394
+ <label style={label}>{tr("forge.account_dashboard.current_password")}</label>
1213
1395
  <input type="password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} style={input} />
1214
1396
  </div>
1215
1397
  <div style={{ display: "flex", gap: 12 }}>
1216
1398
  <div style={{ flex: 1 }}>
1217
- <label style={label}>New password</label>
1399
+ <label style={label}>{tr("forge.account_dashboard.new_password")}</label>
1218
1400
  <input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} style={input} />
1219
1401
  </div>
1220
1402
  <div style={{ flex: 1 }}>
1221
- <label style={label}>Confirm new password</label>
1403
+ <label style={label}>{tr("forge.account_dashboard.confirm_password")}</label>
1222
1404
  <input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} style={input} />
1223
1405
  </div>
1224
1406
  </div>
1225
1407
  </div>
1226
1408
  <button type="submit" style={{ ...button, marginTop: 16 }}>
1227
- Update Password
1409
+ {tr("forge.account_dashboard.update_password")}
1228
1410
  </button>
1229
1411
  </form>
1230
1412
 
1231
1413
  {/* Data rights */}
1232
1414
  <div style={card}>
1233
- <h2 style={sectionTitle}>Your Data</h2>
1234
- <p style={{ fontSize: 13, opacity: 0.7, marginBottom: 12 }}>Export a copy of your personal data.</p>
1415
+ <h2 style={sectionTitle}>{tr("forge.account_dashboard.data_section_title")}</h2>
1416
+ <p style={{ fontSize: 13, opacity: 0.7, marginBottom: 12 }}>
1417
+ {tr("forge.account_dashboard.data_export_description")}
1418
+ </p>
1235
1419
  <button onClick={onExport} style={ghostButton}>
1236
- Export My Data
1420
+ {tr("forge.account_dashboard.export_data")}
1237
1421
  </button>
1238
1422
  </div>
1239
1423
 
1240
1424
  {/* Delete account */}
1241
1425
  <div style={{ ...card, borderColor: "#ef444533" }}>
1242
- <h2 style={{ ...sectionTitle, color: "#ef4444" }}>Delete Account</h2>
1426
+ <h2 style={{ ...sectionTitle, color: "#ef4444" }}>{tr("forge.account_dashboard.delete_section_title")}</h2>
1243
1427
  {scheduledAt ? (
1244
1428
  <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
1245
1429
  <p style={{ fontSize: 14 }}>
1246
- Account deletion scheduled for {formatDate(scheduledAt)}. You can cancel before then.
1430
+ {tr("forge.account_dashboard.deletion_scheduled", { date: formatDate(scheduledAt) })}
1247
1431
  </p>
1248
1432
  <button onClick={() => cancelDeletion.mutate()} style={ghostButton}>
1249
- Cancel Deletion
1433
+ {tr("forge.account_dashboard.cancel_deletion")}
1250
1434
  </button>
1251
1435
  </div>
1252
1436
  ) : confirmDelete ? (
1253
1437
  <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
1254
- <p style={{ fontSize: 14 }}>
1255
- Your account will be scheduled for permanent deletion after a 30-day cooling-off period.
1256
- </p>
1438
+ <p style={{ fontSize: 14 }}>{tr("forge.account_dashboard.deletion_confirm_body")}</p>
1257
1439
  <div style={{ display: "flex", gap: 12 }}>
1258
1440
  <button onClick={() => requestDeletion.mutate()} style={{ ...button, background: "#ef4444" }}>
1259
- Yes, delete my account
1441
+ {tr("forge.account_dashboard.deletion_confirm")}
1260
1442
  </button>
1261
1443
  <button onClick={() => setConfirmDelete(false)} style={ghostButton}>
1262
- Cancel
1444
+ {tr("forge.account_dashboard.cancel")}
1263
1445
  </button>
1264
1446
  </div>
1265
1447
  </div>
@@ -1268,7 +1450,7 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
1268
1450
  onClick={() => setConfirmDelete(true)}
1269
1451
  style={{ padding: "8px 14px", borderRadius: t.cornerRadius, background: "#ef444411", color: "#ef4444", border: "1px solid #ef444433", cursor: "pointer" }}
1270
1452
  >
1271
- Request Account Deletion
1453
+ {tr("forge.account_dashboard.request_deletion")}
1272
1454
  </button>
1273
1455
  )}
1274
1456
  </div>
@@ -1280,6 +1462,7 @@ function AccountTab({ ctx }: { ctx: TabContext }) {
1280
1462
 
1281
1463
  function NotificationsTab() {
1282
1464
  const { t, card } = useCardStyles();
1465
+ const tr = useForgeT();
1283
1466
  const { data } = useNotificationPreferences();
1284
1467
  const updatePreference = useUpdateNotificationPreference();
1285
1468
 
@@ -1341,10 +1524,20 @@ function NotificationsTab() {
1341
1524
 
1342
1525
  return (
1343
1526
  <div style={card}>
1344
- <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>Notification Types</h2>
1527
+ <h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 16, fontFamily: t.headingFontFamily }}>
1528
+ {tr("forge.account_dashboard.notifications_title")}
1529
+ </h2>
1345
1530
  <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
1346
- <Row k="newPosts" title="New Posts" desc="Get notified when new posts are published" />
1347
- <Row k="chatMessages" title="Chat Messages" desc="Get notified when new chat messages arrive" />
1531
+ <Row
1532
+ k="newPosts"
1533
+ title={tr("forge.account_dashboard.notify_new_posts_title")}
1534
+ desc={tr("forge.account_dashboard.notify_new_posts_description")}
1535
+ />
1536
+ <Row
1537
+ k="chatMessages"
1538
+ title={tr("forge.account_dashboard.notify_chat_title")}
1539
+ desc={tr("forge.account_dashboard.notify_chat_description")}
1540
+ />
1348
1541
  </div>
1349
1542
  </div>
1350
1543
  );