@tribe-nest/forge 3.25.0 → 3.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/package.json +1 -1
  2. package/src/client/_tests/localeHeader.spec.tsx +98 -0
  3. package/src/client/activeFunnel.ts +35 -0
  4. package/src/client/activeLocale.ts +34 -0
  5. package/src/client/createForgeClient.ts +22 -0
  6. package/src/contexts/CartContext.tsx +84 -1
  7. package/src/contexts/_tests/CartContext.spec.tsx +210 -0
  8. package/src/data/queries/useCheckouts.ts +83 -0
  9. package/src/data/queries/useForms.ts +48 -3
  10. package/src/data/queries/usePageActions.ts +2 -2
  11. package/src/data/queries/useWebsite.ts +7 -0
  12. package/src/i18n/CONVENTION.md +184 -0
  13. package/src/i18n/_tests/translationKeys.spec.ts +153 -0
  14. package/src/i18n/de.json +960 -0
  15. package/src/i18n/en.json +960 -0
  16. package/src/i18n/index.ts +125 -0
  17. package/src/index.ts +21 -0
  18. package/src/provider/ForgeAppProvider.tsx +2 -1
  19. package/src/provider/SiteConfigProvider.tsx +11 -0
  20. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
  21. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
  22. package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
  23. package/src/ui/headless/checkout/useCartResume.ts +340 -0
  24. package/src/ui/headless/checkout/useCheckout.ts +59 -14
  25. package/src/ui/headless/event/useEventCheckout.ts +3 -0
  26. package/src/ui/headless/funnel/Funnel.tsx +13 -0
  27. package/src/ui/headless/index.ts +18 -0
  28. package/src/ui/index.ts +2 -0
  29. package/src/ui/shell/TribeNestApp.tsx +36 -27
  30. package/src/ui/styled/AccountDashboard.tsx +197 -93
  31. package/src/ui/styled/AiAgentWidget.tsx +10 -6
  32. package/src/ui/styled/AudioPlayer.tsx +14 -6
  33. package/src/ui/styled/BlogCategory.tsx +4 -2
  34. package/src/ui/styled/BlogComments.tsx +27 -20
  35. package/src/ui/styled/BlogList.tsx +5 -3
  36. package/src/ui/styled/BlogPost.tsx +14 -11
  37. package/src/ui/styled/BundleConfirmation.tsx +86 -46
  38. package/src/ui/styled/CancellationTerms.tsx +3 -1
  39. package/src/ui/styled/Cart.tsx +32 -14
  40. package/src/ui/styled/ChatRoom.tsx +65 -20
  41. package/src/ui/styled/Checkout.tsx +189 -88
  42. package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
  43. package/src/ui/styled/CoachingBooking.tsx +71 -44
  44. package/src/ui/styled/CoachingConfirmation.tsx +56 -19
  45. package/src/ui/styled/CoachingDetail.tsx +15 -11
  46. package/src/ui/styled/CohortPage.tsx +10 -8
  47. package/src/ui/styled/ConfirmSubscription.tsx +14 -8
  48. package/src/ui/styled/ContactForm.tsx +31 -12
  49. package/src/ui/styled/CookieConsent.tsx +19 -15
  50. package/src/ui/styled/CourseAccess.tsx +54 -47
  51. package/src/ui/styled/CourseCheckout.tsx +56 -38
  52. package/src/ui/styled/CourseConfirmation.tsx +39 -17
  53. package/src/ui/styled/CourseDetail.tsx +21 -15
  54. package/src/ui/styled/CoursesGrid.tsx +4 -2
  55. package/src/ui/styled/CurrencySwitcher.tsx +4 -1
  56. package/src/ui/styled/DiscountCode.tsx +22 -18
  57. package/src/ui/styled/DonationButton.tsx +52 -17
  58. package/src/ui/styled/DonationPage.tsx +4 -4
  59. package/src/ui/styled/EmailListForm.tsx +22 -9
  60. package/src/ui/styled/EventConfirmation.tsx +37 -14
  61. package/src/ui/styled/EventCountdown.tsx +10 -6
  62. package/src/ui/styled/EventDetail.tsx +8 -6
  63. package/src/ui/styled/EventSeriesDetail.tsx +18 -13
  64. package/src/ui/styled/EventTickets.tsx +311 -125
  65. package/src/ui/styled/EventWaitlist.tsx +42 -33
  66. package/src/ui/styled/EventsList.tsx +15 -13
  67. package/src/ui/styled/ForgotPasswordForm.tsx +12 -12
  68. package/src/ui/styled/FormRenderer.tsx +12 -7
  69. package/src/ui/styled/InstallBanner.tsx +21 -18
  70. package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
  71. package/src/ui/styled/InvoicePayment.tsx +37 -16
  72. package/src/ui/styled/LeadMagnet.tsx +9 -7
  73. package/src/ui/styled/Loading.tsx +7 -1
  74. package/src/ui/styled/LoginForm.tsx +22 -14
  75. package/src/ui/styled/MembershipCheckout.tsx +32 -13
  76. package/src/ui/styled/MembershipTiers.tsx +27 -18
  77. package/src/ui/styled/OfferButton.tsx +33 -13
  78. package/src/ui/styled/PageActions.tsx +13 -9
  79. package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
  80. package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
  81. package/src/ui/styled/PodcastEpisode.tsx +20 -9
  82. package/src/ui/styled/PodcastList.tsx +9 -3
  83. package/src/ui/styled/PodcastShow.tsx +29 -7
  84. package/src/ui/styled/PostsFeed.tsx +10 -8
  85. package/src/ui/styled/PresaleCode.tsx +8 -11
  86. package/src/ui/styled/ProductBrowseNav.tsx +21 -11
  87. package/src/ui/styled/ProductDetail.tsx +58 -28
  88. package/src/ui/styled/ProductGrid.tsx +17 -4
  89. package/src/ui/styled/ReactionBar.tsx +4 -2
  90. package/src/ui/styled/ReplayList.tsx +24 -13
  91. package/src/ui/styled/ResetPasswordForm.tsx +27 -15
  92. package/src/ui/styled/ResumeCart.tsx +289 -0
  93. package/src/ui/styled/ReviewForm.tsx +52 -58
  94. package/src/ui/styled/SignupForm.tsx +29 -16
  95. package/src/ui/styled/TicketTransfer.tsx +55 -54
  96. package/src/ui/styled/UserMenu.tsx +16 -14
  97. package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -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";
