orionplace-react 0.6.0 → 0.6.2

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 (26) hide show
  1. package/package.json +1 -1
  2. package/runtime/marketplace.d.mts +12 -0
  3. package/runtime/marketplace.mjs +31 -2
  4. package/src/project-readme.mjs +6 -0
  5. package/templates/app/_storefront/ArticlePage.tsx +3 -0
  6. package/templates/app/_storefront/CategoryPage.tsx +6 -2
  7. package/templates/app/_storefront/SearchPage.tsx +7 -2
  8. package/templates/app/_storefront/SellersPage.tsx +9 -3
  9. package/templates/app/_storefront/storefrontRoutes.tsx +31 -10
  10. package/templates/app/_storefront/useProductSearch.ts +97 -26
  11. package/templates/components/orionplace/blog/BlogArticle.README.md +24 -0
  12. package/templates/components/orionplace/blog/BlogArticle.module.css +70 -46
  13. package/templates/components/orionplace/blog/BlogArticle.tsx +115 -0
  14. package/templates/components/orionplace/products/ProductCard.README.md +2 -0
  15. package/templates/components/orionplace/products/ProductCard.tsx +14 -0
  16. package/templates/components/orionplace/products/ProductGroup.README.md +2 -0
  17. package/templates/components/orionplace/products/ProductGroup.tsx +3 -0
  18. package/templates/lib/orionplace/blogArticle.ts +25 -0
  19. package/templates/lib/orionplace/blogArticleHtml.ts +1 -0
  20. package/templates/lib/orionplace/blogProductPlacements.ts +105 -0
  21. package/templates/lib/orionplace/pageMetadata.ts +24 -4
  22. package/templates/lib/orionplace/pageSeo.ts +68 -0
  23. package/templates/lib/orionplace/productListLocation.ts +130 -0
  24. package/templates/lib/orionplace/productSearch.ts +48 -0
  25. package/templates/lib/orionplace/serverMetadata.ts +7 -0
  26. package/templates/proxy.ts +5 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orionplace-react",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Next.js 16 storefront for your Orionplace marketplace: setup CLI, server API gateway, typed store and ready-made catalog, cart, checkout and account components in English and Russian, plus an Android app build.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,6 +37,18 @@ export declare function marketplacePublicOrigin(
37
37
  tenantDomain?: string,
38
38
  ): string;
39
39
 
40
+ export declare function noindexPathPrefixes(value?: string): string[];
41
+
42
+ export declare function marketplaceNoindexPath(
43
+ pathname: string,
44
+ value?: string,
45
+ ): boolean;
46
+
47
+ export declare function sitemapIndexedPages(
48
+ pages: MarketplaceSitemapPage[],
49
+ value?: string,
50
+ ): MarketplaceSitemapPage[];
51
+
40
52
  export declare function marketplaceRobots(origin: string): string;
41
53
 
