@tribe-nest/forge 3.26.0 → 3.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/package.json +6 -3
  2. package/src/_tests/publishedResolvability.spec.ts +184 -0
  3. package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
  4. package/src/_tests/workspaceAliases.ts +40 -0
  5. package/src/client/_tests/localeHeader.spec.tsx +98 -0
  6. package/src/client/activeLocale.ts +34 -0
  7. package/src/client/createForgeClient.ts +13 -0
  8. package/src/contexts/CartContext.tsx +76 -0
  9. package/src/contexts/PublicAuthContext.tsx +34 -5
  10. package/src/contexts/_tests/CartContext.spec.tsx +210 -0
  11. package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
  12. package/src/data/queries/useBroadcasts.ts +151 -0
  13. package/src/data/queries/useCheckouts.ts +83 -0
  14. package/src/data/queries/useMyBookings.ts +9 -1
  15. package/src/data/queries/useWebsite.ts +7 -0
  16. package/src/i18n/CONVENTION.md +184 -0
  17. package/src/i18n/_tests/translationKeys.spec.ts +153 -0
  18. package/src/i18n/de.json +1019 -0
  19. package/src/i18n/en.json +1019 -0
  20. package/src/i18n/index.ts +125 -0
  21. package/src/index.ts +21 -0
  22. package/src/provider/ForgeAppProvider.tsx +2 -1
  23. package/src/provider/SiteConfigProvider.tsx +11 -0
  24. package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
  25. package/src/ui/format/_tests/pwyw.spec.ts +65 -8
  26. package/src/ui/format/membershipPwyw.ts +164 -0
  27. package/src/ui/format/pwyw.ts +37 -0
  28. package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
  29. package/src/ui/headless/broadcast/broadcastState.ts +158 -0
  30. package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
  31. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
  32. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
  33. package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
  34. package/src/ui/headless/checkout/useCartResume.ts +340 -0
  35. package/src/ui/headless/checkout/useCheckout.ts +9 -2
  36. package/src/ui/headless/event/useEventCheckout.ts +8 -13
  37. package/src/ui/headless/index.ts +32 -0
  38. package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
  39. package/src/ui/index.ts +38 -0
  40. package/src/ui/media/CallHelpHint.tsx +87 -0
  41. package/src/ui/media/CallStage.tsx +542 -0
  42. package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
  43. package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
  44. package/src/ui/media/_tests/callState.spec.ts +452 -0
  45. package/src/ui/media/_tests/fakeNode.ts +178 -0
  46. package/src/ui/media/bookingSession.tsx +194 -0
  47. package/src/ui/media/callState.ts +341 -0
  48. package/src/ui/media/index.ts +135 -0
  49. package/src/ui/shell/TribeNestApp.tsx +36 -27
  50. package/src/ui/styled/AccountDashboard.tsx +289 -96
  51. package/src/ui/styled/AiAgentWidget.tsx +10 -6
  52. package/src/ui/styled/AudioPlayer.tsx +14 -6
  53. package/src/ui/styled/BlogCategory.tsx +4 -2
  54. package/src/ui/styled/BlogComments.tsx +27 -20
  55. package/src/ui/styled/BlogList.tsx +5 -3
  56. package/src/ui/styled/BlogPost.tsx +14 -11
  57. package/src/ui/styled/BroadcastWatch.tsx +107 -0
  58. package/src/ui/styled/BundleConfirmation.tsx +32 -23
  59. package/src/ui/styled/CancellationTerms.tsx +3 -1
  60. package/src/ui/styled/Cart.tsx +32 -14
  61. package/src/ui/styled/ChatRoom.tsx +65 -20
  62. package/src/ui/styled/Checkout.tsx +151 -89
  63. package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
  64. package/src/ui/styled/CoachingBooking.tsx +71 -44
  65. package/src/ui/styled/CoachingConfirmation.tsx +56 -19
  66. package/src/ui/styled/CoachingDetail.tsx +15 -11
  67. package/src/ui/styled/CohortPage.tsx +10 -8
  68. package/src/ui/styled/ConfirmSubscription.tsx +14 -8
  69. package/src/ui/styled/ContactForm.tsx +31 -12
  70. package/src/ui/styled/CookieConsent.tsx +19 -15
  71. package/src/ui/styled/CourseAccess.tsx +54 -47
  72. package/src/ui/styled/CourseCheckout.tsx +56 -38
  73. package/src/ui/styled/CourseConfirmation.tsx +39 -17
  74. package/src/ui/styled/CourseDetail.tsx +21 -15
  75. package/src/ui/styled/CoursesGrid.tsx +4 -2
  76. package/src/ui/styled/CurrencySwitcher.tsx +4 -1
  77. package/src/ui/styled/DiscountCode.tsx +22 -18
  78. package/src/ui/styled/DonationButton.tsx +52 -17
  79. package/src/ui/styled/DonationPage.tsx +4 -4
  80. package/src/ui/styled/EmailListForm.tsx +22 -9
  81. package/src/ui/styled/EventConfirmation.tsx +37 -14
  82. package/src/ui/styled/EventCountdown.tsx +10 -6
  83. package/src/ui/styled/EventDetail.tsx +8 -6
  84. package/src/ui/styled/EventSeriesDetail.tsx +18 -13
  85. package/src/ui/styled/EventTickets.tsx +232 -108
  86. package/src/ui/styled/EventWaitlist.tsx +42 -33
  87. package/src/ui/styled/EventsList.tsx +15 -13
  88. package/src/ui/styled/ForgotPasswordForm.tsx +17 -12
  89. package/src/ui/styled/FormRenderer.tsx +12 -7
  90. package/src/ui/styled/InstallBanner.tsx +21 -18
  91. package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
  92. package/src/ui/styled/InvoicePayment.tsx +37 -16
  93. package/src/ui/styled/LeadMagnet.tsx +9 -7
  94. package/src/ui/styled/LiveBroadcastList.tsx +171 -0
  95. package/src/ui/styled/Loading.tsx +7 -1
  96. package/src/ui/styled/LoginForm.tsx +32 -14
  97. package/src/ui/styled/MembershipCheckout.tsx +341 -49
  98. package/src/ui/styled/MembershipTiers.tsx +34 -18
  99. package/src/ui/styled/OfferButton.tsx +33 -13
  100. package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
  101. package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
  102. package/src/ui/styled/PodcastEpisode.tsx +20 -9
  103. package/src/ui/styled/PodcastList.tsx +9 -3
  104. package/src/ui/styled/PodcastShow.tsx +29 -7
  105. package/src/ui/styled/PostsFeed.tsx +10 -8
  106. package/src/ui/styled/PresaleCode.tsx +8 -11
  107. package/src/ui/styled/ProductBrowseNav.tsx +21 -11
  108. package/src/ui/styled/ProductDetail.tsx +58 -28
  109. package/src/ui/styled/ReactionBar.tsx +4 -2
  110. package/src/ui/styled/ReplayList.tsx +24 -13
  111. package/src/ui/styled/ResetPasswordForm.tsx +32 -15
  112. package/src/ui/styled/ResumeCart.tsx +289 -0
  113. package/src/ui/styled/ReviewForm.tsx +52 -58
  114. package/src/ui/styled/SignupForm.tsx +34 -16
  115. package/src/ui/styled/TicketTransfer.tsx +55 -54
  116. package/src/ui/styled/UserMenu.tsx +16 -14
  117. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
  118. package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
  119. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
  120. package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
  121. package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
  122. package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
  123. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
  124. package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
  125. package/src/ui/styled/community/CommunityComposer.tsx +182 -3
  126. package/src/ui/styled/community/CommunityFeed.tsx +36 -51
  127. package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
  128. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
  129. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
