@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
@@ -5,6 +5,7 @@ import {
5
5
  type IPublicProductCategory,
6
6
  } from "../../data/queries/useProducts";
7
7
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
8
+ import { useForgeT } from "../../i18n";
8
9
  import { Loading } from "./Loading";
9
10
 
10
11
  export interface ProductBrowseNavProps {
@@ -52,7 +53,8 @@ export function ProductBrowseNav({
52
53
  className,
53
54
  style,
54
55
  }: ProductBrowseNavProps) {
55
- const t = useThemeTokens();
56
+ const tokens = useThemeTokens();
57
+ const t = useForgeT();
56
58
  const categoriesQuery = useProductCategories();
57
59
  const collectionsQuery = useProductCollections();
58
60
  const [expanded, setExpanded] = useState<Set<string>>(new Set());
@@ -91,14 +93,14 @@ export function ProductBrowseNav({
91
93
  const label = (
92
94
  <>
93
95
  <span>{category.title}</span>
94
- <span style={{ color: t.muted, fontSize: 12, marginLeft: 8 }}>{category.productCount}</span>
96
+ <span style={{ color: tokens.muted, fontSize: 12, marginLeft: 8 }}>{category.productCount}</span>
95
97
  </>
96
98
  );
97
99
 
98
100
  const linkStyle: React.CSSProperties = {
99
101
  display: "inline-flex",
100
102
  alignItems: "baseline",
101
- color: isActive ? t.primary : t.text,
103
+ color: isActive ? tokens.primary : tokens.text,
102
104
  fontWeight: isActive ? 600 : 400,
103
105
  textDecoration: "none",
104
106
  background: "none",
@@ -119,12 +121,16 @@ export function ProductBrowseNav({
119
121
  type="button"
120
122
  onClick={() => toggle(category.id)}
121
123
  aria-expanded={isOpen}
122
- aria-label={isOpen ? `Collapse ${category.title}` : `Expand ${category.title}`}
124
+ aria-label={
125
+ isOpen
126
+ ? t("forge.product_browse_nav.aria_collapse", { category: category.title })
127
+ : t("forge.product_browse_nav.aria_expand", { category: category.title })
128
+ }
123
129
  style={{
124
130
  background: "none",
125
131
  border: "none",
126
132
  cursor: "pointer",
127
- color: t.muted,
133
+ color: tokens.muted,
128
134
  padding: 0,
129
135
  lineHeight: 1,
130
136
  width: 12,
@@ -156,7 +162,7 @@ export function ProductBrowseNav({
156
162
  };
157
163
 
158
164
  const headingStyle: React.CSSProperties = {
159
- color: t.muted,
165
+ color: tokens.muted,
160
166
  fontSize: 12,
161
167
  letterSpacing: "0.08em",
162
168
  textTransform: "uppercase",
@@ -164,23 +170,27 @@ export function ProductBrowseNav({
164
170
  };
165
171
 
166
172
  return (
167
- <nav className={className} style={{ display: "grid", gap: 24, ...style }} aria-label="Browse the store">
173
+ <nav
174
+ className={className}
175
+ style={{ display: "grid", gap: 24, ...style }}
176
+ aria-label={t("forge.product_browse_nav.aria_nav")}
177
+ >
168
178
  {showCategories && categories.length > 0 && (
169
179
  <div>
170
- <h2 style={headingStyle}>Categories</h2>
180
+ <h2 style={headingStyle}>{t("forge.product_browse_nav.categories_title")}</h2>
171
181
  <ul style={{ margin: 0, padding: 0 }}>{categories.map((c) => renderCategory(c, 0))}</ul>
172
182
  </div>
173
183
  )}
174
184
 
175
185
  {showCollections && collections.length > 0 && (
176
186
  <div>
177
- <h2 style={headingStyle}>Collections</h2>
187
+ <h2 style={headingStyle}>{t("forge.product_browse_nav.collections_title")}</h2>
178
188
  <ul style={{ margin: 0, padding: 0 }}>
179
189
  {collections.map((collection) => {
180
190
  const isActive = collection.id === activeCollectionId;
181
191
  const href = hrefForCollection?.(collection);
182
192
  const linkStyle: React.CSSProperties = {
183
- color: isActive ? t.primary : t.text,
193
+ color: isActive ? tokens.primary : tokens.text,
184
194
  fontWeight: isActive ? 600 : 400,
185
195
  textDecoration: "none",
186
196
  background: "none",
@@ -200,7 +210,7 @@ export function ProductBrowseNav({
200
210
  {collection.title}
201
211
  </button>
202
212
  )}
203
- <span style={{ color: t.muted, fontSize: 12, marginLeft: 8 }}>{collection.productCount}</span>
213
+ <span style={{ color: tokens.muted, fontSize: 12, marginLeft: 8 }}>{collection.productCount}</span>
204
214
  </li>
205
215
  );
206
216
  })}
@@ -7,6 +7,7 @@ import { useGetProduct } from "../../data/queries/useProducts";
7
7
  import { useCart } from "../../contexts/CartContext";
8
8
  import { useAudioPlayer } from "../../contexts/AudioPlayerContext";
9
9
  import { MembershipGateNotice } from "./MembershipGateNotice";
10
+ import { useForgeT } from "../../i18n";
10
11
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
11
12
  import { readableTextOn } from "../theme/contrast";
12
13
  import { useAmountFormatter } from "../format/useFormatCurrency";
@@ -64,11 +65,12 @@ export function ProductDetail({
64
65
  membershipPath,
65
66
  }: ProductDetailProps) {
66
67
  const t = useThemeTokens();
68
+ const tr = useForgeT();
67
69
  const { data: product, isLoading } = useGetProduct(slug, { initialData: initialProduct });
68
70
  const fmt = useAmountFormatter(formatAmount);
69
71
 
70
72
  if (isLoading) return <Loading fullPage />;
71
- if (!product) return <p style={{ color: t.text }}>Product not found.</p>;
73
+ if (!product) return <p style={{ color: t.text }}>{tr("forge.product_detail.not_found")}</p>;
72
74
 
73
75
  // Classify on the product's TYPE, NOT "has tracks" — a Digital product
74
76
  // stores its download as a track too, so track-presence would misrender it as
@@ -172,6 +174,7 @@ function StandardDetail({
172
174
  membershipPath?: string;
173
175
  }) {
174
176
  const t = useThemeTokens();
177
+ const tr = useForgeT();
175
178
  // Inclusive stores caption the price as tax-inclusive (display only).
176
179
  const pricesIncludeTax = usePricesIncludeTax();
177
180
  const cover = product.media?.find((m) => m.type === "image")?.url ?? product.media?.[0]?.url;
@@ -220,12 +223,12 @@ function StandardDetail({
220
223
  const isGated = !!product.membershipGate && !product.membershipGate.allowed;
221
224
  const canAdd = !!selectedVariant && !outOfStock && !isGated;
222
225
  const label = isGated
223
- ? "Members only"
226
+ ? tr("forge.product_detail.members_only")
224
227
  : !selectedVariant
225
- ? "Select options"
228
+ ? tr("forge.product_detail.select_options")
226
229
  : outOfStock
227
- ? "Out of stock"
228
- : "Add to cart";
230
+ ? tr("forge.product_detail.out_of_stock")
231
+ : tr("forge.product_detail.add_to_cart");
229
232
  const addToCart = () => {
230
233
  if (!canAdd || !selectedVariant) return;
231
234
  add(selectedVariant, { quantity, coverImage: images[imageIndex]?.url ?? images[0]?.url });
@@ -289,7 +292,9 @@ function StandardDetail({
289
292
  {product.artist && <p style={{ opacity: 0.7, marginTop: 4 }}>{product.artist}</p>}
290
293
  <p style={{ color: t.primary, fontWeight: 700, fontSize: 20, marginTop: 8 }}>
291
294
  <PriceDisplay amount={price} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={t.text} captionStyle={{ opacity: 0.65 }} />
292
- {selectedVariant?.payWhatYouWant && <span style={{ fontSize: 13, opacity: 0.7 }}> (Pay what you want)</span>}
295
+ {selectedVariant?.payWhatYouWant && (
296
+ <span style={{ fontSize: 13, opacity: 0.7 }}>{" "}{tr("forge.product_detail.pay_what_you_want")}</span>
297
+ )}
293
298
  </p>
294
299
 
295
300
  {/* Options — one block per axis the product actually uses. */}
@@ -309,7 +314,7 @@ function StandardDetail({
309
314
  key={v.optionValueId}
310
315
  type="button"
311
316
  title={v.value}
312
- aria-label={`${axis.axis}: ${v.value}`}
317
+ aria-label={tr("forge.product_detail.option_aria", { axis: axis.axis, value: v.value })}
313
318
  aria-pressed={on}
314
319
  disabled={!v.isAvailable}
315
320
  onClick={() => select(axis.optionTypeId, v.optionValueId)}
@@ -332,8 +337,8 @@ function StandardDetail({
332
337
  type="button"
333
338
  aria-pressed={on}
334
339
  // Same accessible name as the swatch form, so one locator
335
- // and one screen-reader announcement covers both.
336
- aria-label={`${axis.axis}: ${v.value}`}
340
+ // (and one screen-reader announcement) covers both.
341
+ aria-label={tr("forge.product_detail.option_aria", { axis: axis.axis, value: v.value })}
337
342
  disabled={!v.isAvailable}
338
343
  onClick={() => select(axis.optionTypeId, v.optionValueId)}
339
344
  style={{
@@ -357,7 +362,7 @@ function StandardDetail({
357
362
  {/* Quantity */}
358
363
  {selectedVariant && (
359
364
  <div style={{ marginTop: 18 }}>
360
- <p style={{ fontSize: 15, marginBottom: 8 }}>Quantity</p>
365
+ <p style={{ fontSize: 15, marginBottom: 8 }}>{tr("forge.product_detail.quantity")}</p>
361
366
  <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
362
367
  <QtyButton onClick={() => setQuantity((q) => Math.max(1, q - 1))} border={a(t.text, 0.25)} color={t.text}>
363
368
 
@@ -380,7 +385,7 @@ function StandardDetail({
380
385
  <MembershipGateNotice
381
386
  gate={product.membershipGate}
382
387
  variant="card"
383
- itemLabel="This product"
388
+ itemLabel={tr("forge.product_detail.gate_item_product")}
384
389
  loginPath={loginPath}
385
390
  membershipPath={membershipPath}
386
391
  style={{ marginTop: 12 }}
@@ -393,14 +398,25 @@ function StandardDetail({
393
398
  {/* Product details */}
394
399
  {selectedVariant && (
395
400
  <div style={{ marginTop: 20, paddingTop: 16, borderTop: `1px solid ${a(t.text, 0.15)}` }}>
396
- <p style={{ fontWeight: 700, fontSize: 13, opacity: 0.7, marginBottom: 6 }}>Product details</p>
401
+ <p style={{ fontWeight: 700, fontSize: 13, opacity: 0.7, marginBottom: 6 }}>
402
+ {tr("forge.product_detail.details_title")}
403
+ </p>
397
404
  {/* "Type", not "Category" — the two were the same thing until the
398
405
  taxonomy split, and the creator's real categories are below. */}
399
- <p style={{ fontSize: 14, opacity: 0.75 }}>Type: {product.productType}</p>
400
- <p style={{ fontSize: 14, opacity: 0.75 }}>SKU: {selectedVariant.upcCode || "N/A"}</p>
406
+ <p style={{ fontSize: 14, opacity: 0.75 }}>
407
+ {tr("forge.product_detail.type", { type: product.productType })}
408
+ </p>
409
+ <p style={{ fontSize: 14, opacity: 0.75 }}>
410
+ {tr("forge.product_detail.sku", {
411
+ sku: selectedVariant.upcCode || tr("forge.product_detail.sku_none"),
412
+ })}
413
+ </p>
401
414
  {!!product.categories?.length && (
402
415
  <p style={{ fontSize: 14, opacity: 0.75 }}>
403
- {product.categories.length === 1 ? "Category: " : "Categories: "}
416
+ {product.categories.length === 1
417
+ ? tr("forge.product_detail.category_label")
418
+ : tr("forge.product_detail.categories_label")}
419
+ {" "}
404
420
  {product.categories.map((c, i) => (
405
421
  <span key={c.id}>
406
422
  {i > 0 && ", "}
@@ -417,7 +433,10 @@ function StandardDetail({
417
433
  )}
418
434
  {!!product.collections?.length && (
419
435
  <p style={{ fontSize: 14, opacity: 0.75 }}>
420
- {product.collections.length === 1 ? "Collection: " : "Collections: "}
436
+ {product.collections.length === 1
437
+ ? tr("forge.product_detail.collection_label")
438
+ : tr("forge.product_detail.collections_label")}
439
+ {" "}
421
440
  {product.collections.map((c, i) => (
422
441
  <span key={c.id}>
423
442
  {i > 0 && ", "}
@@ -437,7 +456,7 @@ function StandardDetail({
437
456
 
438
457
  {product.credits && (
439
458
  <div style={{ marginTop: 20 }}>
440
- <p style={{ fontWeight: 700, fontSize: 13, opacity: 0.7 }}>Credits</p>
459
+ <p style={{ fontWeight: 700, fontSize: 13, opacity: 0.7 }}>{tr("forge.product_detail.credits_title")}</p>
441
460
  <p style={{ whiteSpace: "pre-line", fontSize: 14, opacity: 0.7 }}>{product.credits}</p>
442
461
  </div>
443
462
  )}
@@ -513,6 +532,7 @@ function MusicDetail({
513
532
  membershipPath?: string;
514
533
  }) {
515
534
  const t = useThemeTokens();
535
+ const tr = useForgeT();
516
536
  // Inclusive stores caption the price as tax-inclusive (display only).
517
537
  const pricesIncludeTax = usePricesIncludeTax();
518
538
  const { pause, loadAndPlay, currentTrack, play, isPlaying } = useAudioPlayer();
@@ -544,13 +564,13 @@ function MusicDetail({
544
564
  title: t.title,
545
565
  url: t.media.find((m) => m.type === "audio")?.url ?? t.media[0]?.url ?? "",
546
566
  coverImage: cover,
547
- artist: t.artist || product.artist || "Unknown Artist",
567
+ artist: t.artist || product.artist || tr("forge.product_detail.unknown_artist"),
548
568
  productId: product.id,
549
569
  });
550
570
  loadAndPlay(toTrack(track), isSingle ? [] : tracks.map(toTrack));
551
571
  };
552
572
 
553
- if (!defaultVariant || !firstTrack) return <p style={{ color: t.text }}>No preview available.</p>;
573
+ if (!defaultVariant || !firstTrack) return <p style={{ color: t.text }}>{tr("forge.product_detail.no_preview")}</p>;
554
574
 
555
575
  const border = `1px solid ${a(t.primary, 0.2)}`;
556
576
  const onPrimary = readableTextOn(t.primary);
@@ -576,7 +596,11 @@ function MusicDetail({
576
596
  )}
577
597
  </div>
578
598
  <button
579
- aria-label={isPlaying && belongsToProduct ? "Pause" : "Play"}
599
+ aria-label={
600
+ isPlaying && belongsToProduct
601
+ ? tr("forge.product_detail.aria_pause")
602
+ : tr("forge.product_detail.aria_play")
603
+ }
580
604
  onClick={() => {
581
605
  if (!currentTrack || !belongsToProduct || currentTrack.id === firstTrack.id) return handlePlayPause(firstTrack);
582
606
  return isPlaying ? pause() : play();
@@ -619,7 +643,9 @@ function MusicDetail({
619
643
  background: a(t.text, 0.08),
620
644
  }}
621
645
  >
622
- {isSingle ? "Single" : `Album · ${tracks.length} tracks`}
646
+ {isSingle
647
+ ? tr("forge.product_detail.badge_single")
648
+ : tr("forge.product_detail.badge_album", { count: tracks.length })}
623
649
  </span>
624
650
  <div style={{ fontSize: 20, fontWeight: 700, color: t.primary }}>
625
651
  <PriceDisplay amount={defaultVariant.price} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={t.text} captionStyle={{ opacity: 0.65 }} />
@@ -636,7 +662,7 @@ function MusicDetail({
636
662
  key={v.optionValueId}
637
663
  type="button"
638
664
  aria-pressed={on}
639
- aria-label={`${axis.axis}: ${v.value}`}
665
+ aria-label={tr("forge.product_detail.option_aria", { axis: axis.axis, value: v.value })}
640
666
  disabled={!v.isAvailable}
641
667
  onClick={() => select(axis.optionTypeId, v.optionValueId)}
642
668
  style={{
@@ -659,14 +685,14 @@ function MusicDetail({
659
685
 
660
686
  <div style={{ display: "flex", gap: 10, flexWrap: "wrap", marginTop: 4 }}>
661
687
  <Button disabled={isGated} onClick={() => buyNow(defaultVariant, { canIncreaseQuantity: false })}>
662
- Buy now
688
+ {tr("forge.product_detail.buy_now")}
663
689
  </Button>
664
690
  <Button
665
691
  variant="outline"
666
692
  disabled={isGated}
667
693
  onClick={() => add(defaultVariant, { canIncreaseQuantity: false })}
668
694
  >
669
- Add to cart
695
+ {tr("forge.product_detail.add_to_cart")}
670
696
  </Button>
671
697
  </div>
672
698
  {/* A gated release is still listed and still PLAYABLE — the
@@ -675,7 +701,7 @@ function MusicDetail({
675
701
  <MembershipGateNotice
676
702
  gate={product.membershipGate}
677
703
  variant="badge"
678
- itemLabel="This release"
704
+ itemLabel={tr("forge.product_detail.gate_item_release")}
679
705
  loginPath={loginPath}
680
706
  membershipPath={membershipPath}
681
707
  />
@@ -701,7 +727,11 @@ function MusicDetail({
701
727
  <button
702
728
  onClick={() => handlePlayPause(track)}
703
729
  disabled={!audioUrl}
704
- aria-label={active && isPlaying ? "Pause" : "Play"}
730
+ aria-label={
731
+ active && isPlaying
732
+ ? tr("forge.product_detail.aria_pause")
733
+ : tr("forge.product_detail.aria_play")
734
+ }
705
735
  style={{
706
736
  width: 44,
707
737
  height: 44,
@@ -724,7 +754,7 @@ function MusicDetail({
724
754
  </span>
725
755
  {track.hasExplicitContent && (
726
756
  <span style={{ fontSize: 10, padding: "0 4px", borderRadius: 3, border: `1px solid ${a(t.primary, 0.3)}`, background: a(t.primary, 0.1) }}>
727
- E
757
+ {tr("forge.product_detail.explicit_badge")}
728
758
  </span>
729
759
  )}
730
760
  {track.isFeatured && <Star size={14} color={t.primary} />}
@@ -740,7 +770,7 @@ function MusicDetail({
740
770
  {/* Credits */}
741
771
  {product.credits && (
742
772
  <div style={{ padding: 24, borderTop: border }}>
743
- <p style={{ fontWeight: 700, fontSize: 13, opacity: 0.7 }}>Credits</p>
773
+ <p style={{ fontWeight: 700, fontSize: 13, opacity: 0.7 }}>{tr("forge.product_detail.credits_title")}</p>
744
774
  <p style={{ whiteSpace: "pre-line", fontSize: 14, opacity: 0.7 }}>{product.credits}</p>
745
775
  </div>
746
776
  )}
@@ -1,4 +1,5 @@
1
1
  import { useState } from "react";
2
+ import { useForgeT } from "../../i18n";
2
3
  import { usePostEngagement, type UsePostEngagementOptions } from "../headless/engagement/usePostEngagement";
3
4
  import type { EngagementEntityType } from "../../types/models";
4
5
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
@@ -12,6 +13,7 @@ export interface ReactionBarProps extends UsePostEngagementOptions {
12
13
 
13
14
  /** Themed like / save / comment bar built on `usePostEngagement`. */
14
15
  export function ReactionBar({ entityId, entityType = "post", showComments = true, ...opts }: ReactionBarProps) {
16
+ const t = useForgeT();
15
17
  const theme = useForgeTheme();
16
18
  const e = usePostEngagement(entityId, entityType, opts);
17
19
  const [draft, setDraft] = useState("");
@@ -57,7 +59,7 @@ export function ReactionBar({ entityId, entityType = "post", showComments = true
57
59
  <input
58
60
  value={draft}
59
61
  onChange={(ev) => setDraft(ev.target.value)}
60
- placeholder="Add a comment…"
62
+ placeholder={t("forge.reaction_bar.comment_placeholder")}
61
63
  style={{
62
64
  flex: 1,
63
65
  padding: 8,
@@ -83,7 +85,7 @@ export function ReactionBar({ entityId, entityType = "post", showComments = true
83
85
  opacity: e.isAddingComment || !draft.trim() ? 0.6 : 1,
84
86
  }}
85
87
  >
86
- Post
88
+ {t("forge.reaction_bar.submit")}
87
89
  </button>
88
90
  </div>
89
91
  </div>
@@ -1,6 +1,7 @@
1
1
  import { useState } from "react";
2
2
  import { useReplayBroadcasts } from "../../data/queries/useReplay";
3
3
  import type { ReplayBroadcast } from "../../types/models";
4
+ import { useForgeT } from "../../i18n";
4
5
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
5
6
  import { Loading } from "./Loading";
6
7
 
@@ -26,6 +27,7 @@ function formatDateTime(value: string | number | Date, timezone?: string): strin
26
27
 
27
28
  /** Replay broadcasts gallery, gated by ticket id. Built on `useReplayBroadcasts`. */
28
29
  export function ReplayList({ ticketId }: ReplayListProps) {
30
+ const t = useForgeT();
29
31
  const theme = useForgeTheme();
30
32
  const [selected, setSelected] = useState<ReplayBroadcast | null>(null);
31
33
 
@@ -34,7 +36,7 @@ export function ReplayList({ ticketId }: ReplayListProps) {
34
36
  if (!ticketId) {
35
37
  return (
36
38
  <div style={{ maxWidth: 520, margin: "0 auto", textAlign: "center", color: theme.colors.text }}>
37
- <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>Enter Your Ticket ID</h1>
39
+ <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>{t("forge.replay_list.no_ticket_title")}</h1>
38
40
  <p style={{ opacity: 0.7 }}>
39
41
  Add your ticket id to the URL, e.g. <code>/i/replay?ticketId=&lt;ticketId&gt;</code>.
40
42
  </p>
@@ -45,11 +47,7 @@ export function ReplayList({ ticketId }: ReplayListProps) {
45
47
  if (isLoading) return <Loading fullPage />;
46
48
 
47
49
  if (error || !data) {
48
- return (
49
- <p style={{ color: theme.colors.text }}>
50
- Invalid ticket id or no broadcasts found. Please check your ticket id and try again.
51
- </p>
52
- );
50
+ return <p style={{ color: theme.colors.text }}>{t("forge.replay_list.error")}</p>;
53
51
  }
54
52
 
55
53
  if (selected) {
@@ -66,20 +64,33 @@ export function ReplayList({ ticketId }: ReplayListProps) {
66
64
  fontSize: 14,
67
65
  }}
68
66
  >
69
- ← Back to broadcasts
67
+ {t("forge.replay_list.back")}
70
68
  </button>
71
- <div style={{ borderRadius: theme.cornerRadius, overflow: "hidden", border: `1px solid ${theme.colors.text}1a` }}>
69
+ <div
70
+ style={{ borderRadius: theme.cornerRadius, overflow: "hidden", border: `1px solid ${theme.colors.text}1a` }}
71
+ >
72
72
  {selected.vodUrl ? (
73
73
  <video src={selected.vodUrl} controls playsInline style={{ width: "100%", background: "#000" }} />
74
74
  ) : (
75
- <div style={{ aspectRatio: "16/9", display: "flex", alignItems: "center", justifyContent: "center", background: "#000", color: "#fff" }}>
76
- Video not available
75
+ <div
76
+ style={{
77
+ aspectRatio: "16/9",
78
+ display: "flex",
79
+ alignItems: "center",
80
+ justifyContent: "center",
81
+ background: "#000",
82
+ color: "#fff",
83
+ }}
84
+ >
85
+ {t("forge.replay_list.video_unavailable")}
77
86
  </div>
78
87
  )}
79
88
  <div style={{ padding: 24 }}>
80
89
  <h1 style={{ fontSize: 22, fontWeight: 700 }}>{selected.title}</h1>
81
90
  {selected.endedAt && (
82
- <p style={{ fontSize: 13, opacity: 0.7, marginTop: 4 }}>Ended {formatDateTime(selected.endedAt)}</p>
91
+ <p style={{ fontSize: 13, opacity: 0.7, marginTop: 4 }}>
92
+ {t("forge.replay_list.ended", { date: formatDateTime(selected.endedAt) })}
93
+ </p>
83
94
  )}
84
95
  </div>
85
96
  </div>
@@ -88,12 +99,12 @@ export function ReplayList({ ticketId }: ReplayListProps) {
88
99
  }
89
100
 
90
101
  if (data.broadcasts.length === 0) {
91
- return <p style={{ color: theme.colors.text }}>No replay broadcasts available for this ticket.</p>;
102
+ return <p style={{ color: theme.colors.text }}>{t("forge.replay_list.empty_title")}</p>;
92
103
  }
93
104
 
94
105
  return (
95
106
  <div style={{ maxWidth: 960, margin: "0 auto", color: theme.colors.text }}>
96
- <h1 style={{ fontSize: 28, fontWeight: 700, marginBottom: 24 }}>Replay Broadcasts</h1>
107
+ <h1 style={{ fontSize: 28, fontWeight: 700, marginBottom: 24 }}>{t("forge.replay_list.title")}</h1>
97
108
  <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gap: 16 }}>
98
109
  {data.broadcasts.map((broadcast) => {
99
110
  const thumb = broadcast.thumbnailUrl || broadcast.generatedThumbnailUrl;
@@ -1,5 +1,6 @@
1
1
  import { useState } from "react";
2
2
  import { useResetPassword } from "../../data/queries/useAuthActions";
3
+ import { useForgeT } from "../../i18n";
3
4
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
4
5
 
5
6
  export interface ResetPasswordFormProps {
@@ -16,6 +17,7 @@ const msg = (e: unknown) => (e as { response?: { data?: { message?: string } } }
16
17
  /** Themed reset-password form, built on `useResetPassword`. */
17
18
  export function ResetPasswordForm({ token, loginHref, forgotHref }: ResetPasswordFormProps) {
18
19
  const theme = useForgeTheme();
20
+ const t = useForgeT();
19
21
  const resetPassword = useResetPassword();
20
22
  const [password, setPassword] = useState("");
21
23
  const [confirmPassword, setConfirmPassword] = useState("");
@@ -24,6 +26,11 @@ export function ResetPasswordForm({ token, loginHref, forgotHref }: ResetPasswor
24
26
  const [isSuccess, setIsSuccess] = useState(false);
25
27
 
26
28
  const card: React.CSSProperties = {
29
+ // See `LoginForm`. The host layout is a COLUMN flex container, and an auto
30
+ // cross-axis margin disables `align-items: stretch`, so `maxWidth` alone
31
+ // leaves the card shrink-to-fit. The cap is unchanged: this fills the
32
+ // container UP TO 420 and no further.
33
+ width: "100%",
27
34
  maxWidth: 420,
28
35
  margin: "40px auto 0",
29
36
  padding: 24,
@@ -56,10 +63,12 @@ export function ResetPasswordForm({ token, loginHref, forgotHref }: ResetPasswor
56
63
  if (!token) {
57
64
  return (
58
65
  <div style={card}>
59
- <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>Invalid link</h1>
60
- <p style={{ fontSize: 14, marginBottom: 24 }}>This password reset link is invalid or has expired.</p>
66
+ <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>
67
+ {t("forge.reset_password_form.invalid_title")}
68
+ </h1>
69
+ <p style={{ fontSize: 14, marginBottom: 24 }}>{t("forge.reset_password_form.invalid_body")}</p>
61
70
  <a href={forgotHref} style={link}>
62
- Request a new reset link
71
+ {t("forge.reset_password_form.request_new_link")}
63
72
  </a>
64
73
  </div>
65
74
  );
@@ -68,10 +77,12 @@ export function ResetPasswordForm({ token, loginHref, forgotHref }: ResetPasswor
68
77
  if (isSuccess) {
69
78
  return (
70
79
  <div style={card}>
71
- <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>Password reset</h1>
72
- <p style={{ fontSize: 14, marginBottom: 24 }}>Your password has been reset successfully.</p>
80
+ <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>
81
+ {t("forge.reset_password_form.success_title")}
82
+ </h1>
83
+ <p style={{ fontSize: 14, marginBottom: 24 }}>{t("forge.reset_password_form.success_body")}</p>
73
84
  <a href={loginHref} style={link}>
74
- Back to login
85
+ {t("forge.reset_password_form.back_to_login")}
75
86
  </a>
76
87
  </div>
77
88
  );
@@ -81,11 +92,11 @@ export function ResetPasswordForm({ token, loginHref, forgotHref }: ResetPasswor
81
92
  e.preventDefault();
82
93
  setError("");
83
94
  if (password.length < 8) {
84
- setError("Password must be at least 8 characters.");
95
+ setError(t("forge.reset_password_form.password_too_short"));
85
96
  return;
86
97
  }
87
98
  if (password !== confirmPassword) {
88
- setError("Passwords don't match.");
99
+ setError(t("forge.reset_password_form.passwords_mismatch"));
89
100
  return;
90
101
  }
91
102
  setIsSubmitting(true);
@@ -93,7 +104,7 @@ export function ResetPasswordForm({ token, loginHref, forgotHref }: ResetPasswor
93
104
  await resetPassword.mutateAsync({ token, password });
94
105
  setIsSuccess(true);
95
106
  } catch (err) {
96
- setError(msg(err) || "Something went wrong. Please try again.");
107
+ setError(msg(err) || t("forge.reset_password_form.error"));
97
108
  } finally {
98
109
  setIsSubmitting(false);
99
110
  }
@@ -101,25 +112,31 @@ export function ResetPasswordForm({ token, loginHref, forgotHref }: ResetPasswor
101
112
 
102
113
  return (
103
114
  <div style={card}>
104
- <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>Reset password</h1>
115
+ <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>{t("forge.reset_password_form.title")}</h1>
105
116
  {error && <p style={{ color: "#ef4444", marginBottom: 16 }}>{error}</p>}
106
117
  <form onSubmit={onSubmit} style={{ display: "flex", flexDirection: "column", gap: 16 }}>
107
118
  <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
108
- <label style={{ fontSize: 14 }}>Password</label>
109
- <input type="password" placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} style={input} />
119
+ <label style={{ fontSize: 14 }}>{t("forge.reset_password_form.password_label")}</label>
120
+ <input
121
+ type="password"
122
+ placeholder={t("forge.reset_password_form.password_placeholder")}
123
+ value={password}
124
+ onChange={(e) => setPassword(e.target.value)}
125
+ style={input}
126
+ />
110
127
  </div>
111
128
  <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
112
- <label style={{ fontSize: 14 }}>Confirm password</label>
129
+ <label style={{ fontSize: 14 }}>{t("forge.reset_password_form.confirm_password_label")}</label>
113
130
  <input
114
131
  type="password"
115
- placeholder="Confirm password"
132
+ placeholder={t("forge.reset_password_form.confirm_password_placeholder")}
116
133
  value={confirmPassword}
117
134
  onChange={(e) => setConfirmPassword(e.target.value)}
118
135
  style={input}
119
136
  />
120
137
  </div>
121
138
  <button type="submit" disabled={isSubmitting} style={button}>
122
- {isSubmitting ? "Resetting…" : "Reset password"}
139
+ {isSubmitting ? t("forge.reset_password_form.submitting") : t("forge.reset_password_form.submit")}
123
140
  </button>
124
141
  </form>
125
142
  </div>