@@ -16,6 +17,7 @@ import {
16
17
  ticketSeatState,
17
18
  } from "../format/ticketAvailability";
18
19
  import { usePricesIncludeTax } from "../../data/queries/useWebsite";
20
+ import { usePageActions } from "../../data/queries/usePageActions";
19
21
  import { Loading } from "./Loading";
20
22
  import { PaystackPayButton } from "./PaystackPayButton";
21
23
  import { usePaymentRenderer, type PaymentRenderProps } from "../payment/ForgePaymentProvider";
@@ -41,7 +43,7 @@ export interface EventTicketsProps {
41
43
  triggerText?: string;
42
44
  /** Format a numeric amount for display. */
43
45
  formatAmount?: (amount: number) => string;
44
- /** Optional payment renderer falls back to a registered <ForgePaymentProvider>. */
46
+ /** Optional payment renderer. Falls back to a registered <ForgePaymentProvider>. */
45
47
  renderPayment?: (props: PaymentRenderProps) => ReactNode;
46
48
  /** Stripe return URL for a PAID purchase. Default `/i/events/:slug/finalise`. */
47
49
  finalisePath?: (slug: string, orderId: string) => string;
@@ -52,21 +54,25 @@ export interface EventTicketsProps {
52
54
  * code sites mount theirs under `/i`. The sign-in returns them here.
53
55
  */
54
56
  loginPath?: string;
55
- /** 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. */
56
58
  membershipPath?: string;
57
59
  }
58
60
 
59
61
  /** Append an alpha to a 6-digit hex color (mirrors frontend-shared addAlphaToHexCode). */
60
- 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");
61
67
 
62
68
  /**
63
- * Themed event ticket purchase a "Buy tickets" trigger + modal running the
69
+ * Themed event ticket purchase: a "Buy tickets" trigger + modal running the
64
70
  * three-step flow (tickets → details → payment) on `useEventCheckout`. Port of
65
71
  * the client app's EventTickets modal, styled from `ForgeThemeProvider`.
66
72
  */
67
73
  export function EventTickets({
68
74
  slug,
69
- triggerText = "Buy tickets",
75
+ triggerText,
70
76
  formatAmount,
71
77
  renderPayment,
72
78
  finalisePath,
@@ -74,17 +80,32 @@ export function EventTickets({
74
80
  loginPath,
75
81
  membershipPath,
76
82
  }: EventTicketsProps) {
83
+ const t = useForgeT();
77
84
  const theme = useForgeTheme();
78
85
  const registered = usePaymentRenderer();
79
86
  const fmt = useAmountFormatter(formatAmount);
80
87
  const c = useEventCheckout(slug, { finalisePath, onComplete });
81
88
  const renderPay = renderPayment ?? registered ?? undefined;
82
89
 
90
+ // Does this page sell add-ons for this event? Same query key as the page's
91
+ // own <PageActions> mount, so this is a cache read, not a second request.
92
+ // When add-ons exist, the PRIMARY exit from ticket selection is the cart
93
+ // (that's what unlocks the add-on grid); direct payment stays the happy
94
+ // path only on pages without add-ons.
95
+ const { data: pageActionsData } = usePageActions("event_detail", c.event?.id);
96
+ const hasAddons = useMemo(
97
+ () =>
98
+ (pageActionsData?.actions ?? []).some(
99
+ (action) => action.type === "product_cards" && Boolean((action.config as Record<string, unknown>)?.isAddon),
100
+ ),
101
+ [pageActionsData],
102
+ );
103
+
83
104
  const [open, setOpen] = useState(false);
84
105
 
85
106
  const tryClose = (next: boolean) => {
86
107
  if (!next && c.step !== "tickets" && typeof window !== "undefined") {
87
- if (!window.confirm("Are you sure you want to leave checkout?")) return;
108
+ if (!window.confirm(t("forge.event_tickets.leave_confirm"))) return;
88
109
  }
89
110
  setOpen(next);
90
111
  };
@@ -92,7 +113,7 @@ export function EventTickets({
92
113
  return (
93
114
  <DialogRoot open={open} onOpenChange={tryClose}>
94
115
  <DialogTrigger asChild>
95
- <button style={triggerButtonStyle(theme)}>{triggerText}</button>
116
+ <button style={triggerButtonStyle(theme)}>{triggerText ?? t("forge.event_tickets.trigger")}</button>
96
117
  </DialogTrigger>
97
118
  <DialogPortal>
98
119
  <DialogOverlay style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", zIndex: 50 }} />
@@ -115,16 +136,18 @@ export function EventTickets({
115
136
  aria-describedby={undefined}
116
137
  >
117
138
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
118
- <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>
119
142
  <DialogClose asChild>
120
- <button aria-label="Close" style={closeButtonStyle(theme)}>
143
+ <button aria-label={t("forge.event_tickets.aria_close")} style={closeButtonStyle(theme)}>
121
144
  <X size={18} />
122
145
  </button>
123
146
  </DialogClose>
124
147
  </div>
125
148
 
126
149
  {c.isLoading && <Loading />}
127
- {!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>}
128
151
  {c.event && (
129
152
  <EventTicketsBody
130
153
  c={c}
@@ -133,9 +156,20 @@ export function EventTickets({
133
156
  renderPayment={renderPay}
134
157
  loginPath={loginPath}
135
158
  membershipPath={membershipPath}
136
- // Selection is safe in the cart now — close so the buyer can go
137
- // browse the add-ons.
138
- onAddedToCart={() => setOpen(false)}
159
+ hasAddons={hasAddons}
160
+ // Selection is safe in the cart now: close, and bring the
161
+ // freshly unlocked add-on grid into view so the buyer lands on
162
+ // the next step instead of wherever they left the page.
163
+ onAddedToCart={() => {
164
+ setOpen(false);
165
+ if (typeof document !== "undefined") {
166
+ setTimeout(() => {
167
+ document
168
+ .querySelector('[data-testid="addons-block"]')
169
+ ?.scrollIntoView({ behavior: "smooth", block: "center" });
170
+ }, 100);
171
+ }
172
+ }}
139
173
  />
140
174
  )}
141
175
  </DialogContent>
@@ -152,6 +186,7 @@ function EventTicketsBody({
152
186
  fmt,
153
187
  renderPayment,
154
188
  onAddedToCart,
189
+ hasAddons,
155
190
  loginPath,
156
191
  membershipPath,
157
192
  }: {
@@ -160,6 +195,7 @@ function EventTicketsBody({
160
195
  fmt: (n: number) => string;
161
196
  renderPayment?: (props: PaymentRenderProps) => ReactNode;
162
197
  onAddedToCart?: () => void;
198
+ hasAddons?: boolean;
163
199
  loginPath?: string;
164
200
  membershipPath?: string;
165
201
  }) {
@@ -173,6 +209,7 @@ function EventTicketsBody({
173
209
  event={event}
174
210
  fmt={fmt}
175
211
  onAddedToCart={onAddedToCart}
212
+ hasAddons={hasAddons}
176
213
  loginPath={loginPath}
177
214
  membershipPath={membershipPath}
178
215
  />
@@ -232,6 +269,7 @@ function TicketStep({
232
269
  event,
233
270
  fmt,
234
271
  onAddedToCart,
272
+ hasAddons,
235
273
  loginPath,
236
274
  membershipPath,
237
275
  }: {
@@ -239,9 +277,11 @@ function TicketStep({
239
277
  event: IEvent;
240
278
  fmt: (n: number) => string;
241
279
  onAddedToCart?: () => void;
280
+ hasAddons?: boolean;
242
281
  loginPath?: string;
243
282
  membershipPath?: string;
244
283
  }) {
284
+ const t = useForgeT();
245
285
  const theme = useForgeTheme();
246
286
  // Inclusive stores caption ticket prices as tax-inclusive (display only).
247
287
  const pricesIncludeTax = usePricesIncludeTax();
@@ -255,21 +295,55 @@ function TicketStep({
255
295
  const urgency = (expiresAt?: string) => {
256
296
  if (!expiresAt) return null;
257
297
  const diff = new Date(expiresAt).getTime() - now;
258
- 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") };
259
300
  const hours = Math.floor(diff / 3_600_000);
260
301
  const minutes = Math.floor((diff % 3_600_000) / 60_000);
261
302
  const totalMinutes = hours * 60 + minutes;
262
- if (totalMinutes < 30) return { expired: false, color: "#dc2626", alert: true, message: `⚠️ Only ${minutes} minute${minutes !== 1 ? "s" : ""} left!` };
263
- if (totalMinutes < 60) return { expired: false, color: "#f59e0b", alert: true, message: "⏰ Less than 1 hour remaining!" };
264
- if (hours < 6) return { expired: false, color: "#f97316", alert: false, message: `⏰ Expires in ${hours} hour${hours !== 1 ? "s" : ""}!` };
265
- 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
+ };
266
332
  const days = Math.floor(hours / 24);
267
- 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
+ };
268
342
  };
269
343
 
270
344
  const setQty = (ticket: ITicket, qty: number) => {
271
345
  // `maxAddableTickets` subtracts `held` and returns 0 before a scheduled
272
- // 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
273
347
  // the cap opens up on the tick the on-sale time passes, with no reload.
274
348
  //
275
349
  // 2.6b adds back the seats held for THIS visitor by a waitlist offer.
@@ -283,14 +357,14 @@ function TicketStep({
283
357
 
284
358
  return (
285
359
  <div>
286
- <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>
287
361
  {/* Renders nothing on an event with no coded tier (1.2). Here as well as
288
- on the page because this modal is reachable on its own from an offer,
289
- 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.
290
364
  Both read the SAME stored code, so unlocking in one unlocks the other. */}
291
365
  <PresaleCodeField presale={c.presale} style={{ marginBottom: 16 }} />
292
366
  <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
293
- {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>}
294
368
  {event.tickets.map((ticket) => {
295
369
  const selectedQty = c.selectedTickets[ticket.id] ?? 0;
296
370
  /**
@@ -299,7 +373,7 @@ function TicketStep({
299
373
  * checkouts as available, so a fan in a rush was told "3 left",
300
374
  * picked two, filled in the form and was refused at reserve.
301
375
  */
302
- // 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
303
377
  // checkout"; it is the seat they were emailed a deadline for.
304
378
  const reserved = c.reservedForYou[ticket.id] ?? 0;
305
379
  const seatState = ticketSeatState(ticket, reserved);
@@ -319,8 +393,8 @@ function TicketStep({
319
393
  /**
320
394
  * A members-only tier the buyer cannot have (S.4).
321
395
  *
322
- * The event-level gate applies to EVERY tier "no ticket to this show
323
- * 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
324
398
  * once at the top: a buyer reading one tier row must be able to tell
325
399
  * from that row whether they can buy it.
326
400
  */
@@ -339,11 +413,13 @@ function TicketStep({
339
413
  opacity: isSoldOut || isExpired ? 0.5 : 1,
340
414
  }}
341
415
  >
342
- <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
+ >
343
419
  <div style={{ flex: 1 }}>
344
420
  <h3 style={{ fontSize: 17, fontWeight: 600, marginBottom: 6 }}>{ticket.title}</h3>
345
421
  {/* A hidden tier is only ever on screen because a code
346
- revealed it so say so. Without the badge, a code that
422
+ revealed it, so say so. Without the badge, a code that
347
423
  unlocked a tier the buyer had not seen listed looks like it
348
424
  did nothing. */}
349
425
  {ticket.isHidden && (
@@ -365,7 +441,7 @@ function TicketStep({
365
441
  }}
366
442
  >
367
443
  <KeyRound size={12} />
368
- Presale
444
+ {t("forge.event_tickets.presale_badge")}
369
445
  </div>
370
446
  )}
371
447
  {/* A tier that is announced but not yet buyable says WHEN,
@@ -417,23 +493,39 @@ function TicketStep({
417
493
  )}
418
494
  <div style={{ fontSize: 22, fontWeight: 700, color: theme.colors.primary }}>
419
495
  {ticket.compareAtPrice != null && Number(ticket.compareAtPrice) > Number(ticket.price) && (
420
- <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
+ >
421
505
  {fmt(Number(ticket.compareAtPrice))}
422
506
  </span>
423
507
  )}
424
508
  {/* On a PWYW tier `price` is the FLOOR, so it is labelled as
425
- one showing it bare would read as a fixed price the
509
+ one. Showing it bare would read as a fixed price the
426
510
  buyer is about to be charged. */}
427
511
  {isPayWhatYouWant(ticket) && (
428
- <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>
429
515
  )}
430
- <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
+ />
431
523
  </div>
432
524
  </div>
433
525
  <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
434
526
  <button
435
527
  onClick={() => setQty(ticket, selectedQty - 1)}
436
- // Never disabled by availability only by there being
528
+ // Never disabled by availability, only by there being
437
529
  // nothing to remove. A buyer holding two seats on a tier
438
530
  // that has just gone unavailable must still be able to put
439
531
  // them back; disabling this would trap the cart.
@@ -450,7 +542,7 @@ function TicketStep({
450
542
  // A gated tier cannot be added, nor one whose on-sale time
451
543
  // has not arrived, nor one whose remaining seats are inside
452
544
  // somebody else's checkout. Not because the client is the
453
- // authority `createOrder` refuses all three regardless
545
+ // authority (`createOrder` refuses all three regardless)
454
546
  // but because letting a buyer build a cart the server will
455
547
  // not sell is precisely the "discoverable only by failing"
456
548
  // journey this change exists to end.
@@ -461,8 +553,8 @@ function TicketStep({
461
553
  </button>
462
554
  </div>
463
555
  </div>
464
- {/* The amount box appears only once a seat is actually selected
465
- 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
466
558
  inputs down the tier list reads as a form to fill in rather
467
559
  than a choice to make. */}
468
560
  {isPayWhatYouWant(ticket) && selectedQty > 0 && !isSoldOut && !isExpired && !isScheduled && (
@@ -479,13 +571,17 @@ function TicketStep({
479
571
  <p dangerouslySetInnerHTML={{ __html: ticket.description }} style={{ marginTop: 12, fontSize: 14 }} />
480
572
  )}
481
573
  {/* Named on the TIER, before the buyer has typed anything. The
482
- tier stays in the list on purpose hiding it kills the upsell
483
- 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"
484
576
  into an offer with a way in. */}
485
577
  <MembershipGateNotice
486
578
  gate={gate}
487
579
  variant="badge"
488
- 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
+ }
489
585
  loginPath={loginPath}
490
586
  membershipPath={membershipPath}
491
587
  />
@@ -524,7 +620,7 @@ function TicketStep({
524
620
  }}
525
621
  >
526
622
  <AlertCircle size={16} />
527
- This ticket has expired and is no longer available
623
+ {t("forge.event_tickets.expired_notice")}
528
624
  </div>
529
625
  )}
530
626
  </div>
@@ -533,21 +629,43 @@ function TicketStep({
533
629
  </div>
534
630
 
535
631
  {c.error && <p style={{ color: "#ef4444", fontSize: 14, marginTop: 12 }}>{c.error}</p>}
536
- <ActionBar
537
- c={c}
538
- fmt={fmt}
539
- onNext={c.goToDetails}
540
- nextLabel="Continue"
541
- // Buying tickets alone still goes straight down the untouched
542
- // per-event flow; this exit exists so the buyer can go add merch and
543
- // come back with the selection intact.
544
- secondary={{
545
- label: c.ticketsInCart ? "Update cart" : "Add to cart",
546
- onClick: () => {
632
+ {/* Two shapes of this bar. With add-ons on the page, the PRIMARY action
633
+ is the cart: that is the only path that unlocks the add-on grid, so
634
+ it has to be the happy path, with a direct tickets-only checkout as
635
+ the secondary exit. Without add-ons the original flow is untouched:
636
+ Continue goes straight to details, the cart is the secondary exit. */}
637
+ {hasAddons ? (
638
+ <ActionBar
639
+ c={c}
640
+ fmt={fmt}
641
+ onNext={() => {
547
642
  if (c.addTicketsToCart()) onAddedToCart?.();
548
- },
549
- }}
550
- />
643
+ }}
644
+ nextLabel={
645
+ c.ticketsInCart ? t("forge.event_tickets.update_cart_addons") : t("forge.event_tickets.continue_addons")
646
+ }
647
+ nextTestId="tickets-add-to-cart"
648
+ secondary={{
649
+ label: t("forge.event_tickets.checkout_tickets_only"),
650
+ onClick: c.goToDetails,
651
+ testId: "tickets-checkout-only",
652
+ }}
653
+ />
654
+ ) : (
655
+ <ActionBar
656
+ c={c}
657
+ fmt={fmt}
658
+ onNext={c.goToDetails}
659
+ nextLabel={t("forge.event_tickets.continue")}
660
+ secondary={{
661
+ label: c.ticketsInCart ? t("forge.event_tickets.update_cart") : t("forge.event_tickets.add_to_cart"),
662
+ onClick: () => {
663
+ if (c.addTicketsToCart()) onAddedToCart?.();
664
+ },
665
+ testId: "tickets-add-to-cart",
666
+ }}
667
+ />
668
+ )}
551
669
  </div>
552
670
  );
553
671
  }
@@ -566,6 +684,7 @@ function DetailsStep({
566
684
  loginPath?: string;
567
685
  membershipPath?: string;
568
686
  }) {
687
+ const t = useForgeT();
569
688
  const theme = useForgeTheme();
570
689
  const [confirmEmail, setConfirmEmail] = useState("");
571
690
  const [answers, setAnswers] = useState<Record<string, string>>({});
@@ -581,30 +700,57 @@ function DetailsStep({
581
700
 
582
701
  const onContinue = () => {
583
702
  setLocalError(null);
584
- if (!c.firstName.trim() || !c.lastName.trim()) return setLocalError("Please enter your name.");
585
- if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email)) return setLocalError("Please enter a valid email.");
586
- 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"));
587
706
  for (const q of questions) {
588
- 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 }));
589
709
  }
590
710
  c.continueToPayment();
591
711
  };
592
712
 
593
713
  return (
594
714
  <div>
595
- <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>
596
716
  <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
597
717
  <div style={{ display: "flex", gap: 8 }}>
598
- <input placeholder="First name" value={c.firstName} onChange={(e) => c.setFirstName(e.target.value)} style={inputStyle(theme)} />
599
- <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
+ />
600
730
  </div>
601
731
  <div>
602
- <input placeholder="Email" type="email" value={c.email} onChange={(e) => c.setEmail(e.target.value)} style={inputStyle(theme)} />
603
- {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
+ )}
604
742
  </div>
605
743
  <div>
606
- <input placeholder="Confirm email" type="email" value={confirmEmail} onChange={(e) => setConfirmEmail(e.target.value)} style={inputStyle(theme)} />
607
- {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
+ )}
608
754
  </div>
609
755
 
610
756
  {questions.map((q) => {
@@ -616,8 +762,12 @@ function DetailsStep({
616
762
  {!q.optional && <span style={{ color: "#ef4444" }}> *</span>}
617
763
  </span>
618
764
  {q.options && q.options.length > 0 ? (
619
- <select value={answers[q.id] ?? ""} onChange={(e) => onAnswer(q.id, e.target.value)} style={inputStyle(theme)}>
620
- <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>
621
771
  {q.options.map((opt) => (
622
772
  <option key={opt} value={opt}>
623
773
  {opt}
@@ -625,15 +775,19 @@ function DetailsStep({
625
775
  ))}
626
776
  </select>
627
777
  ) : (
628
- <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
+ />
629
783
  )}
630
- {missing && <FieldError>This field is required</FieldError>}
784
+ {missing && <FieldError>{t("forge.event_tickets.field_required")}</FieldError>}
631
785
  </label>
632
786
  );
633
787
  })}
634
788
 
635
789
  {/* Tickets have no apply-coupon endpoint, so the code is carried into
636
- the order that "Continue" creates hence `deferred`. Its result is
790
+ the order that "Continue" creates, hence `deferred`. Its result is
637
791
  what draws the discount line on the payment step. */}
638
792
  {c.subTotal > 0 && (
639
793
  <div style={{ marginTop: 4, paddingTop: 12, borderTop: `1px solid ${a(theme.colors.primary, 0.13)}` }}>
@@ -643,14 +797,14 @@ function DetailsStep({
643
797
 
644
798
  {/* The refusal path. It should be unreachable now that the tier itself
645
799
  is badged, but a membership can lapse mid-checkout and a page can be
646
- 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
647
801
  name and a way in, not a red line reading "Something went wrong."
648
802
  `c.error` is deliberately not set for this case, so the two cannot
649
803
  both draw. */}
650
804
  <MembershipGateNotice
651
805
  refusal={c.gateRefusal}
652
806
  variant="card"
653
- itemLabel="This ticket"
807
+ itemLabel={t("forge.event_tickets.item_label_ticket")}
654
808
  loginPath={loginPath}
655
809
  membershipPath={membershipPath}
656
810
  style={{ marginTop: 4 }}
@@ -659,10 +813,9 @@ function DetailsStep({
659
813
  {c.attendeeSlots.length > 0 && (
660
814
  <div style={{ display: "flex", flexDirection: "column", gap: 12, marginTop: 4 }}>
661
815
  <div>
662
- <h3 style={{ fontSize: 16, fontWeight: 700 }}>Who is coming?</h3>
816
+ <h3 style={{ fontSize: 16, fontWeight: 700 }}>{t("forge.event_tickets.attendees_title")}</h3>
663
817
  <p style={{ fontSize: 13, opacity: 0.75, marginTop: 4 }}>
664
- This event prints each attendee&rsquo;s name on their own pass, so the door list matches who
665
- actually turns up.
818
+ {t("forge.event_tickets.attendees_description")}
666
819
  </p>
667
820
  </div>
668
821
  {c.attendeeSlots.map((slot) => (
@@ -672,10 +825,10 @@ function DetailsStep({
672
825
  >
673
826
  <span>
674
827
  {slot.label}
675
- {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>}
676
829
  </span>
677
830
  <input
678
- placeholder="Full name"
831
+ placeholder={t("forge.event_tickets.attendee_name_placeholder")}
679
832
  value={slot.value}
680
833
  onChange={(e) => c.setAttendee(slot.ticketId, slot.index, e.target.value)}
681
834
  style={inputStyle(theme)}
@@ -692,7 +845,19 @@ function DetailsStep({
692
845
  fmt={fmt}
693
846
  onBack={() => c.setStep("tickets")}
694
847
  onNext={onContinue}
695
- nextLabel={c.isProcessing ? "Processing…" : "Continue to payment"}
848
+ // Nothing to pay means no payment step follows, so promising one is a
849
+ // lie the next screen immediately contradicts. Read off `totalAmount`
850
+ // rather than the ticket prices because it already carries the booking
851
+ // fee and any discount: a free ticket with a FLAT booking fee still
852
+ // costs money and still goes to payment, and a 100%-off code on a paid
853
+ // ticket does not.
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
+ }
696
861
  nextDisabled={c.isProcessing}
697
862
  />
698
863
  </div>
@@ -709,6 +874,7 @@ function PaymentStep({
709
874
  fmt: (n: number) => string;
710
875
  renderPayment?: (props: PaymentRenderProps) => ReactNode;
711
876
  }) {
877
+ const t = useForgeT();
712
878
  const theme = useForgeTheme();
713
879
  // Authoritative tax quote from start-payment: exclusive stores get a "Tax"
714
880
  // row (already contained in the charged total below); inclusive stores get a
@@ -716,10 +882,10 @@ function PaymentStep({
716
882
  const taxSummary = summarizeTaxQuote(c.taxQuote);
717
883
  return (
718
884
  <div>
719
- <h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>Payment</h2>
720
- <p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>Complete your purchase to secure your tickets.</p>
721
- {/* The reservation clock. Event ticket holds are taken unconditionally
722
- 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
723
889
  fires for every profile, and it was the one that did not render it. */}
724
890
  <HoldNotice hold={c.hold} expiredMessage={c.error ?? undefined} style={{ marginBottom: 16 }} />
725
891
  <div
@@ -730,7 +896,7 @@ function PaymentStep({
730
896
  marginBottom: 16,
731
897
  }}
732
898
  >
733
- {/* 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
734
900
  that silently shrank. Automatic (no-code) discounts land here too. */}
735
901
  <DiscountSummaryLines
736
902
  subTotal={c.subTotal}
@@ -755,27 +921,29 @@ function PaymentStep({
755
921
  fontFamily: theme.fontFamily,
756
922
  }}
757
923
  >
758
- Remove discount
924
+ {t("forge.event_tickets.remove_discount")}
759
925
  </button>
760
926
  </div>
761
927
  )}
762
928
  {/* Above the tax row, because tax is charged ON the fee: the order total
763
929
  the tax quote priced already contained it. By this step the number is
764
- 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
765
931
  adds up to the amount that will actually be charged. */}
766
932
  <BookingFeeLine c={c} fmt={fmt} />
767
933
  {taxSummary?.mode === "exclusive" && (
768
934
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
769
- <span style={{ fontSize: 14, opacity: 0.75 }}>Tax</span>
935
+ <span style={{ fontSize: 14, opacity: 0.75 }}>{t("forge.event_tickets.tax")}</span>
770
936
  <span style={{ fontSize: 14, opacity: 0.75 }}>{taxSummary.formattedTax}</span>
771
937
  </div>
772
938
  )}
773
939
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
774
- <span style={{ fontSize: 16 }}>Total Amount</span>
940
+ <span style={{ fontSize: 16 }}>{t("forge.event_tickets.total_amount")}</span>
775
941
  <span style={{ fontSize: 22, fontWeight: 700, color: theme.colors.primary, textAlign: "right" }}>
776
942
  {fmt(c.totalAmount)}
777
943
  {taxSummary?.mode === "inclusive" && (
778
- <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
+ >
779
947
  {taxSummary.label}
780
948
  </span>
781
949
  )}
@@ -786,19 +954,22 @@ function PaymentStep({
786
954
  so it is checked BEFORE the "not configured" branch. */}
787
955
  {c.isPaystack ? (
788
956
  c.canOpenPaystack ? (
789
- <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
+ />
790
961
  ) : (
791
- <p style={{ opacity: 0.7 }}>Preparing payment…</p>
962
+ <p style={{ opacity: 0.7 }}>{t("forge.event_tickets.preparing_payment")}</p>
792
963
  )
793
964
  ) : !renderPayment ? (
794
- <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>
795
966
  ) : c.clientSecret ? (
796
967
  renderPayment({ clientSecret: c.clientSecret, returnUrl: c.returnUrl, amount: c.totalAmount, mode: "redirect" })
797
968
  ) : (
798
- <p style={{ opacity: 0.7 }}>Preparing payment…</p>
969
+ <p style={{ opacity: 0.7 }}>{t("forge.event_tickets.preparing_payment")}</p>
799
970
  )}
800
971
  <button onClick={() => c.setStep("details")} style={{ ...secondaryButtonStyle(theme), marginTop: 16 }}>
801
- Back
972
+ {t("forge.event_tickets.back")}
802
973
  </button>
803
974
  </div>
804
975
  );
@@ -813,7 +984,7 @@ function FieldError({ children }: { children: ReactNode }) {
813
984
  *
814
985
  * This is the line that did not exist. The fee was added to `total_amount` on
815
986
  * the server and named on no surface, so a buyer choosing £50 of tickets was
816
- * 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
817
988
  * `Subtotal` and `Discount` were both drawn, the total did not reconcile with
818
989
  * them and nothing explained why. A mandatory charge disclosed only on the
819
990
  * receipt is what the FTC's junk-fee rule, the CMA's guidance and the EU
@@ -821,7 +992,7 @@ function FieldError({ children }: { children: ReactNode }) {
821
992
  *
822
993
  * Renders NOTHING when the fee is zero. The payload always arrives (so callers
823
994
  * can rely on its shape), which means "this artist charges no fee" comes down as
824
- * 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
825
996
  * exist and hint that one might appear later. There is nothing to disclose when
826
997
  * nothing is charged.
827
998
  *
@@ -831,6 +1002,7 @@ function FieldError({ children }: { children: ReactNode }) {
831
1002
  * ON.
832
1003
  */
833
1004
  function BookingFeeLine({ c, fmt }: { c: Checkout; fmt: (n: number) => string }) {
1005
+ const t = useForgeT();
834
1006
  const theme = useForgeTheme();
835
1007
  if (!(c.bookingFeeAmount > 0)) return null;
836
1008
  const discountDrewSubtotal = c.coupon.discountAmount > 0;
@@ -842,15 +1014,12 @@ function BookingFeeLine({ c, fmt }: { c: Checkout; fmt: (n: number) => string })
842
1014
  >
843
1015
  {!discountDrewSubtotal && (
844
1016
  <div style={{ display: "flex", justifyContent: "space-between", fontSize: 14 }}>
845
- <span style={{ opacity: 0.75 }}>Subtotal</span>
1017
+ <span style={{ opacity: 0.75 }}>{t("forge.event_tickets.subtotal")}</span>
846
1018
  <span style={{ opacity: 0.75 }}>{fmt(c.subTotal)}</span>
847
1019
  </div>
848
1020
  )}
849
- <div
850
- data-testid="booking-fee-line"
851
- style={{ display: "flex", justifyContent: "space-between", fontSize: 14 }}
852
- >
853
- <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>
854
1023
  <span style={{ color: theme.colors.text, opacity: 0.75 }}>{fmt(c.bookingFeeAmount)}</span>
855
1024
  </div>
856
1025
  </div>
@@ -865,6 +1034,7 @@ function ActionBar({
865
1034
  onNext,
866
1035
  nextLabel,
867
1036
  nextDisabled,
1037
+ nextTestId,
868
1038
  secondary,
869
1039
  }: {
870
1040
  c: Checkout;
@@ -873,9 +1043,13 @@ function ActionBar({
873
1043
  onNext: () => void;
874
1044
  nextLabel: string;
875
1045
  nextDisabled?: boolean;
876
- /** Extra exit shown next to Back "Add to cart" on the selection step. */
877
- secondary?: { label: string; onClick: () => void };
1046
+ /** Test id for the primary button; the caller says which button is which
1047
+ action, because add-to-cart moves between slots depending on add-ons. */
1048
+ nextTestId?: string;
1049
+ /** Extra exit shown next to Back on the selection step. */
1050
+ secondary?: { label: string; onClick: () => void; testId?: string };
878
1051
  }) {
1052
+ const t = useForgeT();
879
1053
  const theme = useForgeTheme();
880
1054
  return (
881
1055
  <div style={{ marginTop: 24 }}>
@@ -894,25 +1068,34 @@ function ActionBar({
894
1068
  fmt={fmt}
895
1069
  />
896
1070
  {/* `Total` below is the figure the buyer reads as final while they are
897
- 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
898
1072
  three screens later. */}
899
1073
  <BookingFeeLine c={c} fmt={fmt} />
900
1074
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
901
- <span style={{ fontWeight: 600 }}>Total</span>
1075
+ <span style={{ fontWeight: 600 }}>{t("forge.event_tickets.total")}</span>
902
1076
  <span style={{ fontSize: 18, fontWeight: 700, color: theme.colors.primary }}>{fmt(c.totalAmount)}</span>
903
1077
  </div>
904
1078
  </div>
905
1079
  <div style={{ display: "flex", gap: 8, justifyContent: "space-between" }}>
906
1080
  {secondary ? (
907
- <button data-testid="tickets-add-to-cart" onClick={secondary.onClick} style={secondaryButtonStyle(theme)}>
1081
+ <button data-testid={secondary.testId} onClick={secondary.onClick} style={secondaryButtonStyle(theme)}>
908
1082
  {secondary.label}
909
1083
  </button>
910
1084
  ) : (
911
- <button onClick={onBack} disabled={!onBack} style={{ ...secondaryButtonStyle(theme), opacity: onBack ? 1 : 0.4 }}>
912
- 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")}
913
1091
  </button>
914
1092
  )}
915
- <button onClick={onNext} disabled={nextDisabled} style={{ ...primaryButtonStyle(theme), opacity: nextDisabled ? 0.6 : 1 }}>
1093
+ <button
1094
+ data-testid={nextTestId}
1095
+ onClick={onNext}
1096
+ disabled={nextDisabled}
1097
+ style={{ ...primaryButtonStyle(theme), opacity: nextDisabled ? 0.6 : 1 }}
1098
+ >
916
1099
  {nextLabel}
917
1100
  </button>
918
1101
  </div>
@@ -945,7 +1128,7 @@ const closeButtonStyle = (t: ForgeTheme): CSSProperties => ({
945
1128
  * to the floor on every keystroke), and "1" on the way to "15" must not be
946
1129
  * clamped up to the minimum the instant it is typed.
947
1130
  *
948
- * 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
949
1132
  * clamps again regardless, so nothing here is load-bearing for money.
950
1133
  */
951
1134
  function PwywAmountField({
@@ -963,12 +1146,13 @@ function PwywAmountField({
963
1146
  fmt: (amount: number) => string;
964
1147
  theme: ForgeTheme;
965
1148
  }) {
1149
+ const t = useForgeT();
966
1150
  const floor = Number(ticket.price);
967
1151
  const maximum = pwywMaximum(ticket);
968
1152
  const suggested = pwywDefaultAmount(ticket);
969
1153
  const [draft, setDraft] = useState<string>(String(value ?? suggested));
970
1154
 
971
- // 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
972
1156
  // operator's suggested amount arriving late (or the selection being cleared
973
1157
  // and remade) should re-seed the box rather than leave a stale figure.
974
1158
  useEffect(() => {
@@ -993,15 +1177,13 @@ function PwywAmountField({
993
1177
 
994
1178
  return (
995
1179
  <div style={{ marginTop: 12 }}>
996
- <label
997
- htmlFor={`pwyw-${ticket.id}`}
998
- style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 6 }}
999
- >
1000
- 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")}
1001
1182
  <span style={{ fontWeight: 400, opacity: 0.7 }}>
1002
1183
  {" "}
1003
- (minimum {fmt(floor)}
1004
- {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) })}
1005
1187
  </span>
1006
1188
  </label>
1007
1189
  <input
@@ -1018,19 +1200,23 @@ function PwywAmountField({
1018
1200
  />
1019
1201
  <p style={{ fontSize: 12, opacity: 0.7, marginTop: 6 }}>
1020
1202
  {belowFloor
1021
- ? `The minimum is ${fmt(floor)}, so we'll use that.`
1203
+ ? t("forge.event_tickets.pwyw_below_min", { min: fmt(floor) })
1022
1204
  : aboveMax
1023
- ? `The most you can pay is ${fmt(maximum!)}, so we'll use that.`
1205
+ ? t("forge.event_tickets.pwyw_above_max", { max: fmt(maximum!) })
1024
1206
  : quantity > 1
1025
- ? `${fmt(effective)} each · ${fmt(effective * quantity)} for ${quantity}`
1026
- : "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")}
1027
1213
  </p>
1028
1214
  </div>
1029
1215
  );
1030
1216
  }
1031
1217
 
1032
1218
  /**
1033
- * 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
1034
1220
  * below it, so "why can't I buy this?" always answers itself in one place and
1035
1221
  * one visual language, whichever the reason.
1036
1222
  */