@@ -1,3 +1,4 @@
1
+ import { useForgeT } from "../../i18n";
1
2
  import { useInvoicePayment } from "../headless/invoice/useInvoicePayment";
2
3
  import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
3
4
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
@@ -26,13 +27,15 @@ const formatDate = (value: string | number | Date): string =>
26
27
  * or Paystack's inline modal behind a pay button.
27
28
  */
28
29
  export function InvoicePayment({ invoiceId, formatAmount, returnPath }: InvoicePaymentProps) {
30
+ const t = useForgeT();
29
31
  const theme = useForgeTheme();
30
32
  const renderPayment = usePaymentRenderer();
31
33
  const { invoice, downloadPdf, clientSecret, returnUrl, isStarting, error, taxQuote, isPaystack, canOpenPaystack, openPaystackCheckout } =
32
34
  useInvoicePayment(invoiceId, { returnPath });
33
35
 
34
36
  if (invoice.isLoading) return <Loading fullPage />;
35
- if (invoice.error || !invoice.data) return <p style={{ color: theme.colors.text }}>Invoice not found.</p>;
37
+ if (invoice.error || !invoice.data)
38
+ return <p style={{ color: theme.colors.text }}>{t("forge.invoice_payment.not_found")}</p>;
36
39
 
37
40
  const inv = invoice.data;
38
41
  const isPaid = inv.status === "paid" || !!inv.paidAt;
@@ -72,7 +75,9 @@ export function InvoicePayment({ invoiceId, formatAmount, returnPath }: InvoiceP
72
75
  <div style={{ maxWidth: 640, margin: "0 auto", color: theme.colors.text }}>
73
76
  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 16 }}>
