@tribe-nest/forge 3.26.0 → 3.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/package.json +1 -1
  2. package/src/client/_tests/localeHeader.spec.tsx +98 -0
  3. package/src/client/activeLocale.ts +34 -0
  4. package/src/client/createForgeClient.ts +13 -0
  5. package/src/contexts/CartContext.tsx +76 -0
  6. package/src/contexts/_tests/CartContext.spec.tsx +210 -0
  7. package/src/data/queries/useCheckouts.ts +83 -0
  8. package/src/data/queries/useWebsite.ts +7 -0
  9. package/src/i18n/CONVENTION.md +184 -0
  10. package/src/i18n/_tests/translationKeys.spec.ts +153 -0
  11. package/src/i18n/de.json +960 -0
  12. package/src/i18n/en.json +960 -0
  13. package/src/i18n/index.ts +125 -0
  14. package/src/index.ts +21 -0
  15. package/src/provider/ForgeAppProvider.tsx +2 -1
  16. package/src/provider/SiteConfigProvider.tsx +11 -0
  17. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
  18. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
  19. package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
  20. package/src/ui/headless/checkout/useCartResume.ts +340 -0
  21. package/src/ui/headless/checkout/useCheckout.ts +9 -2
  22. package/src/ui/headless/index.ts +18 -0
  23. package/src/ui/index.ts +2 -0
  24. package/src/ui/shell/TribeNestApp.tsx +36 -27
  25. package/src/ui/styled/AccountDashboard.tsx +197 -93
  26. package/src/ui/styled/AiAgentWidget.tsx +10 -6
  27. package/src/ui/styled/AudioPlayer.tsx +14 -6
  28. package/src/ui/styled/BlogCategory.tsx +4 -2
  29. package/src/ui/styled/BlogComments.tsx +27 -20
  30. package/src/ui/styled/BlogList.tsx +5 -3
  31. package/src/ui/styled/BlogPost.tsx +14 -11
  32. package/src/ui/styled/BundleConfirmation.tsx +32 -23
  33. package/src/ui/styled/CancellationTerms.tsx +3 -1
  34. package/src/ui/styled/Cart.tsx +32 -14
  35. package/src/ui/styled/ChatRoom.tsx +65 -20
  36. package/src/ui/styled/Checkout.tsx +151 -89
  37. package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
  38. package/src/ui/styled/CoachingBooking.tsx +71 -44
  39. package/src/ui/styled/CoachingConfirmation.tsx +56 -19
  40. package/src/ui/styled/CoachingDetail.tsx +15 -11
  41. package/src/ui/styled/CohortPage.tsx +10 -8
  42. package/src/ui/styled/ConfirmSubscription.tsx +14 -8
  43. package/src/ui/styled/ContactForm.tsx +31 -12
  44. package/src/ui/styled/CookieConsent.tsx +19 -15
  45. package/src/ui/styled/CourseAccess.tsx +54 -47
  46. package/src/ui/styled/CourseCheckout.tsx +56 -38
  47. package/src/ui/styled/CourseConfirmation.tsx +39 -17
  48. package/src/ui/styled/CourseDetail.tsx +21 -15
  49. package/src/ui/styled/CoursesGrid.tsx +4 -2
  50. package/src/ui/styled/CurrencySwitcher.tsx +4 -1
  51. package/src/ui/styled/DiscountCode.tsx +22 -18
  52. package/src/ui/styled/DonationButton.tsx +52 -17
  53. package/src/ui/styled/DonationPage.tsx +4 -4
  54. package/src/ui/styled/EmailListForm.tsx +22 -9
  55. package/src/ui/styled/EventConfirmation.tsx +37 -14
  56. package/src/ui/styled/EventCountdown.tsx +10 -6
  57. package/src/ui/styled/EventDetail.tsx +8 -6
  58. package/src/ui/styled/EventSeriesDetail.tsx +18 -13
  59. package/src/ui/styled/EventTickets.tsx +232 -108
  60. package/src/ui/styled/EventWaitlist.tsx +42 -33
  61. package/src/ui/styled/EventsList.tsx +15 -13
  62. package/src/ui/styled/ForgotPasswordForm.tsx +12 -12
  63. package/src/ui/styled/FormRenderer.tsx +12 -7
  64. package/src/ui/styled/InstallBanner.tsx +21 -18
  65. package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
  66. package/src/ui/styled/InvoicePayment.tsx +37 -16
  67. package/src/ui/styled/LeadMagnet.tsx +9 -7
  68. package/src/ui/styled/Loading.tsx +7 -1
  69. package/src/ui/styled/LoginForm.tsx +22 -14
  70. package/src/ui/styled/MembershipCheckout.tsx +32 -13
  71. package/src/ui/styled/MembershipTiers.tsx +27 -18
  72. package/src/ui/styled/OfferButton.tsx +33 -13
  73. package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
  74. package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
  75. package/src/ui/styled/PodcastEpisode.tsx +20 -9
  76. package/src/ui/styled/PodcastList.tsx +9 -3
  77. package/src/ui/styled/PodcastShow.tsx +29 -7
  78. package/src/ui/styled/PostsFeed.tsx +10 -8
  79. package/src/ui/styled/PresaleCode.tsx +8 -11
  80. package/src/ui/styled/ProductBrowseNav.tsx +21 -11
  81. package/src/ui/styled/ProductDetail.tsx +58 -28
  82. package/src/ui/styled/ReactionBar.tsx +4 -2
  83. package/src/ui/styled/ReplayList.tsx +24 -13
  84. package/src/ui/styled/ResetPasswordForm.tsx +27 -15
  85. package/src/ui/styled/ResumeCart.tsx +289 -0
  86. package/src/ui/styled/ReviewForm.tsx +52 -58
  87. package/src/ui/styled/SignupForm.tsx +29 -16
  88. package/src/ui/styled/TicketTransfer.tsx +55 -54
  89. package/src/ui/styled/UserMenu.tsx +16 -14
  90. package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
