orionplace-react 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/package.json +1 -1
- package/runtime/marketplace.d.mts +5 -0
- package/runtime/marketplace.mjs +13 -1
- package/src/project-readme.mjs +8 -0
- package/templates/app/_storefront/ArticlePage.tsx +3 -0
- package/templates/app/_storefront/ProductPage.tsx +14 -2
- package/templates/app/_storefront/StorefrontProductStructuredData.tsx +79 -0
- package/templates/app/_storefront/storefrontRoutes.tsx +6 -0
- 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/productStructuredData.ts +220 -0
- package/templates/lib/orionplace/serverRoute.tsx +9 -2
- package/templates/proxy.ts +4 -3
package/README.md
CHANGED
|
@@ -478,7 +478,9 @@ The corresponding `.cfg` is read on every request. Backend URLs must use HTTPS,
|
|
|
478
478
|
end in `/api`, and belong to `*.orionplace.app`. API methods, query strings, bodies,
|
|
479
479
|
authorization and cookies are forwarded. Backend statuses are retained and cookie
|
|
480
480
|
Domain attributes are removed to bind cookies to the storefront. Gateway responses
|
|
481
|
-
use `Cache-Control: no-store
|
|
481
|
+
use `Cache-Control: no-store`, except public blog images and videos: they keep the
|
|
482
|
+
backend's `private, max-age` rule, so the browser does not download them again on
|
|
483
|
+
every page. Network failures return `502`.
|
|
482
484
|
|
|
483
485
|
A changing request (POST, PUT, PATCH, DELETE) from another site is rejected with
|
|
484
486
|
`403`. A request from the page's own host or the storefront domain passes, and the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orionplace-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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,11 @@ export declare function marketplacePublicOrigin(
|
|
|
37
37
|
tenantDomain?: string,
|
|
38
38
|
): string;
|
|
39
39
|
|
|
40
|
+
export declare function marketplaceCacheControl(
|
|
41
|
+
media: boolean,
|
|
42
|
+
upstream: Response,
|
|
43
|
+
): string;
|
|
44
|
+
|
|
40
45
|
export declare function noindexPathPrefixes(value?: string): string[];
|
|
41
46
|
|
|
42
47
|
export declare function marketplaceNoindexPath(
|
package/runtime/marketplace.mjs
CHANGED
|
@@ -41,6 +41,18 @@ const hostnamePattern =
|
|
|
41
41
|
const blogMediaPattern =
|
|
42
42
|
/^\/ps3\/blog\/posts\/(?:images\/\d+|videos\/\d+(?:\/preview)?)$/;
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* API answers are never stored. A media file keeps the backend's own browser
|
|
46
|
+
* cache rule when the backend allows it (a published public blog image:
|
|
47
|
+
* "private, max-age=…"); "private" keeps it out of shared caches.
|
|
48
|
+
*/
|
|
49
|
+
export function marketplaceCacheControl(media, upstream) {
|
|
50
|
+
const value = upstream.headers.get('cache-control') ?? '';
|
|
51
|
+
return media && upstream.ok && /\bprivate\b/i.test(value) && /\bmax-age=\d+/i.test(value) && !/no-store|no-cache/i.test(value)
|
|
52
|
+
? value
|
|
53
|
+
: 'no-store';
|
|
54
|
+
}
|
|
55
|
+
|
|
44
56
|
/** @param {string} pathname */
|
|
45
57
|
export function isMarketplaceMediaPath(pathname) {
|
|
46
58
|
return blogMediaPattern.test(pathname);
|
|
@@ -307,7 +319,7 @@ export async function forwardMarketplace(request, configuration = {}) {
|
|
|
307
319
|
: destination.href,
|
|
308
320
|
);
|
|
309
321
|
}
|
|
310
|
-
responseHeaders.set('Cache-Control',
|
|
322
|
+
responseHeaders.set('Cache-Control', marketplaceCacheControl(media, upstream));
|
|
311
323
|
responseHeaders.set('Vary', 'Host, X-Forwarded-Host, X-Front-Domain');
|
|
312
324
|
// fetch decodes compressed bodies: do not reuse upstream Content-Length/Encoding.
|
|
313
325
|
return new Response(upstream.body, {
|
package/src/project-readme.mjs
CHANGED
|
@@ -411,6 +411,14 @@ for example \`ORIONPLACE_NOINDEX_PATHS="/catalog /category /product"\`. Those
|
|
|
411
411
|
pages answer with \`X-Robots-Tag: noindex, follow\` and the sitemap leaves them
|
|
412
412
|
out; the journal and other pages stay indexable. Leave it empty on a live shop.
|
|
413
413
|
|
|
414
|
+
Every product page carries schema.org product data for Google and Yandex: the
|
|
415
|
+
price, the crossed-out old price, stock, photos, brand, seller, rating and the
|
|
416
|
+
return terms the page shows. To include the return terms, set
|
|
417
|
+
\`ORIONPLACE_SHIPPING_COUNTRIES\` in \`.env\` to the countries you ship to, as
|
|
418
|
+
ISO codes separated by spaces, for example
|
|
419
|
+
\`ORIONPLACE_SHIPPING_COUNTRIES="RU BY"\`; without it the return terms are left
|
|
420
|
+
out. The delivery cost is not included: it depends on the buyer's address.
|
|
421
|
+
|
|
414
422
|
This setup does not add analytics.
|
|
415
423
|
|
|
416
424
|
## 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,
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from 'react';
|
|
4
4
|
import { StorefrontPageMetadata } from '@/app/_storefront/StorefrontPageMetadata';
|
|
5
|
+
import { StorefrontProductStructuredData } from '@/app/_storefront/StorefrontProductStructuredData';
|
|
5
6
|
import { StorefrontErrorRoute } from '@/app/_storefront/StorefrontErrorRoute';
|
|
6
7
|
import { StorefrontProduct } from '@/components/orionplace/products/StorefrontProduct';
|
|
7
8
|
import type { IProduct } from '@/interfaces/orionplace';
|
|
@@ -16,14 +17,20 @@ import styles from '@/app/storefront.module.css';
|
|
|
16
17
|
|
|
17
18
|
export function ProductPage({
|
|
18
19
|
productId,
|
|
19
|
-
previewKey
|
|
20
|
+
previewKey = '',
|
|
20
21
|
initialProduct,
|
|
21
|
-
initialError
|
|
22
|
+
initialError = '',
|
|
23
|
+
origin = '',
|
|
24
|
+
shippingCountries = '',
|
|
22
25
|
}: {
|
|
23
26
|
productId: number;
|
|
24
27
|
previewKey?: string;
|
|
25
28
|
initialProduct?: IProduct;
|
|
26
29
|
initialError?: string;
|
|
30
|
+
/** The visitor's origin from the server render. */
|
|
31
|
+
origin?: string;
|
|
32
|
+
/** ORIONPLACE_SHIPPING_COUNTRIES from the server's .env. */
|
|
33
|
+
shippingCountries?: string;
|
|
27
34
|
}) {
|
|
28
35
|
const api = useOrionplaceApi();
|
|
29
36
|
const init = useOrionplace(selectInit);
|
|
@@ -53,6 +60,11 @@ export function ProductPage({
|
|
|
53
60
|
data-storefront-page="product"
|
|
54
61
|
>
|
|
55
62
|
<StorefrontPageMetadata product={metadataProduct} />
|
|
63
|
+
<StorefrontProductStructuredData
|
|
64
|
+
product={metadataProduct}
|
|
65
|
+
origin={origin}
|
|
66
|
+
shippingCountries={shippingCountries}
|
|
67
|
+
/>
|
|
56
68
|
<StorefrontProduct
|
|
57
69
|
productId={productId}
|
|
58
70
|
previewKey={previewKey}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { usePathname, useSearchParams } from 'next/navigation';
|
|
4
|
+
import {
|
|
5
|
+
useOrionplace,
|
|
6
|
+
useOrionplaceSelections,
|
|
7
|
+
} from '@/providers/OrionplaceProvider';
|
|
8
|
+
import { selectInit } from '@/stores/orionplace';
|
|
9
|
+
import type { IProduct } from '@/interfaces/orionplace';
|
|
10
|
+
import type { IProductLocale } from '@/interfaces/orionplace/products/IProduct';
|
|
11
|
+
import {
|
|
12
|
+
getAvailableNestedValueFromObject,
|
|
13
|
+
plainText,
|
|
14
|
+
} from '@/lib/orionplace/presentation';
|
|
15
|
+
import {
|
|
16
|
+
storefrontAbsoluteUrl,
|
|
17
|
+
storefrontCanonicalUrl,
|
|
18
|
+
} from '@/lib/orionplace/pageSeo';
|
|
19
|
+
import {
|
|
20
|
+
shippingCountryCodes,
|
|
21
|
+
storefrontProductStructuredData,
|
|
22
|
+
structuredDataJson,
|
|
23
|
+
} from '@/lib/orionplace/productStructuredData';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The product page's schema.org data for search engines, rendered with the
|
|
27
|
+
* page on the server. Nuxt and Svelte put the same data into the page head
|
|
28
|
+
* through useOrionplacePageSeo.
|
|
29
|
+
*/
|
|
30
|
+
export function StorefrontProductStructuredData({
|
|
31
|
+
product,
|
|
32
|
+
origin,
|
|
33
|
+
shippingCountries,
|
|
34
|
+
}: {
|
|
35
|
+
product?: IProduct;
|
|
36
|
+
/** The visitor's origin from the server render (storefrontPublicOrigin). */
|
|
37
|
+
origin: string;
|
|
38
|
+
/** ORIONPLACE_SHIPPING_COUNTRIES from the server's .env. */
|
|
39
|
+
shippingCountries: string;
|
|
40
|
+
}) {
|
|
41
|
+
const pathname = usePathname();
|
|
42
|
+
const search = useSearchParams().toString();
|
|
43
|
+
const init = useOrionplace(selectInit);
|
|
44
|
+
const locale = useOrionplaceSelections((state) => state.localeCode);
|
|
45
|
+
const currencyId = useOrionplaceSelections((state) => state.currencyId);
|
|
46
|
+
const field = <Key extends keyof IProductLocale>(name: Key) =>
|
|
47
|
+
getAvailableNestedValueFromObject(product?.locale, locale, name);
|
|
48
|
+
// A preview or any other page without a canonical address gets no data.
|
|
49
|
+
const canonical = storefrontCanonicalUrl(
|
|
50
|
+
origin,
|
|
51
|
+
pathname,
|
|
52
|
+
search ? `?${search}` : '',
|
|
53
|
+
locale,
|
|
54
|
+
);
|
|
55
|
+
const json = structuredDataJson(
|
|
56
|
+
canonical
|
|
57
|
+
? storefrontProductStructuredData(product, {
|
|
58
|
+
url: canonical,
|
|
59
|
+
name: field('product_name'),
|
|
60
|
+
description: plainText(
|
|
61
|
+
field('meta_description') || field('short_description'),
|
|
62
|
+
).slice(0, 160),
|
|
63
|
+
currencyId,
|
|
64
|
+
currencyCode: init?.currencies?.[currencyId]?.currency_code,
|
|
65
|
+
shippingCountries: shippingCountryCodes(shippingCountries),
|
|
66
|
+
showSeller: !init?.visual_settings?.single_seller_mode,
|
|
67
|
+
absoluteUrl: (value) => storefrontAbsoluteUrl(value, origin),
|
|
68
|
+
})
|
|
69
|
+
: undefined,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
return json ? (
|
|
73
|
+
<script
|
|
74
|
+
type="application/ld+json"
|
|
75
|
+
// The JSON is escaped by structuredDataJson, so it cannot close the tag.
|
|
76
|
+
dangerouslySetInnerHTML={{ __html: json }}
|
|
77
|
+
/>
|
|
78
|
+
) : null;
|
|
79
|
+
}
|
|
@@ -97,6 +97,10 @@ export interface StorefrontRouteContext {
|
|
|
97
97
|
query: StorefrontRouteQuery;
|
|
98
98
|
/** The address with its query, which sign-in returns to. */
|
|
99
99
|
address: string;
|
|
100
|
+
/** The visitor's origin (storefrontPublicOrigin), for complete addresses. */
|
|
101
|
+
origin?: string;
|
|
102
|
+
/** ORIONPLACE_SHIPPING_COUNTRIES from the server's .env. */
|
|
103
|
+
shippingCountries?: string;
|
|
100
104
|
appLoad: { result: AppLoadResult | null; failure: AppLoadFailure | null };
|
|
101
105
|
/** The storefront language of this request. */
|
|
102
106
|
locale: () => Promise<string>;
|
|
@@ -461,6 +465,8 @@ const routes: Record<string, Loader> = {
|
|
|
461
465
|
previewKey={previewKey}
|
|
462
466
|
initialProduct={initialProduct}
|
|
463
467
|
initialError={initialError}
|
|
468
|
+
origin={context.origin ?? ''}
|
|
469
|
+
shippingCountries={context.shippingCountries ?? ''}
|
|
464
470
|
/>
|
|
465
471
|
),
|
|
466
472
|
};
|
|
@@ -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` | Вызывается, когда запрошенные данные успешно загружены. Данные обработчика указаны в его типе. |
|
|
@@ -75,87 +75,88 @@
|
|
|
75
75
|
content: '';
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
/* Product cards placed in the text keep their own look: the text rules skip placement blocks. */
|
|
79
|
+
.prose :where(p, ul, ol, blockquote, pre, figure):where(:not([data-product-id] *, [data-product-ids] *)) {
|
|
79
80
|
margin: 1rem 0;
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
.prose :where(h1, h2, h3, h4, h5, h6, figure, table, pre) {
|
|
83
|
+
.prose :where(h1, h2, h3, h4, h5, h6, figure, table, pre):where(:not([data-product-id] *, [data-product-ids] *)) {
|
|
83
84
|
clear: both;
|
|
84
85
|
}
|
|
85
86
|
|
|
86
|
-
.prose :where(h1, h2, h3, h4, h5, h6) {
|
|
87
|
+
.prose :where(h1, h2, h3, h4, h5, h6):where(:not([data-product-id] *, [data-product-ids] *)) {
|
|
87
88
|
margin: 2rem 0 1rem;
|
|
88
89
|
font-weight: 500;
|
|
89
90
|
line-height: 1.3;
|
|
90
91
|
}
|
|
91
92
|
|
|
92
|
-
.prose h1 { font-size: 1.875rem; }
|
|
93
|
-
.prose h2 { font-size: 1.5rem; }
|
|
94
|
-
.prose h3 { font-size: 1.25rem; }
|
|
95
|
-
.prose h4 { font-size: 1.125rem; }
|
|
96
|
-
.prose h5 { font-size: 1rem; }
|
|
97
|
-
.prose h6 {
|
|
93
|
+
.prose h1:where(:not([data-product-id] *, [data-product-ids] *)) { font-size: 1.875rem; }
|
|
94
|
+
.prose h2:where(:not([data-product-id] *, [data-product-ids] *)) { font-size: 1.5rem; }
|
|
95
|
+
.prose h3:where(:not([data-product-id] *, [data-product-ids] *)) { font-size: 1.25rem; }
|
|
96
|
+
.prose h4:where(:not([data-product-id] *, [data-product-ids] *)) { font-size: 1.125rem; }
|
|
97
|
+
.prose h5:where(:not([data-product-id] *, [data-product-ids] *)) { font-size: 1rem; }
|
|
98
|
+
.prose h6:where(:not([data-product-id] *, [data-product-ids] *)) {
|
|
98
99
|
font-size: 0.875rem;
|
|
99
100
|
text-transform: uppercase;
|
|
100
101
|
letter-spacing: 0.08em;
|
|
101
102
|
}
|
|
102
103
|
|
|
103
|
-
.prose :where(strong, b) { font-weight: 500; }
|
|
104
|
-
.prose :where(sub, sup) { font-size: 0.75em; line-height: 0; }
|
|
105
|
-
.prose mark { color: inherit; background: var(--orionplace-attention-surface); }
|
|
104
|
+
.prose :where(strong, b):where(:not([data-product-id] *, [data-product-ids] *)) { font-weight: 500; }
|
|
105
|
+
.prose :where(sub, sup):where(:not([data-product-id] *, [data-product-ids] *)) { font-size: 0.75em; line-height: 0; }
|
|
106
|
+
.prose mark:where(:not([data-product-id] *, [data-product-ids] *)) { color: inherit; background: var(--orionplace-attention-surface); }
|
|
106
107
|
|
|
107
|
-
.prose a {
|
|
108
|
+
.prose a:where(:not([data-product-id] *, [data-product-ids] *)) {
|
|
108
109
|
border-bottom: 1px solid currentColor;
|
|
109
110
|
color: var(--orionplace-text);
|
|
110
111
|
text-decoration: none;
|
|
111
112
|
}
|
|
112
113
|
|
|
113
|
-
.prose a:not([href]) { border-bottom: 0; }
|
|
114
|
-
.prose a:focus-visible { color: var(--orionplace-accent); outline: none; }
|
|
114
|
+
.prose a:not([href]):where(:not([data-product-id] *, [data-product-ids] *)) { border-bottom: 0; }
|
|
115
|
+
.prose a:focus-visible:where(:not([data-product-id] *, [data-product-ids] *)) { color: var(--orionplace-accent); outline: none; }
|
|
115
116
|
@media (hover: hover) {
|
|
116
|
-
.prose a:hover {
|
|
117
|
+
.prose a:hover:where(:not([data-product-id] *, [data-product-ids] *)) {
|
|
117
118
|
color: var(--orionplace-accent);
|
|
118
119
|
outline: none;
|
|
119
120
|
}
|
|
120
121
|
}
|
|
121
|
-
.prose :where(ul, ol) { padding-left: 1.5rem; }
|
|
122
|
-
.prose ul { list-style: disc; }
|
|
123
|
-
.prose ol { list-style: decimal; }
|
|
124
|
-
.prose li > :where(ul, ol) { margin: 0.25rem 0; }
|
|
125
|
-
.prose li > p { margin: 0; }
|
|
126
|
-
.prose ul[data-type='taskList'] { padding-left: 0; list-style: none; }
|
|
122
|
+
.prose :where(ul, ol):where(:not([data-product-id] *, [data-product-ids] *)) { padding-left: 1.5rem; }
|
|
123
|
+
.prose ul:where(:not([data-product-id] *, [data-product-ids] *)) { list-style: disc; }
|
|
124
|
+
.prose ol:where(:not([data-product-id] *, [data-product-ids] *)) { list-style: decimal; }
|
|
125
|
+
.prose li > :where(ul, ol):where(:not([data-product-id] *, [data-product-ids] *)) { margin: 0.25rem 0; }
|
|
126
|
+
.prose li > p:where(:not([data-product-id] *, [data-product-ids] *)) { margin: 0; }
|
|
127
|
+
.prose ul[data-type='taskList']:where(:not([data-product-id] *, [data-product-ids] *)) { padding-left: 0; list-style: none; }
|
|
127
128
|
|
|
128
|
-
.prose li[data-type='taskItem'] {
|
|
129
|
+
.prose li[data-type='taskItem']:where(:not([data-product-id] *, [data-product-ids] *)) {
|
|
129
130
|
display: flex;
|
|
130
131
|
align-items: flex-start;
|
|
131
132
|
gap: 0.5rem;
|
|
132
133
|
}
|
|
133
134
|
|
|
134
|
-
.prose li[data-type='taskItem'] > label {
|
|
135
|
+
.prose li[data-type='taskItem'] > label:where(:not([data-product-id] *, [data-product-ids] *)) {
|
|
135
136
|
display: flex;
|
|
136
137
|
align-items: center;
|
|
137
138
|
min-height: 1.85em;
|
|
138
139
|
}
|
|
139
140
|
|
|
140
|
-
.prose blockquote {
|
|
141
|
+
.prose blockquote:where(:not([data-product-id] *, [data-product-ids] *)) {
|
|
141
142
|
padding-left: 1rem;
|
|
142
143
|
border-left: 2px solid var(--orionplace-border);
|
|
143
144
|
color: var(--orionplace-muted);
|
|
144
145
|
}
|
|
145
146
|
|
|
146
|
-
.prose hr {
|
|
147
|
+
.prose hr:where(:not([data-product-id] *, [data-product-ids] *)) {
|
|
147
148
|
margin: 2rem 0;
|
|
148
149
|
border: 0;
|
|
149
150
|
border-top: 1px solid var(--orionplace-border);
|
|
150
151
|
}
|
|
151
152
|
|
|
152
|
-
.prose code {
|
|
153
|
+
.prose code:where(:not([data-product-id] *, [data-product-ids] *)) {
|
|
153
154
|
padding: 0.1em 0.3em;
|
|
154
155
|
background: var(--orionplace-muted-surface);
|
|
155
156
|
font-size: 0.875em;
|
|
156
157
|
}
|
|
157
158
|
|
|
158
|
-
.prose pre {
|
|
159
|
+
.prose pre:where(:not([data-product-id] *, [data-product-ids] *)) {
|
|
159
160
|
overflow-x: auto;
|
|
160
161
|
padding: 1rem;
|
|
161
162
|
border: 1px solid var(--orionplace-border);
|
|
@@ -163,9 +164,9 @@
|
|
|
163
164
|
line-height: 1.6;
|
|
164
165
|
}
|
|
165
166
|
|
|
166
|
-
.prose pre code { padding: 0; background: transparent; }
|
|
167
|
+
.prose pre code:where(:not([data-product-id] *, [data-product-ids] *)) { padding: 0; background: transparent; }
|
|
167
168
|
|
|
168
|
-
.prose table {
|
|
169
|
+
.prose table:where(:not([data-product-id] *, [data-product-ids] *)) {
|
|
169
170
|
display: block;
|
|
170
171
|
max-width: 100%;
|
|
171
172
|
overflow-x: auto;
|
|
@@ -173,40 +174,40 @@
|
|
|
173
174
|
border-collapse: collapse;
|
|
174
175
|
}
|
|
175
176
|
|
|
176
|
-
.prose :where(th, td) {
|
|
177
|
+
.prose :where(th, td):where(:not([data-product-id] *, [data-product-ids] *)) {
|
|
177
178
|
padding: 0.5rem 0.75rem;
|
|
178
179
|
border: 1px solid var(--orionplace-border);
|
|
179
180
|
text-align: left;
|
|
180
181
|
vertical-align: top;
|
|
181
182
|
}
|
|
182
183
|
|
|
183
|
-
.prose :where(img, video, audio) { max-width: 100%; }
|
|
184
|
-
.prose img { height: auto; }
|
|
185
|
-
.prose figure { max-width: 100%; }
|
|
186
|
-
.prose figure > img { display: block; margin-inline: auto; }
|
|
187
|
-
.prose figcaption { margin-top: 0.5rem; color: var(--orionplace-muted); }
|
|
184
|
+
.prose :where(img, video, audio):where(:not([data-product-id] *, [data-product-ids] *)) { max-width: 100%; }
|
|
185
|
+
.prose img:where(:not([data-product-id] *, [data-product-ids] *)) { height: auto; }
|
|
186
|
+
.prose figure:where(:not([data-product-id] *, [data-product-ids] *)) { max-width: 100%; }
|
|
187
|
+
.prose figure > img:where(:not([data-product-id] *, [data-product-ids] *)) { display: block; margin-inline: auto; }
|
|
188
|
+
.prose figcaption:where(:not([data-product-id] *, [data-product-ids] *)) { margin-top: 0.5rem; color: var(--orionplace-muted); }
|
|
188
189
|
|
|
189
|
-
.prose :where(img, figure)[data-float='left'],
|
|
190
|
-
.prose :where(img, figure)[style*='float: left'] {
|
|
190
|
+
.prose :where(img, figure)[data-float='left']:where(:not([data-product-id] *, [data-product-ids] *)),
|
|
191
|
+
.prose :where(img, figure)[style*='float: left']:where(:not([data-product-id] *, [data-product-ids] *)) {
|
|
191
192
|
float: left;
|
|
192
193
|
max-width: 50%;
|
|
193
194
|
margin: 0.25rem 1.5rem 1rem 0;
|
|
194
195
|
}
|
|
195
196
|
|
|
196
|
-
.prose :where(img, figure)[data-float='right'],
|
|
197
|
-
.prose :where(img, figure)[style*='float: right'] {
|
|
197
|
+
.prose :where(img, figure)[data-float='right']:where(:not([data-product-id] *, [data-product-ids] *)),
|
|
198
|
+
.prose :where(img, figure)[style*='float: right']:where(:not([data-product-id] *, [data-product-ids] *)) {
|
|
198
199
|
float: right;
|
|
199
200
|
max-width: 50%;
|
|
200
201
|
margin: 0.25rem 0 1rem 1.5rem;
|
|
201
202
|
}
|
|
202
203
|
|
|
203
|
-
.prose details {
|
|
204
|
+
.prose details:where(:not([data-product-id] *, [data-product-ids] *)) {
|
|
204
205
|
margin: 1rem 0;
|
|
205
206
|
padding: 0.75rem 1rem;
|
|
206
207
|
border: 1px solid var(--orionplace-border);
|
|
207
208
|
}
|
|
208
209
|
|
|
209
|
-
.prose summary { cursor: pointer; font-weight: 500; }
|
|
210
|
+
.prose summary:where(:not([data-product-id] *, [data-product-ids] *)) { cursor: pointer; font-weight: 500; }
|
|
210
211
|
|
|
211
212
|
/* `op-feature-panel op-feature-stack` in the other packages. */
|
|
212
213
|
.access {
|
|
@@ -283,8 +284,8 @@
|
|
|
283
284
|
/* A narrow article has no room for text beside a floated image; 592px is
|
|
284
285
|
the article's width in the /journal page on a 640px screen. */
|
|
285
286
|
@container article (max-width: 591px) {
|
|
286
|
-
.prose :where(img, figure)[data-float],
|
|
287
|
-
.prose :where(img, figure)[style*='float:'] {
|
|
287
|
+
.prose :where(img, figure)[data-float]:where(:not([data-product-id] *, [data-product-ids] *)),
|
|
288
|
+
.prose :where(img, figure)[style*='float:']:where(:not([data-product-id] *, [data-product-ids] *)) {
|
|
288
289
|
float: none !important;
|
|
289
290
|
width: auto !important;
|
|
290
291
|
max-width: 100%;
|
|
@@ -304,6 +305,29 @@
|
|
|
304
305
|
}
|
|
305
306
|
/* Images that open in the viewer. */
|
|
306
307
|
.cover[data-blog-image-viewer],
|
|
307
|
-
.prose img[data-blog-image-viewer] { cursor: zoom-in; }
|
|
308
|
+
.prose img[data-blog-image-viewer]:where(:not([data-product-id] *, [data-product-ids] *)) { cursor: zoom-in; }
|
|
308
309
|
.cover[data-blog-image-viewer]:focus-visible,
|
|
309
|
-
.prose img[data-blog-image-viewer]:focus-visible { outline: 2px solid var(--orionplace-accent); outline-offset: 3px; }
|
|
310
|
+
.prose img[data-blog-image-viewer]:focus-visible:where(:not([data-product-id] *, [data-product-ids] *)) { outline: 2px solid var(--orionplace-accent); outline-offset: 3px; }
|
|
311
|
+
|
|
312
|
+
/* Product placements: cards fill them; one without its products stays hidden. */
|
|
313
|
+
.prose :where([data-product-id], [data-product-ids]):empty {
|
|
314
|
+
display: none;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
.prose [data-product-ids] {
|
|
318
|
+
clear: both;
|
|
319
|
+
margin: 1.5rem 0;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.prose [data-product-id]:not([data-product-ids]) {
|
|
323
|
+
width: min(100%, 260px);
|
|
324
|
+
margin-block: 1.5rem;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/* The editor's float has no room beside a card on a narrow page. */
|
|
328
|
+
@container article (max-width: 480px) {
|
|
329
|
+
.prose [data-product-id]:not([data-product-ids]) {
|
|
330
|
+
float: none !important;
|
|
331
|
+
margin-inline: auto !important;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
useState,
|
|
9
9
|
type SyntheticEvent,
|
|
10
10
|
} from 'react';
|
|
11
|
+
import { createPortal } from 'react-dom';
|
|
11
12
|
import {
|
|
12
13
|
useOrionplace,
|
|
13
14
|
useOrionplaceSelections,
|
|
@@ -20,8 +21,10 @@ import {
|
|
|
20
21
|
blogArticleMediaUrl,
|
|
21
22
|
blogArticlePlansHref,
|
|
22
23
|
blogArticleRequest,
|
|
24
|
+
blogPlacementRequest,
|
|
23
25
|
lockBlogArticle,
|
|
24
26
|
type BlogArticleLoadAction,
|
|
27
|
+
type BlogPlacementLoadAction,
|
|
25
28
|
} from '@/lib/orionplace/blogArticle';
|
|
26
29
|
import {
|
|
27
30
|
removeLeadingDuplicateBlogHeading,
|
|
@@ -52,6 +55,15 @@ import {
|
|
|
52
55
|
type IBlogViewerImage,
|
|
53
56
|
} from '@/lib/orionplace/blogImageViewer';
|
|
54
57
|
import { ProductReviewImageViewer } from '@/components/orionplace/products/ProductReviewImageViewer';
|
|
58
|
+
import { ProductCard } from '@/components/orionplace/products/ProductCard';
|
|
59
|
+
import {
|
|
60
|
+
blogPlacementProductIds,
|
|
61
|
+
blogPlacementRequests,
|
|
62
|
+
blogProductPlacements,
|
|
63
|
+
type IBlogProductPlacement,
|
|
64
|
+
} from '@/lib/orionplace/blogProductPlacements';
|
|
65
|
+
import { productWithSellerPermission } from '@/lib/orionplace/productSeller';
|
|
66
|
+
import type { IProduct, IProductSearchSeller } from '@/interfaces/orionplace/products/IProduct';
|
|
55
67
|
import styles from './BlogArticle.module.css';
|
|
56
68
|
|
|
57
69
|
export interface BlogArticleProps extends ProductGroupEvents {
|
|
@@ -62,6 +74,10 @@ export interface BlogArticleProps extends ProductGroupEvents {
|
|
|
62
74
|
onLoad?: (
|
|
63
75
|
action: BlogArticleLoadAction,
|
|
64
76
|
) => Promise<IBlogPost | null | undefined> | IBlogPost | null | undefined;
|
|
77
|
+
// Loads products placed in the text; without it only related products fill placements.
|
|
78
|
+
onLoadPlacements?: (
|
|
79
|
+
action: BlogPlacementLoadAction,
|
|
80
|
+
) => Promise<BlogPlacementData | null | undefined> | BlogPlacementData | null | undefined;
|
|
65
81
|
onReading?: (
|
|
66
82
|
action: BlogReadingAction,
|
|
67
83
|
) => Promise<IBlogReadingData> | IBlogReadingData;
|
|
@@ -70,12 +86,18 @@ export interface BlogArticleProps extends ProductGroupEvents {
|
|
|
70
86
|
onMissing?: () => void;
|
|
71
87
|
}
|
|
72
88
|
|
|
89
|
+
type BlogPlacementData = {
|
|
90
|
+
products?: { data?: IProduct[] };
|
|
91
|
+
selects?: { sellers?: IProductSearchSeller[] };
|
|
92
|
+
};
|
|
93
|
+
|
|
73
94
|
export function BlogArticle({
|
|
74
95
|
postId,
|
|
75
96
|
productActionsDisabled = false,
|
|
76
97
|
initialPost = null,
|
|
77
98
|
commentsProps,
|
|
78
99
|
onLoad,
|
|
100
|
+
onLoadPlacements,
|
|
79
101
|
onReading,
|
|
80
102
|
onBack,
|
|
81
103
|
onLoaded,
|
|
@@ -129,6 +151,11 @@ export function BlogArticle({
|
|
|
129
151
|
);
|
|
130
152
|
const cover = blogArticleCover(post);
|
|
131
153
|
const showSkeleton = !post && !settled && !error;
|
|
154
|
+
// Product cards the author placed in the text (see blogProductPlacements).
|
|
155
|
+
const [placements, setPlacements] = useState<IBlogProductPlacement[]>([]);
|
|
156
|
+
const [placed, setPlaced] = useState<Record<number, IProduct>>({});
|
|
157
|
+
const placedProduct = (id: number) =>
|
|
158
|
+
placed[id] ?? post?.related_products?.find((product) => product.id === id);
|
|
132
159
|
|
|
133
160
|
const lockAccess = useCallback(() => {
|
|
134
161
|
setAccessChanged(true);
|
|
@@ -199,6 +226,43 @@ export function BlogArticle({
|
|
|
199
226
|
);
|
|
200
227
|
}, [html, cover, coverFailed, mounted, labels.imageOpen, post?.id]);
|
|
201
228
|
|
|
229
|
+
// The text's placement blocks after each render; missing products come
|
|
230
|
+
// with one request per 35.
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
const found = mounted ? blogProductPlacements(articleRef.current) : [];
|
|
233
|
+
const known = new Set([
|
|
234
|
+
...Object.keys(placed).map(Number),
|
|
235
|
+
...(post?.related_products ?? []).map((product) => product.id),
|
|
236
|
+
]);
|
|
237
|
+
const missing = blogPlacementProductIds(found).filter((id) => !known.has(id));
|
|
238
|
+
const controller = new AbortController();
|
|
239
|
+
|
|
240
|
+
setPlacements(found);
|
|
241
|
+
if (onLoadPlacements && missing.length) {
|
|
242
|
+
void (async () => {
|
|
243
|
+
for (const chunk of blogPlacementRequests(missing)) {
|
|
244
|
+
try {
|
|
245
|
+
const data = await onLoadPlacements(blogPlacementRequest(chunk, controller.signal));
|
|
246
|
+
if (controller.signal.aborted) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const loaded = data?.products?.data ?? [];
|
|
250
|
+
setPlaced((current) => ({
|
|
251
|
+
...current,
|
|
252
|
+
...Object.fromEntries(loaded.map((product) => [
|
|
253
|
+
product.id,
|
|
254
|
+
productWithSellerPermission(product, data?.selects?.sellers),
|
|
255
|
+
])),
|
|
256
|
+
}));
|
|
257
|
+
} catch {
|
|
258
|
+
// A placement without its products stays empty and hidden.
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
})();
|
|
262
|
+
}
|
|
263
|
+
return () => controller.abort();
|
|
264
|
+
}, [html, mounted, post?.id]);
|
|
265
|
+
|
|
202
266
|
const openViewer = (event: SyntheticEvent) => {
|
|
203
267
|
const images = blogViewerImages([coverRef.current, articleRef.current]);
|
|
204
268
|
const index = blogViewerIndex(event.target, images);
|
|
@@ -327,6 +391,57 @@ export function BlogArticle({
|
|
|
327
391
|
</div>
|
|
328
392
|
)}
|
|
329
393
|
|
|
394
|
+
{placements.map((placement, index) => {
|
|
395
|
+
const products = placement.productIds
|
|
396
|
+
.map(placedProduct)
|
|
397
|
+
.filter((product): product is IProduct => Boolean(product));
|
|
398
|
+
|
|
399
|
+
if (!products.length || !placement.element.isConnected) {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
return createPortal(
|
|
403
|
+
placement.layout === 'single' ? (
|
|
404
|
+
<ProductCard
|
|
405
|
+
productId={products[0]!.id}
|
|
406
|
+
initialProduct={products[0]}
|
|
407
|
+
hiddenBadges={placement.hiddenBadges}
|
|
408
|
+
disabled={productActionsDisabled}
|
|
409
|
+
onSelect={onSelect}
|
|
410
|
+
onSelectSeller={onSelectSeller}
|
|
411
|
+
onSelectVariant={onSelectVariant}
|
|
412
|
+
onRequestSecondaryVariants={onRequestSecondaryVariants}
|
|
413
|
+
onSelectSecondaryVariant={onSelectSecondaryVariant}
|
|
414
|
+
onToggleFavorite={onToggleFavorite}
|
|
415
|
+
onAdd={onAdd}
|
|
416
|
+
onRemove={onRemove}
|
|
417
|
+
onSubscribe={onSubscribe}
|
|
418
|
+
onUnsubscribe={onUnsubscribe}
|
|
419
|
+
onPreorder={onPreorder}
|
|
420
|
+
/>
|
|
421
|
+
) : (
|
|
422
|
+
<ProductGroup
|
|
423
|
+
products={products}
|
|
424
|
+
productIds={products.map((product) => product.id)}
|
|
425
|
+
hiddenBadges={placement.hiddenBadges}
|
|
426
|
+
disabled={productActionsDisabled}
|
|
427
|
+
onSelect={onSelect}
|
|
428
|
+
onSelectSeller={onSelectSeller}
|
|
429
|
+
onSelectVariant={onSelectVariant}
|
|
430
|
+
onRequestSecondaryVariants={onRequestSecondaryVariants}
|
|
431
|
+
onSelectSecondaryVariant={onSelectSecondaryVariant}
|
|
432
|
+
onToggleFavorite={onToggleFavorite}
|
|
433
|
+
onAdd={onAdd}
|
|
434
|
+
onRemove={onRemove}
|
|
435
|
+
onSubscribe={onSubscribe}
|
|
436
|
+
onUnsubscribe={onUnsubscribe}
|
|
437
|
+
onPreorder={onPreorder}
|
|
438
|
+
/>
|
|
439
|
+
),
|
|
440
|
+
placement.element,
|
|
441
|
+
`placement-${index}`,
|
|
442
|
+
);
|
|
443
|
+
})}
|
|
444
|
+
|
|
330
445
|
{!full ? (
|
|
331
446
|
<div className={styles.access} data-blog-article-access>
|
|
332
447
|
<h2>{labels.fullAccess}</h2>
|
|
@@ -52,6 +52,7 @@ displayed product and provide the equivalent of the Nuxt named slots.
|
|
|
52
52
|
| `collection?: OrionplaceProductCollection` | Product-store collection used to resolve the supplied product ID. |
|
|
53
53
|
| `compact?: boolean` | Larger representative layout: the image beside the details once the card itself is at least 22rem (352px) wide, above them in a narrower card; leaves out the description, badges, rating and stock notice. |
|
|
54
54
|
| `details?: boolean` | Purchase-panel mode for a product page: no image, name, seller, rating or description; the chosen main variant above the choices, actions with text labels and `renderAfterPrice` after the price. Overrides `compact`. |
|
|
55
|
+
| `hiddenBadges?: readonly string[]` | Card badges to leave out: `new`, `popular`, `deal`. A blog article passes the badges the author turned off for a placement. |
|
|
55
56
|
| `disabled?: boolean` | Disables the component controls and prevents new actions. |
|
|
56
57
|
| `actionsDisabled?: boolean` | Disables product actions while the surrounding flow is busy. |
|
|
57
58
|
| `purchaseActions?: boolean` | Shows or hides the product purchase controls. |
|
|
@@ -130,6 +131,7 @@ displayed product and provide the equivalent of the Nuxt named slots.
|
|
|
130
131
|
| `collection?: OrionplaceProductCollection` | Коллекция store товаров, в которой компонент ищет переданный ID товара. |
|
|
131
132
|
| `compact?: boolean` | Увеличенная карточка представителя: фотография рядом с данными, когда сама карточка шире 22rem (352px), и над ними в более узкой; без описания, отметок, оценки и сообщения о наличии. |
|
|
132
133
|
| `details?: boolean` | Режим блока покупки для страницы товара: без фотографии, названия, продавца, оценки и описания; выбранный основной вариант над вариантами, действия с подписями и `renderAfterPrice` после цены. Имеет приоритет над `compact`. |
|
|
134
|
+
| `hiddenBadges?: readonly string[]` | Значки карточки, которые не показываются: `new`, `popular`, `deal`. Статья блога передаёт значки, отключённые автором для размещения. |
|
|
133
135
|
| `disabled?: boolean` | Блокирует элементы компонента и новые действия. |
|
|
134
136
|
| `actionsDisabled?: boolean` | Блокирует действия с товаром, пока внешний сценарий занят. |
|
|
135
137
|
| `purchaseActions?: boolean` | Показывает или скрывает действия покупки товара. |
|
|
@@ -52,6 +52,15 @@ import type { ProductCardVariantsHandle } from './ProductCardVariants';
|
|
|
52
52
|
import type { ProductActionSelection } from './ProductCardActions';
|
|
53
53
|
import styles from './ProductCard.module.css';
|
|
54
54
|
|
|
55
|
+
// An empty label hides the badge; an omitted one keeps the locale's label.
|
|
56
|
+
function hiddenBadgeLabels(hidden: readonly string[]) {
|
|
57
|
+
return {
|
|
58
|
+
...(hidden.includes('new') ? { newLabel: '' } : {}),
|
|
59
|
+
...(hidden.includes('popular') ? { popularLabel: '' } : {}),
|
|
60
|
+
...(hidden.includes('deal') ? { dealLabel: '' } : {}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
55
64
|
export interface ProductCardProps {
|
|
56
65
|
productId: number;
|
|
57
66
|
initialProduct?: IProduct;
|
|
@@ -62,6 +71,8 @@ export interface ProductCardProps {
|
|
|
62
71
|
actionsDisabled?: boolean;
|
|
63
72
|
purchaseActions?: boolean;
|
|
64
73
|
variantProductIds?: number[];
|
|
74
|
+
// Badge keys not to show on this card (new / popular / deal).
|
|
75
|
+
hiddenBadges?: readonly string[];
|
|
65
76
|
productHref?: (id: number) => string;
|
|
66
77
|
onSelect?: (product: IProduct) => void;
|
|
67
78
|
onSelectSeller?: (seller: IProductSeller) => void;
|
|
@@ -98,6 +109,7 @@ export function ProductCard({
|
|
|
98
109
|
actionsDisabled = false,
|
|
99
110
|
purchaseActions = true,
|
|
100
111
|
variantProductIds,
|
|
112
|
+
hiddenBadges = [],
|
|
101
113
|
productHref,
|
|
102
114
|
onSelect,
|
|
103
115
|
onSelectSeller,
|
|
@@ -539,6 +551,7 @@ export function ProductCard({
|
|
|
539
551
|
productId={product.id}
|
|
540
552
|
collection={collection}
|
|
541
553
|
lowStockLabel=""
|
|
554
|
+
{...hiddenBadgeLabels(hiddenBadges)}
|
|
542
555
|
className={styles.overlaidBadges}
|
|
543
556
|
/>
|
|
544
557
|
)}
|
|
@@ -634,6 +647,7 @@ export function ProductCard({
|
|
|
634
647
|
productId={product.id}
|
|
635
648
|
collection={collection}
|
|
636
649
|
lowStockLabel=""
|
|
650
|
+
{...hiddenBadgeLabels(hiddenBadges)}
|
|
637
651
|
/>
|
|
638
652
|
)}
|
|
639
653
|
<ProductCardPrice
|
|
@@ -28,6 +28,7 @@ image until interacted with.
|
|
|
28
28
|
| `limit?: number` | Limits how many supplied entries the component renders. |
|
|
29
29
|
| `products?: readonly IProduct[]` | Product records supplied for server rendering and hydration. |
|
|
30
30
|
| `productIds?: number[]` | Product IDs rendered in the supplied order. |
|
|
31
|
+
| `hiddenBadges?: readonly string[]` | Card badges to leave out: `new`, `popular`, `deal`. A blog article passes the badges the author turned off for a placement. |
|
|
31
32
|
| `layout?: "responsive" \| "grid"` | Selects the supported component layout. |
|
|
32
33
|
| `productHref?: (id: number) => string` | Builds the product destination from the callback arguments. |
|
|
33
34
|
| `renderFooter?: (product: IProduct) => ReactNode` | Replaces the default footer region; the renderer inputs are shown in its type. |
|
|
@@ -72,6 +73,7 @@ image until interacted with.
|
|
|
72
73
|
| `limit?: number` | Ограничивает число показанных переданных записей. |
|
|
73
74
|
| `products?: readonly IProduct[]` | Товары для серверного рендера и гидратации. |
|
|
74
75
|
| `productIds?: number[]` | ID для товара, используемые в переданном порядке. |
|
|
76
|
+
| `hiddenBadges?: readonly string[]` | Значки карточки, которые не показываются: `new`, `popular`, `deal`. Статья блога передаёт значки, отключённые автором для размещения. |
|
|
75
77
|
| `layout?: "responsive" \| "grid"` | Выбирает поддерживаемый вариант компоновки. |
|
|
76
78
|
| `productHref?: (id: number) => string` | Строит адрес ссылки товара из аргументов обработчика. |
|
|
77
79
|
| `renderFooter?: (product: IProduct) => ReactNode` | Заменяет стандартный блок нижней части; входные данные функции указаны в её типе. |
|
|
@@ -32,6 +32,7 @@ export interface ProductGroupProps extends ProductGroupEvents {
|
|
|
32
32
|
products?: readonly IProduct[];
|
|
33
33
|
productIds?: number[];
|
|
34
34
|
layout?: 'responsive' | 'grid';
|
|
35
|
+
hiddenBadges?: ProductCardProps['hiddenBadges'];
|
|
35
36
|
productHref?: ProductCardProps['productHref'];
|
|
36
37
|
renderFooter?: (product: IProduct) => ReactNode;
|
|
37
38
|
className?: string;
|
|
@@ -44,6 +45,7 @@ export function ProductGroup({
|
|
|
44
45
|
products: suppliedProducts,
|
|
45
46
|
productIds,
|
|
46
47
|
layout = 'responsive',
|
|
48
|
+
hiddenBadges,
|
|
47
49
|
productHref,
|
|
48
50
|
renderFooter,
|
|
49
51
|
className = '',
|
|
@@ -140,6 +142,7 @@ export function ProductGroup({
|
|
|
140
142
|
initialProduct={listedProduct}
|
|
141
143
|
collection={collection}
|
|
142
144
|
disabled={disabled}
|
|
145
|
+
hiddenBadges={hiddenBadges}
|
|
143
146
|
productHref={productHref}
|
|
144
147
|
renderFooter={renderFooter
|
|
145
148
|
? () => renderFooter(listedProduct)
|
|
@@ -15,6 +15,31 @@ export interface BlogArticleLoadAction {
|
|
|
15
15
|
signal: AbortSignal;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// Products placed in the article text that no list loaded yet (see
|
|
19
|
+
// blogProductPlacements); the answer is the products_search page data.
|
|
20
|
+
export interface BlogPlacementLoadAction {
|
|
21
|
+
endpoint: 'products_search';
|
|
22
|
+
request: {
|
|
23
|
+
filters: { id_in: string };
|
|
24
|
+
paginate: { page: 1; pp_items: number };
|
|
25
|
+
};
|
|
26
|
+
signal: AbortSignal;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function blogPlacementRequest(
|
|
30
|
+
ids: number[],
|
|
31
|
+
signal: AbortSignal,
|
|
32
|
+
): BlogPlacementLoadAction {
|
|
33
|
+
return {
|
|
34
|
+
endpoint: 'products_search',
|
|
35
|
+
request: {
|
|
36
|
+
filters: { id_in: ids.join(',') },
|
|
37
|
+
paginate: { page: 1, pp_items: ids.length },
|
|
38
|
+
},
|
|
39
|
+
signal,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
18
43
|
export function blogArticleRequest(
|
|
19
44
|
postId: number,
|
|
20
45
|
signal: AbortSignal,
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Product placements in article text. The marketplace blog editor writes an
|
|
3
|
+
* empty block where the author put products:
|
|
4
|
+
* <div data-product-id="12"></div> one product card
|
|
5
|
+
* <div data-product-ids="12,15,9"></div> a row of cards (up to 24)
|
|
6
|
+
* with optional data-product-hidden-badges="new,popular,good-deal" and a
|
|
7
|
+
* float or centered style. The article shows product cards in those blocks.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const blogPlacementMaxProducts = 24;
|
|
11
|
+
|
|
12
|
+
// The backend lists at most 35 products per page.
|
|
13
|
+
export const blogPlacementPageSize = 35;
|
|
14
|
+
|
|
15
|
+
export const blogPlacementSelector = '[data-product-id], [data-product-ids]';
|
|
16
|
+
|
|
17
|
+
// Editor badge names → product card badge keys.
|
|
18
|
+
const placementBadges = {
|
|
19
|
+
'new': 'new',
|
|
20
|
+
'popular': 'popular',
|
|
21
|
+
'good-deal': 'deal',
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
export type TBlogPlacementBadge = (typeof placementBadges)[keyof typeof placementBadges];
|
|
25
|
+
|
|
26
|
+
export interface IBlogProductPlacement {
|
|
27
|
+
element: HTMLElement;
|
|
28
|
+
layout: 'single' | 'list';
|
|
29
|
+
productIds: number[];
|
|
30
|
+
hiddenBadges: TBlogPlacementBadge[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Positive whole ids from «12,15,9», without repeats, at most `limit`. */
|
|
34
|
+
export function blogPlacementIds(value: string | null | undefined, limit = blogPlacementMaxProducts): number[] {
|
|
35
|
+
const ids: number[] = [];
|
|
36
|
+
|
|
37
|
+
if (!value || value.length > 512) {
|
|
38
|
+
return ids;
|
|
39
|
+
}
|
|
40
|
+
for (const part of value.split(',')) {
|
|
41
|
+
const source = part.trim();
|
|
42
|
+
const id = /^\d+$/.test(source) ? Number(source) : 0;
|
|
43
|
+
|
|
44
|
+
if (Number.isSafeInteger(id) && id > 0 && !ids.includes(id)) {
|
|
45
|
+
ids.push(id);
|
|
46
|
+
}
|
|
47
|
+
if (ids.length >= limit) {
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return ids;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Card badge keys the author turned off for this placement. */
|
|
55
|
+
export function blogPlacementHiddenBadges(value: string | null | undefined): TBlogPlacementBadge[] {
|
|
56
|
+
if (!value || value.length > 512) {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
const names = value.split(',').map((name) => name.trim().toLowerCase());
|
|
60
|
+
|
|
61
|
+
return Object.entries(placementBadges)
|
|
62
|
+
.filter(([name]) => names.includes(name))
|
|
63
|
+
.map(([, key]) => key);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The placements of a rendered article, in text order. */
|
|
67
|
+
export function blogProductPlacements(root: ParentNode | null | undefined): IBlogProductPlacement[] {
|
|
68
|
+
if (typeof root?.querySelectorAll !== 'function') {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
return Array.from(root.querySelectorAll<HTMLElement>(blogPlacementSelector)).flatMap((element) => {
|
|
72
|
+
// Product cards inside a filled placement carry data-product-id too.
|
|
73
|
+
if (element.parentElement?.closest(blogPlacementSelector)) {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
const list = element.hasAttribute('data-product-ids');
|
|
77
|
+
const productIds = list
|
|
78
|
+
? blogPlacementIds(element.getAttribute('data-product-ids'))
|
|
79
|
+
: blogPlacementIds(element.getAttribute('data-product-id'), 1);
|
|
80
|
+
|
|
81
|
+
return productIds.length
|
|
82
|
+
? [{
|
|
83
|
+
element,
|
|
84
|
+
layout: list ? 'list' : 'single',
|
|
85
|
+
productIds,
|
|
86
|
+
hiddenBadges: blogPlacementHiddenBadges(element.getAttribute('data-product-hidden-badges')),
|
|
87
|
+
}]
|
|
88
|
+
: [];
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Every product id of the placements, once, in text order. */
|
|
93
|
+
export function blogPlacementProductIds(placements: Pick<IBlogProductPlacement, 'productIds'>[]): number[] {
|
|
94
|
+
return [...new Set(placements.flatMap((placement) => placement.productIds))];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Ids split into requests the backend answers in one page each. */
|
|
98
|
+
export function blogPlacementRequests(ids: number[]): number[][] {
|
|
99
|
+
const requests: number[][] = [];
|
|
100
|
+
|
|
101
|
+
for (let start = 0; start < ids.length; start += blogPlacementPageSize) {
|
|
102
|
+
requests.push(ids.slice(start, start + blogPlacementPageSize));
|
|
103
|
+
}
|
|
104
|
+
return requests;
|
|
105
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// The product page's schema.org data for search engines: Google merchant
|
|
2
|
+
// listings and Yandex product snippets read the same block. Every value
|
|
3
|
+
// repeats what the page shows: the price and the crossed-out old price,
|
|
4
|
+
// stock, return terms, photos, brand, seller and rating. The delivery cost is
|
|
5
|
+
// left out: it depends on the buyer's address.
|
|
6
|
+
// The same file is in the three packages; it has no imports so that it stays
|
|
7
|
+
// identical.
|
|
8
|
+
|
|
9
|
+
const SCHEMA_ORG = 'https://schema.org/';
|
|
10
|
+
const MAX_IMAGES = 10;
|
|
11
|
+
// EProductStatus.PUBLISHED and PUBLISHED_AS_OPTION: the product is on sale.
|
|
12
|
+
const ON_SALE_STATUSES = [2, 1];
|
|
13
|
+
|
|
14
|
+
interface StructuredPrice {
|
|
15
|
+
transform_value: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The product fields the data uses (IProduct has them all). */
|
|
19
|
+
export interface StorefrontStructuredProduct {
|
|
20
|
+
id: number;
|
|
21
|
+
is_digital?: boolean;
|
|
22
|
+
discount: number;
|
|
23
|
+
base_price_dict: Record<string, StructuredPrice>;
|
|
24
|
+
product_price_dict: Record<string, StructuredPrice>;
|
|
25
|
+
images?: { full?: string[] | null; small?: string[] | null } | null;
|
|
26
|
+
status: { status: number };
|
|
27
|
+
c_real_left: number;
|
|
28
|
+
seller?: { name?: string | null } | null;
|
|
29
|
+
manufacture?: { name?: string | null } | null;
|
|
30
|
+
refunds?: {
|
|
31
|
+
customer_max_refund_days: number;
|
|
32
|
+
max_refund_days_with_defect: number;
|
|
33
|
+
} | null;
|
|
34
|
+
feedback_stat?: {
|
|
35
|
+
c_feedbacks?: number | null;
|
|
36
|
+
avg_raiting?: number | string | null;
|
|
37
|
+
} | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface StorefrontProductDataOptions {
|
|
41
|
+
/** The page's canonical address; without one the page gets no data. */
|
|
42
|
+
url: string | undefined;
|
|
43
|
+
name: string | undefined;
|
|
44
|
+
description?: string;
|
|
45
|
+
/** The currency the page shows prices in (its ID and code). */
|
|
46
|
+
currencyId: string;
|
|
47
|
+
currencyCode: string | undefined;
|
|
48
|
+
/** ORIONPLACE_SHIPPING_COUNTRIES, parsed by shippingCountryCodes(). */
|
|
49
|
+
shippingCountries: string[];
|
|
50
|
+
/** False when the page hides the seller (single-seller storefront). */
|
|
51
|
+
showSeller: boolean;
|
|
52
|
+
/** Makes an image address complete (storefrontAbsoluteUrl). */
|
|
53
|
+
absoluteUrl: (value: string | undefined) => string | undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The countries the shop ships to, from ORIONPLACE_SHIPPING_COUNTRIES:
|
|
58
|
+
* ISO 3166-1 alpha-2 codes separated by spaces or commas, e.g. "RU BY".
|
|
59
|
+
*/
|
|
60
|
+
export function shippingCountryCodes(value: unknown): string[] {
|
|
61
|
+
const codes = String(value ?? '')
|
|
62
|
+
.split(/[\s,;]+/)
|
|
63
|
+
.map((code) => code.trim().toUpperCase())
|
|
64
|
+
.filter((code) => /^[A-Z]{2}$/.test(code));
|
|
65
|
+
|
|
66
|
+
return [...new Set(codes)];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function wholeDays(value: unknown) {
|
|
70
|
+
const days = Math.floor(Number(value));
|
|
71
|
+
|
|
72
|
+
return Number.isFinite(days) && days > 0 ? days : 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The return terms the page shows: the days for a product without a defect
|
|
77
|
+
* are the return window (sent back by mail at the buyer's cost); a defective
|
|
78
|
+
* product goes back at the shop's cost. A digital product or one without
|
|
79
|
+
* days cannot be returned. Without a shipping country there is no policy:
|
|
80
|
+
* search engines require the country.
|
|
81
|
+
*/
|
|
82
|
+
export function productReturnPolicy(
|
|
83
|
+
product: Pick<StorefrontStructuredProduct, 'is_digital' | 'refunds'>,
|
|
84
|
+
countries: string[],
|
|
85
|
+
) {
|
|
86
|
+
if (!countries.length) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const applicableCountry = countries.length === 1 ? countries[0] : countries;
|
|
91
|
+
const returnDays = product.is_digital
|
|
92
|
+
? 0
|
|
93
|
+
: wholeDays(product.refunds?.customer_max_refund_days);
|
|
94
|
+
const defectDays = product.is_digital
|
|
95
|
+
? 0
|
|
96
|
+
: wholeDays(product.refunds?.max_refund_days_with_defect);
|
|
97
|
+
|
|
98
|
+
if (!returnDays) {
|
|
99
|
+
return {
|
|
100
|
+
'@type': 'MerchantReturnPolicy',
|
|
101
|
+
applicableCountry,
|
|
102
|
+
'returnPolicyCategory': `${SCHEMA_ORG}MerchantReturnNotPermitted`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
'@type': 'MerchantReturnPolicy',
|
|
108
|
+
applicableCountry,
|
|
109
|
+
'returnPolicyCategory': `${SCHEMA_ORG}MerchantReturnFiniteReturnWindow`,
|
|
110
|
+
'merchantReturnDays': returnDays,
|
|
111
|
+
'returnMethod': `${SCHEMA_ORG}ReturnByMail`,
|
|
112
|
+
'returnFees': `${SCHEMA_ORG}ReturnFeesCustomerResponsibility`,
|
|
113
|
+
...(defectDays ? { itemDefectReturnFees: `${SCHEMA_ORG}FreeReturn` } : {}),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function priceValue(
|
|
118
|
+
prices: Record<string, StructuredPrice> | undefined,
|
|
119
|
+
currencyId: string,
|
|
120
|
+
) {
|
|
121
|
+
const value = Number(prices?.[currencyId]?.transform_value);
|
|
122
|
+
|
|
123
|
+
return prices?.[currencyId] && Number.isFinite(value) ? value : undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The schema.org Product of the page, or undefined when it cannot be built. */
|
|
127
|
+
export function storefrontProductStructuredData(
|
|
128
|
+
product: StorefrontStructuredProduct | undefined,
|
|
129
|
+
options: StorefrontProductDataOptions,
|
|
130
|
+
) {
|
|
131
|
+
const name = options.name?.trim();
|
|
132
|
+
const currencyCode = options.currencyCode?.trim().toUpperCase() ?? '';
|
|
133
|
+
|
|
134
|
+
if (!product || !options.url || !name) {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// The price the page shows, as ProductCardPrice picks it.
|
|
139
|
+
const currencyId =
|
|
140
|
+
options.currencyId ||
|
|
141
|
+
Object.keys(product.product_price_dict ?? {})[0] ||
|
|
142
|
+
Object.keys(product.base_price_dict ?? {})[0] ||
|
|
143
|
+
'';
|
|
144
|
+
const basePrice = priceValue(product.base_price_dict, currencyId);
|
|
145
|
+
const price =
|
|
146
|
+
priceValue(product.product_price_dict, currencyId) ?? basePrice;
|
|
147
|
+
const images = [
|
|
148
|
+
...(product.images?.full ?? []),
|
|
149
|
+
...(product.images?.small ?? []),
|
|
150
|
+
]
|
|
151
|
+
.map((image) => options.absoluteUrl(image))
|
|
152
|
+
.filter((image, index, list): image is string =>
|
|
153
|
+
Boolean(image) && list.indexOf(image) === index,
|
|
154
|
+
)
|
|
155
|
+
.slice(0, MAX_IMAGES);
|
|
156
|
+
const inStock =
|
|
157
|
+
ON_SALE_STATUSES.includes(product.status?.status) &&
|
|
158
|
+
product.c_real_left > 0;
|
|
159
|
+
const returns = productReturnPolicy(product, options.shippingCountries);
|
|
160
|
+
const brand = product.manufacture?.name?.trim();
|
|
161
|
+
const seller = options.showSeller ? product.seller?.name?.trim() : '';
|
|
162
|
+
const reviews = Number(product.feedback_stat?.c_feedbacks ?? 0);
|
|
163
|
+
const rating = Number(product.feedback_stat?.avg_raiting ?? 0);
|
|
164
|
+
const offer =
|
|
165
|
+
price !== undefined && /^[A-Z]{3}$/.test(currencyCode)
|
|
166
|
+
? {
|
|
167
|
+
'@type': 'Offer',
|
|
168
|
+
'url': options.url,
|
|
169
|
+
price,
|
|
170
|
+
'priceCurrency': currencyCode,
|
|
171
|
+
// The crossed-out old price, when the page shows one.
|
|
172
|
+
...(basePrice !== undefined && price < basePrice
|
|
173
|
+
? {
|
|
174
|
+
priceSpecification: {
|
|
175
|
+
'@type': 'UnitPriceSpecification',
|
|
176
|
+
'priceType': `${SCHEMA_ORG}StrikethroughPrice`,
|
|
177
|
+
'price': basePrice,
|
|
178
|
+
'priceCurrency': currencyCode,
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
: {}),
|
|
182
|
+
'availability': `${SCHEMA_ORG}${inStock ? 'InStock' : 'OutOfStock'}`,
|
|
183
|
+
'itemCondition': `${SCHEMA_ORG}NewCondition`,
|
|
184
|
+
...(returns ? { hasMerchantReturnPolicy: returns } : {}),
|
|
185
|
+
...(seller
|
|
186
|
+
? { seller: { '@type': 'Organization', 'name': seller } }
|
|
187
|
+
: {}),
|
|
188
|
+
}
|
|
189
|
+
: undefined;
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
'@context': 'https://schema.org',
|
|
193
|
+
'@type': 'Product',
|
|
194
|
+
name,
|
|
195
|
+
'url': options.url,
|
|
196
|
+
'sku': String(product.id),
|
|
197
|
+
...(options.description?.trim()
|
|
198
|
+
? { description: options.description.trim() }
|
|
199
|
+
: {}),
|
|
200
|
+
...(images.length ? { image: images } : {}),
|
|
201
|
+
...(brand ? { brand: { '@type': 'Brand', 'name': brand } } : {}),
|
|
202
|
+
...(offer ? { offers: offer } : {}),
|
|
203
|
+
...(reviews > 0 && Number.isFinite(rating) && rating > 0
|
|
204
|
+
? {
|
|
205
|
+
aggregateRating: {
|
|
206
|
+
'@type': 'AggregateRating',
|
|
207
|
+
'ratingValue': Math.round(rating * 10) / 10,
|
|
208
|
+
'reviewCount': reviews,
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
: {}),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The data as the text of a <script type="application/ld+json"> tag. */
|
|
216
|
+
export function structuredDataJson(value: unknown): string {
|
|
217
|
+
return value === undefined
|
|
218
|
+
? ''
|
|
219
|
+
: JSON.stringify(value).replace(/</g, '\\u003c');
|
|
220
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { headers } from 'next/headers';
|
|
1
2
|
import { notFound, redirect } from 'next/navigation';
|
|
2
3
|
import { getOrionplaceAppLoad } from '@/lib/orionplace/appLoadServer';
|
|
4
|
+
import { storefrontPublicOrigin } from '@/lib/orionplace/pageSeo';
|
|
3
5
|
import { getOrionplaceServerApi, serverErrorText } from '@/lib/orionplace/serverApi';
|
|
4
6
|
import {
|
|
5
7
|
getOrionplaceServerLocale,
|
|
@@ -27,11 +29,16 @@ export async function renderStorefrontRoute(
|
|
|
27
29
|
pattern: StorefrontRoutePattern,
|
|
28
30
|
{ params, searchParams }: StorefrontPageProps,
|
|
29
31
|
) {
|
|
30
|
-
const url
|
|
31
|
-
const
|
|
32
|
+
const url = await getStorefrontRequestUrl();
|
|
33
|
+
const incoming = await headers();
|
|
34
|
+
const result = await loadStorefrontRoute(pattern, {
|
|
32
35
|
params: (await params) ?? {},
|
|
33
36
|
query: (await searchParams) ?? {},
|
|
34
37
|
address: url.pathname + url.search,
|
|
38
|
+
origin: storefrontPublicOrigin(
|
|
39
|
+
incoming.get('x-forwarded-host') ?? incoming.get('host'),
|
|
40
|
+
),
|
|
41
|
+
shippingCountries: process.env.ORIONPLACE_SHIPPING_COUNTRIES ?? '',
|
|
35
42
|
appLoad: await getOrionplaceAppLoad(),
|
|
36
43
|
locale: getOrionplaceServerLocale,
|
|
37
44
|
api: getOrionplaceServerApi,
|
package/templates/proxy.ts
CHANGED
|
@@ -55,8 +55,9 @@ export async function proxy(request: NextRequest) {
|
|
|
55
55
|
return response;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
// The gateway sets its own headers for /api, robots.txt and the
|
|
59
|
-
//
|
|
58
|
+
// The gateway sets its own headers for /api, /ps3 media, robots.txt and the
|
|
59
|
+
// sitemap (public blog images keep the backend's browser cache rule); static
|
|
60
|
+
// assets keep their caching.
|
|
60
61
|
export const config = {
|
|
61
|
-
matcher: ['/((?!api/|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)'],
|
|
62
|
+
matcher: ['/((?!api/|ps3/|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)'],
|
|
62
63
|
};
|