@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
@@ -4,6 +4,7 @@ import { CreditCard, MapPin, Package, User } from "lucide-react";
4
4
  import { useCheckout, CHECKOUT_STAGE_ORDER, type CheckoutStage } from "../headless/checkout/useCheckout";
5
5
  import type { SelectedShippingRate } from "../headless/checkout/useCheckout";
6
6
  import type { ShippingRate, ShippingGroup } from "../../data/queries/useShipping";
7
+ import { useForgeT } from "../../i18n";
7
8
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
8
9
  import { readableTextOn } from "../theme/contrast";
9
10
  import { useFormatCurrency } from "../format/useFormatCurrency";
@@ -64,6 +65,8 @@ export function Checkout({
64
65
  style,
65
66
  }: CheckoutProps) {
66
67
  const theme = useForgeTheme();
68
+ // `tr`, not `t`: a ticket line below binds `t` as its own map callback param.
69
+ const tr = useForgeT();
67
70
  const { formatCurrency } = useFormatCurrency();
68
71
  // Store display mode (site config) — drives the pre-quote "incl. tax" caption.
69
72
  // Once the payment starts, the authoritative taxQuote from start-payment wins.
@@ -177,7 +180,9 @@ export function Checkout({
177
180
  return <Loading fullPage />;
178
181
  }
179
182
 
180
- if (c.cartItems.length === 0) {
183
+ // Tickets count: a cart holding only tickets (buyer skipped the add-ons)
184
+ // is NOT empty, and this page is the only checkout it has.
185
+ if (c.cartItems.length === 0 && c.ticketItems.length === 0) {
181
186
  return (
182
187
  <div
183
188
  className={["w-full max-w-4xl mx-auto p-6", className].filter(Boolean).join(" ")}
@@ -185,12 +190,12 @@ export function Checkout({
185
190
  >
186
191
  <div className="text-center py-12">
187
192
  <h1 className="text-2xl font-bold mb-4" style={{ color: theme.colors.text }}>
188
- Your cart is empty
193
+ {tr("forge.checkout.empty_title")}
189
194
  </h1>
190
195
  <p className="mb-6" style={{ color: `${theme.colors.text}${alpha(0.7)}` }}>
191
- Add some items to your cart before checking out.
196
+ {tr("forge.checkout.empty_body")}
192
197
  </p>
193
- <PrimaryButton text="Continue Shopping" onClick={() => go(storePath)} />
198
+ <PrimaryButton text={tr("forge.checkout.continue_shopping")} onClick={() => go(storePath)} />
194
199
  </div>
195
200
  </div>
196
201
  );
@@ -198,14 +203,14 @@ export function Checkout({
198
203
 
199
204
  const stages = c.hasPhysicalProduct
200
205
  ? [
201
- { key: "userDetails" as const, label: "User", icon: User },
202
- { key: "shippingAddress" as const, label: "Address", icon: MapPin },
203
- { key: "shippingRates" as const, label: "Shipping", icon: Package },
204
- { key: "payment" as const, label: "Payment", icon: CreditCard },
206
+ { key: "userDetails" as const, label: tr("forge.checkout.stage_user"), icon: User },
207
+ { key: "shippingAddress" as const, label: tr("forge.checkout.stage_address"), icon: MapPin },
208
+ { key: "shippingRates" as const, label: tr("forge.checkout.stage_shipping"), icon: Package },
209
+ { key: "payment" as const, label: tr("forge.checkout.stage_payment"), icon: CreditCard },
205
210
  ]
206
211
  : [
207
- { key: "userDetails" as const, label: "User", icon: User },
208
- { key: "payment" as const, label: "Payment", icon: CreditCard },
212
+ { key: "userDetails" as const, label: tr("forge.checkout.stage_user"), icon: User },
213
+ { key: "payment" as const, label: tr("forge.checkout.stage_payment"), icon: CreditCard },
209
214
  ];
210
215
 
211
216
  const currentIdx = stageIdx(c.currentStage);
@@ -270,29 +275,35 @@ export function Checkout({
270
275
  >
271
276
  <div className="pb-6" style={{ borderTop: `1px solid ${theme.colors.primary}${alpha(0.2)}` }}>
272
277
  <h3 className="text-lg font-semibold mb-4" style={{ color: theme.colors.text }}>
273
- Already have an account?
278
+ {tr("forge.checkout.have_account")}
274
279
  </h3>
275
- <PrimaryButton text="Login" onClick={handleLogin} fullWidth />
280
+ <PrimaryButton text={tr("forge.checkout.login")} onClick={handleLogin} fullWidth />
276
281
  </div>
277
282
  <h2 className="text-2xl font-bold mb-6" style={{ color: theme.colors.text }}>
278
- Or
283
+ {tr("forge.checkout.or")}
279
284
  </h2>
280
285
 
281
286
  <div className="mb-6">
282
287
  <h3 className="text-lg font-semibold mb-4" style={{ color: theme.colors.text }}>
283
- Continue as Guest
288
+ {tr("forge.checkout.guest_title")}
284
289
  </h3>
285
290
  <form onSubmit={guestForm.handleSubmit(c.submitGuest)} className="space-y-4">
286
291
  <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
287
292
  <div>
288
293
  <label className="block text-sm font-medium mb-2" style={{ color: theme.colors.text }}>
289
- First Name
294
+ {tr("forge.checkout.first_name")}
290
295
  </label>
291
296
  <Controller
292
297
  control={guestForm.control}
293
298
  name="firstName"
294
- rules={{ required: "First name is required" }}
295
- render={({ field }) => <input {...field} placeholder="First Name" style={inputStyle} />}
299
+ rules={{ required: tr("forge.checkout.first_name_required") }}
300
+ render={({ field }) => (
301
+ <input
302
+ {...field}
303
+ placeholder={tr("forge.checkout.first_name_placeholder")}
304
+ style={inputStyle}
305
+ />
306
+ )}
296
307
  />
297
308
  {guestForm.formState.errors.firstName && (
298
309
  <p className="text-sm text-red-500 mt-1">{guestForm.formState.errors.firstName.message}</p>
@@ -300,13 +311,19 @@ export function Checkout({
300
311
  </div>
301
312
  <div>
302
313
  <label className="block text-sm font-medium mb-2" style={{ color: theme.colors.text }}>
303
- Last Name
314
+ {tr("forge.checkout.last_name")}
304
315
  </label>
305
316
  <Controller
306
317
  control={guestForm.control}
307
318
  name="lastName"
308
- rules={{ required: "Last name is required" }}
309
- render={({ field }) => <input {...field} placeholder="Last Name" style={inputStyle} />}
319
+ rules={{ required: tr("forge.checkout.last_name_required") }}
320
+ render={({ field }) => (
321
+ <input
322
+ {...field}
323
+ placeholder={tr("forge.checkout.last_name_placeholder")}
324
+ style={inputStyle}
325
+ />
326
+ )}
310
327
  />
311
328
  {guestForm.formState.errors.lastName && (
312
329
  <p className="text-sm text-red-500 mt-1">{guestForm.formState.errors.lastName.message}</p>
@@ -315,16 +332,26 @@ export function Checkout({
315
332
  </div>
316
333
  <div>
317
334
  <label className="block text-sm font-medium mb-2" style={{ color: theme.colors.text }}>
318
- Email
335
+ {tr("forge.checkout.email")}
319
336
  </label>
320
337
  <Controller
321
338
  control={guestForm.control}
322
339
  name="email"
323
340
  rules={{
324
- required: "Email is required",
325
- pattern: { value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, message: "Invalid email address" },
341
+ required: tr("forge.checkout.email_required"),
342
+ pattern: {
343
+ value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
344
+ message: tr("forge.checkout.email_invalid"),
345
+ },
326
346
  }}
327
- render={({ field }) => <input {...field} type="email" placeholder="Email" style={inputStyle} />}
347
+ render={({ field }) => (
348
+ <input
349
+ {...field}
350
+ type="email"
351
+ placeholder={tr("forge.checkout.email_placeholder")}
352
+ style={inputStyle}
353
+ />
354
+ )}
328
355
  />
329
356
  {guestForm.formState.errors.email && (
330
357
  <p className="text-sm text-red-500 mt-1">{guestForm.formState.errors.email.message}</p>
@@ -340,10 +367,10 @@ export function Checkout({
340
367
  style={{ accentColor: theme.colors.primary }}
341
368
  />
342
369
  <label htmlFor="joinEmailList" className="text-sm" style={{ color: theme.colors.text }}>
343
- Sign up to receive updates
370
+ {tr("forge.checkout.join_email_list")}
344
371
  </label>
345
372
  </div>
346
- <PrimaryButton text="Continue as Guest" type="submit" fullWidth />
373
+ <PrimaryButton text={tr("forge.checkout.guest_submit")} type="submit" fullWidth />
347
374
  </form>
348
375
  </div>
349
376
  </div>
@@ -360,18 +387,20 @@ export function Checkout({
360
387
  }}
361
388
  >
362
389
  <h2 className="text-2xl font-bold mb-6" style={{ color: theme.colors.text }}>
363
- Shipping Address
390
+ {tr("forge.checkout.shipping_address_title")}
364
391
  </h2>
365
392
  <form onSubmit={shippingForm.handleSubmit(c.submitAddress)} className="space-y-4">
366
393
  <div>
367
394
  <label className="block text-sm font-medium mb-2" style={{ color: theme.colors.text }}>
368
- Address Line 1
395
+ {tr("forge.checkout.address1_label")}
369
396
  </label>
370
397
  <Controller
371
398
  control={shippingForm.control}
372
399
  name="address1"
373
- rules={{ required: "Address is required" }}
374
- render={({ field }) => <input {...field} placeholder="12 address avenue, Bankstown" style={inputStyle} />}
400
+ rules={{ required: tr("forge.checkout.address_required") }}
401
+ render={({ field }) => (
402
+ <input {...field} placeholder={tr("forge.checkout.address1_placeholder")} style={inputStyle} />
403
+ )}
375
404
  />
376
405
  {shippingForm.formState.errors.address1 && (
377
406
  <p className="text-sm text-red-500 mt-1">{shippingForm.formState.errors.address1.message}</p>
@@ -381,13 +410,15 @@ export function Checkout({
381
410
  <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
382
411
  <div>
383
412
  <label className="block text-sm font-medium mb-2" style={{ color: theme.colors.text }}>
384
- City
413
+ {tr("forge.checkout.city_label")}
385
414
  </label>
386
415
  <Controller
387
416
  control={shippingForm.control}
388
417
  name="city"
389
- rules={{ required: "City is required" }}
390
- render={({ field }) => <input {...field} placeholder="Sydney" style={inputStyle} />}
418
+ rules={{ required: tr("forge.checkout.city_required") }}
419
+ render={({ field }) => (
420
+ <input {...field} placeholder={tr("forge.checkout.city_placeholder")} style={inputStyle} />
421
+ )}
391
422
  />
392
423
  {shippingForm.formState.errors.city && (
393
424
  <p className="text-sm text-red-500 mt-1">{shippingForm.formState.errors.city.message}</p>
@@ -396,13 +427,15 @@ export function Checkout({
396
427
 
397
428
  <div>
398
429
  <label className="block text-sm font-medium mb-2" style={{ color: theme.colors.text }}>
399
- Postal Code
430
+ {tr("forge.checkout.zip_label")}
400
431
  </label>
401
432
  <Controller
402
433
  control={shippingForm.control}
403
434
  name="zip"
404
- rules={{ required: "Postal code is required" }}
405
- render={({ field }) => <input {...field} placeholder="2200" style={inputStyle} />}
435
+ rules={{ required: tr("forge.checkout.zip_required") }}
436
+ render={({ field }) => (
437
+ <input {...field} placeholder={tr("forge.checkout.zip_placeholder")} style={inputStyle} />
438
+ )}
406
439
  />
407
440
  {shippingForm.formState.errors.zip && (
408
441
  <p className="text-sm text-red-500 mt-1">{shippingForm.formState.errors.zip.message}</p>
@@ -413,15 +446,15 @@ export function Checkout({
413
446
  <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
414
447
  <div>
415
448
  <label className="block text-sm font-medium mb-2" style={{ color: theme.colors.text }}>
416
- Country
449
+ {tr("forge.checkout.country_label")}
417
450
  </label>
418
451
  <Controller
419
452
  control={shippingForm.control}
420
453
  name="countryCode"
421
- rules={{ required: "Country is required" }}
454
+ rules={{ required: tr("forge.checkout.country_required") }}
422
455
  render={({ field }) => (
423
456
  <select {...field} style={inputStyle}>
424
- <option value="">Select Country</option>
457
+ <option value="">{tr("forge.checkout.country_select")}</option>
425
458
  {c.countries.map((country) => (
426
459
  <option key={country.code} value={country.code}>
427
460
  {country.name}
@@ -438,15 +471,15 @@ export function Checkout({
438
471
  {selectedCountry?.states && selectedCountry.states.length > 0 && (
439
472
  <div>
440
473
  <label className="block text-sm font-medium mb-2" style={{ color: theme.colors.text }}>
441
- State/Province
474
+ {tr("forge.checkout.state_label")}
442
475
  </label>
443
476
  <Controller
444
477
  control={shippingForm.control}
445
478
  name="stateCode"
446
- rules={{ required: "State/Province is required" }}
479
+ rules={{ required: tr("forge.checkout.state_required") }}
447
480
  render={({ field }) => (
448
481
  <select {...field} style={inputStyle}>
449
- <option value="">Select State/Province</option>
482
+ <option value="">{tr("forge.checkout.state_select")}</option>
450
483
  {selectedCountry.states!.map((state) => (
451
484
  <option key={state.code} value={state.code}>
452
485
  {state.name}
@@ -464,13 +497,15 @@ export function Checkout({
464
497
 
465
498
  <div>
466
499
  <label className="block text-sm font-medium mb-2" style={{ color: theme.colors.text }}>
467
- Phone Number
500
+ {tr("forge.checkout.phone_label")}
468
501
  </label>
469
502
  <Controller
470
503
  control={shippingForm.control}
471
504
  name="phone"
472
- rules={{ required: "Phone number is required" }}
473
- render={({ field }) => <input {...field} placeholder="Phone number" style={inputStyle} />}
505
+ rules={{ required: tr("forge.checkout.phone_required") }}
506
+ render={({ field }) => (
507
+ <input {...field} placeholder={tr("forge.checkout.phone_placeholder")} style={inputStyle} />
508
+ )}
474
509
  />
475
510
  {shippingForm.formState.errors.phone && (
476
511
  <p className="text-sm text-red-500 mt-1">{shippingForm.formState.errors.phone.message}</p>
@@ -478,9 +513,18 @@ export function Checkout({
478
513
  </div>
479
514
 
480
515
  <div className="flex gap-4 pt-4">
481
- {!c.user && <SecondaryButton text="Back" onClick={() => c.setCurrentStage("userDetails")} />}
516
+ {!c.user && (
517
+ <SecondaryButton
518
+ text={tr("forge.checkout.back")}
519
+ onClick={() => c.setCurrentStage("userDetails")}
520
+ />
521
+ )}
482
522
  <PrimaryButton
483
- text={c.isFetchingRates ? "Fetching shipping rates..." : "Continue to Shipping"}
523
+ text={
524
+ c.isFetchingRates
525
+ ? tr("forge.checkout.fetching_rates")
526
+ : tr("forge.checkout.continue_to_shipping")
527
+ }
484
528
  type="submit"
485
529
  fullWidth
486
530
  disabled={c.isFetchingRates}
@@ -501,7 +545,7 @@ export function Checkout({
501
545
  }}
502
546
  >
503
547
  <h2 className="text-2xl font-bold mb-6" style={{ color: theme.colors.text }}>
504
- Shipping Options
548
+ {tr("forge.checkout.shipping_options_title")}
505
549
  </h2>
506
550
 
507
551
  <ShippingRateSelector
@@ -512,9 +556,12 @@ export function Checkout({
512
556
  />
513
557
 
514
558
  <div className="flex gap-4 pt-6">
515
- <SecondaryButton text="Back" onClick={() => c.setCurrentStage("shippingAddress")} />
559
+ <SecondaryButton
560
+ text={tr("forge.checkout.back")}
561
+ onClick={() => c.setCurrentStage("shippingAddress")}
562
+ />
516
563
  <PrimaryButton
517
- text="Continue to Payment"
564
+ text={tr("forge.checkout.continue_to_payment")}
518
565
  onClick={c.confirmRates}
519
566
  fullWidth
520
567
  disabled={c.shippingGroups.some((g) => g.selectable && !c.selections[g.id])}
@@ -548,12 +595,17 @@ export function Checkout({
548
595
  }}
549
596
  >
550
597
  <h2 className="text-2xl font-bold mb-6" style={{ color: theme.colors.text }}>
551
- Payment Information
598
+ {tr("forge.checkout.payment_title")}
552
599
  </h2>
553
600
  <p className="text-sm mb-8" style={{ color: `${theme.colors.text}${alpha(0.7)}` }}>
554
- This is a free checkout. You will not be charged anything.
601
+ {tr("forge.checkout.free_checkout_body")}
555
602
  </p>
556
- <PrimaryButton text="Complete Checkout" onClick={handleFreeCheckout} fullWidth disabled={c.isFreeCheckoutLoading} />
603
+ <PrimaryButton
604
+ text={tr("forge.checkout.complete_checkout")}
605
+ onClick={handleFreeCheckout}
606
+ fullWidth
607
+ disabled={c.isFreeCheckoutLoading}
608
+ />
557
609
  </div>
558
610
  )}
559
611
  </div>
@@ -569,13 +621,13 @@ export function Checkout({
569
621
  }}
570
622
  >
571
623
  <h3 className="text-xl font-bold mb-4" style={{ color: theme.colors.text }}>
572
- Order Summary
624
+ {tr("forge.checkout.summary_title")}
573
625
  </h3>
574
626
 
575
627
  {/* Customer */}
576
628
  <div className="mb-4 pb-4" style={{ borderBottom: `1px solid ${theme.colors.primary}${alpha(0.2)}` }}>
577
629
  <h4 className="font-semibold mb-2" style={{ color: theme.colors.text }}>
578
- Customer
630
+ {tr("forge.checkout.summary_customer")}
579
631
  </h4>
580
632
  {c.user ? (
581
633
  <div className="text-sm">
@@ -593,7 +645,7 @@ export function Checkout({
593
645
  </div>
594
646
  ) : (
595
647
  <p className="text-sm" style={{ color: `${theme.colors.text}${alpha(0.5)}` }}>
596
- Guest checkout
648
+ {tr("forge.checkout.guest_checkout")}
597
649
  </p>
598
650
  )}
599
651
  </div>
@@ -602,7 +654,7 @@ export function Checkout({
602
654
  {c.hasPhysicalProduct && c.shippingData && (
603
655
  <div className="mb-4 pb-4" style={{ borderBottom: `1px solid ${theme.colors.primary}${alpha(0.2)}` }}>
604
656
  <h4 className="font-semibold mb-2" style={{ color: theme.colors.text }}>
605
- Shipping Address
657
+ {tr("forge.checkout.summary_shipping_address")}
606
658
  </h4>
607
659
  <div className="text-sm">
608
660
  <p style={{ color: theme.colors.text }}>{c.shippingData.address1}</p>
@@ -619,9 +671,37 @@ export function Checkout({
619
671
  {/* Cart Items */}
620
672
  <div className="mb-4 pb-4" style={{ borderBottom: `1px solid ${theme.colors.primary}${alpha(0.2)}` }}>
621
673
  <h4 className="font-semibold mb-2" style={{ color: theme.colors.text }}>
622
- Items
674
+ {tr("forge.checkout.summary_items")}
623
675
  </h4>
624
676
  <div className="space-y-2">
677
+ {/* Ticket lines first: they are what the bundle is FOR, and the
678
+ subtotal already includes them, so a summary without them
679
+ reads as a wrong total. */}
680
+ {c.ticketItems.flatMap((t) =>
681
+ Object.entries(t.tickets)
682
+ .filter(([, qty]) => qty > 0)
683
+ .map(([ticketId, qty]) => {
684
+ const meta = t.ticketMeta[ticketId];
685
+ return (
686
+ <div key={`${t.eventId}-${ticketId}`} className="flex justify-between text-sm" data-testid="summary-ticket-line">
687
+ <div className="flex-1">
688
+ <p className="font-medium" style={{ color: theme.colors.text }}>
689
+ {meta?.title ?? tr("forge.checkout.ticket_fallback")}
690
+ </p>
691
+ <p className="text-xs" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
692
+ {t.eventTitle}
693
+ </p>
694
+ <p className="text-xs" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
695
+ {tr("forge.checkout.qty", { count: qty })}
696
+ </p>
697
+ </div>
698
+ <p className="font-medium" style={{ color: theme.colors.text }}>
699
+ {formatCurrency((meta?.price ?? 0) * qty)}
700
+ </p>
701
+ </div>
702
+ );
703
+ }),
704
+ )}
625
705
  {c.cartItems.map((item) => (
626
706
  <div
627
707
  key={`${item.productId}-${item.productVariantId}-${item.isGift}-${item.recipientEmail}`}
@@ -637,11 +717,11 @@ export function Checkout({
637
717
  <CartLineOptions item={item} accent={theme.colors.primary} size="small" />
638
718
  {item.isGift && (
639
719
  <p className="text-xs" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
640
- Gift for {item.recipientName}
720
+ {tr("forge.checkout.gift_for", { recipient_name: item.recipientName ?? "" })}
641
721
  </p>
642
722
  )}
643
723
  <p className="text-xs" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
644
- Qty: {item.quantity}
724
+ {tr("forge.checkout.qty", { count: item.quantity })}
645
725
  </p>
646
726
  </div>
647
727
  <p className="font-medium" style={{ color: theme.colors.text }}>
@@ -668,25 +748,25 @@ export function Checkout({
668
748
  style={{ color: theme.colors.primary }}
669
749
  onClick={() => c.setShowCouponInput(true)}
670
750
  >
671
- Have a coupon code?
751
+ {tr("forge.checkout.coupon_prompt")}
672
752
  </button>
673
753
  )}
674
754
  {c.showCouponInput && !c.appliedCoupon && c.isCouponEditable && (
675
755
  <div className="flex gap-2 items-end">
676
756
  <div className="flex-1">
677
757
  <p className="text-sm mb-1" style={{ color: theme.colors.text }}>
678
- Coupon Code
758
+ {tr("forge.checkout.coupon_label")}
679
759
  </p>
680
760
  <input
681
- aria-label="Coupon code"
682
- placeholder="Enter coupon code"
761
+ aria-label={tr("forge.checkout.aria_coupon_code")}
762
+ placeholder={tr("forge.checkout.coupon_placeholder")}
683
763
  style={inputStyle}
684
764
  value={c.couponCode}
685
765
  onChange={(e) => c.setCouponCode(e.target.value.toUpperCase())}
686
766
  />
687
767
  </div>
688
768
  <PrimaryButton
689
- text={c.isApplyingCoupon ? "..." : "Apply"}
769
+ text={c.isApplyingCoupon ? "..." : tr("forge.checkout.coupon_apply")}
690
770
  onClick={c.applyCoupon}
691
771
  disabled={c.isApplyingCoupon || !c.couponCode.trim()}
692
772
  />
@@ -696,7 +776,7 @@ export function Checkout({
696
776
  code is priced live, so promising "at payment" would be wrong. */}
697
777
  {c.isBundle && !c.checkoutId && c.showCouponInput && !c.appliedCoupon && c.isCouponEditable && (
698
778
  <p className="text-xs mt-1" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
699
- Your code is applied when you reach payment.
779
+ {tr("forge.checkout.coupon_deferred_note")}
700
780
  </p>
701
781
  )}
702
782
  {c.couponError && <p className="text-sm text-red-500 mt-1">{c.couponError}</p>}
@@ -707,19 +787,25 @@ export function Checkout({
707
787
  >
708
788
  <div>
709
789
  <p className="text-sm font-semibold" style={{ color: theme.colors.primary }}>
710
- Coupon applied: {c.appliedCoupon.code}
790
+ {tr("forge.checkout.coupon_applied", { code: c.appliedCoupon.code })}
711
791
  </p>
712
792
  {/* A bundle reports the cash that came off and the code it
713
793
  came off under, but not the rate — so say the cash,
714
794
  never a made-up "0% off". */}
715
795
  <p className="text-xs" style={{ color: theme.colors.text }}>
716
796
  {c.appliedCoupon.discountType === "percentage"
717
- ? `${c.appliedCoupon.discountValue}% off`
797
+ ? // `String(...)` keeps a null rate rendering exactly as the old template
798
+ // literal did, rather than quietly turning it into a made-up "0".
799
+ tr("forge.checkout.coupon_percent_off", { percent: String(c.appliedCoupon.discountValue) })
718
800
  : c.appliedCoupon.discountValue != null
719
- ? `${formatCurrency(c.appliedCoupon.discountValue)} off`
801
+ ? tr("forge.checkout.coupon_amount_off", {
802
+ amount: formatCurrency(c.appliedCoupon.discountValue),
803
+ })
720
804
  : c.appliedCoupon.discountAmount != null
721
- ? `${formatCurrency(c.appliedCoupon.discountAmount)} off`
722
- : "Applied at payment"}
805
+ ? tr("forge.checkout.coupon_amount_off", {
806
+ amount: formatCurrency(c.appliedCoupon.discountAmount),
807
+ })
808
+ : tr("forge.checkout.coupon_applied_at_payment")}
723
809
  </p>
724
810
  </div>
725
811
  {c.isCouponEditable && (
@@ -730,7 +816,7 @@ export function Checkout({
730
816
  onClick={c.removeCoupon}
731
817
  disabled={c.isApplyingCoupon}
732
818
  >
733
- Remove
819
+ {tr("forge.checkout.coupon_remove")}
734
820
  </button>
735
821
  )}
736
822
  </div>
@@ -741,26 +827,35 @@ export function Checkout({
741
827
  {/* Totals */}
742
828
  <div className="space-y-2">
743
829
  <div className="flex justify-between">
744
- <span style={{ color: theme.colors.text }}>Subtotal</span>
830
+ <span style={{ color: theme.colors.text }}>{tr("forge.checkout.subtotal")}</span>
745
831
  <span style={{ color: theme.colors.text }}>{formatCurrency(c.subTotal)}</span>
746
832
  </div>
747
833
  {c.discountAmount > 0 && (
748
834
  <div className="flex justify-between">
749
- <span style={{ color: theme.colors.text }}>Discount</span>
835
+ <span style={{ color: theme.colors.text }}>{tr("forge.checkout.discount")}</span>
750
836
  <span style={{ color: theme.colors.primary }}>-{formatCurrency(c.discountAmount)}</span>
751
837
  </div>
752
838
  )}
753
839
  {c.shippingCost > 0 && (
754
840
  <div className="flex justify-between">
755
- <span style={{ color: theme.colors.text }}>Shipping</span>
841
+ <span style={{ color: theme.colors.text }}>{tr("forge.checkout.shipping")}</span>
756
842
  <span style={{ color: theme.colors.text }}>{formatCurrency(c.shippingCost)}</span>
757
843
  </div>
758
844
  )}
845
+ {/* The ticket booking fee, named BEFORE the buyer commits. It is
846
+ charged either way; omitting the line here left a total that
847
+ jumped past the subtotal with no explanation. */}
848
+ {c.bookingFeeCost > 0 && (
849
+ <div className="flex justify-between" data-testid="summary-booking-fee">
850
+ <span style={{ color: theme.colors.text }}>{tr("forge.checkout.booking_fee")}</span>
851
+ <span style={{ color: theme.colors.text }}>{formatCurrency(c.bookingFeeCost)}</span>
852
+ </div>
853
+ )}
759
854
  {/* Exclusive mode: the authoritative tax line, once the checkout
760
855
  quote is known (added on top of the total). */}
761
856
  {taxSummary?.mode === "exclusive" && (
762
857
  <div className="flex justify-between">
763
- <span style={{ color: theme.colors.text }}>Tax</span>
858
+ <span style={{ color: theme.colors.text }}>{tr("forge.checkout.tax")}</span>
764
859
  <span style={{ color: theme.colors.text }}>{taxSummary.formattedTax}</span>
765
860
  </div>
766
861
  )}
@@ -768,11 +863,11 @@ export function Checkout({
768
863
  className="flex justify-between font-bold text-lg pt-2"
769
864
  style={{ borderTop: `1px solid ${theme.colors.primary}${alpha(0.2)}`, color: theme.colors.primary }}
770
865
  >
771
- <span>Total</span>
866
+ <span>{tr("forge.checkout.total")}</span>
772
867
  <span>
773
868
  {c.chargedTotal
774
869
  ? formatCurrency(c.chargedTotal.amount, c.chargedTotal.currency)
775
- : formatCurrency(c.subTotal + c.shippingCost)}
870
+ : formatCurrency(c.subTotal + c.shippingCost + c.bookingFeeCost)}
776
871
  </span>
777
872
  </div>
778
873
  {/* Inclusive mode: the total already contains tax — break out the
@@ -785,7 +880,7 @@ export function Checkout({
785
880
  !taxSummary &&
786
881
  pricesIncludeTax && (
787
882
  <p className="text-xs text-right" style={{ color: `${theme.colors.text}${alpha(0.6)}`, margin: 0 }}>
788
- incl. tax
883
+ {tr("forge.checkout.incl_tax")}
789
884
  </p>
790
885
  )
791
886
  )}
@@ -818,6 +913,7 @@ function PaymentSection({
818
913
  onBack?: () => void;
819
914
  }) {
820
915
  const theme = useForgeTheme();
916
+ const tr = useForgeT();
821
917
  const renderPay = usePaymentRenderer();
822
918
  const { paymentSecret, chargedAmount, chargedCurrency, returnUrl, isPaystack, isCreatingOrder, startError } = checkout;
823
919
  const { canOpenPaystack, openPaystackCheckout } = checkout;
@@ -839,7 +935,7 @@ function PaymentSection({
839
935
  borderRadius: `${theme.cornerRadius}px`,
840
936
  }}
841
937
  >
842
- Back
938
+ {tr("forge.checkout.back")}
843
939
  </button>
844
940
  ) : null;
845
941
 
@@ -855,10 +951,10 @@ function PaymentSection({
855
951
  }}
856
952
  >
857
953
  <h2 className="text-2xl font-bold mb-2" style={{ color: theme.colors.text }}>
858
- Payment
954
+ {tr("forge.checkout.payment_heading")}
859
955
  </h2>
860
956
  <p className="text-lg mb-4" style={{ color: theme.colors.text }}>
861
- Payment is not configured
957
+ {tr("forge.checkout.payment_not_configured")}
862
958
  </p>
863
959
  {backButton}
864
960
  </div>
@@ -875,7 +971,7 @@ function PaymentSection({
875
971
  }}
876
972
  >
877
973
  <h2 className="text-2xl font-bold mb-6" style={{ color: theme.colors.text }}>
878
- Payment Information
974
+ {tr("forge.checkout.payment_title")}
879
975
  </h2>
880
976
 
881
977
  {startError && (
@@ -893,13 +989,15 @@ function PaymentSection({
893
989
  expiredMessage={holdExpiredError || undefined}
894
990
  onRetry={retryHold}
895
991
  isRetrying={isRetryingHold}
896
- secondaryAction={onBack ? { label: "Change your order", onClick: onBack } : undefined}
992
+ secondaryAction={onBack ? { label: tr("forge.checkout.hold_change_order"), onClick: onBack } : undefined}
897
993
  />
898
994
 
899
995
  {/* Suppressed once the server has refused: there is no intent coming, so a
900
996
  "Preparing payment…" spinner would sit there forever under a notice
901
997
  that has already explained what happened. */}
902
- {isCreatingOrder && !hasCharge && !isHoldExpired && <Loading label="Preparing payment…" size={22} />}
998
+ {isCreatingOrder && !hasCharge && !isHoldExpired && (
999
+ <Loading label={tr("forge.checkout.preparing_payment")} size={22} />
1000
+ )}
903
1001
 
904
1002
  {isPaystack
905
1003
  ? canOpenPaystack && (
@@ -946,6 +1044,7 @@ function ShippingRateSelector({
946
1044
  formatCurrency: (amount: number, currencyCode?: string) => string;
947
1045
  }) {
948
1046
  const theme = useForgeTheme();
1047
+ const tr = useForgeT();
949
1048
  if (groups.length === 0) return null;
950
1049
 
951
1050
  return (
@@ -972,7 +1071,7 @@ function ShippingRateSelector({
972
1071
  </span>
973
1072
  {group.rates[0].estimatedDays != null && (
974
1073
  <span className="text-xs ml-2" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
975
- ({group.rates[0].estimatedDays} days)
1074
+ {tr("forge.checkout.rate_days", { days: group.rates[0].estimatedDays })}
976
1075
  </span>
977
1076
  )}
978
1077
  </div>
@@ -985,7 +1084,7 @@ function ShippingRateSelector({
985
1084
  <div className="space-y-2">
986
1085
  {group.rates.length === 0 && (
987
1086
  <p className="text-sm" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
988
- No shipping options available for this address.
1087
+ {tr("forge.checkout.no_shipping_options")}
989
1088
  </p>
990
1089
  )}
991
1090
  {group.rates.map((rate) => {
@@ -1014,7 +1113,9 @@ function ShippingRateSelector({
1014
1113
  </span>
1015
1114
  {rate.estimatedDays && (
1016
1115
  <p className="text-xs" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
1017
- Estimated {rate.estimatedDays} {rate.estimatedDays === 1 ? "day" : "days"}
1116
+ {rate.estimatedDays === 1
1117
+ ? tr("forge.checkout.rate_estimated_day", { days: rate.estimatedDays })
1118
+ : tr("forge.checkout.rate_estimated_days", { days: rate.estimatedDays })}
1018
1119
  </p>
1019
1120
  )}
1020
1121
  </div>