create-brainerce-store 1.88.0 → 1.89.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 +3 -2
- package/messages/en.json +21 -0
- package/messages/he.json +21 -0
- package/package.json +3 -2
- package/templates/nextjs/base/.eslintrc.json +2 -0
- package/templates/nextjs/base/AI-GUIDE.md +259 -221
- package/templates/nextjs/base/TRANSLATIONS.md +6 -0
- package/templates/nextjs/base/scripts/agent-docs.mjs +76 -0
- package/templates/nextjs/base/scripts/connect.mjs +6 -0
- package/templates/nextjs/base/src/app/api/auth/oauth-callback/route.ts +102 -96
- package/templates/nextjs/base/src/app/blog/[slug]/{page.tsx.ejs → page.tsx} +41 -144
- package/templates/nextjs/base/src/app/blog/page.tsx +187 -0
- package/templates/nextjs/base/src/app/blog/rss.xml/route.ts +7 -3
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +26 -11
- package/templates/nextjs/base/src/app/faq/page.tsx.ejs +20 -4
- package/templates/nextjs/base/src/app/layout.tsx.ejs +18 -50
- package/templates/nextjs/base/src/app/page.tsx +109 -75
- package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +7 -0
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +267 -220
- package/templates/nextjs/base/src/app/products/page.tsx +184 -19
- package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +9 -1
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +30 -1
- package/templates/nextjs/base/src/components/seo/article-json-ld.tsx +12 -6
- package/templates/nextjs/base/src/components/seo/category-json-ld.tsx +9 -10
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +15 -6
- package/templates/nextjs/base/src/components/seo/product-og-meta.tsx +46 -0
- package/templates/nextjs/base/src/core/hooks/use-home-data.ts +24 -4
- package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +99 -116
- package/templates/nextjs/base/src/core/hooks/use-reviews-enabled.ts +17 -0
- package/templates/nextjs/base/src/core/lib/listing-query.ts +199 -0
- package/templates/nextjs/base/src/core/lib/locale-path.ts +29 -0
- package/templates/nextjs/base/src/core/lib/reviews-disabled.ts +19 -0
- package/templates/nextjs/base/src/core/lib/server-messages.ts.ejs +34 -0
- package/templates/nextjs/base/src/core/lib/store-info.ts +20 -0
- package/templates/nextjs/base/src/core/providers/listing-seed.tsx +66 -0
- package/templates/nextjs/base/src/ui/product/product-card.tsx +7 -2
- package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +6 -2
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +18 -50
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +8 -2
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +7 -2
- package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +6 -2
- package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +6 -2
- package/templates/nextjs/base/src/app/blog/page.tsx.ejs +0 -277
- package/templates/nextjs/base/src/app/products/layout.tsx.ejs +0 -18
|
@@ -1,25 +1,27 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useEffect, useMemo, useState, useCallback } from 'react';
|
|
3
|
+
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
|
4
4
|
import { useSearchParams } from 'next/navigation';
|
|
5
5
|
import { useRouter } from '@/core/lib/navigation';
|
|
6
6
|
import type { Product, ProductQueryParams, PublicMetafieldDefinition } from 'brainerce';
|
|
7
7
|
import { getClient } from '@/core/lib/brainerce';
|
|
8
|
+
import {
|
|
9
|
+
buildProductQuery,
|
|
10
|
+
isFilterableMetafield,
|
|
11
|
+
listingSignature,
|
|
12
|
+
LISTING_SORTS,
|
|
13
|
+
METAFIELD_URL_PREFIX,
|
|
14
|
+
readListingUrlState,
|
|
15
|
+
selectedMetafieldsKey,
|
|
16
|
+
} from '@/core/lib/listing-query';
|
|
17
|
+
import { useListingSeed } from '@/core/providers/listing-seed';
|
|
8
18
|
import { useRegion } from '@/core/providers/store-provider';
|
|
9
19
|
|
|
10
|
-
const PAGE_SIZE = 20;
|
|
11
|
-
|
|
12
20
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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.
|
|
21
|
+
* How the URL becomes a query (page size, sort table, `mf_<key>` facets, which
|
|
22
|
+
* custom fields count as filterable) lives in `@/core/lib/listing-query`, shared
|
|
23
|
+
* with the server page that renders the first page of `/products`.
|
|
20
24
|
*/
|
|
21
|
-
const METAFIELD_URL_PREFIX = 'mf_';
|
|
22
|
-
const FILTERABLE_TYPES = new Set(['SELECT', 'MULTI_SELECT', 'BOOLEAN']);
|
|
23
25
|
|
|
24
26
|
export type SortOption = {
|
|
25
27
|
labelKey: 'sortNewest' | 'sortNameAZ' | 'sortNameZA' | 'sortPriceLow' | 'sortPriceHigh';
|
|
@@ -28,11 +30,11 @@ export type SortOption = {
|
|
|
28
30
|
};
|
|
29
31
|
|
|
30
32
|
export const sortOptions: SortOption[] = [
|
|
31
|
-
{ labelKey: 'sortNewest',
|
|
32
|
-
{ labelKey: 'sortNameAZ',
|
|
33
|
-
{ labelKey: 'sortNameZA',
|
|
34
|
-
{ labelKey: 'sortPriceLow',
|
|
35
|
-
{ labelKey: 'sortPriceHigh',
|
|
33
|
+
{ labelKey: 'sortNewest', ...LISTING_SORTS[0] },
|
|
34
|
+
{ labelKey: 'sortNameAZ', ...LISTING_SORTS[1] },
|
|
35
|
+
{ labelKey: 'sortNameZA', ...LISTING_SORTS[2] },
|
|
36
|
+
{ labelKey: 'sortPriceLow', ...LISTING_SORTS[3] },
|
|
37
|
+
{ labelKey: 'sortPriceHigh', ...LISTING_SORTS[4] },
|
|
36
38
|
];
|
|
37
39
|
|
|
38
40
|
export interface CategoryNode {
|
|
@@ -115,35 +117,64 @@ export function useProductListing(): UseProductListingResult {
|
|
|
115
117
|
const router = useRouter();
|
|
116
118
|
const { regionId } = useRegion();
|
|
117
119
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
const tagId
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
120
|
+
// Everything the URL says about the query, read by the same function the
|
|
121
|
+
// server page uses.
|
|
122
|
+
const urlState = useMemo(() => readListingUrlState(searchParams), [searchParams]);
|
|
123
|
+
const { search: searchQuery, categoryId, brandId, tagId, minPrice, maxPrice } = urlState;
|
|
124
|
+
const sortIndex = urlState.sortIndex;
|
|
125
|
+
|
|
126
|
+
// The server renders the first page of THIS url (`app/products/page.tsx`), so
|
|
127
|
+
// the initial HTML carries real products. The seed answers the query only
|
|
128
|
+
// while its signature equals the one this hook computes: same filters, sort,
|
|
129
|
+
// page, facets and region. Anything else is fetched exactly as before.
|
|
130
|
+
const listingSeed = useListingSeed();
|
|
131
|
+
|
|
132
|
+
// Filterable custom-field definitions. Seeded when the URL selects a facet, so
|
|
133
|
+
// the signature below can be computed before the browser has loaded them.
|
|
134
|
+
const [metafieldDefs, setMetafieldDefs] = useState<PublicMetafieldDefinition[]>(
|
|
135
|
+
listingSeed?.metafieldDefs ?? []
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// Stable serialized signature of the selected metafield facets: the
|
|
139
|
+
// `loadProducts` dependency, and the source of the SDK `metafields` shape.
|
|
140
|
+
// `''` when nothing is selected, so the SDK call omits `metafields` entirely
|
|
141
|
+
// and the backend short-circuits that filter branch.
|
|
142
|
+
const metafieldsKey = useMemo(
|
|
143
|
+
() =>
|
|
144
|
+
selectedMetafieldsKey(
|
|
145
|
+
urlState.metafields,
|
|
146
|
+
metafieldDefs.map((d) => d.key)
|
|
147
|
+
),
|
|
148
|
+
[urlState.metafields, metafieldDefs]
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const querySignature = listingSignature(urlState, {
|
|
152
|
+
regionId,
|
|
153
|
+
metafieldsKey,
|
|
154
|
+
page: urlState.page,
|
|
155
|
+
});
|
|
156
|
+
const seedApplies = !!listingSeed && listingSeed.signature === querySignature;
|
|
129
157
|
|
|
130
|
-
const [products, setProducts] = useState<Product[]>(
|
|
131
|
-
|
|
158
|
+
const [products, setProducts] = useState<Product[]>(
|
|
159
|
+
seedApplies && listingSeed ? listingSeed.products : []
|
|
160
|
+
);
|
|
161
|
+
// ⛔ A seeded listing starts with `loading` false. The spinner branch renders
|
|
162
|
+
// instead of the grid while it is true, so a `true` here puts the spinner, not
|
|
163
|
+
// the products, into the server HTML and the seed buys nothing.
|
|
164
|
+
const [loading, setLoading] = useState(!seedApplies);
|
|
132
165
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
133
|
-
const [page, setPage] = useState(
|
|
134
|
-
const [totalPages, setTotalPages] = useState(
|
|
135
|
-
|
|
166
|
+
const [page, setPage] = useState(seedApplies && listingSeed ? listingSeed.page : urlState.page);
|
|
167
|
+
const [totalPages, setTotalPages] = useState(
|
|
168
|
+
seedApplies && listingSeed ? listingSeed.totalPages : 1
|
|
169
|
+
);
|
|
170
|
+
const [total, setTotal] = useState(seedApplies && listingSeed ? listingSeed.total : 0);
|
|
136
171
|
const [categories, setCategories] = useState<CategoryNode[]>([]);
|
|
137
172
|
const [brands, setBrands] = useState<Array<{ id: string; name: string }>>([]);
|
|
138
173
|
const [tags, setTags] = useState<Array<{ id: string; name: string }>>([]);
|
|
139
|
-
const [metafieldDefs, setMetafieldDefs] = useState<PublicMetafieldDefinition[]>([]);
|
|
140
174
|
const [metafieldCounts, setMetafieldCounts] = useState<Record<string, Record<string, number>>>(
|
|
141
175
|
{}
|
|
142
176
|
);
|
|
143
177
|
|
|
144
|
-
const sortIndex = parseInt(sortParam, 10) || 0;
|
|
145
|
-
const currentSort = sortOptions[sortIndex] || sortOptions[0];
|
|
146
|
-
|
|
147
178
|
// Load categories, brands, tags, and metafield definitions (for custom-field facets).
|
|
148
179
|
// The metafield-definitions endpoint already returns only definitions
|
|
149
180
|
// published to this sales channel; we filter client-side to the ones flagged
|
|
@@ -167,11 +198,7 @@ export function useProductListing(): UseProductListingResult {
|
|
|
167
198
|
if (brandRes.status === 'fulfilled') setBrands(brandRes.value.brands);
|
|
168
199
|
if (tagRes.status === 'fulfilled') setTags(tagRes.value.tags);
|
|
169
200
|
if (mfRes.status === 'fulfilled') {
|
|
170
|
-
setMetafieldDefs(
|
|
171
|
-
mfRes.value.definitions.filter(
|
|
172
|
-
(d) => d.filterable === true && FILTERABLE_TYPES.has(d.type)
|
|
173
|
-
)
|
|
174
|
-
);
|
|
201
|
+
setMetafieldDefs(mfRes.value.definitions.filter(isFilterableMetafield));
|
|
175
202
|
}
|
|
176
203
|
if (mfCountRes.status === 'fulfilled') {
|
|
177
204
|
const counts: Record<string, Record<string, number>> = {};
|
|
@@ -186,20 +213,6 @@ export function useProductListing(): UseProductListingResult {
|
|
|
186
213
|
loadFilters();
|
|
187
214
|
}, []);
|
|
188
215
|
|
|
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
216
|
// Load products when filters change
|
|
204
217
|
const loadProducts = useCallback(
|
|
205
218
|
async (pageNum: number, append: boolean) => {
|
|
@@ -211,50 +224,9 @@ export function useProductListing(): UseProductListingResult {
|
|
|
211
224
|
}
|
|
212
225
|
|
|
213
226
|
const client = getClient();
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
227
|
+
// Region switching re-runs the query so the grid re-prices, exactly like
|
|
228
|
+
// changing a filter (`regionId` is part of the signature).
|
|
229
|
+
const params = buildProductQuery(urlState, { regionId, metafieldsKey, page: pageNum });
|
|
258
230
|
const result = await client.getProducts(params);
|
|
259
231
|
|
|
260
232
|
if (append) {
|
|
@@ -272,25 +244,32 @@ export function useProductListing(): UseProductListingResult {
|
|
|
272
244
|
setLoadingMore(false);
|
|
273
245
|
}
|
|
274
246
|
},
|
|
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
|
-
]
|
|
247
|
+
[urlState, regionId, metafieldsKey]
|
|
289
248
|
);
|
|
290
249
|
|
|
250
|
+
// The signature of the query the products on screen answer. Starts as the
|
|
251
|
+
// seed's when it applies, so the first effect run is a no-op. A ref holding the
|
|
252
|
+
// signature rather than a flag spent on first use: React's dev-only double
|
|
253
|
+
// effect run then cannot consume the seed and fetch anyway. The first run that
|
|
254
|
+
// sees a different signature (a filter, a sort, a page, a region switch) loads.
|
|
255
|
+
const loadedSignature = useRef<string | null>(seedApplies ? querySignature : null);
|
|
256
|
+
|
|
291
257
|
useEffect(() => {
|
|
292
|
-
|
|
293
|
-
|
|
258
|
+
if (loadedSignature.current === querySignature) return;
|
|
259
|
+
loadedSignature.current = querySignature;
|
|
260
|
+
// Client-side navigation to a URL the server has already rendered (the
|
|
261
|
+
// crawlable prev/next links under the grid): the new seed arrives with the
|
|
262
|
+
// new URL, so adopt it instead of fetching the same page a second time.
|
|
263
|
+
if (listingSeed && listingSeed.signature === querySignature) {
|
|
264
|
+
setProducts(listingSeed.products);
|
|
265
|
+
setPage(listingSeed.page);
|
|
266
|
+
setTotalPages(listingSeed.totalPages);
|
|
267
|
+
setTotal(listingSeed.total);
|
|
268
|
+
setLoading(false);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
loadProducts(urlState.page, false);
|
|
272
|
+
}, [loadProducts, querySignature, urlState.page, listingSeed]);
|
|
294
273
|
|
|
295
274
|
function handleLoadMore() {
|
|
296
275
|
if (page < totalPages && !loadingMore) {
|
|
@@ -305,6 +284,9 @@ export function useProductListing(): UseProductListingResult {
|
|
|
305
284
|
} else {
|
|
306
285
|
params.delete(key);
|
|
307
286
|
}
|
|
287
|
+
// A different filter or sort is a different result set; keeping `?page=3`
|
|
288
|
+
// would land the shopper on the third page of something they just changed.
|
|
289
|
+
if (key !== 'page') params.delete('page');
|
|
308
290
|
router.push(`/products?${params.toString()}`);
|
|
309
291
|
}
|
|
310
292
|
|
|
@@ -329,6 +311,7 @@ export function useProductListing(): UseProductListingResult {
|
|
|
329
311
|
} else {
|
|
330
312
|
params.delete('maxPrice');
|
|
331
313
|
}
|
|
314
|
+
params.delete('page');
|
|
332
315
|
router.push(`/products?${params.toString()}`);
|
|
333
316
|
}
|
|
334
317
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useStoreInfo } from '@/core/providers/store-provider';
|
|
4
|
+
import { reviewsAreOn } from '@/core/lib/store-info';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Whether to render reviews UI: the section, the star rating on a product card,
|
|
8
|
+
* a rating band. `false` only when the merchant switched reviews off.
|
|
9
|
+
*
|
|
10
|
+
* Absent counts as ON, including while the store info is still loading, so a
|
|
11
|
+
* store with reviews on never flashes its stars away. `Product.avgRating` and
|
|
12
|
+
* `reviewCount` keep their stored values while reviews are off, so gate on this,
|
|
13
|
+
* never on `reviewCount > 0`.
|
|
14
|
+
*/
|
|
15
|
+
export function useReviewsEnabled(): boolean {
|
|
16
|
+
return reviewsAreOn(useStoreInfo().storeInfo);
|
|
17
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type { ProductQueryParams, PublicMetafieldDefinition } from 'brainerce';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The product-listing query, in one plain module shared by the server page and
|
|
5
|
+
* the client hook.
|
|
6
|
+
*
|
|
7
|
+
* `/products` renders its first page on the SERVER from the URL
|
|
8
|
+
* (`app/products/page.tsx`), and `use-product-listing.ts` decides in the browser
|
|
9
|
+
* whether that seed still answers the query it would have run. Both must derive
|
|
10
|
+
* the SAME query and the SAME signature from the same URL, or the seed is either
|
|
11
|
+
* thrown away (a wasted round trip and a spinner flash) or shown for a query it
|
|
12
|
+
* does not match (the wrong products). Two copies of this logic would drift, so
|
|
13
|
+
* there is one.
|
|
14
|
+
*
|
|
15
|
+
* No `'use client'` directive on purpose: a non-component export crossing that
|
|
16
|
+
* boundary into a server component becomes a client reference, not a value.
|
|
17
|
+
*/
|
|
18
|
+
export const LISTING_PAGE_SIZE = 20;
|
|
19
|
+
|
|
20
|
+
/** Number of products the homepage's featured grid shows. */
|
|
21
|
+
export const HOME_FEATURED_LIMIT = 8;
|
|
22
|
+
|
|
23
|
+
/** Metafield facets travel in the URL as `mf_<key>=v1,v2`. */
|
|
24
|
+
export const METAFIELD_URL_PREFIX = 'mf_';
|
|
25
|
+
|
|
26
|
+
/** Sort choices by `?sort=` index. Index 0 (newest first) is the default. */
|
|
27
|
+
export const LISTING_SORTS = [
|
|
28
|
+
{ sortBy: 'createdAt', sortOrder: 'desc' },
|
|
29
|
+
{ sortBy: 'name', sortOrder: 'asc' },
|
|
30
|
+
{ sortBy: 'name', sortOrder: 'desc' },
|
|
31
|
+
{ sortBy: 'price', sortOrder: 'asc' },
|
|
32
|
+
{ sortBy: 'price', sortOrder: 'desc' },
|
|
33
|
+
] as const satisfies ReadonlyArray<{
|
|
34
|
+
sortBy: NonNullable<ProductQueryParams['sortBy']>;
|
|
35
|
+
sortOrder: NonNullable<ProductQueryParams['sortOrder']>;
|
|
36
|
+
}>;
|
|
37
|
+
|
|
38
|
+
/** Only these custom-field types render as a facet, and so only these filter. */
|
|
39
|
+
const FILTERABLE_METAFIELD_TYPES = new Set(['SELECT', 'MULTI_SELECT', 'BOOLEAN']);
|
|
40
|
+
|
|
41
|
+
export function isFilterableMetafield(def: PublicMetafieldDefinition): boolean {
|
|
42
|
+
return def.filterable === true && FILTERABLE_METAFIELD_TYPES.has(def.type);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What both `URLSearchParams` and a server `searchParams` object can answer. */
|
|
46
|
+
export interface ListingParamSource {
|
|
47
|
+
get(name: string): string | null;
|
|
48
|
+
keys(): Iterable<string>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Wrap a Next.js server `searchParams` object so it reads like `URLSearchParams`. */
|
|
52
|
+
export function paramSourceFromObject(
|
|
53
|
+
query: Record<string, string | string[] | undefined>
|
|
54
|
+
): ListingParamSource {
|
|
55
|
+
const first = (value: string | string[] | undefined): string | null =>
|
|
56
|
+
Array.isArray(value) ? (value[0] ?? null) : (value ?? null);
|
|
57
|
+
return {
|
|
58
|
+
get: (name) => first(query[name]),
|
|
59
|
+
keys: () => Object.keys(query),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ListingUrlState {
|
|
64
|
+
search: string;
|
|
65
|
+
categoryId: string;
|
|
66
|
+
brandId: string;
|
|
67
|
+
tagId: string;
|
|
68
|
+
minPrice: string;
|
|
69
|
+
maxPrice: string;
|
|
70
|
+
sortIndex: number;
|
|
71
|
+
/** 1-based page named by `?page=`; 1 when absent or not a positive integer. */
|
|
72
|
+
page: number;
|
|
73
|
+
/** Raw `mf_<key>` values by metafield key. */
|
|
74
|
+
metafields: Record<string, string>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const MAX_PAGE = 1000;
|
|
78
|
+
|
|
79
|
+
export function readListingUrlState(source: ListingParamSource): ListingUrlState {
|
|
80
|
+
const metafields: Record<string, string> = {};
|
|
81
|
+
for (const key of source.keys()) {
|
|
82
|
+
if (!key.startsWith(METAFIELD_URL_PREFIX)) continue;
|
|
83
|
+
const raw = source.get(key);
|
|
84
|
+
if (raw) metafields[key.slice(METAFIELD_URL_PREFIX.length)] = raw;
|
|
85
|
+
}
|
|
86
|
+
const pageNum = parseInt(source.get('page') ?? '', 10);
|
|
87
|
+
return {
|
|
88
|
+
search: source.get('search') || '',
|
|
89
|
+
categoryId: source.get('category') || '',
|
|
90
|
+
brandId: source.get('brand') || '',
|
|
91
|
+
tagId: source.get('tag') || '',
|
|
92
|
+
// `minPrice` / `maxPrice` are discrete top-level query keys on getProducts;
|
|
93
|
+
// there is no `filters` object, and the SDK builds its query string from a
|
|
94
|
+
// fixed whitelist, so a misspelt key is dropped silently and the caller gets
|
|
95
|
+
// a 200 of unfiltered results rather than an error.
|
|
96
|
+
minPrice: source.get('minPrice') || '',
|
|
97
|
+
maxPrice: source.get('maxPrice') || '',
|
|
98
|
+
sortIndex: parseInt(source.get('sort') ?? '', 10) || 0,
|
|
99
|
+
page: Number.isFinite(pageNum) && pageNum >= 1 ? Math.min(pageNum, MAX_PAGE) : 1,
|
|
100
|
+
metafields,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The sort the URL selects, falling back to newest-first for an unknown index. */
|
|
105
|
+
export function listingSort(state: Pick<ListingUrlState, 'sortIndex'>) {
|
|
106
|
+
return LISTING_SORTS[state.sortIndex] ?? LISTING_SORTS[0];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Stable signature of the selected metafield facets, in `key=v1,v2` segments
|
|
111
|
+
* joined by `&` and sorted. Only keys named in `filterableKeys` count: a facet
|
|
112
|
+
* the store does not expose as filterable is ignored, exactly as the listing UI
|
|
113
|
+
* ignores it. `''` when nothing is selected, so the SDK call omits `metafields`
|
|
114
|
+
* entirely and the backend short-circuits that filter branch.
|
|
115
|
+
*/
|
|
116
|
+
export function selectedMetafieldsKey(
|
|
117
|
+
metafields: Record<string, string>,
|
|
118
|
+
filterableKeys: readonly string[]
|
|
119
|
+
): string {
|
|
120
|
+
const parts: string[] = [];
|
|
121
|
+
for (const key of filterableKeys) {
|
|
122
|
+
const raw = metafields[key];
|
|
123
|
+
if (raw) parts.push(`${key}=${raw}`);
|
|
124
|
+
}
|
|
125
|
+
return parts.sort().join('&');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface QueryOptions {
|
|
129
|
+
regionId: string | undefined;
|
|
130
|
+
metafieldsKey: string;
|
|
131
|
+
page: number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The `getProducts` params for a URL state. The one place that knows how a URL
|
|
136
|
+
* becomes a query; the server page and the client hook both call it.
|
|
137
|
+
*
|
|
138
|
+
* ⛔ Region belongs on every product read. Omit it and the grid quotes the
|
|
139
|
+
* DEFAULT region's prices to every shopper on earth, on a page that looks
|
|
140
|
+
* perfectly correct. `undefined` on a store with no regions, and the SDK then
|
|
141
|
+
* omits the param.
|
|
142
|
+
*/
|
|
143
|
+
export function buildProductQuery(state: ListingUrlState, opts: QueryOptions): ProductQueryParams {
|
|
144
|
+
const sort = listingSort(state);
|
|
145
|
+
const params: ProductQueryParams = {
|
|
146
|
+
page: opts.page,
|
|
147
|
+
limit: LISTING_PAGE_SIZE,
|
|
148
|
+
sortBy: sort.sortBy,
|
|
149
|
+
sortOrder: sort.sortOrder,
|
|
150
|
+
};
|
|
151
|
+
if (opts.regionId) params.regionId = opts.regionId;
|
|
152
|
+
if (state.search) params.search = state.search;
|
|
153
|
+
if (state.categoryId) params.categories = state.categoryId;
|
|
154
|
+
if (state.brandId) params.brands = state.brandId;
|
|
155
|
+
if (state.tagId) params.tags = state.tagId;
|
|
156
|
+
// Sent only when the shopper set a bound, and only when it parses as a number:
|
|
157
|
+
// `minPrice`/`maxPrice` are typed `number`, and a NaN would serialize into the
|
|
158
|
+
// query string and filter everything out. An unset bound is simply omitted.
|
|
159
|
+
const minPriceNum = state.minPrice === '' ? NaN : Number(state.minPrice);
|
|
160
|
+
const maxPriceNum = state.maxPrice === '' ? NaN : Number(state.maxPrice);
|
|
161
|
+
if (Number.isFinite(minPriceNum)) params.minPrice = minPriceNum;
|
|
162
|
+
if (Number.isFinite(maxPriceNum)) params.maxPrice = maxPriceNum;
|
|
163
|
+
if (opts.metafieldsKey) {
|
|
164
|
+
const mf: Record<string, string[]> = {};
|
|
165
|
+
for (const segment of opts.metafieldsKey.split('&')) {
|
|
166
|
+
const eq = segment.indexOf('=');
|
|
167
|
+
if (eq < 0) continue;
|
|
168
|
+
const values = segment
|
|
169
|
+
.slice(eq + 1)
|
|
170
|
+
.split(',')
|
|
171
|
+
.map((s) => s.trim())
|
|
172
|
+
.filter(Boolean);
|
|
173
|
+
if (values.length > 0) mf[segment.slice(0, eq)] = values;
|
|
174
|
+
}
|
|
175
|
+
if (Object.keys(mf).length > 0) params.metafields = mf;
|
|
176
|
+
}
|
|
177
|
+
return params;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Everything that decides WHICH products a query returns, as one string. A seed
|
|
182
|
+
* is valid for exactly the signature it was fetched at.
|
|
183
|
+
*/
|
|
184
|
+
export function listingSignature(state: ListingUrlState, opts: QueryOptions): string {
|
|
185
|
+
const sort = listingSort(state);
|
|
186
|
+
return [
|
|
187
|
+
opts.regionId ?? '',
|
|
188
|
+
state.search,
|
|
189
|
+
state.categoryId,
|
|
190
|
+
state.brandId,
|
|
191
|
+
state.tagId,
|
|
192
|
+
state.minPrice,
|
|
193
|
+
state.maxPrice,
|
|
194
|
+
sort.sortBy,
|
|
195
|
+
sort.sortOrder,
|
|
196
|
+
opts.metafieldsKey,
|
|
197
|
+
opts.page,
|
|
198
|
+
].join('|');
|
|
199
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { defaultLocale } from '@/i18n';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* URL prefix for a locale under the "as-needed" strategy: none for the default
|
|
5
|
+
* locale (clean URLs), `/{locale}` for every other one.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ The default locale arrives as a real `params.locale` too. The middleware
|
|
8
|
+
* rewrites `/foo` to `/{defaultLocale}/foo` internally and 308-redirects
|
|
9
|
+
* `/{defaultLocale}/foo` back to `/foo`, so a canonical tag, breadcrumb link or
|
|
10
|
+
* JSON-LD url built as `/${locale}/foo` for the default locale points at a URL
|
|
11
|
+
* that redirects. Build every public path through this helper.
|
|
12
|
+
*
|
|
13
|
+
* Reads the same scaffold-time `defaultLocale` the middleware uses, so the two
|
|
14
|
+
* can never disagree about which locale is the unprefixed one.
|
|
15
|
+
*/
|
|
16
|
+
export function localePrefix(locale: string | undefined | null): string {
|
|
17
|
+
return locale && locale !== defaultLocale ? `/${locale}` : '';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Site-relative public path for `path` in `locale`:
|
|
22
|
+
* `localePath('he', '/blog')` → `/he/blog`, default locale → `/blog`,
|
|
23
|
+
* `localePath('he', '/')` → `/he`, `localePath(undefined, '/')` → `/`.
|
|
24
|
+
*/
|
|
25
|
+
export function localePath(locale: string | undefined | null, path: string): string {
|
|
26
|
+
const prefix = localePrefix(locale);
|
|
27
|
+
if (path === '' || path === '/') return prefix || '/';
|
|
28
|
+
return `${prefix}${path.startsWith('/') ? path : `/${path}`}`;
|
|
29
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { BrainerceError } from 'brainerce';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* True when a reviews call failed because the merchant switched reviews off.
|
|
5
|
+
*
|
|
6
|
+
* Branch on the stable `details.code`, never on the message wording: a bare 403
|
|
7
|
+
* is also what the origin check and a suspended store answer. Any other failure
|
|
8
|
+
* (network, 5xx) is a real error and must not be treated as "reviews are off".
|
|
9
|
+
*
|
|
10
|
+
* Prefer reading `storeInfo.reviewsEnabled` first (see `reviewsAreOn`); this
|
|
11
|
+
* covers a backend too old to send the flag.
|
|
12
|
+
*/
|
|
13
|
+
export function isReviewsDisabledError(err: unknown): boolean {
|
|
14
|
+
return (
|
|
15
|
+
err instanceof BrainerceError &&
|
|
16
|
+
err.statusCode === 403 &&
|
|
17
|
+
(err.details as { code?: string } | undefined)?.code === 'REVIEWS_DISABLED'
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<% if (i18nEnabled) { %>import { getMessages, defaultLocale } from '@/i18n';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One message namespace for a SERVER component, in the request locale.
|
|
5
|
+
*
|
|
6
|
+
* Page metadata, JSON-LD and breadcrumb names are rendered on the server, before
|
|
7
|
+
* any client i18n context exists, so they cannot use `useTranslations`. A locale
|
|
8
|
+
* with no message file falls back to the default locale's file (see `getMessages`),
|
|
9
|
+
* never to a hardcoded English string.
|
|
10
|
+
*/
|
|
11
|
+
export async function getServerNamespace(
|
|
12
|
+
namespace: string,
|
|
13
|
+
locale?: string
|
|
14
|
+
): Promise<Record<string, string>> {
|
|
15
|
+
const all = await getMessages(locale ?? defaultLocale);
|
|
16
|
+
return (all[namespace] ?? {}) as Record<string, string>;
|
|
17
|
+
}
|
|
18
|
+
<% } else { %>import { messages } from '@/i18n';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One message namespace for a SERVER component. Single-language storefront: the
|
|
22
|
+
* one message file is the answer for every request. Same signature as the
|
|
23
|
+
* multi-language build so callers do not branch.
|
|
24
|
+
*/
|
|
25
|
+
export async function getServerNamespace(
|
|
26
|
+
namespace: string,
|
|
27
|
+
_locale?: string
|
|
28
|
+
): Promise<Record<string, string>> {
|
|
29
|
+
return ((messages as Record<string, Record<string, string>>)[namespace] ?? {}) as Record<
|
|
30
|
+
string,
|
|
31
|
+
string
|
|
32
|
+
>;
|
|
33
|
+
}
|
|
34
|
+
<% } %>
|
|
@@ -60,6 +60,14 @@ export interface PublicStoreInfo {
|
|
|
60
60
|
* same change or it silently resolves to undefined.
|
|
61
61
|
*/
|
|
62
62
|
stockAlertsEnabled?: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Whether the merchant has product reviews switched on. `false` means render
|
|
65
|
+
* no reviews UI at all: not the section, not the star rating on a product
|
|
66
|
+
* card, and no `aggregateRating` in the structured data. Absent means ON (an
|
|
67
|
+
* older backend sends no flag) — read it through {@link reviewsAreOn}, never
|
|
68
|
+
* with a bare truthiness check, or an old backend silently hides every review.
|
|
69
|
+
*/
|
|
70
|
+
reviewsEnabled?: boolean;
|
|
63
71
|
/**
|
|
64
72
|
* IANA timezone the store operates in (e.g. "Asia/Jerusalem"). The checkout
|
|
65
73
|
* date/time custom fields evaluate availability against THIS, never the
|
|
@@ -92,6 +100,18 @@ export function pickPublicStoreInfo(raw: StoreInfo): PublicStoreInfo {
|
|
|
92
100
|
tracking: raw.tracking,
|
|
93
101
|
seo: raw.seo,
|
|
94
102
|
stockAlertsEnabled: raw.stockAlertsEnabled,
|
|
103
|
+
// `brainerce` 3.9.0 declares `reviewsEnabled` on StoreInfo; the pinned SDK is
|
|
104
|
+
// older, so widen the type here. The platform already sends the field, and
|
|
105
|
+
// this cast goes away when the pin passes 3.9.0.
|
|
106
|
+
reviewsEnabled: (raw as StoreInfo & { reviewsEnabled?: boolean }).reviewsEnabled,
|
|
95
107
|
timezone: raw.timezone,
|
|
96
108
|
};
|
|
97
109
|
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Whether to render reviews UI. Absent counts as ON: an older backend sends no
|
|
113
|
+
* flag, and reviews are on by default. Only an explicit `false` hides them.
|
|
114
|
+
*/
|
|
115
|
+
export function reviewsAreOn(info: Pick<PublicStoreInfo, 'reviewsEnabled'> | null | undefined) {
|
|
116
|
+
return info?.reviewsEnabled !== false;
|
|
117
|
+
}
|