orionplace-svelte 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/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 +49 -7
- package/templates/src/lib/composables/useOrionplaceProductSearch.svelte.ts +2 -1
- package/templates/src/lib/layouts/StorefrontPageHead.svelte +15 -3
- package/templates/src/lib/orionplace/appLoadClient.ts +5 -0
- package/templates/src/lib/orionplace/appLoadResult.ts +10 -0
- package/templates/src/lib/orionplace/appLoadServer.ts +23 -4
- package/templates/src/lib/orionplace/navigation.svelte.ts +32 -5
- 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 +318 -8
- package/templates/src/routes/+layout.svelte +4 -1
- package/templates/src/routes/journal/[id]/+page.svelte +36 -0
package/README.md
CHANGED
|
@@ -519,6 +519,9 @@ data and actions; `orionplace/COMPONENTS.md` lists their customer paths.
|
|
|
519
519
|
Page titles, descriptions and link-preview metadata follow the current page
|
|
520
520
|
and selected language, including client navigation. Public events and banner
|
|
521
521
|
images come from Application Load.
|
|
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
|
+
|
|
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.
|
|
522
525
|
|
|
523
526
|
## Application Load starter
|
|
524
527
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orionplace-svelte",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
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,11 +2,16 @@ 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
|
+
import { useOrionplaceStore } from '@/lib/stores/orionplace.svelte';
|
|
6
7
|
import {
|
|
7
8
|
storefrontAbsoluteUrl,
|
|
9
|
+
storefrontAlternateLinks,
|
|
8
10
|
storefrontCanonicalUrl,
|
|
11
|
+
storefrontOpenGraphLocale,
|
|
12
|
+
storefrontOpenGraphType,
|
|
9
13
|
storefrontPageMetadata,
|
|
14
|
+
storefrontPagedText,
|
|
10
15
|
storefrontPublicOrigin,
|
|
11
16
|
} from '@/lib/utils/orionplace/pageSeo';
|
|
12
17
|
import type { StorefrontSeoPage } from '@/lib/utils/orionplace/pageSeo';
|
|
@@ -21,6 +26,9 @@ export function useOrionplacePageSeo(
|
|
|
21
26
|
keywords?: MaybeRefOrGetter<string | undefined>;
|
|
22
27
|
robots?: MaybeRefOrGetter<string | undefined>;
|
|
23
28
|
image?: MaybeRefOrGetter<string | undefined>;
|
|
29
|
+
/** A journal article's dates (ISO 8601) for article:published_time. */
|
|
30
|
+
published?: MaybeRefOrGetter<string | undefined>;
|
|
31
|
+
modified?: MaybeRefOrGetter<string | undefined>;
|
|
24
32
|
/**
|
|
25
33
|
* The page's schema.org data (a record page, e.g. the product), built
|
|
26
34
|
* from its canonical address and the visitor's origin. A page without a
|
|
@@ -29,6 +37,7 @@ export function useOrionplacePageSeo(
|
|
|
29
37
|
structuredData?: (canonical: string, origin: string) => unknown;
|
|
30
38
|
} = {},
|
|
31
39
|
) {
|
|
40
|
+
const store = useOrionplaceStore();
|
|
32
41
|
// The address the visitor used: the proxy's host during the server render.
|
|
33
42
|
const origin = browser
|
|
34
43
|
? window.location.origin
|
|
@@ -44,25 +53,49 @@ export function useOrionplacePageSeo(
|
|
|
44
53
|
storefrontCanonicalUrl(
|
|
45
54
|
origin,
|
|
46
55
|
currentPage.url.pathname,
|
|
47
|
-
|
|
56
|
+
currentSearch(),
|
|
48
57
|
toValue(locale),
|
|
49
58
|
toValue(values.robots),
|
|
50
59
|
),
|
|
51
60
|
);
|
|
52
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
|
+
|
|
53
75
|
// Link previews use the same title and description as the page; the
|
|
54
76
|
// image is the record's first picture when the page has one, as a
|
|
55
77
|
// complete address.
|
|
56
78
|
const seo = {
|
|
57
|
-
title: () =>
|
|
58
|
-
description: () =>
|
|
79
|
+
title: () => paged.value.title,
|
|
80
|
+
description: () => paged.value.description,
|
|
59
81
|
keywords: () => metadata.value.keywords,
|
|
60
82
|
robots: () => toValue(values.robots),
|
|
61
|
-
ogTitle: () =>
|
|
62
|
-
ogDescription: () =>
|
|
83
|
+
ogTitle: () => paged.value.title,
|
|
84
|
+
ogDescription: () => paged.value.description,
|
|
63
85
|
ogImage: () => storefrontAbsoluteUrl(toValue(values.image), origin),
|
|
64
86
|
ogUrl: () => canonical.value,
|
|
87
|
+
ogType: () => storefrontOpenGraphType(toValue(page)),
|
|
88
|
+
ogLocale: () => storefrontOpenGraphLocale(toValue(locale)),
|
|
89
|
+
articlePublishedTime: () => toValue(values.published),
|
|
90
|
+
articleModifiedTime: () => toValue(values.modified),
|
|
65
91
|
};
|
|
92
|
+
// The page in the storefront's other languages, beside the canonical one.
|
|
93
|
+
const alternates = computed(() =>
|
|
94
|
+
storefrontAlternateLinks(
|
|
95
|
+
canonical.value,
|
|
96
|
+
Object.keys(store.init?.locale ?? {}),
|
|
97
|
+
),
|
|
98
|
+
);
|
|
66
99
|
const structured = computed(() =>
|
|
67
100
|
canonical.value && values.structuredData
|
|
68
101
|
? structuredDataJson(values.structuredData(canonical.value, origin))
|
|
@@ -70,7 +103,16 @@ export function useOrionplacePageSeo(
|
|
|
70
103
|
);
|
|
71
104
|
useSeoMeta(seo);
|
|
72
105
|
useHead(() => ({
|
|
73
|
-
link: canonical.value
|
|
106
|
+
link: canonical.value
|
|
107
|
+
? [
|
|
108
|
+
{ rel: 'canonical', href: canonical.value },
|
|
109
|
+
...alternates.value.map((alternate) => ({
|
|
110
|
+
rel: 'alternate',
|
|
111
|
+
hreflang: alternate.hreflang,
|
|
112
|
+
href: alternate.href,
|
|
113
|
+
})),
|
|
114
|
+
]
|
|
115
|
+
: [],
|
|
74
116
|
jsonLd: structured.value || undefined,
|
|
75
117
|
}));
|
|
76
118
|
}
|
|
@@ -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() {
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { PageHead } from '@/lib/orionplace/navigation.svelte';
|
|
3
|
+
import { storefrontIconLinks } from '@/lib/utils/orionplace/pageSeo';
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
// `icon`: the store's icon from App Load (logos.ico).
|
|
6
|
+
let { metadata, icon }: { metadata: PageHead; icon?: string | null } =
|
|
7
|
+
$props();
|
|
8
|
+
const iconLinks = $derived(storefrontIconLinks(icon));
|
|
5
9
|
|
|
6
10
|
// The JSON is escaped by structuredDataJson, so it cannot close the tag.
|
|
7
11
|
const scriptOpen = '<script type="application/ld+json">';
|
|
@@ -13,7 +17,7 @@
|
|
|
13
17
|
|
|
14
18
|
{#each metadata.meta ?? [] as meta}
|
|
15
19
|
{#if meta.content}
|
|
16
|
-
{#if meta.property || meta.name
|
|
20
|
+
{#if meta.property || /^(?:og|article):/.test(meta.name ?? '')}
|
|
17
21
|
<meta
|
|
18
22
|
property={meta.property ?? meta.name}
|
|
19
23
|
content={meta.content}
|
|
@@ -31,12 +35,20 @@
|
|
|
31
35
|
{@html scriptOpen + metadata.jsonLd + scriptClose}
|
|
32
36
|
{/if}
|
|
33
37
|
|
|
34
|
-
{#each metadata.link ?? [] as link (link.rel)}
|
|
38
|
+
{#each metadata.link ?? [] as link (`${link.rel} ${link.hreflang ?? ''}`)}
|
|
35
39
|
{#if link.href}
|
|
36
40
|
<link
|
|
37
41
|
rel={link.rel}
|
|
42
|
+
hreflang={link.hreflang}
|
|
38
43
|
href={link.href}
|
|
39
44
|
/>
|
|
40
45
|
{/if}
|
|
41
46
|
{/each}
|
|
47
|
+
|
|
48
|
+
{#each iconLinks as link (link.key)}
|
|
49
|
+
<link
|
|
50
|
+
rel={link.rel}
|
|
51
|
+
href={link.href}
|
|
52
|
+
/>
|
|
53
|
+
{/each}
|
|
42
54
|
</svelte:head>
|
|
@@ -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,6 +1,10 @@
|
|
|
1
1
|
import { env } from '$env/dynamic/private';
|
|
2
2
|
import { forwardMarketplace } from '@orionplace-runtime/marketplace.mjs';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
appLoadRejectedLocale,
|
|
5
|
+
appLoadResult,
|
|
6
|
+
inspectAppLoad,
|
|
7
|
+
} from '@/lib/orionplace/appLoadResult';
|
|
4
8
|
import type { OrionplaceSnapshot } from '@/lib/orionplace/appLoadData';
|
|
5
9
|
import type { AppLoadRequest } from '@/lib/interfaces/orionplace/transport';
|
|
6
10
|
import type { Cookies, RequestEvent } from '@sveltejs/kit';
|
|
@@ -20,9 +24,13 @@ const FORWARDED = [
|
|
|
20
24
|
'x-tz',
|
|
21
25
|
];
|
|
22
26
|
|
|
23
|
-
function buildRequest(
|
|
27
|
+
function buildRequest(
|
|
28
|
+
event: RequestEvent,
|
|
29
|
+
signal: AbortSignal,
|
|
30
|
+
withLocale = true,
|
|
31
|
+
): Request {
|
|
24
32
|
const url = new URL('/api/appload', event.url.origin);
|
|
25
|
-
const locale = event.url.searchParams.get('locale');
|
|
33
|
+
const locale = withLocale ? event.url.searchParams.get('locale') : null;
|
|
26
34
|
if (locale) {
|
|
27
35
|
url.searchParams.set('lang', locale);
|
|
28
36
|
}
|
|
@@ -112,13 +120,14 @@ export interface ServerAppLoad extends OrionplaceSnapshot {
|
|
|
112
120
|
export async function loadOrionplaceAppLoad(
|
|
113
121
|
event: RequestEvent,
|
|
114
122
|
signal?: AbortSignal,
|
|
123
|
+
withLocale = true,
|
|
115
124
|
): Promise<ServerAppLoad> {
|
|
116
125
|
const timeout = AbortSignal.timeout(35_000);
|
|
117
126
|
const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
118
127
|
let response: Response;
|
|
119
128
|
try {
|
|
120
129
|
response = await forwardMarketplace(
|
|
121
|
-
buildRequest(event, combined),
|
|
130
|
+
buildRequest(event, combined, withLocale),
|
|
122
131
|
{
|
|
123
132
|
mimicFrontDomain: env.MIMIC_FRONT_DOMAIN ?? '',
|
|
124
133
|
},
|
|
@@ -140,6 +149,16 @@ export async function loadOrionplaceAppLoad(
|
|
|
140
149
|
body = null;
|
|
141
150
|
}
|
|
142
151
|
const failure = inspectAppLoad(body, response.status);
|
|
152
|
+
// An address with a language the storefront does not have (`?locale=xx`)
|
|
153
|
+
// opens in the storefront's default language instead of the 503 page.
|
|
154
|
+
if (
|
|
155
|
+
failure &&
|
|
156
|
+
withLocale &&
|
|
157
|
+
event.url.searchParams.get('locale') &&
|
|
158
|
+
appLoadRejectedLocale(failure)
|
|
159
|
+
) {
|
|
160
|
+
return loadOrionplaceAppLoad(event, signal, false);
|
|
161
|
+
}
|
|
143
162
|
if (failure) {
|
|
144
163
|
return { result: null, failure, cookies };
|
|
145
164
|
}
|
|
@@ -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;
|
|
@@ -103,8 +122,9 @@ const headKey = Symbol('orionplace-head');
|
|
|
103
122
|
export interface PageHead {
|
|
104
123
|
title?: string;
|
|
105
124
|
meta?: Record<string, any>[];
|
|
106
|
-
// <link> tags, one per `rel` (the canonical address
|
|
107
|
-
|
|
125
|
+
// <link> tags, one per `rel` and `hreflang` (the canonical address and
|
|
126
|
+
// the page in other languages).
|
|
127
|
+
link?: { rel: string; href: string; hreflang?: string }[];
|
|
108
128
|
// The page's schema.org data as JSON (structuredDataJson), if it has any.
|
|
109
129
|
jsonLd?: string;
|
|
110
130
|
}
|
|
@@ -114,7 +134,7 @@ function collectHead(readers: HeadReader[]): PageHead {
|
|
|
114
134
|
let title: string | undefined;
|
|
115
135
|
let jsonLd: string | undefined;
|
|
116
136
|
const meta = new Map<string, Record<string, any>>();
|
|
117
|
-
const link = new Map<string, { rel: string; href: string }>();
|
|
137
|
+
const link = new Map<string, { rel: string; href: string; hreflang?: string }>();
|
|
118
138
|
for (const read of readers) {
|
|
119
139
|
const head = read();
|
|
120
140
|
title = head.title ?? title;
|
|
@@ -126,7 +146,7 @@ function collectHead(readers: HeadReader[]): PageHead {
|
|
|
126
146
|
}
|
|
127
147
|
}
|
|
128
148
|
for (const tag of head.link ?? []) {
|
|
129
|
-
link.set(tag.rel
|
|
149
|
+
link.set(`${tag.rel} ${tag.hreflang ?? ''}`, tag);
|
|
130
150
|
}
|
|
131
151
|
}
|
|
132
152
|
return {
|
|
@@ -180,6 +200,13 @@ export function useHead(source: PageHead | (() => PageHead)) {
|
|
|
180
200
|
return () => untrack(() => registry.remove(read));
|
|
181
201
|
});
|
|
182
202
|
}
|
|
203
|
+
// The other Open Graph names the pages set (useSeoMeta keys, as in Nuxt).
|
|
204
|
+
const seoMetaNames: Record<string, string> = {
|
|
205
|
+
ogType: 'og:type',
|
|
206
|
+
ogLocale: 'og:locale',
|
|
207
|
+
articlePublishedTime: 'article:published_time',
|
|
208
|
+
articleModifiedTime: 'article:modified_time',
|
|
209
|
+
};
|
|
183
210
|
export function useSeoMeta(source: Record<string, any>) {
|
|
184
211
|
useHead(() => ({
|
|
185
212
|
title: typeof source.title === 'function' ? source.title() : source.title,
|
|
@@ -195,7 +222,7 @@ export function useSeoMeta(source: Record<string, any>) {
|
|
|
195
222
|
? 'og:image'
|
|
196
223
|
: name === 'ogUrl'
|
|
197
224
|
? 'og:url'
|
|
198
|
-
: name,
|
|
225
|
+
: (seoMetaNames[name] ?? name),
|
|
199
226
|
content: typeof value === 'function' ? value() : value,
|
|
200
227
|
})),
|
|
201
228
|
}));
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -134,7 +134,10 @@
|
|
|
134
134
|
</OrionplaceThemeProvider>
|
|
135
135
|
|
|
136
136
|
<!-- Render after the page registers its metadata during server rendering. -->
|
|
137
|
-
<StorefrontPageHead
|
|
137
|
+
<StorefrontPageHead
|
|
138
|
+
metadata={metadata.value}
|
|
139
|
+
icon={marketplace.init?.logos?.ico}
|
|
140
|
+
/>
|
|
138
141
|
|
|
139
142
|
<style>
|
|
140
143
|
.storefront-progress {
|
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
import { useOrionplaceStorefront } from '@/lib/composables/useOrionplaceStorefront.svelte';
|
|
8
8
|
import { useOrionplaceServerData } from '@/lib/composables/useOrionplaceServerData.svelte';
|
|
9
9
|
import { requestBlogPost } from '@/lib/utils/orionplace/blogLayout';
|
|
10
|
+
import {
|
|
11
|
+
storefrontAbsoluteUrl,
|
|
12
|
+
storefrontArticleData,
|
|
13
|
+
storefrontIsoDate,
|
|
14
|
+
} from '@/lib/utils/orionplace/pageSeo';
|
|
10
15
|
import {
|
|
11
16
|
getAvailableNestedValueFromObject,
|
|
12
17
|
plainText,
|
|
@@ -77,6 +82,37 @@
|
|
|
77
82
|
),
|
|
78
83
|
).slice(0, 160),
|
|
79
84
|
image: () => cover.value,
|
|
85
|
+
published: () => storefrontIsoDate(post.value?.date_created?.date),
|
|
86
|
+
modified: () =>
|
|
87
|
+
storefrontIsoDate(post.value?.date_updated?.date) ??
|
|
88
|
+
storefrontIsoDate(post.value?.date_created?.date),
|
|
89
|
+
// The article as schema.org BlogPosting, with the store as its author.
|
|
90
|
+
structuredData: (canonical, origin) =>
|
|
91
|
+
post.value
|
|
92
|
+
? storefrontArticleData({
|
|
93
|
+
canonical,
|
|
94
|
+
origin,
|
|
95
|
+
headline:
|
|
96
|
+
getAvailableNestedValueFromObject(
|
|
97
|
+
post.value.locale,
|
|
98
|
+
store.localeCode,
|
|
99
|
+
'post_title',
|
|
100
|
+
) ?? '',
|
|
101
|
+
description:
|
|
102
|
+
plainText(
|
|
103
|
+
getAvailableNestedValueFromObject(
|
|
104
|
+
post.value.locale,
|
|
105
|
+
store.localeCode,
|
|
106
|
+
'post_preview_text',
|
|
107
|
+
),
|
|
108
|
+
).slice(0, 160) || undefined,
|
|
109
|
+
image: storefrontAbsoluteUrl(cover.value, origin),
|
|
110
|
+
published: storefrontIsoDate(post.value.date_created?.date),
|
|
111
|
+
modified: storefrontIsoDate(post.value.date_updated?.date),
|
|
112
|
+
locale: store.localeCode,
|
|
113
|
+
storeName: store.init?.contact?.company_name,
|
|
114
|
+
})
|
|
115
|
+
: undefined,
|
|
80
116
|
});
|
|
81
117
|
|
|
82
118
|
// An invalid ID, or an article that blog_posts_get does not return,
|