@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
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
+ import { useForgeT } from "../../i18n";
2
3
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
3
4
  import { readableTextOn } from "../theme/contrast";
4
5
  import { useAiAgent } from "../headless/agent/useAiAgent";
@@ -24,6 +25,7 @@ export interface AiAgentWidgetProps {
24
25
  * behavior. For custom UI, use the `useAiAgent()` hook instead.
25
26
  */
26
27
  export function AiAgentWidget({ position = "bottom-right", accentColor, title, zIndex = 9999 }: AiAgentWidgetProps) {
28
+ const t = useForgeT();
27
29
  const theme = useForgeTheme();
28
30
  const { isEnabled, config, messages, sending, error, send } = useAiAgent();
29
31
  const [open, setOpen] = useState(false);
@@ -33,7 +35,7 @@ export function AiAgentWidget({ position = "bottom-right", accentColor, title, z
33
35
  const accent = accentColor || theme.colors.primary;
34
36
  const onAccent = readableTextOn(accent);
35
37
  const side = position === "bottom-left" ? { left: 20 } : { right: 20 };
36
- const heading = title || config?.displayName || "Assistant";
38
+ const heading = title || config?.displayName || t("forge.ai_agent_widget.default_title");
37
39
 
38
40
  useEffect(() => {
39
41
  scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
@@ -67,14 +69,16 @@ export function AiAgentWidget({ position = "bottom-right", accentColor, title, z
67
69
  >
68
70
  <div style={{ background: accent, color: onAccent, padding: "12px 16px", fontWeight: 600, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
69
71
  <span>{heading}</span>
70
- <button onClick={() => setOpen(false)} style={{ background: "transparent", border: "none", color: onAccent, cursor: "pointer", fontSize: 20, lineHeight: 1 }} aria-label="Close">
72
+ <button onClick={() => setOpen(false)} style={{ background: "transparent", border: "none", color: onAccent, cursor: "pointer", fontSize: 20, lineHeight: 1 }} aria-label={t("forge.ai_agent_widget.aria_close")}>
71
73
  ×
72
74
  </button>
73
75
  </div>
74
76
 
75
77
  <div ref={scrollRef} style={{ flex: 1, overflowY: "auto", padding: 16, display: "flex", flexDirection: "column", gap: 10 }}>
76
78
  {messages.length === 0 && (
77
- <div style={{ opacity: 0.7, fontSize: 14 }}>{config?.greeting || "Hi! Ask me anything."}</div>
79
+ <div style={{ opacity: 0.7, fontSize: 14 }}>
80
+ {config?.greeting || t("forge.ai_agent_widget.default_greeting")}
81
+ </div>
78
82
  )}
79
83
  {messages.map((m, i) => (
80
84
  <div
@@ -105,7 +109,7 @@ export function AiAgentWidget({ position = "bottom-right", accentColor, title, z
105
109
  value={input}
106
110
  onChange={(e) => setInput(e.target.value)}
107
111
  onKeyDown={(e) => e.key === "Enter" && submit()}
108
- placeholder="Type a message…"
112
+ placeholder={t("forge.ai_agent_widget.input_placeholder")}
109
113
  disabled={sending}
110
114
  style={{
111
115
  flex: 1,
@@ -123,7 +127,7 @@ export function AiAgentWidget({ position = "bottom-right", accentColor, title, z
123
127
  disabled={sending || !input.trim()}
124
128
  style={{ background: accent, color: onAccent, border: "none", borderRadius: theme.cornerRadius, padding: "0 16px", cursor: "pointer", fontWeight: 600 }}
125
129
  >
126
- Send
130
+ {t("forge.ai_agent_widget.send")}
127
131
  </button>
128
132
  </div>
129
133
  </div>
@@ -131,7 +135,7 @@ export function AiAgentWidget({ position = "bottom-right", accentColor, title, z
131
135
 
132
136
  <button
133
137
  onClick={() => setOpen((o) => !o)}
134
- aria-label="Open chat assistant"
138
+ aria-label={t("forge.ai_agent_widget.aria_open")}
135
139
  style={{
136
140
  width: 56,
137
141
  height: 56,
@@ -1,5 +1,6 @@
1
1
  import { SkipBack, SkipForward, Play, Pause, X } from "lucide-react";
2
2
  import { useAudioPlayer } from "../../contexts/AudioPlayerContext";
3
+ import { useForgeT } from "../../i18n";
3
4
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
4
5
 
5
6
  function fmtTime(s: number): string {
@@ -26,6 +27,7 @@ export interface AudioPlayerProps {
26
27
  * playback and dismisses the bar.
27
28
  */
28
29
  export function AudioPlayer({ hideWhenEmpty = true, fixed = true }: AudioPlayerProps) {
30
+ const t = useForgeT();
29
31
  const theme = useForgeTheme();
30
32
  const a = useAudioPlayer();
31
33
 
@@ -64,19 +66,25 @@ export function AudioPlayer({ hideWhenEmpty = true, fixed = true }: AudioPlayerP
64
66
  style={{ width: 44, height: 44, borderRadius: theme.cornerRadius, objectFit: "cover", flexShrink: 0 }}
65
67
  />
66
68
  )}
67
- <button onClick={a.playPrevious} style={ctrlBtn} aria-label="Previous">
69
+ <button onClick={a.playPrevious} style={ctrlBtn} aria-label={t("forge.audio_player.previous")}>
68
70
  <SkipBack size={18} />
69
71
  </button>
70
- <button onClick={() => (a.isPlaying ? a.pause() : a.play())} style={ctrlBtn} aria-label={a.isPlaying ? "Pause" : "Play"}>
72
+ <button
73
+ onClick={() => (a.isPlaying ? a.pause() : a.play())}
74
+ style={ctrlBtn}
75
+ aria-label={a.isPlaying ? t("forge.audio_player.pause") : t("forge.audio_player.play")}
76
+ >
71
77
  {a.isPlaying ? <Pause size={20} /> : <Play size={20} />}
72
78
  </button>
73
- <button onClick={a.playNext} style={ctrlBtn} aria-label="Next">
79
+ <button onClick={a.playNext} style={ctrlBtn} aria-label={t("forge.audio_player.next")}>
74
80
  <SkipForward size={18} />
75
81
  </button>
76
82
 
77
83
  <div style={{ flex: 1, minWidth: 0 }}>
78
- <div style={{ fontWeight: 600, fontSize: 14, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
79
- {a.currentTrack?.title ?? "Nothing playing"}
84
+ <div
85
+ style={{ fontWeight: 600, fontSize: 14, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
86
+ >
87
+ {a.currentTrack?.title ?? t("forge.audio_player.nothing_playing")}
80
88
  {a.currentTrack?.artist ? ` · ${a.currentTrack.artist}` : ""}
81
89
  </div>
82
90
  <div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12, opacity: 0.8 }}>
@@ -93,7 +101,7 @@ export function AudioPlayer({ hideWhenEmpty = true, fixed = true }: AudioPlayerP
93
101
  </div>
94
102
  </div>
95
103
 
96
- <button onClick={a.stop} style={{ ...ctrlBtn, opacity: 0.7 }} aria-label="Close player">
104
+ <button onClick={a.stop} style={{ ...ctrlBtn, opacity: 0.7 }} aria-label={t("forge.audio_player.aria_close")}>
97
105
  <X size={18} />
98
106
  </button>
99
107
  </div>
@@ -1,5 +1,6 @@
1
1
  import { useBlogCategory, useBlogCategoryPosts } from "../../data/queries/useBlog";
2
2
  import type { BlogPost, BlogCategory as BlogCategoryModel } from "../../types/models";
3
+ import { useForgeT } from "../../i18n";
3
4
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
4
5
  import { Loading } from "./Loading";
5
6
 
@@ -19,11 +20,12 @@ function excerpt(html: string, len: number) {
19
20
  /** Blog category page — grid of posts in a category. Built on `useBlogCategory` + `useBlogCategoryPosts`. */
20
21
  export function BlogCategory({ slug, blogPostHref = (s) => `/i/blog/${s}`, initialCategory }: BlogCategoryProps) {
21
22
  const theme = useForgeTheme();
23
+ const t = useForgeT();
22
24
  const { data: category, isLoading: catLoading } = useBlogCategory(slug, { initialData: initialCategory });
23
25
  const { data: posts, isLoading: postsLoading } = useBlogCategoryPosts(slug, { page: 1, limit: 12 });
24
26
 
25
27
  if (catLoading || postsLoading) return <Loading fullPage />;
26
- if (!category) return <p>Category not found.</p>;
28
+ if (!category) return <p>{t("forge.blog_category.not_found")}</p>;
27
29
 
28
30
  const list = posts?.data ?? [];
29
31
 
@@ -72,7 +74,7 @@ export function BlogCategory({ slug, blogPostHref = (s) => `/i/blog/${s}`, initi
72
74
  })}
73
75
  </div>
74
76
  ) : (
75
- <p style={{ opacity: 0.7 }}>No posts yet.</p>
77
+ <p style={{ opacity: 0.7 }}>{t("forge.blog_category.empty_title")}</p>
76
78
  )}
77
79
  </div>
78
80
  );
@@ -9,6 +9,7 @@ import {
9
9
  type BlogComment,
10
10
  } from "../../data/queries/useBlogComments";
11
11
  import { BotProtection, EMPTY_BOT_FIELDS, type BotFields } from "./BotProtection";
12
+ import { useForgeT } from "../../i18n";
12
13
 
13
14
  export interface BlogCommentsProps {
14
15
  blogPostId: string;
@@ -31,7 +32,8 @@ function formatDate(iso: string): string {
31
32
  * (apps/client, site-starter, AI-code-built sites). Renders nothing when the
32
33
  * profile has comments disabled.
33
34
  */
34
- export function BlogComments({ blogPostId, pageSize = 10, title = "Comments", signInHref, className, style }: BlogCommentsProps) {
35
+ export function BlogComments({ blogPostId, pageSize = 10, title, signInHref, className, style }: BlogCommentsProps) {
36
+ const t = useForgeT();
35
37
  const theme = useForgeTheme();
36
38
  const { token } = useForge();
37
39
  const isSignedIn = !!token;
@@ -53,6 +55,7 @@ export function BlogComments({ blogPostId, pageSize = 10, title = "Comments", si
53
55
  [commentsQuery.data],
54
56
  );
55
57
  const total = commentsQuery.data?.pages?.[0]?.total ?? 0;
58
+ const heading = title ?? t("forge.blog_comments.title");
56
59
 
57
60
  const muted = theme.colors.muted ?? `${theme.colors.text}99`;
58
61
  const border = theme.colors.border ?? `${theme.colors.primary}33`;
@@ -74,11 +77,11 @@ export function BlogComments({ blogPostId, pageSize = 10, title = "Comments", si
74
77
  setError(null);
75
78
  const trimmed = content.trim();
76
79
  if (!trimmed) {
77
- setError("Please write a comment.");
80
+ setError(t("forge.blog_comments.error_empty"));
78
81
  return;
79
82
  }
80
83
  if (!isSignedIn && (!name.trim() || !email.trim())) {
81
- setError("Name and email are required.");
84
+ setError(t("forge.blog_comments.error_identity_required"));
82
85
  return;
83
86
  }
84
87
  try {
@@ -96,7 +99,7 @@ export function BlogComments({ blogPostId, pageSize = 10, title = "Comments", si
96
99
  setBotResetKey((k) => k + 1);
97
100
  } catch (e) {
98
101
  const message =
99
- (e as { response?: { data?: { message?: string } } })?.response?.data?.message ?? "Something went wrong. Please try again.";
102
+ (e as { response?: { data?: { message?: string } } })?.response?.data?.message ?? t("forge.blog_comments.error_generic");
100
103
  setError(message);
101
104
  }
102
105
  };
@@ -109,8 +112,7 @@ export function BlogComments({ blogPostId, pageSize = 10, title = "Comments", si
109
112
  style={{ color: theme.colors.text, fontFamily: theme.fontFamily, textAlign: "left", ...style }}
110
113
  >
111
114
  <h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 16 }}>
112
- {title}
113
- {total > 0 ? ` (${total})` : ""}
115
+ {total > 0 ? t("forge.blog_comments.title_with_count", { title: heading, count: total }) : heading}
114
116
  </h2>
115
117
 
116
118
  {/* Form */}
@@ -118,33 +120,38 @@ export function BlogComments({ blogPostId, pageSize = 10, title = "Comments", si
118
120
  <p style={{ color: muted, marginBottom: 24 }}>
119
121
  {signInHref ? (
120
122
  <a href={signInHref} style={{ color: theme.colors.primary }}>
121
- Sign in
123
+ {t("forge.blog_comments.sign_in")}
122
124
  </a>
123
125
  ) : (
124
- "Sign in"
126
+ t("forge.blog_comments.sign_in")
125
127
  )}{" "}
126
- to leave a comment.
128
+ {t("forge.blog_comments.sign_in_suffix")}
127
129
  </p>
128
130
  ) : pendingNotice ? (
129
131
  <div
130
132
  style={{ padding: 12, borderRadius: theme.cornerRadius, background: `${theme.colors.primary}14`, marginBottom: 24 }}
131
133
  >
132
- Thanks! Your comment is awaiting moderation.
134
+ {t("forge.blog_comments.pending_notice")}
133
135
  <button
134
136
  onClick={() => setPendingNotice(false)}
135
137
  style={{ marginLeft: 8, background: "none", border: "none", color: theme.colors.primary, cursor: "pointer" }}
136
138
  >
137
- Write another
139
+ {t("forge.blog_comments.write_another")}
138
140
  </button>
139
141
  </div>
140
142
  ) : (
141
143
  <div style={{ display: "flex", flexDirection: "column", gap: 10, marginBottom: 28 }}>
142
144
  {!isSignedIn && (
143
145
  <div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
144
- <input placeholder="Your name" value={name} onChange={(e) => setName(e.target.value)} style={{ ...inputStyle, flex: 1, minWidth: 160 }} />
146
+ <input
147
+ placeholder={t("forge.blog_comments.name_placeholder")}
148
+ value={name}
149
+ onChange={(e) => setName(e.target.value)}
150
+ style={{ ...inputStyle, flex: 1, minWidth: 160 }}
151
+ />
145
152
  <input
146
153
  type="email"
147
- placeholder="Email (not published)"
154
+ placeholder={t("forge.blog_comments.email_placeholder")}
148
155
  value={email}
149
156
  onChange={(e) => setEmail(e.target.value)}
150
157
  style={{ ...inputStyle, flex: 1, minWidth: 160 }}
@@ -152,7 +159,7 @@ export function BlogComments({ blogPostId, pageSize = 10, title = "Comments", si
152
159
  </div>
153
160
  )}
154
161
  <textarea
155
- placeholder="Add a comment…"
162
+ placeholder={t("forge.blog_comments.comment_placeholder")}
156
163
  value={content}
157
164
  onChange={(e) => setContent(e.target.value)}
158
165
  rows={3}
@@ -175,7 +182,7 @@ export function BlogComments({ blogPostId, pageSize = 10, title = "Comments", si
175
182
  opacity: submit.isPending ? 0.7 : 1,
176
183
  }}
177
184
  >
178
- {submit.isPending ? "Posting…" : "Post comment"}
185
+ {submit.isPending ? t("forge.blog_comments.submitting") : t("forge.blog_comments.submit")}
179
186
  </button>
180
187
  </div>
181
188
  </div>
@@ -183,15 +190,15 @@ export function BlogComments({ blogPostId, pageSize = 10, title = "Comments", si
183
190
 
184
191
  {/* List */}
185
192
  {commentsQuery.isLoading ? (
186
- <p style={{ color: muted }}>Loading comments…</p>
193
+ <p style={{ color: muted }}>{t("forge.blog_comments.loading")}</p>
187
194
  ) : comments.length === 0 ? (
188
- <p style={{ color: muted }}>No comments yet. Be the first.</p>
195
+ <p style={{ color: muted }}>{t("forge.blog_comments.empty_title")}</p>
189
196
  ) : (
190
197
  <div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
191
198
  {comments.map((c) => (
192
199
  <div key={c.id}>
193
200
  <div style={{ display: "flex", justifyContent: "space-between", gap: 12 }}>
194
- <strong>{c.authorName || "Anonymous"}</strong>
201
+ <strong>{c.authorName || t("forge.blog_comments.anonymous")}</strong>
195
202
  <span style={{ color: muted, fontSize: 13 }}>{formatDate(c.createdAt)}</span>
196
203
  </div>
197
204
  <p style={{ margin: "4px 0", whiteSpace: "pre-wrap" }}>{c.content}</p>
@@ -200,7 +207,7 @@ export function BlogComments({ blogPostId, pageSize = 10, title = "Comments", si
200
207
  {c.replies.map((r) => (
201
208
  <div key={r.id}>
202
209
  <div style={{ display: "flex", justifyContent: "space-between", gap: 12 }}>
203
- <strong style={{ color: theme.colors.primary }}>{r.authorName || "Author"}</strong>
210
+ <strong style={{ color: theme.colors.primary }}>{r.authorName || t("forge.blog_comments.author")}</strong>
204
211
  <span style={{ color: muted, fontSize: 13 }}>{formatDate(r.createdAt)}</span>
205
212
  </div>
206
213
  <p style={{ margin: "4px 0", whiteSpace: "pre-wrap" }}>{r.content}</p>
@@ -227,7 +234,7 @@ export function BlogComments({ blogPostId, pageSize = 10, title = "Comments", si
227
234
  cursor: "pointer",
228
235
  }}
229
236
  >
230
- {commentsQuery.isFetchingNextPage ? "Loading…" : "Load more comments"}
237
+ {commentsQuery.isFetchingNextPage ? t("forge.blog_comments.loading_more") : t("forge.blog_comments.load_more")}
231
238
  </button>
232
239
  </div>
233
240
  )}
@@ -1,5 +1,6 @@
1
1
  import { useFeaturedBlogPost, useBlogPosts } from "../../data/queries/useBlog";
2
2
  import type { BlogPost } from "../../types/models";
3
+ import { useForgeT } from "../../i18n";
3
4
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
4
5
  import { Loading } from "./Loading";
5
6
 
@@ -19,6 +20,7 @@ function excerpt(html: string, len: number) {
19
20
 
20
21
  /** Blog index — featured post + grid of remaining posts. Built on `useFeaturedBlogPost` + `useBlogPosts`. */
21
22
  export function BlogList({ blogPostHref = (slug) => `/i/blog/${slug}` }: BlogListProps = {}) {
23
+ const t = useForgeT();
22
24
  const theme = useForgeTheme();
23
25
  const { data: featured, isLoading: loadingFeatured } = useFeaturedBlogPost();
24
26
  const { data, isLoading: loadingPosts } = useBlogPosts({ page: 1, limit: 12 });
@@ -39,7 +41,7 @@ export function BlogList({ blogPostHref = (slug) => `/i/blog/${slug}` }: BlogLis
39
41
 
40
42
  return (
41
43
  <div style={{ maxWidth: 900, margin: "0 auto", color: theme.colors.text }}>
42
- <h1 style={{ fontSize: 32, fontWeight: 800, marginBottom: 24 }}>Blog</h1>
44
+ <h1 style={{ fontSize: 32, fontWeight: 800, marginBottom: 24 }}>{t("forge.blog_list.title")}</h1>
43
45
 
44
46
  {featured && (
45
47
  <a href={blogPostHref(featured.slug)} style={{ ...card, marginBottom: 32 }}>
@@ -61,7 +63,7 @@ export function BlogList({ blogPostHref = (slug) => `/i/blog/${slug}` }: BlogLis
61
63
  color: theme.colors.background,
62
64
  }}
63
65
  >
64
- Featured
66
+ {t("forge.blog_list.featured_badge")}
65
67
  </span>
66
68
  {featured.publishedAt && (
67
69
  <span style={{ fontSize: 13, opacity: 0.6 }}>{formatDate(featured.publishedAt)}</span>
@@ -94,7 +96,7 @@ export function BlogList({ blogPostHref = (slug) => `/i/blog/${slug}` }: BlogLis
94
96
  })}
95
97
  </div>
96
98
  ) : (
97
- !featured && <p style={{ opacity: 0.7 }}>No blog posts available yet.</p>
99
+ !featured && <p style={{ opacity: 0.7 }}>{t("forge.blog_list.empty_body")}</p>
98
100
  )}
99
101
  </div>
100
102
  );
@@ -3,6 +3,7 @@ import { useBlogPost } from "../../data/queries/useBlog";
3
3
  import { useAudioPlayer } from "../../contexts/AudioPlayerContext";
4
4
  import type { BlogPost as BlogPostModel } from "../../types/models";
5
5
  import { useForgeTheme, useThemeTokens } from "../theme/ForgeThemeProvider";
6
+ import { useForgeT } from "../../i18n";
6
7
  import { Loading } from "./Loading";
7
8
  // Baseline `.rich-text` content styles ship with Forge (WYSIWYG with the admin
8
9
  // editor) — no host-loaded stylesheet needed.
@@ -46,7 +47,8 @@ function AudioAttachmentBar({
46
47
  audio: BlogPostModel["media"][number];
47
48
  coverImage?: string;
48
49
  }) {
49
- const t = useThemeTokens();
50
+ const tokens = useThemeTokens();
51
+ const t = useForgeT();
50
52
  const player = useAudioPlayer();
51
53
 
52
54
  const isCurrent = player.currentTrack?.id === post.id;
@@ -71,15 +73,15 @@ function AudioAttachmentBar({
71
73
  gap: 16,
72
74
  padding: 16,
73
75
  marginBottom: 24,
74
- border: `1px solid ${t.border}`,
75
- borderRadius: t.cornerRadius,
76
- background: t.surface,
76
+ border: `1px solid ${tokens.border}`,
77
+ borderRadius: tokens.cornerRadius,
78
+ background: tokens.surface,
77
79
  }}
78
80
  >
79
81
  <button
80
82
  type="button"
81
83
  onClick={onClick}
82
- aria-label={isPlaying ? "Pause" : "Play"}
84
+ aria-label={isPlaying ? t("forge.blog_post.pause") : t("forge.blog_post.play")}
83
85
  style={{
84
86
  display: "inline-flex",
85
87
  alignItems: "center",
@@ -89,12 +91,12 @@ function AudioAttachmentBar({
89
91
  border: "none",
90
92
  cursor: "pointer",
91
93
  fontWeight: 600,
92
- background: t.primary,
93
- color: t.textPrimary,
94
+ background: tokens.primary,
95
+ color: tokens.textPrimary,
94
96
  }}
95
97
  >
96
98
  {isPlaying ? <Pause size={18} /> : <Play size={18} />}
97
- {isPlaying ? "Pause" : "Play"}
99
+ {isPlaying ? t("forge.blog_post.pause") : t("forge.blog_post.play")}
98
100
  </button>
99
101
  {duration && <span style={{ fontSize: 14, opacity: 0.6, fontVariantNumeric: "tabular-nums" }}>{duration}</span>}
100
102
  </div>
@@ -104,11 +106,12 @@ function AudioAttachmentBar({
104
106
  /** Blog post detail, resolved by slug. Built on `useBlogPost`. */
105
107
  export function BlogPost({ slug, blogHref = "/i/blog", initialPost }: BlogPostProps) {
106
108
  const theme = useForgeTheme();
109
+ const t = useForgeT();
107
110
  // Detail is resolved by slug (the public API looks posts up by slug).
108
111
  const { data: post, isLoading } = useBlogPost(slug, { initialData: initialPost });
109
112
 
110
113
  if (isLoading) return <Loading fullPage />;
111
- if (!post) return <p>Post not found.</p>;
114
+ if (!post) return <p>{t("forge.blog_post.not_found")}</p>;
112
115
 
113
116
  // Cover is the first image media — audio posts put the audio file first in
114
117
  // `media`, which must not be rendered as an <img>. `type` may be a bare kind
@@ -120,7 +123,7 @@ export function BlogPost({ slug, blogHref = "/i/blog", initialPost }: BlogPostPr
120
123
  return (
121
124
  <article style={{ maxWidth: 720, margin: "0 auto", color: theme.colors.text }}>
122
125
  <a href={blogHref} style={{ color: theme.colors.primary, fontSize: 14 }}>
123
- ← Back to Blog
126
+ {t("forge.blog_post.back_to_blog")}
124
127
  </a>
125
128
 
126
129
  {cover && (
@@ -143,7 +146,7 @@ export function BlogPost({ slug, blogHref = "/i/blog", initialPost }: BlogPostPr
143
146
  color: theme.colors.background,
144
147
  }}
145
148
  >
146
- Featured
149
+ {t("forge.blog_post.featured")}
147
150
  </span>
148
151
  )}
149
152
  </div>
@@ -6,6 +6,7 @@ import { ConfirmationStage, ConfirmationCard, CheckSeal, WarnSeal, alpha } from
6
6
  import { useCheckoutFinalize } from "../../data/queries/useFinalize";
7
7
  import { useCart } from "../../contexts/CartContext";
8
8
  import { usePublicAuth } from "../../contexts/PublicAuthContext";
9
+ import { useForgeT } from "../../i18n";
9
10
 
10
11
  /** The card's padded, centered interior — the shell itself has neither, and
11
12
  content dropped in bare sits left-aligned against the card edge. */
@@ -38,6 +39,7 @@ export function BundleConfirmation({
38
39
  checkoutPath = "/i/checkout",
39
40
  }: BundleConfirmationProps) {
40
41
  const theme = useForgeTheme();
42
+ const t = useForgeT();
41
43
  const { clearCart } = useCart();
42
44
  // A guest has no account screen behind "View your orders": the link would
43
45
  // land them on a login wall for an order their EMAIL is the receipt for.
@@ -85,11 +87,11 @@ export function BundleConfirmation({
85
87
  <ConfirmationCard>
86
88
  <Body>
87
89
  <WarnSeal icon={<XCircle size={28} />} />
88
- <h1 style={{ fontSize: 22, fontWeight: 800, margin: "12px 0 6px" }}>Payment didn&apos;t go through</h1>
89
- <p style={{ opacity: 0.75, marginBottom: 20 }}>
90
- Nothing was charged and your cart is still here. You can try again.
91
- </p>
92
- {link(checkoutPath, "Return to checkout", true)}
90
+ <h1 style={{ fontSize: 22, fontWeight: 800, margin: "12px 0 6px" }}>
91
+ {t("forge.bundle_confirmation.failed_title")}
92
+ </h1>
93
+ <p style={{ opacity: 0.75, marginBottom: 20 }}>{t("forge.bundle_confirmation.failed_body")}</p>
94
+ {link(checkoutPath, t("forge.bundle_confirmation.return_to_checkout"), true)}
93
95
  </Body>
94
96
  </ConfirmationCard>
95
97
  </ConfirmationStage>
@@ -103,7 +105,7 @@ export function BundleConfirmation({
103
105
  <Body>
104
106
  <Loader2 size={28} style={{ animation: "cc-spin 1s linear infinite" }} />
105
107
  <style>{"@keyframes cc-spin{to{transform:rotate(360deg)}}"}</style>
106
- <h1 style={{ fontSize: 20, fontWeight: 700, marginTop: 12 }}>Confirming your order…</h1>
108
+ <h1 style={{ fontSize: 20, fontWeight: 700, marginTop: 12 }}>{t("forge.bundle_confirmation.loading")}</h1>
107
109
  </Body>
108
110
  </ConfirmationCard>
109
111
  </ConfirmationStage>
@@ -119,14 +121,13 @@ export function BundleConfirmation({
119
121
  <ConfirmationCard>
120
122
  <Body>
121
123
  <WarnSeal icon={<Clock size={28} />} />
122
- <h1 style={{ fontSize: 22, fontWeight: 800, margin: "12px 0 6px" }}>Payment received</h1>
123
- <p style={{ opacity: 0.75, marginBottom: 20 }}>
124
- Your payment went through and part of your order is confirmed. We&apos;re still finishing the rest. It
125
- will arrive by email shortly, with nothing more to pay.
126
- </p>
124
+ <h1 style={{ fontSize: 22, fontWeight: 800, margin: "12px 0 6px" }}>
125
+ {t("forge.bundle_confirmation.partial_title")}
126
+ </h1>
127
+ <p style={{ opacity: 0.75, marginBottom: 20 }}>{t("forge.bundle_confirmation.partial_body")}</p>
127
128
  <div style={{ display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap" }}>
128
- {user && link(accountPath, "View your orders", true)}
129
- {link(explorePath, "Continue shopping", !user)}
129
+ {user && link(accountPath, t("forge.bundle_confirmation.view_orders"), true)}
130
+ {link(explorePath, t("forge.bundle_confirmation.continue_shopping"), !user)}
130
131
  </div>
131
132
  </Body>
132
133
  </ConfirmationCard>
@@ -137,6 +138,18 @@ export function BundleConfirmation({
137
138
  const ticketChildren = (data?.children ?? []).filter((c) => c.sourceType === "event_ticket_order").length;
138
139
  const orderChildren = (data?.children ?? []).filter((c) => c.sourceType === "order").length;
139
140
 
141
+ // One key per combination rather than three clauses glued together: German
142
+ // orders the halves differently, so a sentence assembled from fragments can
143
+ // only ever be right in English.
144
+ const summaryKey =
145
+ ticketChildren > 0 && orderChildren > 0
146
+ ? "forge.bundle_confirmation.summary_both"
147
+ : ticketChildren > 0
148
+ ? "forge.bundle_confirmation.summary_tickets"
149
+ : orderChildren > 0
150
+ ? "forge.bundle_confirmation.summary_order"
151
+ : "forge.bundle_confirmation.summary_paid";
152
+
140
153
  return (
141
154
  <ConfirmationStage>
142
155
  <ConfirmationCard>
@@ -152,14 +165,10 @@ export function BundleConfirmation({
152
165
  color: theme.colors.primary,
153
166
  }}
154
167
  >
155
- Order confirmed
156
- </p>
157
- <h1 style={{ fontSize: 24, fontWeight: 800, margin: "0 0 8px" }}>You&apos;re all set</h1>
158
- <p style={{ opacity: 0.75, margin: "0 auto 16px", maxWidth: 360, lineHeight: 1.55 }}>
159
- Paid in one go. {ticketChildren > 0 && "Your tickets are on their way by email"}
160
- {ticketChildren > 0 && orderChildren > 0 && ", and "}
161
- {orderChildren > 0 && `${ticketChildren > 0 ? "your order is confirmed" : "Your order is confirmed"}`}.
168
+ {t("forge.bundle_confirmation.eyebrow")}
162
169
  </p>
170
+ <h1 style={{ fontSize: 24, fontWeight: 800, margin: "0 0 8px" }}>{t("forge.bundle_confirmation.title")}</h1>
171
+ <p style={{ opacity: 0.75, margin: "0 auto 16px", maxWidth: 360, lineHeight: 1.55 }}>{t(summaryKey)}</p>
163
172
 
164
173
  {ticketChildren > 0 && (
165
174
  <div
@@ -177,13 +186,13 @@ export function BundleConfirmation({
177
186
  }}
178
187
  >
179
188
  <Ticket size={16} />
180
- Tickets emailed to you
189
+ {t("forge.bundle_confirmation.tickets_emailed")}
181
190
  </div>
182
191
  )}
183
192
 
184
193
  <div style={{ display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap" }}>
185
- {user && link(accountPath, "View your orders", true)}
186
- {link(explorePath, "Continue shopping", !user)}
194
+ {user && link(accountPath, t("forge.bundle_confirmation.view_orders"), true)}
195
+ {link(explorePath, t("forge.bundle_confirmation.continue_shopping"), !user)}
187
196
  </div>
188
197
  </Body>
189
198
  </ConfirmationCard>
@@ -1,6 +1,7 @@
1
1
  import { type CSSProperties } from "react";
2
2
  import { CalendarX2 } from "lucide-react";
3
3
  import type { CancellationTermsView } from "../../types/models";
4
+ import { useForgeT } from "../../i18n";
4
5
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
5
6
 
6
7
  export interface CancellationTermsProps {
@@ -38,6 +39,7 @@ export interface CancellationTermsProps {
38
39
  */
39
40
  export function CancellationTerms({ terms, variant = "card", className, style }: CancellationTermsProps) {
40
41
  const t = useThemeTokens();
42
+ const tr = useForgeT();
41
43
  if (!terms?.description) return null;
42
44
 
43
45
  const muted = t.muted;
@@ -58,7 +60,7 @@ export function CancellationTerms({ terms, variant = "card", className, style }:
58
60
  >
59
61
  <CalendarX2 size={15} style={{ flexShrink: 0, marginTop: 2, color: muted }} aria-hidden />
60
62
  <div style={{ minWidth: 0 }}>
61
- <p style={{ margin: 0, fontWeight: 600 }}>Cancellation policy</p>
63
+ <p style={{ margin: 0, fontWeight: 600 }}>{tr("forge.cancellation_terms.title")}</p>
62
64
  <p style={{ margin: "2px 0 0", color: muted }}>{terms.description}</p>
63
65
  {terms.terms && (
64
66
  // The seller's own words, preserved as written — line breaks included.