@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.
@@ -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 { ProductCategory } from "../../types/models";
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 (category `Music`) get an album/single
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({ slug, formatAmount, checkoutPath = "/i/checkout", className, style, initialProduct }: ProductDetailProps) {
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 category, NOT "has tracks" — a Digital product
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.category === ProductCategory.Music;
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 product={product} fmt={fmt} checkoutPath={checkoutPath} className={className} style={style} />
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
- // Colors that exist across variants (each flagged if it has any in-stock size).
129
- const availableColors = useMemo(() => {
130
- const colors = Array.from(new Set(product.variants.map((v) => v.color)));
131
- return colors
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
- {/* Color */}
261
- {availableColors.length > 0 && (
262
- <div style={{ marginTop: 18 }}>
263
- <p style={{ fontSize: 15, marginBottom: 8 }}>Color</p>
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
- {availableColors.map((c) => {
266
- const isHex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(c.name);
267
- const on = selectedColor === c.name;
268
- return (
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={c.name}
264
+ key={v.optionValueId}
271
265
  type="button"
272
- title={c.name}
273
- disabled={!c.hasAvailableSize}
274
- onClick={() => {
275
- setSelectedColor(c.name);
276
- setSelectedSize(null);
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: c.hasAvailableSize ? "pointer" : "not-allowed",
284
- opacity: c.hasAvailableSize ? 1 : 0.5,
285
- background: isHex ? c.name : c.name.toLowerCase(),
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={s.name}
286
+ key={v.optionValueId}
307
287
  type="button"
308
- disabled={!s.isAvailable}
309
- onClick={() => setSelectedSize(s.name)}
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: s.isAvailable ? "pointer" : "not-allowed",
297
+ cursor: v.isAvailable ? "pointer" : "not-allowed",
314
298
  fontWeight: 600,
315
- color: s.isAvailable ? t.text : a(t.text, 0.4),
316
- background: on ? a(t.primary, 0.1) : s.isAvailable ? "transparent" : a(t.text, 0.06),
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
- {s.name}
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
- <p style={{ fontSize: 14, opacity: 0.75 }}>Category: {product.category}</p>
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
- const defaultVariant = product.variants.find((v) => v.isDefault) || product.variants[0];
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 })}>