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.
- package/dist/index.js +37 -4
- package/messages/en.json +30 -1
- package/messages/he.json +30 -1
- package/package.json +1 -1
- package/templates/nextjs/base/AGENTS.md.ejs +63 -16
- package/templates/nextjs/base/AI-GUIDE.md +17 -4
- package/templates/nextjs/base/CLAUDE.md.ejs +67 -20
- package/templates/nextjs/base/TRANSLATIONS.md +207 -207
- package/templates/nextjs/base/src/app/account/page.tsx +168 -122
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +10 -1
- package/templates/nextjs/base/src/app/checkout/page.tsx +1179 -1074
- package/templates/nextjs/base/src/app/layout.tsx.ejs +48 -4
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +98 -3
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +14 -2
- package/templates/nextjs/base/src/components/account/loyalty-panel.tsx +226 -0
- package/templates/nextjs/base/src/components/account/order-history.tsx +422 -371
- package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +85 -85
- package/templates/nextjs/base/src/components/account/saved-payment-methods.tsx +104 -0
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +618 -612
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +354 -306
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +141 -74
- package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +218 -127
- package/templates/nextjs/base/src/core/hooks/use-home-data.ts +63 -51
- package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +379 -295
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +33 -1
- package/templates/nextjs/base/src/core/lib/capabilities.ts +65 -0
- package/templates/nextjs/base/src/core/lib/display-price.ts +139 -0
- package/templates/nextjs/base/src/core/lib/region.server.ts +112 -0
- package/templates/nextjs/base/src/core/lib/region.ts +112 -0
- package/templates/nextjs/base/src/core/lib/store-info.ts +19 -0
- package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +423 -243
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +174 -164
- package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +109 -110
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +219 -176
- package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +191 -0
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +137 -137
- package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +108 -0
- package/templates/nextjs/base/src/ui/home/home-client.tsx +27 -29
- package/templates/nextjs/base/src/ui/layout/header-search.tsx +249 -0
- package/templates/nextjs/base/src/ui/layout/region-switcher.tsx +59 -0
- package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +171 -152
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +197 -191
- package/templates/nextjs/base/src/ui/product/product-card.tsx +25 -52
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +12 -3
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +460 -446
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +108 -102
- package/templates/nextjs/base/src/ui/product/review-form.tsx +33 -11
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +15 -3
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +48 -4
- package/templates/nextjs/designs/atelier/messages-patch/en.json +21 -25
- package/templates/nextjs/designs/atelier/messages-patch/he.json +21 -25
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +177 -181
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +168 -158
- package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +106 -107
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +229 -184
- package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +131 -131
- package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +108 -0
- package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +10 -1
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +178 -174
- package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +4 -6
- package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +150 -78
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +79 -46
- package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +264 -0
- package/templates/nextjs/designs/atelier/ui/layout/region-switcher.tsx +44 -0
- package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +5 -0
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +202 -196
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +27 -52
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +12 -3
- package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +332 -260
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +101 -95
- package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +30 -10
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +17 -2
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +9 -0
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +137 -137
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +98 -99
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +140 -140
- package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +142 -0
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +124 -124
- package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +104 -0
- package/templates/nextjs/ui-canvas/home/home-client.tsx +24 -26
- package/templates/nextjs/ui-canvas/layout/region-switcher.tsx +51 -0
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +3 -0
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +174 -168
- package/templates/nextjs/ui-canvas/product/product-card.tsx +9 -51
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +5 -3
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +105 -99
- package/templates/nextjs/ui-canvas/product/review-form.tsx +9 -1
- 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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
{ labelKey: '
|
|
32
|
-
{ labelKey: '
|
|
33
|
-
{ labelKey: '
|
|
34
|
-
{ labelKey: '
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
products
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
+
}
|