orionplace-nuxt 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1021,6 +1021,19 @@ replace options, colors, counts or price bounds with narrowed responses. New
1021
1021
  queries, categories and AppLoad refreshes reset that scope; Clear keeps
1022
1022
  its original selectors. Superseded requests are cancelled and ignored.
1023
1023
 
1024
+ Category, search, featured and seller lists keep their result page and
1025
+ filters in the URL as `?page=2&filters={...}` (JSON of the filter selection).
1026
+ The composable replaces the current history entry on each change, so the
1027
+ browser's Back button from a product, and a reload, open the same list, while
1028
+ Back from the list still leaves it in one step. Page loads read the same
1029
+ query through `productListLocationFromQuery()` in
1030
+ `app/utils/orionplace/productListLocation.ts`, which accepts only plain filter
1031
+ values within fixed limits. A list opened this way on a later page or with
1032
+ filters also requests the unfiltered first page once, so the filter panel
1033
+ keeps every choice. Pass `rememberInAddress: false` to keep a list out of the
1034
+ URL. The React and Svelte packages do the same in `useProductSearch()` and
1035
+ `useOrionplaceProductSearch()`.
1036
+
1024
1037
  `SearchFilters` supplies cumulative ID arrays and scalar price, discount and
1025
1038
  rating fields. Currency-keyed `selects.measures.min_price` and `max_price` supply
1026
1039
  the price range; Apply or a committed slider change emits `price_from`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orionplace-nuxt",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Nuxt 4 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",
@@ -1,11 +1,15 @@
1
1
  // Other server code can read event.context.marketplaceDomain for canonical URLs.
2
- import { defineEventHandler, toWebRequest } from 'h3';
2
+ // Pages under ORIONPLACE_NOINDEX_PATHS answer with `X-Robots-Tag: noindex, follow`.
3
+ import { defineEventHandler, getRequestURL, setResponseHeader, toWebRequest } from 'h3';
3
4
  import { useRuntimeConfig } from '#imports';
4
- import { resolveFrontDomain } from './marketplace.mjs';
5
+ import { marketplaceNoindexPath, resolveFrontDomain } from './marketplace.mjs';
5
6
 
6
7
  export default defineEventHandler((event) => {
7
8
  event.context.marketplaceDomain = resolveFrontDomain(
8
9
  toWebRequest(event),
9
10
  process.env.MIMIC_FRONT_DOMAIN ?? useRuntimeConfig(event).mimicFrontDomain,
10
11
  );
12
+ if (marketplaceNoindexPath(getRequestURL(event).pathname)) {
13
+ setResponseHeader(event, 'X-Robots-Tag', 'noindex, follow');
14
+ }
11
15
  });
