@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.
- package/package.json +2 -2
- package/src/contexts/CartContext.tsx +38 -2
- package/src/data/queries/useCheckouts.ts +14 -0
- package/src/data/queries/useEvents.ts +6 -0
- package/src/data/queries/useProducts.ts +104 -10
- package/src/index.ts +8 -0
- package/src/types/models.ts +87 -5
- package/src/ui/format/_tests/pwyw.spec.ts +157 -0
- package/src/ui/format/pwyw.ts +95 -0
- package/src/ui/headless/event/useEventCheckout.ts +96 -6
- package/src/ui/headless/index.ts +1 -0
- package/src/ui/headless/useVariantSelection.ts +138 -0
- package/src/ui/index.ts +17 -0
- package/src/ui/styled/AccountDashboard.tsx +7 -0
- package/src/ui/styled/Cart.tsx +11 -8
- package/src/ui/styled/CartLineOptions.tsx +107 -0
- package/src/ui/styled/Checkout.tsx +5 -0
- package/src/ui/styled/CheckoutConfirmation.tsx +4 -6
- package/src/ui/styled/EventTickets.tsx +114 -0
- package/src/ui/styled/ProductBrowseNav.tsx +212 -0
- package/src/ui/styled/ProductDetail.tsx +158 -91
- package/src/ui/styled/ProductGrid.tsx +16 -2
|
@@ -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
|
|
|
@@ -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
|
-
//
|
|
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]);
|
|
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
|
-
{/*
|
|
261
|
-
{
|
|
262
|
-
<div style={{ marginTop: 18 }}>
|
|
263
|
-
<p style={{ fontSize: 15, marginBottom: 8 }}>
|
|
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
|
-
{
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
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={
|
|
272
|
+
key={v.optionValueId}
|
|
271
273
|
type="button"
|
|
272
|
-
title={
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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:
|
|
284
|
-
opacity:
|
|
285
|
-
background:
|
|
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={
|
|
294
|
+
key={v.optionValueId}
|
|
307
295
|
type="button"
|
|
308
|
-
|
|
309
|
-
|
|
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:
|
|
305
|
+
cursor: v.isAvailable ? "pointer" : "not-allowed",
|
|
314
306
|
fontWeight: 600,
|
|
315
|
-
color:
|
|
316
|
-
background: on ? a(t.primary, 0.1) :
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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;
|