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 +2 -0
- package/package.json +1 -1
- package/templates/app/_storefront/AppRoutes.tsx +2 -0
- package/templates/app/_storefront/StorefrontPageMetadata.tsx +43 -0
- package/templates/app/_storefront/storefrontRoutes.tsx +10 -6
- package/templates/components/orionplace/blog/StorefrontJournal.tsx +1 -0
- package/templates/components/orionplace/search/ProductSearchResults.module.css +4 -1
- package/templates/components/orionplace/search/ProductSearchResults.tsx +67 -16
- package/templates/components/orionplace/shared/StorefrontPagination.README.md +8 -2
- package/templates/components/orionplace/shared/StorefrontPagination.tsx +83 -27
- package/templates/components/orionplace/stores/StorefrontStores.tsx +1 -0
- package/templates/lib/orionplace/blogJournal.ts +13 -0
- package/templates/lib/orionplace/pageMetadata.ts +24 -2
- package/templates/lib/orionplace/pageSeo.ts +58 -7
- package/templates/lib/orionplace/serverRecords.ts +6 -0
- package/templates/lib/orionplace/serverRoute.tsx +2 -0
- package/templates/lib/orionplace/storefrontRecords.ts +13 -0
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
|
+
"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
|
-
|
|
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
|
|
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
|
|
@@ -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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
154
|
+
) : labels.next,
|
|
155
|
+
)}
|
|
100
156
|
</nav>
|
|
101
157
|
);
|
|
102
158
|
}
|
|
@@ -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
|
|
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: {
|
|
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=`).
|
|
466
|
-
*
|
|
467
|
-
* pages
|
|
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
|
|
477
|
-
const query
|
|
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
|
|
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:
|
|
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']>>(
|