@tribe-nest/forge 3.26.0 → 3.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/package.json +1 -1
  2. package/src/client/_tests/localeHeader.spec.tsx +98 -0
  3. package/src/client/activeLocale.ts +34 -0
  4. package/src/client/createForgeClient.ts +13 -0
  5. package/src/contexts/CartContext.tsx +76 -0
  6. package/src/contexts/_tests/CartContext.spec.tsx +210 -0
  7. package/src/data/queries/useCheckouts.ts +83 -0
  8. package/src/data/queries/useWebsite.ts +7 -0
  9. package/src/i18n/CONVENTION.md +184 -0
  10. package/src/i18n/_tests/translationKeys.spec.ts +153 -0
  11. package/src/i18n/de.json +960 -0
  12. package/src/i18n/en.json +960 -0
  13. package/src/i18n/index.ts +125 -0
  14. package/src/index.ts +21 -0
  15. package/src/provider/ForgeAppProvider.tsx +2 -1
  16. package/src/provider/SiteConfigProvider.tsx +11 -0
  17. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
  18. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
  19. package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
  20. package/src/ui/headless/checkout/useCartResume.ts +340 -0
  21. package/src/ui/headless/checkout/useCheckout.ts +9 -2
  22. package/src/ui/headless/index.ts +18 -0
  23. package/src/ui/index.ts +2 -0
  24. package/src/ui/shell/TribeNestApp.tsx +36 -27
  25. package/src/ui/styled/AccountDashboard.tsx +197 -93
  26. package/src/ui/styled/AiAgentWidget.tsx +10 -6
  27. package/src/ui/styled/AudioPlayer.tsx +14 -6
  28. package/src/ui/styled/BlogCategory.tsx +4 -2
  29. package/src/ui/styled/BlogComments.tsx +27 -20
  30. package/src/ui/styled/BlogList.tsx +5 -3
  31. package/src/ui/styled/BlogPost.tsx +14 -11
  32. package/src/ui/styled/BundleConfirmation.tsx +32 -23
  33. package/src/ui/styled/CancellationTerms.tsx +3 -1
  34. package/src/ui/styled/Cart.tsx +32 -14
  35. package/src/ui/styled/ChatRoom.tsx +65 -20
  36. package/src/ui/styled/Checkout.tsx +151 -89
  37. package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
  38. package/src/ui/styled/CoachingBooking.tsx +71 -44
  39. package/src/ui/styled/CoachingConfirmation.tsx +56 -19
  40. package/src/ui/styled/CoachingDetail.tsx +15 -11
  41. package/src/ui/styled/CohortPage.tsx +10 -8
  42. package/src/ui/styled/ConfirmSubscription.tsx +14 -8
  43. package/src/ui/styled/ContactForm.tsx +31 -12
  44. package/src/ui/styled/CookieConsent.tsx +19 -15
  45. package/src/ui/styled/CourseAccess.tsx +54 -47
  46. package/src/ui/styled/CourseCheckout.tsx +56 -38
  47. package/src/ui/styled/CourseConfirmation.tsx +39 -17
  48. package/src/ui/styled/CourseDetail.tsx +21 -15
  49. package/src/ui/styled/CoursesGrid.tsx +4 -2
  50. package/src/ui/styled/CurrencySwitcher.tsx +4 -1
  51. package/src/ui/styled/DiscountCode.tsx +22 -18
  52. package/src/ui/styled/DonationButton.tsx +52 -17
  53. package/src/ui/styled/DonationPage.tsx +4 -4
  54. package/src/ui/styled/EmailListForm.tsx +22 -9
  55. package/src/ui/styled/EventConfirmation.tsx +37 -14
  56. package/src/ui/styled/EventCountdown.tsx +10 -6
  57. package/src/ui/styled/EventDetail.tsx +8 -6
  58. package/src/ui/styled/EventSeriesDetail.tsx +18 -13
  59. package/src/ui/styled/EventTickets.tsx +232 -108
  60. package/src/ui/styled/EventWaitlist.tsx +42 -33
  61. package/src/ui/styled/EventsList.tsx +15 -13
  62. package/src/ui/styled/ForgotPasswordForm.tsx +12 -12
  63. package/src/ui/styled/FormRenderer.tsx +12 -7
  64. package/src/ui/styled/InstallBanner.tsx +21 -18
  65. package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
  66. package/src/ui/styled/InvoicePayment.tsx +37 -16
  67. package/src/ui/styled/LeadMagnet.tsx +9 -7
  68. package/src/ui/styled/Loading.tsx +7 -1
  69. package/src/ui/styled/LoginForm.tsx +22 -14
  70. package/src/ui/styled/MembershipCheckout.tsx +32 -13
  71. package/src/ui/styled/MembershipTiers.tsx +27 -18
  72. package/src/ui/styled/OfferButton.tsx +33 -13
  73. package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
  74. package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
  75. package/src/ui/styled/PodcastEpisode.tsx +20 -9
  76. package/src/ui/styled/PodcastList.tsx +9 -3
  77. package/src/ui/styled/PodcastShow.tsx +29 -7
  78. package/src/ui/styled/PostsFeed.tsx +10 -8
  79. package/src/ui/styled/PresaleCode.tsx +8 -11
  80. package/src/ui/styled/ProductBrowseNav.tsx +21 -11
  81. package/src/ui/styled/ProductDetail.tsx +58 -28
  82. package/src/ui/styled/ReactionBar.tsx +4 -2
  83. package/src/ui/styled/ReplayList.tsx +24 -13
  84. package/src/ui/styled/ResetPasswordForm.tsx +27 -15
  85. package/src/ui/styled/ResumeCart.tsx +289 -0
  86. package/src/ui/styled/ReviewForm.tsx +52 -58
  87. package/src/ui/styled/SignupForm.tsx +29 -16
  88. package/src/ui/styled/TicketTransfer.tsx +55 -54
  89. package/src/ui/styled/UserMenu.tsx +16 -14
  90. package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
