@tribe-nest/forge 3.25.0 → 3.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/package.json +1 -1
  2. package/src/client/_tests/localeHeader.spec.tsx +98 -0
  3. package/src/client/activeFunnel.ts +35 -0
  4. package/src/client/activeLocale.ts +34 -0
  5. package/src/client/createForgeClient.ts +22 -0
  6. package/src/contexts/CartContext.tsx +84 -1
  7. package/src/contexts/_tests/CartContext.spec.tsx +210 -0
  8. package/src/data/queries/useCheckouts.ts +83 -0
  9. package/src/data/queries/useForms.ts +48 -3
  10. package/src/data/queries/usePageActions.ts +2 -2
  11. package/src/data/queries/useWebsite.ts +7 -0
  12. package/src/i18n/CONVENTION.md +184 -0
  13. package/src/i18n/_tests/translationKeys.spec.ts +153 -0
  14. package/src/i18n/de.json +960 -0
  15. package/src/i18n/en.json +960 -0
  16. package/src/i18n/index.ts +125 -0
  17. package/src/index.ts +21 -0
  18. package/src/provider/ForgeAppProvider.tsx +2 -1
  19. package/src/provider/SiteConfigProvider.tsx +11 -0
  20. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
  21. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
  22. package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
  23. package/src/ui/headless/checkout/useCartResume.ts +340 -0
  24. package/src/ui/headless/checkout/useCheckout.ts +59 -14
  25. package/src/ui/headless/event/useEventCheckout.ts +3 -0
  26. package/src/ui/headless/funnel/Funnel.tsx +13 -0
  27. package/src/ui/headless/index.ts +18 -0
  28. package/src/ui/index.ts +2 -0
  29. package/src/ui/shell/TribeNestApp.tsx +36 -27
  30. package/src/ui/styled/AccountDashboard.tsx +197 -93
  31. package/src/ui/styled/AiAgentWidget.tsx +10 -6
  32. package/src/ui/styled/AudioPlayer.tsx +14 -6
  33. package/src/ui/styled/BlogCategory.tsx +4 -2
  34. package/src/ui/styled/BlogComments.tsx +27 -20
  35. package/src/ui/styled/BlogList.tsx +5 -3
  36. package/src/ui/styled/BlogPost.tsx +14 -11
  37. package/src/ui/styled/BundleConfirmation.tsx +86 -46
  38. package/src/ui/styled/CancellationTerms.tsx +3 -1
  39. package/src/ui/styled/Cart.tsx +32 -14
  40. package/src/ui/styled/ChatRoom.tsx +65 -20
  41. package/src/ui/styled/Checkout.tsx +189 -88
  42. package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
  43. package/src/ui/styled/CoachingBooking.tsx +71 -44
  44. package/src/ui/styled/CoachingConfirmation.tsx +56 -19
  45. package/src/ui/styled/CoachingDetail.tsx +15 -11
  46. package/src/ui/styled/CohortPage.tsx +10 -8
  47. package/src/ui/styled/ConfirmSubscription.tsx +14 -8
  48. package/src/ui/styled/ContactForm.tsx +31 -12
  49. package/src/ui/styled/CookieConsent.tsx +19 -15
  50. package/src/ui/styled/CourseAccess.tsx +54 -47
  51. package/src/ui/styled/CourseCheckout.tsx +56 -38
  52. package/src/ui/styled/CourseConfirmation.tsx +39 -17
  53. package/src/ui/styled/CourseDetail.tsx +21 -15
  54. package/src/ui/styled/CoursesGrid.tsx +4 -2
  55. package/src/ui/styled/CurrencySwitcher.tsx +4 -1
  56. package/src/ui/styled/DiscountCode.tsx +22 -18
  57. package/src/ui/styled/DonationButton.tsx +52 -17
  58. package/src/ui/styled/DonationPage.tsx +4 -4
  59. package/src/ui/styled/EmailListForm.tsx +22 -9
  60. package/src/ui/styled/EventConfirmation.tsx +37 -14
  61. package/src/ui/styled/EventCountdown.tsx +10 -6
  62. package/src/ui/styled/EventDetail.tsx +8 -6
  63. package/src/ui/styled/EventSeriesDetail.tsx +18 -13
  64. package/src/ui/styled/EventTickets.tsx +311 -125
  65. package/src/ui/styled/EventWaitlist.tsx +42 -33
  66. package/src/ui/styled/EventsList.tsx +15 -13
  67. package/src/ui/styled/ForgotPasswordForm.tsx +12 -12
  68. package/src/ui/styled/FormRenderer.tsx +12 -7
  69. package/src/ui/styled/InstallBanner.tsx +21 -18
  70. package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
  71. package/src/ui/styled/InvoicePayment.tsx +37 -16
  72. package/src/ui/styled/LeadMagnet.tsx +9 -7
  73. package/src/ui/styled/Loading.tsx +7 -1
  74. package/src/ui/styled/LoginForm.tsx +22 -14
  75. package/src/ui/styled/MembershipCheckout.tsx +32 -13
  76. package/src/ui/styled/MembershipTiers.tsx +27 -18
  77. package/src/ui/styled/OfferButton.tsx +33 -13
  78. package/src/ui/styled/PageActions.tsx +13 -9
  79. package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
  80. package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
  81. package/src/ui/styled/PodcastEpisode.tsx +20 -9
  82. package/src/ui/styled/PodcastList.tsx +9 -3
  83. package/src/ui/styled/PodcastShow.tsx +29 -7
  84. package/src/ui/styled/PostsFeed.tsx +10 -8
  85. package/src/ui/styled/PresaleCode.tsx +8 -11
  86. package/src/ui/styled/ProductBrowseNav.tsx +21 -11
  87. package/src/ui/styled/ProductDetail.tsx +58 -28
  88. package/src/ui/styled/ProductGrid.tsx +17 -4
  89. package/src/ui/styled/ReactionBar.tsx +4 -2
  90. package/src/ui/styled/ReplayList.tsx +24 -13
  91. package/src/ui/styled/ResetPasswordForm.tsx +27 -15
  92. package/src/ui/styled/ResumeCart.tsx +289 -0
  93. package/src/ui/styled/ReviewForm.tsx +52 -58
  94. package/src/ui/styled/SignupForm.tsx +29 -16
  95. package/src/ui/styled/TicketTransfer.tsx +55 -54
  96. package/src/ui/styled/UserMenu.tsx +16 -14
  97. package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
