@tribe-nest/forge 3.26.0 → 3.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/package.json +6 -3
  2. package/src/_tests/publishedResolvability.spec.ts +184 -0
  3. package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
  4. package/src/_tests/workspaceAliases.ts +40 -0
  5. package/src/client/_tests/localeHeader.spec.tsx +98 -0
  6. package/src/client/activeLocale.ts +34 -0
  7. package/src/client/createForgeClient.ts +13 -0
  8. package/src/contexts/CartContext.tsx +76 -0
  9. package/src/contexts/PublicAuthContext.tsx +34 -5
  10. package/src/contexts/_tests/CartContext.spec.tsx +210 -0
  11. package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
  12. package/src/data/queries/useBroadcasts.ts +151 -0
  13. package/src/data/queries/useCheckouts.ts +83 -0
  14. package/src/data/queries/useMyBookings.ts +9 -1
  15. package/src/data/queries/useWebsite.ts +7 -0
  16. package/src/i18n/CONVENTION.md +184 -0
  17. package/src/i18n/_tests/translationKeys.spec.ts +153 -0
  18. package/src/i18n/de.json +1019 -0
  19. package/src/i18n/en.json +1019 -0
  20. package/src/i18n/index.ts +125 -0
  21. package/src/index.ts +21 -0
  22. package/src/provider/ForgeAppProvider.tsx +2 -1
  23. package/src/provider/SiteConfigProvider.tsx +11 -0
  24. package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
  25. package/src/ui/format/_tests/pwyw.spec.ts +65 -8
  26. package/src/ui/format/membershipPwyw.ts +164 -0
  27. package/src/ui/format/pwyw.ts +37 -0
  28. package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
  29. package/src/ui/headless/broadcast/broadcastState.ts +158 -0
  30. package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
  31. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
  32. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
  33. package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
  34. package/src/ui/headless/checkout/useCartResume.ts +340 -0
  35. package/src/ui/headless/checkout/useCheckout.ts +9 -2
  36. package/src/ui/headless/event/useEventCheckout.ts +8 -13
  37. package/src/ui/headless/index.ts +32 -0
  38. package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
  39. package/src/ui/index.ts +38 -0
  40. package/src/ui/media/CallHelpHint.tsx +87 -0
  41. package/src/ui/media/CallStage.tsx +542 -0
  42. package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
  43. package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
  44. package/src/ui/media/_tests/callState.spec.ts +452 -0
  45. package/src/ui/media/_tests/fakeNode.ts +178 -0
  46. package/src/ui/media/bookingSession.tsx +194 -0
  47. package/src/ui/media/callState.ts +341 -0
  48. package/src/ui/media/index.ts +135 -0
  49. package/src/ui/shell/TribeNestApp.tsx +36 -27
  50. package/src/ui/styled/AccountDashboard.tsx +289 -96
  51. package/src/ui/styled/AiAgentWidget.tsx +10 -6
  52. package/src/ui/styled/AudioPlayer.tsx +14 -6
  53. package/src/ui/styled/BlogCategory.tsx +4 -2
  54. package/src/ui/styled/BlogComments.tsx +27 -20
  55. package/src/ui/styled/BlogList.tsx +5 -3
  56. package/src/ui/styled/BlogPost.tsx +14 -11
  57. package/src/ui/styled/BroadcastWatch.tsx +107 -0
  58. package/src/ui/styled/BundleConfirmation.tsx +32 -23
  59. package/src/ui/styled/CancellationTerms.tsx +3 -1
  60. package/src/ui/styled/Cart.tsx +32 -14
  61. package/src/ui/styled/ChatRoom.tsx +65 -20
  62. package/src/ui/styled/Checkout.tsx +151 -89
  63. package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
  64. package/src/ui/styled/CoachingBooking.tsx +71 -44
  65. package/src/ui/styled/CoachingConfirmation.tsx +56 -19
  66. package/src/ui/styled/CoachingDetail.tsx +15 -11
  67. package/src/ui/styled/CohortPage.tsx +10 -8
  68. package/src/ui/styled/ConfirmSubscription.tsx +14 -8
  69. package/src/ui/styled/ContactForm.tsx +31 -12
  70. package/src/ui/styled/CookieConsent.tsx +19 -15
  71. package/src/ui/styled/CourseAccess.tsx +54 -47
  72. package/src/ui/styled/CourseCheckout.tsx +56 -38
  73. package/src/ui/styled/CourseConfirmation.tsx +39 -17
  74. package/src/ui/styled/CourseDetail.tsx +21 -15
  75. package/src/ui/styled/CoursesGrid.tsx +4 -2
  76. package/src/ui/styled/CurrencySwitcher.tsx +4 -1
  77. package/src/ui/styled/DiscountCode.tsx +22 -18
  78. package/src/ui/styled/DonationButton.tsx +52 -17
  79. package/src/ui/styled/DonationPage.tsx +4 -4
  80. package/src/ui/styled/EmailListForm.tsx +22 -9
  81. package/src/ui/styled/EventConfirmation.tsx +37 -14
  82. package/src/ui/styled/EventCountdown.tsx +10 -6
  83. package/src/ui/styled/EventDetail.tsx +8 -6
  84. package/src/ui/styled/EventSeriesDetail.tsx +18 -13
  85. package/src/ui/styled/EventTickets.tsx +232 -108
  86. package/src/ui/styled/EventWaitlist.tsx +42 -33
  87. package/src/ui/styled/EventsList.tsx +15 -13
  88. package/src/ui/styled/ForgotPasswordForm.tsx +17 -12
  89. package/src/ui/styled/FormRenderer.tsx +12 -7
  90. package/src/ui/styled/InstallBanner.tsx +21 -18
  91. package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
  92. package/src/ui/styled/InvoicePayment.tsx +37 -16
  93. package/src/ui/styled/LeadMagnet.tsx +9 -7
  94. package/src/ui/styled/LiveBroadcastList.tsx +171 -0
  95. package/src/ui/styled/Loading.tsx +7 -1
  96. package/src/ui/styled/LoginForm.tsx +32 -14
  97. package/src/ui/styled/MembershipCheckout.tsx +341 -49
  98. package/src/ui/styled/MembershipTiers.tsx +34 -18
  99. package/src/ui/styled/OfferButton.tsx +33 -13
  100. package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
  101. package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
  102. package/src/ui/styled/PodcastEpisode.tsx +20 -9
  103. package/src/ui/styled/PodcastList.tsx +9 -3
  104. package/src/ui/styled/PodcastShow.tsx +29 -7
  105. package/src/ui/styled/PostsFeed.tsx +10 -8
  106. package/src/ui/styled/PresaleCode.tsx +8 -11
  107. package/src/ui/styled/ProductBrowseNav.tsx +21 -11
  108. package/src/ui/styled/ProductDetail.tsx +58 -28
  109. package/src/ui/styled/ReactionBar.tsx +4 -2
  110. package/src/ui/styled/ReplayList.tsx +24 -13
  111. package/src/ui/styled/ResetPasswordForm.tsx +32 -15
  112. package/src/ui/styled/ResumeCart.tsx +289 -0
  113. package/src/ui/styled/ReviewForm.tsx +52 -58
  114. package/src/ui/styled/SignupForm.tsx +34 -16
  115. package/src/ui/styled/TicketTransfer.tsx +55 -54
  116. package/src/ui/styled/UserMenu.tsx +16 -14
  117. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
  118. package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
  119. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
  120. package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
  121. package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
  122. package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
  123. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
  124. package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
  125. package/src/ui/styled/community/CommunityComposer.tsx +182 -3
  126. package/src/ui/styled/community/CommunityFeed.tsx +36 -51
  127. package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
  128. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
  129. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
