@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
@@ -13,6 +13,7 @@ import {
13
13
  ConfirmationLostCheckout,
14
14
  alpha,
15
15
  } from "./Confirmation";
16
+ import { useForgeT } from "../../i18n";
16
17
  import { useCoachingBookingFinalize } from "../../data/queries/useFinalize";
17
18
  import { useBookingSecret } from "../headless/booking/useBookingSecret";
18
19
  import { AddToCalendar } from "./AddToCalendar";
@@ -48,6 +49,7 @@ export function CoachingConfirmation({
48
49
  restartPath,
49
50
  }: CoachingConfirmationProps) {
50
51
  const theme = useForgeTheme();
52
+ const t = useForgeT();
51
53
  const fmt = useAmountFormatter(formatAmount);
52
54
  // The credential the booking flow stashed before the payment redirect.
53
55
  // `resolved` gates the finalise: storage can only be read on the client, so
@@ -66,7 +68,7 @@ export function CoachingConfirmation({
66
68
  if (!resolved || isLoading) {
67
69
  return (
68
70
  <ConfirmationStage>
69
- <Loading label="Confirming your booking…" />
71
+ <Loading label={t("forge.coaching_confirmation.loading")} />
70
72
  </ConfirmationStage>
71
73
  );
72
74
  }
@@ -78,10 +80,10 @@ export function CoachingConfirmation({
78
80
  return (
79
81
  <ConfirmationStage>
80
82
  <ConfirmationLostCheckout
81
- title="We can’t confirm your booking in this browser"
82
- body="Confirming a booking 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 confirmation email, so check your inbox before booking again. Otherwise, pick a new time and start over."
83
+ title={t("forge.coaching_confirmation.lost_title")}
84
+ body={t("forge.coaching_confirmation.lost_body")}
83
85
  restartPath={restartPath ?? `/i/coaching/${slug}`}
84
- restartLabel="Pick a new time"
86
+ restartLabel={t("forge.coaching_confirmation.lost_restart")}
85
87
  />
86
88
  </ConfirmationStage>
87
89
  );
@@ -108,14 +110,18 @@ export function CoachingConfirmation({
108
110
  animationDelay: "0.15s",
109
111
  }}
110
112
  >
111
- {confirmed ? "Session Pass" : "Action needed"}
113
+ {confirmed
114
+ ? t("forge.coaching_confirmation.eyebrow_confirmed")
115
+ : t("forge.coaching_confirmation.eyebrow_action_needed")}
112
116
  </p>
113
117
 
114
118
  <h1
115
119
  className="cf-rise"
116
120
  style={{ fontSize: 30, fontWeight: 800, lineHeight: 1.1, margin: "0 0 10px", animationDelay: "0.22s" }}
117
121
  >
118
- {confirmed ? "You’re booked in." : "Booking not completed"}
122
+ {confirmed
123
+ ? t("forge.coaching_confirmation.title_confirmed")
124
+ : t("forge.coaching_confirmation.title_incomplete")}
119
125
  </h1>
120
126
 
121
127
  <p
@@ -123,8 +129,17 @@ export function CoachingConfirmation({
123
129
  style={{ fontSize: 15, lineHeight: 1.5, color: alpha(text, 0.7), margin: "0 auto", maxWidth: 340, animationDelay: "0.3s" }}
124
130
  >
125
131
  {confirmed
126
- ? `Nice one${data?.firstName ? `, ${data.firstName}` : ""}. Your session is confirmed. We’ve sent the details to ${data?.email ?? "your inbox"}.`
127
- : `Your booking is currently ${data?.status ?? "unresolved"}. If you were charged, reach out and we’ll sort it right away.`}
132
+ ? data?.firstName
133
+ ? t("forge.coaching_confirmation.body_confirmed", {
134
+ first_name: data.firstName,
135
+ email: data?.email ?? t("forge.coaching_confirmation.email_fallback"),
136
+ })
137
+ : t("forge.coaching_confirmation.body_confirmed_no_name", {
138
+ email: data?.email ?? t("forge.coaching_confirmation.email_fallback"),
139
+ })
140
+ : t("forge.coaching_confirmation.body_incomplete", {
141
+ status: data?.status ?? t("forge.coaching_confirmation.status_unknown"),
142
+ })}
128
143
  </p>
129
144
  </div>
130
145
 
@@ -136,24 +151,44 @@ export function CoachingConfirmation({
136
151
  {data && (
137
152
  <dl style={{ display: "grid", gridTemplateColumns: "1fr auto", rowGap: 14, columnGap: 16, margin: 0 }}>
138
153
  {data.sessionStartTime && (
139
- <ConfirmationRow label="Session" value={formatSession(data.sessionStartTime)} accent strong />
154
+ <ConfirmationRow
155
+ label={t("forge.coaching_confirmation.row_session")}
156
+ value={formatSession(data.sessionStartTime)}
157
+ accent
158
+ strong
159
+ />
140
160
  )}
141
- {data.durationMinutes ? <ConfirmationRow label="Duration" value={`${data.durationMinutes} minutes`} /> : null}
142
- <ConfirmationRow label="Guest" value={`${data.firstName} ${data.lastName}`.trim() || "Not given"} />
143
- <ConfirmationRow label="Email" value={data.email} />
144
- <ConfirmationRow label="Reference" value={`#${data.id.slice(0, 8).toUpperCase()}`} mono />
161
+ {data.durationMinutes ? (
162
+ <ConfirmationRow
163
+ label={t("forge.coaching_confirmation.row_duration")}
164
+ value={t("forge.coaching_confirmation.duration_minutes", { count: data.durationMinutes })}
165
+ />
166
+ ) : null}
167
+ <ConfirmationRow
168
+ label={t("forge.coaching_confirmation.row_guest")}
169
+ value={`${data.firstName} ${data.lastName}`.trim() || t("forge.coaching_confirmation.not_given")}
170
+ />
171
+ <ConfirmationRow label={t("forge.coaching_confirmation.row_email")} value={data.email} />
172
+ <ConfirmationRow
173
+ label={t("forge.coaching_confirmation.row_reference")}
174
+ value={`#${data.id.slice(0, 8).toUpperCase()}`}
175
+ mono
176
+ />
145
177
  {Number(data.taxAmount || 0) > 0 && !data.taxInclusive && (
146
- <ConfirmationRow label="Tax" value={fmt(Number(data.taxAmount), data.chargedCurrency || undefined)} />
178
+ <ConfirmationRow
179
+ label={t("forge.coaching_confirmation.row_tax")}
180
+ value={fmt(Number(data.taxAmount), data.chargedCurrency || undefined)}
181
+ />
147
182
  )}
148
183
  {/* The exact charged total (charged currency) when present — never a re-converted catalog amount. */}
149
184
  <ConfirmationRow
150
- label="Paid"
185
+ label={t("forge.coaching_confirmation.row_paid")}
151
186
  value={
152
187
  Number(data.totalAmountInChargedCurrency ?? data.totalAmount) > 0
153
188
  ? data.totalAmountInChargedCurrency
154
189
  ? fmt(Number(data.totalAmountInChargedCurrency), data.chargedCurrency || undefined)
155
190
  : fmt(data.totalAmount)
156
- : "Free session"
191
+ : t("forge.coaching_confirmation.free")
157
192
  }
158
193
  accent
159
194
  strong
@@ -162,14 +197,16 @@ export function CoachingConfirmation({
162
197
  )}
163
198
  {data && Number(data.taxAmount || 0) > 0 && data.taxInclusive && (
164
199
  <div style={{ textAlign: "right", fontSize: 12, color: alpha(text, 0.6), marginTop: 6 }}>
165
- Includes {fmt(Number(data.taxAmount), data.chargedCurrency || undefined)} tax
200
+ {t("forge.coaching_confirmation.tax_included", {
201
+ amount: fmt(Number(data.taxAmount), data.chargedCurrency || undefined),
202
+ })}
166
203
  </div>
167
204
  )}
168
205
 
169
206
  {confirmed && data?.sessionStartTime && (
170
207
  <AddToCalendar
171
208
  variant="compact"
172
- title={data.productTitle || "Coaching session"}
209
+ title={data.productTitle || t("forge.coaching_confirmation.calendar_title")}
173
210
  start={data.sessionStartTime}
174
211
  end={data.sessionEndTime}
175
212
  defaultDurationMinutes={data.durationMinutes ?? undefined}
@@ -199,7 +236,7 @@ export function CoachingConfirmation({
199
236
  textDecoration: "none",
200
237
  }}
201
238
  >
202
- Explore more sessions <ArrowRight size={16} />
239
+ {t("forge.coaching_confirmation.explore")} <ArrowRight size={16} />
203
240
  </a>
204
241
  </div>
205
242
  </ConfirmationCard>
@@ -5,6 +5,7 @@ import { useThemeTokens } from "../theme/ForgeThemeProvider";
5
5
  import { useAmountFormatter } from "../format/useFormatCurrency";
6
6
  import { PriceDisplay } from "../format/PriceDisplay";
7
7
  import { usePricesIncludeTax } from "../../data/queries/useWebsite";
8
+ import { useForgeT } from "../../i18n";
8
9
  import { Loading } from "./Loading";
9
10
  import { CoachingBooking } from "./CoachingBooking";
10
11
  import { CancellationTerms } from "./CancellationTerms";
@@ -36,42 +37,45 @@ export function CoachingDetail({
36
37
  finalisePath,
37
38
  onComplete,
38
39
  }: CoachingDetailProps) {
39
- const t = useThemeTokens();
40
+ const tokens = useThemeTokens();
41
+ const t = useForgeT();
40
42
  const fmt = useAmountFormatter(formatAmount);
41
43
  const pricesIncludeTax = usePricesIncludeTax();
42
44
  const { data: product, isLoading } = useGetCoachingProduct(slug, { initialData: initialCoaching });
43
45
 
44
46
  if (isLoading) return <Loading fullPage />;
45
- if (!product) return <p>Coaching session not found.</p>;
47
+ if (!product) return <p>{t("forge.coaching_detail.not_found")}</p>;
46
48
 
47
49
  const cover = product.media?.find((m) => m.type === "image")?.url ?? product.media?.[0]?.url;
48
50
 
49
51
  return (
50
52
  <div
51
53
  className={`pb-32 md:pb-0${className ? ` ${className}` : ""}`}
52
- style={{ maxWidth: 1040, margin: "0 auto", color: t.text, ...style }}
54
+ style={{ maxWidth: 1040, margin: "0 auto", color: tokens.text, ...style }}
53
55
  >
54
56
  {cover && (
55
57
  <img
56
58
  src={cover}
57
59
  alt={product.title}
58
- style={{ width: "100%", aspectRatio: "16/9", objectFit: "cover", borderRadius: t.cornerRadius }}
60
+ style={{ width: "100%", aspectRatio: "16/9", objectFit: "cover", borderRadius: tokens.cornerRadius }}
59
61
  />
60
62
  )}
61
63
 
62
64
  <div className="flex flex-col md:flex-row md:items-start gap-6 mt-6">
63
65
  <div className="flex-1 min-w-0">
64
- <h1 style={{ fontSize: 32, fontWeight: 800, margin: "0 0 12px", fontFamily: t.headingFontFamily }}>{product.title}</h1>
66
+ <h1 style={{ fontSize: 32, fontWeight: 800, margin: "0 0 12px", fontFamily: tokens.headingFontFamily }}>{product.title}</h1>
65
67
 
66
68
  <div style={{ display: "flex", flexDirection: "column", gap: 8, fontSize: 14 }}>
67
69
  <span style={{ display: "flex", alignItems: "center", gap: 8 }}>
68
- <Clock size={16} /> {product.durationMinutes} minutes
70
+ <Clock size={16} /> {t("forge.coaching_detail.duration_minutes", { minutes: product.durationMinutes })}
69
71
  </span>
70
72
  </div>
71
73
 
72
74
  {product.description && (
73
75
  <>
74
- <h2 style={{ fontSize: 20, fontWeight: 700, margin: "24px 0 8px", fontFamily: t.headingFontFamily }}>About this session</h2>
76
+ <h2 style={{ fontSize: 20, fontWeight: 700, margin: "24px 0 8px", fontFamily: tokens.headingFontFamily }}>
77
+ {t("forge.coaching_detail.about_title")}
78
+ </h2>
75
79
  <div dangerouslySetInnerHTML={{ __html: product.description }} style={{ lineHeight: 1.7, opacity: 0.9 }} />
76
80
  </>
77
81
  )}
@@ -81,13 +85,13 @@ export function CoachingDetail({
81
85
  className="fixed bottom-0 left-0 right-0 z-40 w-full md:sticky md:top-4 md:bottom-auto md:left-auto md:right-auto md:w-[300px] md:shrink-0 flex flex-col gap-3"
82
86
  style={{
83
87
  padding: 16,
84
- border: `1px solid ${t.primary}80`,
85
- borderRadius: t.cornerRadius,
86
- background: t.surface,
88
+ border: `1px solid ${tokens.primary}80`,
89
+ borderRadius: tokens.cornerRadius,
90
+ background: tokens.surface,
87
91
  }}
88
92
  >
89
93
  <span style={{ fontWeight: 600 }}>
90
- <PriceDisplay amount={Number(product.price)} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={t.text} captionStyle={{ opacity: 0.65 }} />
94
+ <PriceDisplay amount={Number(product.price)} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={tokens.text} captionStyle={{ opacity: 0.65 }} />
91
95
  </span>
92
96
  <CoachingBooking slug={slug} formatAmount={fmt} finalisePath={finalisePath} onComplete={onComplete} />
93
97
  {/* S.6 — next to the price and the button, so the terms are readable
@@ -1,4 +1,5 @@
1
1
  import { useCohortPage } from "../../data/queries/useCohortPage";
2
+ import { useForgeT } from "../../i18n";
2
3
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
3
4
  import { Loading } from "./Loading";
4
5
 
@@ -15,6 +16,7 @@ function formatDate(value: string | number | Date): string {
15
16
  /** Cohort landing page — structured cohort/course/curriculum data. Built on `useCohortPage`. */
16
17
  export function CohortPage({ cohortId }: CohortPageProps) {
17
18
  const theme = useForgeTheme();
19
+ const t = useForgeT();
18
20
  const { data, isLoading } = useCohortPage(cohortId);
19
21
 
20
22
  if (isLoading) return <Loading fullPage />;
@@ -26,17 +28,17 @@ export function CohortPage({ cohortId }: CohortPageProps) {
26
28
  const course = data?.data?.course;
27
29
  const modules = data?.data?.modules ?? [];
28
30
 
29
- if (!cohort) return <p style={{ color: theme.colors.text }}>Cohort not found.</p>;
31
+ if (!cohort) return <p style={{ color: theme.colors.text }}>{t("forge.cohort_page.not_found")}</p>;
32
+
33
+ const startLabel = cohort.startDate ? formatDate(cohort.startDate) : t("forge.cohort_page.date_tbd");
34
+ const dateLabel = cohort.endDate
35
+ ? t("forge.cohort_page.date_range", { start: startLabel, end: formatDate(cohort.endDate) })
36
+ : startLabel;
30
37
 
31
38
  return (
32
39
  <div style={{ maxWidth: 760, margin: "0 auto", color: theme.colors.text }}>
33
40
  <h1 style={{ fontSize: 32, fontWeight: 800, marginBottom: 8 }}>{cohort.title}</h1>
34
- {(cohort.startDate || cohort.endDate) && (
35
- <p style={{ opacity: 0.7, marginBottom: 16 }}>
36
- {cohort.startDate ? formatDate(cohort.startDate) : "TBD"}
37
- {cohort.endDate ? ` to ${formatDate(cohort.endDate)}` : ""}
38
- </p>
39
- )}
41
+ {(cohort.startDate || cohort.endDate) && <p style={{ opacity: 0.7, marginBottom: 16 }}>{dateLabel}</p>}
40
42
  {cohort.description && <p style={{ marginBottom: 24, lineHeight: 1.6 }}>{cohort.description}</p>}
41
43
 
42
44
  {course && (
@@ -48,7 +50,7 @@ export function CohortPage({ cohortId }: CohortPageProps) {
48
50
 
49
51
  {modules.length > 0 && (
50
52
  <section>
51
- <h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 12 }}>Curriculum</h2>
53
+ <h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 12 }}>{t("forge.cohort_page.curriculum_title")}</h2>
52
54
  <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
53
55
  {[...modules]
54
56
  .sort((a, b) => a.order - b.order)
@@ -1,5 +1,6 @@
1
1
  import { AlertTriangle, ArrowRight, Loader2, Mail } from "lucide-react";
2
2
  import { useConfirmEmailSubscription } from "../../data/queries/useLeadMagnet";
3
+ import { useForgeT } from "../../i18n";
3
4
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
4
5
  import { readableTextOn } from "../theme/contrast";
5
6
  import { ConfirmationStage, ConfirmationCard, CheckSeal, WarnSeal, alpha } from "./Confirmation";
@@ -17,6 +18,7 @@ export interface ConfirmSubscriptionProps {
17
18
  * confirmations. Built on `useConfirmEmailSubscription`.
18
19
  */
19
20
  export function ConfirmSubscription({ subscriberId, homePath = "/" }: ConfirmSubscriptionProps) {
21
+ const t = useForgeT();
20
22
  const theme = useForgeTheme();
21
23
  const text = theme.colors.text;
22
24
  const primary = theme.colors.primary;
@@ -30,7 +32,7 @@ export function ConfirmSubscription({ subscriberId, homePath = "/" }: ConfirmSub
30
32
  <div style={{ textAlign: "center", color: alpha(text, 0.75) }}>
31
33
  <Loader2 size={40} color={primary} style={{ animation: "cs-spin 1s linear infinite" }} />
32
34
  <style>{"@keyframes cs-spin{to{transform:rotate(360deg)}}"}</style>
33
- <p style={{ marginTop: 16, fontSize: 15 }}>Confirming your subscription…</p>
35
+ <p style={{ marginTop: 16, fontSize: 15 }}>{t("forge.confirm_subscription.loading")}</p>
34
36
  </div>
35
37
  </ConfirmationStage>
36
38
  );
@@ -44,15 +46,17 @@ export function ConfirmSubscription({ subscriberId, homePath = "/" }: ConfirmSub
44
46
  <div style={{ padding: "40px 30px", textAlign: "center" }}>
45
47
  <WarnSeal icon={<AlertTriangle size={34} color="#f59e0b" />} />
46
48
  <h1 style={{ fontSize: 24, fontWeight: 800, margin: "22px 0 8px" }}>
47
- {subscriberId ? "Link expired" : "Invalid link"}
49
+ {subscriberId
50
+ ? t("forge.confirm_subscription.expired_title")
51
+ : t("forge.confirm_subscription.invalid_title")}
48
52
  </h1>
49
53
  <p style={{ fontSize: 15, color: alpha(text, 0.7), margin: "0 0 24px" }}>
50
54
  {subscriberId
51
- ? "This confirmation link is no longer valid. Try subscribing again."
52
- : "This confirmation link is missing or malformed."}
55
+ ? t("forge.confirm_subscription.expired_body")
56
+ : t("forge.confirm_subscription.invalid_body")}
53
57
  </p>
54
58
  <Action href={homePath} primary={primary}>
55
- Back to site <ArrowRight size={16} />
59
+ {t("forge.confirm_subscription.back_to_site")} <ArrowRight size={16} />
56
60
  </Action>
57
61
  </div>
58
62
  </ConfirmationCard>
@@ -83,7 +87,7 @@ export function ConfirmSubscription({ subscriberId, homePath = "/" }: ConfirmSub
83
87
  </p>
84
88
  )}
85
89
  <h1 className="cf-rise" style={{ fontSize: 27, fontWeight: 800, margin: "6px 0 8px", animationDelay: "0.22s" }}>
86
- You’re on the list 🎉
90
+ {t("forge.confirm_subscription.success_title")}
87
91
  </h1>
88
92
  <p
89
93
  className="cf-rise"
@@ -98,11 +102,13 @@ export function ConfirmSubscription({ subscriberId, homePath = "/" }: ConfirmSub
98
102
  }}
99
103
  >
100
104
  <Mail size={15} color={alpha(text, 0.6)} />
101
- You’ll now receive updates from {profile?.name || "us"}.
105
+ {profile?.name
106
+ ? t("forge.confirm_subscription.success_body", { profile_name: profile.name })
107
+ : t("forge.confirm_subscription.success_body_generic")}
102
108
  </p>
103
109
  <div className="cf-rise" style={{ animationDelay: "0.4s" }}>
104
110
  <Action href={homePath} primary={primary}>
105
- Explore the site <ArrowRight size={16} />
111
+ {t("forge.confirm_subscription.explore_site")} <ArrowRight size={16} />
106
112
  </Action>
107
113
  </div>
108
114
  </div>
@@ -1,4 +1,5 @@
1
1
  import { useContactForm } from "../headless/forms/useContactForm";
2
+ import { useForgeT } from "../../i18n";
2
3
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
3
4
  import { readableTextOn } from "../theme/contrast";
4
5
 
@@ -9,13 +10,12 @@ export interface ContactFormProps {
9
10
  }
10
11
 
11
12
  /** One-line themed website contact form, built on `useContactForm`. */
12
- export function ContactForm({
13
- buttonText = "Send Message",
14
- successMessage = "Message sent. We'll be in touch.",
15
- showJoinEmailList = true,
16
- }: ContactFormProps) {
13
+ export function ContactForm({ buttonText, successMessage, showJoinEmailList = true }: ContactFormProps) {
14
+ const t = useForgeT();
17
15
  const theme = useForgeTheme();
18
16
  const f = useContactForm();
17
+ const submitLabel = buttonText ?? t("forge.contact_form.submit");
18
+ const successText = successMessage ?? t("forge.contact_form.success");
19
19
 
20
20
  const inputStyle = {
21
21
  width: "100%",
@@ -29,21 +29,40 @@ export function ContactForm({
29
29
  if (f.isSuccess) {
30
30
  return (
31
31
  <div style={{ color: theme.colors.text, fontFamily: theme.fontFamily, textAlign: "center", padding: 8 }}>
32
- {successMessage}
32
+ {successText}
33
33
  </div>
34
34
  );
35
35
  }
36
36
 
37
37
  return (
38
- <div style={{ display: "flex", flexDirection: "column", gap: 10, color: theme.colors.text, fontFamily: theme.fontFamily }}>
39
- <input placeholder="Your name" value={f.name} onChange={(e) => f.setName(e.target.value)} style={inputStyle} />
38
+ <div
39
+ style={{
40
+ display: "flex",
41
+ flexDirection: "column",
42
+ gap: 10,
43
+ color: theme.colors.text,
44
+ fontFamily: theme.fontFamily,
45
+ }}
46
+ >
47
+ <input
48
+ placeholder={t("forge.contact_form.name_placeholder")}
49
+ value={f.name}
50
+ onChange={(e) => f.setName(e.target.value)}
51
+ style={inputStyle}
52
+ />
40
53
  {f.errors.name && <p style={{ color: "#ef4444", fontSize: 13 }}>{f.errors.name}</p>}
41
54
 
42
- <input type="email" placeholder="Email" value={f.email} onChange={(e) => f.setEmail(e.target.value)} style={inputStyle} />
55
+ <input
56
+ type="email"
57
+ placeholder={t("forge.contact_form.email_placeholder")}
58
+ value={f.email}
59
+ onChange={(e) => f.setEmail(e.target.value)}
60
+ style={inputStyle}
61
+ />
43
62
  {f.errors.email && <p style={{ color: "#ef4444", fontSize: 13 }}>{f.errors.email}</p>}
44
63
 
45
64
  <textarea
46
- placeholder="Your message"
65
+ placeholder={t("forge.contact_form.message_placeholder")}
47
66
  value={f.message}
48
67
  onChange={(e) => f.setMessage(e.target.value)}
49
68
  rows={4}
@@ -54,7 +73,7 @@ export function ContactForm({
54
73
  {showJoinEmailList && (
55
74
  <label style={{ display: "flex", gap: 8, alignItems: "center", fontSize: 14 }}>
56
75
  <input type="checkbox" checked={f.joinEmailList} onChange={(e) => f.setJoinEmailList(e.target.checked)} />
57
- Also join the email list
76
+ {t("forge.contact_form.join_email_list")}
58
77
  </label>
59
78
  )}
60
79
 
@@ -74,7 +93,7 @@ export function ContactForm({
74
93
  opacity: f.isSubmitting ? 0.6 : 1,
75
94
  }}
76
95
  >
77
- {f.isSubmitting ? "Sending…" : buttonText}
96
+ {f.isSubmitting ? t("forge.contact_form.submitting") : submitLabel}
78
97
  </button>
79
98
  </div>
80
99
  );
@@ -1,13 +1,14 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import { X } from "lucide-react";
3
+ import { useForgeT } from "../../i18n";
3
4
  import { useCookieConsent } from "../headless/consent/useCookieConsent";
4
5
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
5
6
  import { readableTextOn } from "../theme/contrast";
6
7
 
7
8
  export interface CookieConsentProps {
8
- /** Heading text. */
9
+ /** Heading text. Defaults to Forge's own translated heading. */
9
10
  title?: string;
10
- /** Body copy above the toggles. */
11
+ /** Body copy above the toggles. Defaults to Forge's own translated copy. */
11
12
  description?: React.ReactNode;
12
13
  /** Show the Performance/analytics toggle. Default `true`. Set `false` when
13
14
  * first-party analytics runs cookieless (it needs no storage consent), so the
@@ -47,8 +48,8 @@ const TRIBENEST_PRIVACY_URL = "https://www.tribenest.co/privacy";
47
48
  * `<CookieConsent privacyPolicyHref="/privacy" />`.
48
49
  */
49
50
  export function CookieConsent({
50
- title = "Cookie Consent",
51
- description = "This site uses cookies for performance, analytics, and personalization to improve the site and give you a better experience. Accept all, reject all but strictly necessary, or choose which to allow.",
51
+ title,
52
+ description,
52
53
  showPerformance = true,
53
54
  privacyPolicyHref,
54
55
  useTribeNestPrivacy,
@@ -57,6 +58,7 @@ export function CookieConsent({
57
58
  className,
58
59
  }: CookieConsentProps) {
59
60
  const t = useThemeTokens();
61
+ const tr = useForgeT();
60
62
  const privacyHref = privacyPolicyHref ?? (useTribeNestPrivacy ? TRIBENEST_PRIVACY_URL : undefined);
61
63
  // External TribeNest link opens in a new tab; a same-origin custom page stays.
62
64
  const privacyExternal = !privacyPolicyHref && useTribeNestPrivacy;
@@ -90,7 +92,7 @@ export function CookieConsent({
90
92
  <div
91
93
  className={className}
92
94
  role="dialog"
93
- aria-label="Cookie consent"
95
+ aria-label={tr("forge.cookie_consent.aria_dialog")}
94
96
  style={{
95
97
  position: "fixed",
96
98
  left: 0,
@@ -116,13 +118,15 @@ export function CookieConsent({
116
118
  }}
117
119
  >
118
120
  <div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 12 }}>
119
- <h2 style={{ margin: 0, fontSize: 18, fontWeight: 800, fontFamily: t.headingFontFamily }}>{title}</h2>
121
+ <h2 style={{ margin: 0, fontSize: 18, fontWeight: 800, fontFamily: t.headingFontFamily }}>
122
+ {title ?? tr("forge.cookie_consent.title")}
123
+ </h2>
120
124
  {/* Close is only meaningful once a choice exists (reopened panel) —
121
125
  on first run the visitor must pick an option. */}
122
126
  {decided && (
123
127
  <button
124
128
  type="button"
125
- aria-label="Close"
129
+ aria-label={tr("forge.cookie_consent.aria_close")}
126
130
  onClick={done(() => {})}
127
131
  style={{ background: "transparent", border: "none", cursor: "pointer", color: t.muted, padding: 2, flexShrink: 0 }}
128
132
  >
@@ -131,7 +135,7 @@ export function CookieConsent({
131
135
  )}
132
136
  </div>
133
137
  <p style={{ margin: "8px 0 0", fontSize: 14, lineHeight: 1.5, color: t.muted }}>
134
- {description}
138
+ {description ?? tr("forge.cookie_consent.description")}
135
139
  {privacyHref && (
136
140
  <>
137
141
  {" "}
@@ -140,7 +144,7 @@ export function CookieConsent({
140
144
  style={{ color: t.primary, textDecoration: "underline" }}
141
145
  {...(privacyExternal ? { target: "_blank", rel: "noopener noreferrer" } : {})}
142
146
  >
143
- Privacy Policy
147
+ {tr("forge.cookie_consent.privacy_policy")}
144
148
  </a>
145
149
  .
146
150
  </>
@@ -148,17 +152,17 @@ export function CookieConsent({
148
152
  </p>
149
153
 
150
154
  <div style={{ display: "flex", flexWrap: "wrap", gap: 20, margin: "16px 0 4px" }}>
151
- <Toggle label="Functional" checked disabled tokens={t} />
155
+ <Toggle label={tr("forge.cookie_consent.toggle_functional")} checked disabled tokens={t} />
152
156
  {showPerformance && (
153
157
  <Toggle
154
- label="Performance"
158
+ label={tr("forge.cookie_consent.toggle_performance")}
155
159
  checked={draft.performance}
156
160
  tokens={t}
157
161
  onChange={(v) => setDraft((d) => ({ ...d, performance: v }))}
158
162
  />
159
163
  )}
160
164
  <Toggle
161
- label="Marketing"
165
+ label={tr("forge.cookie_consent.toggle_marketing")}
162
166
  checked={draft.marketing}
163
167
  tokens={t}
164
168
  onChange={(v) => setDraft((d) => ({ ...d, marketing: v }))}
@@ -167,13 +171,13 @@ export function CookieConsent({
167
171
 
168
172
  <div style={{ display: "flex", flexWrap: "wrap", gap: 10, justifyContent: "flex-end", marginTop: 16 }}>
169
173
  <button type="button" onClick={done(rejectAll)} style={ghostBtn(t)}>
170
- Reject all
174
+ {tr("forge.cookie_consent.reject_all")}
171
175
  </button>
172
176
  <button type="button" onClick={done(() => save(draft))} style={ghostBtn(t)}>
173
- Save settings
177
+ {tr("forge.cookie_consent.save_settings")}
174
178
  </button>
175
179
  <button type="button" onClick={done(acceptAll)} style={{ ...ghostBtn(t), background: t.primary, color: onPrimary, border: "none" }}>
176
- Accept all
180
+ {tr("forge.cookie_consent.accept_all")}
177
181
  </button>
178
182
  </div>
179
183
  </div>