@@ -21,6 +21,7 @@ import { useCart } from "../../contexts/CartContext";
21
21
  import { usePublicAuth } from "../../contexts/PublicAuthContext";
22
22
  import { OrderStatus, ProductDeliveryType, type IPublicOrder } from "../../types/models";
23
23
  import { CartLineOptions } from "./CartLineOptions";
24
+ import { useForgeT } from "../../i18n";
24
25
 
25
26
  export interface CheckoutConfirmationProps {
26
27
  orderId?: string;
@@ -60,10 +61,10 @@ const PAID_STATES = new Set<OrderStatus>([
60
61
  const FAILED_STATES = new Set<OrderStatus>([OrderStatus.PaymentFailed, OrderStatus.Failed, OrderStatus.Cancelled]);
61
62
  const PENDING_STATES = new Set<OrderStatus>([OrderStatus.InitiatedPayment, OrderStatus.Processing]);
62
63
 
63
- const DELIVERY_LABEL: Record<ProductDeliveryType, string> = {
64
- [ProductDeliveryType.Physical]: "Physical delivery",
65
- [ProductDeliveryType.Service]: "Service delivery",
66
- [ProductDeliveryType.Digital]: "Digital delivery",
64
+ const DELIVERY_LABEL_KEY: Record<ProductDeliveryType, string> = {
65
+ [ProductDeliveryType.Physical]: "forge.checkout_confirmation.delivery_physical",
66
+ [ProductDeliveryType.Service]: "forge.checkout_confirmation.delivery_service",
67
+ [ProductDeliveryType.Digital]: "forge.checkout_confirmation.delivery_digital",
67
68
  };
68
69
 
69
70
  /** Short date only, e.g. "Jun 30, 2026". */
@@ -130,6 +131,7 @@ function OrderConfirmation({
130
131
  accountPath = "/i/account",
131
132
  checkoutPath = "/i/checkout",
132
133
  }: CheckoutConfirmationProps) {
134
+ const t = useForgeT();
133
135
  const theme = useForgeTheme();
134
136
  const formatAmt = useAmountFormatter(formatAmount);
135
137
  const text = theme.colors.text;
@@ -165,7 +167,7 @@ function OrderConfirmation({
165
167
  <div style={{ textAlign: "center", color: alpha(text, 0.75) }}>
166
168
  <Loader2 size={40} color={primary} style={{ animation: "cc-spin 1s linear infinite" }} />
167
169
  <style>{"@keyframes cc-spin{to{transform:rotate(360deg)}}"}</style>
168
- <p style={{ marginTop: 16, fontSize: 15 }}>Confirming your payment…</p>
170
+ <p style={{ marginTop: 16, fontSize: 15 }}>{t("forge.checkout_confirmation.confirming")}</p>
169
171
  </div>
170
172
  </ConfirmationStage>
171
173
  );
@@ -178,14 +180,14 @@ function OrderConfirmation({
178
180
  <ConfirmationCard width={440}>
179
181
  <div style={{ padding: "40px 30px", textAlign: "center" }}>
180
182
  <WarnSeal icon={<AlertTriangle size={34} color="#f59e0b" />} />
181
- <h1 style={{ fontSize: 24, fontWeight: 800, margin: "22px 0 8px" }}>Order not found</h1>
183
+ <h1 style={{ fontSize: 24, fontWeight: 800, margin: "22px 0 8px" }}>{t("forge.checkout_confirmation.not_found_title")}</h1>
182
184
  <p style={{ fontSize: 15, color: alpha(text, 0.7), margin: "0 0 24px" }}>
183
185
  {isError
184
- ? "We couldn’t confirm this order. If you were charged, your receipt will still arrive by email."
185
- : "This confirmation link looks incomplete or has expired."}
186
+ ? t("forge.checkout_confirmation.not_found_error_body")
187
+ : t("forge.checkout_confirmation.not_found_body")}
186
188
  </p>
187
189
  <Action href={explorePath} primary={primary}>
188
- Continue shopping <ArrowRight size={16} />
190
+ {t("forge.checkout_confirmation.continue_shopping")} <ArrowRight size={16} />
189
191
  </Action>
190
192
  </div>
191
193
  </ConfirmationCard>
@@ -207,18 +209,28 @@ function OrderConfirmation({
207
209
  const discount = order?.discountAmount || 0;
208
210
  const email = order?.customerEmail;
209
211
 
210
- const eyebrow = isPaid ? "Order confirmed" : isFailed ? "Payment failed" : "Payment processing";
212
+ const eyebrow = isPaid
213
+ ? t("forge.checkout_confirmation.eyebrow_paid")
214
+ : isFailed
215
+ ? t("forge.checkout_confirmation.eyebrow_failed")
216
+ : t("forge.checkout_confirmation.eyebrow_pending");
211
217
  const eyebrowColor = isPaid ? primary : isFailed ? "#f59e0b" : primary;
212
- const heading = isPaid ? "Thank you! 🛍" : isFailed ? "Payment not completed" : gaveUp ? "Still processing" : "Confirming your payment…";
218
+ const heading = isPaid
219
+ ? t("forge.checkout_confirmation.heading_paid")
220
+ : isFailed
221
+ ? t("forge.checkout_confirmation.heading_failed")
222
+ : gaveUp
223
+ ? t("forge.checkout_confirmation.heading_still_processing")
224
+ : t("forge.checkout_confirmation.confirming");
213
225
  const sub = isPaid
214
226
  ? email
215
- ? `We’ve emailed your receipt to ${email}.`
216
- : "Your receipt is on its way."
227
+ ? t("forge.checkout_confirmation.receipt_emailed", { email })
228
+ : t("forge.checkout_confirmation.receipt_on_way")
217
229
  : isFailed
218
- ? "Your payment didn’t go through, so you haven’t been charged. You can try again."
230
+ ? t("forge.checkout_confirmation.failed_body")
219
231
  : gaveUp
220
- ? "This is taking longer than usual. We’ll email your receipt as soon as it clears."
221
- : "Hang tight: this only takes a few seconds.";
232
+ ? t("forge.checkout_confirmation.slow_body")
233
+ : t("forge.checkout_confirmation.pending_body");
222
234
 
223
235
  return (
224
236
  <ConfirmationStage>
@@ -284,17 +296,21 @@ function OrderConfirmation({
284
296
  {isFailed ? (
285
297
  <div className="cf-rise" style={{ padding: "6px 30px 34px", animationDelay: "0.4s" }}>
286
298
  <Action href={checkoutPath} primary={primary}>
287
- Return to checkout <ArrowRight size={16} />
299
+ {t("forge.checkout_confirmation.return_to_checkout")} <ArrowRight size={16} />
288
300
  </Action>
289
301
  </div>
290
302
  ) : (
291
303
  <>
292
304
  {/* ── Order items (grouped by delivery) ── */}
293
305
  <div className="cf-rise" style={{ padding: "0 30px 8px", animationDelay: "0.42s" }}>
294
- <SectionLabel icon={<ShoppingBag size={16} color={primary} />} text="Your order" muted={alpha(text, 0.55)} />
306
+ <SectionLabel
307
+ icon={<ShoppingBag size={16} color={primary} />}
308
+ text={t("forge.checkout_confirmation.order_section")}
309
+ muted={alpha(text, 0.55)}
310
+ />
295
311
  {groups.length === 0 ? (
296
312
  <p style={{ textAlign: "center", padding: "12px 0", color: alpha(text, 0.6), fontSize: 14 }}>
297
- No items in this order.
313
+ {t("forge.checkout_confirmation.no_items")}
298
314
  </p>
299
315
  ) : (
300
316
  <div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
@@ -308,24 +324,32 @@ function OrderConfirmation({
308
324
  {/* ── Totals ── */}
309
325
  <div className="cf-rise" style={{ padding: "12px 30px 0", animationDelay: "0.48s" }}>
310
326
  <div style={{ paddingTop: 14, borderTop: `1px solid ${alpha(text, 0.1)}`, display: "flex", flexDirection: "column", gap: 6 }}>
311
- <SummaryLine label="Subtotal" value={fmt(subtotal)} muted={alpha(text, 0.6)} />
312
- {shipping > 0 && <SummaryLine label="Shipping" value={fmt(shipping)} muted={alpha(text, 0.6)} />}
313
- {discount > 0 && <SummaryLine label="Discount" value={`−${fmt(discount)}`} muted={alpha(text, 0.6)} />}
327
+ <SummaryLine label={t("forge.checkout_confirmation.subtotal")} value={fmt(subtotal)} muted={alpha(text, 0.6)} />
328
+ {shipping > 0 && (
329
+ <SummaryLine label={t("forge.checkout_confirmation.shipping")} value={fmt(shipping)} muted={alpha(text, 0.6)} />
330
+ )}
331
+ {discount > 0 && (
332
+ <SummaryLine label={t("forge.checkout_confirmation.discount")} value={`−${fmt(discount)}`} muted={alpha(text, 0.6)} />
333
+ )}
314
334
  {Number(order?.taxAmount || 0) > 0 && !order?.taxInclusive && (
315
335
  <SummaryLine
316
- label="Tax"
336
+ label={t("forge.checkout_confirmation.tax")}
317
337
  value={formatAmt(Number(order?.taxAmount), order?.chargedCurrency || currency)}
318
338
  muted={alpha(text, 0.6)}
319
339
  />
320
340
  )}
321
341
  </div>
322
342
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 12 }}>
323
- <span style={{ fontWeight: 700 }}>Total {isPaid ? "paid" : ""}</span>
343
+ <span style={{ fontWeight: 700 }}>
344
+ {isPaid ? t("forge.checkout_confirmation.total_paid") : t("forge.checkout_confirmation.total")}
345
+ </span>
324
346
  <span style={{ fontSize: 20, fontWeight: 800, color: primary }}>{totalDisplay}</span>
325
347
  </div>
326
348
  {Number(order?.taxAmount || 0) > 0 && order?.taxInclusive && (
327
349
  <div style={{ textAlign: "right", fontSize: 12, color: alpha(text, 0.6), marginTop: 4 }}>
328
- Includes {formatAmt(Number(order?.taxAmount), order?.chargedCurrency || currency)} tax
350
+ {t("forge.checkout_confirmation.tax_inclusive_note", {
351
+ amount: formatAmt(Number(order?.taxAmount), order?.chargedCurrency || currency),
352
+ })}
329
353
  </div>
330
354
  )}
331
355
  </div>
@@ -337,22 +361,28 @@ function OrderConfirmation({
337
361
  {/* ── Details ── */}
338
362
  <div className="cf-rise" style={{ padding: "20px 30px 8px", animationDelay: "0.54s" }}>
339
363
  <dl style={{ display: "grid", gridTemplateColumns: "1fr auto", rowGap: 10, columnGap: 16, margin: 0 }}>
340
- <ConfirmationRow label="Order" value={`#${order!.id.slice(0, 8).toUpperCase()}`} mono />
341
- <ConfirmationRow label="Date" value={formatDate(order!.createdAt)} />
342
- {order!.customerName && <ConfirmationRow label="Name" value={order!.customerName} />}
343
- {email && <ConfirmationRow label="Email" value={email} />}
364
+ <ConfirmationRow
365
+ label={t("forge.checkout_confirmation.row_order")}
366
+ value={`#${order!.id.slice(0, 8).toUpperCase()}`}
367
+ mono
368
+ />
369
+ <ConfirmationRow label={t("forge.checkout_confirmation.row_date")} value={formatDate(order!.createdAt)} />
370
+ {order!.customerName && (
371
+ <ConfirmationRow label={t("forge.checkout_confirmation.row_name")} value={order!.customerName} />
372
+ )}
373
+ {email && <ConfirmationRow label={t("forge.checkout_confirmation.row_email")} value={email} />}
344
374
  </dl>
345
375
  </div>
346
376
 
347
377
  {/* ── Actions ── */}
348
378
  <div className="cf-rise" style={{ padding: "22px 30px 30px", display: "flex", flexDirection: "column", gap: 10, animationDelay: "0.6s" }}>
349
379
  <Action href={explorePath} primary={primary}>
350
- Continue shopping <ArrowRight size={16} />
380
+ {t("forge.checkout_confirmation.continue_shopping")} <ArrowRight size={16} />
351
381
  </Action>
352
382
  {/* Orders live behind auth — only offer the link to signed-in buyers. */}
353
383
  {user && (
354
384
  <Action href={accountPath} primary={primary} ghost>
355
- View your orders
385
+ {t("forge.checkout_confirmation.view_orders")}
356
386
  </Action>
357
387
  )}
358
388
  </div>
@@ -381,15 +411,16 @@ function GroupBlock({
381
411
  fmt: (n: number) => string;
382
412
  theme: ReturnType<typeof useForgeTheme>;
383
413
  }) {
414
+ const t = useForgeT();
384
415
  const text = theme.colors.text;
385
416
  const primary = theme.colors.primary;
386
417
  return (
387
418
  <div style={{ borderRadius: theme.cornerRadius, border: `1px solid ${alpha(primary, 0.16)}`, background: alpha(primary, 0.04), overflow: "hidden" }}>
388
419
  {/* group header */}
389
420
  <div style={{ padding: "10px 14px", borderBottom: `1px solid ${alpha(primary, 0.12)}` }}>
390
- <div style={{ fontSize: 13, fontWeight: 700, color: text }}>{DELIVERY_LABEL[group.deliveryType]}</div>
421
+ <div style={{ fontSize: 13, fontWeight: 700, color: text }}>{t(DELIVERY_LABEL_KEY[group.deliveryType])}</div>
391
422
  <div style={{ fontSize: 12, color: alpha(text, 0.65), marginTop: 2 }}>
392
- To {group.recipientName} ({group.recipientEmail})
423
+ {t("forge.checkout_confirmation.recipient", { name: group.recipientName, email: group.recipientEmail })}
393
424
  </div>
394
425
  {group.recipientMessage && (
395
426
  <div style={{ fontSize: 12, color: alpha(text, 0.6), marginTop: 2, fontStyle: "italic" }}>“{group.recipientMessage}”</div>
@@ -418,14 +449,14 @@ function GroupBlock({
418
449
  {item.title}
419
450
  </div>
420
451
  <div style={{ fontSize: 12, color: alpha(text, 0.6), marginTop: 2 }}>
421
- {fmt(item.price)} · Qty {item.quantity}
452
+ {t("forge.checkout_confirmation.item_price_qty", { price: fmt(item.price), quantity: item.quantity })}
422
453
  </div>
423
454
  {/* Which version — the receipt has to say, or a buyer cannot check
424
455
  they were charged for the one they picked. */}
425
456
  <CartLineOptions item={item} accent={primary} size="small" />
426
457
  {item.isGift && (
427
458
  <div style={{ fontSize: 12, marginTop: 4, color: primary }}>
428
- 🎁 Gift for {item.recipientName} ({item.recipientEmail})
459
+ {t("forge.checkout_confirmation.gift_for", { name: item.recipientName ?? "", email: item.recipientEmail ?? "" })}
429
460
  </div>
430
461
  )}
431
462
  {item.recipientMessage && (
@@ -2,6 +2,7 @@ import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useStat
2
2
  import { Calendar, ChevronLeft, ChevronRight, Clock, X } from "lucide-react";
3
3
  import type { BookingSlot, CoachingProduct } from "../../types/models";
4
4
  import { useCoachingBooking } from "../headless/coaching/useCoachingBooking";
5
+ import { useForgeT } from "../../i18n";
5
6
  import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
6
7
  import { useAmountFormatter } from "../format/useFormatCurrency";
7
8
  import { summarizeTaxQuote } from "../format/PriceDisplay";
@@ -56,12 +57,13 @@ const a = (hex: string, alpha: number) => hex + Math.round(alpha * 255).toString
56
57
  */
57
58
  export function CoachingBooking({
58
59
  slug,
59
- triggerText = "Select Availability",
60
+ triggerText,
60
61
  formatAmount,
61
62
  renderPayment,
62
63
  finalisePath,
63
64
  onComplete,
64
65
  }: CoachingBookingProps) {
66
+ const t = useForgeT();
65
67
  const theme = useForgeTheme();
66
68
  const registered = usePaymentRenderer();
67
69
  const fmt = useAmountFormatter(formatAmount);
@@ -72,7 +74,7 @@ export function CoachingBooking({
72
74
 
73
75
  const tryClose = (next: boolean) => {
74
76
  if (!next && c.step !== "slot") {
75
- if (typeof window !== "undefined" && !window.confirm("Are you sure you want to leave checkout?")) return;
77
+ if (typeof window !== "undefined" && !window.confirm(t("forge.coaching_booking.confirm_leave"))) return;
76
78
  // Release the held slot so it's not locked until the timer runs out.
77
79
  void c.releaseReservation();
78
80
  }
@@ -82,7 +84,7 @@ export function CoachingBooking({
82
84
  return (
83
85
  <DialogRoot open={open} onOpenChange={tryClose}>
84
86
  <DialogTrigger asChild>
85
- <button style={triggerButtonStyle(theme)}>{triggerText}</button>
87
+ <button style={triggerButtonStyle(theme)}>{triggerText ?? t("forge.coaching_booking.trigger")}</button>
86
88
  </DialogTrigger>
87
89
  <DialogPortal>
88
90
  <DialogOverlay style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", zIndex: 50 }} />
@@ -106,17 +108,17 @@ export function CoachingBooking({
106
108
  >
107
109
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
108
110
  <DialogTitle style={{ fontSize: 18, fontWeight: 700, margin: 0 }}>
109
- {c.product?.title ?? "Book a session"}
111
+ {c.product?.title ?? t("forge.coaching_booking.title")}
110
112
  </DialogTitle>
111
113
  <DialogClose asChild>
112
- <button aria-label="Close" style={closeButtonStyle(theme)}>
114
+ <button aria-label={t("forge.coaching_booking.aria_close")} style={closeButtonStyle(theme)}>
113
115
  <X size={18} />
114
116
  </button>
115
117
  </DialogClose>
116
118
  </div>
117
119
 
118
120
  {c.isLoading && <Loading />}
119
- {!c.isLoading && !c.product && <p style={{ marginTop: 8 }}>Coaching session not found.</p>}
121
+ {!c.isLoading && !c.product && <p style={{ marginTop: 8 }}>{t("forge.coaching_booking.not_found")}</p>}
120
122
  {c.product && <CoachingBookingBody c={c} product={c.product} fmt={fmt} renderPayment={renderPay} />}
121
123
  </DialogContent>
122
124
  </DialogPortal>
@@ -156,6 +158,7 @@ function CoachingBookingBody({
156
158
  // (keeps the Stripe form in the payment step from re-mounting). Fires `onExpire`
157
159
  // exactly once when the hold runs out.
158
160
  function ReservationTimer({ expiresAt, onExpire }: { expiresAt: number | null; onExpire: () => void }) {
161
+ const t = useForgeT();
159
162
  const theme = useForgeTheme();
160
163
  const [now, setNow] = useState(() => Date.now());
161
164
  const firedRef = useRef(false);
@@ -202,7 +205,9 @@ function ReservationTimer({ expiresAt, onExpire }: { expiresAt: number | null; o
202
205
  }}
203
206
  >
204
207
  <Clock size={16} style={{ color }} />
205
- <span style={{ fontWeight: 600, fontSize: 14, color }}>Time remaining: {mmss}</span>
208
+ <span style={{ fontWeight: 600, fontSize: 14, color }}>
209
+ {t("forge.coaching_booking.time_remaining", { time: mmss })}
210
+ </span>
206
211
  </div>
207
212
  );
208
213
  }
@@ -250,6 +255,7 @@ function Progress({ current }: { current: number }) {
250
255
 
251
256
  // ── Step 1: week navigation + slot selection (reserve happens on Continue) ─────
252
257
  function SlotStep({ c, product, fmt }: { c: Booking; product: CoachingProduct; fmt: (n: number) => string }) {
258
+ const t = useForgeT();
253
259
  const theme = useForgeTheme();
254
260
  const slots: BookingSlot[] = c.availability.data ?? [];
255
261
 
@@ -284,7 +290,10 @@ function SlotStep({ c, product, fmt }: { c: Booking; product: CoachingProduct; f
284
290
  }
285
291
  })();
286
292
 
287
- const weekLabel = `${c.weekStart.toLocaleDateString("en-US", { month: "short", day: "numeric" })} to ${c.weekEnd.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`;
293
+ const weekLabel = t("forge.coaching_booking.week_range", {
294
+ start: c.weekStart.toLocaleDateString("en-US", { month: "short", day: "numeric" }),
295
+ end: c.weekEnd.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }),
296
+ });
288
297
  const navButton = (disabled: boolean): CSSProperties => ({
289
298
  padding: 8,
290
299
  borderRadius: theme.cornerRadius,
@@ -299,28 +308,31 @@ function SlotStep({ c, product, fmt }: { c: Booking; product: CoachingProduct; f
299
308
 
300
309
  return (
301
310
  <div>
302
- <h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 6 }}>Select Availability</h2>
311
+ <h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 6 }}>{t("forge.coaching_booking.slot_title")}</h2>
303
312
  <p style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 13, color: a(theme.colors.text, 0.6), margin: "0 0 16px" }}>
304
- <Clock size={14} /> Times shown in your timezone{tzAbbr ? ` (${tzAbbr})` : ""}
313
+ <Clock size={14} />{" "}
314
+ {tzAbbr
315
+ ? t("forge.coaching_booking.timezone_note_named", { timezone: tzAbbr })
316
+ : t("forge.coaching_booking.timezone_note")}
305
317
  </p>
306
318
 
307
319
  {/* Week navigation */}
308
320
  <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
309
321
  <span style={{ fontSize: 14, color: a(theme.colors.text, 0.6) }}>{weekLabel}</span>
310
322
  <div style={{ display: "flex", gap: 8 }}>
311
- <button aria-label="Previous week" onClick={c.goPrevWeek} disabled={!c.canGoPrevWeek} style={navButton(!c.canGoPrevWeek)}>
323
+ <button aria-label={t("forge.coaching_booking.aria_prev_week")} onClick={c.goPrevWeek} disabled={!c.canGoPrevWeek} style={navButton(!c.canGoPrevWeek)}>
312
324
  <ChevronLeft size={16} />
313
325
  </button>
314
- <button aria-label="Next week" onClick={c.goNextWeek} style={navButton(false)}>
326
+ <button aria-label={t("forge.coaching_booking.aria_next_week")} onClick={c.goNextWeek} style={navButton(false)}>
315
327
  <ChevronRight size={16} />
316
328
  </button>
317
329
  </div>
318
330
  </div>
319
331
 
320
332
  {c.availability.isLoading ? (
321
- <Loading label="Loading available slots…" />
333
+ <Loading label={t("forge.coaching_booking.loading_slots")} />
322
334
  ) : grouped.length === 0 ? (
323
- <p style={{ opacity: 0.7 }}>No available slots for this week.</p>
335
+ <p style={{ opacity: 0.7 }}>{t("forge.coaching_booking.no_slots")}</p>
324
336
  ) : (
325
337
  <div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
326
338
  {grouped.map(([dateKey, daySlots]) => {
@@ -376,7 +388,7 @@ function SlotStep({ c, product, fmt }: { c: Booking; product: CoachingProduct; f
376
388
  product={product}
377
389
  fmt={fmt}
378
390
  onNext={c.reserveSelected}
379
- nextLabel={c.isProcessing ? "Reserving…" : "Continue"}
391
+ nextLabel={c.isProcessing ? t("forge.coaching_booking.reserving") : t("forge.coaching_booking.continue")}
380
392
  nextDisabled={!c.selectedSlotId || c.isProcessing}
381
393
  />
382
394
  </div>
@@ -385,6 +397,7 @@ function SlotStep({ c, product, fmt }: { c: Booking; product: CoachingProduct; f
385
397
 
386
398
  // ── Step 2: buyer details + questionnaire ─────────────────────────────────────
387
399
  function DetailsStep({ c, product, fmt }: { c: Booking; product: CoachingProduct; fmt: (n: number) => string }) {
400
+ const t = useForgeT();
388
401
  const theme = useForgeTheme();
389
402
  const [confirmEmail, setConfirmEmail] = useState("");
390
403
  const [answers, setAnswers] = useState<Record<string, string>>({});
@@ -400,11 +413,13 @@ function DetailsStep({ c, product, fmt }: { c: Booking; product: CoachingProduct
400
413
 
401
414
  const onContinue = () => {
402
415
  setLocalError(null);
403
- if (!c.firstName.trim() || !c.lastName.trim()) return setLocalError("Please enter your name.");
404
- if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email)) return setLocalError("Please enter a valid email.");
405
- if (c.email !== confirmEmail) return setLocalError("Emails do not match.");
416
+ if (!c.firstName.trim() || !c.lastName.trim()) return setLocalError(t("forge.coaching_booking.error_name_required"));
417
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email))
418
+ return setLocalError(t("forge.coaching_booking.error_email_invalid"));
419
+ if (c.email !== confirmEmail) return setLocalError(t("forge.coaching_booking.error_email_mismatch"));
406
420
  for (const q of questions) {
407
- if (!q.optional && !(answers[q.id] ?? "").trim()) return setLocalError(`Please answer: ${q.question}`);
421
+ if (!q.optional && !(answers[q.id] ?? "").trim())
422
+ return setLocalError(t("forge.coaching_booking.error_answer_required", { question: q.question }));
408
423
  }
409
424
  c.continueToPayment();
410
425
  };
@@ -413,22 +428,24 @@ function DetailsStep({ c, product, fmt }: { c: Booking; product: CoachingProduct
413
428
 
414
429
  return (
415
430
  <div>
416
- <h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 4 }}>Contact Information</h2>
417
- <p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>
418
- Please provide your contact information for the booking.
419
- </p>
431
+ <h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 4 }}>{t("forge.coaching_booking.details_title")}</h2>
432
+ <p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>{t("forge.coaching_booking.details_subtitle")}</p>
420
433
  <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
421
434
  <div style={{ display: "flex", gap: 8 }}>
422
- <input placeholder="First name" value={c.firstName} onChange={(e) => c.setFirstName(e.target.value)} style={inputStyle(theme)} />
423
- <input placeholder="Last name" value={c.lastName} onChange={(e) => c.setLastName(e.target.value)} style={inputStyle(theme)} />
435
+ <input placeholder={t("forge.coaching_booking.first_name_placeholder")} value={c.firstName} onChange={(e) => c.setFirstName(e.target.value)} style={inputStyle(theme)} />
436
+ <input placeholder={t("forge.coaching_booking.last_name_placeholder")} value={c.lastName} onChange={(e) => c.setLastName(e.target.value)} style={inputStyle(theme)} />
424
437
  </div>
425
438
  <div>
426
- <input placeholder="Email" type="email" value={c.email} onChange={(e) => c.setEmail(e.target.value)} style={inputStyle(theme)} />
427
- {c.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email) && <FieldError>Please enter a valid email address</FieldError>}
439
+ <input placeholder={t("forge.coaching_booking.email_placeholder")} type="email" value={c.email} onChange={(e) => c.setEmail(e.target.value)} style={inputStyle(theme)} />
440
+ {c.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email) && (
441
+ <FieldError>{t("forge.coaching_booking.email_invalid_hint")}</FieldError>
442
+ )}
428
443
  </div>
429
444
  <div>
430
- <input placeholder="Confirm email" type="email" value={confirmEmail} onChange={(e) => setConfirmEmail(e.target.value)} style={inputStyle(theme)} />
431
- {confirmEmail && c.email !== confirmEmail && <FieldError>Email addresses do not match</FieldError>}
445
+ <input placeholder={t("forge.coaching_booking.confirm_email_placeholder")} type="email" value={confirmEmail} onChange={(e) => setConfirmEmail(e.target.value)} style={inputStyle(theme)} />
446
+ {confirmEmail && c.email !== confirmEmail && (
447
+ <FieldError>{t("forge.coaching_booking.email_mismatch_hint")}</FieldError>
448
+ )}
432
449
  </div>
433
450
 
434
451
  {questions.map((q) => {
@@ -441,7 +458,7 @@ function DetailsStep({ c, product, fmt }: { c: Booking; product: CoachingProduct
441
458
  </span>
442
459
  {q.type === "select" && q.options && q.options.length > 0 ? (
443
460
  <select value={answers[q.id] ?? ""} onChange={(e) => onAnswer(q.id, e.target.value)} style={inputStyle(theme)}>
444
- <option value="">Select…</option>
461
+ <option value="">{t("forge.coaching_booking.select_placeholder")}</option>
445
462
  {q.options.map((opt) => (
446
463
  <option key={opt} value={opt}>
447
464
  {opt}
@@ -453,7 +470,7 @@ function DetailsStep({ c, product, fmt }: { c: Booking; product: CoachingProduct
453
470
  ) : (
454
471
  <input value={answers[q.id] ?? ""} onChange={(e) => onAnswer(q.id, e.target.value)} style={inputStyle(theme)} />
455
472
  )}
456
- {missing && <FieldError>This field is required</FieldError>}
473
+ {missing && <FieldError>{t("forge.coaching_booking.field_required")}</FieldError>}
457
474
  </label>
458
475
  );
459
476
  })}
@@ -475,7 +492,7 @@ function DetailsStep({ c, product, fmt }: { c: Booking; product: CoachingProduct
475
492
  <CancellationTerms terms={c.cancellation} />
476
493
 
477
494
  <p style={{ fontSize: 12, color: a(theme.colors.text, 0.6), marginTop: 4 }}>
478
- By continuing, you agree to receive booking confirmations and important updates about your session via email.
495
+ {t("forge.coaching_booking.consent")}
479
496
  </p>
480
497
  </div>
481
498
  <ActionBar
@@ -484,7 +501,13 @@ function DetailsStep({ c, product, fmt }: { c: Booking; product: CoachingProduct
484
501
  fmt={fmt}
485
502
  onBack={() => void c.releaseReservation()}
486
503
  onNext={onContinue}
487
- nextLabel={c.isProcessing ? "Processing…" : isFree ? "Confirm Booking" : "Continue to payment"}
504
+ nextLabel={
505
+ c.isProcessing
506
+ ? t("forge.coaching_booking.processing")
507
+ : isFree
508
+ ? t("forge.coaching_booking.confirm_booking")
509
+ : t("forge.coaching_booking.continue_to_payment")
510
+ }
488
511
  nextDisabled={c.isProcessing}
489
512
  />
490
513
  </div>
@@ -507,6 +530,7 @@ function PaymentStep({
507
530
  fmt: (n: number) => string;
508
531
  renderPayment?: (props: PaymentRenderProps) => ReactNode;
509
532
  }) {
533
+ const t = useForgeT();
510
534
  const theme = useForgeTheme();
511
535
  const total = c.totalAmount ?? Number(product.price);
512
536
  // Authoritative tax quote from start-payment — the charged total already
@@ -514,8 +538,8 @@ function PaymentStep({
514
538
  const taxSummary = summarizeTaxQuote(c.taxQuote);
515
539
  return (
516
540
  <div>
517
- <h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>Payment</h2>
518
- <p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>Complete your purchase to secure your booking.</p>
541
+ <h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>{t("forge.coaching_booking.payment_title")}</h2>
542
+ <p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>{t("forge.coaching_booking.payment_subtitle")}</p>
519
543
  <div
520
544
  style={{
521
545
  padding: 20,
@@ -531,12 +555,14 @@ function PaymentStep({
531
555
  fmt={fmt}
532
556
  />
533
557
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
534
- <span style={{ fontSize: 16 }}>Total Amount</span>
558
+ <span style={{ fontSize: 16 }}>{t("forge.coaching_booking.total_amount")}</span>
535
559
  <span style={{ fontSize: 22, fontWeight: 700, color: theme.colors.primary, textAlign: "right" }}>
536
560
  {fmt(total)}
537
561
  {taxSummary && (
538
562
  <span style={{ display: "block", fontSize: 12, fontWeight: 400, color: theme.colors.text, opacity: 0.65 }}>
539
- {taxSummary.mode === "inclusive" ? taxSummary.label : `incl. ${taxSummary.formattedTax} tax`}
563
+ {taxSummary.mode === "inclusive"
564
+ ? taxSummary.label
565
+ : t("forge.coaching_booking.tax_included", { amount: taxSummary.formattedTax })}
540
566
  </span>
541
567
  )}
542
568
  </span>
@@ -550,19 +576,19 @@ function PaymentStep({
550
576
  so it is checked BEFORE the "not configured" branch. */}
551
577
  {c.isPaystack ? (
552
578
  c.canOpenPaystack ? (
553
- <PaystackPayButton onPay={c.openPaystackCheckout} label={`Pay ${fmt(total)}`} />
579
+ <PaystackPayButton onPay={c.openPaystackCheckout} label={t("forge.coaching_booking.pay", { amount: fmt(total) })} />
554
580
  ) : (
555
- <p style={{ opacity: 0.7 }}>Preparing payment…</p>
581
+ <p style={{ opacity: 0.7 }}>{t("forge.coaching_booking.preparing_payment")}</p>
556
582
  )
557
583
  ) : !renderPayment ? (
558
- <p style={{ color: "#ef4444", fontSize: 14 }}>Payment is not configured.</p>
584
+ <p style={{ color: "#ef4444", fontSize: 14 }}>{t("forge.coaching_booking.payment_not_configured")}</p>
559
585
  ) : c.clientSecret ? (
560
586
  renderPayment({ clientSecret: c.clientSecret, returnUrl: c.returnUrl, amount: total, mode: "redirect" })
561
587
  ) : (
562
- <p style={{ opacity: 0.7 }}>Preparing payment…</p>
588
+ <p style={{ opacity: 0.7 }}>{t("forge.coaching_booking.preparing_payment")}</p>
563
589
  )}
564
590
  <button onClick={() => c.setStep("details")} style={{ ...secondaryButtonStyle(theme), marginTop: 16 }}>
565
- Back
591
+ {t("forge.coaching_booking.back")}
566
592
  </button>
567
593
  </div>
568
594
  );
@@ -586,6 +612,7 @@ function ActionBar({
586
612
  nextLabel?: string;
587
613
  nextDisabled?: boolean;
588
614
  }) {
615
+ const t = useForgeT();
589
616
  const theme = useForgeTheme();
590
617
  const total = c.totalAmount ?? Number(product.price);
591
618
  return (
@@ -605,14 +632,14 @@ function ActionBar({
605
632
  fmt={fmt}
606
633
  />
607
634
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
608
- <span style={{ fontWeight: 600 }}>Total</span>
635
+ <span style={{ fontWeight: 600 }}>{t("forge.coaching_booking.total")}</span>
609
636
  <span style={{ fontSize: 18, fontWeight: 700, color: theme.colors.primary }}>{fmt(total)}</span>
610
637
  </div>
611
638
  </div>
612
639
  {(onBack || onNext) && (
613
640
  <div style={{ display: "flex", gap: 8, justifyContent: "space-between" }}>
614
641
  <button onClick={onBack} disabled={!onBack} style={{ ...secondaryButtonStyle(theme), opacity: onBack ? 1 : 0.4 }}>
615
- Back
642
+ {t("forge.coaching_booking.back")}
616
643
  </button>
617
644
  {onNext && (
618
645
  <button onClick={onNext} disabled={nextDisabled} style={{ ...primaryButtonStyle(theme), opacity: nextDisabled ? 0.6 : 1 }}>