@@ -1,3 +1,4 @@
1
+ import { useForgeT } from "../../i18n";
1
2
  import { useInvoicePayment } from "../headless/invoice/useInvoicePayment";
2
3
  import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
3
4
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
@@ -26,13 +27,15 @@ const formatDate = (value: string | number | Date): string =>
26
27
  * or Paystack's inline modal behind a pay button.
27
28
  */
28
29
  export function InvoicePayment({ invoiceId, formatAmount, returnPath }: InvoicePaymentProps) {
30
+ const t = useForgeT();
29
31
  const theme = useForgeTheme();
30
32
  const renderPayment = usePaymentRenderer();
31
33
  const { invoice, downloadPdf, clientSecret, returnUrl, isStarting, error, taxQuote, isPaystack, canOpenPaystack, openPaystackCheckout } =
32
34
  useInvoicePayment(invoiceId, { returnPath });
33
35
 
34
36
  if (invoice.isLoading) return <Loading fullPage />;
35
- if (invoice.error || !invoice.data) return <p style={{ color: theme.colors.text }}>Invoice not found.</p>;
37
+ if (invoice.error || !invoice.data)
38
+ return <p style={{ color: theme.colors.text }}>{t("forge.invoice_payment.not_found")}</p>;
36
39
 
37
40
  const inv = invoice.data;
38
41
  const isPaid = inv.status === "paid" || !!inv.paidAt;
@@ -72,7 +75,9 @@ export function InvoicePayment({ invoiceId, formatAmount, returnPath }: InvoiceP
72
75
  <div style={{ maxWidth: 640, margin: "0 auto", color: theme.colors.text }}>
73
76
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 16 }}>
74
77
  <div>
75
- <h1 style={{ fontSize: 26, fontWeight: 800 }}>Invoice {inv.invoiceNumber}</h1>
78
+ <h1 style={{ fontSize: 26, fontWeight: 800 }}>
79
+ {t("forge.invoice_payment.title", { invoice_number: inv.invoiceNumber })}
80
+ </h1>
76
81
  <p style={{ opacity: 0.7, fontSize: 14 }}>{inv.profileName}</p>
77
82
  </div>
78
83
  <button
@@ -88,18 +93,26 @@ export function InvoicePayment({ invoiceId, formatAmount, returnPath }: InvoiceP
88
93
  fontSize: 14,
89
94
  }}
90
95
  >
91
- {downloadPdf.isPending ? "Preparing…" : "Download PDF"}
96
+ {downloadPdf.isPending ? t("forge.invoice_payment.preparing_pdf") : t("forge.invoice_payment.download_pdf")}
92
97
  </button>
93
98
  </div>
94
99
 
95
100
  <div style={{ fontSize: 14, opacity: 0.85, marginBottom: 16 }}>
96
101
  <div>
