create-brainerce-store 1.72.0 → 1.74.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.
Files changed (88) hide show
  1. package/dist/index.js +37 -4
  2. package/messages/en.json +30 -1
  3. package/messages/he.json +30 -1
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/AGENTS.md.ejs +63 -16
  6. package/templates/nextjs/base/AI-GUIDE.md +17 -4
  7. package/templates/nextjs/base/CLAUDE.md.ejs +67 -20
  8. package/templates/nextjs/base/TRANSLATIONS.md +207 -207
  9. package/templates/nextjs/base/src/app/account/page.tsx +168 -122
  10. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +10 -1
  11. package/templates/nextjs/base/src/app/checkout/page.tsx +1179 -1074
  12. package/templates/nextjs/base/src/app/layout.tsx.ejs +48 -4
  13. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +98 -3
  14. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +14 -2
  15. package/templates/nextjs/base/src/components/account/loyalty-panel.tsx +226 -0
  16. package/templates/nextjs/base/src/components/account/order-history.tsx +422 -371
  17. package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +85 -85
  18. package/templates/nextjs/base/src/components/account/saved-payment-methods.tsx +104 -0
  19. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +618 -612
  20. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +354 -306
  21. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +141 -74
  22. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +218 -127
  23. package/templates/nextjs/base/src/core/hooks/use-home-data.ts +63 -51
  24. package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +379 -295
  25. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +33 -1
  26. package/templates/nextjs/base/src/core/lib/capabilities.ts +65 -0
  27. package/templates/nextjs/base/src/core/lib/display-price.ts +139 -0
  28. package/templates/nextjs/base/src/core/lib/region.server.ts +112 -0
  29. package/templates/nextjs/base/src/core/lib/region.ts +112 -0
  30. package/templates/nextjs/base/src/core/lib/store-info.ts +19 -0
  31. package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +423 -243
  32. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +174 -164
  33. package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +109 -110
  34. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +219 -176
  35. package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +191 -0
  36. package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +137 -137
  37. package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +108 -0
  38. package/templates/nextjs/base/src/ui/home/home-client.tsx +27 -29
  39. package/templates/nextjs/base/src/ui/layout/header-search.tsx +249 -0
  40. package/templates/nextjs/base/src/ui/layout/region-switcher.tsx +59 -0
  41. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +171 -152
  42. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +197 -191
  43. package/templates/nextjs/base/src/ui/product/product-card.tsx +25 -52
  44. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +12 -3
  45. package/templates/nextjs/base/src/ui/product/product-listing.tsx +460 -446
  46. package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +108 -102
  47. package/templates/nextjs/base/src/ui/product/review-form.tsx +33 -11
  48. package/templates/nextjs/base/src/ui/product/stock-badge.tsx +15 -3
  49. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +48 -4
  50. package/templates/nextjs/designs/atelier/messages-patch/en.json +21 -25
  51. package/templates/nextjs/designs/atelier/messages-patch/he.json +21 -25
  52. package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +177 -181
  53. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +168 -158
  54. package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +106 -107
  55. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +229 -184
  56. package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +131 -131
  57. package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +108 -0
  58. package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +10 -1
  59. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +178 -174
  60. package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +4 -6
  61. package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +150 -78
  62. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +79 -46
  63. package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +264 -0
  64. package/templates/nextjs/designs/atelier/ui/layout/region-switcher.tsx +44 -0
  65. package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +5 -0
  66. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +202 -196
  67. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +27 -52
  68. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +12 -3
  69. package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +332 -260
  70. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +101 -95
  71. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +30 -10
  72. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +17 -2
  73. package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +9 -0
  74. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +137 -137
  75. package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +98 -99
  76. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +140 -140
  77. package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +142 -0
  78. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +124 -124
  79. package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +104 -0
  80. package/templates/nextjs/ui-canvas/home/home-client.tsx +24 -26
  81. package/templates/nextjs/ui-canvas/layout/region-switcher.tsx +51 -0
  82. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +3 -0
  83. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +174 -168
  84. package/templates/nextjs/ui-canvas/product/product-card.tsx +9 -51
  85. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +5 -3
  86. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +105 -99
  87. package/templates/nextjs/ui-canvas/product/review-form.tsx +9 -1
  88. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +17 -2
