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 +3 -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/app/layout.tsx +8 -1
- 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/appLoadClient.ts +5 -0
- package/templates/lib/orionplace/appLoadResult.ts +10 -0
- package/templates/lib/orionplace/appLoadServer.ts +25 -4
- package/templates/lib/orionplace/blogJournal.ts +13 -0
- package/templates/lib/orionplace/pageMetadata.ts +97 -4
- package/templates/lib/orionplace/pageSeo.ts +318 -8
- 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
|
@@ -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.
|
|
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
|
package/templates/app/layout.tsx
CHANGED
|
@@ -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 }
|
|
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
|
|
@@ -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
|
}
|
|
@@ -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 {
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
...(
|
|
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
|
-
|
|
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=`).
|
|
326
|
-
*
|
|
327
|
-
* 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.
|
|
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
|
|
337
|
-
const query
|
|
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
|
|
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']>>(
|