@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
@@ -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 { CourseCheckout } from "./CourseCheckout";
10
11
  import { ReviewsSection } from "./ReviewsSection";
@@ -41,13 +42,14 @@ export function CourseDetail({
41
42
  loginPath,
42
43
  membershipPath,
43
44
  }: CourseDetailProps) {
44
- const t = useThemeTokens();
45
+ const tokens = useThemeTokens();
46
+ const t = useForgeT();
45
47
  const fmt = useAmountFormatter(formatAmount);
46
48
  const pricesIncludeTax = usePricesIncludeTax();
47
49
  const { data: course, isLoading } = useGetCourse(slug, { initialData: initialCourse });
48
50
 
49
51
  if (isLoading) return <Loading fullPage />;
50
- if (!course) return <p>Course not found.</p>;
52
+ if (!course) return <p>{t("forge.course_detail.not_found")}</p>;
51
53
 
52
54
  /**
53
55
  * Members-only course (S.4). Announced by the detail read, which is why the
@@ -61,27 +63,27 @@ export function CourseDetail({
61
63
  return (
62
64
  <div
63
65
  className={`pb-32 md:pb-0${className ? ` ${className}` : ""}`}
64
- style={{ maxWidth: 1040, margin: "0 auto", color: t.text, ...style }}
66
+ style={{ maxWidth: 1040, margin: "0 auto", color: tokens.text, ...style }}
65
67
  >
66
68
  {introVideo ? (
67
69
  <video
68
70
  src={introVideo}
69
71
  controls
70
- style={{ width: "100%", aspectRatio: "16/9", objectFit: "cover", borderRadius: t.cornerRadius }}
72
+ style={{ width: "100%", aspectRatio: "16/9", objectFit: "cover", borderRadius: tokens.cornerRadius }}
71
73
  />
72
74
  ) : (
73
75
  cover && (
74
76
  <img
75
77
  src={cover}
76
78
  alt={course.title}
77
- style={{ width: "100%", aspectRatio: "16/9", objectFit: "cover", borderRadius: t.cornerRadius }}
79
+ style={{ width: "100%", aspectRatio: "16/9", objectFit: "cover", borderRadius: tokens.cornerRadius }}
78
80
  />
79
81
  )
80
82
  )}
81
83
 
82
84
  <div className="flex flex-col md:flex-row md:items-start gap-6 mt-6">
83
85
  <div className="flex-1 min-w-0">
84
- <h1 style={{ fontSize: 32, fontWeight: 800, margin: "0 0 12px", fontFamily: t.headingFontFamily }}>{course.title}</h1>
86
+ <h1 style={{ fontSize: 32, fontWeight: 800, margin: "0 0 12px", fontFamily: tokens.headingFontFamily }}>{course.title}</h1>
85
87
 
86
88
  {course.description && (
87
89
  <div dangerouslySetInnerHTML={{ __html: course.description }} style={{ lineHeight: 1.7, opacity: 0.9 }} />
@@ -89,15 +91,19 @@ export function CourseDetail({
89
91
 
90
92
  {modules.length > 0 && (
91
93
  <>
92
- <h2 style={{ fontSize: 20, fontWeight: 700, margin: "24px 0 12px", fontFamily: t.headingFontFamily }}>Course Content</h2>
94
+ <h2
95
+ style={{ fontSize: 20, fontWeight: 700, margin: "24px 0 12px", fontFamily: tokens.headingFontFamily }}
96
+ >
97
+ {t("forge.course_detail.content_title")}
98
+ </h2>
93
99
  <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
94
100
  {modules.map((module) => (
95
101
  <div
96
102
  key={module.id}
97
103
  style={{
98
104
  padding: 16,
99
- border: `1px solid ${t.primary}40`,
100
- borderRadius: t.cornerRadius,
105
+ border: `1px solid ${tokens.primary}40`,
106
+ borderRadius: tokens.cornerRadius,
101
107
  }}
102
108
  >
103
109
  <h3 style={{ fontSize: 17, fontWeight: 600, marginBottom: module.description ? 6 : 0 }}>
@@ -124,21 +130,21 @@ export function CourseDetail({
124
130
  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"
125
131
  style={{
126
132
  padding: 16,
127
- border: `1px solid ${t.primary}80`,
128
- borderRadius: t.cornerRadius,
129
- background: t.surface,
133
+ border: `1px solid ${tokens.primary}80`,
134
+ borderRadius: tokens.cornerRadius,
135
+ background: tokens.surface,
130
136
  }}
131
137
  >
132
- <span style={{ fontWeight: 700, fontSize: 22, color: t.primary }}>
138
+ <span style={{ fontWeight: 700, fontSize: 22, color: tokens.primary }}>
133
139
  {course.compareAtPrice != null && Number(course.compareAtPrice) > Number(course.price) && (
134
140
  <span style={{ textDecoration: "line-through", opacity: 0.6, marginRight: 8, fontWeight: 400, fontSize: 16 }}>
135
141
  {fmt(Number(course.compareAtPrice))}
136
142
  </span>
137
143
  )}
138
144
  {Number(course.price) <= 0 ? (
139
- "Free"
145
+ t("forge.course_detail.free")
140
146
  ) : (
141
- <PriceDisplay amount={Number(course.price)} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={t.text} captionStyle={{ opacity: 0.65 }} />
147
+ <PriceDisplay amount={Number(course.price)} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={tokens.text} captionStyle={{ opacity: 0.65 }} />
142
148
  )}
143
149
  </span>
144
150
  {/*
@@ -1,5 +1,6 @@
1
1
  import { useGetCourses } from "../../data/queries/useCourses";
2
2
  import type { PublicCourse } from "../../types/models";
3
+ import { useForgeT } from "../../i18n";
3
4
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
4
5
  import { useAmountFormatter } from "../format/useFormatCurrency";
5
6
  import { PriceDisplay } from "../format/PriceDisplay";
@@ -18,13 +19,14 @@ export interface CoursesGridProps {
18
19
  /** Themed courses grid, built on `useGetCourses`. */
19
20
  export function CoursesGrid({ columns = 3, limit, formatAmount, onSelect, className, style }: CoursesGridProps) {
20
21
  const t = useThemeTokens();
22
+ const tr = useForgeT();
21
23
  const fmt = useAmountFormatter(formatAmount);
22
24
  const pricesIncludeTax = usePricesIncludeTax();
23
25
  const { data, isLoading } = useGetCourses({ page: 1 });
24
26
  const courses = (data?.data ?? []).slice(0, limit ?? 100);
25
27
 
26
28
  if (isLoading) return <Loading />;
27
- if (!courses.length) return <p style={{ color: t.text, opacity: 0.7 }}>No courses yet.</p>;
29
+ if (!courses.length) return <p style={{ color: t.text, opacity: 0.7 }}>{tr("forge.courses_grid.empty_title")}</p>;
28
30
 
29
31
  const gap = 16;
30
32
  const track = `minmax(max(150px, calc((100% - ${(columns - 1) * gap}px) / ${columns})), 1fr)`;
@@ -73,7 +75,7 @@ export function CoursesGrid({ columns = 3, limit, formatAmount, onSelect, classN
73
75
  {price > 0 ? (
74
76
  <PriceDisplay amount={price} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={t.text} captionStyle={{ opacity: 0.65 }} />
75
77
  ) : (
76
- "Free"
78
+ tr("forge.courses_grid.free")
77
79
  )}
78
80
  </div>
79
81
  </div>
@@ -1,4 +1,5 @@
1
1
  import { usePublicAuth } from "../../contexts/PublicAuthContext";
2
+ import { useForgeT } from "../../i18n";
2
3
  import { currencySymbol, type Currency } from "../../types/models";
3
4
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
4
5
 
@@ -18,6 +19,8 @@ export interface CurrencySwitcherProps {
18
19
  */
19
20
  export function CurrencySwitcher({ className, style }: CurrencySwitcherProps) {
20
21
  const t = useThemeTokens();
22
+ // Named `translate` rather than the usual `t`: `t` is already the theme tokens here.
23
+ const translate = useForgeT();
21
24
  const { currencies, userSelectedCurrency, setUserSelectedCurrency } = usePublicAuth();
22
25
 
23
26
  if (!currencies || !currencies.supportedCurrencies?.length) return null;
@@ -25,7 +28,7 @@ export function CurrencySwitcher({ className, style }: CurrencySwitcherProps) {
25
28
  return (
26
29
  <select
27
30
  className={className}
28
- aria-label="Currency"
31
+ aria-label={translate("forge.currency_switcher.aria_currency")}
29
32
  value={userSelectedCurrency ?? currencies.userCurrency ?? ""}
30
33
  onChange={(e) => setUserSelectedCurrency(e.target.value as Currency)}
31
34
  style={{
@@ -1,11 +1,16 @@
1
1
  import type { CSSProperties } from "react";
2
2
  import type { AppliedDiscountCoupon } from "../../types/models";
3
3
  import type { CouponField } from "../headless/coupon/useCouponField";
4
+ import { useForgeT } from "../../i18n";
4
5
  import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
5
6
  import { buttonStyle } from "./Button";
6
7
 
7
8
  /** Append an alpha to a 6-digit hex color (mirrors frontend-shared addAlphaToHexCode). */
8
- const a = (hex: string, alpha: number) => hex + Math.round(alpha * 255).toString(16).padStart(2, "0");
9
+ const a = (hex: string, alpha: number) =>
10
+ hex +
11
+ Math.round(alpha * 255)
12
+ .toString(16)
13
+ .padStart(2, "0");
9
14
 
10
15
  export interface DiscountCodeFieldProps {
11
16
  coupon: CouponField;
@@ -32,7 +37,7 @@ export interface DiscountCodeFieldProps {
32
37
  * Two things it deliberately does NOT do:
33
38
  *
34
39
  * 1. It never writes its own rejection copy. The backend answers a refused code
35
- * with the actual reason, already translated minimum not met, expired,
40
+ * with the actual reason, already translated: minimum not met, expired,
36
41
  * already used, members only. Collapsing all of that into "invalid code"
37
42
  * throws away the only thing that tells the buyer whether to fix the cart,
38
43
  * fix the code, or give up.
@@ -40,14 +45,10 @@ export interface DiscountCodeFieldProps {
40
45
  * server, including automatic (no-code) discounts, which is the ONLY way a
41
46
  * buyer learns one applied.
42
47
  */
43
- export function DiscountCodeField({
44
- coupon,
45
- fmt,
46
- disabled,
47
- mode = "quote",
48
- promptText = "Have a discount code?",
49
- }: DiscountCodeFieldProps) {
48
+ export function DiscountCodeField({ coupon, fmt, disabled, mode = "quote", promptText }: DiscountCodeFieldProps) {
49
+ const t = useForgeT();
50
50
  const theme = useForgeTheme();
51
+ const prompt = promptText ?? t("forge.discount_code.prompt");
51
52
  // Automatic discounts have no code the buyer typed, so they are shown as
52
53
  // applied even when the input was never opened.
53
54
  const applied = coupon.appliedCoupons;
@@ -72,16 +73,16 @@ export function DiscountCodeField({
72
73
  fontFamily: theme.fontFamily,
73
74
  }}
74
75
  >
75
- {promptText}
76
+ {prompt}
76
77
  </button>
77
78
  )}
78
79
 
79
80
  {showInput && (
80
81
  <div style={{ display: "flex", gap: 8, alignItems: "flex-start" }}>
81
82
  <input
82
- aria-label="Discount code"
83
+ aria-label={t("forge.discount_code.aria_input")}
83
84
  data-testid="discount-code-input"
84
- placeholder="Enter code"
85
+ placeholder={t("forge.discount_code.placeholder")}
85
86
  value={coupon.code}
86
87
  onChange={(e) => coupon.setCode(e.target.value.toUpperCase())}
87
88
  style={{ ...inputStyle(theme), flex: 1 }}
@@ -97,14 +98,14 @@ export function DiscountCodeField({
97
98
  opacity: coupon.isApplying || !coupon.code.trim() ? 0.6 : 1,
98
99
  }}
99
100
  >
100
- {coupon.isApplying ? "Applying…" : "Apply"}
101
+ {coupon.isApplying ? t("forge.discount_code.applying") : t("forge.discount_code.apply")}
101
102
  </button>
102
103
  )}
103
104
  </div>
104
105
  )}
105
106
 
106
107
  {showInput && mode === "deferred" && (
107
- <p style={{ fontSize: 12, opacity: 0.6, margin: 0 }}>Your code is applied at the next step.</p>
108
+ <p style={{ fontSize: 12, opacity: 0.6, margin: 0 }}>{t("forge.discount_code.deferred_note")}</p>
108
109
  )}
109
110
 
110
111
  {coupon.error && (
@@ -129,7 +130,7 @@ export function DiscountCodeField({
129
130
  <div>
130
131
  {applied.map((c) => (
131
132
  <p key={c.code} style={{ margin: 0, fontSize: 14, fontWeight: 600, color: theme.colors.primary }}>
132
- {c.code} · {fmt(c.discountAmount)} off
133
+ {t("forge.discount_code.applied_line", { code: c.code, amount: fmt(c.discountAmount) })}
133
134
  </p>
134
135
  ))}
135
136
  </div>
@@ -150,7 +151,7 @@ export function DiscountCodeField({
150
151
  fontFamily: theme.fontFamily,
151
152
  }}
152
153
  >
153
- Remove
154
+ {t("forge.discount_code.remove")}
154
155
  </button>
155
156
  )}
156
157
  </div>
@@ -176,18 +177,21 @@ export interface DiscountSummaryLinesProps {
176
177
  * working code from a silently-ignored one, and no reason to trust either.
177
178
  */
178
179
  export function DiscountSummaryLines({ subTotal, discountAmount, appliedCoupons, fmt }: DiscountSummaryLinesProps) {
180
+ const t = useForgeT();
179
181
  const theme = useForgeTheme();
180
182
  if (!(discountAmount > 0)) return null;
181
183
  const codes = (appliedCoupons ?? []).map((c) => c.code).filter(Boolean);
182
184
  return (
183
185
  <div data-testid="discount-summary" style={{ display: "flex", flexDirection: "column", gap: 4, marginBottom: 8 }}>
184
186
  <div style={{ display: "flex", justifyContent: "space-between", fontSize: 14 }}>
185
- <span style={{ opacity: 0.75 }}>Subtotal</span>
187
+ <span style={{ opacity: 0.75 }}>{t("forge.discount_code.subtotal")}</span>
186
188
  <span style={{ opacity: 0.75 }}>{fmt(subTotal)}</span>
187
189
  </div>
188
190
  <div data-testid="discount-line" style={{ display: "flex", justifyContent: "space-between", fontSize: 14 }}>
189
191
  <span style={{ color: theme.colors.primary }}>
190
- Discount{codes.length > 0 ? ` (${codes.join(", ")})` : ""}
192
+ {codes.length > 0
193
+ ? t("forge.discount_code.discount_with_codes", { codes: codes.join(", ") })
194
+ : t("forge.discount_code.discount")}
191
195
  </span>
192
196
  <span style={{ color: theme.colors.primary }}>−{fmt(discountAmount)}</span>
193
197
  </div>
@@ -1,4 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
+ import { useForgeT } from "../../i18n";
2
3
  import { Donation, useDonationContext, type DonationPaymentRenderProps } from "../headless/donation";
3
4
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
4
5
  import { Loading } from "./Loading";
@@ -28,6 +29,7 @@ export interface DonationButtonProps {
28
29
  */
29
30
  export function DonationButton({ donationId, text, returnUrl, onSuccess, renderPayment, formatAmount }: DonationButtonProps) {
30
31
  const theme = useForgeTheme();
32
+ const t = useForgeT();
31
33
  const registered = usePaymentRenderer();
32
34
  const fmt = useAmountFormatter(formatAmount);
33
35
  // Donations confirm in place, so default the registered renderer to "manual".
@@ -49,7 +51,7 @@ export function DonationButton({ donationId, text, returnUrl, onSuccess, renderP
49
51
  fontFamily: theme.fontFamily,
50
52
  }}
51
53
  >
52
- {text ?? "Donate"}
54
+ {text ?? t("forge.donation_button.donate")}
53
55
  </button>
54
56
  </Donation.Trigger>
55
57
 
@@ -71,7 +73,7 @@ export function DonationButton({ donationId, text, returnUrl, onSuccess, renderP
71
73
  fontFamily: theme.fontFamily,
72
74
  }}
73
75
  >
74
- <Donation.Title className="">{text ?? "Support"}</Donation.Title>
76
+ <Donation.Title className="">{text ?? t("forge.donation_button.title")}</Donation.Title>
75
77
  <DonationBody fmt={fmt} renderPayment={renderPay} />
76
78
  </Donation.Modal>
77
79
  </Donation>
@@ -87,6 +89,7 @@ function DonationBody({
87
89
  }) {
88
90
  const c = useDonationContext();
89
91
  const theme = useForgeTheme();
92
+ const t = useForgeT();
90
93
  // Authoritative tax quote from the donation start-payment (display only).
91
94
  const taxSummary = summarizeTaxQuote(c.startResult?.taxQuote);
92
95
  const inputStyle = {
@@ -106,17 +109,19 @@ function DonationBody({
106
109
  return (
107
110
  <div style={{ textAlign: "center", padding: "16px 0" }}>
108
111
  <h3 style={{ marginBottom: 8 }}>
109
- {c.outcome === "succeeded" ? "Donation received" : "Payment failed"}
112
+ {c.outcome === "succeeded"
113
+ ? t("forge.donation_button.result_success_title")
114
+ : t("forge.donation_button.result_failed_title")}
110
115
  </h3>
111
116
  <p style={{ opacity: 0.8 }}>
112
117
  {c.outcome === "succeeded"
113
- ? c.donation?.successMessage || "Thank you for your contribution."
114
- : c.paymentErrorMessage || "Your card was not charged. Please try again."}
118
+ ? c.donation?.successMessage || t("forge.donation_button.success_body")
119
+ : c.paymentErrorMessage || t("forge.donation_button.failed_body")}
115
120
  </p>
116
121
  <div style={{ display: "flex", gap: 8, marginTop: 16, justifyContent: "center" }}>
117
122
  {c.outcome === "failed" && (
118
123
  <button onClick={c.reset} style={{ ...inputStyle, width: "auto", cursor: "pointer" }}>
119
- Try again
124
+ {t("forge.donation_button.retry")}
120
125
  </button>
121
126
  )}
122
127
  <Donation.Close asChild>
@@ -130,7 +135,7 @@ function DonationBody({
130
135
  cursor: "pointer",
131
136
  }}
132
137
  >
133
- Close
138
+ {t("forge.donation_button.close")}
134
139
  </button>
135
140
  </Donation.Close>
136
141
  </div>
@@ -143,12 +148,14 @@ function DonationBody({
143
148
  return (
144
149
  <div style={{ marginTop: 16 }}>
145
150
  <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 12 }}>
146
- <span>Total</span>
151
+ <span>{t("forge.donation_button.total")}</span>
147
152
  <strong style={{ color: theme.colors.primary, textAlign: "right" }}>
148
153
  {fmt(c.startResult?.totalAmount ?? c.estimatedTotal)}
149
154
  {taxSummary && (
150
155
  <span style={{ display: "block", fontSize: 12, fontWeight: 400, color: theme.colors.text, opacity: 0.65 }}>
151
- {taxSummary.mode === "inclusive" ? taxSummary.label : `incl. ${taxSummary.formattedTax} tax`}
156
+ {taxSummary.mode === "inclusive"
157
+ ? taxSummary.label
158
+ : t("forge.donation_button.tax_included", { amount: taxSummary.formattedTax })}
152
159
  </span>
153
160
  )}
154
161
  </strong>
@@ -156,7 +163,7 @@ function DonationBody({
156
163
  {renderPayment ? (
157
164
  <Donation.PaymentStep>{(p) => renderPayment(p)}</Donation.PaymentStep>
158
165
  ) : (
159
- <p style={{ opacity: 0.7 }}>Payment is not configured.</p>
166
+ <p style={{ opacity: 0.7 }}>{t("forge.donation_button.payment_not_configured")}</p>
160
167
  )}
161
168
  </div>
162
169
  );
@@ -195,7 +202,9 @@ function DonationBody({
195
202
  )}
196
203
 
197
204
  <label>
198
- Custom amount{c.minimum > 0 ? ` (min ${fmt(c.minimum)})` : ""}
205
+ {c.minimum > 0
206
+ ? t("forge.donation_button.custom_amount_min", { amount: fmt(c.minimum) })
207
+ : t("forge.donation_button.custom_amount")}
199
208
  <input
200
209
  type="number"
201
210
  min={c.minimum || 0}
@@ -212,16 +221,38 @@ function DonationBody({
212
221
  {c.donation?.coverFeesEnabled && c.effectiveAmount > 0 && (
213
222
  <label style={{ display: "flex", gap: 8, alignItems: "center" }}>
214
223
  <input type="checkbox" checked={c.coverFees} onChange={(e) => c.setCoverFees(e.target.checked)} />
215
- <span>Cover the {fmt(c.estimatedFee)} processing fee</span>
224
+ <span>{t("forge.donation_button.cover_fees", { amount: fmt(c.estimatedFee) })}</span>
216
225
  </label>
217
226
  )}
218
227
 
219
228
  <div style={{ display: "flex", gap: 8 }}>
220
- <input placeholder="First name" value={c.firstName} onChange={(e) => c.setFirstName(e.target.value)} style={inputStyle} />
221
- <input placeholder="Last name" value={c.lastName} onChange={(e) => c.setLastName(e.target.value)} style={inputStyle} />
229
+ <input
230
+ placeholder={t("forge.donation_button.first_name_placeholder")}
231
+ value={c.firstName}
232
+ onChange={(e) => c.setFirstName(e.target.value)}
233
+ style={inputStyle}
234
+ />
235
+ <input
236
+ placeholder={t("forge.donation_button.last_name_placeholder")}
237
+ value={c.lastName}
238
+ onChange={(e) => c.setLastName(e.target.value)}
239
+ style={inputStyle}
240
+ />
222
241
  </div>
223
- <input placeholder="Email" type="email" value={c.email} onChange={(e) => c.setEmail(e.target.value)} style={inputStyle} />
224
- <textarea placeholder="Message (optional)" value={c.message} onChange={(e) => c.setMessage(e.target.value)} rows={2} style={inputStyle} />
242
+ <input
243
+ placeholder={t("forge.donation_button.email_placeholder")}
244
+ type="email"
245
+ value={c.email}
246
+ onChange={(e) => c.setEmail(e.target.value)}
247
+ style={inputStyle}
248
+ />
249
+ <textarea
250
+ placeholder={t("forge.donation_button.message_placeholder")}
251
+ value={c.message}
252
+ onChange={(e) => c.setMessage(e.target.value)}
253
+ rows={2}
254
+ style={inputStyle}
255
+ />
225
256
 
226
257
  {c.startError && <p style={{ color: "#ef4444", fontSize: 14 }}>{c.startError}</p>}
227
258
 
@@ -239,7 +270,11 @@ function DonationBody({
239
270
  opacity: c.isStarting ? 0.6 : 1,
240
271
  }}
241
272
  >
242
- {c.isStarting ? "Processing…" : `Donate ${c.effectiveAmount > 0 ? fmt(c.estimatedTotal) : ""}`.trim()}
273
+ {c.isStarting
274
+ ? t("forge.donation_button.processing")
275
+ : c.effectiveAmount > 0
276
+ ? t("forge.donation_button.submit_amount", { amount: fmt(c.estimatedTotal) })
277
+ : t("forge.donation_button.submit")}
243
278
  </button>
244
279
  </div>
245
280
  </Donation.AmountStep>
@@ -1,5 +1,6 @@
1
1
  import { DonationButton } from "./DonationButton";
2
2
  import { useAmountFormatter } from "../format/useFormatCurrency";
3
+ import { useForgeT } from "../../i18n";
3
4
 
4
5
  export interface DonationPageProps {
5
6
  /** Donation id from the URL search param (`?id=`). */
@@ -10,13 +11,12 @@ export interface DonationPageProps {
10
11
 
11
12
  /** Standalone donation page — heading + copy wrapping the `DonationButton` flow. */
12
13
  export function DonationPage({ donationId, formatAmount }: DonationPageProps) {
14
+ const t = useForgeT();
13
15
  const fmt = useAmountFormatter(formatAmount);
14
16
  return (
15
17
  <div style={{ maxWidth: 520, margin: "0 auto", textAlign: "center" }}>
16
- <h1 style={{ fontSize: 32, fontWeight: 800, marginBottom: 12 }}>Support the work</h1>
17
- <p style={{ opacity: 0.75, marginBottom: 24 }}>
18
- Your contribution keeps this going. Thank you.
19
- </p>
18
+ <h1 style={{ fontSize: 32, fontWeight: 800, marginBottom: 12 }}>{t("forge.donation_page.title")}</h1>
19
+ <p style={{ opacity: 0.75, marginBottom: 24 }}>{t("forge.donation_page.subtitle")}</p>
20
20
  {donationId ? (
21
21
  <DonationButton donationId={donationId} formatAmount={fmt} />
22
22
  ) : (
@@ -1,4 +1,5 @@
1
1
  import { useEmailListForm } from "../headless/forms/useEmailListForm";
2
+ import { useForgeT } from "../../i18n";
2
3
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
3
4
  import { readableTextOn } from "../theme/contrast";
4
5
 
@@ -19,14 +20,21 @@ export function EmailListForm({
19
20
  leadMagnetId,
20
21
  includeName,
21
22
  includePhoneNumber,
22
- title = "Join my email list",
23
+ title,
23
24
  description,
24
- buttonText = "Join",
25
- successMessage = "Thanks for subscribing!",
25
+ buttonText,
26
+ successMessage,
26
27
  }: EmailListFormProps) {
28
+ const t = useForgeT();
27
29
  const theme = useForgeTheme();
28
30
  const f = useEmailListForm({ emailListId, leadMagnetId, includeName, includePhoneNumber });
29
31
 
32
+ // Defaults resolve here rather than in the signature so they can be
33
+ // translated. `??` keeps a caller-supplied "" falsy exactly as before.
34
+ const titleText = title ?? t("forge.email_list_form.title");
35
+ const buttonLabel = buttonText ?? t("forge.email_list_form.submit");
36
+ const successText = successMessage ?? t("forge.email_list_form.success");
37
+
30
38
  const inputStyle = {
31
39
  width: "100%",
32
40
  padding: 10,
@@ -39,28 +47,33 @@ export function EmailListForm({
39
47
  if (f.isSuccess) {
40
48
  return (
41
49
  <div style={{ color: theme.colors.text, fontFamily: theme.fontFamily, textAlign: "center", padding: 8 }}>
42
- {f.requiresConfirmation ? "Almost there: check your email to confirm." : successMessage}
50
+ {f.requiresConfirmation ? t("forge.email_list_form.confirm_required") : successText}
43
51
  </div>
44
52
  );
45
53
  }
46
54
 
47
55
  return (
48
56
  <div style={{ display: "flex", flexDirection: "column", gap: 10, color: theme.colors.text, fontFamily: theme.fontFamily }}>
49
- {title && <h3 style={{ fontWeight: 600 }}>{title}</h3>}
57
+ {titleText && <h3 style={{ fontWeight: 600 }}>{titleText}</h3>}
50
58
  {description && <p style={{ opacity: 0.8, fontSize: 14 }}>{description}</p>}
51
59
  {includeName && (
52
- <input placeholder="Your name" value={f.name} onChange={(e) => f.setName(e.target.value)} style={inputStyle} />
60
+ <input
61
+ placeholder={t("forge.email_list_form.name_placeholder")}
62
+ value={f.name}
63
+ onChange={(e) => f.setName(e.target.value)}
64
+ style={inputStyle}
65
+ />
53
66
  )}
54
67
  <input
55
68
  type="email"
56
- placeholder="Email address"
69
+ placeholder={t("forge.email_list_form.email_placeholder")}
57
70
  value={f.email}
58
71
  onChange={(e) => f.setEmail(e.target.value)}
59
72
  style={inputStyle}
60
73
  />
61
74
  {includePhoneNumber && (
62
75
  <input
63
- placeholder="Phone number"
76
+ placeholder={t("forge.email_list_form.phone_placeholder")}
64
77
  value={f.phoneNumber}
65
78
  onChange={(e) => f.setPhoneNumber(e.target.value)}
66
79
  style={inputStyle}
@@ -81,7 +94,7 @@ export function EmailListForm({
81
94
  opacity: f.isSubmitting ? 0.6 : 1,
82
95
  }}
83
96
  >
84
- {f.isSubmitting ? "Joining…" : buttonText}
97
+ {f.isSubmitting ? t("forge.email_list_form.submitting") : buttonLabel}
85
98
  </button>
86
99
  </div>
87
100
  );