orionplace-react 0.7.2 → 0.7.3

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
@@ -513,6 +513,7 @@ server render. Public product, article and tariff details load through the
513
513
  maintained API client. Page metadata uses those returned records and follows
514
514
  client navigation and language selection. Home-page event clicks open enrollment
515
515
  for the returned event, preserving its return path through sign-in.
516
+ Pages with a canonical address also link the same page in the other storefront languages (`hreflang`) and set `og:type` and `og:locale`; a journal article adds its publication date (`article:published_time`) and schema.org `BlogPosting` data. The store icon (`logos.ico`) is the page's `<link rel="icon">`. An unknown `?locale` opens the default language.
516
517
 
517
518
  `StorefrontShell` mounts the header, route content and footer after Application
518
519
  Load succeeds. If initialization fails, it shows `StorefrontErrorPage`; Retry
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orionplace-react",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
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",
@@ -25,7 +25,7 @@ export default async function RootLayout({
25
25
  // Bootstrap precedes page rendering. Failures stay in the store for the interface to handle.
26
26
  const { result, failure } = await getOrionplaceAppLoad();
27
27
  const language = await getOrionplaceServerLocale();
28
- const { tags } = await getStorefrontMetadataContext();
28
+ const { tags, jsonLd } = await getStorefrontMetadataContext();
29
29
  return (
30
30
  <html lang={language}>
31
31
  <head>
@@ -44,6 +44,13 @@ export default async function RootLayout({
44
44
  />
45
45
  ),
46
46
  )}
47
+ {jsonLd ? (
48
+ <script
49
+ type="application/ld+json"
50
+ // Escaped by structuredDataJson, so the text cannot close the tag.
51
+ dangerouslySetInnerHTML={{ __html: jsonLd }}
52
+ />
53
+ ) : null}
47
54
  </head>
48
55
  <body>
49
56
  <OrionplaceProvider
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  AppLoadError,
3
+ appLoadRejectedLocale,
3
4
  appLoadResult,
4
5
  inspectAppLoad,
5
6
  } from '@/lib/orionplace/appLoadResult';
@@ -68,6 +69,10 @@ export async function requestBrowserAppLoad(
68
69
  });
69
70
  }
70
71
  const failure = inspectAppLoad(body, status);
72
+ // A language the storefront does not have opens its default language.
73
+ if (failure && locale && appLoadRejectedLocale(failure)) {
74
+ return requestBrowserAppLoad(signal);
75
+ }
71
76
  if (failure) {
72
77
  throw new AppLoadError('Marketplace initialization failed.', failure);
73
78
  }
@@ -108,3 +108,13 @@ export class AppLoadError extends Error {
108
108
  this.data = data;
109
109
  }
110
110
  }
111
+
112
+ /**
113
+ * Whether App Load refused the request's language (`?lang=` of a language the
114
+ * storefront does not have): the backend answers it with field errors and
115
+ * HTTP 452. The language is the only client value App Load reads, so the
116
+ * storefront loads again without it.
117
+ */
118
+ export function appLoadRejectedLocale(failure: AppLoadFailure): boolean {
119
+ return failure.kind === 'validation' || failure.httpStatus === 452;
120
+ }
@@ -1,7 +1,11 @@
1
1
  import { cache } from 'react';
2
2
  import { headers } from 'next/headers';
3
3
  import { forwardMarketplace } from '@orionplace-runtime/marketplace.mjs';
