orionplace-react 0.6.2 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.2",
3
+ "version": "0.7.1",
4
4
  "description": "Next.js 16 storefront for your Orionplace marketplace: setup CLI, server API gateway, typed store and ready-made catalog, cart, checkout and account components in English and Russian, plus an Android app build.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,6 +37,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,13 @@ 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. The return terms name the shop's shipping
417
+ countries, which Application Load sends in \`init.shipping_countries\`; without
418
+ a shipping country they are left out. The delivery cost is not included: it
419
+ depends on the buyer's address.
420
+
414
421
  This setup does not add analytics.
415
422
 
416
423
  ## Android app
@@ -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';
@@ -19,11 +20,14 @@ export function ProductPage({
19
20
  previewKey = '',
20
21
  initialProduct,
21
22
  initialError = '',
23
+ origin = '',
22
24
  }: {
23
25
  productId: number;
24
26
  previewKey?: string;
25
27
  initialProduct?: IProduct;
26
28
  initialError?: string;
29
+ /** The visitor's origin from the server render. */
30
+ origin?: string;
27
31
  }) {
28
32
  const api = useOrionplaceApi();
29
33
  const init = useOrionplace(selectInit);
@@ -53,6 +57,10 @@ export function ProductPage({
53
57
  data-storefront-page="product"
54
58
  >
55
59
  <StorefrontPageMetadata product={metadataProduct} />
60
+ <StorefrontProductStructuredData
61
+ product={metadataProduct}
62
+ origin={origin}
63
+ />
56
64
  <StorefrontProduct
57
65
  productId={productId}
58
66
  previewKey={previewKey}
@@ -0,0 +1,77 @@
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
+ }: {
34
+ product?: IProduct;
35
+ /** The visitor's origin from the server render (storefrontPublicOrigin). */
36
+ origin: string;
37
+ }) {
38
+ const pathname = usePathname();
39
+ const search = useSearchParams().toString();
40
+ const init = useOrionplace(selectInit);
41
+ const locale = useOrionplaceSelections((state) => state.localeCode);
42
+ const currencyId = useOrionplaceSelections((state) => state.currencyId);
43
+ const field = <Key extends keyof IProductLocale>(name: Key) =>
44
+ getAvailableNestedValueFromObject(product?.locale, locale, name);
45
+ // A preview or any other page without a canonical address gets no data.
46
+ const canonical = storefrontCanonicalUrl(
47
+ origin,
48
+ pathname,
49
+ search ? `?${search}` : '',
50
+ locale,
51
+ );
52
+ const json = structuredDataJson(
53
+ canonical
54
+ ? storefrontProductStructuredData(product, {
55
+ url: canonical,
56
+ name: field('product_name'),
57
+ description: plainText(
58
+ field('meta_description') || field('short_description'),
59
+ ).slice(0, 160),
60
+ currencyId,
61
+ currencyCode: init?.currencies?.[currencyId]?.currency_code,
62
+ // The backend's shipping countries, for the return terms.
63
+ shippingCountries: shippingCountryCodes(init?.shipping_countries),
64
+ showSeller: !init?.visual_settings?.single_seller_mode,
65
+ absoluteUrl: (value) => storefrontAbsoluteUrl(value, origin),
66
+ })
67
+ : undefined,
68
+ );
69
+
70
+ return json ? (
71
+ <script
72
+ type="application/ld+json"
73
+ // The JSON is escaped by structuredDataJson, so it cannot close the tag.
74
+ dangerouslySetInnerHTML={{ __html: json }}
75
+ />
76
+ ) : null;
77
+ }
@@ -97,6 +97,8 @@ 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;
100
102
  appLoad: { result: AppLoadResult | null; failure: AppLoadFailure | null };
101
103
  /** The storefront language of this request. */
102
104
  locale: () => Promise<string>;
@@ -461,6 +463,7 @@ const routes: Record<string, Loader> = {
461
463
  previewKey={previewKey}
462
464
  initialProduct={initialProduct}
463
465
  initialError={initialError}
466
+ origin={context.origin ?? ''}
464
467
  />
465
468
  ),
466
469
  };
@@ -24,6 +24,8 @@ export interface IAppLoadCommon {
24
24
  weights: Record<string, IAppLoadCommonUnit>;
25
25
  lengths: Record<string, IAppLoadCommonUnit>;
26
26
  geo_location_enabled: boolean;
27
+ /** ISO codes of the shop's shipping countries, sorted. */
28
+ shipping_countries?: string[];
27
29
  is_authenticated: boolean;
28
30
  wallet?: Record<string, IAppLoadCommonWalletValue>;
29
31
  logos: IAppLoadCommonLogos;
@@ -0,0 +1,221 @@
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
+ /** App Load `init.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: App Load `init.shipping_countries` (the
58
+ * backend's countries marked as shipping countries), as valid ISO 3166-1
59
+ * alpha-2 codes.
60
+ */
61
+ export function shippingCountryCodes(value: unknown): string[] {
62
+ const codes = (Array.isArray(value) ? value.join(' ') : String(value ?? ''))
63
+ .split(/[\s,;]+/)
64
+ .map((code) => code.trim().toUpperCase())
65
+ .filter((code) => /^[A-Z]{2}$/.test(code));
66
+
67
+ return [...new Set(codes)];
68
+ }
69
+
70
+ function wholeDays(value: unknown) {
71
+ const days = Math.floor(Number(value));
72
+
73
+ return Number.isFinite(days) && days > 0 ? days : 0;
74
+ }
75
+
76
+ /**
77
+ * The return terms the page shows: the days for a product without a defect
78
+ * are the return window (sent back by mail at the buyer's cost); a defective
79
+ * product goes back at the shop's cost. A digital product or one without
80
+ * days cannot be returned. Without a shipping country there is no policy:
81
+ * search engines require the country.
82
+ */
83
+ export function productReturnPolicy(
84
+ product: Pick<StorefrontStructuredProduct, 'is_digital' | 'refunds'>,
85
+ countries: string[],
86
+ ) {
87
+ if (!countries.length) {
88
+ return undefined;
89
+ }
90
+
91
+ const applicableCountry = countries.length === 1 ? countries[0] : countries;
92
+ const returnDays = product.is_digital
93
+ ? 0
94
+ : wholeDays(product.refunds?.customer_max_refund_days);
95
+ const defectDays = product.is_digital
96
+ ? 0
97
+ : wholeDays(product.refunds?.max_refund_days_with_defect);
98
+
99
+ if (!returnDays) {
100
+ return {
101
+ '@type': 'MerchantReturnPolicy',
102
+ applicableCountry,
103
+ 'returnPolicyCategory': `${SCHEMA_ORG}MerchantReturnNotPermitted`,
104
+ };
105
+ }
106
+
107
+ return {
108
+ '@type': 'MerchantReturnPolicy',
109
+ applicableCountry,
110
+ 'returnPolicyCategory': `${SCHEMA_ORG}MerchantReturnFiniteReturnWindow`,
111
+ 'merchantReturnDays': returnDays,
112
+ 'returnMethod': `${SCHEMA_ORG}ReturnByMail`,
113
+ 'returnFees': `${SCHEMA_ORG}ReturnFeesCustomerResponsibility`,
114
+ ...(defectDays ? { itemDefectReturnFees: `${SCHEMA_ORG}FreeReturn` } : {}),
115
+ };
116
+ }
117
+
118
+ function priceValue(
119
+ prices: Record<string, StructuredPrice> | undefined,
120
+ currencyId: string,
121
+ ) {
122
+ const value = Number(prices?.[currencyId]?.transform_value);
123
+
124
+ return prices?.[currencyId] && Number.isFinite(value) ? value : undefined;
125
+ }
126
+
127
+ /** The schema.org Product of the page, or undefined when it cannot be built. */
128
+ export function storefrontProductStructuredData(
129
+ product: StorefrontStructuredProduct | undefined,
130
+ options: StorefrontProductDataOptions,
131
+ ) {
132
+ const name = options.name?.trim();
133
+ const currencyCode = options.currencyCode?.trim().toUpperCase() ?? '';
134
+
135
+ if (!product || !options.url || !name) {
136
+ return undefined;
137
+ }
138
+
139
+ // The price the page shows, as ProductCardPrice picks it.
140
+ const currencyId =
141
+ options.currencyId ||
142
+ Object.keys(product.product_price_dict ?? {})[0] ||
143
+ Object.keys(product.base_price_dict ?? {})[0] ||
144
+ '';
145
+ const basePrice = priceValue(product.base_price_dict, currencyId);
146
+ const price =
147
+ priceValue(product.product_price_dict, currencyId) ?? basePrice;
148
+ const images = [
149
+ ...(product.images?.full ?? []),
150
+ ...(product.images?.small ?? []),
151
+ ]
152
+ .map((image) => options.absoluteUrl(image))
153
+ .filter((image, index, list): image is string =>
154
+ Boolean(image) && list.indexOf(image) === index,
155
+ )
156
+ .slice(0, MAX_IMAGES);
157
+ const inStock =
158
+ ON_SALE_STATUSES.includes(product.status?.status) &&
159
+ product.c_real_left > 0;
160
+ const returns = productReturnPolicy(product, options.shippingCountries);
161
+ const brand = product.manufacture?.name?.trim();
162
+ const seller = options.showSeller ? product.seller?.name?.trim() : '';
163
+ const reviews = Number(product.feedback_stat?.c_feedbacks ?? 0);
164
+ const rating = Number(product.feedback_stat?.avg_raiting ?? 0);
165
+ const offer =
166
+ price !== undefined && /^[A-Z]{3}$/.test(currencyCode)
167
+ ? {
168
+ '@type': 'Offer',
169
+ 'url': options.url,
170
+ price,
171
+ 'priceCurrency': currencyCode,
172
+ // The crossed-out old price, when the page shows one.
173
+ ...(basePrice !== undefined && price < basePrice
174
+ ? {
175
+ priceSpecification: {
176
+ '@type': 'UnitPriceSpecification',
177
+ 'priceType': `${SCHEMA_ORG}StrikethroughPrice`,
178
+ 'price': basePrice,
179
+ 'priceCurrency': currencyCode,
180
+ },
181
+ }
182
+ : {}),
183
+ 'availability': `${SCHEMA_ORG}${inStock ? 'InStock' : 'OutOfStock'}`,
184
+ 'itemCondition': `${SCHEMA_ORG}NewCondition`,
185
+ ...(returns ? { hasMerchantReturnPolicy: returns } : {}),
186
+ ...(seller
187
+ ? { seller: { '@type': 'Organization', 'name': seller } }
188
+ : {}),
189
+ }
190
+ : undefined;
191
+
192
+ return {
193
+ '@context': 'https://schema.org',
194
+ '@type': 'Product',
195
+ name,
196
+ 'url': options.url,
197
+ 'sku': String(product.id),
198
+ ...(options.description?.trim()
199
+ ? { description: options.description.trim() }
200
+ : {}),
201
+ ...(images.length ? { image: images } : {}),
202
+ ...(brand ? { brand: { '@type': 'Brand', 'name': brand } } : {}),
203
+ ...(offer ? { offers: offer } : {}),
204
+ ...(reviews > 0 && Number.isFinite(rating) && rating > 0
205
+ ? {
206
+ aggregateRating: {
207
+ '@type': 'AggregateRating',
208
+ 'ratingValue': Math.round(rating * 10) / 10,
209
+ 'reviewCount': reviews,
210
+ },
211
+ }
212
+ : {}),
213
+ };
214
+ }
215
+
216
+ /** The data as the text of a <script type="application/ld+json"> tag. */
217
+ export function structuredDataJson(value: unknown): string {
218
+ return value === undefined
219
+ ? ''
220
+ : JSON.stringify(value).replace(/</g, '\\u003c');
221
+ }
@@ -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,15 @@ 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
+ ),
35
41
  appLoad: await getOrionplaceAppLoad(),
36
42
  locale: getOrionplaceServerLocale,
37
43
  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
  };