@tribe-nest/forge 3.26.0 → 3.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/package.json +6 -3
  2. package/src/_tests/publishedResolvability.spec.ts +184 -0
  3. package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
  4. package/src/_tests/workspaceAliases.ts +40 -0
  5. package/src/client/_tests/localeHeader.spec.tsx +98 -0
  6. package/src/client/activeLocale.ts +34 -0
  7. package/src/client/createForgeClient.ts +13 -0
  8. package/src/contexts/CartContext.tsx +76 -0
  9. package/src/contexts/PublicAuthContext.tsx +34 -5
  10. package/src/contexts/_tests/CartContext.spec.tsx +210 -0
  11. package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
  12. package/src/data/queries/useBroadcasts.ts +151 -0
  13. package/src/data/queries/useCheckouts.ts +83 -0
  14. package/src/data/queries/useMyBookings.ts +9 -1
  15. package/src/data/queries/useWebsite.ts +7 -0
  16. package/src/i18n/CONVENTION.md +184 -0
  17. package/src/i18n/_tests/translationKeys.spec.ts +153 -0
  18. package/src/i18n/de.json +1019 -0
  19. package/src/i18n/en.json +1019 -0
  20. package/src/i18n/index.ts +125 -0
  21. package/src/index.ts +21 -0
  22. package/src/provider/ForgeAppProvider.tsx +2 -1
  23. package/src/provider/SiteConfigProvider.tsx +11 -0
  24. package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
  25. package/src/ui/format/_tests/pwyw.spec.ts +65 -8
  26. package/src/ui/format/membershipPwyw.ts +164 -0
  27. package/src/ui/format/pwyw.ts +37 -0
  28. package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
  29. package/src/ui/headless/broadcast/broadcastState.ts +158 -0
  30. package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
  31. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
  32. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
  33. package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
  34. package/src/ui/headless/checkout/useCartResume.ts +340 -0
  35. package/src/ui/headless/checkout/useCheckout.ts +9 -2
  36. package/src/ui/headless/event/useEventCheckout.ts +8 -13
  37. package/src/ui/headless/index.ts +32 -0
  38. package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
  39. package/src/ui/index.ts +38 -0
  40. package/src/ui/media/CallHelpHint.tsx +87 -0
  41. package/src/ui/media/CallStage.tsx +542 -0
  42. package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
  43. package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
  44. package/src/ui/media/_tests/callState.spec.ts +452 -0
  45. package/src/ui/media/_tests/fakeNode.ts +178 -0
  46. package/src/ui/media/bookingSession.tsx +194 -0
  47. package/src/ui/media/callState.ts +341 -0
  48. package/src/ui/media/index.ts +135 -0
  49. package/src/ui/shell/TribeNestApp.tsx +36 -27
  50. package/src/ui/styled/AccountDashboard.tsx +289 -96
  51. package/src/ui/styled/AiAgentWidget.tsx +10 -6
  52. package/src/ui/styled/AudioPlayer.tsx +14 -6
  53. package/src/ui/styled/BlogCategory.tsx +4 -2
  54. package/src/ui/styled/BlogComments.tsx +27 -20
  55. package/src/ui/styled/BlogList.tsx +5 -3
  56. package/src/ui/styled/BlogPost.tsx +14 -11
  57. package/src/ui/styled/BroadcastWatch.tsx +107 -0
  58. package/src/ui/styled/BundleConfirmation.tsx +32 -23
  59. package/src/ui/styled/CancellationTerms.tsx +3 -1
  60. package/src/ui/styled/Cart.tsx +32 -14
  61. package/src/ui/styled/ChatRoom.tsx +65 -20
  62. package/src/ui/styled/Checkout.tsx +151 -89
  63. package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
  64. package/src/ui/styled/CoachingBooking.tsx +71 -44
  65. package/src/ui/styled/CoachingConfirmation.tsx +56 -19
  66. package/src/ui/styled/CoachingDetail.tsx +15 -11
  67. package/src/ui/styled/CohortPage.tsx +10 -8
  68. package/src/ui/styled/ConfirmSubscription.tsx +14 -8
  69. package/src/ui/styled/ContactForm.tsx +31 -12
  70. package/src/ui/styled/CookieConsent.tsx +19 -15
  71. package/src/ui/styled/CourseAccess.tsx +54 -47
  72. package/src/ui/styled/CourseCheckout.tsx +56 -38
  73. package/src/ui/styled/CourseConfirmation.tsx +39 -17
  74. package/src/ui/styled/CourseDetail.tsx +21 -15
  75. package/src/ui/styled/CoursesGrid.tsx +4 -2
  76. package/src/ui/styled/CurrencySwitcher.tsx +4 -1
  77. package/src/ui/styled/DiscountCode.tsx +22 -18
  78. package/src/ui/styled/DonationButton.tsx +52 -17
  79. package/src/ui/styled/DonationPage.tsx +4 -4
  80. package/src/ui/styled/EmailListForm.tsx +22 -9
  81. package/src/ui/styled/EventConfirmation.tsx +37 -14
  82. package/src/ui/styled/EventCountdown.tsx +10 -6
  83. package/src/ui/styled/EventDetail.tsx +8 -6
  84. package/src/ui/styled/EventSeriesDetail.tsx +18 -13
  85. package/src/ui/styled/EventTickets.tsx +232 -108
  86. package/src/ui/styled/EventWaitlist.tsx +42 -33
  87. package/src/ui/styled/EventsList.tsx +15 -13
  88. package/src/ui/styled/ForgotPasswordForm.tsx +17 -12
  89. package/src/ui/styled/FormRenderer.tsx +12 -7
  90. package/src/ui/styled/InstallBanner.tsx +21 -18
  91. package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
  92. package/src/ui/styled/InvoicePayment.tsx +37 -16
  93. package/src/ui/styled/LeadMagnet.tsx +9 -7
  94. package/src/ui/styled/LiveBroadcastList.tsx +171 -0
  95. package/src/ui/styled/Loading.tsx +7 -1
  96. package/src/ui/styled/LoginForm.tsx +32 -14
  97. package/src/ui/styled/MembershipCheckout.tsx +341 -49
  98. package/src/ui/styled/MembershipTiers.tsx +34 -18
  99. package/src/ui/styled/OfferButton.tsx +33 -13
  100. package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
  101. package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
  102. package/src/ui/styled/PodcastEpisode.tsx +20 -9
  103. package/src/ui/styled/PodcastList.tsx +9 -3
  104. package/src/ui/styled/PodcastShow.tsx +29 -7
  105. package/src/ui/styled/PostsFeed.tsx +10 -8
  106. package/src/ui/styled/PresaleCode.tsx +8 -11
  107. package/src/ui/styled/ProductBrowseNav.tsx +21 -11
  108. package/src/ui/styled/ProductDetail.tsx +58 -28
  109. package/src/ui/styled/ReactionBar.tsx +4 -2
  110. package/src/ui/styled/ReplayList.tsx +24 -13
  111. package/src/ui/styled/ResetPasswordForm.tsx +32 -15
  112. package/src/ui/styled/ResumeCart.tsx +289 -0
  113. package/src/ui/styled/ReviewForm.tsx +52 -58
  114. package/src/ui/styled/SignupForm.tsx +34 -16
  115. package/src/ui/styled/TicketTransfer.tsx +55 -54
  116. package/src/ui/styled/UserMenu.tsx +16 -14
  117. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
  118. package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
  119. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
  120. package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
  121. package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
  122. package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
  123. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
  124. package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
  125. package/src/ui/styled/community/CommunityComposer.tsx +182 -3
  126. package/src/ui/styled/community/CommunityFeed.tsx +36 -51
  127. package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
  128. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
  129. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