74
77
  <div>
75
- <h1 style={{ fontSize: 26, fontWeight: 800 }}>Invoice {inv.invoiceNumber}</h1>
78
+ <h1 style={{ fontSize: 26, fontWeight: 800 }}>
79
+ {t("forge.invoice_payment.title", { invoice_number: inv.invoiceNumber })}
80
+ </h1>
76
81
  <p style={{ opacity: 0.7, fontSize: 14 }}>{inv.profileName}</p>
77
82
  </div>
78
83
  <button
@@ -88,18 +93,26 @@ export function InvoicePayment({ invoiceId, formatAmount, returnPath }: InvoiceP
88
93
  fontSize: 14,
89
94
  }}
90
95
  >
91
- {downloadPdf.isPending ? "Preparing…" : "Download PDF"}
96
+ {downloadPdf.isPending ? t("forge.invoice_payment.preparing_pdf") : t("forge.invoice_payment.download_pdf")}
92
97
  </button>
93
98
  </div>
94
99
 
95
100
  <div style={{ fontSize: 14, opacity: 0.85, marginBottom: 16 }}>
96
101
  <div>
97
- Billed to: {inv.clientName}
98
- {inv.clientCompany ? ` · ${inv.clientCompany}` : ""}
102
+ {inv.clientCompany
103
+ ? t("forge.invoice_payment.billed_to_company", {
104
+ client_name: inv.clientName,
105
+ client_company: inv.clientCompany,
106
+ })
107
+ : t("forge.invoice_payment.billed_to", { client_name: inv.clientName })}
99
108
  </div>
100
109
  <div>
101
- Issued: {formatDate(inv.issueDate)}
102
- {inv.dueDate ? ` · Due: ${formatDate(inv.dueDate)}` : ""}
110
+ {inv.dueDate
111
+ ? t("forge.invoice_payment.issued_due", {
112
+ issue_date: formatDate(inv.issueDate),
113
+ due_date: formatDate(inv.dueDate),
114
+ })
115
+ : t("forge.invoice_payment.issued", { issue_date: formatDate(inv.issueDate) })}
103
116
  </div>
104
117
  </div>
105
118
 
@@ -123,7 +136,10 @@ export function InvoicePayment({ invoiceId, formatAmount, returnPath }: InvoiceP
123
136
  >
124
137
  <span>
125
138
  {li.description}
126
- <span style={{ opacity: 0.6 }}> × {li.quantity}</span>
139
+ <span style={{ opacity: 0.6 }}>
140
+ {" "}
141
+ {t("forge.invoice_payment.line_item_quantity", { quantity: li.quantity })}
142
+ </span>
127
143
  </span>
128
144
  <span>{money(Number(li.amount))}</span>
129
145
  </div>
@@ -142,13 +158,13 @@ export function InvoicePayment({ invoiceId, formatAmount, returnPath }: InvoiceP
142
158
  opacity: 0.8,
143
159
  }}
