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.
Files changed (44) hide show
  1. package/dist/index.js +3 -2
  2. package/messages/en.json +21 -0
  3. package/messages/he.json +21 -0
  4. package/package.json +3 -2
  5. package/templates/nextjs/base/.eslintrc.json +2 -0
  6. package/templates/nextjs/base/AI-GUIDE.md +259 -221
  7. package/templates/nextjs/base/TRANSLATIONS.md +6 -0
  8. package/templates/nextjs/base/scripts/agent-docs.mjs +76 -0
  9. package/templates/nextjs/base/scripts/connect.mjs +6 -0
  10. package/templates/nextjs/base/src/app/api/auth/oauth-callback/route.ts +102 -96
  11. package/templates/nextjs/base/src/app/blog/[slug]/{page.tsx.ejs → page.tsx} +41 -144
  12. package/templates/nextjs/base/src/app/blog/page.tsx +187 -0
  13. package/templates/nextjs/base/src/app/blog/rss.xml/route.ts +7 -3
  14. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +26 -11
  15. package/templates/nextjs/base/src/app/faq/page.tsx.ejs +20 -4
  16. package/templates/nextjs/base/src/app/layout.tsx.ejs +18 -50
  17. package/templates/nextjs/base/src/app/page.tsx +109 -75
  18. package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +7 -0
  19. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +267 -220
  20. package/templates/nextjs/base/src/app/products/page.tsx +184 -19
  21. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +9 -1
  22. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +30 -1
  23. package/templates/nextjs/base/src/components/seo/article-json-ld.tsx +12 -6
  24. package/templates/nextjs/base/src/components/seo/category-json-ld.tsx +9 -10
  25. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +15 -6
  26. package/templates/nextjs/base/src/components/seo/product-og-meta.tsx +46 -0
  27. package/templates/nextjs/base/src/core/hooks/use-home-data.ts +24 -4
  28. package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +99 -116
  29. package/templates/nextjs/base/src/core/hooks/use-reviews-enabled.ts +17 -0
  30. package/templates/nextjs/base/src/core/lib/listing-query.ts +199 -0
  31. package/templates/nextjs/base/src/core/lib/locale-path.ts +29 -0
  32. package/templates/nextjs/base/src/core/lib/reviews-disabled.ts +19 -0
  33. package/templates/nextjs/base/src/core/lib/server-messages.ts.ejs +34 -0
  34. package/templates/nextjs/base/src/core/lib/store-info.ts +20 -0
  35. package/templates/nextjs/base/src/core/providers/listing-seed.tsx +66 -0
  36. package/templates/nextjs/base/src/ui/product/product-card.tsx +7 -2
  37. package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +6 -2
  38. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +18 -50
  39. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +8 -2
  40. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +7 -2
  41. package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +6 -2
  42. package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +6 -2
  43. package/templates/nextjs/base/src/app/blog/page.tsx.ejs +0 -277
  44. 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
- * 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.
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', 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' },
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
- 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';
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
- const [loading, setLoading] = useState(true);
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(1);
134
- const [totalPages, setTotalPages] = useState(1);
135
- const [total, setTotal] = useState(0);
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
- 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
-
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
- loadProducts(1, false);
293
- }, [loadProducts]);
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
+ }