@@ -11,6 +11,7 @@ import {
11
11
  useLeaveEventWaitlist,
12
12
  type WaitlistEntry,
13
13
  } from "../../data/queries/useEventWaitlist";
14
+ import { useForgeT } from "../../i18n";
14
15
  import { useThemeTokens, type ResolvedThemeTokens } from "../theme/ForgeThemeProvider";
15
16
  import { Loading } from "./Loading";
16
17
 
@@ -40,7 +41,7 @@ export interface EventWaitlistProps {
40
41
  event?: IEvent;
41
42
  /** Slug or id, when the host has not already loaded the event. */
42
43
  slug?: string;
43
- /** Section heading. */
44
+ /** Section heading. Defaults to Forge's own translated heading. */
44
45
  title?: string;
45
46
  className?: string;
46
47
  style?: CSSProperties;
@@ -48,8 +49,9 @@ export interface EventWaitlistProps {
48
49
 
49
50
  const alpha = (hex: string, a: number) => hex + Math.round(a * 255).toString(16).padStart(2, "0");
50
51
 
51
- export function EventWaitlist({ event: initialEvent, slug, title = "Waitlist", className, style }: EventWaitlistProps) {
52
+ export function EventWaitlist({ event: initialEvent, slug, title, className, style }: EventWaitlistProps) {
52
53
  const t = useThemeTokens();
54
+ const tr = useForgeT();
53
55
  const { user } = usePublicAuth();
54
56
  const { data: fetched } = useEvent(initialEvent ? undefined : slug);
55
57
  const event = initialEvent ?? fetched;
@@ -89,11 +91,9 @@ export function EventWaitlist({ event: initialEvent, slug, title = "Waitlist", c
89
91
  }}
90
92
  >
91
93
  <Users size={18} style={{ color: t.primary }} />
92
- {title}
94
+ {title ?? tr("forge.event_waitlist.title")}
93
95
  </h2>
94
- <p style={{ fontSize: 14, opacity: 0.75, margin: "0 0 16px" }}>
95
- Sold out. Join the queue and we&apos;ll email you if a ticket is released.
96
- </p>
96
+ <p style={{ fontSize: 14, opacity: 0.75, margin: "0 0 16px" }}>{tr("forge.event_waitlist.subtitle")}</p>
97
97
 
98
98
  {isLoading && live.length === 0 && soldOutTiers.length === 0 ? (
99
99
  <Loading />
@@ -138,6 +138,7 @@ function WaitlistPlaceCard({
138
138
  t: ResolvedThemeTokens;
139
139
  }) {
140
140
  const leave = useLeaveEventWaitlist(eventId);
141
+ const tr = useForgeT();
141
142
  const [error, setError] = useState<string | null>(null);
142
143
  const offerOpen = entry.offer.offerOpen;
143
144
 
@@ -146,7 +147,7 @@ function WaitlistPlaceCard({
146
147
  try {
147
148
  await leave.mutateAsync({ entryId: entry.id });
148
149
  } catch (err) {
149
- setError(readError(err, "We could not remove you from the waitlist. Please try again."));
150
+ setError(readError(err, tr("forge.event_waitlist.leave_error")));
150
151
  }
151
152
  };
152
153
 
@@ -160,9 +161,11 @@ function WaitlistPlaceCard({
160
161
  >
161
162
  <div style={{ display: "flex", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
162
163
  <div>
163
- <div style={{ fontWeight: 600 }}>{ticket?.title ?? "Ticket"}</div>
164
+ <div style={{ fontWeight: 600 }}>{ticket?.title ?? tr("forge.event_waitlist.ticket_fallback")}</div>
164
165
  <div style={{ fontSize: 13, opacity: 0.7 }}>
165
- {entry.quantity} {entry.quantity === 1 ? "ticket" : "tickets"} requested
166
+ {entry.quantity === 1
167
+ ? tr("forge.event_waitlist.quantity_requested_one", { count: entry.quantity })
168
+ : tr("forge.event_waitlist.quantity_requested_other", { count: entry.quantity })}
166
169
  </div>
167
170
  </div>
168
171
  {offerOpen ? (
@@ -170,14 +173,16 @@ function WaitlistPlaceCard({
170
173
  <BellRing size={16} />
171
174
  {/* 2.6b — "reserved for you" is a materially different promise from
172
175
  "available", and the fan acts differently on each. */}
173
- {entry.offer.seatsReserved ? "Reserved for you" : "Tickets available"}
176
+ {entry.offer.seatsReserved
177
+ ? tr("forge.event_waitlist.offer_reserved")
178
+ : tr("forge.event_waitlist.offer_available")}
174
179
  </span>
175
180
  ) : entry.position != null ? (
176
181
  <span style={{ fontSize: 14, opacity: 0.85 }}>
177
- Position <strong style={{ color: t.primary }}>#{entry.position}</strong>
182
+ {tr("forge.event_waitlist.position_label")} <strong style={{ color: t.primary }}>#{entry.position}</strong>
178
183
  </span>
179
184
  ) : (
180
- <span style={{ fontSize: 14, opacity: 0.85 }}>You&apos;re on the list</span>
185
+ <span style={{ fontSize: 14, opacity: 0.85 }}>{tr("forge.event_waitlist.on_the_list")}</span>
181
186
  )}
182
187
  </div>
183
188
 
@@ -200,7 +205,7 @@ function WaitlistPlaceCard({
200
205
  fontWeight: 600,
201
206
  }}
202
207
  >
203
- {leave.isPending ? "Leaving…" : "Leave waitlist"}
208
+ {leave.isPending ? tr("forge.event_waitlist.leaving") : tr("forge.event_waitlist.leave")}
204
209
  </button>
205
210
  </div>
206
211
  );
@@ -214,6 +219,7 @@ function WaitlistPlaceCard({
214
219
  * locally, so a slow clock can shorten what the buyer sees but never lengthen it.
215
220
  */
216
221
  function OfferDeadline({ entry, t }: { entry: WaitlistEntry; t: ResolvedThemeTokens }) {
222
+ const tr = useForgeT();
217
223
  const [remaining, setRemaining] = useState(entry.offer.secondsRemaining ?? 0);
218
224
  // Absent on a payload from an older API build — read as "not reserved", which
219
225
  // is the weaker and therefore safe reading.
@@ -255,7 +261,9 @@ function OfferDeadline({ entry, t }: { entry: WaitlistEntry; t: ResolvedThemeTok
255
261
  >
256
262
  <div style={{ display: "flex", alignItems: "center", gap: 8, fontWeight: 600 }}>
257
263
  <Clock size={14} style={{ color: t.primary }} />
258
- {remaining > 0 ? `Buy within ${formatCountdown(remaining)}` : "This offer has closed"}
264
+ {remaining > 0
265
+ ? tr("forge.event_waitlist.buy_within", { time: formatCountdown(remaining) })
266
+ : tr("forge.event_waitlist.offer_closed")}
259
267
  </div>
260
268
  {deadline && (
261
269
  <div style={{ opacity: 0.8, marginTop: 4 }}>
@@ -272,10 +280,12 @@ function OfferDeadline({ entry, t }: { entry: WaitlistEntry; t: ResolvedThemeTok
272
280
  failure this change closes.
273
281
  */}
274
282
  {remaining <= 0
275
- ? `Your offer expired at ${deadline}.`
283
+ ? tr("forge.event_waitlist.offer_expired_at", { deadline })
276
284
  : reserved
277
- ? `${entry.quantity === 1 ? "Your ticket is" : `Your ${entry.quantity} tickets are`} reserved until ${deadline}. Nobody else can buy ${entry.quantity === 1 ? "it" : "them"}. Check out from the ticket options above.`
278
- : `Your offer closes ${deadline}. Tickets are not reserved. Buy from the ticket options above to secure them.`}
285
+ ? entry.quantity === 1
286
+ ? tr("forge.event_waitlist.reserved_until_one", { deadline })
287
+ : tr("forge.event_waitlist.reserved_until_other", { count: entry.quantity, deadline })
288
+ : tr("forge.event_waitlist.offer_not_reserved", { deadline })}
279
289
  </div>
280
290
  )}
281
291
  </div>
@@ -308,6 +318,7 @@ function JoinCard({
308
318
  signedIn: boolean;
309
319
  }) {
310
320
  const join = useJoinEventWaitlist(eventId);
321
+ const tr = useForgeT();
311
322
  const [open, setOpen] = useState(false);
312
323
  const [email, setEmail] = useState("");
313
324
  const [firstName, setFirstName] = useState("");
@@ -319,7 +330,7 @@ function JoinCard({
319
330
  e.preventDefault();
320
331
  setError(null);
321
332
  if (!signedIn && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
322
- return setError("Please enter a valid email address.");
333
+ return setError(tr("forge.event_waitlist.email_invalid"));
323
334
  }
324
335
  try {
325
336
  await join.mutateAsync({
@@ -330,7 +341,7 @@ function JoinCard({
330
341
  quantity,
331
342
  });
332
343
  } catch (err) {
333
- setError(readError(err, "We could not add you to the waitlist. Please try again."));
344
+ setError(readError(err, tr("forge.event_waitlist.join_error")));
334
345
  }
335
346
  };
336
347
 
@@ -349,7 +360,7 @@ function JoinCard({
349
360
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
350
361
  <div>
351
362
  <div style={{ fontWeight: 600 }}>{ticket.title}</div>
352
- <div style={{ fontSize: 13, opacity: 0.7 }}>Sold out</div>
363
+ <div style={{ fontSize: 13, opacity: 0.7 }}>{tr("forge.event_waitlist.sold_out")}</div>
353
364
  </div>
354
365
  {!open && (
355
366
  <button
@@ -365,7 +376,7 @@ function JoinCard({
365
376
  cursor: "pointer",
366
377
  }}
367
378
  >
368
- Join waitlist
379
+ {tr("forge.event_waitlist.join")}
369
380
  </button>
370
381
  )}
371
382
  </div>
@@ -375,31 +386,31 @@ function JoinCard({
375
386
  {!signedIn && (
376
387
  <input
377
388
  type="email"
378
- placeholder="Email"
389
+ placeholder={tr("forge.event_waitlist.email_placeholder")}
379
390
  value={email}
380
391
  onChange={(e) => setEmail(e.target.value)}
381
392
  style={input}
382
- aria-label="Email"
393
+ aria-label={tr("forge.event_waitlist.aria_email")}
383
394
  />
384
395
  )}
385
396
  <div style={{ display: "flex", gap: 8 }}>
386
397
  <input
387
- placeholder="First name (optional)"
398
+ placeholder={tr("forge.event_waitlist.first_name_placeholder")}
388
399
  value={firstName}
389
400
  onChange={(e) => setFirstName(e.target.value)}
390
401
  style={input}
391
- aria-label="First name"
402
+ aria-label={tr("forge.event_waitlist.aria_first_name")}
392
403
  />
393
404
  <input
394
- placeholder="Last name (optional)"
405
+ placeholder={tr("forge.event_waitlist.last_name_placeholder")}
395
406
  value={lastName}
396
407
  onChange={(e) => setLastName(e.target.value)}
397
408
  style={input}
398
- aria-label="Last name"
409
+ aria-label={tr("forge.event_waitlist.aria_last_name")}
399
410
  />
400
411
  </div>
401
412
  <label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 14 }}>
402
- How many tickets?
413
+ {tr("forge.event_waitlist.quantity_label")}
403
414
  <input
404
415
  type="number"
405
416
  min={1}
@@ -424,7 +435,7 @@ function JoinCard({
424
435
  cursor: join.isPending ? "wait" : "pointer",
425
436
  }}
426
437
  >
427
- {join.isPending ? "Joining…" : "Join waitlist"}
438
+ {join.isPending ? tr("forge.event_waitlist.joining") : tr("forge.event_waitlist.join")}
428
439
  </button>
429
440
  <button
430
441
  type="button"
@@ -438,12 +449,10 @@ function JoinCard({
438
449
  cursor: "pointer",
439
450
  }}
440
451
  >
441
- Cancel
452
+ {tr("forge.event_waitlist.cancel")}
442
453
  </button>
443
454
  </div>
444
- <p style={{ fontSize: 12, opacity: 0.65, margin: 0 }}>
445
- We&apos;ll only email you about tickets for this event.
446
- </p>
455
+ <p style={{ fontSize: 12, opacity: 0.65, margin: 0 }}>{tr("forge.event_waitlist.email_promise")}</p>
447
456
  </form>
448
457
  )}
449
458
  </div>
@@ -2,6 +2,7 @@ import { useEvents } from "../../data/queries/useEvents";
2
2
  import type { IEvent } from "../../types/models";
3
3
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
4
4
  import { Loading } from "./Loading";
5
+ import { useForgeT } from "../../i18n";
5
6
 
6
7
  export interface EventsListProps {
7
8
  limit?: number;
@@ -22,23 +23,24 @@ function formatDate(dateTime: string, timezone?: string) {
22
23
  });
23
24
  }
24
25
 
25
- function locationText(event: IEvent): string {
26
- if (event.type === "virtual") return "Virtual";
26
+ function locationText(event: IEvent, virtualLabel: string): string {
27
+ if (event.type === "virtual") return virtualLabel;
27
28
  if (event.address) return event.address.name || event.address.city;
28
- return "Virtual";
29
+ return virtualLabel;
29
30
  }
30
31
 
31
32
  /** Themed upcoming-events list, built on `useEvents`. */
32
33
  export function EventsList({ limit, onSelect, className, style }: EventsListProps) {
33
- const t = useThemeTokens();
34
+ const t = useForgeT();
35
+ const tokens = useThemeTokens();
34
36
  const { data, isLoading } = useEvents();
35
37
  const events = (data ?? []).slice(0, limit ?? 100);
36
38
 
37
39
  if (isLoading) return <Loading />;
38
- if (!events.length) return <p style={{ color: t.text, opacity: 0.7 }}>No upcoming events.</p>;
40
+ if (!events.length) return <p style={{ color: tokens.text, opacity: 0.7 }}>{t("forge.events_list.empty_title")}</p>;
39
41
 
40
42
  return (
41
- <div className={className} style={{ display: "flex", flexDirection: "column", gap: 12, fontFamily: t.fontFamily, ...style }}>
43
+ <div className={className} style={{ display: "flex", flexDirection: "column", gap: 12, fontFamily: tokens.fontFamily, ...style }}>
42
44
  {events.map((ev) => {
43
45
  const cover = ev.media?.find((m) => m.type === "image")?.url ?? ev.media?.[0]?.url;
44
46
  return (
@@ -51,21 +53,21 @@ export function EventsList({ limit, onSelect, className, style }: EventsListProp
51
53
  alignItems: "center",
52
54
  textAlign: "left",
53
55
  cursor: onSelect ? "pointer" : "default",
54
- background: t.surface,
55
- border: `1px solid ${t.primary}20`,
56
- borderRadius: t.cornerRadius,
56
+ background: tokens.surface,
57
+ border: `1px solid ${tokens.primary}20`,
58
+ borderRadius: tokens.cornerRadius,
57
59
  padding: 10,
58
- color: t.text,
60
+ color: tokens.text,
59
61
  }}
60
62
  >
61
63
  <div
62
64
  style={{
63
65
  width: 72,
64
66
  height: 72,
65
- borderRadius: t.cornerRadius,
67
+ borderRadius: tokens.cornerRadius,
66
68
  overflow: "hidden",
67
69
  flexShrink: 0,
68
- background: `${t.primary}10`,
70
+ background: `${tokens.primary}10`,
69
71
  }}
70
72
  >
71
73
  {cover && <img src={cover} alt={ev.title} style={{ width: "100%", height: "100%", objectFit: "cover" }} />}
@@ -73,7 +75,7 @@ export function EventsList({ limit, onSelect, className, style }: EventsListProp
73
75
  <div>
74
76
  <div style={{ fontWeight: 600 }}>{ev.title}</div>
75
77
  <div style={{ fontSize: 13, opacity: 0.75 }}>{formatDate(ev.dateTime, ev.timezone)}</div>
76
- <div style={{ fontSize: 13, opacity: 0.75 }}>{locationText(ev)}</div>
78
+ <div style={{ fontSize: 13, opacity: 0.75 }}>{locationText(ev, t("forge.events_list.virtual"))}</div>
77
79
  </div>
78
80
  </button>
79
81
  );
@@ -1,5 +1,6 @@
1
1
  import { useState } from "react";
2
2
  import { useForgotPassword } from "../../data/queries/useAuthActions";
3
+ import { useForgeT } from "../../i18n";
3
4
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
4
5
 
5
6
  export interface ForgotPasswordFormProps {
@@ -11,6 +12,7 @@ const msg = (e: unknown) => (e as { response?: { data?: { message?: string } } }
11
12
 
12
13
  /** Themed forgot-password request form, built on `useForgotPassword`. */
13
14
  export function ForgotPasswordForm({ loginHref }: ForgotPasswordFormProps) {
15
+ const t = useForgeT();
14
16
  const theme = useForgeTheme();
15
17
  const forgotPassword = useForgotPassword();
16
18
  const [email, setEmail] = useState("");
@@ -46,7 +48,7 @@ export function ForgotPasswordForm({ loginHref }: ForgotPasswordFormProps) {
46
48
  await forgotPassword.mutateAsync({ email, origin: window.location.origin });
47
49
  setIsSuccess(true);
48
50
  } catch (err) {
49
- setError(msg(err) || "Something went wrong. Please try again.");
51
+ setError(msg(err) || t("forge.forgot_password_form.error"));
50
52
  } finally {
51
53
  setIsSubmitting(false);
52
54
  }
@@ -55,12 +57,10 @@ export function ForgotPasswordForm({ loginHref }: ForgotPasswordFormProps) {
55
57
  if (isSuccess) {
56
58
  return (
57
59
  <div style={card}>
58
- <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>Check your email</h1>
59
- <p style={{ fontSize: 14, marginBottom: 24 }}>
60
- If an account exists with that email, we&apos;ve sent a password reset link. Please check your inbox.
61
- </p>
60
+ <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>{t("forge.forgot_password_form.sent_title")}</h1>
61
+ <p style={{ fontSize: 14, marginBottom: 24 }}>{t("forge.forgot_password_form.sent_body")}</p>
62
62
  <a href={loginHref} style={link}>
63
- Back to login
63
+ {t("forge.forgot_password_form.back_to_login")}
64
64
  </a>
65
65
  </div>
66
66
  );
@@ -68,14 +68,14 @@ export function ForgotPasswordForm({ loginHref }: ForgotPasswordFormProps) {
68
68
 
69
69
  return (
70
70
  <div style={card}>
71
- <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>Reset password</h1>
71
+ <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>{t("forge.forgot_password_form.title")}</h1>
72
72
  {error && <p style={{ color: "#ef4444", marginBottom: 16 }}>{error}</p>}
73
73
  <form onSubmit={onSubmit} style={{ display: "flex", flexDirection: "column", gap: 16 }}>
74
74
  <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
75
- <label style={{ fontSize: 14 }}>Email</label>
75
+ <label style={{ fontSize: 14 }}>{t("forge.forgot_password_form.email_label")}</label>
76
76
  <input
77
77
  type="email"
78
- placeholder="Email"
78
+ placeholder={t("forge.forgot_password_form.email_placeholder")}
79
79
  value={email}
80
80
  onChange={(e) => setEmail(e.target.value)}
81
81
  style={{
@@ -91,13 +91,13 @@ export function ForgotPasswordForm({ loginHref }: ForgotPasswordFormProps) {
91
91
  />
92
92
  </div>
93
93
  <button type="submit" disabled={isSubmitting} style={button}>
94
- {isSubmitting ? "Sending…" : "Send reset email"}
94
+ {isSubmitting ? t("forge.forgot_password_form.submitting") : t("forge.forgot_password_form.submit")}
95
95
  </button>
96
96
  </form>
97
97
  <p style={{ marginTop: 16, fontSize: 14, textAlign: "center" }}>
98
- Already have an account?{" "}
98
+ {t("forge.forgot_password_form.login_prompt")}{" "}
99
99
  <a href={loginHref} style={link}>
100
- Login
100
+ {t("forge.forgot_password_form.login_link")}
101
101
  </a>
102
102
  </p>
103
103
  </div>
@@ -2,6 +2,7 @@ import { useState } from "react";
2
2
  import { usePublicForm, useSubmitPublicForm } from "../../data/queries/useForms";
3
3
  import type { FormField } from "../../types/models";
4
4
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
5
+ import { useForgeT } from "../../i18n";
5
6
  import { Loading } from "./Loading";
6
7
 
7
8
  export interface FormRendererProps {
@@ -15,6 +16,7 @@ export interface FormRendererProps {
15
16
  */
16
17
  export function FormRenderer({ formId }: FormRendererProps) {
17
18
  const theme = useForgeTheme();
19
+ const t = useForgeT();
18
20
  const { data: form, isLoading } = usePublicForm(formId);
19
21
  const submitMutation = useSubmitPublicForm(formId);
20
22
 
@@ -33,12 +35,12 @@ export function FormRenderer({ formId }: FormRendererProps) {
33
35
  setError("");
34
36
  for (const field of fields) {
35
37
  if (field.isRequired && !answers[field.id]) {
36
- setError(`"${field.label}" is required`);
38
+ setError(t("forge.form_renderer.required_error", { label: field.label }));
37
39
  return;
38
40
  }
39
41
  if (field.type === "email" && answers[field.id]) {
40
42
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(answers[field.id])) {
41
- setError("Please enter a valid email address");
43
+ setError(t("forge.form_renderer.invalid_email"));
42
44
  return;
43
45
  }
44
46
  }
@@ -48,7 +50,7 @@ export function FormRenderer({ formId }: FormRendererProps) {
48
50
  await submitMutation.mutateAsync(entries);
49
51
  setIsCompleted(true);
50
52
  } catch {
51
- setError("Failed to submit. Please try again.");
53
+ setError(t("forge.form_renderer.submit_error"));
52
54
  }
53
55
  };
54
56
 
@@ -58,9 +60,9 @@ export function FormRenderer({ formId }: FormRendererProps) {
58
60
  return (
59
61
  <div style={{ ...wrap, textAlign: "center" }}>
60
62
  <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>
61
- {settings.thankYouTitle || "Thank you!"}
63
+ {settings.thankYouTitle || t("forge.form_renderer.thank_you_title")}
62
64
  </h1>
63
- <p style={{ opacity: 0.8 }}>{settings.thankYouMessage || "Your response has been recorded."}</p>
65
+ <p style={{ opacity: 0.8 }}>{settings.thankYouMessage || t("forge.form_renderer.thank_you_body")}</p>
64
66
  </div>
65
67
  );
66
68
  }
@@ -99,7 +101,9 @@ export function FormRenderer({ formId }: FormRendererProps) {
99
101
  color: theme.colors.background,
100
102
  }}
101
103
  >
102
- {submitMutation.isPending ? "Submitting…" : settings.submitButtonText || "Submit"}
104
+ {submitMutation.isPending
105
+ ? t("forge.form_renderer.submitting")
106
+ : settings.submitButtonText || t("forge.form_renderer.submit")}
103
107
  </button>
104
108
  </div>
105
109
  );
@@ -116,6 +120,7 @@ export function FieldInput({
116
120
  onChange: (v: string) => void;
117
121
  theme: ReturnType<typeof useForgeTheme>;
118
122
  }) {
123
+ const t = useForgeT();
119
124
  const inputStyle: React.CSSProperties = {
120
125
  width: "100%",
121
126
  padding: "10px 12px",
@@ -149,7 +154,7 @@ export function FieldInput({
149
154
  case "single_select":
150
155
  control = (
151
156
  <select value={value} onChange={(e) => onChange(e.target.value)} style={inputStyle}>
152
- <option value="">Select…</option>
157
+ <option value="">{t("forge.form_renderer.select_placeholder")}</option>
153
158
  {field.options?.map((o) => (
154
159
  <option key={o.id} value={o.label}>
155
160
  {o.label}
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import { useInstallPrompt } from "../../data/queries/usePWA";
3
3
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
4
+ import { useForgeT } from "../../i18n";
4
5
  import { Button } from "./Button";
5
6
 
6
7
  const DISMISS_KEY = "forge-install-banner-dismissed";
@@ -25,13 +26,15 @@ export interface InstallBannerProps {
25
26
  * iOS the installed PWA is also the gateway to web push (iOS 16.4+).
26
27
  * Hidden when installed, already dismissed (localStorage), or not `enabled`.
27
28
  */
28
- export function InstallBanner({
29
- enabled = true,
30
- title = "Install this app",
31
- description = "Add it to your home screen for a faster, full-screen experience.",
32
- iosInstructions = "Tap the Share icon, then “Add to Home Screen”.",
33
- }: InstallBannerProps) {
34
- const t = useThemeTokens();
29
+ export function InstallBanner({ enabled = true, title, description, iosInstructions }: InstallBannerProps) {
30
+ const tokens = useThemeTokens();
31
+ const t = useForgeT();
32
+ // The defaults moved out of the signature because they are now translated,
33
+ // and a hook cannot be called in a parameter default. A caller-supplied
34
+ // string still wins, exactly as before.
35
+ const heading = title ?? t("forge.install_banner.title");
36
+ const body = description ?? t("forge.install_banner.description");
37
+ const iosBody = iosInstructions ?? t("forge.install_banner.ios_instructions");
35
38
  const { isInstallable, isInstalled, isIOS, isStandalone, promptInstall } = useInstallPrompt();
36
39
  const [dismissed, setDismissed] = useState(true);
37
40
 
@@ -59,7 +62,7 @@ export function InstallBanner({
59
62
  return (
60
63
  <div
61
64
  role="dialog"
62
- aria-label={title}
65
+ aria-label={heading}
63
66
  style={{
64
67
  position: "fixed",
65
68
  left: 12,
@@ -69,10 +72,10 @@ export function InstallBanner({
69
72
  maxWidth: 520,
70
73
  margin: "0 auto",
71
74
  padding: 16,
72
- borderRadius: t.cornerRadius,
73
- background: t.surface,
74
- color: t.text,
75
- border: `1px solid ${t.border}`,
75
+ borderRadius: tokens.cornerRadius,
76
+ background: tokens.surface,
77
+ color: tokens.text,
78
+ border: `1px solid ${tokens.border}`,
76
79
  boxShadow: "0 8px 30px rgba(0,0,0,0.18)",
77
80
  display: "flex",
78
81
  flexDirection: "column",
@@ -81,14 +84,14 @@ export function InstallBanner({
81
84
  >
82
85
  <div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 12 }}>
83
86
  <div>
84
- <div style={{ fontWeight: 700, fontSize: 15 }}>{title}</div>
85
- <div style={{ fontSize: 13, color: t.muted, marginTop: 2 }}>{isIOS ? iosInstructions : description}</div>
87
+ <div style={{ fontWeight: 700, fontSize: 15 }}>{heading}</div>
88
+ <div style={{ fontSize: 13, color: tokens.muted, marginTop: 2 }}>{isIOS ? iosBody : body}</div>
86
89
  </div>
87
90
  <button
88
91
  type="button"
89
- aria-label="Dismiss"
92
+ aria-label={t("forge.install_banner.aria_dismiss")}
90
93
  onClick={dismiss}
91
- style={{ background: "transparent", border: "none", color: t.muted, cursor: "pointer", fontSize: 18, lineHeight: 1 }}
94
+ style={{ background: "transparent", border: "none", color: tokens.muted, cursor: "pointer", fontSize: 18, lineHeight: 1 }}
92
95
  >
93
96
  {"×"}
94
97
  </button>
@@ -102,10 +105,10 @@ export function InstallBanner({
102
105
  if (ok) dismiss();
103
106
  }}
104
107
  >
105
- Install
108
+ {t("forge.install_banner.install")}
106
109
  </Button>
107
110
  <Button size="sm" variant="ghost" onClick={dismiss}>
108
- Not now
111
+ {t("forge.install_banner.not_now")}
109
112
  </Button>
110
113
  </div>
111
114
  )}
@@ -1,3 +1,4 @@
1
+ import { useForgeT } from "../../i18n";
1
2
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
2
3
  import { useAmountFormatter } from "../format/useFormatCurrency";
3
4
  import { Loading } from "./Loading";
@@ -20,30 +21,37 @@ export function InvoiceConfirmation({
20
21
  invoicePath = `/i/invoice/${invoiceId}`,
21
22
  }: InvoiceConfirmationProps) {
22
23
  const theme = useForgeTheme();
24
+ const t = useForgeT();
23
25
  const fmt = useAmountFormatter(formatAmount);
24
26
  const { data, isLoading, error } = useInvoiceFinalize(invoiceId);
25
27
 
26
28
  if (isLoading) return <Loading fullPage />;
27
- if (error || !data) return <p style={{ color: theme.colors.text }}>We couldn’t load this invoice.</p>;
29
+ if (error || !data) return <p style={{ color: theme.colors.text }}>{t("forge.invoice_confirmation.load_error")}</p>;
28
30
 
29
31
  const paid = data.status === "paid" || !!data.paidAt;
30
32
 
31
33
  return (
32
34
  <div style={{ maxWidth: 520, margin: "0 auto", textAlign: "center", color: theme.colors.text }}>
33
35
  <h1 style={{ fontSize: 28, fontWeight: 800, marginBottom: 8 }}>
34
- {paid ? "Payment received" : "Payment not completed"}
36
+ {paid ? t("forge.invoice_confirmation.paid_title") : t("forge.invoice_confirmation.unpaid_title")}
35
37
  </h1>
36
38
  <p style={{ opacity: 0.75, marginBottom: 16 }}>
37
39
  {paid
38
- ? `Thank you, ${data.clientName}. Invoice ${data.invoiceNumber} is now paid.`
39
- : `Invoice ${data.invoiceNumber} is ${data.status}. If you were charged, please contact support.`}
40
+ ? t("forge.invoice_confirmation.paid_body", {
41
+ client_name: data.clientName,
42
+ invoice_number: data.invoiceNumber,
43
+ })
44
+ : t("forge.invoice_confirmation.unpaid_body", {
45
+ invoice_number: data.invoiceNumber,
46
+ status: data.status,
47
+ })}
40
48
  </p>
41
49
  <p style={{ fontWeight: 700, color: theme.colors.primary }}>{fmt(Number(data.total), data.currency)}</p>
42
50
  <a
43
51
  href={invoicePath}
44
52
  style={{ display: "inline-block", marginTop: 24, color: theme.colors.primary, textDecoration: "underline" }}
45
53
  >
46
- View invoice
54
+ {t("forge.invoice_confirmation.view_invoice")}
47
55
  </a>
48
56
  </div>
49
57
  );