@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
@@ -2,6 +2,7 @@ import { type CSSProperties, type ReactNode, useEffect, useMemo, useState } from
2
2
  import { AlertCircle, CalendarClock, Clock, KeyRound, X } from "lucide-react";
3
3
  import type { IEvent, ITicket } from "../../types/models";
4
4
  import { useEventCheckout } from "../headless/event/useEventCheckout";
5
+ import { useForgeT } from "../../i18n";
5
6
  import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
6
7
  import { useAmountFormatter } from "../format/useFormatCurrency";
7
8
  import { PriceDisplay, summarizeTaxQuote } from "../format/PriceDisplay";
@@ -42,7 +43,7 @@ export interface EventTicketsProps {
42
43
  triggerText?: string;
43
44
  /** Format a numeric amount for display. */
44
45
  formatAmount?: (amount: number) => string;
45
- /** Optional payment renderer falls back to a registered <ForgePaymentProvider>. */
46
+ /** Optional payment renderer. Falls back to a registered <ForgePaymentProvider>. */
46
47
  renderPayment?: (props: PaymentRenderProps) => ReactNode;
47
48
  /** Stripe return URL for a PAID purchase. Default `/i/events/:slug/finalise`. */
48
49
  finalisePath?: (slug: string, orderId: string) => string;
@@ -53,21 +54,25 @@ export interface EventTicketsProps {
53
54
  * code sites mount theirs under `/i`. The sign-in returns them here.
54
55
  */
55
56
  loginPath?: string;
56
- /** Where a members-only tier sends a signed-in NON-member the tier listing. */
57
+ /** Where a members-only tier sends a signed-in NON-member: the tier listing. */
57
58
  membershipPath?: string;
58
59
  }
59
60
 
60
61
  /** Append an alpha to a 6-digit hex color (mirrors frontend-shared addAlphaToHexCode). */
61
- const a = (hex: string, alpha: number) => hex + Math.round(alpha * 255).toString(16).padStart(2, "0");
62
+ const a = (hex: string, alpha: number) =>
63
+ hex +
64
+ Math.round(alpha * 255)
65
+ .toString(16)
66
+ .padStart(2, "0");
62
67
 
63
68
  /**
64
- * Themed event ticket purchase a "Buy tickets" trigger + modal running the
69
+ * Themed event ticket purchase: a "Buy tickets" trigger + modal running the
65
70
  * three-step flow (tickets → details → payment) on `useEventCheckout`. Port of
66
71
  * the client app's EventTickets modal, styled from `ForgeThemeProvider`.
67
72
  */
68
73
  export function EventTickets({
69
74
  slug,
70
- triggerText = "Buy tickets",
75
+ triggerText,
71
76
  formatAmount,
72
77
  renderPayment,
73
78
  finalisePath,
@@ -75,6 +80,7 @@ export function EventTickets({
75
80
  loginPath,
76
81
  membershipPath,
77
82
  }: EventTicketsProps) {
83
+ const t = useForgeT();
78
84
  const theme = useForgeTheme();
79
85
  const registered = usePaymentRenderer();
80
86
  const fmt = useAmountFormatter(formatAmount);
@@ -99,7 +105,7 @@ export function EventTickets({
99
105
 
100
106
  const tryClose = (next: boolean) => {
101
107
  if (!next && c.step !== "tickets" && typeof window !== "undefined") {
102
- if (!window.confirm("Are you sure you want to leave checkout?")) return;
108
+ if (!window.confirm(t("forge.event_tickets.leave_confirm"))) return;
103
109
  }
104
110
  setOpen(next);
105
111
  };
@@ -107,7 +113,7 @@ export function EventTickets({
107
113
  return (
108
114
  <DialogRoot open={open} onOpenChange={tryClose}>
109
115
  <DialogTrigger asChild>
110
- <button style={triggerButtonStyle(theme)}>{triggerText}</button>
116
+ <button style={triggerButtonStyle(theme)}>{triggerText ?? t("forge.event_tickets.trigger")}</button>
111
117
  </DialogTrigger>
112
118
  <DialogPortal>
113
119
  <DialogOverlay style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", zIndex: 50 }} />
@@ -130,16 +136,18 @@ export function EventTickets({
130
136
  aria-describedby={undefined}
131
137
  >
132
138
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
133
- <DialogTitle style={{ fontSize: 18, fontWeight: 700, margin: 0 }}>{c.event?.title ?? "Tickets"}</DialogTitle>
139
+ <DialogTitle style={{ fontSize: 18, fontWeight: 700, margin: 0 }}>
140
+ {c.event?.title ?? t("forge.event_tickets.title")}
141
+ </DialogTitle>
134
142
  <DialogClose asChild>
135
- <button aria-label="Close" style={closeButtonStyle(theme)}>
143
+ <button aria-label={t("forge.event_tickets.aria_close")} style={closeButtonStyle(theme)}>
136
144
  <X size={18} />
137
145
  </button>
138
146
  </DialogClose>
139
147
  </div>
140
148
 
141
149
  {c.isLoading && <Loading />}
142
- {!c.isLoading && !c.event && <p style={{ marginTop: 8 }}>Event not found.</p>}
150
+ {!c.isLoading && !c.event && <p style={{ marginTop: 8 }}>{t("forge.event_tickets.not_found")}</p>}
143
151
  {c.event && (
144
152
  <EventTicketsBody
145
153
  c={c}
@@ -273,6 +281,7 @@ function TicketStep({
273
281
  loginPath?: string;
274
282
  membershipPath?: string;
275
283
  }) {
284
+ const t = useForgeT();
276
285
  const theme = useForgeTheme();
277
286
  // Inclusive stores caption ticket prices as tax-inclusive (display only).
278
287
  const pricesIncludeTax = usePricesIncludeTax();
@@ -286,21 +295,55 @@ function TicketStep({
286
295
  const urgency = (expiresAt?: string) => {
287
296
  if (!expiresAt) return null;
288
297
  const diff = new Date(expiresAt).getTime() - now;
289
- if (diff <= 0) return { expired: true, color: "#ef4444", alert: true, message: "This ticket has expired" };
298
+ if (diff <= 0)
299
+ return { expired: true, color: "#ef4444", alert: true, message: t("forge.event_tickets.expired_badge") };
290
300
  const hours = Math.floor(diff / 3_600_000);
291
301
  const minutes = Math.floor((diff % 3_600_000) / 60_000);
292
302
  const totalMinutes = hours * 60 + minutes;
293
- if (totalMinutes < 30) return { expired: false, color: "#dc2626", alert: true, message: `⚠️ Only ${minutes} minute${minutes !== 1 ? "s" : ""} left!` };
294
- if (totalMinutes < 60) return { expired: false, color: "#f59e0b", alert: true, message: "⏰ Less than 1 hour remaining!" };
295
- if (hours < 6) return { expired: false, color: "#f97316", alert: false, message: `⏰ Expires in ${hours} hour${hours !== 1 ? "s" : ""}!` };
296
- if (hours < 24) return { expired: false, color: theme.colors.primary, alert: false, message: `Expires in ${hours} hours` };
303
+ if (totalMinutes < 30)
304
+ return {
305
+ expired: false,
306
+ color: "#dc2626",
307
+ alert: true,
308
+ message:
309
+ minutes !== 1
310
+ ? t("forge.event_tickets.minutes_left_other", { count: minutes })
311
+ : t("forge.event_tickets.minutes_left_one", { count: minutes }),
312
+ };
313
+ if (totalMinutes < 60)
314
+ return { expired: false, color: "#f59e0b", alert: true, message: t("forge.event_tickets.less_than_hour") };
315
+ if (hours < 6)
316
+ return {
317
+ expired: false,
318
+ color: "#f97316",
319
+ alert: false,
320
+ message:
321
+ hours !== 1
322
+ ? t("forge.event_tickets.expires_hours_soon_other", { count: hours })
323
+ : t("forge.event_tickets.expires_hours_soon_one", { count: hours }),
324
+ };
325
+ if (hours < 24)
326
+ return {
327
+ expired: false,
328
+ color: theme.colors.primary,
329
+ alert: false,
330
+ message: t("forge.event_tickets.expires_hours", { count: hours }),
331
+ };
297
332
  const days = Math.floor(hours / 24);
298
- return { expired: false, color: theme.colors.primary, alert: false, message: `Expires in ${days} day${days !== 1 ? "s" : ""}` };
333
+ return {
334
+ expired: false,
335
+ color: theme.colors.primary,
336
+ alert: false,
337
+ message:
338
+ days !== 1
339
+ ? t("forge.event_tickets.expires_days_other", { count: days })
340
+ : t("forge.event_tickets.expires_days_one", { count: days }),
341
+ };
299
342
  };
300
343
 
301
344
  const setQty = (ticket: ITicket, qty: number) => {
302
345
  // `maxAddableTickets` subtracts `held` and returns 0 before a scheduled
303
- // on-sale the same two facts the server sells on. Computed off `now` so
346
+ // on-sale, the same two facts the server sells on. Computed off `now` so
304
347
  // the cap opens up on the tick the on-sale time passes, with no reload.
305
348
  //
306
349
  // 2.6b adds back the seats held for THIS visitor by a waitlist offer.
@@ -314,14 +357,14 @@ function TicketStep({
314
357
 
315
358
  return (
316
359
  <div>
317
- <h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 16 }}>Select Tickets</h2>
360
+ <h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 16 }}>{t("forge.event_tickets.select_title")}</h2>
318
361
  {/* Renders nothing on an event with no coded tier (1.2). Here as well as
319
- on the page because this modal is reachable on its own from an offer,
320
- and from the broadcast-pass prompt where there is no page-level box.
362
+ on the page because this modal is reachable on its own (from an offer,
363
+ and from the broadcast-pass prompt) where there is no page-level box.
321
364
  Both read the SAME stored code, so unlocking in one unlocks the other. */}
322
365
  <PresaleCodeField presale={c.presale} style={{ marginBottom: 16 }} />
323
366
  <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
324
- {event.tickets.length === 0 && <p style={{ opacity: 0.7 }}>No tickets available.</p>}
367
+ {event.tickets.length === 0 && <p style={{ opacity: 0.7 }}>{t("forge.event_tickets.no_tickets")}</p>}
325
368
  {event.tickets.map((ticket) => {
326
369
  const selectedQty = c.selectedTickets[ticket.id] ?? 0;
327
370
  /**
@@ -330,7 +373,7 @@ function TicketStep({
330
373
  * checkouts as available, so a fan in a rush was told "3 left",
331
374
  * picked two, filled in the form and was refused at reserve.
332
375
  */
333
- // 2.6b the offeree's own reservation is not "somebody else's
376
+ // 2.6b: the offeree's own reservation is not "somebody else's
334
377
  // checkout"; it is the seat they were emailed a deadline for.
335
378
  const reserved = c.reservedForYou[ticket.id] ?? 0;
336
379
  const seatState = ticketSeatState(ticket, reserved);
@@ -350,8 +393,8 @@ function TicketStep({
350
393
  /**
351
394
  * A members-only tier the buyer cannot have (S.4).
352
395
  *
353
- * The event-level gate applies to EVERY tier "no ticket to this show
354
- * sells to a non-member" so it is folded in here rather than drawn
396
+ * The event-level gate applies to EVERY tier ("no ticket to this show
397
+ * sells to a non-member"), so it is folded in here rather than drawn
355
398
  * once at the top: a buyer reading one tier row must be able to tell
356
399
  * from that row whether they can buy it.
357
400
  */
@@ -370,11 +413,13 @@ function TicketStep({
370
413
  opacity: isSoldOut || isExpired ? 0.5 : 1,
371
414
  }}
372
415
  >
373
- <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 8 }}>
416
+ <div
417
+ style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 8 }}
418
+ >
374
419
  <div style={{ flex: 1 }}>
375
420
  <h3 style={{ fontSize: 17, fontWeight: 600, marginBottom: 6 }}>{ticket.title}</h3>
376
421
  {/* A hidden tier is only ever on screen because a code
377
- revealed it so say so. Without the badge, a code that
422
+ revealed it, so say so. Without the badge, a code that
378
423
  unlocked a tier the buyer had not seen listed looks like it
379
424
  did nothing. */}
380
425
  {ticket.isHidden && (
@@ -396,7 +441,7 @@ function TicketStep({
396
441
  }}
397
442
  >
398
443
  <KeyRound size={12} />
399
- Presale
444
+ {t("forge.event_tickets.presale_badge")}
400
445
  </div>
401
446
  )}
402
447
  {/* A tier that is announced but not yet buyable says WHEN,
@@ -448,23 +493,39 @@ function TicketStep({
448
493
  )}
449
494
  <div style={{ fontSize: 22, fontWeight: 700, color: theme.colors.primary }}>
450
495
  {ticket.compareAtPrice != null && Number(ticket.compareAtPrice) > Number(ticket.price) && (
451
- <span style={{ textDecoration: "line-through", opacity: 0.6, marginRight: 8, fontWeight: 400, fontSize: 16 }}>
496
+ <span
497
+ style={{
498
+ textDecoration: "line-through",
499
+ opacity: 0.6,
500
+ marginRight: 8,
501
+ fontWeight: 400,
502
+ fontSize: 16,
503
+ }}
504
+ >
452
505
  {fmt(Number(ticket.compareAtPrice))}
453
506
  </span>
454
507
  )}
455
508
  {/* On a PWYW tier `price` is the FLOOR, so it is labelled as
456
- one showing it bare would read as a fixed price the
509
+ one. Showing it bare would read as a fixed price the
457
510
  buyer is about to be charged. */}
458
511
  {isPayWhatYouWant(ticket) && (
459
- <span style={{ fontSize: 14, fontWeight: 500, opacity: 0.7, marginRight: 6 }}>from</span>
512
+ <span style={{ fontSize: 14, fontWeight: 500, opacity: 0.7, marginRight: 6 }}>
513
+ {t("forge.event_tickets.price_from")}
514
+ </span>
460
515
  )}
461
- <PriceDisplay amount={Number(ticket.price)} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={theme.colors.text} captionStyle={{ opacity: 0.65 }} />
516
+ <PriceDisplay
517
+ amount={Number(ticket.price)}
518
+ pricesIncludeTax={pricesIncludeTax}
519
+ formatAmount={fmt}
520
+ mutedColor={theme.colors.text}
521
+ captionStyle={{ opacity: 0.65 }}
522
+ />
462
523
  </div>
463
524
  </div>
464
525
  <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
465
526
  <button
466
527
  onClick={() => setQty(ticket, selectedQty - 1)}
467
- // Never disabled by availability only by there being
528
+ // Never disabled by availability, only by there being
468
529
  // nothing to remove. A buyer holding two seats on a tier
469
530
  // that has just gone unavailable must still be able to put
470
531
  // them back; disabling this would trap the cart.
@@ -481,7 +542,7 @@ function TicketStep({
481
542
  // A gated tier cannot be added, nor one whose on-sale time
482
543
  // has not arrived, nor one whose remaining seats are inside
483
544
  // somebody else's checkout. Not because the client is the
484
- // authority `createOrder` refuses all three regardless
545
+ // authority (`createOrder` refuses all three regardless)
485
546
  // but because letting a buyer build a cart the server will
486
547
  // not sell is precisely the "discoverable only by failing"
487
548
  // journey this change exists to end.
@@ -492,8 +553,8 @@ function TicketStep({
492
553
  </button>
493
554
  </div>
494
555
  </div>
495
- {/* The amount box appears only once a seat is actually selected
496
- before that there is nothing to price, and a row of empty
556
+ {/* The amount box appears only once a seat is actually selected.
557
+ Before that there is nothing to price, and a row of empty
497
558
  inputs down the tier list reads as a form to fill in rather
498
559
  than a choice to make. */}
499
560
  {isPayWhatYouWant(ticket) && selectedQty > 0 && !isSoldOut && !isExpired && !isScheduled && (
@@ -510,13 +571,17 @@ function TicketStep({
510
571
  <p dangerouslySetInnerHTML={{ __html: ticket.description }} style={{ marginTop: 12, fontSize: 14 }} />
511
572
  )}
512
573
  {/* Named on the TIER, before the buyer has typed anything. The
513
- tier stays in the list on purpose hiding it kills the upsell
514
- so this is what turns "visible but mysteriously unbuyable"
574
+ tier stays in the list on purpose (hiding it kills the upsell)
575
+ so this is what turns "visible but mysteriously unbuyable"
515
576
  into an offer with a way in. */}
516
577
  <MembershipGateNotice
517
578
  gate={gate}
518
579
  variant="badge"
519
- itemLabel={event.membershipGate && !event.membershipGate.allowed ? "This event" : "This ticket"}
580
+ itemLabel={
581
+ event.membershipGate && !event.membershipGate.allowed
582
+ ? t("forge.event_tickets.item_label_event")
583
+ : t("forge.event_tickets.item_label_ticket")
584
+ }
520
585
  loginPath={loginPath}
521
586
  membershipPath={membershipPath}
522
587
  />
@@ -555,7 +620,7 @@ function TicketStep({
555
620
  }}
556
621
  >
557
622
  <AlertCircle size={16} />
558
- This ticket has expired and is no longer available
623
+ {t("forge.event_tickets.expired_notice")}
559
624
  </div>
560
625
  )}
561
626
  </div>
@@ -576,18 +641,24 @@ function TicketStep({
576
641
  onNext={() => {
577
642
  if (c.addTicketsToCart()) onAddedToCart?.();
578
643
  }}
579
- nextLabel={c.ticketsInCart ? "Update cart & choose add-ons" : "Continue to add-ons"}
644
+ nextLabel={
645
+ c.ticketsInCart ? t("forge.event_tickets.update_cart_addons") : t("forge.event_tickets.continue_addons")
646
+ }
580
647
  nextTestId="tickets-add-to-cart"
581
- secondary={{ label: "Checkout tickets only", onClick: c.goToDetails, testId: "tickets-checkout-only" }}
648
+ secondary={{
649
+ label: t("forge.event_tickets.checkout_tickets_only"),
650
+ onClick: c.goToDetails,
651
+ testId: "tickets-checkout-only",
652
+ }}
582
653
  />
583
654
  ) : (
584
655
  <ActionBar
585
656
  c={c}
586
657
  fmt={fmt}
587
658
  onNext={c.goToDetails}
588
- nextLabel="Continue"
659
+ nextLabel={t("forge.event_tickets.continue")}
589
660
  secondary={{
590
- label: c.ticketsInCart ? "Update cart" : "Add to cart",
661
+ label: c.ticketsInCart ? t("forge.event_tickets.update_cart") : t("forge.event_tickets.add_to_cart"),
591
662
  onClick: () => {
592
663
  if (c.addTicketsToCart()) onAddedToCart?.();
593
664
  },
@@ -613,6 +684,7 @@ function DetailsStep({
613
684
  loginPath?: string;
614
685
  membershipPath?: string;
615
686
  }) {
687
+ const t = useForgeT();
616
688
  const theme = useForgeTheme();
617
689
  const [confirmEmail, setConfirmEmail] = useState("");
618
690
  const [answers, setAnswers] = useState<Record<string, string>>({});
@@ -628,30 +700,57 @@ function DetailsStep({
628
700
 
629
701
  const onContinue = () => {
630
702
  setLocalError(null);
631
- if (!c.firstName.trim() || !c.lastName.trim()) return setLocalError("Please enter your name.");
632
- if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email)) return setLocalError("Please enter a valid email.");
633
- if (c.email !== confirmEmail) return setLocalError("Emails do not match.");
703
+ if (!c.firstName.trim() || !c.lastName.trim()) return setLocalError(t("forge.event_tickets.error_name"));
704
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email)) return setLocalError(t("forge.event_tickets.error_email"));
705
+ if (c.email !== confirmEmail) return setLocalError(t("forge.event_tickets.error_email_mismatch"));
634
706
  for (const q of questions) {
635
- if (!q.optional && !(answers[q.id] ?? "").trim()) return setLocalError(`Please answer: ${q.question}`);
707
+ if (!q.optional && !(answers[q.id] ?? "").trim())
708
+ return setLocalError(t("forge.event_tickets.error_answer_required", { question: q.question }));
636
709
  }
637
710
  c.continueToPayment();
638
711
  };
639
712
 
640
713
  return (
641
714
  <div>
642
- <h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 16 }}>Your details</h2>
715
+ <h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 16 }}>{t("forge.event_tickets.details_title")}</h2>
643
716
  <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
644
717
  <div style={{ display: "flex", gap: 8 }}>
645
- <input placeholder="First name" value={c.firstName} onChange={(e) => c.setFirstName(e.target.value)} style={inputStyle(theme)} />
646
- <input placeholder="Last name" value={c.lastName} onChange={(e) => c.setLastName(e.target.value)} style={inputStyle(theme)} />
718
+ <input
719
+ placeholder={t("forge.event_tickets.first_name_placeholder")}
720
+ value={c.firstName}
721
+ onChange={(e) => c.setFirstName(e.target.value)}
722
+ style={inputStyle(theme)}
723
+ />
724
+ <input
725
+ placeholder={t("forge.event_tickets.last_name_placeholder")}
726
+ value={c.lastName}
727
+ onChange={(e) => c.setLastName(e.target.value)}
728
+ style={inputStyle(theme)}
729
+ />
647
730
  </div>
648
731
  <div>
649
- <input placeholder="Email" type="email" value={c.email} onChange={(e) => c.setEmail(e.target.value)} style={inputStyle(theme)} />
650
- {c.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email) && <FieldError>Please enter a valid email address</FieldError>}
732
+ <input
733
+ placeholder={t("forge.event_tickets.email_placeholder")}
734
+ type="email"
735
+ value={c.email}
736
+ onChange={(e) => c.setEmail(e.target.value)}
737
+ style={inputStyle(theme)}
738
+ />
739
+ {c.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email) && (
740
+ <FieldError>{t("forge.event_tickets.invalid_email")}</FieldError>
741
+ )}
651
742
  </div>
652
743
  <div>
653
- <input placeholder="Confirm email" type="email" value={confirmEmail} onChange={(e) => setConfirmEmail(e.target.value)} style={inputStyle(theme)} />
654
- {confirmEmail && c.email !== confirmEmail && <FieldError>Email addresses do not match</FieldError>}
744
+ <input
745
+ placeholder={t("forge.event_tickets.confirm_email_placeholder")}
746
+ type="email"
747
+ value={confirmEmail}
748
+ onChange={(e) => setConfirmEmail(e.target.value)}
749
+ style={inputStyle(theme)}
750
+ />
751
+ {confirmEmail && c.email !== confirmEmail && (
752
+ <FieldError>{t("forge.event_tickets.email_mismatch")}</FieldError>
753
+ )}
655
754
  </div>
656
755
 
657
756
  {questions.map((q) => {
@@ -663,8 +762,12 @@ function DetailsStep({
663
762
  {!q.optional && <span style={{ color: "#ef4444" }}> *</span>}
664
763
  </span>
665
764
  {q.options && q.options.length > 0 ? (
666
- <select value={answers[q.id] ?? ""} onChange={(e) => onAnswer(q.id, e.target.value)} style={inputStyle(theme)}>
667
- <option value="">Select…</option>
765
+ <select
766
+ value={answers[q.id] ?? ""}
767
+ onChange={(e) => onAnswer(q.id, e.target.value)}
768
+ style={inputStyle(theme)}
769
+ >
770
+ <option value="">{t("forge.event_tickets.select_option")}</option>
668
771
  {q.options.map((opt) => (
669
772
  <option key={opt} value={opt}>
670
773
  {opt}
@@ -672,15 +775,19 @@ function DetailsStep({
672
775
  ))}
673
776
  </select>
674
777
  ) : (
675
- <input value={answers[q.id] ?? ""} onChange={(e) => onAnswer(q.id, e.target.value)} style={inputStyle(theme)} />
778
+ <input
779
+ value={answers[q.id] ?? ""}
780
+ onChange={(e) => onAnswer(q.id, e.target.value)}
781
+ style={inputStyle(theme)}
782
+ />
676
783
  )}
677
- {missing && <FieldError>This field is required</FieldError>}
784
+ {missing && <FieldError>{t("forge.event_tickets.field_required")}</FieldError>}
678
785
  </label>
679
786
  );
680
787
  })}
681
788
 
682
789
  {/* Tickets have no apply-coupon endpoint, so the code is carried into
683
- the order that "Continue" creates hence `deferred`. Its result is
790
+ the order that "Continue" creates, hence `deferred`. Its result is
684
791
  what draws the discount line on the payment step. */}
685
792
  {c.subTotal > 0 && (
686
793
  <div style={{ marginTop: 4, paddingTop: 12, borderTop: `1px solid ${a(theme.colors.primary, 0.13)}` }}>
@@ -690,14 +797,14 @@ function DetailsStep({
690
797
 
691
798
  {/* The refusal path. It should be unreachable now that the tier itself
692
799
  is badged, but a membership can lapse mid-checkout and a page can be
693
- served from cache and when it happens the buyer gets the tier's
800
+ served from cache, and when it happens the buyer gets the tier's
694
801
  name and a way in, not a red line reading "Something went wrong."
695
802
  `c.error` is deliberately not set for this case, so the two cannot
696
803
  both draw. */}
697
804
  <MembershipGateNotice
698
805
  refusal={c.gateRefusal}
699
806
  variant="card"
700
- itemLabel="This ticket"
807
+ itemLabel={t("forge.event_tickets.item_label_ticket")}
701
808
  loginPath={loginPath}
702
809
  membershipPath={membershipPath}
703
810
  style={{ marginTop: 4 }}
@@ -706,10 +813,9 @@ function DetailsStep({
706
813
  {c.attendeeSlots.length > 0 && (
707
814
  <div style={{ display: "flex", flexDirection: "column", gap: 12, marginTop: 4 }}>
708
815
  <div>
709
- <h3 style={{ fontSize: 16, fontWeight: 700 }}>Who is coming?</h3>
816
+ <h3 style={{ fontSize: 16, fontWeight: 700 }}>{t("forge.event_tickets.attendees_title")}</h3>
710
817
  <p style={{ fontSize: 13, opacity: 0.75, marginTop: 4 }}>
711
- This event prints each attendee&rsquo;s name on their own pass, so the door list matches who
712
- actually turns up.
818
+ {t("forge.event_tickets.attendees_description")}
713
819
  </p>
714
820
  </div>
715
821
  {c.attendeeSlots.map((slot) => (
@@ -719,10 +825,10 @@ function DetailsStep({
719
825
  >
720
826
  <span>
721
827
  {slot.label}
722
- {slot.isBuyer && <span style={{ opacity: 0.6 }}> (that&rsquo;s you)</span>}
828
+ {slot.isBuyer && <span style={{ opacity: 0.6 }}> {t("forge.event_tickets.attendee_is_you")}</span>}
723
829
  </span>
724
830
  <input
725
- placeholder="Full name"
831
+ placeholder={t("forge.event_tickets.attendee_name_placeholder")}
726
832
  value={slot.value}
727
833
  onChange={(e) => c.setAttendee(slot.ticketId, slot.index, e.target.value)}
728
834
  style={inputStyle(theme)}
@@ -745,7 +851,13 @@ function DetailsStep({
745
851
  // fee and any discount: a free ticket with a FLAT booking fee still
746
852
  // costs money and still goes to payment, and a 100%-off code on a paid
747
853
  // ticket does not.
748
- nextLabel={c.isProcessing ? "Processing…" : c.totalAmount > 0 ? "Continue to payment" : "Confirm"}
854
+ nextLabel={
855
+ c.isProcessing
856
+ ? t("forge.event_tickets.processing")
857
+ : c.totalAmount > 0
858
+ ? t("forge.event_tickets.continue_payment")
859
+ : t("forge.event_tickets.confirm")
860
+ }
749
861
  nextDisabled={c.isProcessing}
750
862
  />
751
863
  </div>
@@ -762,6 +874,7 @@ function PaymentStep({
762
874
  fmt: (n: number) => string;
763
875
  renderPayment?: (props: PaymentRenderProps) => ReactNode;
764
876
  }) {
877
+ const t = useForgeT();
765
878
  const theme = useForgeTheme();
766
879
  // Authoritative tax quote from start-payment: exclusive stores get a "Tax"
767
880
  // row (already contained in the charged total below); inclusive stores get a
@@ -769,10 +882,10 @@ function PaymentStep({
769
882
  const taxSummary = summarizeTaxQuote(c.taxQuote);
770
883
  return (
771
884
  <div>
772
- <h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>Payment</h2>
773
- <p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>Complete your purchase to secure your tickets.</p>
774
- {/* The reservation clock. Event ticket holds are taken unconditionally
775
- no feature switch so this is the one checkout where the countdown
885
+ <h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>{t("forge.event_tickets.payment_title")}</h2>
886
+ <p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>{t("forge.event_tickets.payment_subtitle")}</p>
887
+ {/* The reservation clock. Event ticket holds are taken unconditionally
888
+ (no feature switch), so this is the one checkout where the countdown
776
889
  fires for every profile, and it was the one that did not render it. */}
777
890
  <HoldNotice hold={c.hold} expiredMessage={c.error ?? undefined} style={{ marginBottom: 16 }} />
778
891
  <div
@@ -783,7 +896,7 @@ function PaymentStep({
783
896
  marginBottom: 16,
784
897
  }}
785
898
  >
786
- {/* What the code actually did, from the order response never a total
899
+ {/* What the code actually did, from the order response, never a total
787
900
  that silently shrank. Automatic (no-code) discounts land here too. */}
788
901
  <DiscountSummaryLines
789
902
  subTotal={c.subTotal}
@@ -808,27 +921,29 @@ function PaymentStep({
808
921
  fontFamily: theme.fontFamily,
809
922
  }}
810
923
  >
811
- Remove discount
924
+ {t("forge.event_tickets.remove_discount")}
812
925
  </button>
813
926
  </div>
814
927
  )}
815
928
  {/* Above the tax row, because tax is charged ON the fee: the order total
816
929
  the tax quote priced already contained it. By this step the number is
817
- the SERVER's own what it wrote to the order so the itemisation
930
+ the SERVER's own (what it wrote to the order), so the itemisation
818
931
  adds up to the amount that will actually be charged. */}
819
932
  <BookingFeeLine c={c} fmt={fmt} />
820
933
  {taxSummary?.mode === "exclusive" && (
821
934
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
822
- <span style={{ fontSize: 14, opacity: 0.75 }}>Tax</span>
935
+ <span style={{ fontSize: 14, opacity: 0.75 }}>{t("forge.event_tickets.tax")}</span>
823
936
  <span style={{ fontSize: 14, opacity: 0.75 }}>{taxSummary.formattedTax}</span>
824
937
  </div>
825
938
  )}
826
939
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
827
- <span style={{ fontSize: 16 }}>Total Amount</span>
940
+ <span style={{ fontSize: 16 }}>{t("forge.event_tickets.total_amount")}</span>
828
941
  <span style={{ fontSize: 22, fontWeight: 700, color: theme.colors.primary, textAlign: "right" }}>
829
942
  {fmt(c.totalAmount)}
830
943
  {taxSummary?.mode === "inclusive" && (
831
- <span style={{ display: "block", fontSize: 12, fontWeight: 400, color: theme.colors.text, opacity: 0.65 }}>
944
+ <span
945
+ style={{ display: "block", fontSize: 12, fontWeight: 400, color: theme.colors.text, opacity: 0.65 }}
946
+ >
832
947
  {taxSummary.label}
833
948
  </span>
834
949
  )}
@@ -839,19 +954,22 @@ function PaymentStep({
839
954
  so it is checked BEFORE the "not configured" branch. */}
840
955
  {c.isPaystack ? (
841
956
  c.canOpenPaystack ? (
842
- <PaystackPayButton onPay={c.openPaystackCheckout} label={`Pay ${fmt(c.totalAmount)}`} />
957
+ <PaystackPayButton
958
+ onPay={c.openPaystackCheckout}
959
+ label={t("forge.event_tickets.pay", { amount: fmt(c.totalAmount) })}
960
+ />
843
961
  ) : (
844
- <p style={{ opacity: 0.7 }}>Preparing payment…</p>
962
+ <p style={{ opacity: 0.7 }}>{t("forge.event_tickets.preparing_payment")}</p>
845
963
  )
846
964
  ) : !renderPayment ? (
847
- <p style={{ color: "#ef4444", fontSize: 14 }}>Payment is not configured.</p>
965
+ <p style={{ color: "#ef4444", fontSize: 14 }}>{t("forge.event_tickets.payment_not_configured")}</p>
848
966
  ) : c.clientSecret ? (
849
967
  renderPayment({ clientSecret: c.clientSecret, returnUrl: c.returnUrl, amount: c.totalAmount, mode: "redirect" })
850
968
  ) : (
851
- <p style={{ opacity: 0.7 }}>Preparing payment…</p>
969
+ <p style={{ opacity: 0.7 }}>{t("forge.event_tickets.preparing_payment")}</p>
852
970
  )}
853
971
  <button onClick={() => c.setStep("details")} style={{ ...secondaryButtonStyle(theme), marginTop: 16 }}>
854
- Back
972
+ {t("forge.event_tickets.back")}
855
973
  </button>
856
974
  </div>
857
975
  );
@@ -866,7 +984,7 @@ function FieldError({ children }: { children: ReactNode }) {
866
984
  *
867
985
  * This is the line that did not exist. The fee was added to `total_amount` on
868
986
  * the server and named on no surface, so a buyer choosing £50 of tickets was
869
- * shown "Total £50" and charged £52.75 and even after a discount, where
987
+ * shown "Total £50" and charged £52.75, and even after a discount, where
870
988
  * `Subtotal` and `Discount` were both drawn, the total did not reconcile with
871
989
  * them and nothing explained why. A mandatory charge disclosed only on the
872
990
  * receipt is what the FTC's junk-fee rule, the CMA's guidance and the EU
@@ -874,7 +992,7 @@ function FieldError({ children }: { children: ReactNode }) {
874
992
  *
875
993
  * Renders NOTHING when the fee is zero. The payload always arrives (so callers
876
994
  * can rely on its shape), which means "this artist charges no fee" comes down as
877
- * a zero and a "+£0.00 booking fee" row would invent a charge that does not
995
+ * a zero, and a "+£0.00 booking fee" row would invent a charge that does not
878
996
  * exist and hint that one might appear later. There is nothing to disclose when
879
997
  * nothing is charged.
880
998
  *
@@ -884,6 +1002,7 @@ function FieldError({ children }: { children: ReactNode }) {
884
1002
  * ON.
885
1003
  */
886
1004
  function BookingFeeLine({ c, fmt }: { c: Checkout; fmt: (n: number) => string }) {
1005
+ const t = useForgeT();
887
1006
  const theme = useForgeTheme();
888
1007
  if (!(c.bookingFeeAmount > 0)) return null;
889
1008
  const discountDrewSubtotal = c.coupon.discountAmount > 0;
@@ -895,15 +1014,12 @@ function BookingFeeLine({ c, fmt }: { c: Checkout; fmt: (n: number) => string })
895
1014
  >
896
1015
  {!discountDrewSubtotal && (
897
1016
  <div style={{ display: "flex", justifyContent: "space-between", fontSize: 14 }}>
898
- <span style={{ opacity: 0.75 }}>Subtotal</span>
1017
+ <span style={{ opacity: 0.75 }}>{t("forge.event_tickets.subtotal")}</span>
899
1018
  <span style={{ opacity: 0.75 }}>{fmt(c.subTotal)}</span>
900
1019
  </div>
901
1020
  )}
902
- <div
903
- data-testid="booking-fee-line"
904
- style={{ display: "flex", justifyContent: "space-between", fontSize: 14 }}
905
- >
906
- <span style={{ color: theme.colors.text, opacity: 0.75 }}>Booking fee</span>
1021
+ <div data-testid="booking-fee-line" style={{ display: "flex", justifyContent: "space-between", fontSize: 14 }}>
1022
+ <span style={{ color: theme.colors.text, opacity: 0.75 }}>{t("forge.event_tickets.booking_fee")}</span>
907
1023
  <span style={{ color: theme.colors.text, opacity: 0.75 }}>{fmt(c.bookingFeeAmount)}</span>
908
1024
  </div>
909
1025
  </div>
@@ -933,6 +1049,7 @@ function ActionBar({
933
1049
  /** Extra exit shown next to Back on the selection step. */
934
1050
  secondary?: { label: string; onClick: () => void; testId?: string };
935
1051
  }) {
1052
+ const t = useForgeT();
936
1053
  const theme = useForgeTheme();
937
1054
  return (
938
1055
  <div style={{ marginTop: 24 }}>
@@ -951,11 +1068,11 @@ function ActionBar({
951
1068
  fmt={fmt}
952
1069
  />
953
1070
  {/* `Total` below is the figure the buyer reads as final while they are
954
- still choosing quantities so the fee has to be named HERE, not
1071
+ still choosing quantities, so the fee has to be named HERE, not
955
1072
  three screens later. */}
956
1073
  <BookingFeeLine c={c} fmt={fmt} />
957
1074
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
958
- <span style={{ fontWeight: 600 }}>Total</span>
1075
+ <span style={{ fontWeight: 600 }}>{t("forge.event_tickets.total")}</span>
959
1076
  <span style={{ fontSize: 18, fontWeight: 700, color: theme.colors.primary }}>{fmt(c.totalAmount)}</span>
960
1077
  </div>
961
1078
  </div>
@@ -965,8 +1082,12 @@ function ActionBar({
965
1082
  {secondary.label}
966
1083
  </button>
967
1084
  ) : (
968
- <button onClick={onBack} disabled={!onBack} style={{ ...secondaryButtonStyle(theme), opacity: onBack ? 1 : 0.4 }}>
969
- Back
1085
+ <button
1086
+ onClick={onBack}
1087
+ disabled={!onBack}
1088
+ style={{ ...secondaryButtonStyle(theme), opacity: onBack ? 1 : 0.4 }}
1089
+ >
1090
+ {t("forge.event_tickets.back")}
970
1091
  </button>
971
1092
  )}
972
1093
  <button
@@ -1007,7 +1128,7 @@ const closeButtonStyle = (t: ForgeTheme): CSSProperties => ({
1007
1128
  * to the floor on every keystroke), and "1" on the way to "15" must not be
1008
1129
  * clamped up to the minimum the instant it is typed.
1009
1130
  *
1010
- * Correction happens on BLUR, when the buyer has finished and the server
1131
+ * Correction happens on BLUR, when the buyer has finished, and the server
1011
1132
  * clamps again regardless, so nothing here is load-bearing for money.
1012
1133
  */
1013
1134
  function PwywAmountField({
@@ -1025,12 +1146,13 @@ function PwywAmountField({
1025
1146
  fmt: (amount: number) => string;
1026
1147
  theme: ForgeTheme;
1027
1148
  }) {
1149
+ const t = useForgeT();
1028
1150
  const floor = Number(ticket.price);
1029
1151
  const maximum = pwywMaximum(ticket);
1030
1152
  const suggested = pwywDefaultAmount(ticket);
1031
1153
  const [draft, setDraft] = useState<string>(String(value ?? suggested));
1032
1154
 
1033
- // Follow the tier's own default when the buyer hasn't typed anything an
1155
+ // Follow the tier's own default when the buyer hasn't typed anything: an
1034
1156
  // operator's suggested amount arriving late (or the selection being cleared
1035
1157
  // and remade) should re-seed the box rather than leave a stale figure.
1036
1158
  useEffect(() => {
@@ -1055,15 +1177,13 @@ function PwywAmountField({
1055
1177
 
1056
1178
  return (
1057
1179
  <div style={{ marginTop: 12 }}>
1058
- <label
1059
- htmlFor={`pwyw-${ticket.id}`}
1060
- style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 6 }}
1061
- >
1062
- Name your price
1180
+ <label htmlFor={`pwyw-${ticket.id}`} style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 6 }}>
1181
+ {t("forge.event_tickets.pwyw_label")}
1063
1182
  <span style={{ fontWeight: 400, opacity: 0.7 }}>
1064
1183
  {" "}
1065
- (minimum {fmt(floor)}
1066
- {maximum != null ? `, up to ${fmt(maximum)}` : ""})
1184
+ {maximum != null
1185
+ ? t("forge.event_tickets.pwyw_range_min_max", { min: fmt(floor), max: fmt(maximum) })
1186
+ : t("forge.event_tickets.pwyw_range_min", { min: fmt(floor) })}
1067
1187
  </span>
1068
1188
  </label>
1069
1189
  <input
@@ -1080,19 +1200,23 @@ function PwywAmountField({
1080
1200
  />
1081
1201
  <p style={{ fontSize: 12, opacity: 0.7, marginTop: 6 }}>
1082
1202
  {belowFloor
1083
- ? `The minimum is ${fmt(floor)}, so we'll use that.`
1203
+ ? t("forge.event_tickets.pwyw_below_min", { min: fmt(floor) })
1084
1204
  : aboveMax
1085
- ? `The most you can pay is ${fmt(maximum!)}, so we'll use that.`
1205
+ ? t("forge.event_tickets.pwyw_above_max", { max: fmt(maximum!) })
1086
1206
  : quantity > 1
1087
- ? `${fmt(effective)} each · ${fmt(effective * quantity)} for ${quantity}`
1088
- : "Per ticket."}
1207
+ ? t("forge.event_tickets.pwyw_each", {
1208
+ amount: fmt(effective),
1209
+ total: fmt(effective * quantity),
1210
+ count: quantity,
1211
+ })
1212
+ : t("forge.event_tickets.pwyw_per_ticket")}
1089
1213
  </p>
1090
1214
  </div>
1091
1215
  );
1092
1216
  }
1093
1217
 
1094
1218
  /**
1095
- * The inline banner under a tier row same shape as the expired one already
1219
+ * The inline banner under a tier row, same shape as the expired one already
1096
1220
  * below it, so "why can't I buy this?" always answers itself in one place and
1097
1221
  * one visual language, whichever the reason.
1098
1222
  */