orionplace-react 0.7.2 → 0.7.4

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,9 @@ 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.
517
+
518
+ List pages (journal, categories, stores) link their previous and next pages as real links (`?page=2`). Each later page has its own canonical address and a title with its page number; a journal page past the last one answers 404.
516
519
 
517
520
  `StorefrontShell` mounts the header, route content and footer after Application
518
521
  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.4",
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",
@@ -32,6 +32,7 @@ import {
32
32
  } from '@/lib/orionplace/navigation';
33
33
  import { requestProductPage } from '@/lib/orionplace/productPage';
34
34
  import {
35
+ requestJournalPage,
35
36
  requestPromoProducts,
36
37
  requestPublication,
37
38
  requestTariffs,
@@ -121,6 +122,7 @@ export function AppRoutes() {
121
122
  publication: (id) => requestPublication(api, id),
122
123
  tariffs: (id) => requestTariffs(api, id),
123
124
  promoProducts: (id, page) => requestPromoProducts(api, id, page),
125
+ journalPage: (page, pageSize) => requestJournalPage(api, page, pageSize),
124
126
  }).then((route) => {
125
127
  if (!current) {
126
128
  return;
@@ -69,6 +69,49 @@ export function StorefrontPageMetadata(records: StorefrontPageRecords) {
69
69
  element.content = '';
70
70
  }
71
71
  }
72
+ // Canonical and hreflang links follow the address too (a later list
73
+ // page has its own). The server's links belong to Next.js, so they are
74
+ // updated in place; only links added here are ever removed.
75
+ const wanted = new Map(
76
+ head.links.map((link) => [
77
+ 'hreflang' in link ? `alternate:${link.hreflang}` : 'canonical',
78
+ link.href,
79
+ ]),
80
+ );
81
+ // Next.js's own links first, so they take the address and a copy
82
+ // added here earlier is the one removed.
83
+ const links = [
84
+ ...document.head.querySelectorAll<HTMLLinkElement>(
85
+ 'link[rel="canonical"], link[rel="alternate"][hreflang]',
86
+ ),
87
+ ].sort(
88
+ (left, right) =>
89
+ Number(Boolean(left.dataset.orionplaceLink)) -
90
+ Number(Boolean(right.dataset.orionplaceLink)),
91
+ );
92
+ for (const element of links) {
93
+ const key = element.rel === 'canonical'
94
+ ? 'canonical'
95
+ : `alternate:${element.hreflang}`;
96
+ const href = wanted.get(key);
97
+ if (href) {
98
+ element.href = href;
99
+ wanted.delete(key);
100
+ } else if (element.dataset.orionplaceLink) {
101
+ element.remove();
102
+ }
103
+ }
104
+ for (const [key, href] of wanted) {
105
+ const element = document.createElement('link');
106
+ const [rel, hreflang] = key.split(':');
107
+ element.rel = rel;
108
+ element.href = href;
109
+ element.dataset.orionplaceLink = key;
110
+ if (hreflang) {
111
+ element.hreflang = hreflang;
112
+ }
113
+ document.head.append(element);
114
+ }
72
115
  }, [pathname, query, locale, init, product, post, tariff, promo, search, errorKind]);
73
116
  return null;
74
117
  }
@@ -49,6 +49,7 @@ import type { IUserAddressListResponse } from '@/interfaces/orionplace/address/I
49
49
  import type { StorefrontCartData } from '@/lib/orionplace/cartController';
50
50
  import type { requestProductPage } from '@/lib/orionplace/productPage';
