@tribe-nest/forge 3.9.0 → 3.14.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 +2 -2
- package/src/contexts/CartContext.tsx +13 -1
- package/src/data/queries/useCoachingAvailability.ts +14 -3
- package/src/data/queries/useEventSeries.ts +42 -0
- package/src/data/queries/useEvents.ts +10 -1
- package/src/data/queries/useProducts.ts +104 -10
- package/src/data/queries/useSubscriptions.ts +53 -0
- package/src/index.ts +9 -0
- package/src/server/index.ts +20 -0
- package/src/types/models.ts +199 -4
- package/src/ui/format/bookingFee.ts +98 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +19 -0
- package/src/ui/headless/event/useEventCheckout.ts +61 -2
- package/src/ui/headless/index.ts +1 -0
- package/src/ui/headless/membership/MembershipGate.tsx +7 -2
- package/src/ui/headless/useVariantSelection.ts +138 -0
- package/src/ui/index.ts +11 -0
- package/src/ui/styled/AccountDashboard.tsx +45 -4
- package/src/ui/styled/CancellationTerms.tsx +70 -0
- package/src/ui/styled/CoachingBooking.tsx +10 -0
- package/src/ui/styled/CoachingDetail.tsx +4 -0
- package/src/ui/styled/EventDetail.tsx +12 -0
- package/src/ui/styled/EventSeriesDetail.tsx +222 -0
- package/src/ui/styled/EventTickets.tsx +58 -0
- package/src/ui/styled/ProductBrowseNav.tsx +212 -0
- package/src/ui/styled/ProductDetail.tsx +150 -91
- package/src/ui/styled/ProductGrid.tsx +16 -2
- package/src/utils/membershipAccess.ts +182 -0
|
@@ -500,6 +500,11 @@ function PaymentStep({
|
|
|
500
500
|
</button>
|
|
501
501
|
</div>
|
|
502
502
|
)}
|
|
503
|
+
{/* Above the tax row, because tax is charged ON the fee: the order total
|
|
504
|
+
the tax quote priced already contained it. By this step the number is
|
|
505
|
+
the SERVER's own — what it wrote to the order — so the itemisation
|
|
506
|
+
adds up to the amount that will actually be charged. */}
|
|
507
|
+
<BookingFeeLine c={c} fmt={fmt} />
|
|
503
508
|
{taxSummary?.mode === "exclusive" && (
|
|
504
509
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
|
|
505
510
|
<span style={{ fontSize: 14, opacity: 0.75 }}>Tax</span>
|
|
@@ -536,6 +541,55 @@ function FieldError({ children }: { children: ReactNode }) {
|
|
|
536
541
|
return <p style={{ color: "#ef4444", fontSize: 13, marginTop: 4 }}>{children}</p>;
|
|
537
542
|
}
|
|
538
543
|
|
|
544
|
+
/**
|
|
545
|
+
* The artist's booking fee, itemised ABOVE the total (1.4 disclosure).
|
|
546
|
+
*
|
|
547
|
+
* This is the line that did not exist. The fee was added to `total_amount` on
|
|
548
|
+
* the server and named on no surface, so a buyer choosing £50 of tickets was
|
|
549
|
+
* shown "Total £50" and charged £52.75 — and even after a discount, where
|
|
550
|
+
* `Subtotal` and `Discount` were both drawn, the total did not reconcile with
|
|
551
|
+
* them and nothing explained why. A mandatory charge disclosed only on the
|
|
552
|
+
* receipt is what the FTC's junk-fee rule, the CMA's guidance and the EU
|
|
553
|
+
* price-indication rules exist to prevent.
|
|
554
|
+
*
|
|
555
|
+
* Renders NOTHING when the fee is zero. The payload always arrives (so callers
|
|
556
|
+
* can rely on its shape), which means "this artist charges no fee" comes down as
|
|
557
|
+
* a zero — and a "+£0.00 booking fee" row would invent a charge that does not
|
|
558
|
+
* exist and hint that one might appear later. There is nothing to disclose when
|
|
559
|
+
* nothing is charged.
|
|
560
|
+
*
|
|
561
|
+
* The `Subtotal` row is conditional because `DiscountSummaryLines` above already
|
|
562
|
+
* draws one whenever a discount applied; without a discount it draws nothing, and
|
|
563
|
+
* a fee sitting on its own between a heading and a total has nothing to be a fee
|
|
564
|
+
* ON.
|
|
565
|
+
*/
|
|
566
|
+
function BookingFeeLine({ c, fmt }: { c: Checkout; fmt: (n: number) => string }) {
|
|
567
|
+
const theme = useForgeTheme();
|
|
568
|
+
if (!(c.bookingFeeAmount > 0)) return null;
|
|
569
|
+
const discountDrewSubtotal = c.coupon.discountAmount > 0;
|
|
570
|
+
|
|
571
|
+
return (
|
|
572
|
+
<div
|
|
573
|
+
data-testid="booking-fee-summary"
|
|
574
|
+
style={{ display: "flex", flexDirection: "column", gap: 4, marginBottom: 8 }}
|
|
575
|
+
>
|
|
576
|
+
{!discountDrewSubtotal && (
|
|
577
|
+
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 14 }}>
|
|
578
|
+
<span style={{ opacity: 0.75 }}>Subtotal</span>
|
|
579
|
+
<span style={{ opacity: 0.75 }}>{fmt(c.subTotal)}</span>
|
|
580
|
+
</div>
|
|
581
|
+
)}
|
|
582
|
+
<div
|
|
583
|
+
data-testid="booking-fee-line"
|
|
584
|
+
style={{ display: "flex", justifyContent: "space-between", fontSize: 14 }}
|
|
585
|
+
>
|
|
586
|
+
<span style={{ color: theme.colors.text, opacity: 0.75 }}>Booking fee</span>
|
|
587
|
+
<span style={{ color: theme.colors.text, opacity: 0.75 }}>{fmt(c.bookingFeeAmount)}</span>
|
|
588
|
+
</div>
|
|
589
|
+
</div>
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
539
593
|
// ── Shared bits ───────────────────────────────────────────────────────────────
|
|
540
594
|
function ActionBar({
|
|
541
595
|
c,
|
|
@@ -572,6 +626,10 @@ function ActionBar({
|
|
|
572
626
|
appliedCoupons={c.coupon.appliedCoupons}
|
|
573
627
|
fmt={fmt}
|
|
574
628
|
/>
|
|
629
|
+
{/* `Total` below is the figure the buyer reads as final while they are
|
|
630
|
+
still choosing quantities — so the fee has to be named HERE, not
|
|
631
|
+
three screens later. */}
|
|
632
|
+
<BookingFeeLine c={c} fmt={fmt} />
|
|
575
633
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
576
634
|
<span style={{ fontWeight: 600 }}>Total</span>
|
|
577
635
|
<span style={{ fontSize: 18, fontWeight: 700, color: theme.colors.primary }}>{fmt(c.totalAmount)}</span>
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
useProductCategories,
|
|
4
|
+
useProductCollections,
|
|
5
|
+
type IPublicProductCategory,
|
|
6
|
+
} from "../../data/queries/useProducts";
|
|
7
|
+
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
8
|
+
import { Loading } from "./Loading";
|
|
9
|
+
|
|
10
|
+
export interface ProductBrowseNavProps {
|
|
11
|
+
/** The currently browsed category, so the nav can mark it. */
|
|
12
|
+
activeCategoryId?: string;
|
|
13
|
+
activeCollectionId?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Where a category leads. Router-agnostic on purpose: Forge has no router,
|
|
16
|
+
* so the host decides the path.
|
|
17
|
+
*/
|
|
18
|
+
hrefForCategory?: (category: IPublicProductCategory) => string;
|
|
19
|
+
hrefForCollection?: (collection: { id: string; slug: string; title: string }) => string;
|
|
20
|
+
onSelectCategory?: (category: IPublicProductCategory) => void;
|
|
21
|
+
onSelectCollection?: (collection: { id: string; slug: string; title: string }) => void;
|
|
22
|
+
/** Hide collections and show only the category tree, or vice versa. */
|
|
23
|
+
show?: "all" | "categories" | "collections";
|
|
24
|
+
/** Show only collections the creator marked as featured. */
|
|
25
|
+
featuredCollectionsOnly?: boolean;
|
|
26
|
+
className?: string;
|
|
27
|
+
style?: React.CSSProperties;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Storefront navigation over the creator's OWN taxonomy.
|
|
32
|
+
*
|
|
33
|
+
* Two things it deliberately does not do:
|
|
34
|
+
*
|
|
35
|
+
* - **It does not show product types.** Music / Merch / Digital / Service is
|
|
36
|
+
* what a product IS; this is how the creator chose to shelve their catalogue.
|
|
37
|
+
* Mixing them into one list is the conflation the taxonomy split undid.
|
|
38
|
+
* - **It does not hide empty branches by counting children.** The counts are
|
|
39
|
+
* descendant-inclusive server-side, so a parent with products only in its
|
|
40
|
+
* grandchildren still reads as non-empty — which is correct, because browsing
|
|
41
|
+
* it returns those products.
|
|
42
|
+
*/
|
|
43
|
+
export function ProductBrowseNav({
|
|
44
|
+
activeCategoryId,
|
|
45
|
+
activeCollectionId,
|
|
46
|
+
hrefForCategory,
|
|
47
|
+
hrefForCollection,
|
|
48
|
+
onSelectCategory,
|
|
49
|
+
onSelectCollection,
|
|
50
|
+
show = "all",
|
|
51
|
+
featuredCollectionsOnly = false,
|
|
52
|
+
className,
|
|
53
|
+
style,
|
|
54
|
+
}: ProductBrowseNavProps) {
|
|
55
|
+
const t = useThemeTokens();
|
|
56
|
+
const categoriesQuery = useProductCategories();
|
|
57
|
+
const collectionsQuery = useProductCollections();
|
|
58
|
+
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
59
|
+
|
|
60
|
+
const showCategories = show === "all" || show === "categories";
|
|
61
|
+
const showCollections = show === "all" || show === "collections";
|
|
62
|
+
|
|
63
|
+
if (categoriesQuery.isLoading || collectionsQuery.isLoading) return <Loading />;
|
|
64
|
+
|
|
65
|
+
const categories = categoriesQuery.data ?? [];
|
|
66
|
+
const collections = (collectionsQuery.data ?? []).filter((c) =>
|
|
67
|
+
featuredCollectionsOnly ? c.isFeatured : true,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// A creator who has authored no taxonomy gets no nav at all rather than an
|
|
71
|
+
// empty heading — the storefront should look unchanged until they use it.
|
|
72
|
+
if (!categories.length && !collections.length) return null;
|
|
73
|
+
|
|
74
|
+
const toggle = (id: string) =>
|
|
75
|
+
setExpanded((prev) => {
|
|
76
|
+
const next = new Set(prev);
|
|
77
|
+
if (next.has(id)) next.delete(id);
|
|
78
|
+
else next.add(id);
|
|
79
|
+
return next;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const renderCategory = (category: IPublicProductCategory, depth: number): React.ReactNode => {
|
|
83
|
+
const children = category.children ?? [];
|
|
84
|
+
const isActive = category.id === activeCategoryId;
|
|
85
|
+
// A branch containing the active category opens so the reader can see
|
|
86
|
+
// where they are, without needing to remember the path.
|
|
87
|
+
const containsActive = (node: IPublicProductCategory): boolean =>
|
|
88
|
+
node.id === activeCategoryId || (node.children ?? []).some(containsActive);
|
|
89
|
+
const isOpen = expanded.has(category.id) || containsActive(category);
|
|
90
|
+
|
|
91
|
+
const label = (
|
|
92
|
+
<>
|
|
93
|
+
<span>{category.title}</span>
|
|
94
|
+
<span style={{ color: t.muted, fontSize: 12, marginLeft: 8 }}>{category.productCount}</span>
|
|
95
|
+
</>
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const linkStyle: React.CSSProperties = {
|
|
99
|
+
display: "inline-flex",
|
|
100
|
+
alignItems: "baseline",
|
|
101
|
+
color: isActive ? t.primary : t.text,
|
|
102
|
+
fontWeight: isActive ? 600 : 400,
|
|
103
|
+
textDecoration: "none",
|
|
104
|
+
background: "none",
|
|
105
|
+
border: "none",
|
|
106
|
+
padding: 0,
|
|
107
|
+
cursor: "pointer",
|
|
108
|
+
font: "inherit",
|
|
109
|
+
textAlign: "left",
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const href = hrefForCategory?.(category);
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<li key={category.id} style={{ paddingLeft: depth * 14, listStyle: "none", marginBottom: 6 }}>
|
|
116
|
+
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
|
117
|
+
{children.length > 0 && (
|
|
118
|
+
<button
|
|
119
|
+
type="button"
|
|
120
|
+
onClick={() => toggle(category.id)}
|
|
121
|
+
aria-expanded={isOpen}
|
|
122
|
+
aria-label={isOpen ? `Collapse ${category.title}` : `Expand ${category.title}`}
|
|
123
|
+
style={{
|
|
124
|
+
background: "none",
|
|
125
|
+
border: "none",
|
|
126
|
+
cursor: "pointer",
|
|
127
|
+
color: t.muted,
|
|
128
|
+
padding: 0,
|
|
129
|
+
lineHeight: 1,
|
|
130
|
+
width: 12,
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
{isOpen ? "−" : "+"}
|
|
134
|
+
</button>
|
|
135
|
+
)}
|
|
136
|
+
{children.length === 0 && <span style={{ width: 12 }} />}
|
|
137
|
+
|
|
138
|
+
{href ? (
|
|
139
|
+
<a href={href} style={linkStyle} aria-current={isActive ? "page" : undefined}>
|
|
140
|
+
{label}
|
|
141
|
+
</a>
|
|
142
|
+
) : (
|
|
143
|
+
<button type="button" style={linkStyle} onClick={() => onSelectCategory?.(category)}>
|
|
144
|
+
{label}
|
|
145
|
+
</button>
|
|
146
|
+
)}
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
{isOpen && children.length > 0 && (
|
|
150
|
+
<ul style={{ margin: "6px 0 0", padding: 0 }}>
|
|
151
|
+
{children.map((child) => renderCategory(child, depth + 1))}
|
|
152
|
+
</ul>
|
|
153
|
+
)}
|
|
154
|
+
</li>
|
|
155
|
+
);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const headingStyle: React.CSSProperties = {
|
|
159
|
+
color: t.muted,
|
|
160
|
+
fontSize: 12,
|
|
161
|
+
letterSpacing: "0.08em",
|
|
162
|
+
textTransform: "uppercase",
|
|
163
|
+
margin: "0 0 10px",
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<nav className={className} style={{ display: "grid", gap: 24, ...style }} aria-label="Browse the store">
|
|
168
|
+
{showCategories && categories.length > 0 && (
|
|
169
|
+
<div>
|
|
170
|
+
<h2 style={headingStyle}>Categories</h2>
|
|
171
|
+
<ul style={{ margin: 0, padding: 0 }}>{categories.map((c) => renderCategory(c, 0))}</ul>
|
|
172
|
+
</div>
|
|
173
|
+
)}
|
|
174
|
+
|
|
175
|
+
{showCollections && collections.length > 0 && (
|
|
176
|
+
<div>
|
|
177
|
+
<h2 style={headingStyle}>Collections</h2>
|
|
178
|
+
<ul style={{ margin: 0, padding: 0 }}>
|
|
179
|
+
{collections.map((collection) => {
|
|
180
|
+
const isActive = collection.id === activeCollectionId;
|
|
181
|
+
const href = hrefForCollection?.(collection);
|
|
182
|
+
const linkStyle: React.CSSProperties = {
|
|
183
|
+
color: isActive ? t.primary : t.text,
|
|
184
|
+
fontWeight: isActive ? 600 : 400,
|
|
185
|
+
textDecoration: "none",
|
|
186
|
+
background: "none",
|
|
187
|
+
border: "none",
|
|
188
|
+
padding: 0,
|
|
189
|
+
cursor: "pointer",
|
|
190
|
+
font: "inherit",
|
|
191
|
+
};
|
|
192
|
+
return (
|
|
193
|
+
<li key={collection.id} style={{ listStyle: "none", marginBottom: 6 }}>
|
|
194
|
+
{href ? (
|
|
195
|
+
<a href={href} style={linkStyle} aria-current={isActive ? "page" : undefined}>
|
|
196
|
+
{collection.title}
|
|
197
|
+
</a>
|
|
198
|
+
) : (
|
|
199
|
+
<button type="button" style={linkStyle} onClick={() => onSelectCollection?.(collection)}>
|
|
200
|
+
{collection.title}
|
|
201
|
+
</button>
|
|
202
|
+
)}
|
|
203
|
+
<span style={{ color: t.muted, fontSize: 12, marginLeft: 8 }}>{collection.productCount}</span>
|
|
204
|
+
</li>
|
|
205
|
+
);
|
|
206
|
+
})}
|
|
207
|
+
</ul>
|
|
208
|
+
</div>
|
|
209
|
+
)}
|
|
210
|
+
</nav>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { Play, Pause, Music, Star } from "lucide-react";
|
|
3
|
-
import {
|
|
3
|
+
import { ProductType } from "../../types/models";
|
|
4
4
|
import type { IPublicProduct, IPublicProductTrack, IPublicProductVariant } from "../../types/models";
|
|
5
|
+
import { useVariantSelection } from "../headless/useVariantSelection";
|
|
5
6
|
import { useGetProduct } from "../../data/queries/useProducts";
|
|
6
7
|
import { useCart } from "../../contexts/CartContext";
|
|
7
8
|
import { useAudioPlayer } from "../../contexts/AudioPlayerContext";
|
|
@@ -27,18 +28,34 @@ export interface ProductDetailProps {
|
|
|
27
28
|
style?: React.CSSProperties;
|
|
28
29
|
/** Server-fetched product used as initial data (SSR), avoids client loading spinner. */
|
|
29
30
|
initialProduct?: IPublicProduct;
|
|
31
|
+
/**
|
|
32
|
+
* Make the product's categories/collections links. Router-agnostic, like
|
|
33
|
+
* `ProductGrid.hrefFor` — Forge has no router, so the host owns the path.
|
|
34
|
+
* Omit and they render as plain text.
|
|
35
|
+
*/
|
|
36
|
+
hrefForCategory?: (category: { id: string; slug: string; title: string }) => string;
|
|
37
|
+
hrefForCollection?: (collection: { id: string; slug: string; title: string }) => string;
|
|
30
38
|
}
|
|
31
39
|
|
|
32
40
|
const a = (hex: string, al: number) => hex + Math.round(al * 255).toString(16).padStart(2, "0");
|
|
33
41
|
|
|
34
42
|
/**
|
|
35
|
-
* Drop-in product page. **Music** products (
|
|
43
|
+
* Drop-in product page. **Music** products (productType `Music`) get an album/single
|
|
36
44
|
* preview with per-track playback; everything else — including Digital products
|
|
37
45
|
* (whose downloadable file is stored as a track) — gets the standard product
|
|
38
46
|
* layout. Renders the description as sanitized-by-source HTML with responsive
|
|
39
47
|
* media (no horizontal overflow from embedded video/images).
|
|
40
48
|
*/
|
|
41
|
-
export function ProductDetail({
|
|
49
|
+
export function ProductDetail({
|
|
50
|
+
slug,
|
|
51
|
+
formatAmount,
|
|
52
|
+
checkoutPath = "/i/checkout",
|
|
53
|
+
className,
|
|
54
|
+
style,
|
|
55
|
+
initialProduct,
|
|
56
|
+
hrefForCategory,
|
|
57
|
+
hrefForCollection,
|
|
58
|
+
}: ProductDetailProps) {
|
|
42
59
|
const t = useThemeTokens();
|
|
43
60
|
const { data: product, isLoading } = useGetProduct(slug, { initialData: initialProduct });
|
|
44
61
|
const fmt = useAmountFormatter(formatAmount);
|
|
@@ -46,14 +63,22 @@ export function ProductDetail({ slug, formatAmount, checkoutPath = "/i/checkout"
|
|
|
46
63
|
if (isLoading) return <Loading fullPage />;
|
|
47
64
|
if (!product) return <p style={{ color: t.text }}>Product not found.</p>;
|
|
48
65
|
|
|
49
|
-
// Classify on the product's
|
|
66
|
+
// Classify on the product's TYPE, NOT "has tracks" — a Digital product
|
|
50
67
|
// stores its download as a track too, so track-presence would misrender it as
|
|
51
68
|
// music (audio player over a PDF, etc.).
|
|
52
|
-
const isMusic = product.
|
|
69
|
+
const isMusic = product.productType === ProductType.Music;
|
|
53
70
|
return isMusic ? (
|
|
54
71
|
<MusicDetail product={product} fmt={fmt} checkoutPath={checkoutPath} className={className} style={style} />
|
|
55
72
|
) : (
|
|
56
|
-
<StandardDetail
|
|
73
|
+
<StandardDetail
|
|
74
|
+
product={product}
|
|
75
|
+
fmt={fmt}
|
|
76
|
+
checkoutPath={checkoutPath}
|
|
77
|
+
className={className}
|
|
78
|
+
style={style}
|
|
79
|
+
hrefForCategory={hrefForCategory}
|
|
80
|
+
hrefForCollection={hrefForCollection}
|
|
81
|
+
/>
|
|
57
82
|
);
|
|
58
83
|
}
|
|
59
84
|
|
|
@@ -106,12 +131,16 @@ function StandardDetail({
|
|
|
106
131
|
checkoutPath,
|
|
107
132
|
className,
|
|
108
133
|
style,
|
|
134
|
+
hrefForCategory,
|
|
135
|
+
hrefForCollection,
|
|
109
136
|
}: {
|
|
110
137
|
product: IPublicProduct;
|
|
111
138
|
fmt: (n: number) => string;
|
|
112
139
|
checkoutPath: string;
|
|
113
140
|
className?: string;
|
|
114
141
|
style?: React.CSSProperties;
|
|
142
|
+
hrefForCategory?: (category: { id: string; slug: string; title: string }) => string;
|
|
143
|
+
hrefForCollection?: (collection: { id: string; slug: string; title: string }) => string;
|
|
115
144
|
}) {
|
|
116
145
|
const t = useThemeTokens();
|
|
117
146
|
// Inclusive stores caption the price as tax-inclusive (display only).
|
|
@@ -120,39 +149,13 @@ function StandardDetail({
|
|
|
120
149
|
const { add } = useBuy(product, cover, checkoutPath);
|
|
121
150
|
const { isCartOpen } = useCart();
|
|
122
151
|
|
|
123
|
-
const [selectedColor, setSelectedColor] = useState<string | null>(null);
|
|
124
|
-
const [selectedSize, setSelectedSize] = useState<string | null>(null);
|
|
125
152
|
const [quantity, setQuantity] = useState(1);
|
|
126
153
|
const [imageIndex, setImageIndex] = useState(0);
|
|
127
154
|
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
.map((color) => ({
|
|
133
|
-
name: color,
|
|
134
|
-
hasAvailableSize: product.variants.some((v) => v.color === color && v.availabilityStatus === "active"),
|
|
135
|
-
}))
|
|
136
|
-
.filter((c) => c.name);
|
|
137
|
-
}, [product.variants]);
|
|
138
|
-
|
|
139
|
-
// Sizes for the chosen color.
|
|
140
|
-
const availableSizes = useMemo(() => {
|
|
141
|
-
if (!selectedColor) return [];
|
|
142
|
-
return product.variants
|
|
143
|
-
.filter((v) => v.color === selectedColor)
|
|
144
|
-
.map((v) => ({ name: v.size, isAvailable: v.availabilityStatus === "active" }))
|
|
145
|
-
.filter((s) => s.name);
|
|
146
|
-
}, [selectedColor, product.variants]);
|
|
147
|
-
|
|
148
|
-
// Products without color/size options (digital, service, simple physical) just
|
|
149
|
-
// use the default variant; otherwise the buyer must pick color + size.
|
|
150
|
-
const hasOptions = availableColors.length > 0;
|
|
151
|
-
const selectedVariant = useMemo(() => {
|
|
152
|
-
if (!hasOptions) return product.variants?.find((v) => v.isDefault) ?? product.variants?.[0];
|
|
153
|
-
if (!selectedColor || !selectedSize) return undefined;
|
|
154
|
-
return product.variants.find((v) => v.color === selectedColor && v.size === selectedSize);
|
|
155
|
-
}, [hasOptions, selectedColor, selectedSize, product.variants]);
|
|
155
|
+
// Any number of axes, narrowed left to right. Was a hardcoded colour → size
|
|
156
|
+
// cascade, which had nowhere to put "Format" and read a column that holds a
|
|
157
|
+
// hex from one writer and a name from another.
|
|
158
|
+
const { axes, selection, select, selectedVariant, hasOptions } = useVariantSelection(product.variants);
|
|
156
159
|
|
|
157
160
|
// Prefer the selected variant's own images; fall back to the product's.
|
|
158
161
|
const images = useMemo(() => {
|
|
@@ -160,19 +163,6 @@ function StandardDetail({
|
|
|
160
163
|
return vImgs.length > 0 ? vImgs : product.media?.filter((m) => m.type === "image") ?? [];
|
|
161
164
|
}, [selectedVariant, product.media]);
|
|
162
165
|
|
|
163
|
-
// Auto-select the first available color, then its first available size.
|
|
164
|
-
useEffect(() => {
|
|
165
|
-
if (!selectedColor && availableColors.length > 0) {
|
|
166
|
-
const first = availableColors.find((c) => c.hasAvailableSize) ?? availableColors[0];
|
|
167
|
-
setSelectedColor(first.name);
|
|
168
|
-
}
|
|
169
|
-
}, [availableColors, selectedColor]);
|
|
170
|
-
useEffect(() => {
|
|
171
|
-
if (selectedColor && !selectedSize && availableSizes.length > 0) {
|
|
172
|
-
const first = availableSizes.find((s) => s.isAvailable) ?? availableSizes[0];
|
|
173
|
-
setSelectedSize(first.name);
|
|
174
|
-
}
|
|
175
|
-
}, [selectedColor, selectedSize, availableSizes]);
|
|
176
166
|
useEffect(() => setImageIndex(0), [selectedVariant]);
|
|
177
167
|
|
|
178
168
|
// Mobile: once the inline Add-to-cart scrolls out of view, surface a sticky
|
|
@@ -257,73 +247,67 @@ function StandardDetail({
|
|
|
257
247
|
{selectedVariant?.payWhatYouWant && <span style={{ fontSize: 13, opacity: 0.7 }}> (Pay what you want)</span>}
|
|
258
248
|
</p>
|
|
259
249
|
|
|
260
|
-
{/*
|
|
261
|
-
{
|
|
262
|
-
<div style={{ marginTop: 18 }}>
|
|
263
|
-
<p style={{ fontSize: 15, marginBottom: 8 }}>
|
|
250
|
+
{/* Options — one block per axis the product actually uses. */}
|
|
251
|
+
{axes.map((axis) => (
|
|
252
|
+
<div key={axis.optionTypeId} style={{ marginTop: 18 }}>
|
|
253
|
+
<p style={{ fontSize: 15, marginBottom: 8 }}>{axis.axis}</p>
|
|
264
254
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 10, alignItems: "center", minHeight: 40 }}>
|
|
265
|
-
{
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
255
|
+
{axis.values.map((v) => {
|
|
256
|
+
const on = selection[axis.optionTypeId] === v.optionValueId;
|
|
257
|
+
// A swatch only when the value genuinely carries a colour, or
|
|
258
|
+
// when it reads as one. Everything else is a text chip —
|
|
259
|
+
// guessing a colour for "Stems" would render a black circle
|
|
260
|
+
// with no label.
|
|
261
|
+
const hex = v.swatchHex ?? (/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(v.value) ? v.value : null);
|
|
262
|
+
return hex ? (
|
|
269
263
|
<button
|
|
270
|
-
key={
|
|
264
|
+
key={v.optionValueId}
|
|
271
265
|
type="button"
|
|
272
|
-
title={
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}}
|
|
266
|
+
title={v.value}
|
|
267
|
+
aria-label={`${axis.axis}: ${v.value}`}
|
|
268
|
+
aria-pressed={on}
|
|
269
|
+
disabled={!v.isAvailable}
|
|
270
|
+
onClick={() => select(axis.optionTypeId, v.optionValueId)}
|
|
278
271
|
style={{
|
|
279
272
|
position: "relative",
|
|
280
273
|
width: on ? 34 : 30,
|
|
281
274
|
height: on ? 34 : 30,
|
|
282
275
|
borderRadius: "50%",
|
|
283
|
-
cursor:
|
|
284
|
-
opacity:
|
|
285
|
-
background:
|
|
276
|
+
cursor: v.isAvailable ? "pointer" : "not-allowed",
|
|
277
|
+
opacity: v.isAvailable ? 1 : 0.5,
|
|
278
|
+
background: hex,
|
|
286
279
|
border: `2px solid ${on ? t.primary : a(t.text, 0.25)}`,
|
|
287
280
|
boxShadow: on ? "0 2px 8px -2px rgba(0,0,0,0.3)" : "none",
|
|
288
281
|
transition: "all .15s",
|
|
289
282
|
}}
|
|
290
283
|
/>
|
|
291
|
-
)
|
|
292
|
-
})}
|
|
293
|
-
</div>
|
|
294
|
-
</div>
|
|
295
|
-
)}
|
|
296
|
-
|
|
297
|
-
{/* Size */}
|
|
298
|
-
{selectedColor && availableSizes.length > 0 && (
|
|
299
|
-
<div style={{ marginTop: 18 }}>
|
|
300
|
-
<p style={{ fontSize: 15, marginBottom: 8 }}>Size</p>
|
|
301
|
-
<div style={{ display: "flex", flexWrap: "wrap", gap: 10 }}>
|
|
302
|
-
{availableSizes.map((s) => {
|
|
303
|
-
const on = selectedSize === s.name;
|
|
304
|
-
return (
|
|
284
|
+
) : (
|
|
305
285
|
<button
|
|
306
|
-
key={
|
|
286
|
+
key={v.optionValueId}
|
|
307
287
|
type="button"
|
|
308
|
-
|
|
309
|
-
|
|
288
|
+
aria-pressed={on}
|
|
289
|
+
// Same accessible name as the swatch form, so one locator
|
|
290
|
+
// — and one screen-reader announcement — covers both.
|
|
291
|
+
aria-label={`${axis.axis}: ${v.value}`}
|
|
292
|
+
disabled={!v.isAvailable}
|
|
293
|
+
onClick={() => select(axis.optionTypeId, v.optionValueId)}
|
|
310
294
|
style={{
|
|
311
295
|
padding: "8px 16px",
|
|
312
296
|
borderRadius: t.cornerRadius,
|
|
313
|
-
cursor:
|
|
297
|
+
cursor: v.isAvailable ? "pointer" : "not-allowed",
|
|
314
298
|
fontWeight: 600,
|
|
315
|
-
color:
|
|
316
|
-
background: on ? a(t.primary, 0.1) :
|
|
299
|
+
color: v.isAvailable ? t.text : a(t.text, 0.4),
|
|
300
|
+
background: on ? a(t.primary, 0.1) : v.isAvailable ? "transparent" : a(t.text, 0.06),
|
|
317
301
|
border: `2px solid ${on ? t.primary : a(t.text, 0.2)}`,
|
|
318
302
|
}}
|
|
319
303
|
>
|
|
320
|
-
{
|
|
304
|
+
{v.value}
|
|
321
305
|
</button>
|
|
322
306
|
);
|
|
323
307
|
})}
|
|
324
308
|
</div>
|
|
325
309
|
</div>
|
|
326
|
-
)}
|
|
310
|
+
))}
|
|
327
311
|
|
|
328
312
|
{/* Quantity */}
|
|
329
313
|
{selectedVariant && (
|
|
@@ -354,8 +338,44 @@ function StandardDetail({
|
|
|
354
338
|
{selectedVariant && (
|
|
355
339
|
<div style={{ marginTop: 20, paddingTop: 16, borderTop: `1px solid ${a(t.text, 0.15)}` }}>
|
|
356
340
|
<p style={{ fontWeight: 700, fontSize: 13, opacity: 0.7, marginBottom: 6 }}>Product details</p>
|
|
357
|
-
|
|
341
|
+
{/* "Type", not "Category" — the two were the same thing until the
|
|
342
|
+
taxonomy split, and the creator's real categories are below. */}
|
|
343
|
+
<p style={{ fontSize: 14, opacity: 0.75 }}>Type: {product.productType}</p>
|
|
358
344
|
<p style={{ fontSize: 14, opacity: 0.75 }}>SKU: {selectedVariant.upcCode || "N/A"}</p>
|
|
345
|
+
{!!product.categories?.length && (
|
|
346
|
+
<p style={{ fontSize: 14, opacity: 0.75 }}>
|
|
347
|
+
{product.categories.length === 1 ? "Category: " : "Categories: "}
|
|
348
|
+
{product.categories.map((c, i) => (
|
|
349
|
+
<span key={c.id}>
|
|
350
|
+
{i > 0 && ", "}
|
|
351
|
+
{hrefForCategory ? (
|
|
352
|
+
<a href={hrefForCategory(c)} style={{ color: t.primary, textDecoration: "none" }}>
|
|
353
|
+
{c.title}
|
|
354
|
+
</a>
|
|
355
|
+
) : (
|
|
356
|
+
c.title
|
|
357
|
+
)}
|
|
358
|
+
</span>
|
|
359
|
+
))}
|
|
360
|
+
</p>
|
|
361
|
+
)}
|
|
362
|
+
{!!product.collections?.length && (
|
|
363
|
+
<p style={{ fontSize: 14, opacity: 0.75 }}>
|
|
364
|
+
{product.collections.length === 1 ? "Collection: " : "Collections: "}
|
|
365
|
+
{product.collections.map((c, i) => (
|
|
366
|
+
<span key={c.id}>
|
|
367
|
+
{i > 0 && ", "}
|
|
368
|
+
{hrefForCollection ? (
|
|
369
|
+
<a href={hrefForCollection(c)} style={{ color: t.primary, textDecoration: "none" }}>
|
|
370
|
+
{c.title}
|
|
371
|
+
</a>
|
|
372
|
+
) : (
|
|
373
|
+
c.title
|
|
374
|
+
)}
|
|
375
|
+
</span>
|
|
376
|
+
))}
|
|
377
|
+
</p>
|
|
378
|
+
)}
|
|
359
379
|
</div>
|
|
360
380
|
)}
|
|
361
381
|
|
|
@@ -437,7 +457,13 @@ function MusicDetail({
|
|
|
437
457
|
const pricesIncludeTax = usePricesIncludeTax();
|
|
438
458
|
const { pause, loadAndPlay, currentTrack, play, isPlaying } = useAudioPlayer();
|
|
439
459
|
|
|
440
|
-
|
|
460
|
+
// A release can be sold in several forms — MP3 and FLAC, vinyl and cassette —
|
|
461
|
+
// so this page can no longer assume one version. With no axes the hook returns
|
|
462
|
+
// exactly what this used to: the default variant.
|
|
463
|
+
const { axes, selection, select, selectedVariant } = useVariantSelection(product.variants);
|
|
464
|
+
const defaultVariant = selectedVariant ?? product.variants.find((v) => v.isDefault) ?? product.variants[0];
|
|
465
|
+
// The TRACKLIST is the release's contents and does not depend on which
|
|
466
|
+
// version is selected — every version of an album has the same songs.
|
|
441
467
|
const tracks = defaultVariant?.tracks ?? [];
|
|
442
468
|
const isSingle = tracks.length === 1;
|
|
443
469
|
const firstTrack = tracks[0];
|
|
@@ -536,6 +562,39 @@ function MusicDetail({
|
|
|
536
562
|
<div style={{ fontSize: 20, fontWeight: 700, color: t.primary }}>
|
|
537
563
|
<PriceDisplay amount={defaultVariant.price} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={t.text} captionStyle={{ opacity: 0.65 }} />
|
|
538
564
|
</div>
|
|
565
|
+
|
|
566
|
+
{/* One row per axis, only when the release actually has formats. */}
|
|
567
|
+
{axes.map((axis) => (
|
|
568
|
+
<div key={axis.optionTypeId} style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
|
569
|
+
<span style={{ fontSize: 13, opacity: 0.7, color: t.text }}>{axis.axis}</span>
|
|
570
|
+
{axis.values.map((v) => {
|
|
571
|
+
const on = selection[axis.optionTypeId] === v.optionValueId;
|
|
572
|
+
return (
|
|
573
|
+
<button
|
|
574
|
+
key={v.optionValueId}
|
|
575
|
+
type="button"
|
|
576
|
+
aria-pressed={on}
|
|
577
|
+
aria-label={`${axis.axis}: ${v.value}`}
|
|
578
|
+
disabled={!v.isAvailable}
|
|
579
|
+
onClick={() => select(axis.optionTypeId, v.optionValueId)}
|
|
580
|
+
style={{
|
|
581
|
+
padding: "6px 12px",
|
|
582
|
+
fontSize: 13,
|
|
583
|
+
borderRadius: t.cornerRadius,
|
|
584
|
+
cursor: v.isAvailable ? "pointer" : "not-allowed",
|
|
585
|
+
opacity: v.isAvailable ? 1 : 0.5,
|
|
586
|
+
color: t.text,
|
|
587
|
+
background: on ? a(t.primary, 0.1) : "transparent",
|
|
588
|
+
border: `1px solid ${on ? t.primary : a(t.text, 0.25)}`,
|
|
589
|
+
}}
|
|
590
|
+
>
|
|
591
|
+
{v.value}
|
|
592
|
+
</button>
|
|
593
|
+
);
|
|
594
|
+
})}
|
|
595
|
+
</div>
|
|
596
|
+
))}
|
|
597
|
+
|
|
539
598
|
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", marginTop: 4 }}>
|
|
540
599
|
<Button onClick={() => buyNow(defaultVariant, { canIncreaseQuantity: false })}>Buy now</Button>
|
|
541
600
|
<Button variant="outline" onClick={() => add(defaultVariant, { canIncreaseQuantity: false })}>
|