@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 { Loading } from "./Loading";
5
5
  import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
6
6
  import { readableTextOn } from "../theme/contrast";
7
7
  import { useAmountFormatter } from "../format/useFormatCurrency";
8
+ import { useForgeT } from "../../i18n";
8
9
  import { HoldNotice } from "./HoldNotice";
9
10
 
10
11
  export interface OfferButtonProps {
@@ -20,6 +21,7 @@ export interface OfferButtonProps {
20
21
  /** One-line themed product-purchase button + modal, built on the `<Offer>` compound. */
21
22
  export function OfferButton({ productId, text, finalisePath, onSuccess, renderPayment, formatAmount }: OfferButtonProps) {
22
23
  const theme = useForgeTheme();
24
+ const t = useForgeT();
23
25
  const registered = usePaymentRenderer();
24
26
  const fmt = useAmountFormatter(formatAmount);
25
27
  const renderPay =
@@ -40,7 +42,7 @@ export function OfferButton({ productId, text, finalisePath, onSuccess, renderPa
40
42
  fontFamily: theme.fontFamily,
41
43
  }}
42
44
  >
43
- {text ?? "Buy now"}
45
+ {text ?? t("forge.offer_button.buy_now")}
44
46
  </button>
45
47
  </Offer.Trigger>
46
48
 
@@ -77,6 +79,7 @@ function OfferBody({
77
79
  }) {
78
80
  const c = useOfferContext();
79
81
  const theme = useForgeTheme();
82
+ const t = useForgeT();
80
83
  const inputStyle = {
81
84
  width: "100%",
82
85
  padding: 10,
@@ -87,7 +90,7 @@ function OfferBody({
87
90
  } as const;
88
91
 
89
92
  if (c.isLoading) return <Loading />;
90
- if (!c.product) return <p style={{ marginTop: 8 }}>Product not found.</p>;
93
+ if (!c.product) return <p style={{ marginTop: 8 }}>{t("forge.offer_button.not_found")}</p>;
91
94
 
92
95
  // ── Payment (redirect) ──
93
96
  if (c.step === "payment") {
@@ -95,7 +98,7 @@ function OfferBody({
95
98
  <div>
96
99
  <Offer.Title className="">{c.product.title}</Offer.Title>
97
100
  <div style={{ display: "flex", justifyContent: "space-between", margin: "12px 0" }}>
98
- <span>Total</span>
101
+ <span>{t("forge.offer_button.total")}</span>
99
102
  <strong style={{ color: theme.colors.primary }}>{fmt(c.totalPrice)}</strong>
100
103
  </div>
101
104
  {/* The reservation, counted down while the buyer pays. Nothing is drawn
@@ -110,7 +113,7 @@ function OfferBody({
110
113
  {renderPayment ? (
111
114
  <Offer.PaymentStep>{(p) => renderPayment(p)}</Offer.PaymentStep>
112
115
  ) : (
113
- <p style={{ opacity: 0.7 }}>Payment is not configured.</p>
116
+ <p style={{ opacity: 0.7 }}>{t("forge.offer_button.payment_unavailable")}</p>
114
117
  )}
115
118
  </div>
116
119
  );
@@ -123,7 +126,7 @@ function OfferBody({
123
126
  <div style={{ marginTop: 16, display: "flex", flexDirection: "column", gap: 12 }}>
124
127
  {c.variants.length > 1 && (
125
128
  <label>
126
- Option
129
+ {t("forge.offer_button.option_label")}
127
130
  <select
128
131
  value={c.selectedVariant?.id ?? ""}
129
132
  onChange={(e) => c.setSelectedVariantId(e.target.value)}
@@ -131,7 +134,8 @@ function OfferBody({
131
134
  >
132
135
  {c.variants.map((v) => (
133
136
  <option key={v.id} value={v.id}>
134
- {v.title || [v.color, v.size].filter(Boolean).join(" / ") || "Default"} · {fmt(v.price)}
137
+ {v.title || [v.color, v.size].filter(Boolean).join(" / ") || t("forge.offer_button.variant_default")}{" "}
138
+ · {fmt(v.price)}
135
139
  </option>
136
140
  ))}
137
141
  </select>
@@ -139,7 +143,7 @@ function OfferBody({
139
143
  )}
140
144
 
141
145
  <label>
142
- Quantity
146
+ {t("forge.offer_button.quantity_label")}
143
147
  <select value={c.quantity} onChange={(e) => c.setQuantity(Number(e.target.value))} style={inputStyle}>
144
148
  {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((n) => (
145
149
  <option key={n} value={n}>
@@ -150,12 +154,28 @@ function OfferBody({
150
154
  </label>
151
155
 
152
156
  <div style={{ display: "flex", gap: 8 }}>
153
- <input placeholder="First name" value={c.firstName} onChange={(e) => c.setFirstName(e.target.value)} style={inputStyle} />
154
- <input placeholder="Last name" value={c.lastName} onChange={(e) => c.setLastName(e.target.value)} style={inputStyle} />
157
+ <input
158
+ placeholder={t("forge.offer_button.first_name")}
159
+ value={c.firstName}
160
+ onChange={(e) => c.setFirstName(e.target.value)}
161
+ style={inputStyle}
162
+ />
163
+ <input
164
+ placeholder={t("forge.offer_button.last_name")}
165
+ value={c.lastName}
166
+ onChange={(e) => c.setLastName(e.target.value)}
167
+ style={inputStyle}
168
+ />
155
169
  </div>
156
- <input placeholder="Email" type="email" value={c.email} onChange={(e) => c.setEmail(e.target.value)} style={inputStyle} />
157
170
  <input
158
- placeholder="Confirm email"
171
+ placeholder={t("forge.offer_button.email")}
172
+ type="email"
173
+ value={c.email}
174
+ onChange={(e) => c.setEmail(e.target.value)}
175
+ style={inputStyle}
176
+ />
177
+ <input
178
+ placeholder={t("forge.offer_button.confirm_email")}
159
179
  type="email"
160
180
  value={c.confirmEmail}
161
181
  onChange={(e) => c.setConfirmEmail(e.target.value)}
@@ -163,7 +183,7 @@ function OfferBody({
163
183
  />
164
184
 
165
185
  <div style={{ display: "flex", justifyContent: "space-between", fontWeight: 600 }}>
166
- <span>Total</span>
186
+ <span>{t("forge.offer_button.total")}</span>
167
187
  <span style={{ color: theme.colors.primary }}>{fmt(c.totalPrice)}</span>
168
188
  </div>
169
189
 
@@ -196,7 +216,7 @@ function OfferBody({
196
216
  opacity: c.isProcessing ? 0.6 : 1,
197
217
  }}
198
218
  >
199
- {c.isProcessing ? "Processing…" : "Continue to payment"}
219
+ {c.isProcessing ? t("forge.offer_button.processing") : t("forge.offer_button.continue_to_payment")}
200
220
  </button>
201
221
  </div>
202
222
  </Offer.DetailsStep>
@@ -1,3 +1,4 @@
1
+ import { useForgeT } from "../../i18n";
1
2
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
2
3
  import { useAmountFormatter } from "../format/useFormatCurrency";
3
4
  import { Loading } from "./Loading";
@@ -20,30 +21,35 @@ export function PaymentLinkConfirmation({
20
21
  paymentPath = `/i/payment-link/${paymentLinkId}`,
21
22
  }: PaymentLinkConfirmationProps) {
22
23
  const theme = useForgeTheme();
24
+ const t = useForgeT();
23
25
  const fmt = useAmountFormatter(formatAmount);
24
26
  const { data, isLoading, error } = usePaymentLinkFinalize(paymentLinkId);
25
27
 
26
28
  if (isLoading) return <Loading fullPage />;
27
- if (error || !data) return <p style={{ color: theme.colors.text }}>We couldn’t load this payment.</p>;
29
+ if (error || !data)
30
+ return <p style={{ color: theme.colors.text }}>{t("forge.payment_link_confirmation.load_error")}</p>;
28
31
 
29
32
  const paid = data.status === "paid" || !!data.paidAt;
30
33
 
31
34
  return (
32
35
  <div style={{ maxWidth: 520, margin: "0 auto", textAlign: "center", color: theme.colors.text }}>
33
36
  <h1 style={{ fontSize: 28, fontWeight: 800, marginBottom: 8 }}>
34
- {paid ? "Payment received" : "Payment not completed"}
37
+ {paid ? t("forge.payment_link_confirmation.paid_title") : t("forge.payment_link_confirmation.unpaid_title")}
35
38
  </h1>
36
39
  <p style={{ opacity: 0.75, marginBottom: 16 }}>
37
40
  {paid
38
- ? `Thank you, ${data.recipientName}. Your payment to ${data.profileName} is complete.`
39
- : `This payment is ${data.status}. If you were charged, please contact support.`}
41
+ ? t("forge.payment_link_confirmation.paid_body", {
42
+ recipient_name: data.recipientName,
43
+ profile_name: data.profileName,
44
+ })
45
+ : t("forge.payment_link_confirmation.unpaid_body", { status: data.status })}
40
46
  </p>
41
47
  <p style={{ fontWeight: 700, color: theme.colors.primary }}>{fmt(Number(data.amount), data.currency)}</p>
42
48
  <a
43
49
  href={paymentPath}
44
50
  style={{ display: "inline-block", marginTop: 24, color: theme.colors.primary, textDecoration: "underline" }}
45
51
  >
46
- Back to payment
52
+ {t("forge.payment_link_confirmation.back_to_payment")}
47
53
  </a>
48
54
  </div>
49
55
  );
@@ -1,3 +1,4 @@
1
+ import { useForgeT } from "../../i18n";
1
2
  import { usePaymentLinkPayment } from "../headless/paymentLink/usePaymentLinkPayment";
2
3
  import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
3
4
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
@@ -20,6 +21,7 @@ export interface PaymentLinkPaymentProps {
20
21
  * (`usePaymentRenderer`).
21
22
  */
22
23
  export function PaymentLinkPayment({ paymentLinkId, formatAmount, returnPath }: PaymentLinkPaymentProps) {
24
+ const t = useForgeT();
23
25
  const theme = useForgeTheme();
24
26
  const fmt = useAmountFormatter(formatAmount);
25
27
  const renderPayment = usePaymentRenderer();
@@ -27,7 +29,8 @@ export function PaymentLinkPayment({ paymentLinkId, formatAmount, returnPath }:
27
29
  usePaymentLinkPayment(paymentLinkId, { returnPath });
28
30
 
29
31
  if (link.isLoading) return <Loading fullPage />;
30
- if (link.error || !link.data) return <p style={{ color: theme.colors.text }}>Payment link not found.</p>;
32
+ if (link.error || !link.data)
33
+ return <p style={{ color: theme.colors.text }}>{t("forge.payment_link_payment.not_found")}</p>;
31
34
 
32
35
  const data = link.data;
33
36
  const isPaid = data.status === "paid" || !!data.paidAt;
@@ -35,8 +38,10 @@ export function PaymentLinkPayment({ paymentLinkId, formatAmount, returnPath }:
35
38
 
36
39
  return (
37
40
  <div style={{ maxWidth: 520, margin: "0 auto", color: theme.colors.text }}>
38
- <h1 style={{ fontSize: 26, fontWeight: 800, marginBottom: 4 }}>Payment request</h1>
39
- <p style={{ opacity: 0.7, fontSize: 14, marginBottom: 16 }}>from {data.profileName}</p>
41
+ <h1 style={{ fontSize: 26, fontWeight: 800, marginBottom: 4 }}>{t("forge.payment_link_payment.title")}</h1>
42
+ <p style={{ opacity: 0.7, fontSize: 14, marginBottom: 16 }}>
43
+ {t("forge.payment_link_payment.from_profile", { profile_name: data.profileName })}
44
+ </p>
40
45
 
41
46
  <div
42
47
  style={{
@@ -53,28 +58,37 @@ export function PaymentLinkPayment({ paymentLinkId, formatAmount, returnPath }:
53
58
  const taxSummary = summarizeTaxQuote(taxQuote, money);
54
59
  return taxSummary ? (
55
60
  <span style={{ display: "block", fontSize: 13, fontWeight: 400, color: theme.colors.text, opacity: 0.65 }}>
56
- {taxSummary.mode === "inclusive" ? taxSummary.label : `+ ${taxSummary.formattedTax} tax`}
61
+ {taxSummary.mode === "inclusive"
62
+ ? taxSummary.label
63
+ : t("forge.payment_link_payment.tax_added", { tax_amount: taxSummary.formattedTax })}
57
64
  </span>
58
65
  ) : null;
59
66
  })()}
60
67
  </div>
61
- <div style={{ fontSize: 14, opacity: 0.8, marginTop: 8 }}>Billed to {data.recipientName}</div>
68
+ <div style={{ fontSize: 14, opacity: 0.8, marginTop: 8 }}>
69
+ {t("forge.payment_link_payment.billed_to", { recipient_name: data.recipientName })}
70
+ </div>
62
71
  {data.message && <p style={{ marginTop: 12, lineHeight: 1.6, opacity: 0.9 }}>{data.message}</p>}
63
72
  </div>
64
73
 
65
74
  {isPaid ? (
66
- <p style={{ fontWeight: 700, color: theme.colors.primary }}>This payment has been completed. Thank you!</p>
75
+ <p style={{ fontWeight: 700, color: theme.colors.primary }}>{t("forge.payment_link_payment.paid_notice")}</p>
67
76
  ) : (
68
77
  <div>
69
- <h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 12 }}>Payment</h2>
78
+ <h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 12 }}>
79
+ {t("forge.payment_link_payment.payment_heading")}
80
+ </h2>
70
81
  {error && <p style={{ color: "#ef4444", fontSize: 14, marginBottom: 8 }}>{error}</p>}
71
82
  {/* Paystack brings its own modal, so it is checked before the Stripe
72
83
  renderer and needs none of it. */}
73
84
  {isPaystack ? (
74
85
  canOpenPaystack ? (
75
- <PaystackPayButton onPay={openPaystackCheckout} label={`Pay ${money(Number(data.amount))}`} />
86
+ <PaystackPayButton
87
+ onPay={openPaystackCheckout}
88
+ label={t("forge.payment_link_payment.pay_amount", { amount: money(Number(data.amount)) })}
89
+ />
76
90
  ) : (
77
- <Loading label="Preparing payment…" size={22} />
91
+ <Loading label={t("forge.payment_link_payment.preparing_payment")} size={22} />
78
92
  )
79
93
  ) : clientSecret ? (
80
94
  renderPayment?.({
@@ -84,7 +98,13 @@ export function PaymentLinkPayment({ paymentLinkId, formatAmount, returnPath }:
84
98
  mode: "redirect",
85
99
  })
86
100
  ) : (
87
- <>{isStarting ? <Loading label="Preparing payment…" size={22} /> : <p style={{ opacity: 0.7 }}>Payment is not available right now.</p>}</>
101
+ <>
102
+ {isStarting ? (
103
+ <Loading label={t("forge.payment_link_payment.preparing_payment")} size={22} />
104
+ ) : (
105
+ <p style={{ opacity: 0.7 }}>{t("forge.payment_link_payment.payment_unavailable")}</p>
106
+ )}
107
+ </>
88
108
  )}
89
109
  </div>
90
110
  )}
@@ -2,10 +2,11 @@ import { Play, Pause } from "lucide-react";
2
2
  import { usePodcast, findEpisode, episodeAudio } from "../../data/queries/usePodcast";
3
3
  import { usePodcastPlayer } from "../headless/podcast/usePodcastPlayer";
4
4
  import type { BlogPost, BlogCategory } from "../../types/models";
5
+ import { useForgeT } from "../../i18n";
5
6
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
6
7
  import { Loading } from "./Loading";
7
8
  // Baseline `.rich-text` content styles ship with Forge (WYSIWYG with the admin
8
- // editor) no host-loaded stylesheet needed.
9
+ // editor), so no host-loaded stylesheet is needed.
9
10
  import "./rich-text.css";
10
11
 
11
12
  export interface PodcastEpisodeProps {
@@ -28,7 +29,7 @@ function fmt(sec?: number | null) {
28
29
  }
29
30
 
30
31
  /**
31
- * Single-episode page plays this episode and offers Previous / Next episode
32
+ * Single-episode page. Plays this episode and offers Previous / Next episode
32
33
  * buttons. Shares the queue + player with the show page (persistent playback).
33
34
  * Built on `usePodcast` + `usePodcastPlayer`.
34
35
  */
@@ -40,6 +41,7 @@ export function PodcastEpisode({
40
41
  initialShow,
41
42
  initialEpisodes,
42
43
  }: PodcastEpisodeProps) {
44
+ const t = useForgeT();
43
45
  const theme = useForgeTheme();
44
46
  const { show, episodes, isLoading } = usePodcast(showSlug, {
45
47
  initialShow: initialShow ?? undefined,
@@ -49,7 +51,7 @@ export function PodcastEpisode({
49
51
  const { episode, previous, next } = findEpisode(episodes, episodeSlug);
50
52
 
51
53
  if (isLoading) return <Loading fullPage />;
52
- if (!episode) return <p style={{ color: theme.colors.text }}>Episode not found.</p>;
54
+ if (!episode) return <p style={{ color: theme.colors.text }}>{t("forge.podcast_episode.not_found")}</p>;
53
55
 
54
56
  const border = `${theme.colors.text}1a`;
55
57
  const dur = episodeAudio(episode)?.durationSec;
@@ -58,7 +60,7 @@ export function PodcastEpisode({
58
60
  return (
59
61
  <article style={{ maxWidth: 720, margin: "0 auto", padding: "40px 20px", color: theme.colors.text }}>
60
62
  <a href={showHref(showSlug)} style={{ color: theme.colors.primary, textDecoration: "none", fontSize: 14 }}>
61
- ← {show?.title ?? "Podcast"}
63
+ ← {show?.title ?? t("forge.podcast_episode.show_fallback")}
62
64
  </a>
63
65
 
64
66
  <h1 style={{ fontSize: 26, fontWeight: 800, marginTop: 12 }}>{episode.title}</h1>
@@ -85,7 +87,7 @@ export function PodcastEpisode({
85
87
  }}
86
88
  >
87
89
  {playing ? <Pause size={18} /> : <Play size={18} />}
88
- {playing ? "Pause" : "Play episode"}
90
+ {playing ? t("forge.podcast_episode.pause") : t("forge.podcast_episode.play")}
89
91
  </button>
90
92
  {player.isCurrent(episode) && (
91
93
  <span style={{ fontSize: 13, opacity: 0.6 }}>
@@ -94,7 +96,7 @@ export function PodcastEpisode({
94
96
  )}
95
97
  </div>
96
98
 
97
- {/* Show notes `rich-text` = shared baseline content styles (WYSIWYG
99
+ {/* Show notes. `rich-text` = shared baseline content styles (WYSIWYG
98
100
  with the editor), bundled with Forge via the ./rich-text.css import. */}
99
101
  <div
100
102
  className="rich-text"
@@ -103,10 +105,19 @@ export function PodcastEpisode({
103
105
  />
104
106
 
105
107
  {/* Prev / Next episode navigation */}
106
- <nav style={{ display: "flex", justifyContent: "space-between", gap: 12, marginTop: 32, borderTop: `1px solid ${border}`, paddingTop: 18 }}>
108
+ <nav
109
+ style={{
110
+ display: "flex",
111
+ justifyContent: "space-between",
112
+ gap: 12,
113
+ marginTop: 32,
114
+ borderTop: `1px solid ${border}`,
115
+ paddingTop: 18,
116
+ }}
117
+ >
107
118
  {previous ? (
108
119
  <a href={episodeHref(previous.slug)} style={navLink(theme)}>
109
- <span style={{ opacity: 0.55, fontSize: 12 }}>← Previous</span>
120
+ <span style={{ opacity: 0.55, fontSize: 12 }}>{t("forge.podcast_episode.previous")}</span>
110
121
  <span style={{ fontWeight: 600 }}>{previous.title}</span>
111
122
  </a>
112
123
  ) : (
@@ -114,7 +125,7 @@ export function PodcastEpisode({
114
125
  )}
115
126
  {next ? (
116
127
  <a href={episodeHref(next.slug)} style={{ ...navLink(theme), textAlign: "right", alignItems: "flex-end" }}>
117
- <span style={{ opacity: 0.55, fontSize: 12 }}>Next →</span>
128
+ <span style={{ opacity: 0.55, fontSize: 12 }}>{t("forge.podcast_episode.next")}</span>
118
129
  <span style={{ fontWeight: 600 }}>{next.title}</span>
119
130
  </a>
120
131
  ) : (
@@ -1,5 +1,6 @@
1
1
  import { usePodcasts } from "../../data/queries/usePodcast";
2
2
  import type { PodcastShow } from "../../types/models";
3
+ import { useForgeT } from "../../i18n";
3
4
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
4
5
  import { Loading } from "./Loading";
5
6
 
@@ -13,16 +14,19 @@ export interface PodcastListProps {
13
14
  /** Podcast index — grid of the creator's shows. Built on `usePodcasts`. */
14
15
  export function PodcastList({ showHref = (s) => `/i/podcasts/${s}`, initialShows }: PodcastListProps) {
15
16
  const theme = useForgeTheme();
17
+ const t = useForgeT();
16
18
  const { data: shows, isLoading } = usePodcasts({ initialData: initialShows ?? undefined });
17
19
 
18
20
  if (isLoading) return <Loading fullPage />;
19
21
  const list = shows ?? [];
20
- if (!list.length) return <p style={{ opacity: 0.7, color: theme.colors.text }}>No podcasts yet.</p>;
22
+ if (!list.length) {
23
+ return <p style={{ opacity: 0.7, color: theme.colors.text }}>{t("forge.podcast_list.empty_title")}</p>;
24
+ }
21
25
 
22
26
  return (
23
27
  <div style={{ maxWidth: 960, margin: "0 auto", padding: "40px 20px", color: theme.colors.text }}>
24
28
  <header style={{ marginBottom: 28 }}>
25
- <h1 style={{ fontSize: 30, fontWeight: 800 }}>Podcasts</h1>
29
+ <h1 style={{ fontSize: 30, fontWeight: 800 }}>{t("forge.podcast_list.title")}</h1>
26
30
  </header>
27
31
  <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gap: 24 }}>
28
32
  {list.map((show) => (
@@ -47,7 +51,9 @@ export function PodcastList({ showHref = (s) => `/i/podcasts/${s}`, initialShows
47
51
  <h3 style={{ fontWeight: 700 }}>{show.title}</h3>
48
52
  {show.author && <p style={{ fontSize: 13, opacity: 0.6, marginTop: 2 }}>{show.author}</p>}
49
53
  <p style={{ fontSize: 12, opacity: 0.5, marginTop: 6 }}>
50
- {show.episodeCount} episode{show.episodeCount === 1 ? "" : "s"}
54
+ {show.episodeCount === 1
55
+ ? t("forge.podcast_list.episode_count_one", { count: show.episodeCount })
56
+ : t("forge.podcast_list.episode_count_other", { count: show.episodeCount })}
51
57
  </p>
52
58
  </div>
53
59
  </a>
@@ -4,6 +4,7 @@ import { usePodcastPlayer } from "../headless/podcast/usePodcastPlayer";
4
4
  import type { BlogPost, BlogCategory } from "../../types/models";
5
5
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
6
6
  import { Loading } from "./Loading";
7
+ import { useForgeT } from "../../i18n";
7
8
 
8
9
  export interface PodcastShowProps {
9
10
  /** Show (podcast category) slug. */
@@ -28,6 +29,7 @@ function fmt(sec?: number | null) {
28
29
  * Built on `usePodcast` + `usePodcastPlayer` (the shared AudioPlayer + queue).
29
30
  */
30
31
  export function PodcastShow({ slug, episodeHref = (s) => `/i/podcasts/${slug}/${s}`, initialShow, initialEpisodes }: PodcastShowProps) {
32
+ const t = useForgeT();
31
33
  const theme = useForgeTheme();
32
34
  const { show, episodes, isLoading } = usePodcast(slug, {
33
35
  initialShow: initialShow ?? undefined,
@@ -36,7 +38,7 @@ export function PodcastShow({ slug, episodeHref = (s) => `/i/podcasts/${slug}/${
36
38
  const player = usePodcastPlayer({ episodes, show });
37
39
 
38
40
  if (isLoading) return <Loading fullPage />;
39
- if (!show) return <p>Podcast not found.</p>;
41
+ if (!show) return <p>{t("forge.podcast_show.not_found")}</p>;
40
42
 
41
43
  const cover = show.coverImage?.url;
42
44
  const nowPlaying = player.currentEpisode ?? episodes[0] ?? null;
@@ -73,16 +75,30 @@ export function PodcastShow({ slug, episodeHref = (s) => `/i/podcasts/${slug}/${
73
75
  boxShadow: "0 4px 16px rgba(0,0,0,0.06)",
74
76
  }}
75
77
  >
76
- <div style={{ fontSize: 13, opacity: 0.6 }}>Now playing</div>
78
+ <div style={{ fontSize: 13, opacity: 0.6 }}>{t("forge.podcast_show.now_playing")}</div>
77
79
  <div style={{ fontWeight: 600, margin: "2px 0 10px" }}>{nowPlaying.title}</div>
78
80
  <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
79
- <button onClick={player.playPrevious} disabled={!player.hasPrevious} aria-label="Previous episode" style={iconBtn(theme, !player.hasPrevious)}>
81
+ <button
82
+ onClick={player.playPrevious}
83
+ disabled={!player.hasPrevious}
84
+ aria-label={t("forge.podcast_show.aria_previous")}
85
+ style={iconBtn(theme, !player.hasPrevious)}
86
+ >
80
87
  <SkipBack size={18} />
81
88
  </button>
82
- <button onClick={() => player.toggleEpisode(nowPlaying)} aria-label="Play/pause" style={{ ...iconBtn(theme, false), background: theme.colors.primary, color: theme.colors.background, width: 44, height: 44 }}>
89
+ <button
90
+ onClick={() => player.toggleEpisode(nowPlaying)}
91
+ aria-label={t("forge.podcast_show.aria_play_pause")}
92
+ style={{ ...iconBtn(theme, false), background: theme.colors.primary, color: theme.colors.background, width: 44, height: 44 }}
93
+ >
83
94
  {player.isEpisodePlaying(nowPlaying) ? <Pause size={20} /> : <Play size={20} />}
84
95
  </button>
85
- <button onClick={player.playNext} disabled={!player.hasNext} aria-label="Next episode" style={iconBtn(theme, !player.hasNext)}>
96
+ <button
97
+ onClick={player.playNext}
98
+ disabled={!player.hasNext}
99
+ aria-label={t("forge.podcast_show.aria_next")}
100
+ style={iconBtn(theme, !player.hasNext)}
101
+ >
86
102
  <SkipForward size={18} />
87
103
  </button>
88
104
  <div style={{ flex: 1 }}>
@@ -125,7 +141,13 @@ export function PodcastShow({ slug, episodeHref = (s) => `/i/podcasts/${slug}/${
125
141
  borderBottom: `1px solid ${border}`,
126
142
  }}
127
143
  >
128
- <button onClick={() => player.toggleEpisode(ep)} aria-label={player.isEpisodePlaying(ep) ? "Pause episode" : "Play episode"} style={{ ...iconBtn(theme, false), flexShrink: 0 }}>
144
+ <button
145
+ onClick={() => player.toggleEpisode(ep)}
146
+ aria-label={
147
+ player.isEpisodePlaying(ep) ? t("forge.podcast_show.aria_pause_episode") : t("forge.podcast_show.aria_play_episode")
148
+ }
149
+ style={{ ...iconBtn(theme, false), flexShrink: 0 }}
150
+ >
129
151
  {player.isEpisodePlaying(ep) ? <Pause size={16} /> : <Play size={16} />}
130
152
  </button>
131
153
  <div style={{ flex: 1, minWidth: 0 }}>
@@ -140,7 +162,7 @@ export function PodcastShow({ slug, episodeHref = (s) => `/i/podcasts/${slug}/${
140
162
  </div>
141
163
  );
142
164
  })}
143
- {!episodes.length && <p style={{ opacity: 0.7 }}>No episodes yet.</p>}
165
+ {!episodes.length && <p style={{ opacity: 0.7 }}>{t("forge.podcast_show.empty_title")}</p>}
144
166
  </div>
145
167
  </div>
146
168
  );
@@ -1,5 +1,6 @@
1
1
  import { useGetPosts } from "../../data/queries/usePosts";
2
2
  import type { IPublicPost } from "../../types/models";
3
+ import { useForgeT } from "../../i18n";
3
4
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
4
5
  import { Loading } from "./Loading";
5
6
 
@@ -13,12 +14,13 @@ export interface PostsFeedProps {
13
14
 
14
15
  /** Themed posts grid, built on `useGetPosts`. */
15
16
  export function PostsFeed({ columns = 3, limit, onSelect, className, style }: PostsFeedProps) {
16
- const t = useThemeTokens();
17
+ const t = useForgeT();
18
+ const tokens = useThemeTokens();
17
19
  const { data, isLoading } = useGetPosts({ page: 1 });
18
20
  const posts = (data?.data ?? []).slice(0, limit ?? 100);
19
21
 
20
22
  if (isLoading) return <Loading />;
21
- if (!posts.length) return <p style={{ color: t.text, opacity: 0.7 }}>No posts yet.</p>;
23
+ if (!posts.length) return <p style={{ color: tokens.text, opacity: 0.7 }}>{t("forge.posts_feed.empty_body")}</p>;
22
24
 
23
25
  const gap = 16;
24
26
  const track = `minmax(max(150px, calc((100% - ${(columns - 1) * gap}px) / ${columns})), 1fr)`;
@@ -30,7 +32,7 @@ export function PostsFeed({ columns = 3, limit, onSelect, className, style }: Po
30
32
  display: "grid",
31
33
  gridTemplateColumns: `repeat(auto-fill, ${track})`,
32
34
  gap,
33
- fontFamily: t.fontFamily,
35
+ fontFamily: tokens.fontFamily,
34
36
  ...style,
35
37
  }}
36
38
  >
@@ -43,15 +45,15 @@ export function PostsFeed({ columns = 3, limit, onSelect, className, style }: Po
43
45
  style={{
44
46
  textAlign: "left",
45
47
  cursor: onSelect ? "pointer" : "default",
46
- background: t.surface,
47
- border: `1px solid ${t.primary}20`,
48
- borderRadius: t.cornerRadius,
48
+ background: tokens.surface,
49
+ border: `1px solid ${tokens.primary}20`,
50
+ borderRadius: tokens.cornerRadius,
49
51
  overflow: "hidden",
50
52
  padding: 0,
51
- color: t.text,
53
+ color: tokens.text,
52
54
  }}
53
55
  >
54
- <div style={{ aspectRatio: "1 / 1", background: `${t.primary}10` }}>
56
+ <div style={{ aspectRatio: "1 / 1", background: `${tokens.primary}10` }}>
55
57
  {cover && <img src={cover} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />}
56
58
  </div>
57
59
  <div style={{ padding: 10, fontSize: 14 }}>{post.caption?.slice(0, 100)}</div>
@@ -2,6 +2,7 @@ import type { CSSProperties } from "react";
2
2
  import { KeyRound } from "lucide-react";
3
3
  import type { PresaleCodeField } from "../headless/event/usePresaleCode";
4
4
  import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
5
+ import { useForgeT } from "../../i18n";
5
6
  import { buttonStyle } from "./Button";
6
7
 
7
8
  /** Append an alpha to a 6-digit hex color (mirrors frontend-shared addAlphaToHexCode). */
@@ -38,13 +39,9 @@ export interface PresaleCodeFieldProps {
38
39
  * names no tier and confirms no partial guess; a box that said "that isn't
39
40
  * the VIP code" would be a way to enumerate them.
40
41
  */
41
- export function PresaleCodeField({
42
- presale,
43
- promptText = "Have a presale code?",
44
- className,
45
- style,
46
- }: PresaleCodeFieldProps) {
42
+ export function PresaleCodeField({ presale, promptText, className, style }: PresaleCodeFieldProps) {
47
43
  const theme = useForgeTheme();
44
+ const t = useForgeT();
48
45
  if (!presale.visible) return null;
49
46
 
50
47
  const isChecking = presale.status === "checking";
@@ -78,7 +75,7 @@ export function PresaleCodeField({
78
75
  }}
79
76
  >
80
77
  <KeyRound size={14} />
81
- Presale unlocked: {presale.code}
78
+ {t("forge.presale_code.unlocked", { code: presale.code ?? "" })}
82
79
  </span>
83
80
  <button
84
81
  type="button"
@@ -95,7 +92,7 @@ export function PresaleCodeField({
95
92
  fontFamily: theme.fontFamily,
96
93
  }}
97
94
  >
98
- Remove
95
+ {t("forge.presale_code.remove")}
99
96
  </button>
100
97
  </div>
101
98
  );
@@ -112,13 +109,13 @@ export function PresaleCodeField({
112
109
  style={{ fontSize: 14, fontWeight: 600, display: "inline-flex", alignItems: "center", gap: 6 }}
113
110
  >
114
111
  <KeyRound size={14} />
115
- {promptText}
112
+ {promptText ?? t("forge.presale_code.prompt")}
116
113
  </label>
117
114
  <div style={{ display: "flex", gap: 8, alignItems: "flex-start" }}>
118
115
  <input
119
116
  id="presale-code-input"
120
117
  data-testid="presale-code-input"
121
- placeholder="Enter code"
118
+ placeholder={t("forge.presale_code.input_placeholder")}
122
119
  value={presale.input}
123
120
  onChange={(e) => presale.setInput(e.target.value.toUpperCase())}
124
121
  // Enter is what a buyer does after typing a code into a one-field
@@ -143,7 +140,7 @@ export function PresaleCodeField({
143
140
  opacity: isChecking || !presale.input.trim() ? 0.6 : 1,
144
141
  }}
145
142
  >
146
- {isChecking ? "Checking…" : "Unlock"}
143
+ {isChecking ? t("forge.presale_code.checking") : t("forge.presale_code.unlock")}
147
144
  </button>
148
145
  </div>
149
146
  {presale.message && (