@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 CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.11.0",
3
+ "version": "3.14.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "description": "Forge 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.",
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
- deliveryType?: ProductDeliveryType;
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, ProductCategory } from "../../types/models";
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?: ProductCategory[];
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 category. */
13
- export function useFeaturedProducts(category?: ProductCategory) {
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, category],
33
+ queryKey: ["featured-products", profileId, productType],
18
34
  queryFn: async () => {
19
35
  const res = await client.get("/public/products/featured", {
20
- params: { profileId, category },
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
- category: params?.category,
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
- export function useGetProductCategories() {
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: ProductCategory; description: string }[]>({
97
- queryKey: ["product-categories", profileId],
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,
@@ -584,15 +584,35 @@ export type IMedia = {
584
584
  previewStatus?: string | null;
585
585
  };
586
586
 
587
- export enum ProductCategory {
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
- category: ProductCategory;
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;
@@ -76,3 +76,4 @@ export * from "./work";
76
76
  // Funnel instrumentation — wrap a multi-step flow to record step-through and
77
77
  // drop-off on the first-party analytics feed.
78
78
  export * from "./funnel";
79
+ export * from "./useVariantSelection";
@@ -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 { 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 })}>
@@ -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;