42
54
  export declare function marketplaceRobotsResponse(
@@ -329,6 +329,7 @@ export async function forwardMarketplace(request, configuration = {}) {
329
329
  // node_modules/.cache/orionplace/sitemaps/<domain>.json (ORIONPLACE_CACHE_DIR
330
330
  // moves it). "orionplace-<framework> sitemap rebuild" rebuilds that file at
331
331
  // once; the running storefront serves the new file without a restart.
332
+ // Paths in ORIONPLACE_NOINDEX_PATHS are left out when the sitemap is served.
332
333
 
333
334
  export const SITEMAP_LIFETIME = 60 * 60 * 1000;
334
335
  const sitemapPageSize = 100;
@@ -353,6 +354,34 @@ export function marketplacePublicOrigin(request, tenantDomain = '') {
353
354
  return local || !tenantDomain ? url.origin : `https://${tenantDomain}`;
354
355
  }
355
356
 
357
+ /**
358
+ * Pages kept out of search engines: ORIONPLACE_NOINDEX_PATHS lists path
359
+ * prefixes separated by spaces or commas, for example "/catalog /category
360
+ * /product" on a demo or staging storefront. Those pages answer with
361
+ * `X-Robots-Tag: noindex, follow` and the sitemap leaves them out. Empty
362
+ * (the default) keeps every public page indexable.
363
+ */
364
+ export function noindexPathPrefixes(value = environment.ORIONPLACE_NOINDEX_PATHS) {
365
+ return String(value ?? '')
366
+ .split(/[\s,]+/)
367
+ .map((item) => item.trim().replace(/\/+$/, ''))
368
+ .filter((item) => /^\/[A-Za-z0-9._~\/-]+$/.test(item));
369
+ }
370
+
371
+ /** Whether a page path is kept out of search engines (see noindexPathPrefixes). */
372
+ export function marketplaceNoindexPath(pathname, value = environment.ORIONPLACE_NOINDEX_PATHS) {
373
+ const path = String(pathname ?? '');
374
+ return noindexPathPrefixes(value)
375
+ .some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
376
+ }
377
+
378
+ /** The sitemap pages search engines may index. */
379
+ export function sitemapIndexedPages(pages, value = environment.ORIONPLACE_NOINDEX_PATHS) {
380
+ return noindexPathPrefixes(value).length
381
+ ? pages.filter((page) => !marketplaceNoindexPath(page.path, value))
382
+ : pages;
383
+ }
384
+
356
385
  /** @param {string} origin */
357
386
  export function marketplaceRobots(origin) {
358
387
  return `User-agent: *\nDisallow: /api/\nAllow: /\n\nSitemap: ${origin}/sitemap.xml\n`;
@@ -618,7 +647,7 @@ async function buildMarketplaceSitemap(tenant) {
618
647
  /** How many <url> entries a built sitemap has. */
619
648
  export function sitemapUrlCount(sitemap) {
620
649
  let count = 0;
621
- for (const page of sitemap.pages) {
650
+ for (const page of sitemapIndexedPages(sitemap.pages)) {
622
651
  const own = page.locales
623
652
  ? sitemap.locales.filter((code) => page.locales.includes(code))
624
653
  : sitemap.locales;
@@ -776,7 +805,7 @@ export async function marketplaceSitemapResponse(request, configuration = {}) {
776
805
  }
777
806
  }
778
807
  const origin = marketplacePublicOrigin(request, tenant.domain);
779
- return new Response(marketplaceSitemapXml(origin, sitemap.pages, sitemap.locales), {
808
+ return new Response(marketplaceSitemapXml(origin, sitemapIndexedPages(sitemap.pages), sitemap.locales), {
780
809
  headers: {
781
810
  'Content-Type': 'application/xml; charset=utf-8',
782
811
  'Cache-Control': 'public, max-age=3600',
@@ -405,6 +405,12 @@ npx orionplace-react sitemap rebuild shop.example.com # one domain
405
405
  npx orionplace-react sitemap status # when each was built
406
406
  \`\`\`
407
407
 
408
+ To keep a demo or staging storefront's shop pages out of search engines, set
409
+ \`ORIONPLACE_NOINDEX_PATHS\` in \`.env\` to path prefixes separated by spaces,
410
+ for example \`ORIONPLACE_NOINDEX_PATHS="/catalog /category /product"\`. Those
411
+ pages answer with \`X-Robots-Tag: noindex, follow\` and the sitemap leaves them
412
+ out; the journal and other pages stay indexable. Leave it empty on a live shop.
413
+
408
414
  This setup does not add analytics.
409
415
 
410
416
  ## Android app
@@ -60,6 +60,9 @@ export function ArticlePage({
60
60
  }>(action.endpoint, action.request, action.signal);
61
61
  return data.posts.data.find((post) => post.id === postId) ?? null;
62
62
  }}
63
+ onLoadPlacements={(action) =>
64
+ api.request(action.endpoint, action.request, action.signal)
65
+ }
63
66
  onReading={(action) =>
64
67
  api.request<IBlogReadingData>(
65
68
  action.endpoint,
@@ -4,7 +4,8 @@ import { StorefrontCategory } from '@/components/orionplace/categories/Storefron
4
4
  import { useOrionplaceStorefront } from '@/hooks/useOrionplaceStorefront';
5
5
  import { useOrionplaceCatalog } from '@/providers/OrionplaceProvider';
6
6
  import { catalogBranchIds } from '@/lib/orionplace/catalog';
7
- import type { ProductSearchPage } from '@/lib/orionplace/productSearch';
7
+ import type { ProductListLocation } from '@/lib/orionplace/productListLocation';
8
+ import type { ProductListPage } from '@/lib/orionplace/productSearch';
8
9
  import { useProductSearch } from './useProductSearch';
9
10
  import { useStorefrontNavigation } from './useStorefrontNavigation';
10
11
 
@@ -12,10 +13,12 @@ export function CategoryPage({
12
13
  categoryId,
13
14
  initial,
14
15
  initialError = '',
16
+ location,
15
17
  }: {
16
18
  categoryId: number;
17
- initial?: ProductSearchPage;
19
+ initial?: ProductListPage;
18
20
  initialError?: string;
21
+ location?: ProductListLocation;
19
22
  }) {
20
23
  const navigation = useStorefrontNavigation();
21
24
  const controls = useOrionplaceStorefront();
@@ -24,6 +27,7 @@ export function CategoryPage({
24
27
  { categoryId, categoryIds: catalogBranchIds(catalog, categoryId) },
25
28
  initial,
26
29
  initialError,
30
+ { location },
27
31
  );
28
32
  const pagination = search.data?.products?.rpag;
29
33
 
@@ -15,10 +15,11 @@ import { OrionplaceEmptyState } from '@/components/orionplace/shared/OrionplaceE
15
15
  import { useOrionplaceStorefront } from '@/hooks/useOrionplaceStorefront';
16
16
  import { browseLabels } from '@/lib/orionplace/browseLabels';
17
17
  import { featuredLabels } from '@/lib/orionplace/featuredLabels';
18
+ import type { ProductListLocation } from '@/lib/orionplace/productListLocation';
18
19
  import {
19
20
  productSearchHeading,
20
21
  productSearchTrail,
21
- type ProductSearchPage as ProductSearchData,
22
+ type ProductListPage as ProductSearchData,
22
23
  type ProductSearchScope,
23
24
  } from '@/lib/orionplace/productSearch';
24
25
  import { useProductSearch } from './useProductSearch';
@@ -41,10 +42,12 @@ export function SearchPage({
41
42
  scope,
42
43
  initial,
43
44
  initialError = '',
45
+ location,
44
46
  }: {
45
47
  scope: ProductSearchScope;
46
48
  initial?: ProductSearchData;
47
49
  initialError?: string;
50
+ location?: ProductListLocation;
48
51
  }) {
49
52
  const locale = useOrionplaceSelections((state) => state.localeCode);
50
53
  const labels = browseLabels(locale);
@@ -55,7 +58,9 @@ export function SearchPage({
55
58
  const guest = Boolean(scope.featured && ready && !init?.is_authenticated);
56
59
  const navigation = useStorefrontNavigation();
57
60
  const controls = useOrionplaceStorefront();
58
- const search = useProductSearch(scope, initial, initialError);
61
+ const search = useProductSearch(scope, initial, initialError, {
62
+ location,
63
+ });
59
64
  const pagination = search.data?.products?.rpag;
60
65
  const api = useOrionplaceApi();
61
66
  const directory = directoryLabels(locale);
@@ -12,7 +12,8 @@ import { useOrionplaceApi } from '@/hooks/useOrionplaceApi';
12
12
  import { useOrionplaceReviews } from '@/hooks/useOrionplaceReviews';
13
13
  import { useOrionplaceStorefront } from '@/hooks/useOrionplaceStorefront';
14
14
  import { directoryLabels } from '@/lib/orionplace/directoryLabels';
15
- import type { ProductSearchPage } from '@/lib/orionplace/productSearch';
15
+ import type { ProductListLocation } from '@/lib/orionplace/productListLocation';
16
+ import type { ProductListPage } from '@/lib/orionplace/productSearch';
16
17
  import { useOrionplaceSelections } from '@/providers/OrionplaceProvider';
17
18
  import { useProductSearch } from './useProductSearch';
18
19
  import { useStorefrontNavigation } from './useStorefrontNavigation';
@@ -25,11 +26,13 @@ export function SellersPage({
25
26
  initial,
26
27
  initialProducts,
27
28
  initialError = '',
29
+ location,
28
30
  }: {
29
31
  sellerId?: number;
30
32
  initial?: SellerPageData;
31
- initialProducts?: ProductSearchPage;
33
+ initialProducts?: ProductListPage;
32
34
  initialError?: string;
35
+ location?: ProductListLocation;
33
36
  }) {
34
37
  const api = useOrionplaceApi();
35
38
  const locale = useOrionplaceSelections((state) => state.localeCode);
@@ -38,6 +41,7 @@ export function SellersPage({
38
41
  const labels = directoryLabels(locale);
39
42
  const search = useProductSearch({ sellerId }, initialProducts, '', {
40
43
  scrollToTop: false,
44
+ location,
41
45
  });
42
46
  const reviews = useOrionplaceReviews({
43
47
  sellerId,
@@ -112,7 +116,9 @@ export function SellersPage({
112
116
  products={search.data?.products?.data ?? []}
113
117
  facets={search.facets}
114
118
  filters={search.filters}
115
- fixedFilters={['product_owner']}
119
+ // The seller scope fixes the seller and its rating, as in the
120
+ // Nuxt and Svelte packages.
121
+ fixedFilters={['product_owner', 'owner_rating_from']}
116
122
  page={search.page}
117
123
  pages={pagination?.pages}
118
124
  total={total}
@@ -67,10 +67,11 @@ import {
67
67
  type ManufacturerSearchData,
68
68
  } from '@/lib/orionplace/manufacturerDirectory';
69
69
  import {
70
- loadProductSearch,
71
- type ProductSearchPage,
70
+ loadProductListPage,
71
+ type ProductListPage,
72
72
  type ProductSearchScope,
73
73
  } from '@/lib/orionplace/productSearch';
74
+ import { productListLocationFromQuery } from '@/lib/orionplace/productListLocation';
74
75
  import {
75
76
  emptySellerFilters,
76
77
  sellerDirectoryRequest,
@@ -212,8 +213,11 @@ const routes: Record<string, Loader> = {
212
213
  !scope.query ||
213
214
  scope.query.length >= 3 ||
214
215
  Object.values(scope).some((value) => typeof value === 'number');
216
+ const location = productListLocationFromQuery(query);
215
217
  const { initial, initialError } = searched
216
- ? await initialData(context, (api) => loadProductSearch(api, scope))
218
+ ? await initialData(context, (api) =>
219
+ loadProductListPage(api, scope, location.filters, location.page),
220
+ )
217
221
  : { initial: undefined, initialError: '' };
218
222
  return {
219
223
  page: (
@@ -221,6 +225,7 @@ const routes: Record<string, Loader> = {
221
225
  scope={scope}
222
226
  initial={initial}
223
227
  initialError={initialError}
228
+ location={location}
224
229
  />
225
230
  ),
226
231
  };
@@ -254,11 +259,17 @@ const routes: Record<string, Loader> = {
254
259
  if (!categoryId || !findCatalogCategory(catalog, categoryId)) {
255
260
  return { notFound: true };
256
261
  }
262
+ const location = productListLocationFromQuery(context.query);
257
263
  const { initial, initialError } = await initialData(context, (api) =>
258
- loadProductSearch(api, {
259
- categoryId,
260
- categoryIds: catalogBranchIds(catalog, categoryId),
261
- }),
264
+ loadProductListPage(
265
+ api,
266
+ {
267
+ categoryId,
268
+ categoryIds: catalogBranchIds(catalog, categoryId),
269
+ },
270
+ location.filters,
271
+ location.page,
272
+ ),
262
273
  );
263
274
  return {
264
275
  page: (
@@ -266,6 +277,7 @@ const routes: Record<string, Loader> = {
266
277
  categoryId={categoryId}
267
278
  initial={initial}
268
279
  initialError={initialError}
280
+ location={location}
269
281
  />
270
282
  ),
271
283
  };
@@ -275,8 +287,9 @@ const routes: Record<string, Loader> = {
275
287
 
276
288
  '/featured': async (context) => {
277
289
  const scope = { featured: true } as const;
290
+ const location = productListLocationFromQuery(context.query);
278
291
  const { initial, initialError } = await initialData(context, (api) =>
279
- loadProductSearch(api, scope),
292
+ loadProductListPage(api, scope, location.filters, location.page),
280
293
  );
281
294
  return {
282
295
  page: (
@@ -284,6 +297,7 @@ const routes: Record<string, Loader> = {
284
297
  scope={scope}
285
298
  initial={initial}
286
299
  initialError={initialError}
300
+ location={location}
287
301
  />
288
302
  ),
289
303
  };
@@ -487,8 +501,9 @@ const routes: Record<string, Loader> = {
487
501
  return { redirect: `/catalog?locale=${encodeURIComponent(text(query.locale))}` };
488
502
  }
489
503
  const sellerId = positive(text(query.seller) || undefined);
504
+ const location = productListLocationFromQuery(query);
490
505
  let initial;
491
- let initialProducts: ProductSearchPage | undefined;
506
+ let initialProducts: ProductListPage | undefined;
492
507
  let initialError = '';
493
508
  try {
494
509
  const api = await context.api();
@@ -499,7 +514,12 @@ const routes: Record<string, Loader> = {
499
514
  : sellerDirectoryRequest(emptySellerFilters(), 1),
500
515
  );
501
516
  if (sellerId && initial.sellers.data[0]) {
502
- initialProducts = await loadProductSearch(api, { sellerId }, {}, 1);
517
+ initialProducts = await loadProductListPage(
518
+ api,
519
+ { sellerId },
520
+ location.filters,
521
+ location.page,
522
+ );
503
523
  }
504
524
  } catch (reason) {
505
525
  initialError = await context.errorText(reason);
@@ -511,6 +531,7 @@ const routes: Record<string, Loader> = {
511
531
  initial={initial}
512
532
  initialProducts={initialProducts}
513
533
  initialError={initialError}
534
+ location={location}
514
535
  />
515
536
  ),
516
537
  };
@@ -9,14 +9,19 @@ import {
9
9
  import type { ProductFacetSelection } from '@/interfaces/orionplace/products/IProductSearchFacets';
10
10
  import { useOrionplaceApi } from '@/hooks/useOrionplaceApi';
11
11
  import {
12
- loadProductSearch,
13
- productSearchFacetData,
14
- type ProductSearchPage,
12
+ loadProductListPage,
13
+ productListFacetData,
14
+ type ProductListPage,
15
15
  type ProductSearchScope,
16
16
  } from '@/lib/orionplace/productSearch';
17
- import { initialSearchFacets } from '@/lib/orionplace/searchFilters';
18
17
  import { apiErrorMessage } from '@/lib/orionplace/api';
19
18
  import { featureLabels } from '@/lib/orionplace/featureLabels';
19
+ import {
20
+ productListLocationFromQuery,
21
+ productListLocationQuery,
22
+ sameProductListLocation,
23
+ type ProductListLocation,
24
+ } from '@/lib/orionplace/productListLocation';
20
25
 
21
26
  /*
22
27
  * A search needs a category, a fixed scope or a 3-letter query, as in the
@@ -30,35 +35,77 @@ function searchable(scope: ProductSearchScope) {
30
35
  );
31
36
  }
32
37
 
38
+ const firstListPage: ProductListLocation = { page: 1, filters: {} };
39
+
40
+ function listSignature(location: ProductListLocation) {
41
+ return JSON.stringify(productListLocationQuery({}, location));
42
+ }
43
+
44
+ function addressQuery(): Record<string, string> {
45
+ return Object.fromEntries(new URL(window.location.href).searchParams);
46
+ }
47
+
33
48
  /*
34
49
  * `scrollToTop: false` keeps the scroll position on a page turn, for a page
35
50
  * whose pagination scrolls to its own list (the seller page).
51
+ * `location` is the page and filters `initial` was loaded with (the URL of the
52
+ * server render). The page and filters stay in the URL, so Back from a
53
+ * product or a reload opens the list the visitor left; `rememberInAddress:
54
+ * false` keeps them out of it.
36
55
  */
37
56
  export function useProductSearch(
38
57
  scope: ProductSearchScope,
39
- initial?: ProductSearchPage,
58
+ initial?: ProductListPage,
40
59
  initialError = '',
41
- options: { scrollToTop?: boolean } = {},
60
+ options: {
61
+ scrollToTop?: boolean;
62
+ location?: ProductListLocation;
63
+ rememberInAddress?: boolean;
64
+ } = {},
42
65
  ) {
66
+ const remember = options.rememberInAddress !== false;
67
+ const serverLocation = remember
68
+ ? (options.location ?? firstListPage)
69
+ : firstListPage;
70
+ // The page and filters in the URL; the server render reads them from the
71
+ // request, the browser from the address bar.
72
+ function addressLocation(): ProductListLocation {
73
+ if (!remember) {
74
+ return firstListPage;
75
+ }
76
+ return typeof window === 'undefined'
77
+ ? serverLocation
78
+ : productListLocationFromQuery(addressQuery());
79
+ }
80
+ // `initial` belongs to the URL it was loaded for.
81
+ function fromServer(location: ProductListLocation) {
82
+ return listSignature(location) === listSignature(serverLocation);
83
+ }
84
+ const [opening] = useState(addressLocation);
85
+ const openedFromServer = fromServer(opening);
43
86
  const api = useOrionplaceApi();
44
87
  const locale = useOrionplaceSelections(
45
88
  (state) => state.localeCode,
46
89
  );
47
90
  const productsStore = useOrionplaceProductsStore();
48
91
  const browseStore = useOrionplaceBrowseStore();
49
- const [data, setData] = useState(initial);
50
- const [filters, setFilters] = useState<ProductFacetSelection>({});
51
- const [page, setPage] = useState(initial?.products?.rpag.page ?? 1);
92
+ const [data, setData] = useState(openedFromServer ? initial : undefined);
93
+ const [filters, setFilters] = useState<ProductFacetSelection>(opening.filters);
94
+ const [page, setPage] = useState(
95
+ openedFromServer ? (initial?.products?.rpag.page ?? opening.page) : opening.page,
96
+ );
52
97
  const [pending, setPending] = useState(false);
53
- const [loaded, setLoaded] = useState(Boolean(initial));
54
- const [error, setError] = useState(initialError);
98
+ const [loaded, setLoaded] = useState(openedFromServer && Boolean(initial));
99
+ const [error, setError] = useState(openedFromServer ? initialError : '');
55
100
  // Filter choices of the scope's first response; a filtered response must
56
101
  // not shrink them, so the visitor can still pick another seller or price.
57
- const [facets, setFacets] = useState(() => productSearchFacetData(initial));
102
+ const [facets, setFacets] = useState(() =>
103
+ productListFacetData(openedFromServer ? initial : undefined),
104
+ );
58
105
  const request = useRef<AbortController | undefined>(undefined);
59
106
  const signature = JSON.stringify(scope);
60
107
 
61
- function apply(next: ProductSearchPage) {
108
+ function apply(next: ProductListPage) {
62
109
  // The backend sends the unfiltered total with the first page only;
63
110
  // later pages keep it, as in the Nuxt and Svelte packages.
64
111
  setData((previous) =>
@@ -80,10 +127,10 @@ export function useProductSearch(
80
127
  : next,
81
128
  );
82
129
  setLoaded(true);
83
- const incoming = productSearchFacetData(next);
130
+ const incoming = productListFacetData(next);
84
131
  if (incoming) {
85
132
  browseStore.getState().preserveSearchFacets(incoming);
86
- setFacets((previous) => initialSearchFacets(previous, incoming));
133
+ setFacets((previous) => productListFacetData(next, previous));
87
134
  }
88
135
  for (const product of next.products?.data ?? []) {
89
136
  productsStore
@@ -92,19 +139,39 @@ export function useProductSearch(
92
139
  }
93
140
  }
94
141
 
142
+ // Replaces the URL, so Back from the list still leaves it in one step.
143
+ function rememberLocation(next: ProductListLocation) {
144
+ const query = addressQuery();
145
+ if (
146
+ !remember ||
147
+ sameProductListLocation(productListLocationFromQuery(query), next)
148
+ ) {
149
+ return;
150
+ }
151
+ const search = new URLSearchParams(productListLocationQuery(query, next));
152
+ window.history.replaceState(
153
+ null,
154
+ '',
155
+ `${window.location.pathname}?${search}${window.location.hash}`,
156
+ );
157
+ }
158
+
95
159
  async function load(nextPage = page, nextFilters = filters) {
160
+ rememberLocation({ page: nextPage, filters: nextFilters });
96
161
  request.current?.abort();
97
162
  const controller = new AbortController();
98
163
  request.current = controller;
99
164
  setPending(true);
100
165
  setError('');
101
166
  try {
102
- const next = await loadProductSearch(
167
+ const next = await loadProductListPage(
103
168
  api,
104
169
  scope,
105
170
  nextFilters,
106
171
  nextPage,
107
172
  controller.signal,
173
+ // Only a list without its filter choices yet needs the full ones.
174
+ !browseStore.getState().searchFacets,
108
175
  );
109
176
  if (!controller.signal.aborted) {
110
177
  setPage(nextPage);
@@ -122,18 +189,22 @@ export function useProductSearch(
122
189
  }
123
190
  }
124
191
 
192
+ // A new scope starts from the page and filters of its URL: none after a
193
+ // link, the ones the visitor left after Back or a reload.
125
194
  useEffect(() => {
195
+ const location = addressLocation();
196
+ const server = fromServer(location) ? initial : undefined;
126
197
  browseStore.getState().setSearchQuery(scope.query ?? '');
127
- browseStore.getState().setSearchFilters({});
128
- browseStore.getState().setSearchFacets(productSearchFacetData(initial));
129
- setFacets(productSearchFacetData(initial));
130
- setData(initial);
131
- setFilters({});
132
- setPage(initial?.products?.rpag.page ?? 1);
133
- setLoaded(Boolean(initial));
134
- setError(initialError);
135
- if (!initial && searchable(scope)) {
136
- void load(1, {});
198
+ browseStore.getState().setSearchFilters(location.filters);
199
+ browseStore.getState().setSearchFacets(productListFacetData(server));
200
+ setFacets(productListFacetData(server));
201
+ setData(server);
202
+ setFilters(location.filters);
203
+ setPage(server?.products?.rpag.page ?? location.page);
204
+ setLoaded(Boolean(server));
205
+ setError(fromServer(location) ? initialError : '');
206
+ if (!server && searchable(scope)) {
207
+ void load(location.page, location.filters);
137
208
  }
138
209
  return () => request.current?.abort();
139
210
  }, [signature]);
@@ -46,6 +46,17 @@ Hiding the page, moving away, losing focus or becoming inactive pauses the
46
46
  requests. A 401, 403 or returned field error removes the full text and shows
47
47
  the access-changed notice.
48
48
 
49
+ Products the author places in the text appear at those positions. The
50
+ marketplace blog editor writes an empty block with `data-product-id="12"` for
51
+ one card or `data-product-ids="12,15,9"` for a row of up to 24 cards; the
52
+ block keeps its float or centred style, and `data-product-hidden-badges`
53
+ (`new`, `popular`, `good-deal`) turns card badges off. Products missing from
54
+ `related_products` are requested through `onLoadPlacements` with
55
+ `products_search` and `filters.id_in`, up to 35 IDs per request. A block whose
56
+ products are not found stays hidden. Article text styles do not reach the
57
+ cards, and in a narrow article a floated card stands alone in the middle. The
58
+ helper is `lib/orionplace/blogProductPlacements.ts`.
59
+
49
60
  `related_products` supplies complete product objects to `ProductGroup`, so
50
61
  their standard cards are present during server rendering. The product callbacks
51
62
  forward the same payloads as `ProductGroup`; `productActionsDisabled` blocks
@@ -63,6 +74,7 @@ separate.
63
74
  | `initialPost?: IBlogPost` | Server-provided initial post used for the first render and hydration. |
64
75
  | `commentsProps?: Omit<BlogCommentsProps, "postId">` | Props forwarded to the comments child component. |
65
76
  | `onLoad?: (action: BlogArticleLoadAction) => Promise<IBlogPost \| null \| undefined> \| IBlogPost \| null \| undefined` | Called when the component needs its primary data. The callback payload is shown in its type. |
77
+ | `onLoadPlacements?: (action: BlogPlacementLoadAction) => Promise<BlogPlacementData \| null \| undefined> \| BlogPlacementData \| null \| undefined` | Called when products placed in the article text are missing from `related_products`: `products_search` with `filters.id_in`, up to 35 IDs. Without it only related products fill the placements. |
66
78
  | `onReading?: (action: BlogReadingAction) => Promise<IBlogReadingData> \| IBlogReadingData` | Called when the article reading session starts, continues or stops. The callback payload is shown in its type. |
67
79
  | `onBack?: () => void` | Called when the visitor chooses Back. The callback has no payload. |
68
80
  | `onLoaded?: (post: IBlogPost) => void` | Called when the requested data loads successfully. The callback payload is shown in its type. |
@@ -125,6 +137,17 @@ ID тарифов, ссылка также передаёт их в `ids`. Дл
125
137
  приостанавливают запросы. Ответ 401, 403 или ошибка поля удаляет полный текст
126
138
  и показывает сообщение об изменении доступа.
127
139
 
140
+ Товары, которые автор разместил в тексте, показываются на своих местах.
141
+ Редактор блога маркетплейса пишет пустой блок с `data-product-id="12"` для
142
+ одной карточки или `data-product-ids="12,15,9"` для ряда до 24 карточек;
143
+ блок сохраняет обтекание или выравнивание по центру, а
144
+ `data-product-hidden-badges` (`new`, `popular`, `good-deal`) отключает значки
145
+ карточки. Товары, которых нет в `related_products`, запрашиваются через
146
+ `onLoadPlacements` — `products_search` с `filters.id_in`, до 35 ID за запрос.
147
+ Блок, товары которого не найдены, скрыт. Стили текста статьи не действуют на
148
+ карточки, а в узкой статье карточка с обтеканием стоит отдельно по центру.
149
+ Вспомогательный модуль — `lib/orionplace/blogProductPlacements.ts`.
150
+
128
151
  `related_products` передаёт полные объекты товаров в `ProductGroup`, поэтому
129
152
  обычные карточки есть уже в серверном HTML. Обработчики товаров получают те
130
153
  же данные, что и в `ProductGroup`; `productActionsDisabled` блокирует
@@ -142,6 +165,7 @@ ID тарифов, ссылка также передаёт их в `ids`. Дл
142
165
  | `initialPost?: IBlogPost` | Начальное значение статьи, полученное на сервере для первого рендера и гидратации. |
143
166
  | `commentsProps?: Omit<BlogCommentsProps, "postId">` | Свойства, передаваемые дочернему компоненту комментариев. |
144
167
  | `onLoad?: (action: BlogArticleLoadAction) => Promise<IBlogPost \| null \| undefined> \| IBlogPost \| null \| undefined` | Вызывается, когда компоненту нужны основные данные. Данные обработчика указаны в его типе. |
168
+ | `onLoadPlacements?: (action: BlogPlacementLoadAction) => Promise<BlogPlacementData \| null \| undefined> \| BlogPlacementData \| null \| undefined` | Вызывается, когда товаров, размещённых в тексте статьи, нет в `related_products`: `products_search` с `filters.id_in`, до 35 ID. Без него размещения заполняют только прикреплённые товары. |
145
169
  | `onReading?: (action: BlogReadingAction) => Promise<IBlogReadingData> \| IBlogReadingData` | Вызывается, когда сеанс чтения статьи начинается, продолжается или завершается. Данные обработчика указаны в его типе. |
146
170
  | `onBack?: () => void` | Вызывается, когда покупатель выбирает «Назад». Обработчик не получает данные. |
147
171
  | `onLoaded?: (post: IBlogPost) => void` | Вызывается, когда запрошенные данные успешно загружены. Данные обработчика указаны в его типе. |