@tribe-nest/forge 3.11.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/useProducts.ts +104 -10
- package/src/index.ts +5 -0
- package/src/types/models.ts +53 -4
- package/src/ui/headless/index.ts +1 -0
- package/src/ui/headless/useVariantSelection.ts +138 -0
- package/src/ui/index.ts +1 -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/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tribe-nest/forge",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.14.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
7
|
-
"description": "Forge
|
|
7
|
+
"description": "Forge \u2014 the headless React SDK for building custom TribeNest creator sites (the Hydrogen of TribeNest). Exposes the backend (memberships, commerce, gated content, ticketing, courses, booking, auth) as data + behavior primitives.",
|
|
8
8
|
"exports": {
|
|
9
9
|
".": "./src/index.ts",
|
|
10
10
|
"./ui": "./src/ui/index.ts",
|
|
@@ -31,7 +31,19 @@ export type CartItem = {
|
|
|
31
31
|
payWhatYouWant: boolean;
|
|
32
32
|
color?: string;
|
|
33
33
|
size?: string;
|
|
34
|
-
|
|
34
|
+
/**
|
|
35
|
+
* REQUIRED, and deliberately so.
|
|
36
|
+
*
|
|
37
|
+
* Checkout decides whether to ask for a shipping address from this field on
|
|
38
|
+
* the cart LINE. While it was optional, a caller that forgot it produced an
|
|
39
|
+
* item that silently read as non-physical — the Craft music page did exactly
|
|
40
|
+
* that, and a vinyl reached checkout with the buyer never asked where to send
|
|
41
|
+
* the record. A missing address is not recoverable after payment.
|
|
42
|
+
*
|
|
43
|
+
* Making it required turns that into a compile error instead of a defect
|
|
44
|
+
* nobody sees until an order arrives with nowhere to ship it.
|
|
45
|
+
*/
|
|
46
|
+
deliveryType: ProductDeliveryType;
|
|
35
47
|
/** Set automatically from `?addonFor=` — see `addToCart`. */
|
|
36
48
|
attachedTo?: AttachedTo;
|
|
37
49
|
};
|
|
@@ -1,23 +1,39 @@
|
|
|
1
|
-
import type { IPublicProduct, PaginatedData,
|
|
1
|
+
import type { IPublicProduct, PaginatedData, ProductType } from "../../types/models";
|
|
2
2
|
import { useForge } from "../../provider/ForgeProvider";
|
|
3
3
|
import { useQuery } from "@tanstack/react-query";
|
|
4
4
|
|
|
5
5
|
export interface GetProductsParams {
|
|
6
6
|
query?: string;
|
|
7
|
-
category
|
|
7
|
+
/** What the products ARE. Renamed from `category` in the taxonomy split. */
|
|
8
|
+
productType?: ProductType[];
|
|
9
|
+
/**
|
|
10
|
+
* @deprecated The old name for `productType`. Still honoured, and honoured
|
|
11
|
+
* deliberately: callers usually build this object in a `useMemo` and pass it
|
|
12
|
+
* as a VARIABLE, which turns off TypeScript's excess-property check — so a
|
|
13
|
+
* caller left on the old name compiles clean and silently loses its filter,
|
|
14
|
+
* listing the whole catalogue on a page meant to show one type. Dropping the
|
|
15
|
+
* key would make that failure mode look like working code.
|
|
16
|
+
*/
|
|
17
|
+
category?: ProductType[];
|
|
18
|
+
/**
|
|
19
|
+
* A creator-authored category. Descendant-inclusive: filtering on "Apparel"
|
|
20
|
+
* returns everything filed beneath it, at any depth.
|
|
21
|
+
*/
|
|
22
|
+
categoryId?: string;
|
|
23
|
+
collectionId?: string;
|
|
8
24
|
page?: number;
|
|
9
25
|
releaseType?: string;
|
|
10
26
|
}
|
|
11
27
|
|
|
12
|
-
/** Featured products for the profile, optionally filtered by
|
|
13
|
-
export function useFeaturedProducts(
|
|
28
|
+
/** Featured products for the profile, optionally filtered by product type. */
|
|
29
|
+
export function useFeaturedProducts(productType?: ProductType) {
|
|
14
30
|
const { client, profileId } = useForge();
|
|
15
31
|
|
|
16
32
|
return useQuery<IPublicProduct[]>({
|
|
17
|
-
queryKey: ["featured-products", profileId,
|
|
33
|
+
queryKey: ["featured-products", profileId, productType],
|
|
18
34
|
queryFn: async () => {
|
|
19
35
|
const res = await client.get("/public/products/featured", {
|
|
20
|
-
params: { profileId,
|
|
36
|
+
params: { profileId, productType },
|
|
21
37
|
});
|
|
22
38
|
return res.data;
|
|
23
39
|
},
|
|
@@ -34,7 +50,9 @@ export function useGetProducts(params?: GetProductsParams, enabled = true) {
|
|
|
34
50
|
const res = await client.get("/public/products", {
|
|
35
51
|
params: {
|
|
36
52
|
profileId: profileId,
|
|
37
|
-
|
|
53
|
+
productType: params?.productType ?? params?.category,
|
|
54
|
+
categoryId: params?.categoryId,
|
|
55
|
+
collectionId: params?.collectionId,
|
|
38
56
|
page: params?.page || 1,
|
|
39
57
|
limit: 10,
|
|
40
58
|
filter: {
|
|
@@ -90,11 +108,21 @@ export function useGetProductsByIds(productIds: string[]) {
|
|
|
90
108
|
});
|
|
91
109
|
}
|
|
92
110
|
|
|
93
|
-
|
|
111
|
+
/**
|
|
112
|
+
* The product TYPES this creator has products in, plus a synthetic "Coaching"
|
|
113
|
+
* entry when they sell coaching. This is what the storefront's top-level tabs
|
|
114
|
+
* are built from.
|
|
115
|
+
*
|
|
116
|
+
* The endpoint keeps its `/categories` path: every code-site published before
|
|
117
|
+
* the taxonomy split calls it and reads this exact payload, and the hook name
|
|
118
|
+
* is what changed. For the creator's OWN categories use
|
|
119
|
+
* {@link useProductCategories}.
|
|
120
|
+
*/
|
|
121
|
+
export function useGetProductTypes() {
|
|
94
122
|
const { client, profileId } = useForge();
|
|
95
123
|
|
|
96
|
-
return useQuery<{ title:
|
|
97
|
-
queryKey: ["product-
|
|
124
|
+
return useQuery<{ title: string; description: string }[]>({
|
|
125
|
+
queryKey: ["product-types", profileId],
|
|
98
126
|
queryFn: async () => {
|
|
99
127
|
const res = await client.get("/public/products/categories", {
|
|
100
128
|
params: {
|
|
@@ -106,3 +134,69 @@ export function useGetProductCategories() {
|
|
|
106
134
|
enabled: !!profileId && !!client,
|
|
107
135
|
});
|
|
108
136
|
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @deprecated Renamed to {@link useGetProductTypes}, which is what it always
|
|
140
|
+
* returned. Kept so a site built against an earlier Forge keeps compiling; it
|
|
141
|
+
* will go in a future major.
|
|
142
|
+
*/
|
|
143
|
+
export const useGetProductCategories = useGetProductTypes;
|
|
144
|
+
|
|
145
|
+
/** A node in the creator's category tree. Children are nested, never repeated at the root. */
|
|
146
|
+
export interface IPublicProductCategory {
|
|
147
|
+
id: string;
|
|
148
|
+
title: string;
|
|
149
|
+
slug: string;
|
|
150
|
+
description: string | null;
|
|
151
|
+
parentId: string | null;
|
|
152
|
+
position: number;
|
|
153
|
+
productCount: number;
|
|
154
|
+
children: IPublicProductCategory[];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface IPublicProductCollection {
|
|
158
|
+
id: string;
|
|
159
|
+
title: string;
|
|
160
|
+
slug: string;
|
|
161
|
+
description: string | null;
|
|
162
|
+
isFeatured: boolean;
|
|
163
|
+
position: number;
|
|
164
|
+
productCount: number;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The creator's OWN category tree — what they organise their store by, as
|
|
169
|
+
* opposed to {@link useGetProductTypes} which is what their products ARE.
|
|
170
|
+
*
|
|
171
|
+
* Counts are descendant-inclusive, so "Apparel" reports everything beneath it.
|
|
172
|
+
*/
|
|
173
|
+
export function useProductCategories() {
|
|
174
|
+
const { client, profileId } = useForge();
|
|
175
|
+
|
|
176
|
+
return useQuery<IPublicProductCategory[]>({
|
|
177
|
+
queryKey: ["public-product-categories", profileId],
|
|
178
|
+
queryFn: async () => {
|
|
179
|
+
const res = await client.get("/public/products/product-categories", {
|
|
180
|
+
params: { profileId },
|
|
181
|
+
});
|
|
182
|
+
return res.data;
|
|
183
|
+
},
|
|
184
|
+
enabled: !!profileId && !!client,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The creator's curated collections — drops, seasons, hand-picked edits. */
|
|
189
|
+
export function useProductCollections() {
|
|
190
|
+
const { client, profileId } = useForge();
|
|
191
|
+
|
|
192
|
+
return useQuery<IPublicProductCollection[]>({
|
|
193
|
+
queryKey: ["public-product-collections", profileId],
|
|
194
|
+
queryFn: async () => {
|
|
195
|
+
const res = await client.get("/public/products/product-collections", {
|
|
196
|
+
params: { profileId },
|
|
197
|
+
});
|
|
198
|
+
return res.data;
|
|
199
|
+
},
|
|
200
|
+
enabled: !!profileId && !!client,
|
|
201
|
+
});
|
|
202
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -26,6 +26,11 @@ export * from "./types";
|
|
|
26
26
|
export { AudioPlayerProvider, useAudioPlayer } from "./contexts/AudioPlayerContext";
|
|
27
27
|
export type { AudioTrack } from "./contexts/AudioPlayerContext";
|
|
28
28
|
export { CartProvider, useCart } from "./contexts/CartContext";
|
|
29
|
+
// Variant selection, shared with the Craft themes in frontend-shared the same
|
|
30
|
+
// way the cart and audio player are — so a code site and a Craft site cannot
|
|
31
|
+
// disagree about which version a buyer picked.
|
|
32
|
+
export { useVariantSelection } from "./ui/headless/useVariantSelection";
|
|
33
|
+
export type { VariantAxis, VariantAxisValue } from "./ui/headless/useVariantSelection";
|
|
29
34
|
export type { CartItem, TicketCartItem, AttachedTo } from "./contexts/CartContext";
|
|
30
35
|
export {
|
|
31
36
|
ACCESS_TOKEN_KEY,
|
package/src/types/models.ts
CHANGED
|
@@ -584,15 +584,35 @@ export type IMedia = {
|
|
|
584
584
|
previewStatus?: string | null;
|
|
585
585
|
};
|
|
586
586
|
|
|
587
|
-
|
|
587
|
+
/**
|
|
588
|
+
* What a product IS, which decides how it renders: Music shows a track list,
|
|
589
|
+
* Merch shows variants, Digital is a download, Service is fulfilled by the
|
|
590
|
+
* creator.
|
|
591
|
+
*
|
|
592
|
+
* Renamed from `ProductType` in the taxonomy split. It used to be the only
|
|
593
|
+
* taxonomy a product had, pointing into a four-row table shared by every artist
|
|
594
|
+
* on the platform — so no creator could organise their own store. Creator
|
|
595
|
+
* authored categories and collections are separate now (`categories`,
|
|
596
|
+
* `collections` on {@link IPublicProduct}).
|
|
597
|
+
*
|
|
598
|
+
* Coaching and Course are gone: they are separate surfaces with their own
|
|
599
|
+
* tables and never appeared in `products`. The storefront still shows a
|
|
600
|
+
* Coaching tab — see {@link STOREFRONT_COACHING_TAB}.
|
|
601
|
+
*/
|
|
602
|
+
export enum ProductType {
|
|
588
603
|
Music = "Music",
|
|
589
604
|
Merch = "Merch",
|
|
590
605
|
Digital = "Digital",
|
|
591
606
|
Service = "Service",
|
|
592
|
-
Coaching = "Coaching",
|
|
593
|
-
Course = "Course",
|
|
594
607
|
}
|
|
595
608
|
|
|
609
|
+
/**
|
|
610
|
+
* The storefront's product-type navigation is not purely {@link ProductType}: a
|
|
611
|
+
* creator with coaching products gets a "Coaching" tab alongside them, though
|
|
612
|
+
* no product row is ever of that type.
|
|
613
|
+
*/
|
|
614
|
+
export const STOREFRONT_COACHING_TAB = "Coaching" as const;
|
|
615
|
+
|
|
596
616
|
export type PostType = "image" | "video" | "audio" | "poll";
|
|
597
617
|
|
|
598
618
|
export type IPublicComment = {
|
|
@@ -658,11 +678,36 @@ export type IPublicProductVariant = {
|
|
|
658
678
|
payWhatYouWant?: boolean;
|
|
659
679
|
payWhatYouWantMaximum?: number;
|
|
660
680
|
upcCode: string;
|
|
681
|
+
/**
|
|
682
|
+
* Where this variant sits on its product's axes, in the axes' own order.
|
|
683
|
+
*
|
|
684
|
+
* This is what the picker reads. `color` and `size` below are the two
|
|
685
|
+
* hardcoded axes that predate the option library — they cannot express a
|
|
686
|
+
* third ("Format: MP3 / WAV / Stems"), and one product's `color` may hold a
|
|
687
|
+
* hex while another's holds a name, because three different writers filled
|
|
688
|
+
* it. Prefer `options`.
|
|
689
|
+
*/
|
|
690
|
+
options: IPublicVariantOption[];
|
|
691
|
+
/** @deprecated Read `options`. */
|
|
661
692
|
color: string;
|
|
693
|
+
/** @deprecated Read `options`. */
|
|
662
694
|
size: string;
|
|
695
|
+
/** Whether a buyer of this version also receives the downloadable files. */
|
|
696
|
+
includesDownload?: boolean;
|
|
663
697
|
availabilityStatus: "active" | "temporarily_out_of_stock";
|
|
664
698
|
};
|
|
665
699
|
|
|
700
|
+
export type IPublicVariantOption = {
|
|
701
|
+
optionValueId: string;
|
|
702
|
+
optionTypeId: string;
|
|
703
|
+
/** "Colour", "Size", "Format" — as the creator named it. */
|
|
704
|
+
axis: string;
|
|
705
|
+
value: string;
|
|
706
|
+
/** Set only when the value genuinely is a colour; a guessed hex would lie. */
|
|
707
|
+
swatchHex: string | null;
|
|
708
|
+
displayType: string;
|
|
709
|
+
};
|
|
710
|
+
|
|
666
711
|
// ---- Reviews -------------------------------------------------------------------
|
|
667
712
|
|
|
668
713
|
/** Reviewable entity types (v1 — events deferred). */
|
|
@@ -750,8 +795,12 @@ export type IPublicProduct = {
|
|
|
750
795
|
slug?: string;
|
|
751
796
|
title: string;
|
|
752
797
|
description: string;
|
|
753
|
-
|
|
798
|
+
productType: ProductType;
|
|
754
799
|
media: IMedia[];
|
|
800
|
+
/** Creator-authored categories this product is filed under. Direct only — a parent category resolves its descendants server-side. */
|
|
801
|
+
categories?: { id: string; title: string; slug: string; parentId: string | null }[];
|
|
802
|
+
/** Curated collections this product belongs to, with its position in each. */
|
|
803
|
+
collections?: { id: string; title: string; slug: string; isFeatured: boolean; position: number }[];
|
|
755
804
|
variants: IPublicProductVariant[];
|
|
756
805
|
artist: string;
|
|
757
806
|
credits: string;
|
package/src/ui/headless/index.ts
CHANGED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import type { IPublicProductVariant } from "../../types/models";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Picking a version of a product, on any number of axes.
|
|
6
|
+
*
|
|
7
|
+
* This replaces a hardcoded colour → size cascade. That cascade could not
|
|
8
|
+
* express a third axis, so an album sold as MP3 / WAV / Stems had nowhere to put
|
|
9
|
+
* "Format", and it read `variant.color` — a column one writer fills with a hex
|
|
10
|
+
* and another with a name.
|
|
11
|
+
*
|
|
12
|
+
* The cascade itself is kept, generalised: each axis is narrowed by the axes
|
|
13
|
+
* BEFORE it. Picking "Black" leaves only the sizes that exist in black, exactly
|
|
14
|
+
* as before, and picking "FLAC" would leave only the editions pressed in FLAC.
|
|
15
|
+
* Axis order comes from the server, which sorts by the axis's own position, so
|
|
16
|
+
* every variant of a product presents its options the same way round.
|
|
17
|
+
*
|
|
18
|
+
* Shared by BOTH rendering surfaces. The Craft themes in `frontend-shared`
|
|
19
|
+
* import it from here, the way they already import the cart and audio player —
|
|
20
|
+
* the dependency runs one way, so the selection rule cannot drift between a
|
|
21
|
+
* code site and a Craft site even though their markup is unrelated.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export type VariantAxisValue = {
|
|
25
|
+
optionValueId: string;
|
|
26
|
+
value: string;
|
|
27
|
+
/** Only set when the value genuinely is a colour. Render a swatch if present. */
|
|
28
|
+
swatchHex: string | null;
|
|
29
|
+
/** False when every variant carrying it is out of stock. */
|
|
30
|
+
isAvailable: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type VariantAxis = {
|
|
34
|
+
optionTypeId: string;
|
|
35
|
+
/** "Colour", "Size", "Format" — as the creator named it. */
|
|
36
|
+
axis: string;
|
|
37
|
+
displayType: string;
|
|
38
|
+
values: VariantAxisValue[];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type Selection = Record<string, string | undefined>;
|
|
42
|
+
|
|
43
|
+
const optionFor = (variant: IPublicProductVariant, optionTypeId: string) =>
|
|
44
|
+
variant.options?.find((option) => option.optionTypeId === optionTypeId);
|
|
45
|
+
|
|
46
|
+
/** Does this variant match every selection made on the axes listed? */
|
|
47
|
+
const matches = (variant: IPublicProductVariant, selection: Selection, upToAxes: string[]) =>
|
|
48
|
+
upToAxes.every((optionTypeId) => {
|
|
49
|
+
const chosen = selection[optionTypeId];
|
|
50
|
+
if (!chosen) return true;
|
|
51
|
+
return optionFor(variant, optionTypeId)?.optionValueId === chosen;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export function useVariantSelection(variants: IPublicProductVariant[]) {
|
|
55
|
+
const [selection, setSelection] = useState<Selection>({});
|
|
56
|
+
|
|
57
|
+
// The axes this product uses, in server order, deduped. Built from the
|
|
58
|
+
// variants themselves rather than a separate list, so a product can never
|
|
59
|
+
// advertise an axis none of its versions sits on.
|
|
60
|
+
const axisOrder = useMemo(() => {
|
|
61
|
+
const seen: { optionTypeId: string; axis: string; displayType: string }[] = [];
|
|
62
|
+
for (const variant of variants ?? []) {
|
|
63
|
+
for (const option of variant.options ?? []) {
|
|
64
|
+
if (!seen.some((s) => s.optionTypeId === option.optionTypeId)) {
|
|
65
|
+
seen.push({ optionTypeId: option.optionTypeId, axis: option.axis, displayType: option.displayType });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return seen;
|
|
70
|
+
}, [variants]);
|
|
71
|
+
|
|
72
|
+
const axes: VariantAxis[] = useMemo(() => {
|
|
73
|
+
return axisOrder.map((axis, index) => {
|
|
74
|
+
// Narrowed by the axes before it, and only those — an axis must not be
|
|
75
|
+
// constrained by a LATER pick, or choosing a size would start hiding
|
|
76
|
+
// colours and the buyer could reach a state with nothing selectable.
|
|
77
|
+
const earlier = axisOrder.slice(0, index).map((a) => a.optionTypeId);
|
|
78
|
+
const candidates = (variants ?? []).filter((variant) => matches(variant, selection, earlier));
|
|
79
|
+
|
|
80
|
+
const values: VariantAxisValue[] = [];
|
|
81
|
+
for (const variant of candidates) {
|
|
82
|
+
const option = optionFor(variant, axis.optionTypeId);
|
|
83
|
+
if (!option) continue;
|
|
84
|
+
const existing = values.find((v) => v.optionValueId === option.optionValueId);
|
|
85
|
+
const isActive = variant.availabilityStatus === "active";
|
|
86
|
+
if (existing) {
|
|
87
|
+
existing.isAvailable = existing.isAvailable || isActive;
|
|
88
|
+
} else {
|
|
89
|
+
values.push({
|
|
90
|
+
optionValueId: option.optionValueId,
|
|
91
|
+
value: option.value,
|
|
92
|
+
swatchHex: option.swatchHex,
|
|
93
|
+
isAvailable: isActive,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { ...axis, values };
|
|
98
|
+
});
|
|
99
|
+
}, [axisOrder, variants, selection]);
|
|
100
|
+
|
|
101
|
+
const select = useCallback(
|
|
102
|
+
(optionTypeId: string, optionValueId: string) => {
|
|
103
|
+
setSelection((current) => {
|
|
104
|
+
const index = axisOrder.findIndex((a) => a.optionTypeId === optionTypeId);
|
|
105
|
+
const next: Selection = { ...current, [optionTypeId]: optionValueId };
|
|
106
|
+
// Changing an axis invalidates everything narrowed by it. Keeping a
|
|
107
|
+
// stale later pick is how you end up "selecting" a variant that does
|
|
108
|
+
// not exist — the buyer sees Black/XL highlighted and the button dead.
|
|
109
|
+
for (const later of axisOrder.slice(index + 1)) next[later.optionTypeId] = undefined;
|
|
110
|
+
return next;
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
[axisOrder],
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const selectedVariant = useMemo(() => {
|
|
117
|
+
// A product with no axes has exactly one version — every digital release in
|
|
118
|
+
// the catalogue before formats existed.
|
|
119
|
+
if (axisOrder.length === 0) {
|
|
120
|
+
return variants?.find((v) => v.isDefault) ?? variants?.[0];
|
|
121
|
+
}
|
|
122
|
+
if (axisOrder.some((a) => !selection[a.optionTypeId])) return undefined;
|
|
123
|
+
return (variants ?? []).find((variant) =>
|
|
124
|
+
axisOrder.every((a) => optionFor(variant, a.optionTypeId)?.optionValueId === selection[a.optionTypeId]),
|
|
125
|
+
);
|
|
126
|
+
}, [axisOrder, selection, variants]);
|
|
127
|
+
|
|
128
|
+
// Walk the axes in order and pre-pick the first value that is actually in
|
|
129
|
+
// stock, falling back to the first at all so the page is never blank.
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
const missing = axes.find((axis) => !selection[axis.optionTypeId] && axis.values.length > 0);
|
|
132
|
+
if (!missing) return;
|
|
133
|
+
const first = missing.values.find((v) => v.isAvailable) ?? missing.values[0];
|
|
134
|
+
setSelection((current) => ({ ...current, [missing.optionTypeId]: first.optionValueId }));
|
|
135
|
+
}, [axes, selection]);
|
|
136
|
+
|
|
137
|
+
return { axes, selection, select, selectedVariant, hasOptions: axisOrder.length > 0 };
|
|
138
|
+
}
|
package/src/ui/index.ts
CHANGED
|
@@ -65,6 +65,7 @@ export { ContactForm, type ContactFormProps } from "./styled/ContactForm";
|
|
|
65
65
|
export { Paywall, type PaywallProps } from "./styled/Paywall";
|
|
66
66
|
export { ReactionBar, type ReactionBarProps } from "./styled/ReactionBar";
|
|
67
67
|
export { ProductGrid, type ProductGridProps } from "./styled/ProductGrid";
|
|
68
|
+
export { ProductBrowseNav, type ProductBrowseNavProps } from "./styled/ProductBrowseNav";
|
|
68
69
|
export { Addons, type AddonsProps } from "./styled/Addons";
|
|
69
70
|
export { BundleConfirmation, type BundleConfirmationProps } from "./styled/BundleConfirmation";
|
|
70
71
|
export { ProductDetail, type ProductDetailProps } from "./styled/ProductDetail";
|
|
@@ -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 })}>
|
|
@@ -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;
|