orionplace-react 0.7.3 → 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
@@ -515,6 +515,8 @@ 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
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
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.
519
+
518
520
  `StorefrontShell` mounts the header, route content and footer after Application
519
521
  Load succeeds. If initialization fails, it shows `StorefrontErrorPage`; Retry
520
522
  runs an explicit refresh and remains disabled while that request is pending.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orionplace-react",
3
- "version": "0.7.3",
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
@@ -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>
@@ -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
+ }
@@ -22,6 +22,7 @@ import {
22
22
  storefrontOpenGraphLocale,
23
23
  storefrontOpenGraphType,
24
24
  storefrontPageMetadata,
25
+ storefrontPagedText,
25
26
  type StorefrontSeoPage,
26
27
  } from '@/lib/orionplace/pageSeo';
27
28
  import { structuredDataJson } from '@/lib/orionplace/productStructuredData';
@@ -228,7 +229,7 @@ export function storefrontMetadata(
228
229
  // The offer's name, as the other packages title this page.
229
230
  values.title = promoName(records.promo, locale);
230
231
  }
231
- const content = storefrontPageMetadata(page, locale, values);
232
+ const listContent = storefrontPageMetadata(page, locale, values);
232
233
  // Link previews load only complete image addresses; the canonical address
233
234
  // is the sitemap's one for this page.
234
235
  image = storefrontAbsoluteUrl(image, origin);
@@ -239,6 +240,12 @@ export function storefrontMetadata(
239
240
  locale,
240
241
  robots,
241
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
+ };
242
249
  // The home page may carry the admin's own og:type (tags, rendered as is).
243
250
  const ogType = tags.some(
244
251
  (tag) => 'property' in tag && tag.property === 'og:type',
@@ -334,6 +341,21 @@ export function storefrontMetadata(
334
341
  metadata,
335
342
  tags,
336
343
  jsonLd,
337
- head: { title: content.title, meta: [...headTags.values()] },
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
+ },
338
360
  };
339
361
  }
@@ -460,11 +460,15 @@ export function storefrontAbsoluteUrl(
460
460
  // The pages the sitemap lists (runtime/marketplace.mjs), plus plan pages.
461
461
  const indexedPage = /^\/(?:|catalog|journal|tariffs|manufacturers|sellers|stores|contact|privacy|(?:category|product|journal)\/\d+|tariffs\/[^/]+\/\d+)$/;
462
462
 
463
+ // Listed pages that are lists with page numbers (`?page=2`).
464
+ const pagedList = /^\/(?:journal|stores|category\/\d+)$/;
465
+
463
466
  /**
464
467
  * The canonical address of a page: the sitemap's address for it, the page in
465
- * the language it shows (`?locale=`). Pages that are not listed, pages with
466
- * other query parameters (filters, page numbers, private keys) and noindex
467
- * 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.
468
472
  */
469
473
  export function storefrontCanonicalUrl(
470
474
  origin: string,
@@ -473,9 +477,13 @@ export function storefrontCanonicalUrl(
473
477
  locale: string,
474
478
  robots?: string,
475
479
  ): string | undefined {
476
- const path = pathname.replace(/\/+$/, '') || '/';
477
- const query = new URLSearchParams(search);
480
+ const path = pathname.replace(/\/+$/, '') || '/';
481
+ const query = new URLSearchParams(search);
482
+ const pageNumber = query.get('page');
478
483
  query.delete('locale');
484
+ if (pageNumber !== null && pagedList.test(path) && /^[1-9]\d{0,5}$/.test(pageNumber)) {
485
+ query.delete('page');
486
+ }
479
487
  if (
480
488
  !origin ||
481
489
  /noindex/i.test(robots ?? '') ||
@@ -484,13 +492,56 @@ export function storefrontCanonicalUrl(
484
492
  ) {
485
493
  return undefined;
486
494
  }
487
- const url = new URL(path, `${origin}/`);
495
+ const url = new URL(path, `${origin}/`);
488
496
  if (locale) {
489
497
  url.searchParams.set('locale', locale);
490
498
  }
499
+ if (pageNumber && Number(pageNumber) > 1 && pagedList.test(path)) {
500
+ url.searchParams.set('page', pageNumber);
501
+ }
491
502
  return url.toString();
492
503
  }
493
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
+
494
545
  /** og:type of a page: a product and a journal article have their own kinds. */
495
546
  export function storefrontOpenGraphType(
496
547
  page: StorefrontSeoPage,
@@ -605,7 +656,7 @@ export function storefrontArticleData(input: {
605
656
  */
606
657
  export function storefrontIconLinks(
607
658
  icon: string | null | undefined,
608
- ): { key: string; rel: string; href: string }[] {
659
+ ): { key: string; rel: 'icon'; href: string }[] {
609
660
  const href = storefrontAbsoluteUrl(icon, '');
610
661
  return href ? [{ key: 'icon', rel: 'icon', href }] : [];
611
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']>>(