144
160
  >
145
- <span>Tax</span>
161
+ <span>{t("forge.invoice_payment.tax")}</span>
146
162
  <span>{taxSummary.formattedTax}</span>
147
163
  </div>
148
164
  ) : null;
149
165
  })()}
150
166
  <div style={{ display: "flex", justifyContent: "space-between", padding: "12px 14px", fontWeight: 700 }}>
151
- <span>Total</span>
167
+ <span>{t("forge.invoice_payment.total")}</span>
152
168
  <span style={{ color: theme.colors.primary, textAlign: "right" }}>
153
169
  {money(Number(inv.total))}
154
170
  {(() => {
@@ -168,18 +184,23 @@ export function InvoicePayment({ invoiceId, formatAmount, returnPath }: InvoiceP
168
184
  {inv.notes && <p style={{ fontSize: 13, opacity: 0.7, marginBottom: 16 }}>{inv.notes}</p>}
169
185
 
170
186
  {isPaid ? (
171
- <p style={{ fontWeight: 700, color: theme.colors.primary }}>This invoice has been paid. Thank you!</p>
187
+ <p style={{ fontWeight: 700, color: theme.colors.primary }}>{t("forge.invoice_payment.paid_notice")}</p>
172
188
  ) : (
173
189
  <div>
174
- <h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 12 }}>Payment</h2>
190
+ <h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 12 }}>
191
+ {t("forge.invoice_payment.payment_heading")}
192
+ </h2>
175
193
  {error && <p style={{ color: "#ef4444", fontSize: 14, marginBottom: 8 }}>{error}</p>}
176
194
  {/* Paystack brings its own modal, so it is checked before the Stripe
177
195
  renderer and needs none of it. */}
178
196
  {isPaystack ? (
179
197
  canOpenPaystack ? (
180
- <PaystackPayButton onPay={openPaystackCheckout} label={`Pay ${money(Number(inv.total))}`} />
198
+ <PaystackPayButton
199
+ onPay={openPaystackCheckout}
200
+ label={t("forge.invoice_payment.pay_amount", { amount: money(Number(inv.total)) })}
201
+ />
181
202
  ) : (
182
- <Loading label="Preparing payment…" size={22} />
203
+ <Loading label={t("forge.invoice_payment.preparing_payment")} size={22} />
183
204
  )
184
205
  ) : clientSecret ? (
185
206
  renderPayment?.({
@@ -191,9 +212,9 @@ export function InvoicePayment({ invoiceId, formatAmount, returnPath }: InvoiceP
191
212
  ) : (
192
213
  <>
193
214
  {isStarting ? (
194
- <Loading label="Preparing payment…" size={22} />
215
+ <Loading label={t("forge.invoice_payment.preparing_payment")} size={22} />
195
216
  ) : (
196
- <p style={{ opacity: 0.7 }}>Payment is not available right now.</p>
217
+ <p style={{ opacity: 0.7 }}>{t("forge.invoice_payment.payment_unavailable")}</p>
197
218
  )}
198
219
  </>
199
220
  )}
@@ -1,5 +1,6 @@
1
1
  import { useState } from "react";
2
2
  import { useLeadMagnetConfirm, useTrackLeadMagnetDownload } from "../../data/queries/useLeadMagnet";
3
+ import { useForgeT } from "../../i18n";
3
4
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
4
5
  import { Loading } from "./Loading";
5
6
 
@@ -12,6 +13,7 @@ export interface LeadMagnetProps {
12
13
 
13
14
  /** Lead magnet delivery / download screen. Built on `useLeadMagnetConfirm` + `useTrackLeadMagnetDownload`. */
14
15
  export function LeadMagnet({ leadMagnetId, subscriberId }: LeadMagnetProps) {
16
+ const t = useForgeT();
15
17
  const theme = useForgeTheme();
16
18
  const trackDownload = useTrackLeadMagnetDownload();
17
19
  const [isDownloading, setIsDownloading] = useState(false);
@@ -57,8 +59,10 @@ export function LeadMagnet({ leadMagnetId, subscriberId }: LeadMagnetProps) {
57
59
  if (state === "error") {
58
60
  return (
59
61
  <div style={wrap}>
60
- <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>Oops!</h1>
61
- <p>{!leadMagnetId || !subscriberId ? "Invalid link" : "This link is invalid or has expired."}</p>
62
+ <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>{t("forge.lead_magnet.error_title")}</h1>
63
+ <p>
64
+ {!leadMagnetId || !subscriberId ? t("forge.lead_magnet.invalid_link") : t("forge.lead_magnet.expired_link")}
65
+ </p>
62
66
  </div>
63
67
  );
64
68
  }
@@ -67,11 +71,9 @@ export function LeadMagnet({ leadMagnetId, subscriberId }: LeadMagnetProps) {
67
71
  <div style={wrap}>
68
72
  {profile && <p style={{ fontSize: 14, opacity: 0.7, marginBottom: 8 }}>{profile.name}</p>}
69
73
  <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>
70
- {leadMagnet?.thankYouMessage || "Thank you for subscribing!"}
74
+ {leadMagnet?.thankYouMessage || t("forge.lead_magnet.thank_you")}
71
75
  </h1>
72
- <p style={{ opacity: 0.8, marginBottom: 32 }}>
73
- Your download is ready. Click the button below to get your file.
74
- </p>
76
+ <p style={{ opacity: 0.8, marginBottom: 32 }}>{t("forge.lead_magnet.description")}</p>
75
77
  <button
76
78
  onClick={handleDownload}
77
79
  disabled={isDownloading}
@@ -86,7 +88,7 @@ export function LeadMagnet({ leadMagnetId, subscriberId }: LeadMagnetProps) {
86
88
  color: theme.colors.background,
87
89
  }}
88
90
  >
89
- {isDownloading ? "Preparing…" : "Download Now"}
91
+ {isDownloading ? t("forge.lead_magnet.preparing") : t("forge.lead_magnet.download")}
90
92
  </button>
91
93
  </div>
92
94
  );
@@ -0,0 +1,171 @@
1
+ import { Calendar, Play } from "lucide-react";
2
+ import { useLiveBroadcasts } from "../../data/queries/useBroadcasts";
3
+ import type { ILiveBroadcast } from "../../types/models";
4
+ import { formatDateTime } from "../../utils/formatDateTime";
5
+ import { useForgeT } from "../../i18n";
6
+ import { useForgeTheme } from "../theme/ForgeThemeProvider";
7
+ import { broadcastListStatus } from "../headless/broadcast/broadcastState";
8
+ import { Loading } from "./Loading";
9
+
10
+ export interface LiveBroadcastListProps {
11
+ /** Host-owned navigation. Falls back to a full page load. */
12
+ navigate?: (href: string) => void;
13
+ /** Where a card leads. Default `/i/live/:broadcastId`. */
14
+ broadcastPath?: (broadcastId: string) => string;
15
+ }
16
+
17
+ /**
18
+ * What is on now: the tenant's live, upcoming and just-ended broadcasts.
19
+ *
20
+ * The list itself is public. The paywall is on the WATCH page, where a gated
21
+ * broadcast asks for a ticket before a single frame is mounted, so a card here
22
+ * gives away nothing but a title and a start time.
23
+ */
24
+ export function LiveBroadcastList({ navigate, broadcastPath }: LiveBroadcastListProps) {
25
+ const t = useForgeT();
26
+ const theme = useForgeTheme();
27
+ const { data: broadcasts, isLoading, error } = useLiveBroadcasts();
28
+
29
+ const href = (id: string) => (broadcastPath ? broadcastPath(id) : `/i/live/${id}`);
30
+ const go = (id: string) => {
31
+ const path = href(id);
32
+ if (navigate) navigate(path);
33
+ else if (typeof window !== "undefined") window.location.assign(path);
34
+ };
35
+
36
+ const centered = (title: string, body: string) => (
37
+ <div
38
+ style={{
39
+ display: "flex",
40
+ alignItems: "center",
41
+ justifyContent: "center",
42
+ minHeight: 400,
43
+ color: theme.colors.text,
44
+ fontFamily: theme.fontFamily,
45
+ }}
46
+ >
47
+ <div style={{ textAlign: "center" }}>
48
+ <h3 style={{ fontSize: 18, fontWeight: 600, marginBottom: 8 }}>{title}</h3>
49
+ <p style={{ opacity: 0.8 }}>{body}</p>
50
+ </div>
51
+ </div>
52
+ );
53
+
54
+ if (isLoading) return <Loading fullPage />;
55
+
56
+ if (error) {
57
+ return centered(t("forge.live_broadcast_list.error_title"), t("forge.live_broadcast_list.error_body"));
58
+ }
59
+
60
+ if (!broadcasts || broadcasts.length === 0) {
61
+ return centered(t("forge.live_broadcast_list.empty_title"), t("forge.live_broadcast_list.empty_body"));
62
+ }
63
+
64
+ return (
65
+ <div
66
+ style={{
67
+ width: "100%",
68
+ padding: "24px 16px",
69
+ backgroundColor: theme.colors.background,
70
+ color: theme.colors.text,
71
+ fontFamily: theme.fontFamily,
72
+ }}
73
+ >
74
+ <div style={{ maxWidth: 1280, margin: "0 auto" }}>
75
+ <div style={{ marginBottom: 32 }}>
76
+ <h1 style={{ fontSize: 32, fontWeight: 700, marginBottom: 8, fontFamily: theme.headingFontFamily }}>
77
+ {t("forge.live_broadcast_list.title")}
78
+ </h1>
79
+ <p style={{ opacity: 0.8 }}>{t("forge.live_broadcast_list.subtitle")}</p>
80
+ </div>
81
+
82
+ <div
83
+ style={{
84
+ display: "grid",
85
+ gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
86
+ gap: 24,
87
+ }}
88
+ >
89
+ {broadcasts.map((broadcast: ILiveBroadcast) => {
90
+ const status = broadcastListStatus(broadcast);
91
+ const thumb = broadcast.thumbnailUrl || broadcast.generatedThumbnailUrl;
92
+
93
+ return (
94
+ <button
95
+ key={broadcast.id}
96
+ type="button"
97
+ onClick={() => go(broadcast.id)}
98
+ data-testid="live-broadcast-card"
99
+ style={{
100
+ textAlign: "left",
101
+ padding: 0,
102
+ cursor: "pointer",
103
+ overflow: "hidden",
104
+ backgroundColor: theme.colors.background,
105
+ color: theme.colors.text,
106
+ borderRadius: theme.cornerRadius,
107
+ boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
108
+ border: `1px solid ${theme.colors.primary}30`,
109
+ }}
110
+ >
111
+ <div style={{ position: "relative" }}>
112
+ <div
113
+ style={{
114
+ aspectRatio: "16/9",
115
+ display: "flex",
116
+ alignItems: "center",
117
+ justifyContent: "center",
118
+ position: "relative",
119
+ background: `linear-gradient(135deg, ${theme.colors.primary}20, ${theme.colors.primary}10)`,
120
+ }}
121
+ >
122
+ {thumb && (
123
+ <img
124
+ src={thumb}
125
+ alt={broadcast.title}
126
+ style={{
127
+ width: "100%",
128
+ height: "100%",
129
+ objectFit: "cover",
130
+ position: "absolute",
131
+ top: 0,
132
+ left: 0,
133
+ }}
134
+ />
135
+ )}
136
+ <Play size={40} style={{ color: theme.colors.primary, position: "relative", zIndex: 1 }} />
137
+ </div>
138
+ <div
139
+ style={{
140
+ position: "absolute",
141
+ top: 8,
142
+ left: 8,
143
+ padding: "4px 8px",
144
+ borderRadius: 999,
145
+ fontSize: 12,
146
+ fontWeight: 600,
147
+ background: status.background,
148
+ color: status.color,
149
+ }}
150
+ >
151
+ {status.text}
152
+ </div>
153
+ </div>
154
+
155
+ <div style={{ padding: 16 }}>
156
+ <h3 style={{ fontWeight: 600, marginBottom: 8 }}>{broadcast.title}</h3>
157
+ {broadcast.startDate && (
158
+ <div style={{ display: "flex", alignItems: "center", gap: 4, fontSize: 13, opacity: 0.8 }}>
159
+ <Calendar size={14} />
160
+ <span>{formatDateTime(broadcast.startDate)}</span>
161
+ </div>
162
+ )}
163
+ </div>
164
+ </button>
165
+ );
166
+ })}
167
+ </div>
168
+ </div>
169
+ </div>
170
+ );
171
+ }
@@ -1,3 +1,4 @@
1
+ import { useForgeT } from "../../i18n";
1
2
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
2
3
 
3
4
  export interface LoadingProps {
@@ -14,6 +15,7 @@ const alpha = (hex: string, a: number) => hex + Math.round(a * 255).toString(16)
14
15
  /** Themed loading spinner — drop-in from `@tribe-nest/forge/ui`. */
15
16
  export function Loading({ size = 28, label, fullPage }: LoadingProps) {
16
17
  const theme = useForgeTheme();
18
+ const t = useForgeT();
17
19
  const border = Math.max(2, Math.round(size / 9));
18
20
 
19
21
  const content = (
@@ -45,7 +47,11 @@ export function Loading({ size = 28, label, fullPage }: LoadingProps) {
45
47
  }}
46
48
  />
47
49
  {label && <span style={{ fontSize: 14, opacity: 0.7 }}>{label}</span>}
48
- {!label && <span style={{ position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" }}>Loading</span>}
50
+ {!label && (
51
+ <span style={{ position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" }}>
52
+ {t("forge.loading.loading")}
53
+ </span>
54
+ )}
49
55
  </div>
50
56
  );
51
57
 
@@ -1,5 +1,6 @@
1
1
  import { useLoginFlow } from "../headless/auth/useLoginFlow";
2
2
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
3
+ import { useForgeT } from "../../i18n";
3
4
 
4
5
  export interface LoginFormProps {
5
6
  /** Called after a successful login. The shell typically redirects here. */
@@ -12,10 +13,21 @@ export interface LoginFormProps {
12
13
 
13
14
  /** Themed 2FA login form (credentials → emailed code), built on `useLoginFlow`. */
14
15
  export function LoginForm({ onSuccess, signupHref, forgotHref }: LoginFormProps) {
16
+ const t = useForgeT();
15
17
  const theme = useForgeTheme();
16
18
  const flow = useLoginFlow({ onSuccess });
17
19
 
18
20
  const card: React.CSSProperties = {
21
+ // `width: 100%` is what stops this collapsing to its content.
22
+ //
23
+ // The host layout is a COLUMN flex container, and an auto cross-axis margin
24
+ // disables `align-items: stretch`. So `maxWidth` alone left the card
25
+ // shrink-to-fit and the auto margins centred whatever that came to. Sign-in
26
+ // has the least content of the auth forms (two fields, short labels), so it
27
+ // collapsed the furthest and looked broken next to signup.
28
+ //
29
+ // The cap is unchanged: this fills the container UP TO 420 and no further.
30
+ width: "100%",
19
31
  maxWidth: 420,
20
32
  margin: "40px auto 0",
21
33
  padding: 24,
@@ -48,9 +60,9 @@ export function LoginForm({ onSuccess, signupHref, forgotHref }: LoginFormProps)
48
60
  if (flow.step === "code") {
49
61
  return (
50
62
  <div style={card}>
51
- <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 8 }}>Verify your email</h1>
63
+ <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 8 }}>{t("forge.login_form.verify_title")}</h1>
52
64
  <p style={{ fontSize: 14, opacity: 0.7, marginBottom: 24 }}>
53
- We sent a 6-digit code to <strong>{flow.email}</strong>
65
+ {t("forge.login_form.code_sent_to")} <strong>{flow.email}</strong>
54
66
  </p>
55
67
  {flow.error && <p style={{ color: "#ef4444", marginBottom: 16 }}>{flow.error}</p>}
56
68
  <form
@@ -71,12 +83,12 @@ export function LoginForm({ onSuccess, signupHref, forgotHref }: LoginFormProps)
71
83
  style={{ ...input, textAlign: "center", fontSize: 24, letterSpacing: "0.4em" }}
72
84
  />
73
85
  <button type="submit" disabled={flow.isSubmitting || flow.code.length !== 6} style={button}>
74
- {flow.isSubmitting ? "Verifying…" : "Verify & Sign In"}
86
+ {flow.isSubmitting ? t("forge.login_form.verifying") : t("forge.login_form.verify_submit")}
75
87
  </button>
76
88
  </form>
77
89
  <div style={{ display: "flex", justifyContent: "space-between", marginTop: 16, fontSize: 14 }}>
78
90
  <button type="button" onClick={flow.back} style={{ ...link, background: "none", border: "none", cursor: "pointer" }}>
79
- Back to login
91
+ {t("forge.login_form.back_to_login")}
80
92
  </button>
81
93
  <button
82
94
  type="button"
@@ -84,7 +96,7 @@ export function LoginForm({ onSuccess, signupHref, forgotHref }: LoginFormProps)
84
96
  disabled={flow.isSubmitting}
85
97
  style={{ ...link, background: "none", border: "none", cursor: "pointer" }}
86
98
  >
87
- Resend code
99
+ {t("forge.login_form.resend_code")}
88
100
  </button>
89
101
  </div>
90
102
  </div>
@@ -93,7 +105,7 @@ export function LoginForm({ onSuccess, signupHref, forgotHref }: LoginFormProps)
93
105
 
94
106
  return (
95
107
  <div style={card}>
96
- <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>Login</h1>
108
+ <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>{t("forge.login_form.title")}</h1>
97
109
  {flow.error && <p style={{ color: "#ef4444", marginBottom: 16 }}>{flow.error}</p>}
98
110
  <form
99
111
  onSubmit={(e) => {
@@ -103,30 +115,36 @@ export function LoginForm({ onSuccess, signupHref, forgotHref }: LoginFormProps)
103
115
  style={{ display: "flex", flexDirection: "column", gap: 16 }}
104
116
  >
105
117
  <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
106
- <label style={{ fontSize: 14 }}>Email</label>
107
- <input type="email" placeholder="Email" value={flow.email} onChange={(e) => flow.setEmail(e.target.value)} style={input} />
118
+ <label style={{ fontSize: 14 }}>{t("forge.login_form.email_label")}</label>
119
+ <input
120
+ type="email"
121
+ placeholder={t("forge.login_form.email_placeholder")}
122
+ value={flow.email}
123
+ onChange={(e) => flow.setEmail(e.target.value)}
124
+ style={input}
125
+ />
108
126
  </div>
109
127
  <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
110
- <label style={{ fontSize: 14 }}>Password</label>
128
+ <label style={{ fontSize: 14 }}>{t("forge.login_form.password_label")}</label>
111
129
  <input
112
130
  type="password"
113
- placeholder="Password"
131
+ placeholder={t("forge.login_form.password_placeholder")}
114
132
  value={flow.password}
115
133
  onChange={(e) => flow.setPassword(e.target.value)}
116
134
  style={input}
117
135
  />
118
136
  <a href={forgotHref} style={{ ...link, fontSize: 13 }}>
119
- Forgot password?
137
+ {t("forge.login_form.forgot_password")}
120
138
  </a>
121
139
  </div>
122
140
  <button type="submit" disabled={flow.isSubmitting} style={button}>
123
- {flow.isSubmitting ? "Sending code…" : "Login"}
141
+ {flow.isSubmitting ? t("forge.login_form.sending_code") : t("forge.login_form.submit")}
124
142
  </button>
125
143
  </form>
126
144
  <p style={{ marginTop: 16, fontSize: 14, textAlign: "center" }}>
127
- Don&apos;t have an account?{" "}
145
+ {t("forge.login_form.no_account")}{" "}
128
146
  <a href={signupHref} style={link}>
129
- Sign up
147
+ {t("forge.login_form.sign_up")}
130
148
  </a>
131
149
  </p>
132
150
  </div>