@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
@@ -4,6 +4,7 @@ import { readableTextOn } from "../theme/contrast";
4
4
  import { useAmountFormatter } from "../format/useFormatCurrency";
5
5
  import { Loading } from "./Loading";
6
6
  import { ConfirmationStage, ConfirmationCard, CheckSeal, WarnSeal, Perforation, ConfirmationRow, alpha } from "./Confirmation";
7
+ import { useForgeT } from "../../i18n";
7
8
  import { useEventOrderFinalize } from "../../data/queries/useFinalize";
8
9
  import { useEvent } from "../../data/queries/useEvents";
9
10
  import { AddToCalendar } from "./AddToCalendar";
@@ -43,6 +44,7 @@ export function EventConfirmation({
43
44
  explorePath = "/i/events",
44
45
  }: EventConfirmationProps) {
45
46
  const theme = useForgeTheme();
47
+ const t = useForgeT();
46
48
  const formatAmt = useAmountFormatter(formatAmount);
47
49
  const { data: order, isLoading } = useEventOrderFinalize(slug, orderId);
48
50
  const { data: event } = useEvent(slug);
@@ -53,7 +55,7 @@ export function EventConfirmation({
53
55
  if (isLoading) {
54
56
  return (
55
57
  <ConfirmationStage>
56
- <Loading label="Finalizing your order…" />
58
+ <Loading label={t("forge.event_confirmation.loading")} />
57
59
  </ConfirmationStage>
58
60
  );
59
61
  }
@@ -61,7 +63,7 @@ export function EventConfirmation({
61
63
  if (!order) {
62
64
  return (
63
65
  <ConfirmationStage>
64
- <p style={{ color: text }}>We couldn’t find your order.</p>
66
+ <p style={{ color: text }}>{t("forge.event_confirmation.not_found")}</p>
65
67
  </ConfirmationStage>
66
68
  );
67
69
  }
@@ -77,7 +79,7 @@ export function EventConfirmation({
77
79
  const location =
78
80
  event && event.type !== "virtual" && event.address
79
81
  ? [event.address.name, event.address.city, event.address.country].filter(Boolean).join(", ")
80
- : "Virtual event";
82
+ : t("forge.event_confirmation.virtual_location");
81
83
 
82
84
  return (
83
85
  <ConfirmationStage>
@@ -105,17 +107,25 @@ export function EventConfirmation({
105
107
  animationDelay: "0.15s",
106
108
  }}
107
109
  >
108
- {paid ? "E-Ticket" : refunded ? TICKET_ORDER_REFUNDED_COPY.eyebrow : "Order " + order.status.replace(/_/g, " ")}
110
+ {paid
111
+ ? t("forge.event_confirmation.eyebrow_paid")
112
+ : refunded
113
+ ? TICKET_ORDER_REFUNDED_COPY.eyebrow
114
+ : t("forge.event_confirmation.eyebrow_status", { status: order.status.replace(/_/g, " ") })}
109
115
  </p>
110
116
  <h1 className="cf-rise" style={{ fontSize: 29, fontWeight: 800, lineHeight: 1.12, margin: "0 0 8px", animationDelay: "0.22s" }}>
111
- {paid ? "You’re going! 🎟" : refunded ? TICKET_ORDER_REFUNDED_COPY.heading : "Order not completed"}
117
+ {paid
118
+ ? t("forge.event_confirmation.title_paid")
119
+ : refunded
120
+ ? TICKET_ORDER_REFUNDED_COPY.heading
121
+ : t("forge.event_confirmation.title_incomplete")}
112
122
  </h1>
113
123
  <p className="cf-rise" style={{ fontSize: 15, color: alpha(text, 0.7), margin: 0, animationDelay: "0.3s" }}>
114
124
  {paid
115
- ? `We’ve sent your tickets to ${order.email}.`
125
+ ? t("forge.event_confirmation.body_paid", { email: order.email })
116
126
  : refunded
117
127
  ? TICKET_ORDER_REFUNDED_COPY.body
118
- : `Your order is ${order.status.replace(/_/g, " ")}. If you were charged, contact support.`}
128
+ : t("forge.event_confirmation.body_incomplete", { status: order.status.replace(/_/g, " ") })}
119
129
  </p>
120
130
  </div>
121
131
 
@@ -162,7 +172,7 @@ export function EventConfirmation({
162
172
  <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
163
173
  <Ticket size={16} color={primary} />
164
174
  <span style={{ fontSize: 13, fontWeight: 700, letterSpacing: "0.08em", textTransform: "uppercase", color: alpha(text, 0.55) }}>
165
- Tickets
175
+ {t("forge.event_confirmation.tickets_heading")}
166
176
  </span>
167
177
  </div>
168
178
  <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
@@ -178,7 +188,7 @@ export function EventConfirmation({
178
188
 
179
189
  {Number(order.taxAmount || 0) > 0 && !order.taxInclusive && (
180
190
  <div style={{ display: "flex", justifyContent: "space-between", fontSize: 14, marginTop: 10, color: alpha(text, 0.6) }}>
181
- <span>Tax</span>
191
+ <span>{t("forge.event_confirmation.tax")}</span>
182
192
  <span>{formatAmt(Number(order.taxAmount), order.chargedCurrency || order.currency || undefined)}</span>
183
193
  </div>
184
194
  )}
@@ -195,7 +205,11 @@ export function EventConfirmation({
195
205
  {/* Never "Total paid" for money that has gone back, and never a bare
196
206
  "Total" for money that never moved. */}
197
207
  <span style={{ fontWeight: 700 }}>
198
- {paid ? "Total paid" : refunded ? TICKET_ORDER_REFUNDED_COPY.totalLabel : "Total"}
208
+ {paid
209
+ ? t("forge.event_confirmation.total_paid")
210
+ : refunded
211
+ ? TICKET_ORDER_REFUNDED_COPY.totalLabel
212
+ : t("forge.event_confirmation.total")}
199
213
  </span>
200
214
  {/* The exact charged total (charged currency) when present — never a re-converted catalog amount. */}
201
215
  <span style={{ fontSize: 18, fontWeight: 800, color: primary }}>
@@ -206,13 +220,22 @@ export function EventConfirmation({
206
220
  </div>
207
221
  {Number(order.taxAmount || 0) > 0 && order.taxInclusive && (
208
222
  <div style={{ textAlign: "right", fontSize: 12, color: alpha(text, 0.6), marginTop: 4 }}>
209
- Includes {formatAmt(Number(order.taxAmount), order.chargedCurrency || order.currency || undefined)} tax
223
+ {t("forge.event_confirmation.tax_included", {
224
+ amount: formatAmt(Number(order.taxAmount), order.chargedCurrency || order.currency || undefined),
225
+ })}
210
226
  </div>
211
227
  )}
212
228
 
213
229
  <dl style={{ display: "grid", gridTemplateColumns: "1fr auto", rowGap: 10, columnGap: 16, margin: "16px 0 0" }}>
214
- <ConfirmationRow label="Guest" value={`${order.firstName} ${order.lastName}`.trim() || "Not given"} />
215
- <ConfirmationRow label="Reference" value={`#${order.id.slice(0, 8).toUpperCase()}`} mono />
230
+ <ConfirmationRow
231
+ label={t("forge.event_confirmation.row_guest")}
232
+ value={`${order.firstName} ${order.lastName}`.trim() || t("forge.event_confirmation.not_given")}
233
+ />
234
+ <ConfirmationRow
235
+ label={t("forge.event_confirmation.row_reference")}
236
+ value={`#${order.id.slice(0, 8).toUpperCase()}`}
237
+ mono
238
+ />
216
239
  </dl>
217
240
  </div>
218
241
 
@@ -234,7 +257,7 @@ export function EventConfirmation({
234
257
  textDecoration: "none",
235
258
  }}
236
259
  >
237
- Browse more events <ArrowRight size={16} />
260
+ {t("forge.event_confirmation.explore")} <ArrowRight size={16} />
238
261
  </a>
239
262
  </div>
240
263
  </ConfirmationCard>
@@ -1,5 +1,6 @@
1
1
  import { Fragment, useEffect, useState } from "react";
2
2
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
3
+ import { useForgeT } from "../../i18n";
3
4
 
4
5
  export interface EventCountdownProps {
5
6
  /** Event start time (ISO). */
@@ -17,6 +18,7 @@ const a = (hex: string, alpha: number) => hex + Math.round(alpha * 255).toString
17
18
  */
18
19
  export function EventCountdown({ dateTime }: EventCountdownProps) {
19
20
  const theme = useForgeTheme();
21
+ const t = useForgeT();
20
22
  const [remaining, setRemaining] = useState<{
21
23
  days: number;
22
24
  hours: number;
@@ -62,22 +64,24 @@ export function EventCountdown({ dateTime }: EventCountdownProps) {
62
64
  if (remaining.isPast) {
63
65
  return (
64
66
  <div style={box}>
65
- <span style={{ color: theme.colors.text, fontWeight: 600 }}>This event has passed</span>
67
+ <span style={{ color: theme.colors.text, fontWeight: 600 }}>{t("forge.event_countdown.past")}</span>
66
68
  </div>
67
69
  );
68
70
  }
69
71
 
72
+ // `id` stays the React key: a translated label would change identity with the
73
+ // locale, and it is the unit that identifies the column, not its wording.
70
74
  const units = [
71
- { label: "Days", value: remaining.days },
72
- { label: "Hours", value: remaining.hours },
73
- { label: "Minutes", value: remaining.minutes },
74
- { label: "Seconds", value: remaining.seconds },
75
+ { id: "days", label: t("forge.event_countdown.unit_days"), value: remaining.days },
76
+ { id: "hours", label: t("forge.event_countdown.unit_hours"), value: remaining.hours },
77
+ { id: "minutes", label: t("forge.event_countdown.unit_minutes"), value: remaining.minutes },
78
+ { id: "seconds", label: t("forge.event_countdown.unit_seconds"), value: remaining.seconds },
75
79
  ];
76
80
 
77
81
  return (
78
82
  <div style={box}>
79
83
  {units.map((unit, index) => (
80
- <Fragment key={unit.label}>
84
+ <Fragment key={unit.id}>
81
85
  <div style={{ display: "flex", flexDirection: "column", alignItems: "center" }}>
82
86
  <div style={{ fontSize: 24, fontWeight: 700, color: theme.colors.primary }}>
83
87
  {String(unit.value).padStart(2, "0")}
@@ -1,6 +1,7 @@
1
1
  import { Calendar, MapPin } from "lucide-react";
2
2
  import type { IEvent } from "../../types/models";
3
3
  import { usePresaleCode } from "../headless/event/usePresaleCode";
4
+ import { useForgeT } from "../../i18n";
4
5
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
5
6
  import { useAmountFormatter } from "../format/useFormatCurrency";
6
7
  import { PriceDisplay } from "../format/PriceDisplay";
@@ -58,6 +59,7 @@ export function EventDetail({
58
59
  onComplete,
59
60
  }: EventDetailProps) {
60
61
  const t = useThemeTokens();
62
+ const tr = useForgeT();
61
63
  const fmt = useAmountFormatter(formatAmount);
62
64
  const pricesIncludeTax = usePricesIncludeTax();
63
65
  // Read the event THROUGH the presale field: it is the same query, fetched
@@ -67,14 +69,14 @@ export function EventDetail({
67
69
  const { event, isLoading } = presale;
68
70
 
69
71
  if (isLoading) return <Loading fullPage />;
70
- if (!event) return <p>Event not found.</p>;
72
+ if (!event) return <p>{tr("forge.event_detail.not_found")}</p>;
71
73
 
72
74
  const cover = event.media?.find((m) => m.type === "image")?.url ?? event.media?.[0]?.url;
73
75
  const minPrice = event.tickets.length ? Math.min(...event.tickets.map((t) => t.price)) : 0;
74
76
  const feeNote = describeBookingFee(event.bookingFee, fmt);
75
77
  const locationText =
76
78
  event.type === "virtual" || !event.address
77
- ? "Virtual"
79
+ ? tr("forge.event_detail.location_virtual")
78
80
  : [event.address.name, event.address.street, event.address.city, event.address.country].filter(Boolean).join(", ");
79
81
 
80
82
  return (
@@ -132,7 +134,7 @@ export function EventDetail({
132
134
 
133
135
  {event.description && (
134
136
  <>
135
- <h2 style={{ fontSize: 20, fontWeight: 700, margin: "24px 0 8px", fontFamily: t.headingFontFamily }}>About this event</h2>
137
+ <h2 style={{ fontSize: 20, fontWeight: 700, margin: "24px 0 8px", fontFamily: t.headingFontFamily }}>{tr("forge.event_detail.about_title")}</h2>
136
138
  <div dangerouslySetInnerHTML={{ __html: event.description }} style={{ lineHeight: 1.7, opacity: 0.9 }} />
137
139
  </>
138
140
  )}
@@ -164,13 +166,13 @@ export function EventDetail({
164
166
  <PresaleCodeField presale={presale} />
165
167
  {event.tickets.length === 0 && (
166
168
  <span style={{ fontSize: 13, opacity: 0.7 }}>
167
- Tickets for this event are presale only.
169
+ {tr("forge.event_detail.presale_only")}
168
170
  </span>
169
171
  )}
170
172
  {event.tickets.length > 0 && (
171
173
  <>
172
174
  <span style={{ fontWeight: 600 }}>
173
- From{" "}
175
+ {tr("forge.event_detail.price_from_label")}{" "}
174
176
  <PriceDisplay amount={minPrice} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={t.text} captionStyle={{ opacity: 0.65 }} inlineCaption />
175
177
  </span>
176
178
  {/* The cheapest tier is the number a buyer anchors on, and until
@@ -189,7 +191,7 @@ export function EventDetail({
189
191
  {event.tickets.length > 0 && (
190
192
  <EventTickets
191
193
  slug={slug}
192
- triggerText="Buy tickets"
194
+ triggerText={tr("forge.event_detail.buy_tickets")}
193
195
  formatAmount={fmt}
194
196
  finalisePath={finalisePath}
195
197
  onComplete={onComplete}
@@ -1,6 +1,7 @@
1
1
  import { Calendar, MapPin } from "lucide-react";
2
2
  import type { IEventSeries, IEventSeriesDate } from "../../types/models";
3
3
  import { useEventSeries } from "../../data/queries/useEventSeries";
4
+ import { useForgeT } from "../../i18n";
4
5
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
5
6
  import { Loading } from "./Loading";
6
7
 
@@ -54,9 +55,11 @@ const formatTime = (value: string, timezone?: string): string =>
54
55
  timeZoneName: "short",
55
56
  });
56
57
 
57
- const locationText = (date: IEventSeriesDate): string => {
58
- if (date.type === "virtual" || !date.address) return "Online";
59
- return [date.address.name, date.address.city, date.address.country].filter(Boolean).join(", ") || "Online";
58
+ // `onlineLabel` is passed in because this helper sits outside the component and
59
+ // so cannot call the translate hook itself.
60
+ const locationText = (date: IEventSeriesDate, onlineLabel: string): string => {
61
+ if (date.type === "virtual" || !date.address) return onlineLabel;
62
+ return [date.address.name, date.address.city, date.address.country].filter(Boolean).join(", ") || onlineLabel;
60
63
  };
61
64
 
62
65
  /**
@@ -94,10 +97,12 @@ export function EventSeriesDetail({
94
97
  style,
95
98
  }: EventSeriesDetailProps) {
96
99
  const t = useThemeTokens();
100
+ // Named `translate` rather than the usual `t`: `t` is already the theme tokens here.
101
+ const translate = useForgeT();
97
102
  const { data: series, isLoading } = useEventSeries(slug, { initialData: initialSeries });
98
103
 
99
104
  if (isLoading) return <Loading fullPage />;
100
- if (!series) return <p style={{ color: t.text }}>Series not found.</p>;
105
+ if (!series) return <p style={{ color: t.text }}>{translate("forge.event_series_detail.not_found")}</p>;
101
106
 
102
107
  const cover = series.media?.find((m) => m.type === "image")?.url ?? series.media?.[0]?.url;
103
108
  const href = eventHref ?? ((date: IEventSeriesDate) => `/events/${date.slug}`);
@@ -126,13 +131,13 @@ export function EventSeriesDetail({
126
131
  <p style={{ marginTop: 8, opacity: 0.75, display: "flex", alignItems: "center", gap: 8 }}>
127
132
  <Calendar size={16} />
128
133
  <span>
129
- {series.dateCount} dates
130
134
  {series.firstDateTime && series.lastDateTime
131
- ? ` · ${formatDate(series.firstDateTime, series.events[0]?.timezone)} to ${formatDate(
132
- series.lastDateTime,
133
- series.events[series.events.length - 1]?.timezone,
134
- )}`
135
- : ""}
135
+ ? translate("forge.event_series_detail.date_count_range", {
136
+ count: series.dateCount,
137
+ first_date: formatDate(series.firstDateTime, series.events[0]?.timezone),
138
+ last_date: formatDate(series.lastDateTime, series.events[series.events.length - 1]?.timezone),
139
+ })
140
+ : translate("forge.event_series_detail.date_count", { count: series.dateCount })}
136
141
  </span>
137
142
  </p>
138
143
 
@@ -183,7 +188,7 @@ export function EventSeriesDetail({
183
188
  <div style={{ fontWeight: 600 }}>{date.title}</div>
184
189
  <div style={{ fontSize: 13, opacity: 0.75, display: "flex", alignItems: "center", gap: 6 }}>
185
190
  <MapPin size={14} />
186
- <span>{locationText(date)}</span>
191
+ <span>{locationText(date, translate("forge.event_series_detail.location_online"))}</span>
187
192
  </div>
188
193
  </div>
189
194
 
@@ -200,12 +205,12 @@ export function EventSeriesDetail({
200
205
  padding: "4px 8px",
201
206
  }}
202
207
  >
203
- Cancelled
208
+ {translate("forge.event_series_detail.status_cancelled")}
204
209
  </span>
205
210
  )}
206
211
  {date.status === "sold_out" && (
207
212
  <span style={{ fontSize: 12, fontWeight: 700, letterSpacing: 0.6, textTransform: "uppercase" }}>
208
- Sold out
213
+ {translate("forge.event_series_detail.status_sold_out")}
209
214
  </span>
210
215
  )}
211
216
  </div>