orionplace-react 0.6.0 → 0.6.1
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/package.json +1 -1
- package/runtime/marketplace.d.mts +12 -0
- package/runtime/marketplace.mjs +31 -2
- package/src/project-readme.mjs +6 -0
- package/templates/app/_storefront/CategoryPage.tsx +6 -2
- package/templates/app/_storefront/SearchPage.tsx +7 -2
- package/templates/app/_storefront/SellersPage.tsx +9 -3
- package/templates/app/_storefront/storefrontRoutes.tsx +31 -10
- package/templates/app/_storefront/useProductSearch.ts +97 -26
- package/templates/lib/orionplace/pageMetadata.ts +24 -4
- package/templates/lib/orionplace/pageSeo.ts +68 -0
- package/templates/lib/orionplace/productListLocation.ts +130 -0
- package/templates/lib/orionplace/productSearch.ts +48 -0
- package/templates/lib/orionplace/serverMetadata.ts +7 -0
- 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.
|
|
3
|
+
"version": "0.6.1",
|
|
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(
|
package/runtime/marketplace.mjs
CHANGED
|
@@ -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',
|
package/src/project-readme.mjs
CHANGED
|
@@ -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
|
|
@@ -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 {
|
|
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?:
|
|
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
|
|
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 {
|
|
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?:
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
type
|
|
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) =>
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
type
|
|
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?:
|
|
58
|
+
initial?: ProductListPage,
|
|
40
59
|
initialError = '',
|
|
41
|
-
options: {
|
|
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(
|
|
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(() =>
|
|
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:
|
|
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 =
|
|
130
|
+
const incoming = productListFacetData(next);
|
|
84
131
|
if (incoming) {
|
|
85
132
|
browseStore.getState().preserveSearchFacets(incoming);
|
|
86
|
-
setFacets((previous) =>
|
|
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
|
|
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(
|
|
129
|
-
setFacets(
|
|
130
|
-
setData(
|
|
131
|
-
setFilters(
|
|
132
|
-
setPage(
|
|
133
|
-
setLoaded(Boolean(
|
|
134
|
-
setError(initialError);
|
|
135
|
-
if (!
|
|
136
|
-
void load(
|
|
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]);
|
|
@@ -13,6 +13,8 @@ import type { ICategoryLocale } from '@/interfaces/orionplace/category/ICategory
|
|
|
13
13
|
import type { IPromoDefinition } from '@/interfaces/orionplace/cart/ICartData';
|
|
14
14
|
import { promoName } from '@/lib/orionplace/promoPresentation';
|
|
15
15
|
import {
|
|
16
|
+
storefrontAbsoluteUrl,
|
|
17
|
+
storefrontCanonicalUrl,
|
|
16
18
|
storefrontPageMetadata,
|
|
17
19
|
type StorefrontSeoPage,
|
|
18
20
|
} from '@/lib/orionplace/pageSeo';
|
|
@@ -111,6 +113,8 @@ export function storefrontMetadata(
|
|
|
111
113
|
locale: string,
|
|
112
114
|
url: URL,
|
|
113
115
|
records: StorefrontPageRecords = {},
|
|
116
|
+
// The public origin (the proxy's host on the server, the page's own in the browser).
|
|
117
|
+
origin = url.origin,
|
|
114
118
|
) {
|
|
115
119
|
const segments = url.pathname.split('/').filter(Boolean);
|
|
116
120
|
let page = records.pageKind ?? storefrontSeoPage(url.pathname);
|
|
@@ -125,7 +129,13 @@ export function storefrontMetadata(
|
|
|
125
129
|
let image: string | undefined;
|
|
126
130
|
const tags: MetaTags = [];
|
|
127
131
|
let robots: string | undefined;
|
|
128
|
-
|
|
132
|
+
// A private product link (`key`) stays out of search results, loaded or not.
|
|
133
|
+
if (
|
|
134
|
+
privatePages.has(page) ||
|
|
135
|
+
page === 'notFound' ||
|
|
136
|
+
page === 'error' ||
|
|
137
|
+
(page === 'product' && url.searchParams.get('key'))
|
|
138
|
+
) {
|
|
129
139
|
robots = 'noindex, nofollow';
|
|
130
140
|
} else if (page === 'browse') {
|
|
131
141
|
robots = 'noindex, follow';
|
|
@@ -177,9 +187,6 @@ export function storefrontMetadata(
|
|
|
177
187
|
field('meta_description') || field('short_description'),
|
|
178
188
|
).slice(0, 160);
|
|
179
189
|
image = product.images?.full?.[0] ?? product.images?.small?.[0];
|
|
180
|
-
if (url.searchParams.get('key')) {
|
|
181
|
-
robots = 'noindex, nofollow';
|
|
182
|
-
}
|
|
183
190
|
} else if (page === 'publication' && records.post) {
|
|
184
191
|
values.title = getAvailableNestedValueFromObject(
|
|
185
192
|
records.post.locale,
|
|
@@ -210,12 +217,24 @@ export function storefrontMetadata(
|
|
|
210
217
|
values.title = promoName(records.promo, locale);
|
|
211
218
|
}
|
|
212
219
|
const content = storefrontPageMetadata(page, locale, values);
|
|
220
|
+
// Link previews load only complete image addresses; the canonical address
|
|
221
|
+
// is the sitemap's one for this page.
|
|
222
|
+
image = storefrontAbsoluteUrl(image, origin);
|
|
223
|
+
const canonical = storefrontCanonicalUrl(
|
|
224
|
+
origin,
|
|
225
|
+
url.pathname,
|
|
226
|
+
url.search,
|
|
227
|
+
locale,
|
|
228
|
+
robots,
|
|
229
|
+
);
|
|
213
230
|
const metadata: Metadata = {
|
|
214
231
|
...content,
|
|
215
232
|
...(robots ? { robots } : {}),
|
|
233
|
+
...(canonical ? { alternates: { canonical } } : {}),
|
|
216
234
|
openGraph: {
|
|
217
235
|
title: content.title,
|
|
218
236
|
description: content.description,
|
|
237
|
+
...(canonical ? { url: canonical } : {}),
|
|
219
238
|
...(image ? { images: [image] } : {}),
|
|
220
239
|
},
|
|
221
240
|
};
|
|
@@ -230,6 +249,7 @@ export function storefrontMetadata(
|
|
|
230
249
|
'og:title': content.title,
|
|
231
250
|
'og:description': content.description,
|
|
232
251
|
'og:image': image,
|
|
252
|
+
'og:url': canonical,
|
|
233
253
|
})) {
|
|
234
254
|
headTags.set(name, {
|
|
235
255
|
...(name.startsWith('og:') ? { property: name } : { name }),
|
|
@@ -282,3 +282,71 @@ export function storefrontPageMetadata(
|
|
|
282
282
|
keywords: plainText(values.keywords ?? '') || `${title}, ${copy[2]}`,
|
|
283
283
|
};
|
|
284
284
|
}
|
|
285
|
+
|
|
286
|
+
/*
|
|
287
|
+
* Addresses for search engines and link previews. The public origin is the
|
|
288
|
+
* storefront host the visitor used (the proxy's Host or X-Forwarded-Host),
|
|
289
|
+
* over HTTPS; a local host keeps HTTP for development.
|
|
290
|
+
*/
|
|
291
|
+
export function storefrontPublicOrigin(host: string | null | undefined): string {
|
|
292
|
+
const name = (host ?? '').split(',')[0]!.trim().toLowerCase();
|
|
293
|
+
if (!/^(?:[a-z0-9-]+(?:\.[a-z0-9-]+)*|\[[0-9a-f:]+\])(?::\d{1,5})?$/.test(name)) {
|
|
294
|
+
return '';
|
|
295
|
+
}
|
|
296
|
+
const local = /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?$/.test(name);
|
|
297
|
+
return `${local ? 'http' : 'https'}://${name}`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* A complete address for a link preview image: a storefront path such as
|
|
302
|
+
* `/ps3/blog/posts/images/5` gets the public origin; without an origin it is
|
|
303
|
+
* left out, since a relative og:image is not loaded by link previews.
|
|
304
|
+
*/
|
|
305
|
+
export function storefrontAbsoluteUrl(
|
|
306
|
+
value: string | null | undefined,
|
|
307
|
+
origin: string,
|
|
308
|
+
): string | undefined {
|
|
309
|
+
if (!value) {
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
const url = origin ? new URL(value, `${origin}/`) : new URL(value);
|
|
314
|
+
return /^https?:$/.test(url.protocol) ? url.toString() : undefined;
|
|
315
|
+
} catch {
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// The pages the sitemap lists (runtime/marketplace.mjs), plus plan pages.
|
|
321
|
+
const indexedPage = /^\/(?:|catalog|journal|tariffs|manufacturers|sellers|stores|contact|privacy|(?:category|product|journal)\/\d+|tariffs\/[^/]+\/\d+)$/;
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The canonical address of a page: the sitemap's address for it, the page in
|
|
325
|
+
* the language it shows (`?locale=`). Pages that are not listed, pages with
|
|
326
|
+
* other query parameters (filters, page numbers, private keys) and noindex
|
|
327
|
+
* pages get none.
|
|
328
|
+
*/
|
|
329
|
+
export function storefrontCanonicalUrl(
|
|
330
|
+
origin: string,
|
|
331
|
+
pathname: string,
|
|
332
|
+
search: string,
|
|
333
|
+
locale: string,
|
|
334
|
+
robots?: string,
|
|
335
|
+
): string | undefined {
|
|
336
|
+
const path = pathname.replace(/\/+$/, '') || '/';
|
|
337
|
+
const query = new URLSearchParams(search);
|
|
338
|
+
query.delete('locale');
|
|
339
|
+
if (
|
|
340
|
+
!origin ||
|
|
341
|
+
/noindex/i.test(robots ?? '') ||
|
|
342
|
+
!indexedPage.test(path) ||
|
|
343
|
+
[...query.keys()].length
|
|
344
|
+
) {
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
const url = new URL(path, `${origin}/`);
|
|
348
|
+
if (locale) {
|
|
349
|
+
url.searchParams.set('locale', locale);
|
|
350
|
+
}
|
|
351
|
+
return url.toString();
|
|
352
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { ProductFacetSelection } from '@/interfaces/orionplace/products/IProductSearchFacets';
|
|
2
|
+
|
|
3
|
+
// Where a product list is: its result page and the chosen filters. Category,
|
|
4
|
+
// search, seller and featured lists keep it in the URL as `?page&filters`, so
|
|
5
|
+
// a reload and the browser's Back button from a product return to the same
|
|
6
|
+
// list. `filters` is the filter selection as JSON; the address is visitor
|
|
7
|
+
// input, so only plain filter values within the limits below are read.
|
|
8
|
+
export interface ProductListLocation {
|
|
9
|
+
page: number;
|
|
10
|
+
filters: ProductFacetSelection;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type QueryValue = string | null | undefined | readonly (string | null)[];
|
|
14
|
+
|
|
15
|
+
const locationKeys = ['page', 'filters'];
|
|
16
|
+
const maxPage = 10000;
|
|
17
|
+
const maxFiltersText = 4000;
|
|
18
|
+
const maxFilterKeys = 40;
|
|
19
|
+
const maxFilterIds = 200;
|
|
20
|
+
const maxFilterString = 100;
|
|
21
|
+
const filterKey = /^[a-z][a-z0-9_]{0,63}$/;
|
|
22
|
+
|
|
23
|
+
function first(value: QueryValue) {
|
|
24
|
+
return Array.isArray(value) ? value[0] : (value as string | null | undefined);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function filterValue(value: unknown): ProductFacetSelection[string] {
|
|
28
|
+
if (Array.isArray(value)) {
|
|
29
|
+
const ids = value.filter(
|
|
30
|
+
(id): id is number => Number.isSafeInteger(id) && id >= 0,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
return ids.length === value.length && ids.length && ids.length <= maxFilterIds
|
|
34
|
+
? ids
|
|
35
|
+
: undefined;
|
|
36
|
+
}
|
|
37
|
+
if (typeof value === 'string') {
|
|
38
|
+
return value && value.length <= maxFilterString ? value : undefined;
|
|
39
|
+
}
|
|
40
|
+
if (typeof value === 'number') {
|
|
41
|
+
return Number.isFinite(value) ? value : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// `false` chooses nothing, as an empty list or text.
|
|
45
|
+
return value === true ? true : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function productListFiltersFromText(
|
|
49
|
+
text: string | null | undefined,
|
|
50
|
+
): ProductFacetSelection {
|
|
51
|
+
if (!text || text.length > maxFiltersText) {
|
|
52
|
+
return {};
|
|
53
|
+
}
|
|
54
|
+
let parsed: unknown;
|
|
55
|
+
try {
|
|
56
|
+
parsed = JSON.parse(text);
|
|
57
|
+
} catch {
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
const result: ProductFacetSelection = {};
|
|
64
|
+
for (const [key, value] of Object.entries(parsed).slice(0, maxFilterKeys)) {
|
|
65
|
+
const checked = filterKey.test(key) ? filterValue(value) : undefined;
|
|
66
|
+
if (checked !== undefined) {
|
|
67
|
+
result[key] = checked;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The filters as URL text, or undefined when nothing is chosen.
|
|
75
|
+
// Keys are sorted, so the same choice always gives the same text.
|
|
76
|
+
export function productListFiltersText(filters: ProductFacetSelection) {
|
|
77
|
+
const chosen = productListFiltersFromText(JSON.stringify(filters));
|
|
78
|
+
const keys = Object.keys(chosen).sort();
|
|
79
|
+
|
|
80
|
+
return keys.length
|
|
81
|
+
? JSON.stringify(Object.fromEntries(keys.map((key) => [key, chosen[key]])))
|
|
82
|
+
: undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function productListLocationFromQuery(
|
|
86
|
+
query: Record<string, QueryValue>,
|
|
87
|
+
): ProductListLocation {
|
|
88
|
+
const page = Number(first(query.page));
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
page: Number.isSafeInteger(page) && page > 0 && page <= maxPage ? page : 1,
|
|
92
|
+
filters: productListFiltersFromText(first(query.filters)),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Compares the URL form, so the same choice in another key order is equal.
|
|
97
|
+
export function sameProductListLocation(
|
|
98
|
+
left: ProductListLocation,
|
|
99
|
+
right: ProductListLocation,
|
|
100
|
+
) {
|
|
101
|
+
return (
|
|
102
|
+
left.page === right.page &&
|
|
103
|
+
productListFiltersText(left.filters) === productListFiltersText(right.filters)
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The query of `next`: other parameters such as `locale` or `q` stay, and the
|
|
108
|
+
// first page without filters adds nothing.
|
|
109
|
+
export function productListLocationQuery(
|
|
110
|
+
query: Record<string, QueryValue>,
|
|
111
|
+
next: ProductListLocation,
|
|
112
|
+
) {
|
|
113
|
+
const result: Record<string, string> = {};
|
|
114
|
+
|
|
115
|
+
for (const [key, value] of Object.entries(query)) {
|
|
116
|
+
const text = first(value);
|
|
117
|
+
if (typeof text === 'string' && !locationKeys.includes(key)) {
|
|
118
|
+
result[key] = text;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (next.page > 1) {
|
|
122
|
+
result.page = String(next.page);
|
|
123
|
+
}
|
|
124
|
+
const filters = productListFiltersText(next.filters);
|
|
125
|
+
if (filters) {
|
|
126
|
+
result.filters = filters;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
IProductSearchResponse,
|
|
8
8
|
} from '@/interfaces/orionplace/products/IProductSearchApi';
|
|
9
9
|
import type { OrionplaceApi } from '@/lib/orionplace/api';
|
|
10
|
+
import { initialSearchFacets } from '@/lib/orionplace/searchFilters';
|
|
10
11
|
|
|
11
12
|
export type ProductSearchPage = NonNullable<
|
|
12
13
|
IProductSearchResponse['page_data']
|
|
@@ -230,3 +231,50 @@ export async function loadProductSearch(
|
|
|
230
231
|
signal,
|
|
231
232
|
);
|
|
232
233
|
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* A result page with `fullFacets`: the filter choices of the unfiltered
|
|
237
|
+
* first page, when the page was a later one or loaded with filters.
|
|
238
|
+
*/
|
|
239
|
+
export type ProductListPage = ProductSearchPage & {
|
|
240
|
+
fullFacets?: IProductSearchFacets | null;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* A list opened from its URL (Back, a reload) on a later page or with
|
|
245
|
+
* filters gets only the filtered choices, or none after the first page.
|
|
246
|
+
* With `withFullFacets` this also asks for the unfiltered first page, so the
|
|
247
|
+
* filter panel keeps every seller, price and option to choose from. The
|
|
248
|
+
* extra request is only for the panel: when it fails the panel shows the
|
|
249
|
+
* choices of the result.
|
|
250
|
+
*/
|
|
251
|
+
export async function loadProductListPage(
|
|
252
|
+
api: OrionplaceApi,
|
|
253
|
+
scope: ProductSearchScope,
|
|
254
|
+
filters: ProductFacetSelection = {},
|
|
255
|
+
page = 1,
|
|
256
|
+
signal?: AbortSignal,
|
|
257
|
+
withFullFacets = true,
|
|
258
|
+
): Promise<ProductListPage> {
|
|
259
|
+
// Both requests run together.
|
|
260
|
+
const [data, full] = await Promise.all([
|
|
261
|
+
loadProductSearch(api, scope, filters, page, signal),
|
|
262
|
+
withFullFacets && (page > 1 || Object.keys(filters).length > 0)
|
|
263
|
+
? loadProductSearch(api, scope, {}, 1, signal).catch(() => undefined)
|
|
264
|
+
: undefined,
|
|
265
|
+
]);
|
|
266
|
+
|
|
267
|
+
return full ? { ...data, fullFacets: productSearchFacetData(full) } : data;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The filter choices of a list page: the full ones first, as kept by the store. */
|
|
271
|
+
export function productListFacetData(
|
|
272
|
+
page: ProductListPage | undefined,
|
|
273
|
+
previous: IProductSearchFacets | null = null,
|
|
274
|
+
) {
|
|
275
|
+
return [page?.fullFacets ?? null, productSearchFacetData(page)].reduce(
|
|
276
|
+
(current, incoming) =>
|
|
277
|
+
incoming ? initialSearchFacets(current, incoming) : current,
|
|
278
|
+
previous,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { cache } from 'react';
|
|
2
|
+
import { headers } from 'next/headers';
|
|
3
|
+
import { storefrontPublicOrigin } from '@/lib/orionplace/pageSeo';
|
|
2
4
|
import { getOrionplaceAppLoad } from '@/lib/orionplace/appLoadServer';
|
|
3
5
|
import {
|
|
4
6
|
getOrionplaceServerLocale,
|
|
@@ -73,11 +75,16 @@ export const getStorefrontMetadataContext = cache(async () => {
|
|
|
73
75
|
} catch {
|
|
74
76
|
// The page retains its request error and offers Retry.
|
|
75
77
|
}
|
|
78
|
+
const incoming = await headers();
|
|
76
79
|
return storefrontMetadata(
|
|
77
80
|
result?.response.page_data.init ?? null,
|
|
78
81
|
locale,
|
|
79
82
|
url,
|
|
80
83
|
{ product, post, tariff, promo, pageKind },
|
|
84
|
+
// The address the visitor used: the proxy's host.
|
|
85
|
+
storefrontPublicOrigin(
|
|
86
|
+
incoming.get('x-forwarded-host') ?? incoming.get('host'),
|
|
87
|
+
),
|
|
81
88
|
);
|
|
82
89
|
});
|
|
83
90
|
|
package/templates/proxy.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { NextResponse, type NextRequest } from 'next/server';
|
|
2
|
+
import { marketplaceNoindexPath } from '@orionplace-runtime/marketplace.mjs';
|
|
2
3
|
import {
|
|
3
4
|
APP_LOAD_HANDOFF_HEADER,
|
|
4
5
|
startAppLoad,
|
|
@@ -47,6 +48,10 @@ export async function proxy(request: NextRequest) {
|
|
|
47
48
|
...(unavailable ? { status: 503 } : {}),
|
|
48
49
|
});
|
|
49
50
|
response.headers.set('Cache-Control', 'private, no-store');
|
|
51
|
+
// Pages under ORIONPLACE_NOINDEX_PATHS stay out of search engines.
|
|
52
|
+
if (marketplaceNoindexPath(request.nextUrl.pathname)) {
|
|
53
|
+
response.headers.set('X-Robots-Tag', 'noindex, follow');
|
|
54
|
+
}
|
|
50
55
|
return response;
|
|
51
56
|
}
|
|
52
57
|
|