97
- Billed to: {inv.clientName}
98
- {inv.clientCompany ? ` · ${inv.clientCompany}` : ""}
102
+ {inv.clientCompany
103
+ ? t("forge.invoice_payment.billed_to_company", {
104
+ client_name: inv.clientName,
105
+ client_company: inv.clientCompany,
106
+ })
107
+ : t("forge.invoice_payment.billed_to", { client_name: inv.clientName })}
99
108
  </div>
100
109
  <div>
101
- Issued: {formatDate(inv.issueDate)}
102
- {inv.dueDate ? ` · Due: ${formatDate(inv.dueDate)}` : ""}
110
+ {inv.dueDate
111
+ ? t("forge.invoice_payment.issued_due", {
112
+ issue_date: formatDate(inv.issueDate),
113
+ due_date: formatDate(inv.dueDate),
114
+ })
115
+ : t("forge.invoice_payment.issued", { issue_date: formatDate(inv.issueDate) })}
103
116
  </div>
104
117
  </div>
105
118
 
@@ -123,7 +136,10 @@ export function InvoicePayment({ invoiceId, formatAmount, returnPath }: InvoiceP
123
136
  >
124
137
  <span>
125
138
  {li.description}
126
- <span style={{ opacity: 0.6 }}> × {li.quantity}</span>
139
+ <span style={{ opacity: 0.6 }}>
140
+ {" "}
141
+ {t("forge.invoice_payment.line_item_quantity", { quantity: li.quantity })}
142
+ </span>
127
143
  </span>
128
144
  <span>{money(Number(li.amount))}</span>
129
145
  </div>
@@ -142,13 +158,13 @@ export function InvoicePayment({ invoiceId, formatAmount, returnPath }: InvoiceP
142
158
  opacity: 0.8,
143
159
  }}
144
160
  >
145
- <span>Tax</span>
161
+ <span>{t("forge.invoice_payment.tax")}</span>
146
162
  <span>{taxSummary.formattedTax}</span>
147
163
  </div>
148
164
  ) : null;
149
165
  })()}
150
166
  <div style={{ display: "flex", justifyContent: "space-between", padding: "12px 14px", fontWeight: 700 }}>
151
- <span>Total</span>
167
+ <span>{t("forge.invoice_payment.total")}</span>
152
168
  <span style={{ color: theme.colors.primary, textAlign: "right" }}>
153
169
  {money(Number(inv.total))}