4
- import { appLoadResult, inspectAppLoad } from '@/lib/orionplace/appLoadResult';
4
+ import {
5
+ appLoadRejectedLocale,
6
+ appLoadResult,
7
+ inspectAppLoad,
8
+ } from '@/lib/orionplace/appLoadResult';
5
9
  import type {
6
10
  AppLoadFailure,
7
11
  AppLoadRequest,
@@ -49,7 +53,11 @@ function handoffs(): Map<string, Promise<ServerAppLoad>> {
49
53
  return scope[HANDOFF_KEY];
50
54
  }
51
55
 
52
- function buildRequest(incoming: Headers, signal: AbortSignal): Request {
56
+ function buildRequest(
57
+ incoming: Headers,
58
+ signal: AbortSignal,
59
+ withLocale = true,
60
+ ): Request {
53
61
  const host =
54
62
  incoming.get('x-forwarded-host') || incoming.get('host') || 'localhost';
55
63
  const protocol =
@@ -59,7 +67,7 @@ function buildRequest(incoming: Headers, signal: AbortSignal): Request {
59
67
  incoming.get('x-orionplace-page-url') || '/',
60
68
  url.origin,
61
69
  );
62
- const locale = pageUrl.searchParams.get('locale');
70
+ const locale = withLocale ? pageUrl.searchParams.get('locale') : null;
63
71
  if (locale) {
64
72
  url.searchParams.set('lang', locale);
65
73
  }
@@ -92,12 +100,16 @@ function buildRequest(incoming: Headers, signal: AbortSignal): Request {
92
100
  export async function requestAppLoad(
93
101
  signal?: AbortSignal,
94
102
  incoming?: Headers,
103
+ withLocale = true,
95
104
  ): Promise<ServerAppLoad> {
96
105
  const timeout = AbortSignal.timeout(35_000);
97
106
  const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
98
107
  let response: Response;
108
+ let source: Headers;
109
+ let request: Request;
99
110
  try {
100
- const request = buildRequest(incoming ?? new Headers(await headers()), combined);
111
+ source = incoming ?? new Headers(await headers());
112
+ request = buildRequest(source, combined, withLocale);
101
113
  response = await forwardMarketplace(request, {
102
114
  mimicFrontDomain: process.env.MIMIC_FRONT_DOMAIN ?? '',
103
115
  });
@@ -116,6 +128,15 @@ export async function requestAppLoad(
116
128
  body = null;
117
129
  }
118
130
  const failure = inspectAppLoad(body, response.status);
131
+ // An address with a language the storefront does not have (`?locale=xx`)
132
+ // opens in the storefront's default language instead of the 503 page.
133
+ if (
134
+ failure &&
135
+ new URL(request.url).searchParams.has('lang') &&
136
+ appLoadRejectedLocale(failure)
137
+ ) {
138
+ return requestAppLoad(signal, source, false);
139
+ }
119
140
  if (failure) {
120
141
  return { result: null, failure, cookies };
121
142
  }
@@ -14,10 +14,17 @@ import type { IPromoDefinition } from '@/interfaces/orionplace/cart/ICartData';
14
14
  import { promoName } from '@/lib/orionplace/promoPresentation';
15
15
  import {
16
16
  storefrontAbsoluteUrl,
17
+ storefrontAlternateLinks,
18
+ storefrontArticleData,
17
19
  storefrontCanonicalUrl,
20
+ storefrontIconLinks,
21
+ storefrontIsoDate,
22
+ storefrontOpenGraphLocale,
23
+ storefrontOpenGraphType,
18
24
  storefrontPageMetadata,
19
25
  type StorefrontSeoPage,
20
26
  } from '@/lib/orionplace/pageSeo';
27
+ import { structuredDataJson } from '@/lib/orionplace/productStructuredData';
21
28
  import { getStorefrontSeo } from '@/lib/orionplace/seo';
22
29
  import { findCatalogCategory } from '@/lib/orionplace/catalog';
23
30
  import { blogPostCover } from '@/lib/orionplace/blogPresentation';
@@ -127,6 +134,8 @@ export function storefrontMetadata(
127
134
  }
128
135
  const values: MetadataValues = {};
129
136
  let image: string | undefined;
137
+ let published: string | undefined;
138
+ let modified: string | undefined;
130
139
  const tags: MetaTags = [];
131
140
  let robots: string | undefined;
132
141
  // A private product link (`key`) stays out of search results, loaded or not.
@@ -201,6 +210,9 @@ export function storefrontMetadata(
201
210
  ),
202
211
  ).slice(0, 160);
203
212
  image = blogPostCover(records.post);
213
+ published = storefrontIsoDate(records.post.date_created?.date);
214
+ modified =
215
+ storefrontIsoDate(records.post.date_updated?.date) ?? published;
204
216
  } else if (page === 'tariff' && records.tariff) {
205
217
  const tariff = records.tariff;
206
218
  const field = <Key extends keyof ISubscribeTariffLocales>(name: Key) =>
@@ -227,17 +239,71 @@ export function storefrontMetadata(
227
239
  locale,
228
240
  robots,
229
241
  );
242
+ // The home page may carry the admin's own og:type (tags, rendered as is).
243
+ const ogType = tags.some(
244
+ (tag) => 'property' in tag && tag.property === 'og:type',
245
+ )
246
+ ? undefined
247
+ : storefrontOpenGraphType(page);
248
+ const ogLocale = storefrontOpenGraphLocale(locale);
249
+ // The page in the storefront's other languages, beside the canonical one.
250
+ const languages = Object.fromEntries(
251
+ storefrontAlternateLinks(canonical, Object.keys(init?.locale ?? {})).map(
252
+ (alternate) => [alternate.hreflang, alternate.href],
253
+ ),
254
+ );
255
+ const icon = storefrontIconLinks(init?.logos?.ico)[0]?.href;
256
+ // Next.js has no og:type "product"; that page gets it as a plain tag below.
257
+ const openGraph = ogType === 'article'
258
+ ? {
259
+ type: 'article' as const,
260
+ ...(published ? { publishedTime: published } : {}),
261
+ ...(modified ? { modifiedTime: modified } : {}),
262
+ }
263
+ : ogType === 'website'
264
+ ? { type: 'website' as const }
265
+ : {};
230
266
  const metadata: Metadata = {
231
267
  ...content,
232
268
  ...(robots ? { robots } : {}),
233
- ...(canonical ? { alternates: { canonical } } : {}),
269
+ ...(canonical
270
+ ? {
271
+ alternates: {
272
+ canonical,
273
+ ...(Object.keys(languages).length ? { languages } : {}),
274
+ },
275
+ }
276
+ : {}),
277
+ ...(icon ? { icons: { icon } } : {}),
234
278
  openGraph: {
279
+ ...openGraph,
235
280
  title: content.title,
236
281
  description: content.description,
282
+ ...(ogLocale ? { locale: ogLocale } : {}),
237
283
  ...(canonical ? { url: canonical } : {}),
238
284
  ...(image ? { images: [image] } : {}),
239
285
  },
240
286
  };
287
+ if (ogType === 'product') {
288
+ tags.unshift({ property: 'og:type', content: 'product' });
289
+ }
290
+ // A journal article as schema.org BlogPosting, with the store as its author.
291
+ const jsonLd =
292
+ page === 'publication' && records.post && canonical && origin
293
+ ? structuredDataJson(
294
+ storefrontArticleData({
295
+ canonical,
296
+ origin,
297
+ headline: content.title,
298
+ description: content.description,
299
+ image,
300
+ published,
301
+ modified,
302
+ locale,
303
+ storeName: init?.contact?.company_name,
304
+ }),
305
+ )
306
+ : '';
241
307
  const headTags = new Map<
242
308
  string,
243
309
  { name?: string; property?: string; content: string }
@@ -250,9 +316,13 @@ export function storefrontMetadata(
250
316
  'og:description': content.description,
251
317
  'og:image': image,
252
318
  'og:url': canonical,
319
+ 'og:type': ogType,
320
+ 'og:locale': ogLocale,
321
+ 'article:published_time': ogType === 'article' ? published : undefined,
322
+ 'article:modified_time': ogType === 'article' ? modified : undefined,
253
323
  })) {
254
324
  headTags.set(name, {
255
- ...(name.startsWith('og:') ? { property: name } : { name }),
325
+ ...(/^(?:og|article):/.test(name) ? { property: name } : { name }),
256
326
  content: value ?? '',
257
327
  });
258
328
  }
@@ -263,6 +333,7 @@ export function storefrontMetadata(
263
333
  return {
264
334
  metadata,
265
335
  tags,
336
+ jsonLd,
266
337
  head: { title: content.title, meta: [...headTags.values()] },
267
338
  };
268
339
  }
@@ -263,17 +263,157 @@ const pageCopy = {
263
263
  'error, store',
264
264
  ],
265
265
  },
266
+ es: {
267
+ tariffs: [
268
+ 'Planes de suscripción',
269
+ 'Precios por periodo, periodos de prueba y opciones de cada plan.',
270
+ 'planes, suscripciones, prueba',
271
+ ],
272
+ tariff: [
273
+ 'Plan de suscripción',
274
+ 'Descripción, funciones, periodos y opciones de un plan de suscripción.',
275
+ 'plan, suscripción, periodo',
276
+ ],
277
+ subscriptions: [
278
+ 'Mis suscripciones',
279
+ 'Fechas de las suscripciones y renovación automática.',
280
+ 'suscripciones, cuenta',
281
+ ],
282
+ sellers: ['Vendedores', 'Vendedores y sus productos.', 'vendedores, productos'],
283
+ stores: [
284
+ 'Tiendas',
285
+ 'Direcciones, contactos y horarios de las tiendas.',
286
+ 'tiendas, direcciones',
287
+ ],
288
+ catalog: ['Catálogo', 'Categorías de productos de la tienda.', 'catálogo, categorías'],
289
+ activity: [
290
+ 'Mi actividad',
291
+ 'Códigos promocionales, reseñas e historial de accesos del cliente.',
292
+ 'cuenta, códigos promocionales, reseñas, accesos',
293
+ ],
294
+ promo: [
295
+ 'Productos del código promocional',
296
+ 'Productos de la tienda a los que se aplica la oferta del código promocional.',
297
+ 'código promocional, oferta, productos',
298
+ ],
299
+ wishlist: [
300
+ 'Favoritos',
301
+ 'Productos guardados y avisos de disponibilidad.',
302
+ 'favoritos, productos',
303
+ ],
304
+ contact: [
305
+ 'Contacto',
306
+ 'Datos de contacto de la empresa y formulario de mensajes de la tienda.',
307
+ 'contacto, mensaje',
308
+ ],
309
+ privacy: [
310
+ 'Política de privacidad',
311
+ 'Qué datos recibe la tienda, para qué, a quién los transmite y qué cookies usa el sitio.',
312
+ 'datos personales, privacidad, cookies',
313
+ ],
314
+ unsubscribe: [
315
+ 'Preferencias de correo',
316
+ 'Darse de baja de los correos de la tienda.',
317
+ 'correos, baja',
318
+ ],
319
+ tickets: [
320
+ 'Soporte',
321
+ 'Conversaciones del usuario con el soporte.',
322
+ 'soporte, conversaciones',
323
+ ],
324
+ orders: [
325
+ 'Mis pedidos',
326
+ 'Historial de pedidos y artículos comprados.',
327
+ 'pedidos, compras',
328
+ ],
329
+ cart: ['Carrito', 'Artículos y pago del pedido.', 'carrito, pago'],
330
+ addresses: [
331
+ 'Mis direcciones',
332
+ 'Direcciones de entrega guardadas.',
333
+ 'direcciones, entrega',
334
+ ],
335
+ wallet: [
336
+ 'Monedero y pagos',
337
+ 'Saldo e historial de pagos.',
338
+ 'monedero, pagos',
339
+ ],
340
+ home: [
341
+ 'Tienda',
342
+ 'Categorías y productos de la tienda.',
343
+ 'tienda, catálogo, productos',
344
+ ],
345
+ browse: [
346
+ 'Resultados de búsqueda',
347
+ 'Productos que coinciden con la búsqueda y los filtros elegidos.',
348
+ 'búsqueda de productos, filtros, catálogo',
349
+ ],
350
+ featured: [
351
+ 'Destacados',
352
+ 'Productos de las categorías que el cliente ha buscado en la tienda.',
353
+ 'recomendaciones, productos, filtros',
354
+ ],
355
+ category: [
356
+ 'Categoría de productos',
357
+ 'Productos y filtros disponibles de la categoría elegida.',
358
+ 'categoría, productos, filtros',
359
+ ],
360
+ journal: [
361
+ 'Revista',
362
+ 'Publicaciones de la tienda con títulos, resúmenes y tiempo de lectura.',
363
+ 'revista, publicaciones, artículos',
364
+ ],
365
+ publication: [
366
+ 'Publicación de la revista',
367
+ 'Un artículo de la revista de la tienda.',
368
+ 'revista, publicación, artículo',
369
+ ],
370
+ product: [
371
+ 'Ficha de producto',
372
+ 'Descripción, precio, variantes y disponibilidad del producto.',
373
+ 'producto, precio, disponibilidad',
374
+ ],
375
+ manufacturers: [
376
+ 'Fabricantes',
377
+ 'Fabricantes de la tienda con logotipos e índice alfabético.',
378
+ 'fabricantes, marcas, índice alfabético',
379
+ ],
380
+ profile: [
381
+ 'Perfil personal',
382
+ 'Datos personales, correo, notificaciones y contraseña de la cuenta.',
383
+ 'perfil, cuenta, ajustes',
384
+ ],
385
+ logout: [
386
+ 'Cerrar sesión',
387
+ 'Fin de la sesión y vuelta a la página principal.',
388
+ 'cuenta, cerrar sesión, sesión',
389
+ ],
390
+ notFound: [
391
+ 'Página no encontrada',
392
+ 'No hay ninguna página de la tienda en esta dirección.',
393
+ 'página no encontrada, 404',
394
+ ],
395
+ error: [
396
+ 'Algo salió mal',
397
+ 'No se pudo mostrar la página de la tienda.',
398
+ 'error, tienda',
399
+ ],
400
+ },
266
401
  } as const;
267
402
 
268
403
  export type StorefrontSeoPage = keyof typeof pageCopy.en;
269
404
 
405
+ /** The page copy for a storefront language; languages without one use English. */
406
+ function storefrontCopyLanguage(locale: string): keyof typeof pageCopy {
407
+ const language = locale.toLowerCase().split(/[-_]/)[0];
408
+ return language === 'ru' || language === 'es' ? language : 'en';
409
+ }
410
+
270
411
  export function storefrontPageMetadata(
271
412
  page: StorefrontSeoPage,
272
413
  locale: string,
273
414
  values: { title?: string; description?: string; keywords?: string } = {},
274
415
  ) {
275
- const copy =
276
- pageCopy[locale.toLowerCase().startsWith('ru') ? 'ru' : 'en'][page];
416
+ const copy = pageCopy[storefrontCopyLanguage(locale)][page];
277
417
  const title = plainText(values.title ?? '') || copy[0];
278
418
 
279
419
  return {
@@ -350,3 +490,122 @@ export function storefrontCanonicalUrl(
350
490
  }
351
491
  return url.toString();
352
492
  }
493
+
494
+ /** og:type of a page: a product and a journal article have their own kinds. */
495
+ export function storefrontOpenGraphType(
496
+ page: StorefrontSeoPage,
497
+ ): 'product' | 'article' | 'website' {
498
+ if (page === 'product') {
499
+ return 'product';
500
+ }
501
+ return page === 'publication' ? 'article' : 'website';
502
+ }
503
+
504
+ const openGraphRegions: Record<string, string> = {
505
+ de: 'de_DE',
506
+ en: 'en_US',
507
+ es: 'es_ES',
508
+ fr: 'fr_FR',
509
+ it: 'it_IT',
510
+ kk: 'kk_KZ',
511
+ pl: 'pl_PL',
512
+ pt: 'pt_PT',
513
+ ru: 'ru_RU',
514
+ tr: 'tr_TR',
515
+ uk: 'uk_UA',
516
+ zh: 'zh_CN',
517
+ };
518
+
519
+ /** og:locale (`language_TERRITORY`) of a storefront language, when it is known. */
520
+ export function storefrontOpenGraphLocale(locale: string): string | undefined {
521
+ return openGraphRegions[locale.toLowerCase().split(/[-_]/)[0]!];
522
+ }
523
+
524
+ /**
525
+ * The same page in every storefront language (hreflang), for a page with a
526
+ * canonical address: the canonical address with each language's `?locale=`,
527
+ * as the sitemap lists them. Search engines that ignore the sitemap's
528
+ * language links (Yandex) read these. A single language gives none.
529
+ */
530
+ export function storefrontAlternateLinks(
531
+ canonical: string | undefined,
532
+ locales: string[],
533
+ ): { hreflang: string; href: string }[] {
534
+ const codes = [
535
+ ...new Set(
536
+ locales
537
+ .map((code) => code.trim())
538
+ .filter((code) => /^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/i.test(code)),
539
+ ),
540
+ ];
541
+ if (!canonical || codes.length < 2) {
542
+ return [];
543
+ }
544
+ return codes.map((code) => {
545
+ const url = new URL(canonical);
546
+ url.searchParams.set('locale', code);
547
+ return { hreflang: code, href: url.toString() };
548
+ });
549
+ }
550
+
551
+ /**
552
+ * A backend date (`d.m.Y`, e.g. `22.09.2026`) as an ISO 8601 date
553
+ * (`2026-09-22`); anything else gives `undefined`. A date without a time
554
+ * cannot disagree with the visible date in another time zone.
555
+ */
556
+ export function storefrontIsoDate(
557
+ value: string | null | undefined,
558
+ ): string | undefined {
559
+ const match = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec((value ?? '').trim());
560
+ return match ? `${match[3]}-${match[2]}-${match[1]}` : undefined;
561
+ }
562
+
563
+ /**
564
+ * schema.org BlogPosting of a journal article (Google "Article" structured
565
+ * data): headline, cover, dates, and the store as author and publisher.
566
+ */
567
+ export function storefrontArticleData(input: {
568
+ canonical: string;
569
+ origin: string;
570
+ headline: string;
571
+ description?: string;
572
+ image?: string;
573
+ published?: string;
574
+ modified?: string;
575
+ locale: string;
576
+ storeName?: string;
577
+ }): Record<string, unknown> {
578
+ const store = {
579
+ '@type': 'Organization',
580
+ name: input.storeName || new URL(input.origin).hostname,
581
+ url: `${input.origin}/`,
582
+ };
583
+ const modified = input.modified || input.published;
584
+ return {
585
+ '@context': 'https://schema.org',
586
+ '@type': 'BlogPosting',
587
+ '@id': `${input.canonical}#article`,
588
+ mainEntityOfPage: input.canonical,
589
+ url: input.canonical,
590
+ headline: input.headline.slice(0, 110),
591
+ ...(input.description ? { description: input.description } : {}),
592
+ ...(input.image ? { image: [input.image] } : {}),
593
+ ...(input.published ? { datePublished: input.published } : {}),
594
+ ...(modified ? { dateModified: modified } : {}),
595
+ inLanguage: input.locale,
596
+ author: store,
597
+ publisher: store,
598
+ };
599
+ }
600
+
601
+ /**
602
+ * The store's icon (App Load `logos.ico`) as `<link rel="icon">` tags: search
603
+ * engines read the icon from the home page. Only a complete http(s) address
604
+ * is used; the type is left out, so it cannot disagree with the file.
605
+ */
606
+ export function storefrontIconLinks(
607
+ icon: string | null | undefined,
608
+ ): { key: string; rel: string; href: string }[] {
609
+ const href = storefrontAbsoluteUrl(icon, '');
610
+ return href ? [{ key: 'icon', rel: 'icon', href }] : [];
611
+ }