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