@tribe-nest/forge 3.11.0 → 3.17.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.
@@ -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
 
@@ -87,6 +112,14 @@ function useBuy(product: IPublicProduct, cover: string | undefined, checkoutPath
87
112
  quantity: opts?.quantity ?? 1,
88
113
  canIncreaseQuantity: opts?.canIncreaseQuantity ?? true,
89
114
  payWhatYouWant: !!variant.payWhatYouWant,
115
+ // Which version was chosen, carried onto the line so the cart, the
116
+ // checkout and the receipt can all say so. `color`/`size` stay for a
117
+ // moment longer: they are what a cart saved before this holds.
118
+ options: (variant.options ?? []).map((option) => ({
119
+ axis: option.axis,
120
+ value: option.value,
121
+ swatchHex: option.swatchHex,
122
+ })),
90
123
  color: variant.color,
91
124
  size: variant.size,
92
125
  deliveryType: variant.deliveryType,
@@ -106,12 +139,16 @@ function StandardDetail({
106
139
  checkoutPath,
107
140
  className,
108
141
  style,
142
+ hrefForCategory,
143
+ hrefForCollection,
109
144
  }: {
110
145
  product: IPublicProduct;
111
146
  fmt: (n: number) => string;
112
147
  checkoutPath: string;
113
148
  className?: string;
114
149
  style?: React.CSSProperties;
150
+ hrefForCategory?: (category: { id: string; slug: string; title: string }) => string;
151
+ hrefForCollection?: (collection: { id: string; slug: string; title: string }) => string;
115
152
  }) {
116
153
  const t = useThemeTokens();
117
154
  // Inclusive stores caption the price as tax-inclusive (display only).
@@ -120,39 +157,13 @@ function StandardDetail({
120
157
  const { add } = useBuy(product, cover, checkoutPath);
121
158
  const { isCartOpen } = useCart();
122
159
 
123
- const [selectedColor, setSelectedColor] = useState<string | null>(null);
124
- const [selectedSize, setSelectedSize] = useState<string | null>(null);
125
160
  const [quantity, setQuantity] = useState(1);
126
161
  const [imageIndex, setImageIndex] = useState(0);
127
162
 
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]);
163
+ // Any number of axes, narrowed left to right. Was a hardcoded colour size
164
+ // cascade, which had nowhere to put "Format" and read a column that holds a
165
+ // hex from one writer and a name from another.
166
+ const { axes, selection, select, selectedVariant, hasOptions } = useVariantSelection(product.variants);
156
167
 
157
168
  // Prefer the selected variant's own images; fall back to the product's.
158
169
  const images = useMemo(() => {
@@ -160,19 +171,6 @@ function StandardDetail({
160
171
  return vImgs.length > 0 ? vImgs : product.media?.filter((m) => m.type === "image") ?? [];
161
172
  }, [selectedVariant, product.media]);
162
173
 
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
174
  useEffect(() => setImageIndex(0), [selectedVariant]);
177
175
 
178
176
  // Mobile: once the inline Add-to-cart scrolls out of view, surface a sticky
@@ -257,73 +255,67 @@ function StandardDetail({
257
255
  {selectedVariant?.payWhatYouWant && <span style={{ fontSize: 13, opacity: 0.7 }}> (Pay what you want)</span>}
258
256
  </p>
259
257
 
260
- {/* Color */}
261
- {availableColors.length > 0 && (
262
- <div style={{ marginTop: 18 }}>
263
- <p style={{ fontSize: 15, marginBottom: 8 }}>Color</p>
258
+ {/* Options — one block per axis the product actually uses. */}
259
+ {axes.map((axis) => (
260
+ <div key={axis.optionTypeId} style={{ marginTop: 18 }}>
261
+ <p style={{ fontSize: 15, marginBottom: 8 }}>{axis.axis}</p>
264
262
  <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 (
263
+ {axis.values.map((v) => {
264
+ const on = selection[axis.optionTypeId] === v.optionValueId;
265
+ // A swatch only when the value genuinely carries a colour, or
266
+ // when it reads as one. Everything else is a text chip —
267
+ // guessing a colour for "Stems" would render a black circle
268
+ // with no label.
269
+ const hex = v.swatchHex ?? (/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(v.value) ? v.value : null);
270
+ return hex ? (
269
271
  <button
270
- key={c.name}
272
+ key={v.optionValueId}
271
273
  type="button"
272
- title={c.name}
273
- disabled={!c.hasAvailableSize}
274
- onClick={() => {
275
- setSelectedColor(c.name);
276
- setSelectedSize(null);
277
- }}
274
+ title={v.value}
275
+ aria-label={`${axis.axis}: ${v.value}`}
276
+ aria-pressed={on}
277
+ disabled={!v.isAvailable}
278
+ onClick={() => select(axis.optionTypeId, v.optionValueId)}
278
279
  style={{
279
280
  position: "relative",
280
281
  width: on ? 34 : 30,
281
282
  height: on ? 34 : 30,
282
283
  borderRadius: "50%",
283
- cursor: c.hasAvailableSize ? "pointer" : "not-allowed",
284
- opacity: c.hasAvailableSize ? 1 : 0.5,
285
- background: isHex ? c.name : c.name.toLowerCase(),
284
+ cursor: v.isAvailable ? "pointer" : "not-allowed",
285
+ opacity: v.isAvailable ? 1 : 0.5,
286
+ background: hex,
286
287
  border: `2px solid ${on ? t.primary : a(t.text, 0.25)}`,
287
288
  boxShadow: on ? "0 2px 8px -2px rgba(0,0,0,0.3)" : "none",
288
289
  transition: "all .15s",
289
290
  }}
290
291
  />
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 (
292
+ ) : (
305
293
  <button
306
- key={s.name}
294
+ key={v.optionValueId}
307
295
  type="button"
308
- disabled={!s.isAvailable}
309
- onClick={() => setSelectedSize(s.name)}
296
+ aria-pressed={on}
297
+ // Same accessible name as the swatch form, so one locator
298
+ // — and one screen-reader announcement — covers both.
299
+ aria-label={`${axis.axis}: ${v.value}`}
300
+ disabled={!v.isAvailable}
301
+ onClick={() => select(axis.optionTypeId, v.optionValueId)}
310
302
  style={{
311
303
  padding: "8px 16px",
312
304
  borderRadius: t.cornerRadius,
313
- cursor: s.isAvailable ? "pointer" : "not-allowed",
305
+ cursor: v.isAvailable ? "pointer" : "not-allowed",
314
306
  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),
307
+ color: v.isAvailable ? t.text : a(t.text, 0.4),
308
+ background: on ? a(t.primary, 0.1) : v.isAvailable ? "transparent" : a(t.text, 0.06),
317
309
  border: `2px solid ${on ? t.primary : a(t.text, 0.2)}`,
318
310
  }}
319
311
  >
320
- {s.name}
312
+ {v.value}
321
313
  </button>
322
314
  );
323
315
  })}
324
316
  </div>
325
317
  </div>
326
- )}
318
+ ))}
327
319
 
328
320
  {/* Quantity */}
329
321
  {selectedVariant && (
@@ -354,8 +346,44 @@ function StandardDetail({
354
346
  {selectedVariant && (
355
347
  <div style={{ marginTop: 20, paddingTop: 16, borderTop: `1px solid ${a(t.text, 0.15)}` }}>
356
348
  <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>
349
+ {/* "Type", not "Category" — the two were the same thing until the
350
+ taxonomy split, and the creator's real categories are below. */}
351
+ <p style={{ fontSize: 14, opacity: 0.75 }}>Type: {product.productType}</p>
358
352
  <p style={{ fontSize: 14, opacity: 0.75 }}>SKU: {selectedVariant.upcCode || "N/A"}</p>
353
+ {!!product.categories?.length && (
354
+ <p style={{ fontSize: 14, opacity: 0.75 }}>
355
+ {product.categories.length === 1 ? "Category: " : "Categories: "}
356
+ {product.categories.map((c, i) => (
357
+ <span key={c.id}>
358
+ {i > 0 && ", "}
359
+ {hrefForCategory ? (
360
+ <a href={hrefForCategory(c)} style={{ color: t.primary, textDecoration: "none" }}>
361
+ {c.title}
362
+ </a>
363
+ ) : (
364
+ c.title
365
+ )}
366
+ </span>
367
+ ))}
368
+ </p>
369
+ )}
370
+ {!!product.collections?.length && (
371
+ <p style={{ fontSize: 14, opacity: 0.75 }}>
372
+ {product.collections.length === 1 ? "Collection: " : "Collections: "}
373
+ {product.collections.map((c, i) => (
374
+ <span key={c.id}>
375
+ {i > 0 && ", "}
376
+ {hrefForCollection ? (
377
+ <a href={hrefForCollection(c)} style={{ color: t.primary, textDecoration: "none" }}>
378
+ {c.title}
379
+ </a>
380
+ ) : (
381
+ c.title
382
+ )}
383
+ </span>
384
+ ))}
385
+ </p>
386
+ )}
359
387
  </div>
360
388
  )}
361
389
 
@@ -437,7 +465,13 @@ function MusicDetail({
437
465
  const pricesIncludeTax = usePricesIncludeTax();
438
466
  const { pause, loadAndPlay, currentTrack, play, isPlaying } = useAudioPlayer();
439
467
 
440
- const defaultVariant = product.variants.find((v) => v.isDefault) || product.variants[0];
468
+ // A release can be sold in several forms — MP3 and FLAC, vinyl and cassette —
469
+ // so this page can no longer assume one version. With no axes the hook returns
470
+ // exactly what this used to: the default variant.
471
+ const { axes, selection, select, selectedVariant } = useVariantSelection(product.variants);
472
+ const defaultVariant = selectedVariant ?? product.variants.find((v) => v.isDefault) ?? product.variants[0];
473
+ // The TRACKLIST is the release's contents and does not depend on which
474
+ // version is selected — every version of an album has the same songs.
441
475
  const tracks = defaultVariant?.tracks ?? [];
442
476
  const isSingle = tracks.length === 1;
443
477
  const firstTrack = tracks[0];
@@ -536,6 +570,39 @@ function MusicDetail({
536
570
  <div style={{ fontSize: 20, fontWeight: 700, color: t.primary }}>
537
571
  <PriceDisplay amount={defaultVariant.price} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={t.text} captionStyle={{ opacity: 0.65 }} />
538
572
  </div>
573
+
574
+ {/* One row per axis, only when the release actually has formats. */}
575
+ {axes.map((axis) => (
576
+ <div key={axis.optionTypeId} style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
577
+ <span style={{ fontSize: 13, opacity: 0.7, color: t.text }}>{axis.axis}</span>
578
+ {axis.values.map((v) => {
579
+ const on = selection[axis.optionTypeId] === v.optionValueId;
580
+ return (
581
+ <button
582
+ key={v.optionValueId}
583
+ type="button"
584
+ aria-pressed={on}
585
+ aria-label={`${axis.axis}: ${v.value}`}
586
+ disabled={!v.isAvailable}
587
+ onClick={() => select(axis.optionTypeId, v.optionValueId)}
588
+ style={{
589
+ padding: "6px 12px",
590
+ fontSize: 13,
591
+ borderRadius: t.cornerRadius,
592
+ cursor: v.isAvailable ? "pointer" : "not-allowed",
593
+ opacity: v.isAvailable ? 1 : 0.5,
594
+ color: t.text,
595
+ background: on ? a(t.primary, 0.1) : "transparent",
596
+ border: `1px solid ${on ? t.primary : a(t.text, 0.25)}`,
597
+ }}
598
+ >
599
+ {v.value}
600
+ </button>
601
+ );
602
+ })}
603
+ </div>
604
+ ))}
605
+
539
606
  <div style={{ display: "flex", gap: 10, flexWrap: "wrap", marginTop: 4 }}>
540
607
  <Button onClick={() => buyNow(defaultVariant, { canIncreaseQuantity: false })}>Buy now</Button>
541
608
  <Button variant="outline" onClick={() => add(defaultVariant, { canIncreaseQuantity: false })}>
@@ -1,5 +1,5 @@
1
1
  import { useGetProducts, useGetProductsByIds } from "../../data/queries/useProducts";
2
- import type { IPublicProduct } from "../../types/models";
2
+ import type { IPublicProduct, ProductType } from "../../types/models";
3
3
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
4
4
  import { useAmountFormatter } from "../format/useFormatCurrency";
5
5
  import { PriceDisplay } from "../format/PriceDisplay";
@@ -15,6 +15,17 @@ export interface ProductGridProps {
15
15
  * "the first N products".
16
16
  */
17
17
  productIds?: string[];
18
+ /**
19
+ * Show only products filed under this category. **Descendant-inclusive** —
20
+ * passing "Apparel" returns everything beneath it at any depth, which is what
21
+ * makes a parent category a usable browse destination rather than an empty
22
+ * page.
23
+ */
24
+ categoryId?: string;
25
+ /** Show only products in this collection, in the creator's curated order. */
26
+ collectionId?: string;
27
+ /** Show only products of this kind (Music / Merch / Digital / Service). */
28
+ productType?: ProductType[];
18
29
  formatAmount?: (amount: number) => string;
19
30
  onSelect?: (product: IPublicProduct) => void;
20
31
  /**
@@ -39,6 +50,9 @@ export function ProductGrid({
39
50
  columns = 3,
40
51
  limit,
41
52
  productIds,
53
+ categoryId,
54
+ collectionId,
55
+ productType,
42
56
  formatAmount,
43
57
  onSelect,
44
58
  hrefFor,
@@ -52,7 +66,7 @@ export function ProductGrid({
52
66
  const byIds = !!productIds?.length;
53
67
  // Both hooks are called unconditionally (rules of hooks); each disables
54
68
  // itself when it isn't the one in use.
55
- const listQuery = useGetProducts({ page: 1 }, !byIds);
69
+ const listQuery = useGetProducts({ page: 1, categoryId, collectionId, productType }, !byIds);
56
70
  const idsQuery = useGetProductsByIds(byIds ? productIds! : []);
57
71
 
58
72
  const isLoading = byIds ? idsQuery.isLoading : listQuery.isLoading;