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