@@ -2,6 +2,7 @@ import type { CSSProperties } from "react";
2
2
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
3
3
  import { useCourseClassroom, type UseCourseClassroomOptions } from "../headless/course/useCourseClassroom";
4
4
  import { Loading } from "./Loading";
5
+ import { useForgeT } from "../../i18n";
5
6
 
6
7
  export interface CourseAccessProps extends UseCourseClassroomOptions {
7
8
  /** Course access id (route param). */
@@ -36,7 +37,8 @@ export function CourseAccess({
36
37
  style,
37
38
  ...classroomOptions
38
39
  }: CourseAccessProps) {
39
- const t = useThemeTokens();
40
+ const t = useForgeT();
41
+ const tokens = useThemeTokens();
40
42
  const classroom = useCourseClassroom(accessId, classroomOptions);
41
43
  const go = navigate ?? ((href: string) => window.location.assign(href));
42
44
 
@@ -46,24 +48,24 @@ export function CourseAccess({
46
48
  maxWidth: 520,
47
49
  margin: "0 auto",
48
50
  padding: 28,
49
- borderRadius: t.cornerRadius,
50
- border: `1px solid ${t.border}`,
51
- background: t.surface,
52
- color: t.text,
51
+ borderRadius: tokens.cornerRadius,
52
+ border: `1px solid ${tokens.border}`,
53
+ background: tokens.surface,
54
+ color: tokens.text,
53
55
  textAlign: "center",
54
56
  display: "flex",
55
57
  flexDirection: "column",
56
58
  gap: 12,
57
- fontFamily: t.fontFamily,
59
+ fontFamily: tokens.fontFamily,
58
60
  };
59
61
 
60
62
  const primaryButton: CSSProperties = {
61
63
  alignSelf: "center",
62
64
  padding: "10px 20px",
63
- borderRadius: t.cornerRadius,
65
+ borderRadius: tokens.cornerRadius,
64
66
  border: "none",
65
- background: t.primary,
66
- color: t.textPrimary,
67
+ background: tokens.primary,
68
+ color: tokens.textPrimary,
67
69
  fontSize: 14,
68
70
  fontWeight: 700,
69
71
  cursor: "pointer",
@@ -74,17 +76,17 @@ export function CourseAccess({
74
76
  if (gate) {
75
77
  return (
76
78
  <div className={className} style={{ ...panel, ...style }}>
77
- <h1 style={{ fontSize: 20, fontWeight: 800, fontFamily: t.headingFontFamily || t.fontFamily }}>
79
+ <h1 style={{ fontSize: 20, fontWeight: 800, fontFamily: tokens.headingFontFamily || tokens.fontFamily }}>
78
80
  {gate.title}
79
81
  </h1>
80
- <p style={{ fontSize: 14, color: t.muted, lineHeight: 1.6 }}>{gate.body}</p>
82
+ <p style={{ fontSize: 14, color: tokens.muted, lineHeight: 1.6 }}>{gate.body}</p>
81
83
 
82
84
  {/* Naming the session they are in is what makes "wrong account"
83
85
  actionable — otherwise the reader has no idea which of their
84
86
  addresses they are currently signed in with. */}
85
87
  {gate.kind === "wrong_account" && classroom.signedInEmail && (
86
- <p style={{ fontSize: 13, color: t.muted }}>
87
- Signed in as <strong style={{ color: t.text }}>{classroom.signedInEmail}</strong>
88
+ <p style={{ fontSize: 13, color: tokens.muted }}>
89
+ {t("forge.course_access.signed_in_as")} <strong style={{ color: tokens.text }}>{classroom.signedInEmail}</strong>
88
90
  </p>
89
91
  )}
90
92
 
@@ -120,8 +122,8 @@ export function CourseAccess({
120
122
  gap: 24,
121
123
  maxWidth: 1100,
122
124
  margin: "0 auto",
123
- color: t.text,
124
- fontFamily: t.fontFamily,
125
+ color: tokens.text,
126
+ fontFamily: tokens.fontFamily,
125
127
  ...style,
126
128
  }}
127
129
  >
@@ -135,56 +137,59 @@ export function CourseAccess({
135
137
  alignItems: "center",
136
138
  gap: 12,
137
139
  padding: "10px 14px",
138
- borderRadius: t.cornerRadius,
139
- border: `1px solid ${t.border}`,
140
- background: `${t.primary}0d`,
140
+ borderRadius: tokens.cornerRadius,
141
+ border: `1px solid ${tokens.border}`,
142
+ background: `${tokens.primary}0d`,
141
143
  fontSize: 13,
142
144
  }}
143
145
  >
144
- <span style={{ flex: "1 1 260px", color: t.muted, lineHeight: 1.5 }}>
145
- Anyone with this link can open your course. Create an account with{" "}
146
- <strong style={{ color: t.text }}>{securePrompt.email}</strong> to keep it to yourself.
146
+ <span style={{ flex: "1 1 260px", color: tokens.muted, lineHeight: 1.5 }}>
147
+ {t("forge.course_access.secure_prompt_lead")}{" "}
148
+ <strong style={{ color: tokens.text }}>{securePrompt.email}</strong> {t("forge.course_access.secure_prompt_tail")}
147
149
  </span>
148
150
  <button
149
151
  type="button"
150
152
  onClick={() => go(securePrompt.href)}
151
153
  style={{
152
154
  padding: "6px 14px",
153
- borderRadius: t.cornerRadius,
154
- border: `1px solid ${t.primary}`,
155
+ borderRadius: tokens.cornerRadius,
156
+ border: `1px solid ${tokens.primary}`,
155
157
  background: "transparent",
156
- color: t.primary,
158
+ color: tokens.primary,
157
159
  fontSize: 13,
158
160
  fontWeight: 600,
159
161
  cursor: "pointer",
160
162
  }}
161
163
  >
162
- Secure my access
164
+ {t("forge.course_access.secure_action")}
163
165
  </button>
164
166
  <button
165
167
  type="button"
166
168
  onClick={classroom.dismissSecurePrompt}
167
- aria-label="Dismiss"
169
+ aria-label={t("forge.course_access.aria_dismiss")}
168
170
  style={{
169
171
  padding: "6px 10px",
170
172
  border: "none",
171
173
  background: "transparent",
172
- color: t.muted,
174
+ color: tokens.muted,
173
175
  fontSize: 13,
174
176
  cursor: "pointer",
175
177
  }}
176
178
  >
177
- Not now
179
+ {t("forge.course_access.dismiss")}
178
180
  </button>
179
181
  </div>
180
182
  )}
181
183
 
182
184
  <header>
183
- <h1 style={{ fontSize: 26, fontWeight: 800, fontFamily: t.headingFontFamily || t.fontFamily }}>
185
+ <h1 style={{ fontSize: 26, fontWeight: 800, fontFamily: tokens.headingFontFamily || tokens.fontFamily }}>
184
186
  {data.course.title}
185
187
  </h1>
186
- <p style={{ fontSize: 14, color: t.muted }}>
187
- {classroom.completedCount} of {classroom.lessons.length} lessons completed
188
+ <p style={{ fontSize: 14, color: tokens.muted }}>
189
+ {t("forge.course_access.lessons_completed", {
190
+ completed: classroom.completedCount,
191
+ total: classroom.lessons.length,
192
+ })}
188
193
  </p>
189
194
  </header>
190
195
 
@@ -205,12 +210,12 @@ export function CourseAccess({
205
210
  style={{
206
211
  textAlign: "left",
207
212
  padding: "8px 10px",
208
- borderRadius: t.cornerRadius,
213
+ borderRadius: tokens.cornerRadius,
209
214
  border: "none",
210
215
  cursor: "pointer",
211
216
  fontSize: 14,
212
- background: isSelected ? `${t.primary}20` : "transparent",
213
- color: isSelected ? t.primary : t.text,
217
+ background: isSelected ? `${tokens.primary}20` : "transparent",
218
+ color: isSelected ? tokens.primary : tokens.text,
214
219
  fontWeight: isSelected ? 600 : 400,
215
220
  }}
216
221
  >
@@ -235,7 +240,7 @@ export function CourseAccess({
235
240
  controls
236
241
  playsInline
237
242
  onTimeUpdate={(e) => classroom.reportProgress(e.currentTarget.currentTime, e.currentTarget.duration)}
238
- style={{ width: "100%", aspectRatio: "16/9", background: "#000", borderRadius: t.cornerRadius }}
243
+ style={{ width: "100%", aspectRatio: "16/9", background: "#000", borderRadius: tokens.cornerRadius }}
239
244
  />
240
245
  ) : (
241
246
  <div
@@ -245,12 +250,12 @@ export function CourseAccess({
245
250
  display: "flex",
246
251
  alignItems: "center",
247
252
  justifyContent: "center",
248
- background: `${t.text}0d`,
249
- borderRadius: t.cornerRadius,
250
- color: t.muted,
253
+ background: `${tokens.text}0d`,
254
+ borderRadius: tokens.cornerRadius,
255
+ color: tokens.muted,
251
256
  }}
252
257
  >
253
- No video available for this lesson
258
+ {t("forge.course_access.no_video")}
254
259
  </div>
255
260
  )}
256
261
 
@@ -259,7 +264,7 @@ export function CourseAccess({
259
264
  fontSize: 20,
260
265
  fontWeight: 700,
261
266
  margin: "16px 0 8px",
262
- fontFamily: t.headingFontFamily || t.fontFamily,
267
+ fontFamily: tokens.headingFontFamily || tokens.fontFamily,
263
268
  }}
264
269
  >
265
270
  {selected.title}
@@ -267,7 +272,7 @@ export function CourseAccess({
267
272
 
268
273
  {files.length > 0 && (
269
274
  <div style={{ marginBottom: 16 }}>
270
- <h3 style={{ fontWeight: 600, marginBottom: 8 }}>Additional Resources</h3>
275
+ <h3 style={{ fontWeight: 600, marginBottom: 8 }}>{t("forge.course_access.resources_title")}</h3>
271
276
  <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
272
277
  {files.map((file) => (
273
278
  <a
@@ -279,16 +284,18 @@ export function CourseAccess({
279
284
  display: "flex",
280
285
  justifyContent: "space-between",
281
286
  padding: "10px 12px",
282
- borderRadius: t.cornerRadius,
283
- border: `1px solid ${t.border}`,
284
- background: `${t.primary}10`,
285
- color: t.text,
287
+ borderRadius: tokens.cornerRadius,
288
+ border: `1px solid ${tokens.border}`,
289
+ background: `${tokens.primary}10`,
290
+ color: tokens.text,
286
291
  textDecoration: "none",
287
292
  fontSize: 14,
288
293
  }}
289
294
  >
290
295
  <span>{file.filename}</span>
291
- <span style={{ color: t.muted }}>{(file.size / 1024 / 1024).toFixed(2)} MB</span>
296
+ <span style={{ color: tokens.muted }}>
297
+ {t("forge.course_access.file_size_mb", { size: (file.size / 1024 / 1024).toFixed(2) })}
298
+ </span>
292
299
  </a>
293
300
  ))}
294
301
  </div>
@@ -300,7 +307,7 @@ export function CourseAccess({
300
307
  )}
301
308
  </>
302
309
  ) : (
303
- <p style={{ color: t.muted }}>No lesson selected.</p>
310
+ <p style={{ color: tokens.muted }}>{t("forge.course_access.no_lesson_selected")}</p>
304
311
  )}
305
312
  </main>
306
313
  </div>
@@ -2,6 +2,7 @@ import { type CSSProperties, type ReactNode, useMemo, useState } from "react";
2
2
  import { X } from "lucide-react";
3
3
  import type { AppliedDiscountCoupon, PublicCourse, QuestionnaireQuestion } from "../../types/models";
4
4
  import { useCourseCheckout } from "../headless/course/useCourseCheckout";
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";
@@ -54,12 +55,13 @@ const a = (hex: string, alpha: number) => hex + Math.round(alpha * 255).toString
54
55
  */
55
56
  export function CourseCheckout({
56
57
  slug,
57
- triggerText = "Enroll",
58
+ triggerText,
58
59
  formatAmount,
59
60
  renderPayment,
60
61
  finalisePath,
61
62
  onComplete,
62
63
  }: CourseCheckoutProps) {
64
+ const t = useForgeT();
63
65
  const theme = useForgeTheme();
64
66
  const registered = usePaymentRenderer();
65
67
  const fmt = useAmountFormatter(formatAmount);
@@ -70,7 +72,7 @@ export function CourseCheckout({
70
72
 
71
73
  const tryClose = (next: boolean) => {
72
74
  if (!next && c.step !== "details" && typeof window !== "undefined") {
73
- if (!window.confirm("Are you sure you want to leave checkout?")) return;
75
+ if (!window.confirm(t("forge.course_checkout.confirm_leave"))) return;
74
76
  }
75
77
  setOpen(next);
76
78
  };
@@ -78,7 +80,7 @@ export function CourseCheckout({
78
80
  return (
79
81
  <DialogRoot open={open} onOpenChange={tryClose}>
80
82
  <DialogTrigger asChild>
81
- <button style={triggerButtonStyle(theme)}>{triggerText}</button>
83
+ <button style={triggerButtonStyle(theme)}>{triggerText ?? t("forge.course_checkout.trigger")}</button>
82
84
  </DialogTrigger>
83
85
  <DialogPortal>
84
86
  <DialogOverlay style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", zIndex: 50 }} />
@@ -101,16 +103,18 @@ export function CourseCheckout({
101
103
  aria-describedby={undefined}
102
104
  >
103
105
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
104
- <DialogTitle style={{ fontSize: 18, fontWeight: 700, margin: 0 }}>{c.course?.title ?? "Checkout"}</DialogTitle>
106
+ <DialogTitle style={{ fontSize: 18, fontWeight: 700, margin: 0 }}>
107
+ {c.course?.title ?? t("forge.course_checkout.title")}
108
+ </DialogTitle>
105
109
  <DialogClose asChild>
106
- <button aria-label="Close" style={closeButtonStyle(theme)}>
110
+ <button aria-label={t("forge.course_checkout.aria_close")} style={closeButtonStyle(theme)}>
107
111
  <X size={18} />
108
112
  </button>
109
113
  </DialogClose>
110
114
  </div>
111
115
 
112
116
  {c.isLoading && <Loading />}
113
- {!c.isLoading && !c.course && <p style={{ marginTop: 8 }}>Course not found.</p>}
117
+ {!c.isLoading && !c.course && <p style={{ marginTop: 8 }}>{t("forge.course_checkout.not_found")}</p>}
114
118
  {c.course && <CourseCheckoutBody c={c} course={c.course} fmt={fmt} renderPayment={renderPay} />}
115
119
  </DialogContent>
116
120
  </DialogPortal>
@@ -184,6 +188,7 @@ function Progress({ current }: { current: number }) {
184
188
 
185
189
  // ── Step 1: buyer details + questionnaire ─────────────────────────────────────
186
190
  function DetailsStep({ c, course, fmt }: { c: Checkout; course: PublicCourse; fmt: (n: number) => string }) {
191
+ const t = useForgeT();
187
192
  const theme = useForgeTheme();
188
193
  const [confirmEmail, setConfirmEmail] = useState("");
189
194
  const [answers, setAnswers] = useState<Record<string, string>>({});
@@ -202,38 +207,43 @@ function DetailsStep({ c, course, fmt }: { c: Checkout; course: PublicCourse; fm
202
207
 
203
208
  const onContinue = () => {
204
209
  setLocalError(null);
205
- if (!c.firstName.trim() || !c.lastName.trim()) return setLocalError("Please enter your name.");
206
- if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email)) return setLocalError("Please enter a valid email.");
207
- if (c.email !== confirmEmail) return setLocalError("Emails do not match.");
210
+ if (!c.firstName.trim() || !c.lastName.trim()) return setLocalError(t("forge.course_checkout.error_name_required"));
211
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email)) return setLocalError(t("forge.course_checkout.error_email_invalid"));
212
+ if (c.email !== confirmEmail) return setLocalError(t("forge.course_checkout.error_email_mismatch"));
208
213
  for (const q of questions) {
209
- if (!q.optional && !(answers[q.id] ?? "").trim()) return setLocalError(`Please answer: ${q.question}`);
214
+ if (!q.optional && !(answers[q.id] ?? "").trim())
215
+ return setLocalError(t("forge.course_checkout.error_answer_required", { question: q.question }));
210
216
  }
211
217
  c.continueToPayment();
212
218
  };
213
219
 
214
220
  return (
215
221
  <div>
216
- <h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 4 }}>{isFree ? "Get access" : "Contact Information"}</h2>
217
- <p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>
218
- Please provide your contact information for the purchase.
219
- </p>
222
+ <h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 4 }}>
223
+ {isFree ? t("forge.course_checkout.free_title") : t("forge.course_checkout.details_title")}
224
+ </h2>
225
+ <p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>{t("forge.course_checkout.details_subtitle")}</p>
220
226
  <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
221
227
  <div style={{ display: "flex", gap: 8 }}>
222
- <input placeholder="First name" value={c.firstName} onChange={(e) => c.setFirstName(e.target.value)} style={inputStyle(theme)} />
223
- <input placeholder="Last name" value={c.lastName} onChange={(e) => c.setLastName(e.target.value)} style={inputStyle(theme)} />
228
+ <input placeholder={t("forge.course_checkout.first_name_placeholder")} value={c.firstName} onChange={(e) => c.setFirstName(e.target.value)} style={inputStyle(theme)} />
229
+ <input placeholder={t("forge.course_checkout.last_name_placeholder")} value={c.lastName} onChange={(e) => c.setLastName(e.target.value)} style={inputStyle(theme)} />
224
230
  </div>
225
231
  <div>
226
- <input placeholder="Email" type="email" value={c.email} onChange={(e) => c.setEmail(e.target.value)} style={inputStyle(theme)} />
227
- {c.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email) && <FieldError>Please enter a valid email address</FieldError>}
232
+ <input placeholder={t("forge.course_checkout.email_placeholder")} type="email" value={c.email} onChange={(e) => c.setEmail(e.target.value)} style={inputStyle(theme)} />
233
+ {c.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email) && (
234
+ <FieldError>{t("forge.course_checkout.email_invalid_hint")}</FieldError>
235
+ )}
228
236
  </div>
229
237
  <div>
230
- <input placeholder="Confirm email" type="email" value={confirmEmail} onChange={(e) => setConfirmEmail(e.target.value)} style={inputStyle(theme)} />
231
- {confirmEmail && c.email !== confirmEmail && <FieldError>Email addresses do not match</FieldError>}
238
+ <input placeholder={t("forge.course_checkout.confirm_email_placeholder")} type="email" value={confirmEmail} onChange={(e) => setConfirmEmail(e.target.value)} style={inputStyle(theme)} />
239
+ {confirmEmail && c.email !== confirmEmail && (
240
+ <FieldError>{t("forge.course_checkout.email_mismatch_hint")}</FieldError>
241
+ )}
232
242
  </div>
233
243
 
234
244
  {questions.length > 0 && (
235
245
  <div style={{ marginTop: 8, paddingTop: 16, borderTop: `1px solid ${a(theme.colors.primary, 0.13)}` }}>
236
- <h3 style={{ fontSize: 17, fontWeight: 700, marginBottom: 12 }}>Additional Information</h3>
246
+ <h3 style={{ fontSize: 17, fontWeight: 700, marginBottom: 12 }}>{t("forge.course_checkout.questions_title")}</h3>
237
247
  <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
238
248
  {questions.map((q) => {
239
249
  const missing = !q.optional && !(answers[q.id] ?? "").trim();
@@ -245,7 +255,7 @@ function DetailsStep({ c, course, fmt }: { c: Checkout; course: PublicCourse; fm
245
255
  </span>
246
256
  {q.type === "select" && q.options && q.options.length > 0 ? (
247
257
  <select value={answers[q.id] ?? ""} onChange={(e) => onAnswer(q.id, e.target.value)} style={inputStyle(theme)}>
248
- <option value="">Select…</option>
258
+ <option value="">{t("forge.course_checkout.select_placeholder")}</option>
249
259
  {q.options.map((opt) => (
250
260
  <option key={opt} value={opt}>
251
261
  {opt}
@@ -262,7 +272,7 @@ function DetailsStep({ c, course, fmt }: { c: Checkout; course: PublicCourse; fm
262
272
  ) : (
263
273
  <input value={answers[q.id] ?? ""} onChange={(e) => onAnswer(q.id, e.target.value)} style={inputStyle(theme)} />
264
274
  )}
265
- {missing && <FieldError>This field is required</FieldError>}
275
+ {missing && <FieldError>{t("forge.course_checkout.field_required")}</FieldError>}
266
276
  </label>
267
277
  );
268
278
  })}
@@ -279,9 +289,7 @@ function DetailsStep({ c, course, fmt }: { c: Checkout; course: PublicCourse; fm
279
289
  </div>
280
290
  )}
281
291
 
282
- <p style={{ fontSize: 12, opacity: 0.6 }}>
283
- By continuing, you agree to receive course access and important updates via email.
284
- </p>
292
+ <p style={{ fontSize: 12, opacity: 0.6 }}>{t("forge.course_checkout.consent")}</p>
285
293
 
286
294
  {(localError || c.error) && <p style={{ color: "#ef4444", fontSize: 14 }}>{localError ?? c.error}</p>}
287
295
  </div>
@@ -293,7 +301,13 @@ function DetailsStep({ c, course, fmt }: { c: Checkout; course: PublicCourse; fm
293
301
  discountAmount={c.coupon.discountAmount}
294
302
  appliedCoupons={c.coupon.appliedCoupons}
295
303
  onNext={onContinue}
296
- nextLabel={c.isProcessing ? "Processing…" : isFree ? "Confirm Purchase" : "Continue to Payment"}
304
+ nextLabel={
305
+ c.isProcessing
306
+ ? t("forge.course_checkout.processing")
307
+ : isFree
308
+ ? t("forge.course_checkout.confirm_purchase")
309
+ : t("forge.course_checkout.continue_to_payment")
310
+ }
297
311
  nextDisabled={c.isProcessing}
298
312
  />
299
313
  </div>
@@ -312,6 +326,7 @@ function PaymentStep({
312
326
  fmt: (n: number) => string;
313
327
  renderPayment?: (props: PaymentRenderProps) => ReactNode;
314
328
  }) {
329
+ const t = useForgeT();
315
330
  const theme = useForgeTheme();
316
331
  // The server's figure once it exists — the booking quote is already net of
317
332
  // any discount, so the buyer never sees a total that disagrees with the charge.
@@ -322,8 +337,8 @@ function PaymentStep({
322
337
  const taxSummary = summarizeTaxQuote(c.taxQuote);
323
338
  return (
324
339
  <div>
325
- <h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>Payment</h2>
326
- <p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>Complete your purchase to access the course.</p>
340
+ <h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>{t("forge.course_checkout.payment_title")}</h2>
341
+ <p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>{t("forge.course_checkout.payment_subtitle")}</p>
327
342
  <div
328
343
  style={{
329
344
  padding: 20,
@@ -339,7 +354,7 @@ function PaymentStep({
339
354
  fmt={fmt}
340
355
  />
341
356
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
342
- <span style={{ fontSize: 16 }}>Total Amount</span>
357
+ <span style={{ fontSize: 16 }}>{t("forge.course_checkout.total_amount")}</span>
343
358
  <span style={{ fontSize: 22, fontWeight: 700, color: theme.colors.primary }}>
344
359
  {compareAt != null && compareAt > amount && (
345
360
  <span style={{ textDecoration: "line-through", opacity: 0.6, marginRight: 8, fontWeight: 400, fontSize: 16 }}>
@@ -349,7 +364,9 @@ function PaymentStep({
349
364
  {fmt(amount)}
350
365
  {taxSummary && (
351
366
  <span style={{ display: "block", fontSize: 12, fontWeight: 400, color: theme.colors.text, opacity: 0.65 }}>
352
- {taxSummary.mode === "inclusive" ? taxSummary.label : `+ ${taxSummary.formattedTax} tax`}
367
+ {taxSummary.mode === "inclusive"
368
+ ? taxSummary.label
369
+ : t("forge.course_checkout.tax_added", { amount: taxSummary.formattedTax })}
353
370
  </span>
354
371
  )}
355
372
  </span>
@@ -359,19 +376,19 @@ function PaymentStep({
359
376
  so it is checked BEFORE the "not configured" branch. */}
360
377
  {c.isPaystack ? (
361
378
  c.canOpenPaystack ? (
362
- <PaystackPayButton onPay={c.openPaystackCheckout} label={`Pay ${fmt(amount)}`} />
379
+ <PaystackPayButton onPay={c.openPaystackCheckout} label={t("forge.course_checkout.pay", { amount: fmt(amount) })} />
363
380
  ) : (
364
- <p style={{ opacity: 0.7 }}>Preparing payment…</p>
381
+ <p style={{ opacity: 0.7 }}>{t("forge.course_checkout.preparing_payment")}</p>
365
382
  )
366
383
  ) : !renderPayment ? (
367
- <p style={{ color: "#ef4444", fontSize: 14 }}>Payment is not configured.</p>
384
+ <p style={{ color: "#ef4444", fontSize: 14 }}>{t("forge.course_checkout.payment_not_configured")}</p>
368
385
  ) : c.clientSecret ? (
369
386
  renderPayment({ clientSecret: c.clientSecret, returnUrl: c.returnUrl, amount, mode: "redirect" })
370
387
  ) : (
371
- <p style={{ opacity: 0.7 }}>Preparing payment…</p>
388
+ <p style={{ opacity: 0.7 }}>{t("forge.course_checkout.preparing_payment")}</p>
372
389
  )}
373
390
  <button onClick={() => c.setStep("details")} style={{ ...secondaryButtonStyle(theme), marginTop: 16 }}>
374
- Back
391
+ {t("forge.course_checkout.back")}
375
392
  </button>
376
393
  </div>
377
394
  );
@@ -405,6 +422,7 @@ function ActionBar({
405
422
  nextLabel: string;
406
423
  nextDisabled?: boolean;
407
424
  }) {
425
+ const t = useForgeT();
408
426
  const theme = useForgeTheme();
409
427
  return (
410
428
  <div style={{ marginTop: 24 }}>
@@ -423,7 +441,7 @@ function ActionBar({
423
441
  fmt={fmt}
424
442
  />
425
443
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
426
- <span style={{ fontWeight: 600 }}>Total</span>
444
+ <span style={{ fontWeight: 600 }}>{t("forge.course_checkout.total")}</span>
427
445
  <span style={{ fontSize: 18, fontWeight: 700, color: theme.colors.primary }}>
428
446
  {compareAt != null && compareAt > amount && (
429
447
  <span style={{ textDecoration: "line-through", opacity: 0.6, marginRight: 8, fontWeight: 400, fontSize: 14 }}>
@@ -436,7 +454,7 @@ function ActionBar({
436
454
  </div>
437
455
  <div style={{ display: "flex", gap: 8, justifyContent: "space-between" }}>
438
456
  <button onClick={onBack} disabled={!onBack} style={{ ...secondaryButtonStyle(theme), opacity: onBack ? 1 : 0.4 }}>
439
- Back
457
+ {t("forge.course_checkout.back")}
440
458
  </button>
441
459
  <button onClick={onNext} disabled={nextDisabled} style={{ ...primaryButtonStyle(theme), opacity: nextDisabled ? 0.6 : 1 }}>
442
460
  {nextLabel}
@@ -13,6 +13,7 @@ import {
13
13
  ConfirmationLostCheckout,
14
14
  alpha,
15
15
  } from "./Confirmation";
16
+ import { useForgeT } from "../../i18n";
16
17
  import { useCourseBookingFinalize } from "../../data/queries/useFinalize";
17
18
  import { useGetCourse } from "../../data/queries/useCourses";
18
19
  import { useBookingSecret } from "../headless/booking/useBookingSecret";
@@ -39,6 +40,7 @@ export function CourseConfirmation({
39
40
  restartPath,
40
41
  }: CourseConfirmationProps) {
41
42
  const theme = useForgeTheme();
43
+ const t = useForgeT();
42
44
  const fmt = useAmountFormatter(formatAmount);
43
45
  // The credential the checkout stashed before the payment redirect. `resolved`
44
46
  // gates the finalise: storage can only be read on the client, so firing before
@@ -57,7 +59,7 @@ export function CourseConfirmation({
57
59
  if (!resolved || isLoading) {
58
60
  return (
59
61
  <ConfirmationStage>
60
- <Loading label="Confirming your enrollment…" />
62
+ <Loading label={t("forge.course_confirmation.loading")} />
61
63
  </ConfirmationStage>
62
64
  );
63
65
  }
@@ -70,17 +72,17 @@ export function CourseConfirmation({
70
72
  return (
71
73
  <ConfirmationStage>
72
74
  <ConfirmationLostCheckout
73
- title="We can’t confirm your enrollment in this browser"
74
- body="Confirming an enrollment needs the checkout details saved in the tab you started in, and they aren’t here. That usually means the tab was closed, this link was opened on a different device or browser, or private browsing cleared them. If your payment went through you’ll still get your enrollment email, so check your inbox before paying again. Otherwise, start the enrollment over."
75
+ title={t("forge.course_confirmation.lost_title")}
76
+ body={t("forge.course_confirmation.lost_body")}
75
77
  restartPath={restartPath ?? `/i/courses/${slug}`}
76
- restartLabel="Start over"
78
+ restartLabel={t("forge.course_confirmation.lost_restart")}
77
79
  />
78
80
  </ConfirmationStage>
79
81
  );
80
82
  }
81
83
  return (
82
84
  <ConfirmationStage>
83
- <p style={{ color: text }}>We couldn’t find your enrollment.</p>
85
+ <p style={{ color: text }}>{t("forge.course_confirmation.not_found")}</p>
84
86
  </ConfirmationStage>
85
87
  );
86
88
  }
@@ -107,15 +109,21 @@ export function CourseConfirmation({
107
109
  animationDelay: "0.15s",
108
110
  }}
109
111
  >
110
- {confirmed ? "Enrollment" : "Action needed"}
112
+ {confirmed
113
+ ? t("forge.course_confirmation.eyebrow_confirmed")
114
+ : t("forge.course_confirmation.eyebrow_action_needed")}
111
115
  </p>
112
116
  <h1 className="cf-rise" style={{ fontSize: 29, fontWeight: 800, lineHeight: 1.12, margin: "0 0 8px", animationDelay: "0.22s" }}>
113
- {confirmed ? "You’re enrolled 🎓" : "Enrollment not completed"}
117
+ {confirmed
118
+ ? t("forge.course_confirmation.title_confirmed")
119
+ : t("forge.course_confirmation.title_incomplete")}
114
120
  </h1>
115
121
  <p className="cf-rise" style={{ fontSize: 15, color: alpha(text, 0.7), margin: "0 auto", maxWidth: 340, animationDelay: "0.3s" }}>
116
122
  {confirmed
117
- ? `Welcome aboard${data.firstName ? `, ${data.firstName}` : ""}! Your spot is secured.`
118
- : `Your enrollment is ${data.status}. If you were charged, contact support and we’ll help.`}
123
+ ? data.firstName
124
+ ? t("forge.course_confirmation.body_confirmed", { first_name: data.firstName })
125
+ : t("forge.course_confirmation.body_confirmed_no_name")
126
+ : t("forge.course_confirmation.body_incomplete", { status: data.status })}
119
127
  </p>
120
128
  </div>
121
129
 
@@ -143,7 +151,9 @@ export function CourseConfirmation({
143
151
  <div style={{ minWidth: 0 }}>
144
152
  <div style={{ fontSize: 16, fontWeight: 800 }}>{course.title}</div>
145
153
  {course.modules?.length ? (
146
- <div style={{ fontSize: 13, color: alpha(text, 0.6) }}>{course.modules.length} modules</div>
154
+ <div style={{ fontSize: 13, color: alpha(text, 0.6) }}>
155
+ {t("forge.course_confirmation.module_count", { count: course.modules.length })}
156
+ </div>
147
157
  ) : null}
148
158
  </div>
149
159
  </div>
@@ -177,20 +187,30 @@ export function CourseConfirmation({
177
187
  </div>
178
188
  )}
179
189
  <dl style={{ display: "grid", gridTemplateColumns: "1fr auto", rowGap: 12, columnGap: 16, margin: 0 }}>
180
- <ConfirmationRow label="Student" value={`${data.firstName} ${data.lastName}`.trim() || "Not given"} />
181
- <ConfirmationRow label="Reference" value={`#${data.id.slice(0, 8).toUpperCase()}`} mono />
190
+ <ConfirmationRow
191
+ label={t("forge.course_confirmation.row_student")}
192
+ value={`${data.firstName} ${data.lastName}`.trim() || t("forge.course_confirmation.not_given")}
193
+ />
194
+ <ConfirmationRow
195
+ label={t("forge.course_confirmation.row_reference")}
196
+ value={`#${data.id.slice(0, 8).toUpperCase()}`}
197
+ mono
198
+ />
182
199
  {Number(data.taxAmount || 0) > 0 && !data.taxInclusive && (
183
- <ConfirmationRow label="Tax" value={fmt(Number(data.taxAmount), data.chargedCurrency || undefined)} />
200
+ <ConfirmationRow
201
+ label={t("forge.course_confirmation.row_tax")}
202
+ value={fmt(Number(data.taxAmount), data.chargedCurrency || undefined)}
203
+ />
184
204
  )}
185
205
  {/* The exact charged total (charged currency) when present — never a re-converted catalog amount. */}
186
206
  <ConfirmationRow
187
- label="Paid"
207
+ label={t("forge.course_confirmation.row_paid")}
188
208
  value={
189
209
  Number(data.totalAmountInChargedCurrency ?? data.totalAmount) > 0
190
210
  ? data.totalAmountInChargedCurrency
191
211
  ? fmt(Number(data.totalAmountInChargedCurrency), data.chargedCurrency || undefined)
192
212
  : fmt(data.totalAmount)
193
- : "Free"
213
+ : t("forge.course_confirmation.free")
194
214
  }
195
215
  accent
196
216
  strong
@@ -198,7 +218,9 @@ export function CourseConfirmation({
198
218
  </dl>
199
219
  {Number(data.taxAmount || 0) > 0 && data.taxInclusive && (
200
220
  <div style={{ textAlign: "right", fontSize: 12, color: alpha(text, 0.6), marginTop: 6 }}>
201
- Includes {fmt(Number(data.taxAmount), data.chargedCurrency || undefined)} tax
221
+ {t("forge.course_confirmation.tax_included", {
222
+ amount: fmt(Number(data.taxAmount), data.chargedCurrency || undefined),
223
+ })}
202
224
  </div>
203
225
  )}
204
226
  </div>
@@ -221,7 +243,7 @@ export function CourseConfirmation({
221
243
  textDecoration: "none",
222
244
  }}
223
245
  >
224
- Browse more courses <ArrowRight size={16} />
246
+ {t("forge.course_confirmation.explore")} <ArrowRight size={16} />
225
247
  </a>
226
248
  </div>
227
249
  </ConfirmationCard>