154
170
  {(() => {
@@ -168,18 +184,23 @@ export function InvoicePayment({ invoiceId, formatAmount, returnPath }: InvoiceP
168
184
  {inv.notes && <p style={{ fontSize: 13, opacity: 0.7, marginBottom: 16 }}>{inv.notes}</p>}
169
185
 
170
186
  {isPaid ? (
171
- <p style={{ fontWeight: 700, color: theme.colors.primary }}>This invoice has been paid. Thank you!</p>
187
+ <p style={{ fontWeight: 700, color: theme.colors.primary }}>{t("forge.invoice_payment.paid_notice")}</p>
172
188
  ) : (
173
189
  <div>
174
- <h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 12 }}>Payment</h2>
190
+ <h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 12 }}>
191
+ {t("forge.invoice_payment.payment_heading")}
192
+ </h2>
175
193
  {error && <p style={{ color: "#ef4444", fontSize: 14, marginBottom: 8 }}>{error}</p>}
176
194
  {/* Paystack brings its own modal, so it is checked before the Stripe
177
195
  renderer and needs none of it. */}
178
196
  {isPaystack ? (
179
197
  canOpenPaystack ? (
180
- <PaystackPayButton onPay={openPaystackCheckout} label={`Pay ${money(Number(inv.total))}`} />
198
+ <PaystackPayButton
199
+ onPay={openPaystackCheckout}
200
+ label={t("forge.invoice_payment.pay_amount", { amount: money(Number(inv.total)) })}
201
+ />
181
202
  ) : (
182
- <Loading label="Preparing payment…" size={22} />
203
+ <Loading label={t("forge.invoice_payment.preparing_payment")} size={22} />
183
204
  )
184
205
  ) : clientSecret ? (
185
206
  renderPayment?.({
@@ -191,9 +212,9 @@ export function InvoicePayment({ invoiceId, formatAmount, returnPath }: InvoiceP
191
212
  ) : (
192
213
  <>
193
214
  {isStarting ? (
194
- <Loading label="Preparing payment…" size={22} />
215
+ <Loading label={t("forge.invoice_payment.preparing_payment")} size={22} />
195
216
  ) : (
196
- <p style={{ opacity: 0.7 }}>Payment is not available right now.</p>
217
+ <p style={{ opacity: 0.7 }}>{t("forge.invoice_payment.payment_unavailable")}</p>
197
218
  )}
198
219
  </>
199
220
  )}
@@ -1,5 +1,6 @@
1
1
  import { useState } from "react";
2
2
  import { useLeadMagnetConfirm, useTrackLeadMagnetDownload } from "../../data/queries/useLeadMagnet";
3
+ import { useForgeT } from "../../i18n";
3
4
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
4
5
  import { Loading } from "./Loading";
5
6
 
@@ -12,6 +13,7 @@ export interface LeadMagnetProps {
12
13
 
13
14
  /** Lead magnet delivery / download screen. Built on `useLeadMagnetConfirm` + `useTrackLeadMagnetDownload`. */
14
15
  export function LeadMagnet({ leadMagnetId, subscriberId }: LeadMagnetProps) {
16
+ const t = useForgeT();
15
17
  const theme = useForgeTheme();
16
18
  const trackDownload = useTrackLeadMagnetDownload();
17
19
  const [isDownloading, setIsDownloading] = useState(false);
@@ -57,8 +59,10 @@ export function LeadMagnet({ leadMagnetId, subscriberId }: LeadMagnetProps) {
57
59
  if (state === "error") {
58
60
  return (
59
61
  <div style={wrap}>
60
- <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>Oops!</h1>
61
- <p>{!leadMagnetId || !subscriberId ? "Invalid link" : "This link is invalid or has expired."}</p>
62
+ <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>{t("forge.lead_magnet.error_title")}</h1>
63
+ <p>
64
+ {!leadMagnetId || !subscriberId ? t("forge.lead_magnet.invalid_link") : t("forge.lead_magnet.expired_link")}
65
+ </p>
62
66
  </div>
63
67
  );
64
68
  }
@@ -67,11 +71,9 @@ export function LeadMagnet({ leadMagnetId, subscriberId }: LeadMagnetProps) {
67
71
  <div style={wrap}>
68
72
  {profile && <p style={{ fontSize: 14, opacity: 0.7, marginBottom: 8 }}>{profile.name}</p>}
69
73
  <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>
70
- {leadMagnet?.thankYouMessage || "Thank you for subscribing!"}
74
+ {leadMagnet?.thankYouMessage || t("forge.lead_magnet.thank_you")}
71
75
  </h1>
72
- <p style={{ opacity: 0.8, marginBottom: 32 }}>
73
- Your download is ready. Click the button below to get your file.
74
- </p>
76
+ <p style={{ opacity: 0.8, marginBottom: 32 }}>{t("forge.lead_magnet.description")}</p>
75
77
  <button
76
78
  onClick={handleDownload}
77
79
  disabled={isDownloading}
@@ -86,7 +88,7 @@ export function LeadMagnet({ leadMagnetId, subscriberId }: LeadMagnetProps) {
86
88
  color: theme.colors.background,
87
89
  }}
88
90
  >
89
- {isDownloading ? "Preparing…" : "Download Now"}
91
+ {isDownloading ? t("forge.lead_magnet.preparing") : t("forge.lead_magnet.download")}
90
92
  </button>
91
93
  </div>
92
94
  );
@@ -1,3 +1,4 @@
1
+ import { useForgeT } from "../../i18n";
1
2
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
2
3
 
3
4
  export interface LoadingProps {
@@ -14,6 +15,7 @@ const alpha = (hex: string, a: number) => hex + Math.round(a * 255).toString(16)
14
15
  /** Themed loading spinner — drop-in from `@tribe-nest/forge/ui`. */
15
16
  export function Loading({ size = 28, label, fullPage }: LoadingProps) {
16
17
  const theme = useForgeTheme();
18
+ const t = useForgeT();
17
19
  const border = Math.max(2, Math.round(size / 9));
18
20
 
19
21
  const content = (
@@ -45,7 +47,11 @@ export function Loading({ size = 28, label, fullPage }: LoadingProps) {
45
47
  }}
46
48
  />
47
49
  {label && <span style={{ fontSize: 14, opacity: 0.7 }}>{label}</span>}
48
- {!label && <span style={{ position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" }}>Loading</span>}
50
+ {!label && (
51
+ <span style={{ position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" }}>
52
+ {t("forge.loading.loading")}
53
+ </span>
54
+ )}
49
55
  </div>
50
56
  );
51
57
 
@@ -1,5 +1,6 @@
1
1
  import { useLoginFlow } from "../headless/auth/useLoginFlow";
2
2
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
3
+ import { useForgeT } from "../../i18n";
3
4
 
4
5
  export interface LoginFormProps {
5
6
  /** Called after a successful login. The shell typically redirects here. */
@@ -12,6 +13,7 @@ export interface LoginFormProps {
12
13
 
13
14
  /** Themed 2FA login form (credentials → emailed code), built on `useLoginFlow`. */
14
15
  export function LoginForm({ onSuccess, signupHref, forgotHref }: LoginFormProps) {
16
+ const t = useForgeT();
15
17
  const theme = useForgeTheme();
16
18
  const flow = useLoginFlow({ onSuccess });
17
19
 
@@ -48,9 +50,9 @@ export function LoginForm({ onSuccess, signupHref, forgotHref }: LoginFormProps)
48
50
  if (flow.step === "code") {
49
51
  return (
50
52
  <div style={card}>
51
- <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 8 }}>Verify your email</h1>
53
+ <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 8 }}>{t("forge.login_form.verify_title")}</h1>
52
54
  <p style={{ fontSize: 14, opacity: 0.7, marginBottom: 24 }}>
53
- We sent a 6-digit code to <strong>{flow.email}</strong>
55
+ {t("forge.login_form.code_sent_to")} <strong>{flow.email}</strong>
54
56
  </p>
55
57
  {flow.error && <p style={{ color: "#ef4444", marginBottom: 16 }}>{flow.error}</p>}
56
58
  <form
@@ -71,12 +73,12 @@ export function LoginForm({ onSuccess, signupHref, forgotHref }: LoginFormProps)
71
73
  style={{ ...input, textAlign: "center", fontSize: 24, letterSpacing: "0.4em" }}
72
74
  />
73
75
  <button type="submit" disabled={flow.isSubmitting || flow.code.length !== 6} style={button}>
74
- {flow.isSubmitting ? "Verifying…" : "Verify & Sign In"}
76
+ {flow.isSubmitting ? t("forge.login_form.verifying") : t("forge.login_form.verify_submit")}
75
77
  </button>
76
78
  </form>
77
79
  <div style={{ display: "flex", justifyContent: "space-between", marginTop: 16, fontSize: 14 }}>
78
80
  <button type="button" onClick={flow.back} style={{ ...link, background: "none", border: "none", cursor: "pointer" }}>
79
- Back to login
81
+ {t("forge.login_form.back_to_login")}
80
82
  </button>
81
83
  <button
82
84
  type="button"
@@ -84,7 +86,7 @@ export function LoginForm({ onSuccess, signupHref, forgotHref }: LoginFormProps)
84
86
  disabled={flow.isSubmitting}
85
87
  style={{ ...link, background: "none", border: "none", cursor: "pointer" }}
86
88
  >
87
- Resend code
89
+ {t("forge.login_form.resend_code")}
88
90
  </button>
89
91
  </div>
90
92
  </div>
@@ -93,7 +95,7 @@ export function LoginForm({ onSuccess, signupHref, forgotHref }: LoginFormProps)
93
95
 
94
96
  return (
95
97
  <div style={card}>
96
- <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>Login</h1>
98
+ <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>{t("forge.login_form.title")}</h1>
97
99
  {flow.error && <p style={{ color: "#ef4444", marginBottom: 16 }}>{flow.error}</p>}
98
100
  <form
99
101
  onSubmit={(e) => {
@@ -103,30 +105,36 @@ export function LoginForm({ onSuccess, signupHref, forgotHref }: LoginFormProps)
103
105
  style={{ display: "flex", flexDirection: "column", gap: 16 }}
104
106
  >
105
107
  <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
106
- <label style={{ fontSize: 14 }}>Email</label>
107
- <input type="email" placeholder="Email" value={flow.email} onChange={(e) => flow.setEmail(e.target.value)} style={input} />
108
+ <label style={{ fontSize: 14 }}>{t("forge.login_form.email_label")}</label>
109
+ <input
110
+ type="email"
111
+ placeholder={t("forge.login_form.email_placeholder")}
112
+ value={flow.email}
113
+ onChange={(e) => flow.setEmail(e.target.value)}
114
+ style={input}
115
+ />
108
116
  </div>
109
117
  <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
110
- <label style={{ fontSize: 14 }}>Password</label>
118
+ <label style={{ fontSize: 14 }}>{t("forge.login_form.password_label")}</label>
111
119
  <input
112
120
  type="password"
113
- placeholder="Password"
121
+ placeholder={t("forge.login_form.password_placeholder")}
114
122
  value={flow.password}
115
123
  onChange={(e) => flow.setPassword(e.target.value)}
116
124
  style={input}
117
125
  />
118
126
  <a href={forgotHref} style={{ ...link, fontSize: 13 }}>
119
- Forgot password?
127
+ {t("forge.login_form.forgot_password")}
120
128
  </a>
121
129
  </div>
122
130
  <button type="submit" disabled={flow.isSubmitting} style={button}>
123
- {flow.isSubmitting ? "Sending code…" : "Login"}
131
+ {flow.isSubmitting ? t("forge.login_form.sending_code") : t("forge.login_form.submit")}
124
132
  </button>
125
133
  </form>
126
134
  <p style={{ marginTop: 16, fontSize: 14, textAlign: "center" }}>
127
- Don&apos;t have an account?{" "}
135
+ {t("forge.login_form.no_account")}{" "}
128
136
  <a href={signupHref} style={link}>
129
- Sign up
137
+ {t("forge.login_form.sign_up")}
130
138
  </a>
131
139
  </p>
132
140
  </div>
@@ -1,5 +1,6 @@
1
1
  import type { CSSProperties } from "react";
2
2
  import { useMembershipCheckout } from "../headless/membership/useMembershipCheckout";
3
+ import { useForgeT } from "../../i18n";
3
4
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
4
5
  import { useAmountFormatter } from "../format/useFormatCurrency";
5
6
  import { priceTaxCaption } from "../format/PriceDisplay";
@@ -44,6 +45,7 @@ export function MembershipCheckout({
44
45
  className,
45
46
  style,
46
47
  }: MembershipCheckoutProps) {
48
+ const t = useForgeT();
47
49
  const theme = useForgeTheme();
48
50
  const fmt = useAmountFormatter(formatAmount);
49
51
  // Memberships have no checkout tax quote (subscription rail) — inclusive
@@ -81,9 +83,9 @@ export function MembershipCheckout({
81
83
  if (!tier) {
82
84
  return (
83
85
  <div className={className} style={card}>
84
- <p>Select a membership tier to continue.</p>
86
+ <p>{t("forge.membership_checkout.no_tier_body")}</p>
85
87
  <a href={membershipsPath} style={{ ...button, marginTop: 16, display: "inline-block", textAlign: "center", textDecoration: "none" }}>
86
- View memberships
88
+ {t("forge.membership_checkout.view_memberships")}
87
89
  </a>
88
90
  </div>
89
91
  );
@@ -96,13 +98,20 @@ export function MembershipCheckout({
96
98
  if (checkout.step === "payment" && (checkout.clientSecret || checkout.canOpenPaystack)) {
97
99
  return (
98
100
  <div className={className} style={card}>
99
- <h1 style={{ fontSize: 26, fontWeight: 800, marginBottom: 8 }}>Payment</h1>
101
+ <h1 style={{ fontSize: 26, fontWeight: 800, marginBottom: 8 }}>{t("forge.membership_checkout.payment_title")}</h1>
100
102
  <p style={{ opacity: 0.75, marginBottom: 24 }}>
101
- {tier.name} · {fmt(checkout.amount)} / {checkout.billingCycle}
103
+ {t("forge.membership_checkout.payment_summary", {
104
+ tier_name: tier.name,
105
+ amount: fmt(checkout.amount),
106
+ cycle: checkout.billingCycle,
107
+ })}
102
108
  </p>
103
109
  <div style={box}>
104
110
  {checkout.isPaystack ? (
105
- <PaystackPayButton onPay={checkout.openPaystackCheckout} label={`Pay ${fmt(checkout.amount)}`} />
111
+ <PaystackPayButton
112
+ onPay={checkout.openPaystackCheckout}
113
+ label={t("forge.membership_checkout.pay", { amount: fmt(checkout.amount) })}
114
+ />
106
115
  ) : (
107
116
  checkout.clientSecret &&
108
117
  renderPayment({
@@ -119,7 +128,7 @@ export function MembershipCheckout({
119
128
  onClick={() => checkout.setStep("select")}
120
129
  style={{ ...button, background: "transparent", color: theme.colors.text, border: `1px solid ${theme.colors.primary}40` }}
121
130
  >
122
- Back
131
+ {t("forge.membership_checkout.back")}
123
132
  </button>
124
133
  </div>
125
134
  );
@@ -129,7 +138,9 @@ export function MembershipCheckout({
129
138
 
130
139
  return (
131
140
  <div className={className} style={card}>
132
- <h1 style={{ fontSize: 26, fontWeight: 800, marginBottom: 24 }}>Join {tier.name}</h1>
141
+ <h1 style={{ fontSize: 26, fontWeight: 800, marginBottom: 24 }}>
142
+ {t("forge.membership_checkout.title", { tier_name: tier.name })}
143
+ </h1>
133
144
 
134
145
  <div style={box}>
135
146
  {tier.description && <p style={{ marginBottom: 16 }}>{tier.description}</p>}
@@ -148,7 +159,7 @@ export function MembershipCheckout({
148
159
  <div style={box}>
149
160
  {showBillingToggle && (
150
161
  <div style={{ marginBottom: 16 }}>
151
- <p style={{ fontSize: 14, marginBottom: 8 }}>Billing cycle</p>
162
+ <p style={{ fontSize: 14, marginBottom: 8 }}>{t("forge.membership_checkout.billing_cycle_label")}</p>
152
163
  <div style={{ display: "flex", gap: 16 }}>
153
164
  {(["month", "year"] as const).map((cycle) => (
154
165
  <label key={cycle} style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer" }}>
@@ -159,7 +170,9 @@ export function MembershipCheckout({
159
170
  onChange={() => checkout.setBillingCycle(cycle)}
160
171
  style={{ accentColor: theme.colors.primary }}
161
172
  />
162
- {cycle === "month" ? "Monthly" : "Yearly"}
173
+ {cycle === "month"
174
+ ? t("forge.membership_checkout.billing_monthly")
175
+ : t("forge.membership_checkout.billing_yearly")}
163
176
  </label>
164
177
  ))}
165
178
  </div>
@@ -168,7 +181,9 @@ export function MembershipCheckout({
168
181
 
169
182
  {tier.payWhatYouWant && (
170
183
  <div style={{ marginBottom: 16 }}>
171
- <label style={{ fontSize: 14, display: "block", marginBottom: 8 }}>How much would you like to pay?</label>
184
+ <label style={{ fontSize: 14, display: "block", marginBottom: 8 }}>
185
+ {t("forge.membership_checkout.pwyw_label")}
186
+ </label>
172
187
  <input
173
188
  type="number"
174
189
  min={tier.payWhatYouWantMinimum ?? 0}
@@ -189,9 +204,13 @@ export function MembershipCheckout({
189
204
  )}
190
205
 
191
206
  <div style={{ textAlign: "center", paddingTop: 16, borderTop: `1px solid ${theme.colors.primary}20` }}>
192
- <p style={{ fontSize: 14, opacity: 0.75 }}>Total</p>
207
+ <p style={{ fontSize: 14, opacity: 0.75 }}>{t("forge.membership_checkout.total")}</p>
193
208
  <p style={{ fontSize: 28, fontWeight: 800 }}>{fmt(checkout.amount)}</p>
194
- <p style={{ fontSize: 14, opacity: 0.75 }}>{checkout.billingCycle === "month" ? "per month" : "per year"}</p>
209
+ <p style={{ fontSize: 14, opacity: 0.75 }}>
210
+ {checkout.billingCycle === "month"
211
+ ? t("forge.membership_checkout.per_month")
212
+ : t("forge.membership_checkout.per_year")}
213
+ </p>
195
214
  {taxCaption && <p style={{ fontSize: 12, opacity: 0.65, marginTop: 2 }}>{taxCaption}</p>}
196
215
  </div>
197
216
  </div>
@@ -199,7 +218,7 @@ export function MembershipCheckout({
199
218
  {checkout.error && <p style={{ color: "#ef4444", marginBottom: 16 }}>{checkout.error}</p>}
200
219
 
201
220
  <button onClick={() => checkout.subscribe()} disabled={checkout.isProcessing} style={button}>
202
- {checkout.isProcessing ? "Processing…" : "Subscribe"}
221
+ {checkout.isProcessing ? t("forge.membership_checkout.submitting") : t("forge.membership_checkout.submit")}
203
222
  </button>
204
223
  </div>
205
224
  );
@@ -4,6 +4,7 @@ import { useThemeTokens } from "../theme/ForgeThemeProvider";
4
4
  import { useAmountFormatter } from "../format/useFormatCurrency";
5
5
  import { priceTaxCaption } from "../format/PriceDisplay";
6
6
  import { usePricesIncludeTax } from "../../data/queries/useWebsite";
7
+ import { useForgeT } from "../../i18n";
7
8
  import { Button } from "./Button";
8
9
  import { Loading } from "./Loading";
9
10
 
@@ -33,27 +34,31 @@ export function MembershipTiers({
33
34
  formatAmount,
34
35
  checkoutPath = "/i/membership/checkout",
35
36
  onSelect,
36
- heading = "Membership",
37
+ heading,
37
38
  className,
38
39
  style,
39
40
  }: MembershipTiersProps) {
40
- const t = useThemeTokens();
41
+ const tokens = useThemeTokens();
42
+ const t = useForgeT();
41
43
  const fmt = useAmountFormatter(formatAmount);
42
44
  // Display-only: inclusive stores caption tier prices as tax-inclusive.
43
45
  const pricesIncludeTax = usePricesIncludeTax();
44
46
  const taxCaption = priceTaxCaption({ pricesIncludeTax });
45
47
  const { data: tiers, isLoading } = useGetMembershipTiers();
48
+ // `null` still means "no heading"; anything else falls back to the default.
49
+ const resolvedHeading = heading === null ? null : (heading ?? t("forge.membership_tiers.heading"));
50
+ const freeLabel = t("forge.membership_tiers.price_free");
46
51
 
47
52
  if (isLoading) return <Loading fullPage />;
48
- if (!tiers?.length) return <p style={{ color: t.text }}>No membership tiers available.</p>;
53
+ if (!tiers?.length) return <p style={{ color: tokens.text }}>{t("forge.membership_tiers.empty_title")}</p>;
49
54
 
50
55
  const priceLabel = (tier: MembershipTier) => {
51
56
  if (tier.payWhatYouWant) {
52
- return `Pay what you want from ${fmt(tier.payWhatYouWantMinimum ?? 0)}/mo`;
57
+ return t("forge.membership_tiers.price_pwyw", { amount: fmt(tier.payWhatYouWantMinimum ?? 0) });
53
58
  }
54
- if (tier.priceMonthly) return `${fmt(tier.priceMonthly)}/mo`;
55
- if (tier.priceYearly) return `${fmt(tier.priceYearly)}/yr`;
56
- return "Free";
59
+ if (tier.priceMonthly) return t("forge.membership_tiers.price_monthly", { amount: fmt(tier.priceMonthly) });
60
+ if (tier.priceYearly) return t("forge.membership_tiers.price_yearly", { amount: fmt(tier.priceYearly) });
61
+ return freeLabel;
57
62
  };
58
63
 
59
64
  const select = (tier: MembershipTier) => {
@@ -65,8 +70,12 @@ export function MembershipTiers({
65
70
  };
66
71
 
67
72
  return (
68
- <div className={className} style={{ color: t.text, fontFamily: t.fontFamily, ...style }}>
69
- {heading && <h1 style={{ fontSize: 32, fontWeight: 800, marginBottom: 24, fontFamily: t.headingFontFamily }}>{heading}</h1>}
73
+ <div className={className} style={{ color: tokens.text, fontFamily: tokens.fontFamily, ...style }}>
74
+ {resolvedHeading && (
75
+ <h1 style={{ fontSize: 32, fontWeight: 800, marginBottom: 24, fontFamily: tokens.headingFontFamily }}>
76
+ {resolvedHeading}
77
+ </h1>
78
+ )}
70
79
  <div style={{ display: "flex", flexWrap: "wrap", gap: 20, justifyContent: "center" }}>
71
80
  {tiers.map((tier) => (
72
81
  <div
@@ -75,20 +84,20 @@ export function MembershipTiers({
75
84
  flex: "1 1 260px",
76
85
  maxWidth: 340,
77
86
  width: "100%",
78
- background: t.surface,
79
- border: `1px solid ${t.primary}30`,
80
- borderRadius: t.cornerRadius,
87
+ background: tokens.surface,
88
+ border: `1px solid ${tokens.primary}30`,
89
+ borderRadius: tokens.cornerRadius,
81
90
  padding: 24,
82
91
  display: "flex",
83
92
  flexDirection: "column",
84
93
  gap: 12,
85
94
  }}
86
95
  >
87
- <h2 style={{ fontSize: 22, fontWeight: 700, fontFamily: t.headingFontFamily }}>{tier.name}</h2>
88
- <div style={{ fontSize: 24, fontWeight: 800, color: t.primary }}>
96
+ <h2 style={{ fontSize: 22, fontWeight: 700, fontFamily: tokens.headingFontFamily }}>{tier.name}</h2>
97
+ <div style={{ fontSize: 24, fontWeight: 800, color: tokens.primary }}>
89
98
  {priceLabel(tier)}
90
- {taxCaption && priceLabel(tier) !== "Free" && (
91
- <span style={{ display: "block", fontSize: 12, fontWeight: 400, color: t.text, opacity: 0.65 }}>
99
+ {taxCaption && priceLabel(tier) !== freeLabel && (
100
+ <span style={{ display: "block", fontSize: 12, fontWeight: 400, color: tokens.text, opacity: 0.65 }}>
92
101
  {taxCaption}
93
102
  </span>
94
103
  )}
@@ -98,14 +107,14 @@ export function MembershipTiers({
98
107
  <ul style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 6 }}>
99
108
  {tier.benefits.map((b) => (
100
109
  <li key={b.id} style={{ display: "flex", gap: 8, opacity: 0.85 }}>
101
- <span style={{ color: t.primary }}>✓</span>
110
+ <span style={{ color: tokens.primary }}>✓</span>
102
111
  {b.title}
103
112
  </li>
104
113
  ))}
105
114
  </ul>
106
115
  )}
107
116
  <Button fullWidth style={{ marginTop: "auto" }} onClick={() => select(tier)}>
108
- Select
117
+ {t("forge.membership_tiers.select")}
109
118
  </Button>
110
119
  </div>
111
120
  ))}