orionplace-svelte 0.7.3 → 0.7.5
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/src/lib/components/orionplace/blog/StorefrontJournal.svelte +1 -0
- package/templates/src/lib/components/orionplace/search/ProductSearchResults.svelte +64 -14
- package/templates/src/lib/components/orionplace/shared/StorefrontPagination.README.md +5 -0
- package/templates/src/lib/components/orionplace/shared/StorefrontPagination.svelte +77 -16
- package/templates/src/lib/components/orionplace/stores/StorefrontStores.svelte +1 -0
- package/templates/src/lib/composables/useOrionplacePageSeo.svelte.ts +20 -6
- package/templates/src/lib/composables/useOrionplaceProductSearch.svelte.ts +2 -1
- package/templates/src/lib/interfaces/orionplace/subscribes/ISubscriptionApi.ts +2 -0
- package/templates/src/lib/orionplace/navigation.svelte.ts +19 -0
- package/templates/src/lib/orionplace/pageLoad.ts +9 -1
- package/templates/src/lib/utils/orionplace/blogLayout.ts +13 -0
- package/templates/src/lib/utils/orionplace/pageSeo.ts +58 -7
package/README.md
CHANGED
|
@@ -521,6 +521,8 @@ and selected language, including client navigation. Public events and banner
|
|
|
521
521
|
images come from Application Load.
|
|
522
522
|
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.
|
|
523
523
|
|
|
524
|
+
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.
|
|
525
|
+
|
|
524
526
|
## Application Load starter
|
|
525
527
|
|
|
526
528
|
The root layout load awaits `POST /api/appload` before your pages
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orionplace-svelte",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.5",
|
|
4
4
|
"description": "SvelteKit 2 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",
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
import { useOrionplaceSelectionsStore } from '@/lib/stores/orionplaceSelections.svelte';
|
|
7
7
|
import { browseLabels } from '@/lib/utils/orionplace/browseLabels';
|
|
8
8
|
import { hasSearchFilters } from '@/lib/utils/orionplace/searchFilters';
|
|
9
|
+
import { storefrontPageHref } from '@/lib/utils/orionplace/pageSeo';
|
|
10
|
+
import { currentSearch, useRoute } from '@/lib/orionplace/navigation.svelte';
|
|
9
11
|
import SearchFilters from '@/lib/components/orionplace/search/SearchFilters.svelte';
|
|
10
12
|
import ProductGroup from '@/lib/components/orionplace/products/ProductGroup.svelte';
|
|
11
13
|
import type { ProductFacetSelection } from '@/lib/interfaces/orionplace/products/IProductSearchFacets';
|
|
@@ -195,6 +197,36 @@
|
|
|
195
197
|
},
|
|
196
198
|
);
|
|
197
199
|
const labels = computed(() => browseLabels(selectionStore.localeCode));
|
|
200
|
+
// Every list of this component keeps its page in the address (`?page=`),
|
|
201
|
+
// so previous and next are links search engines follow; a click changes
|
|
202
|
+
// the page in place.
|
|
203
|
+
const route = useRoute();
|
|
204
|
+
const search = computed(() => currentSearch());
|
|
205
|
+
const previousHref = computed(() =>
|
|
206
|
+
!inputs.pending && inputs.page > 1
|
|
207
|
+
? storefrontPageHref(route.path, search.value, inputs.page - 1)
|
|
208
|
+
: undefined,
|
|
209
|
+
);
|
|
210
|
+
const nextHref = computed(() =>
|
|
211
|
+
!inputs.pending && !inputs.failed && inputs.hasNext
|
|
212
|
+
? storefrontPageHref(route.path, search.value, inputs.page + 1)
|
|
213
|
+
: undefined,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
// A link opened in a new tab or window keeps its default.
|
|
217
|
+
function followPage(event: MouseEvent, value: number) {
|
|
218
|
+
if (
|
|
219
|
+
event.button !== 0 ||
|
|
220
|
+
event.metaKey ||
|
|
221
|
+
event.ctrlKey ||
|
|
222
|
+
event.shiftKey ||
|
|
223
|
+
event.altKey
|
|
224
|
+
) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
event.preventDefault();
|
|
228
|
+
emit('changePage', value);
|
|
229
|
+
}
|
|
198
230
|
const filtersOpen = ref(false);
|
|
199
231
|
const hasFilters = computed(() =>
|
|
200
232
|
hasSearchFilters(
|
|
@@ -571,17 +603,26 @@
|
|
|
571
603
|
`,
|
|
572
604
|
]}
|
|
573
605
|
>
|
|
574
|
-
<
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
606
|
+
<svelte:element
|
|
607
|
+
this={previousHref.value ? 'a' : 'button'}
|
|
608
|
+
type={previousHref.value ? undefined : 'button'}
|
|
609
|
+
href={previousHref.value}
|
|
610
|
+
rel={previousHref.value ? 'prev' : undefined}
|
|
611
|
+
disabled={previousHref.value
|
|
612
|
+
? undefined
|
|
613
|
+
: inputs.pending || inputs.page === 1}
|
|
614
|
+
onclick={(event: MouseEvent) => {
|
|
615
|
+
if (previousHref.value) {
|
|
616
|
+
followPage(event, inputs.page - 1);
|
|
617
|
+
} else {
|
|
618
|
+
emit('changePage', inputs.page - 1);
|
|
619
|
+
}
|
|
579
620
|
}}
|
|
580
621
|
class={[
|
|
581
622
|
`
|
|
582
623
|
inline-flex h-11 items-center justify-center px-5 [font-family:inherit]
|
|
583
624
|
text-xs font-medium uppercase tracking-[0.08em] text-orionplace-text
|
|
584
|
-
border border-solid border-orionplace-border bg-transparent
|
|
625
|
+
no-underline border border-solid border-orionplace-border bg-transparent
|
|
585
626
|
cursor-pointer outline-none hover:border-orionplace-text
|
|
586
627
|
focus-visible:border-orionplace-text disabled:cursor-not-allowed
|
|
587
628
|
disabled:opacity-40
|
|
@@ -589,7 +630,7 @@
|
|
|
589
630
|
]}
|
|
590
631
|
>
|
|
591
632
|
← {labels.value.previous}
|
|
592
|
-
</
|
|
633
|
+
</svelte:element>
|
|
593
634
|
<span
|
|
594
635
|
class={[
|
|
595
636
|
'text-xs uppercase tracking-[0.08em] tabular-nums text-orionplace-muted',
|
|
@@ -601,17 +642,26 @@
|
|
|
601
642
|
{labels.value.of} {inputs.pages}
|
|
602
643
|
{/if}
|
|
603
644
|
</span>
|
|
604
|
-
<
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
645
|
+
<svelte:element
|
|
646
|
+
this={nextHref.value ? 'a' : 'button'}
|
|
647
|
+
type={nextHref.value ? undefined : 'button'}
|
|
648
|
+
href={nextHref.value}
|
|
649
|
+
rel={nextHref.value ? 'next' : undefined}
|
|
650
|
+
disabled={nextHref.value
|
|
651
|
+
? undefined
|
|
652
|
+
: inputs.pending || inputs.failed || !inputs.hasNext}
|
|
653
|
+
onclick={(event: MouseEvent) => {
|
|
654
|
+
if (nextHref.value) {
|
|
655
|
+
followPage(event, inputs.page + 1);
|
|
656
|
+
} else {
|
|
657
|
+
emit('changePage', inputs.page + 1);
|
|
658
|
+
}
|
|
609
659
|
}}
|
|
610
660
|
class={[
|
|
611
661
|
`
|
|
612
662
|
inline-flex h-11 items-center justify-center px-5 [font-family:inherit]
|
|
613
663
|
text-xs font-medium uppercase tracking-[0.08em] text-orionplace-text
|
|
614
|
-
border border-solid border-orionplace-border bg-transparent
|
|
664
|
+
no-underline border border-solid border-orionplace-border bg-transparent
|
|
615
665
|
cursor-pointer outline-none hover:border-orionplace-text
|
|
616
666
|
focus-visible:border-orionplace-text disabled:cursor-not-allowed
|
|
617
667
|
disabled:opacity-40
|
|
@@ -619,7 +669,7 @@
|
|
|
619
669
|
]}
|
|
620
670
|
>
|
|
621
671
|
{labels.value.next} →
|
|
622
|
-
</
|
|
672
|
+
</svelte:element>
|
|
623
673
|
</nav>
|
|
624
674
|
{/if}
|
|
625
675
|
</div>
|
|
@@ -8,6 +8,10 @@ the list start while accounting for a fixed header and reduced-motion preference
|
|
|
8
8
|
focus moves to the list's first heading. Both controls are disabled while the request
|
|
9
9
|
runs.
|
|
10
10
|
|
|
11
|
+
With `linked`, for lists that keep their page number in the address (`?page=2`),
|
|
12
|
+
previous and next are links to those addresses, so search engines reach every page of
|
|
13
|
+
the list; a click still changes the page in place.
|
|
14
|
+
|
|
11
15
|
Source: `src/lib/components/orionplace/shared/StorefrontPagination.svelte`.
|
|
12
16
|
|
|
13
17
|
## Inputs
|
|
@@ -18,6 +22,7 @@ Source: `src/lib/components/orionplace/shared/StorefrontPagination.svelte`.
|
|
|
18
22
|
| `pending` | `boolean` | Optional |
|
|
19
23
|
| `scrollTarget` | `HTMLElement \| string \| null` | Optional |
|
|
20
24
|
| `compact` | `boolean` | Optional |
|
|
25
|
+
| `linked` | `boolean` | Optional |
|
|
21
26
|
|
|
22
27
|
## Callbacks
|
|
23
28
|
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import { useOrionplaceSelectionsStore } from '@/lib/stores/orionplaceSelections.svelte';
|
|
4
4
|
import { featureLabels } from '@/lib/utils/orionplace/featureLabels';
|
|
5
5
|
import { scrollToPaginatedList } from '@/lib/utils/orionplace/paginationScroll';
|
|
6
|
+
import { storefrontPageHref } from '@/lib/utils/orionplace/pageSeo';
|
|
7
|
+
import { currentSearch, useRoute } from '@/lib/orionplace/navigation.svelte';
|
|
6
8
|
import type { IApiPagination } from '@/lib/interfaces/orionplace/accounts/IProfileData';
|
|
7
9
|
import '@/lib/components/orionplace/shared/features.css';
|
|
8
10
|
|
|
@@ -11,6 +13,12 @@
|
|
|
11
13
|
pending?: boolean;
|
|
12
14
|
scrollTarget?: HTMLElement | string | null;
|
|
13
15
|
compact?: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* The list keeps its page number in the address (`?page=`): previous and
|
|
18
|
+
* next are links to those addresses, so search engines reach every page.
|
|
19
|
+
* A click still changes the page in place.
|
|
20
|
+
*/
|
|
21
|
+
linked?: boolean;
|
|
14
22
|
onChange?: (...args: [page: number]) => unknown;
|
|
15
23
|
[attribute: string]: any;
|
|
16
24
|
};
|
|
@@ -19,6 +27,7 @@
|
|
|
19
27
|
pending: input_pending,
|
|
20
28
|
scrollTarget: input_scrollTarget,
|
|
21
29
|
compact: input_compact,
|
|
30
|
+
linked: input_linked,
|
|
22
31
|
onChange: callback_onChange,
|
|
23
32
|
...attributes
|
|
24
33
|
}: Props = $props();
|
|
@@ -35,6 +44,9 @@
|
|
|
35
44
|
get compact() {
|
|
36
45
|
return input_compact;
|
|
37
46
|
},
|
|
47
|
+
get linked() {
|
|
48
|
+
return input_linked;
|
|
49
|
+
},
|
|
38
50
|
};
|
|
39
51
|
const emit = (event: string, ...args: any[]) => {
|
|
40
52
|
const handlers: Record<string, ((...args: any[]) => unknown) | undefined> =
|
|
@@ -48,11 +60,46 @@
|
|
|
48
60
|
const labels = computed(() => featureLabels(selectionStore.localeCode));
|
|
49
61
|
const page = computed(() => inputs.pagination.page ?? 1);
|
|
50
62
|
const nav = ref<HTMLElement>();
|
|
63
|
+
const route = useRoute();
|
|
64
|
+
const previousPage = computed(
|
|
65
|
+
() => inputs.pagination.previous_page_number || page.value - 1,
|
|
66
|
+
);
|
|
67
|
+
const nextPage = computed(() =>
|
|
68
|
+
typeof inputs.pagination.next_page_number === 'number'
|
|
69
|
+
? inputs.pagination.next_page_number
|
|
70
|
+
: page.value + 1,
|
|
71
|
+
);
|
|
72
|
+
const search = computed(() => currentSearch());
|
|
73
|
+
const previousHref = computed(() =>
|
|
74
|
+
inputs.linked && !inputs.pending && inputs.pagination.has_previous
|
|
75
|
+
? storefrontPageHref(route.path, search.value, previousPage.value)
|
|
76
|
+
: undefined,
|
|
77
|
+
);
|
|
78
|
+
const nextHref = computed(() =>
|
|
79
|
+
inputs.linked && !inputs.pending && inputs.pagination.has_next
|
|
80
|
+
? storefrontPageHref(route.path, search.value, nextPage.value)
|
|
81
|
+
: undefined,
|
|
82
|
+
);
|
|
51
83
|
|
|
52
84
|
function change(value: number) {
|
|
53
85
|
emit('change', value);
|
|
54
86
|
scrollToPaginatedList(nav.value, inputs.scrollTarget);
|
|
55
87
|
}
|
|
88
|
+
|
|
89
|
+
// A link opened in a new tab or window keeps its default.
|
|
90
|
+
function follow(event: MouseEvent, value: number, act: (value: number) => void) {
|
|
91
|
+
if (
|
|
92
|
+
event.button !== 0 ||
|
|
93
|
+
event.metaKey ||
|
|
94
|
+
event.ctrlKey ||
|
|
95
|
+
event.shiftKey ||
|
|
96
|
+
event.altKey
|
|
97
|
+
) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
event.preventDefault();
|
|
101
|
+
act(value);
|
|
102
|
+
}
|
|
56
103
|
</script>
|
|
57
104
|
|
|
58
105
|
{#if inputs.pagination.has_other_pages || inputs.pagination.has_next || inputs.pagination.has_previous}
|
|
@@ -67,13 +114,22 @@
|
|
|
67
114
|
attributes.class,
|
|
68
115
|
]}
|
|
69
116
|
>
|
|
70
|
-
<
|
|
71
|
-
|
|
117
|
+
<svelte:element
|
|
118
|
+
this={previousHref.value ? 'a' : 'button'}
|
|
119
|
+
type={previousHref.value ? undefined : 'button'}
|
|
120
|
+
href={previousHref.value}
|
|
121
|
+
rel={previousHref.value ? 'prev' : undefined}
|
|
72
122
|
aria-label={inputs.compact ? labels.value.previous : undefined}
|
|
73
123
|
title={inputs.compact ? labels.value.previous : undefined}
|
|
74
|
-
disabled={
|
|
75
|
-
|
|
76
|
-
|
|
124
|
+
disabled={previousHref.value
|
|
125
|
+
? undefined
|
|
126
|
+
: inputs.pending || !inputs.pagination.has_previous}
|
|
127
|
+
onclick={(event: MouseEvent) => {
|
|
128
|
+
if (previousHref.value) {
|
|
129
|
+
follow(event, previousPage.value, change);
|
|
130
|
+
} else {
|
|
131
|
+
change(previousPage.value);
|
|
132
|
+
}
|
|
77
133
|
}}
|
|
78
134
|
class={['op-feature-button']}
|
|
79
135
|
>
|
|
@@ -93,7 +149,7 @@
|
|
|
93
149
|
{:else}
|
|
94
150
|
{labels.value.previous}
|
|
95
151
|
{/if}
|
|
96
|
-
</
|
|
152
|
+
</svelte:element>
|
|
97
153
|
<span class={['tabular-nums']}>
|
|
98
154
|
{labels.value.page}
|
|
99
155
|
{page.value}
|
|
@@ -101,17 +157,22 @@
|
|
|
101
157
|
/ {inputs.pagination.pages}
|
|
102
158
|
{/if}
|
|
103
159
|
</span>
|
|
104
|
-
<
|
|
105
|
-
|
|
160
|
+
<svelte:element
|
|
161
|
+
this={nextHref.value ? 'a' : 'button'}
|
|
162
|
+
type={nextHref.value ? undefined : 'button'}
|
|
163
|
+
href={nextHref.value}
|
|
164
|
+
rel={nextHref.value ? 'next' : undefined}
|
|
106
165
|
aria-label={inputs.compact ? labels.value.next : undefined}
|
|
107
166
|
title={inputs.compact ? labels.value.next : undefined}
|
|
108
|
-
disabled={
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
167
|
+
disabled={nextHref.value
|
|
168
|
+
? undefined
|
|
169
|
+
: inputs.pending || !inputs.pagination.has_next}
|
|
170
|
+
onclick={(event: MouseEvent) => {
|
|
171
|
+
if (nextHref.value) {
|
|
172
|
+
follow(event, nextPage.value, change);
|
|
173
|
+
} else {
|
|
174
|
+
change(nextPage.value);
|
|
175
|
+
}
|
|
115
176
|
}}
|
|
116
177
|
class={['op-feature-button']}
|
|
117
178
|
>
|
|
@@ -131,6 +192,6 @@
|
|
|
131
192
|
{:else}
|
|
132
193
|
{labels.value.next}
|
|
133
194
|
{/if}
|
|
134
|
-
</
|
|
195
|
+
</svelte:element>
|
|
135
196
|
</nav>
|
|
136
197
|
{/if}
|
|
@@ -2,7 +2,7 @@ import { page as currentPage } from '$app/state';
|
|
|
2
2
|
import { browser } from '$app/environment';
|
|
3
3
|
import { computed, toValue } from '@/lib/orionplace/reactivity.svelte';
|
|
4
4
|
import type { MaybeRefOrGetter } from '@/lib/orionplace/reactivity.svelte';
|
|
5
|
-
import { useHead, useSeoMeta } from '@/lib/orionplace/navigation.svelte';
|
|
5
|
+
import { currentSearch, useHead, useSeoMeta } from '@/lib/orionplace/navigation.svelte';
|
|
6
6
|
import { useOrionplaceStore } from '@/lib/stores/orionplace.svelte';
|
|
7
7
|
import {
|
|
8
8
|
storefrontAbsoluteUrl,
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
storefrontOpenGraphLocale,
|
|
12
12
|
storefrontOpenGraphType,
|
|
13
13
|
storefrontPageMetadata,
|
|
14
|
+
storefrontPagedText,
|
|
14
15
|
storefrontPublicOrigin,
|
|
15
16
|
} from '@/lib/utils/orionplace/pageSeo';
|
|
16
17
|
import type { StorefrontSeoPage } from '@/lib/utils/orionplace/pageSeo';
|
|
@@ -52,22 +53,35 @@ export function useOrionplacePageSeo(
|
|
|
52
53
|
storefrontCanonicalUrl(
|
|
53
54
|
origin,
|
|
54
55
|
currentPage.url.pathname,
|
|
55
|
-
|
|
56
|
+
currentSearch(),
|
|
56
57
|
toValue(locale),
|
|
57
58
|
toValue(values.robots),
|
|
58
59
|
),
|
|
59
60
|
);
|
|
60
61
|
|
|
62
|
+
// A later list page (`?page=2`) names its page number.
|
|
63
|
+
const paged = computed(() => ({
|
|
64
|
+
title:
|
|
65
|
+
storefrontPagedText(metadata.value.title, canonical.value, toValue(locale)) ??
|
|
66
|
+
metadata.value.title,
|
|
67
|
+
description:
|
|
68
|
+
storefrontPagedText(
|
|
69
|
+
metadata.value.description,
|
|
70
|
+
canonical.value,
|
|
71
|
+
toValue(locale),
|
|
72
|
+
) ?? metadata.value.description,
|
|
73
|
+
}));
|
|
74
|
+
|
|
61
75
|
// Link previews use the same title and description as the page; the
|
|
62
76
|
// image is the record's first picture when the page has one, as a
|
|
63
77
|
// complete address.
|
|
64
78
|
const seo = {
|
|
65
|
-
title: () =>
|
|
66
|
-
description: () =>
|
|
79
|
+
title: () => paged.value.title,
|
|
80
|
+
description: () => paged.value.description,
|
|
67
81
|
keywords: () => metadata.value.keywords,
|
|
68
82
|
robots: () => toValue(values.robots),
|
|
69
|
-
ogTitle: () =>
|
|
70
|
-
ogDescription: () =>
|
|
83
|
+
ogTitle: () => paged.value.title,
|
|
84
|
+
ogDescription: () => paged.value.description,
|
|
71
85
|
ogImage: () => storefrontAbsoluteUrl(toValue(values.image), origin),
|
|
72
86
|
ogUrl: () => canonical.value,
|
|
73
87
|
ogType: () => storefrontOpenGraphType(toValue(page)),
|
|
@@ -9,7 +9,7 @@ import type { MaybeRefOrGetter } from '@/lib/orionplace/reactivity.svelte';
|
|
|
9
9
|
import { browser } from '$app/environment';
|
|
10
10
|
import { replaceState } from '$app/navigation';
|
|
11
11
|
import { page as currentPage } from '$app/state';
|
|
12
|
-
import { routeHref } from '@/lib/orionplace/navigation.svelte';
|
|
12
|
+
import { noteShallowAddress, routeHref } from '@/lib/orionplace/navigation.svelte';
|
|
13
13
|
import { useOrionplaceStore } from '@/lib/stores/orionplace.svelte';
|
|
14
14
|
import { useOrionplaceApi } from '@/lib/composables/useOrionplaceApi.svelte';
|
|
15
15
|
import { useOrionplaceProgress } from '@/lib/composables/useOrionplaceProgress.svelte';
|
|
@@ -260,6 +260,7 @@ export function useOrionplaceProductSearch(
|
|
|
260
260
|
}),
|
|
261
261
|
currentPage.state,
|
|
262
262
|
);
|
|
263
|
+
noteShallowAddress();
|
|
263
264
|
}
|
|
264
265
|
|
|
265
266
|
function cancel() {
|
|
@@ -102,6 +102,8 @@ export interface ICustomerSubscription {
|
|
|
102
102
|
cancelled_date: IApiDateTime | null;
|
|
103
103
|
returned_amount: ICurrencyAmounts;
|
|
104
104
|
c_tickets: number;
|
|
105
|
+
/** Partner code a trial remembered, while it waits for the first payment; fill the promo field with it on upgrade. */
|
|
106
|
+
referral_code: string | null;
|
|
105
107
|
renew_period: ICustomerSubscriptionRenewal | null;
|
|
106
108
|
plugin: {
|
|
107
109
|
remote_subscription_last_sync: IApiDateTime | null;
|
|
@@ -6,6 +6,25 @@ import { getContext, setContext, untrack } from 'svelte';
|
|
|
6
6
|
import { computed, ref } from '@/lib/orionplace/reactivity.svelte';
|
|
7
7
|
import type { Ref } from '@/lib/orionplace/reactivity.svelte';
|
|
8
8
|
|
|
9
|
+
// A shallow replaceState (a product list's page and filters) leaves
|
|
10
|
+
// `page.url` as it was, so the address bar is read again after each one.
|
|
11
|
+
const shallowAddress = $state({ version: 0 });
|
|
12
|
+
|
|
13
|
+
/** Call after a shallow replaceState that changed the address. */
|
|
14
|
+
export function noteShallowAddress() {
|
|
15
|
+
shallowAddress.version += 1;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The query string of the address shown (`?locale=en&page=2`), also after a
|
|
20
|
+
* shallow replaceState; reactive in components and computed values.
|
|
21
|
+
*/
|
|
22
|
+
export function currentSearch(): string {
|
|
23
|
+
const fromPage = page.url.search;
|
|
24
|
+
void shallowAddress.version;
|
|
25
|
+
return browser ? window.location.search : fromPage;
|
|
26
|
+
}
|
|
27
|
+
|
|
9
28
|
export function routeHref(target: unknown): string {
|
|
10
29
|
if (typeof target === 'string') {
|
|
11
30
|
return target;
|
|
@@ -10,8 +10,10 @@ import {
|
|
|
10
10
|
blogPageSize,
|
|
11
11
|
blogViewType,
|
|
12
12
|
blogPostsRequest,
|
|
13
|
+
journalPagePastEnd,
|
|
13
14
|
requestBlogPost,
|
|
14
15
|
} from '@/lib/utils/orionplace/blogLayout';
|
|
16
|
+
import type { IBlogJournalPage } from '@/lib/utils/orionplace/blogLayout';
|
|
15
17
|
import type { OrionplaceApi } from '@/lib/utils/orionplace/api';
|
|
16
18
|
import type { OrionplaceSnapshot } from '@/lib/orionplace/appLoadData';
|
|
17
19
|
|
|
@@ -66,6 +68,7 @@ export async function loadStorefrontPageData(
|
|
|
66
68
|
}
|
|
67
69
|
const api = createApi(store);
|
|
68
70
|
const data: Record<string, unknown> = {};
|
|
71
|
+
let journalPastEnd = false;
|
|
69
72
|
const numberId = (value: string | null | undefined) =>
|
|
70
73
|
/^[1-9]\d*$/.test(value ?? '') ? Number(value) : undefined;
|
|
71
74
|
const id = numberId(event.params.id);
|
|
@@ -129,7 +132,7 @@ export async function loadStorefrontPageData(
|
|
|
129
132
|
const pageSize = blogPageSize(
|
|
130
133
|
blogViewType(store.init?.visual_settings?.blog_view_type),
|
|
131
134
|
);
|
|
132
|
-
|
|
135
|
+
const journalPage: IBlogJournalPage = {
|
|
133
136
|
page,
|
|
134
137
|
pageSize,
|
|
135
138
|
data: await api.request(
|
|
@@ -137,6 +140,10 @@ export async function loadStorefrontPageData(
|
|
|
137
140
|
blogPostsRequest({}, page, pageSize),
|
|
138
141
|
),
|
|
139
142
|
};
|
|
143
|
+
data[`orionplace:journal:${page}:${pageSize}`] = journalPage;
|
|
144
|
+
// A page number past the last page has no articles: 404, so search
|
|
145
|
+
// engines drop it instead of indexing an empty list.
|
|
146
|
+
journalPastEnd = journalPagePastEnd(page, journalPage);
|
|
140
147
|
} else if (path.startsWith('/journal/') && id) {
|
|
141
148
|
const post = await requestBlogPost(api, id);
|
|
142
149
|
data[`orionplace:journal-post:${id}`] = { post: post ?? null };
|
|
@@ -158,6 +165,7 @@ export async function loadStorefrontPageData(
|
|
|
158
165
|
? data[`orionplace:tariff:${id}`]
|
|
159
166
|
: undefined;
|
|
160
167
|
if (
|
|
168
|
+
journalPastEnd ||
|
|
161
169
|
(record as { found?: boolean } | undefined)?.found === false ||
|
|
162
170
|
(data[`orionplace:journal-post:${id}`] as { post?: unknown } | undefined)
|
|
163
171
|
?.post === null
|
|
@@ -93,3 +93,16 @@ export function blogCoverInitial(title: string) {
|
|
|
93
93
|
export function blogCoverPattern(postId: number) {
|
|
94
94
|
return Math.abs(postId) % 3;
|
|
95
95
|
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Whether a journal page number lies past the last page: the server render
|
|
99
|
+
* loaded that page (no filters) and it has no articles. Page 1 of an empty
|
|
100
|
+
* journal is a page of its own.
|
|
101
|
+
*/
|
|
102
|
+
export function journalPagePastEnd(
|
|
103
|
+
page: number,
|
|
104
|
+
loaded: IBlogJournalPage | null | undefined,
|
|
105
|
+
): boolean {
|
|
106
|
+
const posts = loaded?.data?.posts?.data;
|
|
107
|
+
return page > 1 && loaded?.page === page && Array.isArray(posts) && !posts.length;
|
|
108
|
+
}
|
|
@@ -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
|
}
|