@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
@@ -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.
@@ -2,6 +2,7 @@ import { useEffect } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import { ShoppingBag, X, Trash2 } from "lucide-react";
4
4
  import { useCart } from "../../contexts/CartContext";
5
+ import { useForgeT } from "../../i18n";
5
6
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
6
7
  import { readableTextOn } from "../theme/contrast";
7
8
  import { useAmountFormatter } from "../format/useFormatCurrency";
@@ -42,6 +43,9 @@ const DRAWER_Z = 2147483001;
42
43
  */
43
44
  export function Cart({ icon, checkoutPath = "/i/checkout", productHref, formatAmount, className, style }: CartProps) {
44
45
  const t = useThemeTokens();
46
+ // Named `translate` rather than the usual `t`: `t` is already the theme
47
+ // tokens here, and in the `ticketTotal` parameter below.
48
+ const translate = useForgeT();
45
49
  const fmt = useAmountFormatter(formatAmount);
46
50
  // Inclusive stores show a muted "incl. tax" caption on the cart total; the
47
51
  // exact tax figure only exists once checkout quotes it (display-only).
@@ -77,7 +81,7 @@ export function Cart({ icon, checkoutPath = "/i/checkout", productHref, formatAm
77
81
  <button
78
82
  type="button"
79
83
  onClick={() => setCartOpen((p) => !p)}
80
- aria-label="Open cart"
84
+ aria-label={translate("forge.cart.aria_open")}
81
85
  data-testid="cart-button"
82
86
  className={className}
83
87
  style={{
@@ -123,7 +127,7 @@ export function Cart({ icon, checkoutPath = "/i/checkout", productHref, formatAm
123
127
  <style>{KEYFRAMES}</style>
124
128
  <div
125
129
  onClick={() => setCartOpen(false)}
126
- aria-label="Close cart"
130
+ aria-label={translate("forge.cart.aria_close")}
127
131
  style={{ position: "fixed", inset: 0, zIndex: OVERLAY_Z, background: "rgba(0,0,0,0.3)" }}
128
132
  />
129
133
  <aside
@@ -154,11 +158,13 @@ export function Cart({ icon, checkoutPath = "/i/checkout", productHref, formatAm
154
158
  borderBottom: `1px solid ${t.border}`,
155
159
  }}
156
160
  >
157
- <h2 style={{ fontSize: 18, fontWeight: 800, margin: 0, fontFamily: t.headingFontFamily }}>My Cart</h2>
161
+ <h2 style={{ fontSize: 18, fontWeight: 800, margin: 0, fontFamily: t.headingFontFamily }}>
162
+ {translate("forge.cart.title")}
163
+ </h2>
158
164
  <button
159
165
  type="button"
160
166
  onClick={() => setCartOpen(false)}
161
- aria-label="Close cart"
167
+ aria-label={translate("forge.cart.aria_close")}
162
168
  style={{ background: "transparent", border: "none", cursor: "pointer", color: t.text }}
163
169
  >
164
170
  <X size={22} />
@@ -166,7 +172,9 @@ export function Cart({ icon, checkoutPath = "/i/checkout", productHref, formatAm
166
172
  </div>
167
173
 
168
174
  <div style={{ flex: 1, overflowY: "auto", padding: 16, display: "flex", flexDirection: "column", gap: 16 }}>
169
- {count === 0 && <p style={{ textAlign: "center", opacity: 0.7 }}>Your cart is empty.</p>}
175
+ {count === 0 && (
176
+ <p style={{ textAlign: "center", opacity: 0.7 }}>{translate("forge.cart.empty_title")}</p>
177
+ )}
170
178
 
171
179
  {ticketItems.map((ticket) => (
172
180
  <div
@@ -199,7 +207,10 @@ export function Cart({ icon, checkoutPath = "/i/checkout", productHref, formatAm
199
207
  </div>
200
208
  {Object.entries(ticket.tickets).map(([id, qty]) => (
201
209
  <div key={id} style={{ fontSize: 12, marginTop: 4, opacity: 0.75 }}>
202
- {ticket.ticketMeta[id]?.title ?? "Ticket"} × {qty}
210
+ {translate("forge.cart.ticket_line", {
211
+ ticket_title: ticket.ticketMeta[id]?.title ?? translate("forge.cart.ticket_fallback_title"),
212
+ quantity: qty,
213
+ })}
203
214
  </div>
204
215
  ))}
205
216
  <div style={{ fontSize: 14, marginTop: 4, opacity: 0.8 }}>{fmt(ticketTotal(ticket))}</div>
@@ -210,7 +221,7 @@ export function Cart({ icon, checkoutPath = "/i/checkout", productHref, formatAm
210
221
  // cannot be bought on their own, and leaving them would
211
222
  // produce a checkout the server refuses.
212
223
  onClick={() => removeTickets(ticket.eventId)}
213
- aria-label="Remove tickets"
224
+ aria-label={translate("forge.cart.aria_remove_tickets")}
214
225
  style={{ position: "absolute", top: 0, right: 0, background: "transparent", border: "none", cursor: "pointer", color: t.primary, padding: 4 }}
215
226
  >
216
227
  <Trash2 size={16} />
@@ -270,18 +281,25 @@ export function Cart({ icon, checkoutPath = "/i/checkout", productHref, formatAm
270
281
  <CartLineOptions item={item} accent={t.primary} />
271
282
  {item.isGift && (
272
283
  <div style={{ fontSize: 12, marginTop: 4, color: t.primary }}>
273
- Gift for {item.recipientName} ({item.recipientEmail})
284
+ {translate("forge.cart.gift_for", {
285
+ recipient_name: item.recipientName ?? "",
286
+ recipient_email: item.recipientEmail ?? "",
287
+ })}
274
288
  </div>
275
289
  )}
276
- <div style={{ fontSize: 12, marginTop: 4, opacity: 0.7 }}>Qty: {item.quantity}</div>
290
+ <div style={{ fontSize: 12, marginTop: 4, opacity: 0.7 }}>
291
+ {translate("forge.cart.quantity", { quantity: item.quantity })}
292
+ </div>
277
293
  {item.attachedTo && (
278
- <div style={{ fontSize: 12, marginTop: 4, color: t.primary }}>Added with your tickets</div>
294
+ <div style={{ fontSize: 12, marginTop: 4, color: t.primary }}>
295
+ {translate("forge.cart.attached_to_tickets")}
296
+ </div>
279
297
  )}
280
298
  </div>
281
299
  <button
282
300
  type="button"
283
301
  onClick={() => removeFromCart(item.productVariantId, item.isGift, item.recipientEmail)}
284
- aria-label="Remove item"
302
+ aria-label={translate("forge.cart.aria_remove_item")}
285
303
  style={{ position: "absolute", top: 0, right: 0, background: "transparent", border: "none", cursor: "pointer", color: t.primary, padding: 4 }}
286
304
  >
287
305
  <Trash2 size={16} />
@@ -299,12 +317,12 @@ export function Cart({ icon, checkoutPath = "/i/checkout", productHref, formatAm
299
317
  }}
300
318
  >
301
319
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
302
- <span style={{ fontSize: 18, fontWeight: 600 }}>Total:</span>
320
+ <span style={{ fontSize: 18, fontWeight: 600 }}>{translate("forge.cart.total")}</span>
303
321
  <span style={{ fontSize: 18, fontWeight: 800, color: t.primary, textAlign: "right" }}>
304
322
  {fmt(total)}
305
323
  {pricesIncludeTax && (
306
324
  <span style={{ display: "block", fontSize: 11, fontWeight: 400, color: t.text, opacity: 0.65 }}>
307
- incl. tax
325
+ {translate("forge.cart.incl_tax")}
308
326
  </span>
309
327
  )}
310
328
  </span>
@@ -323,7 +341,7 @@ export function Cart({ icon, checkoutPath = "/i/checkout", productHref, formatAm
323
341
  color: onPrimary,
324
342
  }}
325
343
  >
326
- Checkout
344
+ {translate("forge.cart.checkout")}
327
345
  </button>
328
346
  </div>
329
347
  )}
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
2
  import { Lock } from "lucide-react";
3
3
  import type { ChatChannel, ChatMessage } from "../../data/queries/useChat";
4
+ import { useForgeT, type ForgeT } from "../../i18n";
4
5
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
5
6
  import { readableTextOn } from "../theme/contrast";
6
7
  import { Loading } from "./Loading";
@@ -12,19 +13,20 @@ import { NewDmDialog } from "./chat/NewDmDialog";
12
13
 
13
14
  export interface ChatRoomProps {}
14
15
 
15
- const channelLabel = (c: ChatChannel): string => {
16
+ const channelLabel = (c: ChatChannel, t: ForgeT): string => {
16
17
  if (c.type === "dm") {
17
18
  const p = c.otherParticipant;
18
19
  if (p) return `${p.firstName ?? ""} ${p.lastName ?? ""}`.trim() || p.email;
19
- return c.name ?? "Direct message";
20
+ return c.name ?? t("forge.chat_room.dm_fallback");
20
21
  }
21
- return `# ${c.name ?? "channel"}`;
22
+ return `# ${c.name ?? t("forge.chat_room.channel_fallback")}`;
22
23
  };
23
24
 
24
25
  // Reference implementation of the community chat, built entirely on the
25
26
  // `useChatRoom` primitive (channels + messages + realtime socket + read
26
27
  // receipts + threads + member DMs). A creator can rebuild this UI from the same hook.
27
28
  export function ChatRoom(_props: ChatRoomProps) {
29
+ const t = useForgeT();
28
30
  const theme = useForgeTheme();
29
31
  const room = useChatRoom();
30
32
  const [showSidebar, setShowSidebar] = useState(true);
@@ -69,7 +71,7 @@ export function ChatRoom(_props: ChatRoomProps) {
69
71
  >
70
72
  {icon}
71
73
  <span style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
72
- {icon ? c.name ?? "channel" : channelLabel(c)}
74
+ {icon ? (c.name ?? t("forge.chat_room.channel_fallback")) : channelLabel(c, t)}
73
75
  </span>
74
76
  </span>
75
77
  {unread > 0 && (
@@ -111,37 +113,63 @@ export function ChatRoom(_props: ChatRoomProps) {
111
113
  if (room.channelsLoading) return <Loading fullPage />;
112
114
 
113
115
  return (
114
- <div style={{ display: "flex", height: "calc(100vh - 200px)", border, borderRadius: theme.cornerRadius, overflow: "hidden" }}>
115
- {/* Channel list — full-width on mobile, fixed rail on desktop */}
116
+ <div
117
+ style={{
118
+ display: "flex",
119
+ height: "calc(100vh - 200px)",
120
+ border,
121
+ borderRadius: theme.cornerRadius,
122
+ overflow: "hidden",
123
+ }}
124
+ >
125
+ {/* Channel list: full-width on mobile, fixed rail on desktop */}
116
126
  <aside
117
127
  className={`${showSidebar ? "flex" : "hidden"} md:flex w-full md:w-[260px] flex-col`}
118
128
  style={{ borderRight: border, overflowY: "auto" }}
119
129
  >
120
- <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 16px", borderBottom: border }}>
121
- <h2 style={{ fontWeight: 700, margin: 0 }}>Channels</h2>
130
+ <div
131
+ style={{
132
+ display: "flex",
133
+ alignItems: "center",
134
+ justifyContent: "space-between",
135
+ padding: "10px 16px",
136
+ borderBottom: border,
137
+ }}
138
+ >
139
+ <h2 style={{ fontWeight: 700, margin: 0 }}>{t("forge.chat_room.channels_title")}</h2>
122
140
  <button
123
141
  onClick={() => setShowNewDm(true)}
124
- title="New message"
125
- style={{ background: theme.colors.primary, color: readableTextOn(theme.colors.primary), border: "none", borderRadius: theme.cornerRadius, width: 28, height: 28, cursor: "pointer", fontSize: 18, lineHeight: 1 }}
142
+ title={t("forge.chat_room.new_message")}
143
+ style={{
144
+ background: theme.colors.primary,
145
+ color: readableTextOn(theme.colors.primary),
146
+ border: "none",
147
+ borderRadius: theme.cornerRadius,
148
+ width: 28,
149
+ height: 28,
150
+ cursor: "pointer",
151
+ fontSize: 18,
152
+ lineHeight: 1,
153
+ }}
126
154
  >
127
155
  +
128
156
  </button>
129
157
  </div>
130
158
  {renderGroup(
131
- "Public",
159
+ t("forge.chat_room.group_public"),
132
160
  room.channels.filter((c) => c.type === "public"),
133
161
  )}
134
162
  {renderGroup(
135
- "Private channels",
163
+ t("forge.chat_room.group_private"),
136
164
  room.channels.filter((c) => c.type === "private"),
137
165
  <Lock size={14} style={{ opacity: 0.7, flexShrink: 0 }} />,
138
166
  )}
139
167
  {renderGroup(
140
- "Direct messages",
168
+ t("forge.chat_room.group_dms"),
141
169
  room.channels.filter((c) => c.type === "dm"),
142
170
  )}
143
171
  {room.channels.length === 0 && (
144
- <div style={{ padding: 16, opacity: 0.5, fontSize: 13 }}>No channels yet.</div>
172
+ <div style={{ padding: 16, opacity: 0.5, fontSize: 13 }}>{t("forge.chat_room.empty_title")}</div>
145
173
  )}
146
174
  </aside>
147
175
 
@@ -151,7 +179,7 @@ export function ChatRoom(_props: ChatRoomProps) {
151
179
  <MessagePanel room={room} onBack={() => setShowSidebar(true)} border={border} />
152
180
  ) : (
153
181
  <div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", opacity: 0.5 }}>
154
- Select a channel to start chatting
182
+ {t("forge.chat_room.select_channel")}
155
183
  </div>
156
184
  )}
157
185
  </section>
@@ -193,6 +221,7 @@ function MessagePanel({
193
221
  onBack: () => void;
194
222
  border: string;
195
223
  }) {
224
+ const t = useForgeT();
196
225
  const theme = useForgeTheme();
197
226
  const listRef = useRef<HTMLDivElement | null>(null);
198
227
 
@@ -205,12 +234,15 @@ function MessagePanel({
205
234
  // eslint-disable-next-line react-hooks/exhaustive-deps
206
235
  }, [room.messages.length, room.selectedChannel?.id]);
207
236
 
208
- const title = room.selectedChannel ? channelLabel(room.selectedChannel) : "channel";
237
+ const title = room.selectedChannel ? channelLabel(room.selectedChannel, t) : t("forge.chat_room.channel_fallback");
209
238
 
210
239
  return (
211
240
  <>
212
241
  <header style={{ padding: "12px 16px", borderBottom: border, display: "flex", alignItems: "center", gap: 12 }}>
213
- <button onClick={onBack} style={{ background: "none", border: "none", cursor: "pointer", color: theme.colors.text }}>
242
+ <button
243
+ onClick={onBack}
244
+ style={{ background: "none", border: "none", cursor: "pointer", color: theme.colors.text }}
245
+ >
214
246
 
215
247
  </button>
216
248
  <strong>{title}</strong>
@@ -232,14 +264,27 @@ function MessagePanel({
232
264
  />
233
265
  ))}
234
266
  {room.hasMore && (
235
- <button onClick={() => room.fetchNextPage()} style={{ alignSelf: "center", background: "none", border, borderRadius: 8, padding: "4px 10px", cursor: "pointer", color: theme.colors.text }}>
236
- Load earlier
267
+ <button
268
+ onClick={() => room.fetchNextPage()}
269
+ style={{
270
+ alignSelf: "center",
271
+ background: "none",
272
+ border,
273
+ borderRadius: 8,
274
+ padding: "4px 10px",
275
+ cursor: "pointer",
276
+ color: theme.colors.text,
277
+ }}
278
+ >
279
+ {t("forge.chat_room.load_earlier")}
237
280
  </button>
238
281
  )}
239
282
  </div>
240
283
 
241
284
  {room.typingUsers.length > 0 && (
242
- <div style={{ padding: "0 16px 6px", fontSize: 12, opacity: 0.6 }}>{room.typingUsers.join(", ")} typing…</div>
285
+ <div style={{ padding: "0 16px 6px", fontSize: 12, opacity: 0.6 }}>
286
+ {t("forge.chat_room.typing", { users: room.typingUsers.join(", ") })}
287
+ </div>
243
288
  )}
244
289
 
245
290
  <ChatComposer