51
51
  import type {
52
+ requestJournalPage,
52
53
  requestPromoProducts,
53
54
  requestPublication,
54
55
  requestTariffs,
@@ -57,8 +58,7 @@ import type { TicketSubjectContext } from '@/lib/orionplace/ticketRequests';
57
58
  import {
58
59
  blogPageNumber,
59
60
  blogPageSize,
60
- blogPostsRequest,
61
- type BlogPostsPage,
61
+ journalPagePastEnd,
62
62
  } from '@/lib/orionplace/blogJournal';
63
63
  import { blogViewType } from '@/lib/orionplace/blogPresentation';
64
64
  import { catalogBranchIds, findCatalogCategory } from '@/lib/orionplace/catalog';
@@ -113,6 +113,8 @@ export interface StorefrontRouteContext {
113
113
  tariffs: (id: number) => ReturnType<typeof requestTariffs>;
114
114
  /** The promotion page's products; the server shares it with the page title. */
115
115
  promoProducts: (id: number, page: number) => ReturnType<typeof requestPromoProducts>;
116
+ /** One journal list page; the server shares it with the page metadata. */
117
+ journalPage: (page: number, pageSize: number) => ReturnType<typeof requestJournalPage>;
116
118
  }
117
119
 
118
120
  export type StorefrontRouteResult =
@@ -314,14 +316,16 @@ const routes: Record<string, Loader> = {
314
316
  );
315
317
  let initial;
316
318
  try {
317
- const data = await (await context.api()).request<BlogPostsPage>(
318
- 'blog_posts_get',
319
- blogPostsRequest({}, page, pageSize),
320
- );
319
+ const data = await context.journalPage(page, pageSize);
321
320
  initial = { page, pageSize, data };
322
321
  } catch {
323
322
  initial = undefined;
324
323
  }
324
+ // A page number past the last page has no articles: 404, so search
325
+ // engines drop it instead of indexing an empty list.
326
+ if (journalPagePastEnd(page, initial)) {
327
+ return { notFound: true };
328
+ }
325
329
  return {
326
330
  page: (
327
331
  <JournalPage
@@ -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
@@ -350,6 +350,7 @@ export function StorefrontJournal({
350
350
  pagination={pagination}
351
351
  pending={pending}
352
352
  scrollTarget={results.current}
353
+ linked
353
354
  onChange={changePage}
354
355
  />
355
356
  </div>
@@ -276,9 +276,12 @@
276
276
  border-top: 1px solid var(--orionplace-border);
277
277
  }
278
278
 
279
- /* Page buttons have a light frame that darkens on hover or focus, as in Nuxt. */
279
+ /* Page buttons have a light frame that darkens on hover or focus, as in Nuxt.
280
+ Previous and next are links while that page exists. */
280
281
  .pagination .button {
281
282
  border-color: var(--orionplace-border);
283
+ font-family: inherit;
284
+ text-decoration: none;
282
285
  }
283
286
 
284
287
  .pagination .button:focus-visible {
@@ -4,7 +4,9 @@ import {
4
4
  useEffect,
5
5
  useRef,
6
6
  useState,
7
+ type MouseEvent,
7
8
  } from 'react';
9
+ import { usePathname, useSearchParams } from 'next/navigation';
8
10
  import {
9
11
  useOrionplaceBrowse,
10
12
  useOrionplaceSelections,
@@ -15,6 +17,7 @@ import type {
15
17
  ProductFacetSelection,
16
18
  } from '@/interfaces/orionplace/products/IProductSearchFacets';
17
19
  import { hasSearchFilters } from '@/lib/orionplace/searchFilters';
20
+ import { storefrontPageHref } from '@/lib/orionplace/pageSeo';
18
21
  import { browseLabels } from '@/lib/orionplace/browseLabels';
19
22
  import { useOrionplaceStableHeight } from '@/hooks/useOrionplaceStableHeight';
20
23
  import {
@@ -81,6 +84,32 @@ export function ProductSearchResults({
81
84
  const storedFilters = useOrionplaceBrowse((state) => state.searchFilters);
82
85
  const locale = useOrionplaceSelections((state) => state.localeCode);
83
86
  const labels = browseLabels(locale);
87
+ // Every list of this component keeps its page in the address (`?page=`),
88
+ // so previous and next are links search engines follow; a click changes
89
+ // the page in place.
90
+ const pathname = usePathname() ?? '/';
91
+ const search = useSearchParams()?.toString() ?? '';
92
+ const previousHref = !pending && page > 1
93
+ ? storefrontPageHref(pathname, search, page - 1)
94
+ : undefined;
95
+ const nextHref = !pending && !failed && hasNext
96
+ ? storefrontPageHref(pathname, search, page + 1)
97
+ : undefined;
98
+
99
+ // A link opened in a new tab or window keeps its default.
100
+ function followPage(event: MouseEvent<HTMLAnchorElement>, value: number) {
101
+ if (
102
+ event.button !== 0 ||
103
+ event.metaKey ||
104
+ event.ctrlKey ||
105
+ event.shiftKey ||
106
+ event.altKey
107
+ ) {
108
+ return;
109
+ }
110
+ event.preventDefault();
111
+ onChangePage?.(value);
112
+ }
84
113
  const facets = suppliedFacets === undefined ? storedFacets : suppliedFacets;
85
114
  const filters = suppliedFilters ?? storedFilters;
86
115
  const ids = productIds.length
@@ -278,26 +307,48 @@ export function ProductSearchResults({
278
307
  className={styles.pagination}
279
308
  data-search-pagination
280
309
  >
281
- <button
282
- type="button"
283
- className={styles.button}
284
- disabled={pending || page === 1}
285
- onClick={() => onChangePage?.(page - 1)}
286
- >
287
- <span><span aria-hidden="true">←</span> {labels.previous}</span>
288
- </button>
310
+ {previousHref ? (
311
+ <a
312
+ className={styles.button}
313
+ href={previousHref}
314
+ rel="prev"
315
+ onClick={(event) => followPage(event, page - 1)}
316
+ >
317
+ <span><span aria-hidden="true">←</span> {labels.previous}</span>
318
+ </a>
319
+ ) : (
320
+ <button
321
+ type="button"
322
+ className={styles.button}
323
+ disabled={pending || page === 1}
324
+ onClick={() => onChangePage?.(page - 1)}
325
+ >
326
+ <span><span aria-hidden="true">←</span> {labels.previous}</span>
327
+ </button>
328
+ )}
289
329
  <span>
290
330
  {labels.page} {page}
291
331
  {pages ? ` ${labels.of} ${pages}` : ''}
292
332
  </span>
293
- <button
294
- type="button"
295
- className={styles.button}
296
- disabled={pending || failed || !hasNext}
297
- onClick={() => onChangePage?.(page + 1)}
298
- >
299
- <span>{labels.next} <span aria-hidden="true">→</span></span>
300
- </button>
333
+ {nextHref ? (
334
+ <a
335
+ className={styles.button}
336
+ href={nextHref}
337
+ rel="next"
338
+ onClick={(event) => followPage(event, page + 1)}
339
+ >
340
+ <span>{labels.next} <span aria-hidden="true">→</span></span>
341
+ </a>
342
+ ) : (
343
+ <button
344
+ type="button"
345
+ className={styles.button}
346
+ disabled={pending || failed || !hasNext}
347
+ onClick={() => onChangePage?.(page + 1)}
348
+ >
349
+ <span>{labels.next} <span aria-hidden="true">→</span></span>
350
+ </button>
351
+ )}
301
352
  </nav>
302
353
  ) : null}
303
354
  </div>
@@ -7,7 +7,9 @@ an available page calls `onChange(page)` and moves the list into view.
7
7
  `scrollTarget` identifies the list by element or CSS selector; `null` keeps
8
8
  the current scroll position. Without a target, the nearest section, article
9
9
  or main container is used. `pending` disables both actions. `compact` shows
10
- arrow buttons with accessible names.
10
+ arrow buttons with accessible names. With `linked`, Previous and Next are
11
+ links to the list's `?page=` addresses, so search engines reach every page;
12
+ a click still changes the page in place.
11
13
 
12
14
  ### Props
13
15
 
@@ -18,6 +20,7 @@ arrow buttons with accessible names.
18
20
  | `pending?: boolean` | Shows the loading state and blocks duplicate actions. |
19
21
  | `scrollTarget?: PaginationScrollTarget` | Element or selector used as the pagination scroll destination. |
20
22
  | `compact?: boolean` | Uses the compact component presentation. |
23
+ | `linked?: boolean` | Previous and Next become links to the list's `?page=` addresses (lists that keep their page in the address). |
21
24
 
22
25
  ## Русский
23
26
 
@@ -26,7 +29,9 @@ arrow buttons with accessible names.
26
29
  к списку. `scrollTarget` задаёт элемент списка или CSS-селектор; `null`
27
30
  оставляет прокрутку на месте. Без этого параметра используется ближайший
28
31
  контейнер section, article или main. `pending` блокирует кнопки. `compact`
29
- показывает стрелки с доступными названиями.
32
+ показывает стрелки с доступными названиями. С `linked` «Назад» и «Далее» —
33
+ ссылки на адреса списка с `?page=`, поэтому поисковые системы доходят до
34
+ каждой страницы; по клику страница по-прежнему меняется на месте.
30
35
 
31
36
  ### Свойства
32
37
 
@@ -37,3 +42,4 @@ arrow buttons with accessible names.
37
42
  | `pending?: boolean` | Показывает загрузку и блокирует повторные действия. |
38
43
  | `scrollTarget?: PaginationScrollTarget` | Элемент или селектор для прокрутки после смены страницы. |
39
44
  | `compact?: boolean` | Включает компактное представление компонента. |
45
+ | `linked?: boolean` | «Назад» и «Далее» становятся ссылками на адреса списка с `?page=` (для списков, хранящих страницу в адресе). |
@@ -1,8 +1,10 @@
1
1
  'use client';
2
2
 
3
- import { useRef } from 'react';
3
+ import { useRef, type MouseEvent, type ReactNode } from 'react';
4
+ import { usePathname, useSearchParams } from 'next/navigation';
4
5
  import { useOrionplaceSelections } from '@/providers/OrionplaceProvider';
5
6
  import { scrollToPaginatedList } from '@/lib/orionplace/paginationScroll';
7
+ import { storefrontPageHref } from '@/lib/orionplace/pageSeo';
6
8
  import type { IApiPagination } from '@/interfaces/orionplace/accounts/IProfileData';
7
9
  import type { PaginationScrollTarget } from '@/lib/orionplace/paginationScroll';
8
10
  import styles from './StorefrontPagination.module.css';
@@ -13,6 +15,12 @@ export interface StorefrontPaginationProps {
13
15
  pending?: boolean;
14
16
  scrollTarget?: PaginationScrollTarget;
15
17
  compact?: boolean;
18
+ /**
19
+ * The list keeps its page number in the address (`?page=`): previous and
20
+ * next are links to those addresses, so search engines reach every page.
21
+ * A click still changes the page in place.
22
+ */
23
+ linked?: boolean;
16
24
  }
17
25
 
18
26
  export function StorefrontPagination({
@@ -21,7 +29,10 @@ export function StorefrontPagination({
21
29
  pending = false,
22
30
  scrollTarget,
23
31
  compact = false,
32
+ linked = false,
24
33
  }: StorefrontPaginationProps) {
34
+ const pathname = usePathname() ?? '/';
35
+ const search = useSearchParams()?.toString() ?? '';
25
36
  const localeCode = useOrionplaceSelections((state) => state.localeCode);
26
37
  const labels = localeCode.toLowerCase().startsWith('ru')
27
38
  ? { page: 'Страница', previous: 'Назад', next: 'Далее' }
@@ -38,6 +49,61 @@ export function StorefrontPagination({
38
49
  scrollToPaginatedList(nav.current, scrollTarget);
39
50
  }
40
51
 
52
+ const previousPage = pagination.previous_page_number || page - 1;
53
+ const nextPage = typeof pagination.next_page_number === 'number'
54
+ ? pagination.next_page_number
55
+ : page + 1;
56
+
57
+ // A link while that page exists (linked lists), else a button.
58
+ function control(
59
+ target: number,
60
+ available: boolean,
61
+ rel: 'prev' | 'next',
62
+ label: string,
63
+ content: ReactNode,
64
+ ) {
65
+ const common = {
66
+ className: styles.button,
67
+ 'aria-label': compact ? label : undefined,
68
+ title: compact ? label : undefined,
69
+ };
70
+ if (linked && available && !pending) {
71
+ return (
72
+ <a
73
+ {...common}
74
+ href={storefrontPageHref(pathname, search, target)}
75
+ rel={rel}
76
+ onClick={(event: MouseEvent<HTMLAnchorElement>) => {
77
+ // A link opened in a new tab or window keeps its default.
78
+ if (
79
+ event.button !== 0 ||
80
+ event.metaKey ||
81
+ event.ctrlKey ||
82
+ event.shiftKey ||
83
+ event.altKey
84
+ ) {
85
+ return;
86
+ }
87
+ event.preventDefault();
88
+ change(target);
89
+ }}
90
+ >
91
+ {content}
92
+ </a>
93
+ );
94
+ }
95
+ return (
96
+ <button
97
+ {...common}
98
+ type="button"
99
+ disabled={pending || !available}
100
+ onClick={() => change(target)}
101
+ >
102
+ {content}
103
+ </button>
104
+ );
105
+ }
106
+
41
107
  return (
42
108
  <nav
43
109
  ref={nav}
@@ -45,15 +111,12 @@ export function StorefrontPagination({
45
111
  className={`${styles.root} ${compact ? styles.compact : ''}`.trim()}
46
112
  data-feature-part="StorefrontPagination"
47
113
  >
48
- <button
49
- className={styles.button}
50
- type="button"
51
- aria-label={compact ? labels.previous : undefined}
52
- title={compact ? labels.previous : undefined}
53
- disabled={pending || !pagination.has_previous}
54
- onClick={() => change(pagination.previous_page_number || page - 1)}
55
- >
56
- {compact ? (
114
+ {control(
115
+ previousPage,
116
+ Boolean(pagination.has_previous),
117
+ 'prev',
118
+ labels.previous,
119
+ compact ? (
57
120
  <svg
58
121
  viewBox="0 0 24 24"
59
122
  fill="none"
@@ -65,25 +128,18 @@ export function StorefrontPagination({
65
128
  >
66
129
  <path d="m15 5-7 7 7 7" />
67
130
  </svg>
68
- ) : labels.previous}
69
- </button>
131
+ ) : labels.previous,
132
+ )}
70
133
  <span className={styles.count}>
71
134
  {labels.page} {page}
72
135
  {pagination.pages ? ` / ${pagination.pages}` : ''}
73
136
  </span>
74
- <button
75
- className={styles.button}
76
- type="button"
77
- aria-label={compact ? labels.next : undefined}
78
- title={compact ? labels.next : undefined}
79
- disabled={pending || !pagination.has_next}
80
- onClick={() => change(
81
- typeof pagination.next_page_number === 'number'
82
- ? pagination.next_page_number
83
- : page + 1,
84
- )}
85
- >
86
- {compact ? (
137
+ {control(
138
+ nextPage,
139
+ Boolean(pagination.has_next),
140
+ 'next',
141
+ labels.next,
142
+ compact ? (
87
143
  <svg
88
144
  viewBox="0 0 24 24"
89
145
  fill="none"
@@ -95,8 +151,8 @@ export function StorefrontPagination({
95
151
  >
96
152
  <path d="m9 5 7 7-7 7" />
97
153
  </svg>
98
- ) : labels.next}
99
- </button>
154
+ ) : labels.next,
155
+ )}
100
156
  </nav>
101
157
  );
102
158
  }
@@ -389,6 +389,7 @@ export function StorefrontStores({
389
389
  <StorefrontPagination
390
390
  pagination={pagination}
391
391
  pending={pending || disabled}
392
+ linked={!storeId}
392
393
  onChange={changePage}
393
394
  />
394
395
  </section>
@@ -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
  }
@@ -113,3 +113,16 @@ export function mergeBlogPagination(
113
113
  ),
114
114
  };
115
115
  }
116
+
117
+ /**
118
+ * Whether a journal page number lies past the last page: the server render
119
+ * loaded that page (no filters) and it has no articles. Page 1 of an empty
120
+ * journal is a page of its own.
121
+ */
122
+ export function journalPagePastEnd(
123
+ page: number,
124
+ loaded: BlogJournalPage | null | undefined,
125
+ ): boolean {
126
+ const posts = loaded?.data?.posts?.data;
127
+ return page > 1 && loaded?.page === page && Array.isArray(posts) && !posts.length;
128
+ }
@@ -14,10 +14,18 @@ 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,
25
+ storefrontPagedText,
19
26
  type StorefrontSeoPage,
20
27
  } from '@/lib/orionplace/pageSeo';
28
+ import { structuredDataJson } from '@/lib/orionplace/productStructuredData';
21
29
  import { getStorefrontSeo } from '@/lib/orionplace/seo';
22
30
  import { findCatalogCategory } from '@/lib/orionplace/catalog';
23
31
  import { blogPostCover } from '@/lib/orionplace/blogPresentation';
@@ -127,6 +135,8 @@ export function storefrontMetadata(
127
135
  }
128
136
  const values: MetadataValues = {};
129
137
  let image: string | undefined;
138
+ let published: string | undefined;
139
+ let modified: string | undefined;
130
140
  const tags: MetaTags = [];
131
141
  let robots: string | undefined;
132
142
  // A private product link (`key`) stays out of search results, loaded or not.
@@ -201,6 +211,9 @@ export function storefrontMetadata(
201
211
  ),
202
212
  ).slice(0, 160);
203
213
  image = blogPostCover(records.post);
214
+ published = storefrontIsoDate(records.post.date_created?.date);
215
+ modified =
216
+ storefrontIsoDate(records.post.date_updated?.date) ?? published;
204
217
  } else if (page === 'tariff' && records.tariff) {
205
218
  const tariff = records.tariff;
206
219
  const field = <Key extends keyof ISubscribeTariffLocales>(name: Key) =>
@@ -216,7 +229,7 @@ export function storefrontMetadata(
216
229
  // The offer's name, as the other packages title this page.
217
230
  values.title = promoName(records.promo, locale);
218
231
  }
219
- const content = storefrontPageMetadata(page, locale, values);
232
+ const listContent = storefrontPageMetadata(page, locale, values);
220
233
  // Link previews load only complete image addresses; the canonical address
221
234
  // is the sitemap's one for this page.
222
235
  image = storefrontAbsoluteUrl(image, origin);
@@ -227,17 +240,77 @@ export function storefrontMetadata(
227
240
  locale,
228
241
  robots,
229
242
  );
243
+ // A later list page (`?page=2`) names its page number.
244
+ const content = {
245
+ ...listContent,
246
+ title: storefrontPagedText(listContent.title, canonical, locale) ?? listContent.title,
247
+ description: storefrontPagedText(listContent.description, canonical, locale) ?? listContent.description,
248
+ };
249
+ // The home page may carry the admin's own og:type (tags, rendered as is).
250
+ const ogType = tags.some(
251
+ (tag) => 'property' in tag && tag.property === 'og:type',
252
+ )
253
+ ? undefined
254
+ : storefrontOpenGraphType(page);
255
+ const ogLocale = storefrontOpenGraphLocale(locale);
256
+ // The page in the storefront's other languages, beside the canonical one.
257
+ const languages = Object.fromEntries(
258
+ storefrontAlternateLinks(canonical, Object.keys(init?.locale ?? {})).map(
259
+ (alternate) => [alternate.hreflang, alternate.href],
260
+ ),
261
+ );
262
+ const icon = storefrontIconLinks(init?.logos?.ico)[0]?.href;
263
+ // Next.js has no og:type "product"; that page gets it as a plain tag below.
264
+ const openGraph = ogType === 'article'
265
+ ? {
266
+ type: 'article' as const,
267
+ ...(published ? { publishedTime: published } : {}),
268
+ ...(modified ? { modifiedTime: modified } : {}),
269
+ }
270
+ : ogType === 'website'
271
+ ? { type: 'website' as const }
272
+ : {};
230
273
  const metadata: Metadata = {
231
274
  ...content,
232
275
  ...(robots ? { robots } : {}),
233
- ...(canonical ? { alternates: { canonical } } : {}),
276
+ ...(canonical
277
+ ? {
278
+ alternates: {
279
+ canonical,
280
+ ...(Object.keys(languages).length ? { languages } : {}),
281
+ },
282
+ }
283
+ : {}),
284
+ ...(icon ? { icons: { icon } } : {}),
234
285
  openGraph: {
286
+ ...openGraph,
235
287
  title: content.title,
236
288
  description: content.description,
289
+ ...(ogLocale ? { locale: ogLocale } : {}),
237
290
  ...(canonical ? { url: canonical } : {}),
238
291
  ...(image ? { images: [image] } : {}),
239
292
  },
240
293
  };
294
+ if (ogType === 'product') {
295
+ tags.unshift({ property: 'og:type', content: 'product' });
296
+ }
297
+ // A journal article as schema.org BlogPosting, with the store as its author.
298
+ const jsonLd =
299
+ page === 'publication' && records.post && canonical && origin
300
+ ? structuredDataJson(
301
+ storefrontArticleData({
302
+ canonical,
303
+ origin,
304
+ headline: content.title,
305
+ description: content.description,
306
+ image,
307
+ published,
308
+ modified,
309
+ locale,
310
+ storeName: init?.contact?.company_name,
311
+ }),
312
+ )
313
+ : '';
241
314
  const headTags = new Map<
242
315
  string,
243
316
  { name?: string; property?: string; content: string }
@@ -250,9 +323,13 @@ export function storefrontMetadata(
250
323
  'og:description': content.description,
251
324
  'og:image': image,
252
325
  'og:url': canonical,
326
+ 'og:type': ogType,
327
+ 'og:locale': ogLocale,
328
+ 'article:published_time': ogType === 'article' ? published : undefined,
329
+ 'article:modified_time': ogType === 'article' ? modified : undefined,
253
330
  })) {
254
331
  headTags.set(name, {
255
- ...(name.startsWith('og:') ? { property: name } : { name }),
332
+ ...(/^(?:og|article):/.test(name) ? { property: name } : { name }),
256
333
  content: value ?? '',
257
334
  });
258
335
  }
@@ -263,6 +340,22 @@ export function storefrontMetadata(
263
340
  return {
264
341
  metadata,
265
342
  tags,
266
- head: { title: content.title, meta: [...headTags.values()] },
343
+ jsonLd,
344
+ head: {
345
+ title: content.title,
346
+ meta: [...headTags.values()],
347
+ // The canonical address and its other languages, for the browser's
348
+ // head after an in-page change (a list page, a language).
349
+ links: canonical
350
+ ? [
351
+ { rel: 'canonical', href: canonical },
352
+ ...Object.entries(languages).map(([hreflang, href]) => ({
353
+ rel: 'alternate',
354
+ hreflang,
355
+ href,
356
+ })),
357
+ ]
358
+ : [],
359
+ },
267
360
  };
268
361
  }
@@ -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 {
@@ -320,11 +460,15 @@ export function storefrontAbsoluteUrl(
320
460
  // The pages the sitemap lists (runtime/marketplace.mjs), plus plan pages.
321
461
  const indexedPage = /^\/(?:|catalog|journal|tariffs|manufacturers|sellers|stores|contact|privacy|(?:category|product|journal)\/\d+|tariffs\/[^/]+\/\d+)$/;
322
462
 
463
+ // Listed pages that are lists with page numbers (`?page=2`).
464
+ const pagedList = /^\/(?:journal|stores|category\/\d+)$/;
465
+
323
466
  /**
324
467
  * The canonical address of a page: the sitemap's address for it, the page in
325
- * the language it shows (`?locale=`). Pages that are not listed, pages with
326
- * other query parameters (filters, page numbers, private keys) and noindex
327
- * pages get none.
468
+ * the language it shows (`?locale=`). A later page of a list is a page of its
469
+ * own (`?locale=en&page=2`, Google's pagination guide), linked from the
470
+ * list's page links. Pages that are not listed, pages with other query
471
+ * parameters (filters, private keys) and noindex pages get none.
328
472
  */
329
473
  export function storefrontCanonicalUrl(
330
474
  origin: string,
@@ -333,9 +477,13 @@ export function storefrontCanonicalUrl(
333
477
  locale: string,
334
478
  robots?: string,
335
479
  ): string | undefined {
336
- const path = pathname.replace(/\/+$/, '') || '/';
337
- const query = new URLSearchParams(search);
480
+ const path = pathname.replace(/\/+$/, '') || '/';
481
+ const query = new URLSearchParams(search);
482
+ const pageNumber = query.get('page');
338
483
  query.delete('locale');
484
+ if (pageNumber !== null && pagedList.test(path) && /^[1-9]\d{0,5}$/.test(pageNumber)) {
485
+ query.delete('page');
486
+ }
339
487
  if (
340
488
  !origin ||
341
489
  /noindex/i.test(robots ?? '') ||
@@ -344,9 +492,171 @@ export function storefrontCanonicalUrl(
344
492
  ) {
345
493
  return undefined;
346
494
  }
347
- const url = new URL(path, `${origin}/`);
495
+ const url = new URL(path, `${origin}/`);
348
496
  if (locale) {
349
497
  url.searchParams.set('locale', locale);
350
498
  }
499
+ if (pageNumber && Number(pageNumber) > 1 && pagedList.test(path)) {
500
+ url.searchParams.set('page', pageNumber);
501
+ }
351
502
  return url.toString();
352
503
  }
504
+
505
+ /**
506
+ * A later page of a list repeats the list's title and description; the page
507
+ * number keeps them distinct (search engines report repeated titles as
508
+ * duplicates). The number comes from the canonical address, so only the
509
+ * indexed list pages get it.
510
+ */
511
+ export function storefrontPagedText(
512
+ text: string | undefined,
513
+ canonical: string | undefined,
514
+ locale: string,
515
+ ): string | undefined {
516
+ const page = canonical ? Number(new URL(canonical).searchParams.get('page')) : 0;
517
+ if (!text || !(page > 1)) {
518
+ return text;
519
+ }
520
+ const words: Record<string, string> = { ru: 'страница', es: 'página' };
521
+ const word = words[storefrontCopyLanguage(locale)] ?? 'page';
522
+ return `${text} · ${word} ${page}`;
523
+ }
524
+
525
+ /**
526
+ * The address of another page of the list on screen: the current address
527
+ * with `page=` set (left out for page 1), every other parameter kept. Page
528
+ * links are real links, so search engines reach every page of a list.
529
+ */
530
+ export function storefrontPageHref(
531
+ pathname: string,
532
+ search: string,
533
+ page: number,
534
+ ): string {
535
+ const query = new URLSearchParams(search);
536
+ if (page > 1) {
537
+ query.set('page', String(page));
538
+ } else {
539
+ query.delete('page');
540
+ }
541
+ const text = query.toString();
542
+ return `${pathname}${text ? `?${text}` : ''}`;
543
+ }
544
+
545
+ /** og:type of a page: a product and a journal article have their own kinds. */
546
+ export function storefrontOpenGraphType(
547
+ page: StorefrontSeoPage,
548
+ ): 'product' | 'article' | 'website' {
549
+ if (page === 'product') {
550
+ return 'product';
551
+ }
552
+ return page === 'publication' ? 'article' : 'website';
553
+ }
554
+
555
+ const openGraphRegions: Record<string, string> = {
556
+ de: 'de_DE',
557
+ en: 'en_US',
558
+ es: 'es_ES',
559
+ fr: 'fr_FR',
560
+ it: 'it_IT',
561
+ kk: 'kk_KZ',
562
+ pl: 'pl_PL',
563
+ pt: 'pt_PT',
564
+ ru: 'ru_RU',
565
+ tr: 'tr_TR',
566
+ uk: 'uk_UA',
567
+ zh: 'zh_CN',
568
+ };
569
+
570
+ /** og:locale (`language_TERRITORY`) of a storefront language, when it is known. */
571
+ export function storefrontOpenGraphLocale(locale: string): string | undefined {
572
+ return openGraphRegions[locale.toLowerCase().split(/[-_]/)[0]!];
573
+ }
574
+
575
+ /**
576
+ * The same page in every storefront language (hreflang), for a page with a
577
+ * canonical address: the canonical address with each language's `?locale=`,
578
+ * as the sitemap lists them. Search engines that ignore the sitemap's
579
+ * language links (Yandex) read these. A single language gives none.
580
+ */
581
+ export function storefrontAlternateLinks(
582
+ canonical: string | undefined,
583
+ locales: string[],
584
+ ): { hreflang: string; href: string }[] {
585
+ const codes = [
586
+ ...new Set(
587
+ locales
588
+ .map((code) => code.trim())
589
+ .filter((code) => /^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/i.test(code)),
590
+ ),
591
+ ];
592
+ if (!canonical || codes.length < 2) {
593
+ return [];
594
+ }
595
+ return codes.map((code) => {
596
+ const url = new URL(canonical);
597
+ url.searchParams.set('locale', code);
598
+ return { hreflang: code, href: url.toString() };
599
+ });
600
+ }
601
+
602
+ /**
603
+ * A backend date (`d.m.Y`, e.g. `22.09.2026`) as an ISO 8601 date
604
+ * (`2026-09-22`); anything else gives `undefined`. A date without a time
605
+ * cannot disagree with the visible date in another time zone.
606
+ */
607
+ export function storefrontIsoDate(
608
+ value: string | null | undefined,
609
+ ): string | undefined {
610
+ const match = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec((value ?? '').trim());
611
+ return match ? `${match[3]}-${match[2]}-${match[1]}` : undefined;
612
+ }
613
+
614
+ /**
615
+ * schema.org BlogPosting of a journal article (Google "Article" structured
616
+ * data): headline, cover, dates, and the store as author and publisher.
617
+ */
618
+ export function storefrontArticleData(input: {
619
+ canonical: string;
620
+ origin: string;
621
+ headline: string;
622
+ description?: string;
623
+ image?: string;
624
+ published?: string;
625
+ modified?: string;
626
+ locale: string;
627
+ storeName?: string;
628
+ }): Record<string, unknown> {
629
+ const store = {
630
+ '@type': 'Organization',
631
+ name: input.storeName || new URL(input.origin).hostname,
632
+ url: `${input.origin}/`,
633
+ };
634
+ const modified = input.modified || input.published;
635
+ return {
636
+ '@context': 'https://schema.org',
637
+ '@type': 'BlogPosting',
638
+ '@id': `${input.canonical}#article`,
639
+ mainEntityOfPage: input.canonical,
640
+ url: input.canonical,
641
+ headline: input.headline.slice(0, 110),
642
+ ...(input.description ? { description: input.description } : {}),
643
+ ...(input.image ? { image: [input.image] } : {}),
644
+ ...(input.published ? { datePublished: input.published } : {}),
645
+ ...(modified ? { dateModified: modified } : {}),
646
+ inLanguage: input.locale,
647
+ author: store,
648
+ publisher: store,
649
+ };
650
+ }
651
+
652
+ /**
653
+ * The store's icon (App Load `logos.ico`) as `<link rel="icon">` tags: search
654
+ * engines read the icon from the home page. Only a complete http(s) address
655
+ * is used; the type is left out, so it cannot disagree with the file.
656
+ */
657
+ export function storefrontIconLinks(
658
+ icon: string | null | undefined,
659
+ ): { key: string; rel: 'icon'; href: string }[] {
660
+ const href = storefrontAbsoluteUrl(icon, '');
661
+ return href ? [{ key: 'icon', rel: 'icon', href }] : [];
662
+ }
@@ -2,6 +2,7 @@ import { cache } from 'react';
2
2
  import { getOrionplaceServerApi } from '@/lib/orionplace/serverApi';
3
3
  import { requestProductPage } from '@/lib/orionplace/productPage';
4
4
  import {
5
+ requestJournalPage,
5
6
  requestPromoProducts,
6
7
  requestPublication,
7
8
  requestTariffs,
@@ -16,6 +17,11 @@ export const getStorefrontPublication = cache(async (id: number) =>
16
17
  requestPublication(await getOrionplaceServerApi(), id),
17
18
  );
18
19
 
20
+ // One journal list page (no filters), shared by the page and its metadata.
21
+ export const getStorefrontJournalPage = cache(async (page: number, pageSize: number) =>
22
+ requestJournalPage(await getOrionplaceServerApi(), page, pageSize),
23
+ );
24
+
19
25
  export const getStorefrontTariffs = cache(async (id: number) =>
20
26
  requestTariffs(await getOrionplaceServerApi(), id),
21
27
  );
@@ -8,6 +8,7 @@ import {
8
8
  getStorefrontRequestUrl,
9
9
  } from '@/lib/orionplace/serverLocale';
10
10
  import {
11
+ getStorefrontJournalPage,
11
12
  getStorefrontProduct,
12
13
  getStorefrontPromoProducts,
13
14
  getStorefrontPublication,
@@ -46,6 +47,7 @@ export async function renderStorefrontRoute(
46
47
  publication: getStorefrontPublication,
47
48
  tariffs: getStorefrontTariffs,
48
49
  promoProducts: getStorefrontPromoProducts,
50
+ journalPage: getStorefrontJournalPage,
49
51
  });
50
52
  if ('redirect' in result) {
51
53
  redirect(result.redirect);
@@ -1,8 +1,21 @@
1
1
  import type { OrionplaceApi } from '@/lib/orionplace/api';
2
+ import { blogPostsRequest, type BlogPostsPage } from '@/lib/orionplace/blogJournal';
2
3
  import type { IBlogPostsResponse } from '@/interfaces/orionplace/blog/IBlogApi';
3
4
  import type { ISubscriptionTariffsResponse } from '@/interfaces/orionplace/subscribes/ISubscriptionApi';
4
5
  import type { IProductSearchResponse } from '@/interfaces/orionplace/products/IProductSearchApi';
5
6
 
7
+ /** One page of the journal list, without filters. */
8
+ export function requestJournalPage(
9
+ api: OrionplaceApi,
10
+ page: number,
11
+ pageSize: number,
12
+ ) {
13
+ return api.request<BlogPostsPage>(
14
+ 'blog_posts_get',
15
+ blogPostsRequest({}, page, pageSize),
16
+ );
17
+ }
18
+
6
19
  /** One journal publication, or undefined when the storefront has none with this id. */
7
20
  export async function requestPublication(api: OrionplaceApi, id: number) {
8
21
  const data = await api.request<NonNullable<IBlogPostsResponse['page_data']>>(