@@ -329,6 +329,7 @@ export async function forwardMarketplace(request, configuration = {}) {
329
329
  // node_modules/.cache/orionplace/sitemaps/<domain>.json (ORIONPLACE_CACHE_DIR
330
330
  // moves it). "orionplace-<framework> sitemap rebuild" rebuilds that file at
331
331
  // once; the running storefront serves the new file without a restart.
332
+ // Paths in ORIONPLACE_NOINDEX_PATHS are left out when the sitemap is served.
332
333
 
333
334
  export const SITEMAP_LIFETIME = 60 * 60 * 1000;
334
335
  const sitemapPageSize = 100;
@@ -353,6 +354,34 @@ export function marketplacePublicOrigin(request, tenantDomain = '') {
353
354
  return local || !tenantDomain ? url.origin : `https://${tenantDomain}`;
354
355
  }
355
356
 
357
+ /**
358
+ * Pages kept out of search engines: ORIONPLACE_NOINDEX_PATHS lists path
359
+ * prefixes separated by spaces or commas, for example "/catalog /category
360
+ * /product" on a demo or staging storefront. Those pages answer with
361
+ * `X-Robots-Tag: noindex, follow` and the sitemap leaves them out. Empty
362
+ * (the default) keeps every public page indexable.
363
+ */
364
+ export function noindexPathPrefixes(value = environment.ORIONPLACE_NOINDEX_PATHS) {
365
+ return String(value ?? '')
366
+ .split(/[\s,]+/)
367
+ .map((item) => item.trim().replace(/\/+$/, ''))
368
+ .filter((item) => /^\/[A-Za-z0-9._~\/-]+$/.test(item));
369
+ }
370
+
371
+ /** Whether a page path is kept out of search engines (see noindexPathPrefixes). */
372
+ export function marketplaceNoindexPath(pathname, value = environment.ORIONPLACE_NOINDEX_PATHS) {
373
+ const path = String(pathname ?? '');
374
+ return noindexPathPrefixes(value)
375
+ .some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
376
+ }
377
+
378
+ /** The sitemap pages search engines may index. */
379
+ export function sitemapIndexedPages(pages, value = environment.ORIONPLACE_NOINDEX_PATHS) {
380
+ return noindexPathPrefixes(value).length
381
+ ? pages.filter((page) => !marketplaceNoindexPath(page.path, value))
382
+ : pages;
383
+ }
384
+
356
385
  /** @param {string} origin */
357
386
  export function marketplaceRobots(origin) {
358
387
  return `User-agent: *\nDisallow: /api/\nAllow: /\n\nSitemap: ${origin}/sitemap.xml\n`;
@@ -618,7 +647,7 @@ async function buildMarketplaceSitemap(tenant) {
618
647
  /** How many <url> entries a built sitemap has. */
619
648
  export function sitemapUrlCount(sitemap) {
620
649
  let count = 0;
621
- for (const page of sitemap.pages) {
650
+ for (const page of sitemapIndexedPages(sitemap.pages)) {
622
651
  const own = page.locales
623
652
  ? sitemap.locales.filter((code) => page.locales.includes(code))
624
653
  : sitemap.locales;
@@ -776,7 +805,7 @@ export async function marketplaceSitemapResponse(request, configuration = {}) {
776
805
  }
777
806
  }
778
807
  const origin = marketplacePublicOrigin(request, tenant.domain);
779
- return new Response(marketplaceSitemapXml(origin, sitemap.pages, sitemap.locales), {
808
+ return new Response(marketplaceSitemapXml(origin, sitemapIndexedPages(sitemap.pages), sitemap.locales), {
780
809
  headers: {
781
810
  'Content-Type': 'application/xml; charset=utf-8',
782
811
  'Cache-Control': 'public, max-age=3600',
@@ -571,6 +571,12 @@ npx orionplace-nuxt sitemap rebuild shop.example.com # one domain
571
571
  npx orionplace-nuxt sitemap status # when each was built
572
572
  \`\`\`
573
573
 
574
+ To keep a demo or staging storefront's shop pages out of search engines, set
575
+ \`ORIONPLACE_NOINDEX_PATHS\` in \`.env\` to path prefixes separated by spaces,
576
+ for example \`ORIONPLACE_NOINDEX_PATHS="/catalog /category /product"\`. Those
577
+ pages answer with \`X-Robots-Tag: noindex, follow\` and the sitemap leaves them
578
+ out; the journal and other pages stay indexable. Leave it empty on a live shop.
579
+
574
580
  This setup does not add analytics.
575
581
 
576
582
  ## Android app
@@ -157,7 +157,7 @@
157
157
  :disabled="isPending"
158
158
  @click="openBranch(item.id)"
159
159
  >
160
- {{ labels.categories }} →
160
+ {{ labels.categories }} <span aria-hidden="true">→</span>
161
161
  </button>
162
162
  <NuxtLink
163
163
  class="op-feature-link op-feature-button op-feature-primary px-3!"
@@ -166,7 +166,7 @@
166
166
  query: { locale: selectionStore.localeCode },
167
167
  }"
168
168
  >
169
- {{ labels.products }} →
169
+ {{ labels.products }} <span aria-hidden="true">→</span>
170
170
  </NuxtLink>
171
171
  </li>
172
172
  </ul>
@@ -1,7 +1,12 @@
1
1
  import { computed, toValue } from 'vue';
2
2
  import type { MaybeRefOrGetter } from 'vue';
3
- import { useSeoMeta } from '#imports';
4
- import { storefrontPageMetadata } from '@/utils/orionplace/pageSeo';
3
+ import { useHead, useRequestHeader, useRoute, useSeoMeta } from '#imports';
4
+ import {
5
+ storefrontAbsoluteUrl,
6
+ storefrontCanonicalUrl,
7
+ storefrontPageMetadata,
8
+ storefrontPublicOrigin,
9
+ } from '@/utils/orionplace/pageSeo';
5
10
  import type { StorefrontSeoPage } from '@/utils/orionplace/pageSeo';
6
11
 
7
12
  export function useOrionplacePageSeo(
@@ -15,24 +20,48 @@ export function useOrionplacePageSeo(
15
20
  image?: MaybeRefOrGetter<string | undefined>;
16
21
  } = {},
17
22
  ) {
18
- const metadata = computed(() =>
23
+ const route = useRoute();
24
+ // The address the visitor used: the proxy's host during the server render.
25
+ const origin = import.meta.server
26
+ ? storefrontPublicOrigin(
27
+ useRequestHeader('x-forwarded-host') ?? useRequestHeader('host'),
28
+ )
29
+ : window.location.origin;
30
+ const metadata = computed(() =>
19
31
  storefrontPageMetadata(toValue(page), toValue(locale), {
20
32
  title: toValue(values.title),
21
33
  description: toValue(values.description),
22
34
  keywords: toValue(values.keywords),
23
35
  }),
24
36
  );
37
+ const canonical = computed(() =>
38
+ storefrontCanonicalUrl(
39
+ origin,
40
+ route.path,
41
+ new URL(route.fullPath, 'http://storefront').search,
42
+ toValue(locale),
43
+ toValue(values.robots),
44
+ ),
45
+ );
25
46
 
26
47
  // Link previews use the same title and description as the page; the
27
- // image is the record's first picture when the page has one.
28
- const seo = {
48
+ // image is the record's first picture when the page has one, as a
49
+ // complete address.
50
+ const seo = {
29
51
  title: () => metadata.value.title,
30
52
  description: () => metadata.value.description,
31
53
  keywords: () => metadata.value.keywords,
32
54
  robots: () => toValue(values.robots),
33
55
  ogTitle: () => metadata.value.title,
34
56
  ogDescription: () => metadata.value.description,
35
- ogImage: () => toValue(values.image) || undefined,
57
+ ogImage: () => storefrontAbsoluteUrl(toValue(values.image), origin),
58
+ ogUrl: () => canonical.value,
36
59
  };
37
60
  useSeoMeta(seo);
61
+ useHead({
62
+ link: () =>
63
+ canonical.value
64
+ ? [{ key: 'canonical', rel: 'canonical', href: canonical.value }]
65
+ : [],
66
+ });
38
67
  }
@@ -1,5 +1,6 @@
1
1
  import { onMounted, onScopeDispose, ref, toValue, watch } from 'vue';
2
2
  import type { MaybeRefOrGetter } from 'vue';
3
+ import { navigateTo, useRoute } from '#imports';
3
4
  import { useOrionplaceStore } from '@/stores/orionplace';
4
5
  import { useOrionplaceApi } from '@/composables/useOrionplaceApi';
5
6
  import { useOrionplaceProgress } from '@/composables/useOrionplaceProgress';
@@ -19,6 +20,12 @@ import type {
19
20
  } from '@/interfaces/orionplace/products/IProduct';
20
21
  import type { IProductSearchFacets } from '@/interfaces/orionplace/products/IProductSearchFacets';
21
22
  import type { OrionplaceFeatureApi } from '@/composables/useOrionplaceFeature';
23
+ import {
24
+ productListLocationFromQuery,
25
+ productListLocationQuery,
26
+ sameProductListLocation,
27
+ } from '@/utils/orionplace/productListLocation';
28
+ import type { ProductListLocation } from '@/utils/orionplace/productListLocation';
22
29
 
23
30
  export type ProductSearchData = IProductSearchFacets & {
24
31
  products: { data: IProduct[]; rpag?: IProductPagination };
@@ -36,10 +43,18 @@ export type ProductSearchContext = ProductSearchScope & { categoryId?: number };
36
43
  */
37
44
  export interface ProductSearchSnapshot {
38
45
  scope: string;
46
+ // The page and filters it was loaded with; see productListLocation.
47
+ list?: string;
39
48
  productIds: number[];
40
49
  rpag?: IProductPagination;
41
50
  }
42
51
 
52
+ const firstListPage: ProductListLocation = { page: 1, filters: {} };
53
+
54
+ function listSignature(location: ProductListLocation) {
55
+ return JSON.stringify(productListLocationQuery({}, location));
56
+ }
57
+
43
58
  type OrionplaceStore = ReturnType<typeof useOrionplaceStore>;
44
59
 
45
60
  /**
@@ -51,6 +66,7 @@ export function productSearchBody(
51
66
  store: OrionplaceStore,
52
67
  scope: ProductSearchContext,
53
68
  page: number,
69
+ filters = store.searchFilters,
54
70
  ) {
55
71
  const category = scope.categoryId === undefined
56
72
  ? undefined
@@ -65,7 +81,7 @@ export function productSearchBody(
65
81
 
66
82
  if (scope.categoryId !== undefined) {
67
83
  return category
68
- ? categorySearchRequest(category, store.searchFilters, page)
84
+ ? categorySearchRequest(category, filters, page)
69
85
  : undefined;
70
86
  }
71
87
 
@@ -73,7 +89,35 @@ export function productSearchBody(
73
89
  return undefined;
74
90
  }
75
91
 
76
- return scopedSearchRequest(scope, store.searchFilters, page);
92
+ return scopedSearchRequest(scope, filters, page);
93
+ }
94
+
95
+ /**
96
+ * A list opened from its URL (Back, a reload) on a later page or with
97
+ * filters gets only the filtered choices, or none after the first page.
98
+ * Without stored choices for the scope this also asks for the unfiltered
99
+ * first page, so the filter panel keeps every seller, price and option to
100
+ * choose from. The extra request is only for the panel: when it fails the
101
+ * panel shows the choices of the result.
102
+ */
103
+ async function preserveFullSearchFacets(
104
+ store: OrionplaceStore,
105
+ scope: ProductSearchContext,
106
+ page: number,
107
+ requestData: (body: Record<string, unknown>) => Promise<ProductSearchData>,
108
+ ) {
109
+ const needed = page > 1 || Object.keys(store.searchFilters).length > 0;
110
+ const body = needed && !store.searchFacets
111
+ ? productSearchBody(store, scope, 1, {})
112
+ : undefined;
113
+
114
+ if (!body) {
115
+ return;
116
+ }
117
+ const full = await requestData(body).catch(() => undefined);
118
+ if (full && !store.searchFacets) {
119
+ store.preserveSearchFacets(full);
120
+ }
77
121
  }
78
122
 
79
123
  /** Stores the returned products and keeps the scope's first filter choices. */
@@ -91,24 +135,34 @@ export function storeProductSearchPage(
91
135
  }
92
136
 
93
137
  /**
94
- * Loads the first result page during a server render, with the filters
95
- * currently in the store. Returns null when the scope cannot be searched.
138
+ * Loads a result page during a server render: the page and filters of the
139
+ * address (`location`, the first page by default), so a reload shows the
140
+ * list the visitor left. Returns null when the scope cannot be searched.
96
141
  */
97
142
  export async function loadProductSearchSnapshot(
98
143
  api: Pick<OrionplaceFeatureApi, 'request'>,
99
144
  store: OrionplaceStore,
100
145
  scope: ProductSearchContext,
146
+ location: ProductListLocation = firstListPage,
101
147
  ): Promise<ProductSearchSnapshot | null> {
102
- const body = productSearchBody(store, scope, 1);
148
+ store.setSearchFilters(location.filters);
149
+ const body = productSearchBody(store, scope, location.page);
103
150
 
104
151
  if (!body) {
105
152
  return null;
106
153
  }
107
154
 
108
- const data = await api.request<ProductSearchData>('products_search', body);
155
+ // Both requests run together; the full choices are kept first.
156
+ const [data] = await Promise.all([
157
+ api.request<ProductSearchData>('products_search', body),
158
+ preserveFullSearchFacets(store, scope, location.page, (full) =>
159
+ api.request<ProductSearchData>('products_search', full),
160
+ ),
161
+ ]);
109
162
 
110
163
  return {
111
164
  scope: JSON.stringify(scope),
165
+ list: listSignature(location),
112
166
  productIds: storeProductSearchPage(store, data),
113
167
  rpag: data.products.rpag,
114
168
  };
@@ -124,24 +178,41 @@ export function useOrionplaceProductSearch(
124
178
  scrollToTop?: boolean;
125
179
  // The first page from the server render; see useOrionplaceServerData.
126
180
  initial?: ProductSearchSnapshot | null;
181
+ // `false` keeps the page and filters out of the URL.
182
+ rememberInAddress?: boolean;
127
183
  } = {},
128
184
  ) {
129
185
  const store = useOrionplaceStore();
186
+ const route = useRoute();
187
+ const remember = options.rememberInAddress !== false;
130
188
  const api = options.request ? undefined : useOrionplaceApi();
131
189
  const requestData =
132
190
  options.request ??
133
191
  ((body, signal) =>
134
192
  api!.request<ProductSearchData>('products_search', body, signal));
135
- // The server result is used only for the scope it was loaded for.
193
+ // The page and filters in the URL: Back from a product or a reload opens
194
+ // the list the visitor left.
195
+ function addressLocation(): ProductListLocation {
196
+ return remember ? productListLocationFromQuery(route.query) : firstListPage;
197
+ }
198
+ const opening = addressLocation();
199
+ // The server result is used only for the scope and the page and filters
200
+ // it was loaded for.
136
201
  const initial =
137
- options.initial?.scope === JSON.stringify(toValue(context))
202
+ options.initial?.scope === JSON.stringify(toValue(context)) &&
203
+ (options.initial.list ?? listSignature(firstListPage)) ===
204
+ listSignature(opening)
138
205
  ? options.initial
139
206
  : undefined;
207
+ if (initial) {
208
+ // The filter panel shows the filters this result was loaded with.
209
+ store.setSearchFilters(opening.filters);
210
+ }
140
211
  const productIds = ref<number[]>(initial ? [...initial.productIds] : []);
141
212
  const pagination = ref<IProductPagination>(
142
213
  initial ? mergeCategoryPagination({}, initial.rpag) : {},
143
214
  );
144
- const page = ref(1);
215
+ const page = ref(initial ? opening.page : 1);
145
216
  const pending = ref(!initial);
146
217
  const failed = ref(false);
147
218
  const loaded = ref(Boolean(initial));
@@ -150,6 +221,25 @@ export function useOrionplaceProductSearch(
150
221
 
151
222
  useOrionplaceProgress(() => pending.value);
152
223
 
224
+ // Replaces the URL, so Back from the list still leaves it in one step.
225
+ function rememberLocation() {
226
+ const next = { page: page.value, filters: store.searchFilters };
227
+ if (
228
+ !remember ||
229
+ sameProductListLocation(productListLocationFromQuery(route.query), next)
230
+ ) {
231
+ return;
232
+ }
233
+ void navigateTo(
234
+ {
235
+ path: route.path,
236
+ query: productListLocationQuery(route.query, next),
237
+ hash: route.hash,
238
+ },
239
+ { replace: true },
240
+ );
241
+ }
242
+
153
243
  function cancel() {
154
244
  request?.abort();
155
245
  request = undefined;
@@ -171,11 +261,20 @@ export function useOrionplaceProductSearch(
171
261
  pending.value = !mounted;
172
262
  return;
173
263
  }
264
+ rememberLocation();
174
265
  const current = new AbortController();
175
266
  request = current;
176
267
  pending.value = true;
177
268
  try {
178
- const data = await requestData(body, current.signal);
269
+ const [data] = await Promise.all([
270
+ requestData(body, current.signal),
271
+ preserveFullSearchFacets(
272
+ store,
273
+ toValue(context),
274
+ page.value,
275
+ (full) => requestData(full, current.signal),
276
+ ),
277
+ ]);
179
278
  if (current.signal.aborted) {
180
279
  return;
181
280
  }
@@ -202,11 +301,16 @@ export function useOrionplaceProductSearch(
202
301
  store.setSearchFilters({});
203
302
  void load(true);
204
303
  }
304
+ // A new scope starts from the page and filters of its URL: none after a
305
+ // link, the ones the visitor left after Back or a reload.
205
306
  function reset() {
307
+ const location = addressLocation();
206
308
  pagination.value = {};
207
309
  productIds.value = [];
208
310
  store.setSearchFacets(null);
209
- clearFilters();
311
+ store.setSearchFilters(location.filters);
312
+ page.value = location.page;
313
+ void load();
210
314
  }
211
315
  function turnPage(value: number) {
212
316
  if (
@@ -14,6 +14,7 @@
14
14
  import { directoryLabels } from '@/utils/orionplace/directoryLabels';
15
15
  import { storeAddress, storePhoneLink } from '@/utils/orionplace/storeDirectory';
16
16
  import { browseLabels } from '@/utils/orionplace/browseLabels';
17
+ import { productListLocationFromQuery } from '@/utils/orionplace/productListLocation';
17
18
  import {
18
19
  fixedScopeFilters,
19
20
  hasFixedSearchScope,
@@ -63,7 +64,13 @@
63
64
  // browser.
64
65
  const serverResults = await useOrionplaceServerData(
65
66
  `orionplace:search:${JSON.stringify(scope.value)}`,
66
- (api) => loadProductSearchSnapshot(api, store, scope.value),
67
+ (api) =>
68
+ loadProductSearchSnapshot(
69
+ api,
70
+ store,
71
+ scope.value,
72
+ productListLocationFromQuery(route.query),
73
+ ),
67
74
  );
68
75
  const results = useOrionplaceProductSearch(scope, {
69
76
  initial: serverResults.value,
@@ -6,6 +6,7 @@
6
6
  import { useOrionplaceCategoryProducts } from '@/composables/useOrionplaceCategoryProducts';
7
7
  import { useOrionplaceServerData } from '@/composables/useOrionplaceServerData';
8
8
  import { loadProductSearchSnapshot } from '@/composables/useOrionplaceProductSearch';
9
+ import { productListLocationFromQuery } from '@/utils/orionplace/productListLocation';
9
10
  import { useOrionplaceStorefront } from '@/composables/useOrionplaceStorefront';
10
11
  import {
11
12
  getAvailableNestedValueFromObject,
@@ -21,12 +22,19 @@
21
22
  /^\d+$/.test(String(route.params.id)) ? Number(route.params.id) : 0,
22
23
  );
23
24
  const category = computed(() => store.getCatalogCategory(id.value));
24
- // The server render loads the first result page with the visitor's
25
- // cookies, so the page HTML lists the category's products and prices.
26
- // Filters and further pages load in the browser.
25
+ // The server render loads the result page of the URL (page and filters,
26
+ // the first page by default) with the visitor's cookies, so the page HTML
27
+ // lists the category's products and prices. Later changes load in the
28
+ // browser.
27
29
  const serverResults = await useOrionplaceServerData(
28
30
  `orionplace:category:${id.value}`,
29
- (api) => loadProductSearchSnapshot(api, store, { categoryId: id.value }),
31
+ (api) =>
32
+ loadProductSearchSnapshot(
33
+ api,
34
+ store,
35
+ { categoryId: id.value },
36
+ productListLocationFromQuery(route.query),
37
+ ),
30
38
  );
31
39
  const results = useOrionplaceCategoryProducts(
32
40
  id,
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue';
3
- import { navigateTo } from '#imports';
3
+ import { navigateTo, useRoute } from '#imports';
4
4
  import { NuxtLink } from '#components';
5
5
  import { useOrionplacePageSeo } from '@/composables/useOrionplacePageSeo';
6
6
  import { useOrionplaceStore } from '@/stores/orionplace';
@@ -10,15 +10,23 @@
10
10
  import { useOrionplaceStorefront } from '@/composables/useOrionplaceStorefront';
11
11
  import { featuredLabels } from '@/utils/orionplace/featuredLabels';
12
12
  import { browseLabels } from '@/utils/orionplace/browseLabels';
13
+ import { productListLocationFromQuery } from '@/utils/orionplace/productListLocation';
13
14
  import ProductSearchResults from '@/components/orionplace/search/ProductSearchResults.vue';
14
15
  import OrionplaceEmptyState from '@/components/orionplace/shared/OrionplaceEmptyState.vue';
15
16
 
16
17
  const store = useOrionplaceStore();
18
+ const route = useRoute();
17
19
  // Featured products depend on the signed-in customer's searches: the
18
- // server render loads the first page with the visitor's cookies.
20
+ // server render loads the page of the URL with the visitor's cookies.
19
21
  const serverResults = await useOrionplaceServerData(
20
22
  'orionplace:featured',
21
- (api) => loadProductSearchSnapshot(api, store, { featured: true }),
23
+ (api) =>
24
+ loadProductSearchSnapshot(
25
+ api,
26
+ store,
27
+ { featured: true },
28
+ productListLocationFromQuery(route.query),
29
+ ),
22
30
  );
23
31
  const results = useOrionplaceFeaturedProducts(
24
32
  serverResults.value,
@@ -282,3 +282,71 @@ export function storefrontPageMetadata(
282
282
  keywords: plainText(values.keywords ?? '') || `${title}, ${copy[2]}`,
283
283
  };
284
284
  }
285
+
286
+ /*
287
+ * Addresses for search engines and link previews. The public origin is the
288
+ * storefront host the visitor used (the proxy's Host or X-Forwarded-Host),
289
+ * over HTTPS; a local host keeps HTTP for development.
290
+ */
291
+ export function storefrontPublicOrigin(host: string | null | undefined): string {
292
+ const name = (host ?? '').split(',')[0]!.trim().toLowerCase();
293
+ if (!/^(?:[a-z0-9-]+(?:\.[a-z0-9-]+)*|\[[0-9a-f:]+\])(?::\d{1,5})?$/.test(name)) {
294
+ return '';
295
+ }
296
+ const local = /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?$/.test(name);
297
+ return `${local ? 'http' : 'https'}://${name}`;
298
+ }
299
+
300
+ /**
301
+ * A complete address for a link preview image: a storefront path such as
302
+ * `/ps3/blog/posts/images/5` gets the public origin; without an origin it is
303
+ * left out, since a relative og:image is not loaded by link previews.
304
+ */
305
+ export function storefrontAbsoluteUrl(
306
+ value: string | null | undefined,
307
+ origin: string,
308
+ ): string | undefined {
309
+ if (!value) {
310
+ return undefined;
311
+ }
312
+ try {
313
+ const url = origin ? new URL(value, `${origin}/`) : new URL(value);
314
+ return /^https?:$/.test(url.protocol) ? url.toString() : undefined;
315
+ } catch {
316
+ return undefined;
317
+ }
318
+ }
319
+
320
+ // The pages the sitemap lists (runtime/marketplace.mjs), plus plan pages.
321
+ const indexedPage = /^\/(?:|catalog|journal|tariffs|manufacturers|sellers|stores|contact|privacy|(?:category|product|journal)\/\d+|tariffs\/[^/]+\/\d+)$/;
322
+
323
+ /**
324
+ * The canonical address of a page: the sitemap's address for it, the page in
325
+ * the language it shows (`?locale=`). Pages that are not listed, pages with
326
+ * other query parameters (filters, page numbers, private keys) and noindex
327
+ * pages get none.
328
+ */
329
+ export function storefrontCanonicalUrl(
330
+ origin: string,
331
+ pathname: string,
332
+ search: string,
333
+ locale: string,
334
+ robots?: string,
335
+ ): string | undefined {
336
+ const path = pathname.replace(/\/+$/, '') || '/';
337
+ const query = new URLSearchParams(search);
338
+ query.delete('locale');
339
+ if (
340
+ !origin ||
341
+ /noindex/i.test(robots ?? '') ||
342
+ !indexedPage.test(path) ||
343
+ [...query.keys()].length
344
+ ) {
345
+ return undefined;
346
+ }
347
+ const url = new URL(path, `${origin}/`);
348
+ if (locale) {
349
+ url.searchParams.set('locale', locale);
350
+ }
351
+ return url.toString();
352
+ }
@@ -0,0 +1,130 @@
1
+ import type { ProductFacetSelection } from '@/interfaces/orionplace/products/IProductSearchFacets';
2
+
3
+ // Where a product list is: its result page and the chosen filters. Category,
4
+ // search, seller and featured lists keep it in the URL as `?page&filters`, so
5
+ // a reload and the browser's Back button from a product return to the same
6
+ // list. `filters` is the filter selection as JSON; the address is visitor
7
+ // input, so only plain filter values within the limits below are read.
8
+ export interface ProductListLocation {
9
+ page: number;
10
+ filters: ProductFacetSelection;
11
+ }
12
+
13
+ type QueryValue = string | null | undefined | readonly (string | null)[];
14
+
15
+ const locationKeys = ['page', 'filters'];
16
+ const maxPage = 10000;
17
+ const maxFiltersText = 4000;
18
+ const maxFilterKeys = 40;
19
+ const maxFilterIds = 200;
20
+ const maxFilterString = 100;
21
+ const filterKey = /^[a-z][a-z0-9_]{0,63}$/;
22
+
23
+ function first(value: QueryValue) {
24
+ return Array.isArray(value) ? value[0] : (value as string | null | undefined);
25
+ }
26
+
27
+ function filterValue(value: unknown): ProductFacetSelection[string] {
28
+ if (Array.isArray(value)) {
29
+ const ids = value.filter(
30
+ (id): id is number => Number.isSafeInteger(id) && id >= 0,
31
+ );
32
+
33
+ return ids.length === value.length && ids.length && ids.length <= maxFilterIds
34
+ ? ids
35
+ : undefined;
36
+ }
37
+ if (typeof value === 'string') {
38
+ return value && value.length <= maxFilterString ? value : undefined;
39
+ }
40
+ if (typeof value === 'number') {
41
+ return Number.isFinite(value) ? value : undefined;
42
+ }
43
+
44
+ // `false` chooses nothing, as an empty list or text.
45
+ return value === true ? true : undefined;
46
+ }
47
+
48
+ export function productListFiltersFromText(
49
+ text: string | null | undefined,
50
+ ): ProductFacetSelection {
51
+ if (!text || text.length > maxFiltersText) {
52
+ return {};
53
+ }
54
+ let parsed: unknown;
55
+ try {
56
+ parsed = JSON.parse(text);
57
+ } catch {
58
+ return {};
59
+ }
60
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
61
+ return {};
62
+ }
63
+ const result: ProductFacetSelection = {};
64
+ for (const [key, value] of Object.entries(parsed).slice(0, maxFilterKeys)) {
65
+ const checked = filterKey.test(key) ? filterValue(value) : undefined;
66
+ if (checked !== undefined) {
67
+ result[key] = checked;
68
+ }
69
+ }
70
+
71
+ return result;
72
+ }
73
+
74
+ // The filters as URL text, or undefined when nothing is chosen.
75
+ // Keys are sorted, so the same choice always gives the same text.
76
+ export function productListFiltersText(filters: ProductFacetSelection) {
77
+ const chosen = productListFiltersFromText(JSON.stringify(filters));
78
+ const keys = Object.keys(chosen).sort();
79
+
80
+ return keys.length
81
+ ? JSON.stringify(Object.fromEntries(keys.map((key) => [key, chosen[key]])))
82
+ : undefined;
83
+ }
84
+
85
+ export function productListLocationFromQuery(
86
+ query: Record<string, QueryValue>,
87
+ ): ProductListLocation {
88
+ const page = Number(first(query.page));
89
+
90
+ return {
91
+ page: Number.isSafeInteger(page) && page > 0 && page <= maxPage ? page : 1,
92
+ filters: productListFiltersFromText(first(query.filters)),
93
+ };
94
+ }
95
+
96
+ // Compares the URL form, so the same choice in another key order is equal.
97
+ export function sameProductListLocation(
98
+ left: ProductListLocation,
99
+ right: ProductListLocation,
100
+ ) {
101
+ return (
102
+ left.page === right.page &&
103
+ productListFiltersText(left.filters) === productListFiltersText(right.filters)
104
+ );
105
+ }
106
+
107
+ // The query of `next`: other parameters such as `locale` or `q` stay, and the
108
+ // first page without filters adds nothing.
109
+ export function productListLocationQuery(
110
+ query: Record<string, QueryValue>,
111
+ next: ProductListLocation,
112
+ ) {
113
+ const result: Record<string, string> = {};
114
+
115
+ for (const [key, value] of Object.entries(query)) {
116
+ const text = first(value);
117
+ if (typeof text === 'string' && !locationKeys.includes(key)) {
118
+ result[key] = text;
119
+ }
120
+ }
121
+ if (next.page > 1) {
122
+ result.page = String(next.page);
123
+ }
124
+ const filters = productListFiltersText(next.filters);
125
+ if (filters) {
126
+ result.filters = filters;
127
+ }
128
+
129
+ return result;
130
+ }