@@ -1,295 +1,379 @@
1
- 'use client';
2
-
3
- import { useEffect, useMemo, useState, useCallback } from 'react';
4
- import { useSearchParams } from 'next/navigation';
5
- import { useRouter } from '@/core/lib/navigation';
6
- import type { Product, ProductQueryParams, PublicMetafieldDefinition } from 'brainerce';
7
- import { getClient } from '@/core/lib/brainerce';
8
-
9
- const PAGE_SIZE = 20;
10
-
11
- /**
12
- * Metafield (custom-field) values are passed in the URL as `mf_<key>=v1,v2`
13
- * for readability (no JSON encoding). The SDK assembles them back into
14
- * `{ [key]: string[] }` before calling `client.getProducts`.
15
- *
16
- * Only definitions with `filterable: true` of type SELECT / MULTI_SELECT /
17
- * BOOLEAN are honored server-side the storefront renders facets only for
18
- * those.
19
- */
20
- const METAFIELD_URL_PREFIX = 'mf_';
21
- const FILTERABLE_TYPES = new Set(['SELECT', 'MULTI_SELECT', 'BOOLEAN']);
22
-
23
- export type SortOption = {
24
- labelKey: 'sortNewest' | 'sortNameAZ' | 'sortNameZA' | 'sortPriceLow' | 'sortPriceHigh';
25
- sortBy: ProductQueryParams['sortBy'];
26
- sortOrder: ProductQueryParams['sortOrder'];
27
- };
28
-
29
- export const sortOptions: SortOption[] = [
30
- { labelKey: 'sortNewest', sortBy: 'createdAt', sortOrder: 'desc' },
31
- { labelKey: 'sortNameAZ', sortBy: 'name', sortOrder: 'asc' },
32
- { labelKey: 'sortNameZA', sortBy: 'name', sortOrder: 'desc' },
33
- { labelKey: 'sortPriceLow', sortBy: 'price', sortOrder: 'asc' },
34
- { labelKey: 'sortPriceHigh', sortBy: 'price', sortOrder: 'desc' },
35
- ];
36
-
37
- export interface CategoryNode {
38
- id: string;
39
- name: string;
40
- image?: string | null;
41
- parentId?: string | null;
42
- children: CategoryNode[];
43
- }
44
-
45
- /** Collect all descendant IDs (including self) */
46
- export function getAllDescendantIds(node: CategoryNode): string[] {
47
- const ids = [node.id];
48
- for (const child of node.children) {
49
- ids.push(...getAllDescendantIds(child));
50
- }
51
- return ids;
52
- }
53
-
54
- /** Check if a category or any of its descendants matches the selected ID */
55
- export function isActiveInTree(node: CategoryNode, selectedId: string): boolean {
56
- if (node.id === selectedId) return true;
57
- return node.children.some((child) => isActiveInTree(child, selectedId));
58
- }
59
-
60
- export interface UseProductListingResult {
61
- /** Loaded products (accumulates across "load more" pages). */
62
- products: Product[];
63
- loading: boolean;
64
- loadingMore: boolean;
65
- page: number;
66
- totalPages: number;
67
- total: number;
68
- /** Facet sources. */
69
- categories: CategoryNode[];
70
- brands: Array<{ id: string; name: string }>;
71
- tags: Array<{ id: string; name: string }>;
72
- metafieldDefs: PublicMetafieldDefinition[];
73
- /** Current URL filter state. */
74
- searchQuery: string;
75
- categoryId: string;
76
- brandId: string;
77
- tagId: string;
78
- sortIndex: number;
79
- /** Handlers. */
80
- handleLoadMore: () => void;
81
- /** Set/clear a raw URL query param (`category`, `brand`, `tag`, `sort`, …). */
82
- updateParam: (key: string, value: string) => void;
83
- handleCategorySelect: (id: string) => void;
84
- /** Read the currently-selected values for a given metafield definition. */
85
- getSelectedMetafieldValues: (key: string) => string[];
86
- /** Replace the selected values of a metafield facet. */
87
- setMetafieldValue: (key: string, values: string[]) => void;
88
- }
89
-
90
- /**
91
- * Product-listing behavior: URL-driven filters (search / category / brand /
92
- * tag / sort / metafield facets), product loading, and load-more pagination.
93
- * Pure data/behavior rendering lives in `ui/product/product-listing.tsx`.
94
- *
95
- * Must be rendered inside a `<Suspense>` boundary (uses `useSearchParams`).
96
- */
97
- export function useProductListing(): UseProductListingResult {
98
- const searchParams = useSearchParams();
99
- const router = useRouter();
100
-
101
- const searchQuery = searchParams.get('search') || '';
102
- const categoryId = searchParams.get('category') || '';
103
- const brandId = searchParams.get('brand') || '';
104
- const tagId = searchParams.get('tag') || '';
105
- const sortParam = searchParams.get('sort') || '0';
106
-
107
- const [products, setProducts] = useState<Product[]>([]);
108
- const [loading, setLoading] = useState(true);
109
- const [loadingMore, setLoadingMore] = useState(false);
110
- const [page, setPage] = useState(1);
111
- const [totalPages, setTotalPages] = useState(1);
112
- const [total, setTotal] = useState(0);
113
- const [categories, setCategories] = useState<CategoryNode[]>([]);
114
- const [brands, setBrands] = useState<Array<{ id: string; name: string }>>([]);
115
- const [tags, setTags] = useState<Array<{ id: string; name: string }>>([]);
116
- const [metafieldDefs, setMetafieldDefs] = useState<PublicMetafieldDefinition[]>([]);
117
-
118
- const sortIndex = parseInt(sortParam, 10) || 0;
119
- const currentSort = sortOptions[sortIndex] || sortOptions[0];
120
-
121
- // Load categories, brands, tags, and metafield definitions (for custom-field facets).
122
- // The metafield-definitions endpoint already returns only definitions
123
- // published to this sales channel; we filter client-side to the ones flagged
124
- // `filterable: true` and of a UI-renderable type.
125
- useEffect(() => {
126
- async function loadFilters() {
127
- const client = getClient();
128
- const [catRes, brandRes, tagRes, mfRes] = await Promise.allSettled([
129
- client.getCategories(),
130
- client.getBrands(),
131
- client.getTags(),
132
- client.getPublicMetafieldDefinitions(),
133
- ]);
134
- if (catRes.status === 'fulfilled') setCategories(catRes.value.categories as CategoryNode[]);
135
- if (brandRes.status === 'fulfilled') setBrands(brandRes.value.brands);
136
- if (tagRes.status === 'fulfilled') setTags(tagRes.value.tags);
137
- if (mfRes.status === 'fulfilled') {
138
- setMetafieldDefs(
139
- mfRes.value.definitions.filter(
140
- (d) => d.filterable === true && FILTERABLE_TYPES.has(d.type)
141
- )
142
- );
143
- }
144
- }
145
- loadFilters();
146
- }, []);
147
-
148
- // Stable serialized signature of the selected metafield facets — used as
149
- // the `loadProducts` dependency (and to derive the SDK shape). When no
150
- // facets are selected, this is `''` so the SDK call omits `metafields`
151
- // entirely and the backend short-circuits the filter branch.
152
- const metafieldsKey = useMemo(() => {
153
- if (metafieldDefs.length === 0) return '';
154
- const parts: string[] = [];
155
- for (const def of metafieldDefs) {
156
- const raw = searchParams.get(`${METAFIELD_URL_PREFIX}${def.key}`);
157
- if (raw) parts.push(`${def.key}=${raw}`);
158
- }
159
- return parts.sort().join('&');
160
- }, [metafieldDefs, searchParams]);
161
-
162
- // Load products when filters change
163
- const loadProducts = useCallback(
164
- async (pageNum: number, append: boolean) => {
165
- try {
166
- if (append) {
167
- setLoadingMore(true);
168
- } else {
169
- setLoading(true);
170
- }
171
-
172
- const client = getClient();
173
- const params: ProductQueryParams = {
174
- page: pageNum,
175
- limit: PAGE_SIZE,
176
- sortBy: currentSort.sortBy,
177
- sortOrder: currentSort.sortOrder,
178
- };
179
-
180
- if (searchQuery) params.search = searchQuery;
181
- if (categoryId) params.categories = categoryId;
182
- if (brandId) params.brands = brandId;
183
- if (tagId) params.tags = tagId;
184
- // Build the `metafields` shape from `metafieldsKey` (each segment is
185
- // `key=v1,v2`). Equivalent to reading the URL params directly but
186
- // tied to the stable signature already in this callback's deps.
187
- if (metafieldsKey) {
188
- const mf: Record<string, string[]> = {};
189
- for (const segment of metafieldsKey.split('&')) {
190
- const eq = segment.indexOf('=');
191
- if (eq < 0) continue;
192
- const k = segment.slice(0, eq);
193
- const v = segment
194
- .slice(eq + 1)
195
- .split(',')
196
- .map((s) => s.trim())
197
- .filter(Boolean);
198
- if (v.length > 0) mf[k] = v;
199
- }
200
- if (Object.keys(mf).length > 0) params.metafields = mf;
201
- }
202
-
203
- const result = await client.getProducts(params);
204
-
205
- if (append) {
206
- setProducts((prev) => [...prev, ...result.data]);
207
- } else {
208
- setProducts(result.data);
209
- }
210
- setTotalPages(result.meta.totalPages);
211
- setTotal(result.meta.total);
212
- setPage(pageNum);
213
- } catch (err) {
214
- console.error('Failed to load products:', err);
215
- } finally {
216
- setLoading(false);
217
- setLoadingMore(false);
218
- }
219
- },
220
- [
221
- searchQuery,
222
- categoryId,
223
- brandId,
224
- tagId,
225
- currentSort.sortBy,
226
- currentSort.sortOrder,
227
- metafieldsKey,
228
- ]
229
- );
230
-
231
- useEffect(() => {
232
- loadProducts(1, false);
233
- }, [loadProducts]);
234
-
235
- function handleLoadMore() {
236
- if (page < totalPages && !loadingMore) {
237
- loadProducts(page + 1, true);
238
- }
239
- }
240
-
241
- function updateParam(key: string, value: string) {
242
- const params = new URLSearchParams(searchParams.toString());
243
- if (value) {
244
- params.set(key, value);
245
- } else {
246
- params.delete(key);
247
- }
248
- router.push(`/products?${params.toString()}`);
249
- }
250
-
251
- function handleCategorySelect(id: string) {
252
- updateParam('category', id);
253
- }
254
-
255
- /** Read the currently-selected values for a given metafield definition. */
256
- function getSelectedMetafieldValues(key: string): string[] {
257
- const raw = searchParams.get(`${METAFIELD_URL_PREFIX}${key}`);
258
- if (!raw) return [];
259
- return raw
260
- .split(',')
261
- .map((v) => v.trim())
262
- .filter(Boolean);
263
- }
264
-
265
- /**
266
- * Toggle one value on/off in a metafield facet's URL param. Multi-select
267
- * adds/removes from the list; single-select / boolean replace the value.
268
- */
269
- function setMetafieldValue(key: string, values: string[]) {
270
- updateParam(`${METAFIELD_URL_PREFIX}${key}`, values.join(','));
271
- }
272
-
273
- return {
274
- products,
275
- loading,
276
- loadingMore,
277
- page,
278
- totalPages,
279
- total,
280
- categories,
281
- brands,
282
- tags,
283
- metafieldDefs,
284
- searchQuery,
285
- categoryId,
286
- brandId,
287
- tagId,
288
- sortIndex,
289
- handleLoadMore,
290
- updateParam,
291
- handleCategorySelect,
292
- getSelectedMetafieldValues,
293
- setMetafieldValue,
294
- };
295
- }
1
+ 'use client';
2
+
3
+ import { useEffect, useMemo, useState, useCallback } from 'react';
4
+ import { useSearchParams } from 'next/navigation';
5
+ import { useRouter } from '@/core/lib/navigation';
6
+ import type { Product, ProductQueryParams, PublicMetafieldDefinition } from 'brainerce';
7
+ import { getClient } from '@/core/lib/brainerce';
8
+ import { useRegion } from '@/core/providers/store-provider';
9
+
10
+ const PAGE_SIZE = 20;
11
+
12
+ /**
13
+ * Metafield (custom-field) values are passed in the URL as `mf_<key>=v1,v2`
14
+ * for readability (no JSON encoding). The SDK assembles them back into
15
+ * `{ [key]: string[] }` before calling `client.getProducts`.
16
+ *
17
+ * Only definitions with `filterable: true` of type SELECT / MULTI_SELECT /
18
+ * BOOLEAN are honored server-side — the storefront renders facets only for
19
+ * those.
20
+ */
21
+ const METAFIELD_URL_PREFIX = 'mf_';
22
+ const FILTERABLE_TYPES = new Set(['SELECT', 'MULTI_SELECT', 'BOOLEAN']);
23
+
24
+ export type SortOption = {
25
+ labelKey: 'sortNewest' | 'sortNameAZ' | 'sortNameZA' | 'sortPriceLow' | 'sortPriceHigh';
26
+ sortBy: ProductQueryParams['sortBy'];
27
+ sortOrder: ProductQueryParams['sortOrder'];
28
+ };
29
+
30
+ export const sortOptions: SortOption[] = [
31
+ { labelKey: 'sortNewest', sortBy: 'createdAt', sortOrder: 'desc' },
32
+ { labelKey: 'sortNameAZ', sortBy: 'name', sortOrder: 'asc' },
33
+ { labelKey: 'sortNameZA', sortBy: 'name', sortOrder: 'desc' },
34
+ { labelKey: 'sortPriceLow', sortBy: 'price', sortOrder: 'asc' },
35
+ { labelKey: 'sortPriceHigh', sortBy: 'price', sortOrder: 'desc' },
36
+ ];
37
+
38
+ export interface CategoryNode {
39
+ id: string;
40
+ name: string;
41
+ image?: string | null;
42
+ parentId?: string | null;
43
+ children: CategoryNode[];
44
+ }
45
+
46
+ /** Collect all descendant IDs (including self) */
47
+ export function getAllDescendantIds(node: CategoryNode): string[] {
48
+ const ids = [node.id];
49
+ for (const child of node.children) {
50
+ ids.push(...getAllDescendantIds(child));
51
+ }
52
+ return ids;
53
+ }
54
+
55
+ /** Check if a category or any of its descendants matches the selected ID */
56
+ export function isActiveInTree(node: CategoryNode, selectedId: string): boolean {
57
+ if (node.id === selectedId) return true;
58
+ return node.children.some((child) => isActiveInTree(child, selectedId));
59
+ }
60
+
61
+ export interface UseProductListingResult {
62
+ /** Loaded products (accumulates across "load more" pages). */
63
+ products: Product[];
64
+ loading: boolean;
65
+ loadingMore: boolean;
66
+ page: number;
67
+ totalPages: number;
68
+ total: number;
69
+ /** Facet sources. */
70
+ categories: CategoryNode[];
71
+ brands: Array<{ id: string; name: string }>;
72
+ tags: Array<{ id: string; name: string }>;
73
+ metafieldDefs: PublicMetafieldDefinition[];
74
+ /**
75
+ * Product counts per facet value, as `{ [definitionKey]: { [value]: n } }`.
76
+ * Sourced from `getMetafieldFilters()`, which is the only endpoint that
77
+ * carries counts — `getPublicMetafieldDefinitions()` returns declared
78
+ * `enumValues` with no idea how many products actually use each one.
79
+ *
80
+ * Empty on a store where the call is unavailable or failed, so a renderer
81
+ * must treat a missing entry as "no count to show" rather than as zero.
82
+ */
83
+ metafieldCounts: Record<string, Record<string, number>>;
84
+ /** Current URL filter state. */
85
+ searchQuery: string;
86
+ categoryId: string;
87
+ brandId: string;
88
+ tagId: string;
89
+ /** Price bounds from the URL (`?minPrice=` / `?maxPrice=`), '' when unset. */
90
+ minPrice: string;
91
+ maxPrice: string;
92
+ /** Apply a price range. Pass '' for either end to clear that bound. */
93
+ setPriceRange: (min: string, max: string) => void;
94
+ sortIndex: number;
95
+ /** Handlers. */
96
+ handleLoadMore: () => void;
97
+ /** Set/clear a raw URL query param (`category`, `brand`, `tag`, `sort`, …). */
98
+ updateParam: (key: string, value: string) => void;
99
+ handleCategorySelect: (id: string) => void;
100
+ /** Read the currently-selected values for a given metafield definition. */
101
+ getSelectedMetafieldValues: (key: string) => string[];
102
+ /** Replace the selected values of a metafield facet. */
103
+ setMetafieldValue: (key: string, values: string[]) => void;
104
+ }
105
+
106
+ /**
107
+ * Product-listing behavior: URL-driven filters (search / category / brand /
108
+ * tag / sort / metafield facets), product loading, and load-more pagination.
109
+ * Pure data/behavior — rendering lives in `ui/product/product-listing.tsx`.
110
+ *
111
+ * Must be rendered inside a `<Suspense>` boundary (uses `useSearchParams`).
112
+ */
113
+ export function useProductListing(): UseProductListingResult {
114
+ const searchParams = useSearchParams();
115
+ const router = useRouter();
116
+ const { regionId } = useRegion();
117
+
118
+ const searchQuery = searchParams.get('search') || '';
119
+ const categoryId = searchParams.get('category') || '';
120
+ const brandId = searchParams.get('brand') || '';
121
+ const tagId = searchParams.get('tag') || '';
122
+ // `minPrice` / `maxPrice` are discrete top-level query keys on getProducts
123
+ // there is no `filters` object. The SDK builds its query string from a fixed
124
+ // whitelist, so a misspelt key is dropped silently and the caller gets a 200
125
+ // of unfiltered results rather than an error.
126
+ const minPrice = searchParams.get('minPrice') || '';
127
+ const maxPrice = searchParams.get('maxPrice') || '';
128
+ const sortParam = searchParams.get('sort') || '0';
129
+
130
+ const [products, setProducts] = useState<Product[]>([]);
131
+ const [loading, setLoading] = useState(true);
132
+ const [loadingMore, setLoadingMore] = useState(false);
133
+ const [page, setPage] = useState(1);
134
+ const [totalPages, setTotalPages] = useState(1);
135
+ const [total, setTotal] = useState(0);
136
+ const [categories, setCategories] = useState<CategoryNode[]>([]);
137
+ const [brands, setBrands] = useState<Array<{ id: string; name: string }>>([]);
138
+ const [tags, setTags] = useState<Array<{ id: string; name: string }>>([]);
139
+ const [metafieldDefs, setMetafieldDefs] = useState<PublicMetafieldDefinition[]>([]);
140
+ const [metafieldCounts, setMetafieldCounts] = useState<Record<string, Record<string, number>>>(
141
+ {}
142
+ );
143
+
144
+ const sortIndex = parseInt(sortParam, 10) || 0;
145
+ const currentSort = sortOptions[sortIndex] || sortOptions[0];
146
+
147
+ // Load categories, brands, tags, and metafield definitions (for custom-field facets).
148
+ // The metafield-definitions endpoint already returns only definitions
149
+ // published to this sales channel; we filter client-side to the ones flagged
150
+ // `filterable: true` and of a UI-renderable type.
151
+ useEffect(() => {
152
+ async function loadFilters() {
153
+ const client = getClient();
154
+ const [catRes, brandRes, tagRes, mfRes, mfCountRes] = await Promise.allSettled([
155
+ client.getCategories(),
156
+ client.getBrands(),
157
+ client.getTags(),
158
+ client.getPublicMetafieldDefinitions(),
159
+ // Facet values WITH product counts. Fetched alongside the definitions
160
+ // rather than instead of them: the definitions call is what decides
161
+ // which facets render at all (filterable + renderable type), and this
162
+ // one only enriches those with counts. Vibe-coded and storefront modes
163
+ // only it throws in API-key mode, which allSettled absorbs.
164
+ client.getMetafieldFilters(),
165
+ ]);
166
+ if (catRes.status === 'fulfilled') setCategories(catRes.value.categories as CategoryNode[]);
167
+ if (brandRes.status === 'fulfilled') setBrands(brandRes.value.brands);
168
+ if (tagRes.status === 'fulfilled') setTags(tagRes.value.tags);
169
+ if (mfRes.status === 'fulfilled') {
170
+ setMetafieldDefs(
171
+ mfRes.value.definitions.filter(
172
+ (d) => d.filterable === true && FILTERABLE_TYPES.has(d.type)
173
+ )
174
+ );
175
+ }
176
+ if (mfCountRes.status === 'fulfilled') {
177
+ const counts: Record<string, Record<string, number>> = {};
178
+ for (const filter of mfCountRes.value.filters) {
179
+ const byValue: Record<string, number> = {};
180
+ for (const entry of filter.values) byValue[entry.value] = entry.count;
181
+ counts[filter.key] = byValue;
182
+ }
183
+ setMetafieldCounts(counts);
184
+ }
185
+ }
186
+ loadFilters();
187
+ }, []);
188
+
189
+ // Stable serialized signature of the selected metafield facets — used as
190
+ // the `loadProducts` dependency (and to derive the SDK shape). When no
191
+ // facets are selected, this is `''` so the SDK call omits `metafields`
192
+ // entirely and the backend short-circuits the filter branch.
193
+ const metafieldsKey = useMemo(() => {
194
+ if (metafieldDefs.length === 0) return '';
195
+ const parts: string[] = [];
196
+ for (const def of metafieldDefs) {
197
+ const raw = searchParams.get(`${METAFIELD_URL_PREFIX}${def.key}`);
198
+ if (raw) parts.push(`${def.key}=${raw}`);
199
+ }
200
+ return parts.sort().join('&');
201
+ }, [metafieldDefs, searchParams]);
202
+
203
+ // Load products when filters change
204
+ const loadProducts = useCallback(
205
+ async (pageNum: number, append: boolean) => {
206
+ try {
207
+ if (append) {
208
+ setLoadingMore(true);
209
+ } else {
210
+ setLoading(true);
211
+ }
212
+
213
+ const client = getClient();
214
+ const params: ProductQueryParams = {
215
+ page: pageNum,
216
+ limit: PAGE_SIZE,
217
+ sortBy: currentSort.sortBy,
218
+ sortOrder: currentSort.sortOrder,
219
+ };
220
+
221
+ // ⛔ Region belongs on every product read. Omit it and this grid
222
+ // quotes the DEFAULT region's prices to every shopper on earth, on a
223
+ // page that looks perfectly correct. `undefined` on a store with no
224
+ // regions, and the SDK then omits the param.
225
+ if (regionId) params.regionId = regionId;
226
+
227
+ if (searchQuery) params.search = searchQuery;
228
+ if (categoryId) params.categories = categoryId;
229
+ if (brandId) params.brands = brandId;
230
+ if (tagId) params.tags = tagId;
231
+ // Sent only when the shopper set a bound, and only when it parses as a
232
+ // number: `minPrice`/`maxPrice` are typed `number` on ProductQueryParams,
233
+ // and a NaN would serialize into the query string and filter everything
234
+ // out. An unset bound is simply omitted.
235
+ const minPriceNum = minPrice === '' ? NaN : Number(minPrice);
236
+ const maxPriceNum = maxPrice === '' ? NaN : Number(maxPrice);
237
+ if (Number.isFinite(minPriceNum)) params.minPrice = minPriceNum;
238
+ if (Number.isFinite(maxPriceNum)) params.maxPrice = maxPriceNum;
239
+ // Build the `metafields` shape from `metafieldsKey` (each segment is
240
+ // `key=v1,v2`). Equivalent to reading the URL params directly but
241
+ // tied to the stable signature already in this callback's deps.
242
+ if (metafieldsKey) {
243
+ const mf: Record<string, string[]> = {};
244
+ for (const segment of metafieldsKey.split('&')) {
245
+ const eq = segment.indexOf('=');
246
+ if (eq < 0) continue;
247
+ const k = segment.slice(0, eq);
248
+ const v = segment
249
+ .slice(eq + 1)
250
+ .split(',')
251
+ .map((s) => s.trim())
252
+ .filter(Boolean);
253
+ if (v.length > 0) mf[k] = v;
254
+ }
255
+ if (Object.keys(mf).length > 0) params.metafields = mf;
256
+ }
257
+
258
+ const result = await client.getProducts(params);
259
+
260
+ if (append) {
261
+ setProducts((prev) => [...prev, ...result.data]);
262
+ } else {
263
+ setProducts(result.data);
264
+ }
265
+ setTotalPages(result.meta.totalPages);
266
+ setTotal(result.meta.total);
267
+ setPage(pageNum);
268
+ } catch (err) {
269
+ console.error('Failed to load products:', err);
270
+ } finally {
271
+ setLoading(false);
272
+ setLoadingMore(false);
273
+ }
274
+ },
275
+ [
276
+ searchQuery,
277
+ categoryId,
278
+ brandId,
279
+ tagId,
280
+ minPrice,
281
+ maxPrice,
282
+ currentSort.sortBy,
283
+ currentSort.sortOrder,
284
+ metafieldsKey,
285
+ // Switching region re-runs the query so the grid re-prices, exactly
286
+ // like changing a filter.
287
+ regionId,
288
+ ]
289
+ );
290
+
291
+ useEffect(() => {
292
+ loadProducts(1, false);
293
+ }, [loadProducts]);
294
+
295
+ function handleLoadMore() {
296
+ if (page < totalPages && !loadingMore) {
297
+ loadProducts(page + 1, true);
298
+ }
299
+ }
300
+
301
+ function updateParam(key: string, value: string) {
302
+ const params = new URLSearchParams(searchParams.toString());
303
+ if (value) {
304
+ params.set(key, value);
305
+ } else {
306
+ params.delete(key);
307
+ }
308
+ router.push(`/products?${params.toString()}`);
309
+ }
310
+
311
+ function handleCategorySelect(id: string) {
312
+ updateParam('category', id);
313
+ }
314
+
315
+ /**
316
+ * Set both price bounds in one navigation. Two `updateParam` calls would
317
+ * race: each reads the CURRENT `searchParams` and pushes, so the second
318
+ * would overwrite the first's key.
319
+ */
320
+ function setPriceRange(min: string, max: string) {
321
+ const params = new URLSearchParams(searchParams.toString());
322
+ if (min) {
323
+ params.set('minPrice', min);
324
+ } else {
325
+ params.delete('minPrice');
326
+ }
327
+ if (max) {
328
+ params.set('maxPrice', max);
329
+ } else {
330
+ params.delete('maxPrice');
331
+ }
332
+ router.push(`/products?${params.toString()}`);
333
+ }
334
+
335
+ /** Read the currently-selected values for a given metafield definition. */
336
+ function getSelectedMetafieldValues(key: string): string[] {
337
+ const raw = searchParams.get(`${METAFIELD_URL_PREFIX}${key}`);
338
+ if (!raw) return [];
339
+ return raw
340
+ .split(',')
341
+ .map((v) => v.trim())
342
+ .filter(Boolean);
343
+ }
344
+
345
+ /**
346
+ * Toggle one value on/off in a metafield facet's URL param. Multi-select
347
+ * adds/removes from the list; single-select / boolean replace the value.
348
+ */
349
+ function setMetafieldValue(key: string, values: string[]) {
350
+ updateParam(`${METAFIELD_URL_PREFIX}${key}`, values.join(','));
351
+ }
352
+
353
+ return {
354
+ products,
355
+ loading,
356
+ loadingMore,
357
+ page,
358
+ totalPages,
359
+ total,
360
+ categories,
361
+ brands,
362
+ tags,
363
+ metafieldDefs,
364
+ metafieldCounts,
365
+ searchQuery,
366
+ categoryId,
367
+ brandId,
368
+ tagId,
369
+ minPrice,
370
+ maxPrice,
371
+ setPriceRange,
372
+ sortIndex,
373
+ handleLoadMore,
374
+ updateParam,
375
+ handleCategorySelect,
376
+ getSelectedMetafieldValues,
377
+ setMetafieldValue,
378
+ };
379
+ }