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 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`. Network failures return `502`.
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.6.1",
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(
@@ -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', 'no-store');
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, {
@@ -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
- .prose :where(p, ul, ol, blockquote, pre, figure) {
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,
@@ -125,6 +125,7 @@ const attributes = [
125
125
  'data-details-content',
126
126
  'data-product-id',
127
127
  'data-product-ids',
128
+ 'data-product-hidden-badges',
128
129
  ];
129
130
 
130
131
  const marginLength = '(-?\\d+(\\.\\d+)?(px|em|rem|%)?|auto)';
@@ -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 = await getStorefrontRequestUrl();
31
- const result = await loadStorefrontRoute(pattern, {
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,
@@ -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 sitemap;
59
- // static assets keep their caching.
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
  };