@@ -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>
@@ -0,0 +1,107 @@
1
+ import type { ReactNode } from "react";
2
+ import { X } from "lucide-react";
3
+ import { useForgeT } from "../../i18n";
4
+ import { useForgeTheme } from "../theme/ForgeThemeProvider";
5
+ import { useBroadcastWatch } from "../headless/broadcast/useBroadcastWatch";
6
+ import { Loading } from "./Loading";
7
+ import { BroadcastPassValidation } from "./broadcast/BroadcastPassValidation";
8
+ import { BroadcastTicketPurchase } from "./broadcast/BroadcastTicketPurchase";
9
+ import { BroadcastPlayer, type BroadcastPlayerRenderProps } from "./broadcast/BroadcastPlayer";
10
+ import { EndedBroadcast } from "./broadcast/EndedBroadcast";
11
+
12
+ export interface BroadcastWatchProps {
13
+ broadcastId: string;
14
+ /** Host-owned navigation. Falls back to a full page load. */
15
+ navigate?: (href: string) => void;
16
+ /** Where leaving lands. Default `/i/live`. */
17
+ livePath?: string;
18
+ /** How to play the HLS stream. See `BroadcastPlayer` for why this is a prop. */
19
+ renderPlayer?: (props: BroadcastPlayerRenderProps) => ReactNode;
20
+ /** Format a numeric amount for display (the "from ..." line on the paywall). */
21
+ formatAmount?: (amount: number) => string;
22
+ /** Stripe return URL for a PAID ticket purchase made from the paywall. */
23
+ finalisePath?: (slug: string, orderId: string) => string;
24
+ /** Host-owned navigation on FREE ticket completion. */
25
+ onComplete?: (info: { slug: string; orderId: string }) => void;
26
+ }
27
+
28
+ /**
29
+ * The watch page for one live broadcast.
30
+ *
31
+ * What is on screen is decided entirely by `useBroadcastWatch().screen`, which
32
+ * is computed by a pure function. The order those states are checked in is the
33
+ * whole safety property of this page: a broadcast that has ENDED shows the ended
34
+ * screen, a broadcast that is GATED shows the ticket box, and only a viewer past
35
+ * both gets a component that knows the playback URL.
36
+ */
37
+ export function BroadcastWatch({
38
+ broadcastId,
39
+ navigate,
40
+ livePath = "/i/live",
41
+ renderPlayer,
42
+ formatAmount,
43
+ finalisePath,
44
+ onComplete,
45
+ }: BroadcastWatchProps) {
46
+ const t = useForgeT();
47
+ const theme = useForgeTheme();
48
+ const watch = useBroadcastWatch(broadcastId);
49
+ const { broadcast, screen } = watch;
50
+
51
+ const leave = () => {
52
+ watch.leave();
53
+ if (navigate) navigate(livePath);
54
+ else if (typeof window !== "undefined") window.location.assign(livePath);
55
+ };
56
+
57
+ return (
58
+ <div
59
+ style={{
60
+ display: "flex",
61
+ flexDirection: "column",
62
+ flex: 1,
63
+ color: theme.colors.text,
64
+ fontFamily: theme.fontFamily,
65
+ }}
66
+ >
67
+ <div style={{ padding: "0 16px 8px" }}>
68
+ <button
69
+ type="button"
70
+ onClick={leave}
71
+ data-testid="broadcast-leave"
72
+ aria-label={t("forge.broadcast_watch.leave")}
73
+ style={{ background: "transparent", border: "none", padding: 0, cursor: "pointer" }}
74
+ >
75
+ <X size={28} color={theme.colors.primary} />
76
+ </button>
77
+ </div>
78
+
79
+ {screen.showLoading && <Loading fullPage />}
80
+
81
+ {screen.view === "error" && <div data-testid="broadcast-load-error">{t("forge.broadcast_watch.load_error")}</div>}
82
+
83
+ {screen.view === "ended" && broadcast && <EndedBroadcast broadcast={broadcast} />}
84
+
85
+ {screen.view === "player" && broadcast && watch.pass && (
86
+ <BroadcastPlayer broadcast={broadcast} broadcastPass={watch.pass} renderPlayer={renderPlayer} />
87
+ )}
88
+
89
+ {screen.view === "pass" && broadcast && (
90
+ <BroadcastPassValidation
91
+ broadcast={broadcast}
92
+ onValidate={(ticketCode) => watch.validate(ticketCode).catch(() => undefined)}
93
+ isValidating={watch.isValidating}
94
+ error={watch.error}
95
+ buyTickets={
96
+ <BroadcastTicketPurchase
97
+ broadcast={broadcast}
98
+ formatAmount={formatAmount}
99
+ finalisePath={finalisePath}
100
+ onComplete={onComplete}
101
+ />
102
+ }
103
+ />
104
+ )}
105
+ </div>
106
+ );
107
+ }