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.
- 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/ArticlePage.tsx +3 -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/components/orionplace/blog/BlogArticle.README.md +24 -0
- package/templates/components/orionplace/blog/BlogArticle.module.css +70 -46
- package/templates/components/orionplace/blog/BlogArticle.tsx +115 -0
- package/templates/components/orionplace/products/ProductCard.README.md +2 -0
- package/templates/components/orionplace/products/ProductCard.tsx +14 -0
- package/templates/components/orionplace/products/ProductGroup.README.md +2 -0
- package/templates/components/orionplace/products/ProductGroup.tsx +3 -0
- package/templates/lib/orionplace/blogArticle.ts +25 -0
- package/templates/lib/orionplace/blogArticleHtml.ts +1 -0
- package/templates/lib/orionplace/blogProductPlacements.ts +105 -0
- 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
|
@@ -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
|
|