orionplace-nuxt 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 CHANGED
@@ -509,6 +509,8 @@ through `useHead()`. Profile and logout pages remain excluded from indexing;
509
509
  search results retain their `noindex, follow` policy.
510
510
  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.
511
511
 
512
+ 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.
513
+
512
514
  ## Documentation
513
515
 
514
516
  - [Create a Nuxt frontend](https://orionmarket.place/en/sandbox/new-project/nuxt)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orionplace-nuxt",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
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",
@@ -618,6 +618,7 @@
618
618
  <StorefrontPagination
619
619
  :pagination="pagination"
620
620
  :pending="Boolean(task.state.pending)"
621
+ linked
621
622
  @change="changePage"
622
623
  />
623
624
  </div>
@@ -1,10 +1,12 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref, watch } from 'vue';
3
+ import { useRoute } from '#imports';
3
4
  import { useOrionplaceStableHeight } from '@/composables/useOrionplaceStableHeight';
4
5
  import { useOrionplaceBrowseStore } from '@/stores/orionplaceBrowse';
5
6
  import { useOrionplaceSelectionsStore } from '@/stores/orionplaceSelections';
6
7
  import { browseLabels } from '@/utils/orionplace/browseLabels';
7
8
  import { hasSearchFilters } from '@/utils/orionplace/searchFilters';
9
+ import { storefrontPageHref } from '@/utils/orionplace/pageSeo';
8
10
  import SearchFilters from '@/components/orionplace/search/SearchFilters.vue';
9
11
  import ProductGroup from '@/components/orionplace/products/ProductGroup.vue';
10
12
  import type { ProductFacetSelection } from '@/interfaces/orionplace/products/IProductSearchFacets';
@@ -95,6 +97,38 @@
95
97
  }
96
98
  });
97
99
  const labels = computed(() => browseLabels(selectionStore.localeCode));
100
+ // Every list of this component keeps its page in the address (`?page=`),
101
+ // so previous and next are links search engines follow; a click changes
102
+ // the page in place.
103
+ const route = useRoute();
104
+ const search = computed(
105
+ () => new URL(route.fullPath, 'http://storefront').search,
106
+ );
107
+ const previousHref = computed(() =>
108
+ !props.pending && props.page > 1
109
+ ? storefrontPageHref(route.path, search.value, props.page - 1)
110
+ : undefined,
111
+ );
112
+ const nextHref = computed(() =>
113
+ !props.pending && !props.failed && props.hasNext
114
+ ? storefrontPageHref(route.path, search.value, props.page + 1)
115
+ : undefined,
116
+ );
117
+
118
+ // A link opened in a new tab or window keeps its default.
119
+ function followPage(event: MouseEvent, value: number) {
120
+ if (
121
+ event.button !== 0 ||
122
+ event.metaKey ||
123
+ event.ctrlKey ||
124
+ event.shiftKey ||
125
+ event.altKey
126
+ ) {
127
+ return;
128
+ }
129
+ event.preventDefault();
130
+ emit('changePage', value);
131
+ }
98
132
  const filtersOpen = ref(false);
99
133
  const hasFilters = computed(() =>
100
134
  hasSearchFilters(
@@ -410,22 +444,29 @@
410
444
  "
411
445
  data-search-pagination
412
446
  >
413
- <button
414
- type="button"
415
- :disabled="pending || page === 1"
447
+ <component
448
+ :is="previousHref ? 'a' : 'button'"
449
+ :type="previousHref ? undefined : 'button'"
450
+ :href="previousHref"
451
+ :rel="previousHref ? 'prev' : undefined"
452
+ :disabled="previousHref ? undefined : pending || page === 1"
416
453
  class="
417
454
  inline-flex h-11 items-center justify-center px-5
418
455
  [font-family:inherit] text-xs font-medium uppercase tracking-[0.08em]
419
- text-orionplace-text
456
+ text-orionplace-text no-underline
420
457
  border border-solid border-orionplace-border bg-transparent
421
458
  cursor-pointer outline-none
422
459
  hover:border-orionplace-text focus-visible:border-orionplace-text
423
460
  disabled:cursor-not-allowed disabled:opacity-40
424
461
  "
425
- @click="emit('changePage', page - 1)"
462
+ @click="
463
+ previousHref
464
+ ? followPage($event, page - 1)
465
+ : emit('changePage', page - 1)
466
+ "
426
467
  >
427
468
  ← {{ labels.previous }}
428
- </button>
469
+ </component>
429
470
  <span
430
471
  class="
431
472
  text-xs uppercase tracking-[0.08em] tabular-nums
@@ -435,22 +476,29 @@
435
476
  {{ labels.page }} {{ page }}
436
477
  <template v-if="pages">{{ labels.of }} {{ pages }}</template>
437
478
  </span>
438
- <button
439
- type="button"
440
- :disabled="pending || failed || !hasNext"
479
+ <component
480
+ :is="nextHref ? 'a' : 'button'"
481
+ :type="nextHref ? undefined : 'button'"
482
+ :href="nextHref"
483
+ :rel="nextHref ? 'next' : undefined"
484
+ :disabled="nextHref ? undefined : pending || failed || !hasNext"
441
485
  class="
442
486
  inline-flex h-11 items-center justify-center px-5
443
487
  [font-family:inherit] text-xs font-medium uppercase tracking-[0.08em]
444
- text-orionplace-text
488
+ text-orionplace-text no-underline
445
489
  border border-solid border-orionplace-border bg-transparent
446
490
  cursor-pointer outline-none
447
491
  hover:border-orionplace-text focus-visible:border-orionplace-text
448
492
  disabled:cursor-not-allowed disabled:opacity-40
449
493
  "
450
- @click="emit('changePage', page + 1)"
494
+ @click="
495
+ nextHref
496
+ ? followPage($event, page + 1)
497
+ : emit('changePage', page + 1)
498
+ "
451
499
  >
452
500
  {{ labels.next }} →
453
- </button>
501
+ </component>
454
502
  </nav>
455
503
  </div>
456
504
  </section>
@@ -40,6 +40,11 @@ pages supplied by the full starter.
40
40
  between them, for a narrow list such as the support conversations; the buttons
41
41
  keep Previous and Next as their accessible names.
42
42
 
43
+ `linked` is for lists that keep their page number in the address
44
+ (`?page=2`): Previous and Next are links to those addresses, so search engines
45
+ reach every page of the list, and a click still changes the page in place.
46
+ The journal and the store list use it.
47
+
43
48
  ## Endpoint coverage
44
49
 
45
50
  No direct API request; the parent supplies the state.
@@ -1,8 +1,10 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref } from 'vue';
3
+ import { useRoute } from '#imports';
3
4
  import { useOrionplaceSelectionsStore } from '@/stores/orionplaceSelections';
4
5
  import { featureLabels } from '@/utils/orionplace/featureLabels';
5
6
  import { scrollToPaginatedList } from '@/utils/orionplace/paginationScroll';
7
+ import { storefrontPageHref } from '@/utils/orionplace/pageSeo';
6
8
  import type { IApiPagination } from '@/interfaces/orionplace/accounts/IProfileData';
7
9
  import '@/components/orionplace/shared/features.css';
8
10
 
@@ -11,17 +13,58 @@
11
13
  pending?: boolean;
12
14
  scrollTarget?: HTMLElement | string | null;
13
15
  compact?: boolean;
16
+ linked?: boolean;
14
17
  }>();
18
+ // `linked`: the list keeps its page number in the address (`?page=`), so
19
+ // previous and next are links to those addresses and search engines reach
20
+ // every page. A click still changes the page in place.
15
21
  const emit = defineEmits<{ change: [page: number] }>();
16
22
  const selectionStore = useOrionplaceSelectionsStore();
17
23
  const labels = computed(() => featureLabels(selectionStore.localeCode));
18
24
  const page = computed(() => props.pagination.page ?? 1);
19
25
  const nav = ref<HTMLElement>();
26
+ const route = useRoute();
27
+ const previousPage = computed(
28
+ () => props.pagination.previous_page_number || page.value - 1,
29
+ );
30
+ const nextPage = computed(() =>
31
+ typeof props.pagination.next_page_number === 'number'
32
+ ? props.pagination.next_page_number
33
+ : page.value + 1,
34
+ );
35
+ const search = computed(
36
+ () => new URL(route.fullPath, 'http://storefront').search,
37
+ );
38
+ const previousHref = computed(() =>
39
+ props.linked && !props.pending && props.pagination.has_previous
40
+ ? storefrontPageHref(route.path, search.value, previousPage.value)
41
+ : undefined,
42
+ );
43
+ const nextHref = computed(() =>
44
+ props.linked && !props.pending && props.pagination.has_next
45
+ ? storefrontPageHref(route.path, search.value, nextPage.value)
46
+ : undefined,
47
+ );
20
48
 
21
49
  function change(value: number) {
22
50
  emit('change', value);
23
51
  scrollToPaginatedList(nav.value, props.scrollTarget);
24
52
  }
53
+
54
+ // A link opened in a new tab or window keeps its default.
55
+ function follow(event: MouseEvent, value: number) {
56
+ if (
57
+ event.button !== 0 ||
58
+ event.metaKey ||
59
+ event.ctrlKey ||
60
+ event.shiftKey ||
61
+ event.altKey
62
+ ) {
63
+ return;
64
+ }
65
+ event.preventDefault();
66
+ change(value);
67
+ }
25
68
  </script>
26
69
 
27
70
  <template>
@@ -41,13 +84,20 @@
41
84
  The compact form fits a narrow list: arrow buttons named for screen
42
85
  readers, with the page count between them.
43
86
  -->
44
- <button
87
+ <component
88
+ :is="previousHref ? 'a' : 'button'"
45
89
  class="op-feature-button"
46
- type="button"
90
+ :type="previousHref ? undefined : 'button'"
91
+ :href="previousHref"
92
+ :rel="previousHref ? 'prev' : undefined"
47
93
  :aria-label="compact ? labels.previous : undefined"
48
94
  :title="compact ? labels.previous : undefined"
49
- :disabled="pending || !pagination.has_previous"
50
- @click="change(pagination.previous_page_number || page - 1)"
95
+ :disabled="previousHref ? undefined : pending || !pagination.has_previous"
96
+ @click="
97
+ previousHref
98
+ ? follow($event, previousPage)
99
+ : change(previousPage)
100
+ "
51
101
  >
52
102
  <svg
53
103
  v-if="compact"
@@ -63,24 +113,21 @@
63
113
  <path d="m15 5-7 7 7 7" />
64
114
  </svg>
65
115
  <template v-else>{{ labels.previous }}</template>
66
- </button>
116
+ </component>
67
117
  <span class="tabular-nums">
68
118
  {{ labels.page }} {{ page }}
69
119
  <template v-if="pagination.pages">/ {{ pagination.pages }}</template>
70
120
  </span>
71
- <button
121
+ <component
122
+ :is="nextHref ? 'a' : 'button'"
72
123
  class="op-feature-button"
73
- type="button"
124
+ :type="nextHref ? undefined : 'button'"
125
+ :href="nextHref"
126
+ :rel="nextHref ? 'next' : undefined"
74
127
  :aria-label="compact ? labels.next : undefined"
75
128
  :title="compact ? labels.next : undefined"
76
- :disabled="pending || !pagination.has_next"
77
- @click="
78
- change(
79
- typeof pagination.next_page_number === 'number'
80
- ? pagination.next_page_number
81
- : page + 1,
82
- )
83
- "
129
+ :disabled="nextHref ? undefined : pending || !pagination.has_next"
130
+ @click="nextHref ? follow($event, nextPage) : change(nextPage)"
84
131
  >
85
132
  <svg
86
133
  v-if="compact"
@@ -96,6 +143,6 @@
96
143
  <path d="m9 5 7 7-7 7" />
97
144
  </svg>
98
145
  <template v-else>{{ labels.next }}</template>
99
- </button>
146
+ </component>
100
147
  </nav>
101
148
  </template>
@@ -339,6 +339,7 @@
339
339
  <StorefrontPagination
340
340
  :pagination="pagination"
341
341
  :pending="busy"
342
+ :linked="!storeId"
342
343
  @change="storeId ? load($event) : open($event)"
343
344
  />
344
345
  </section>
@@ -9,6 +9,7 @@ import {
9
9
  storefrontOpenGraphLocale,
10
10
  storefrontOpenGraphType,
11
11
  storefrontPageMetadata,
12
+ storefrontPagedText,
12
13
  storefrontPublicOrigin,
13
14
  } from '@/utils/orionplace/pageSeo';
14
15
  import type { StorefrontSeoPage } from '@/utils/orionplace/pageSeo';
@@ -59,19 +60,35 @@ export function useOrionplacePageSeo(
59
60
  ),
60
61
  );
61
62
 
63
+ // A later list page (`?page=2`) names its page number.
64
+ const paged = computed(() => ({
65
+ title:
66
+ storefrontPagedText(metadata.value.title, canonical.value, toValue(locale)) ??
67
+ metadata.value.title,
68
+ description:
69
+ storefrontPagedText(
70
+ metadata.value.description,
71
+ canonical.value,
72
+ toValue(locale),
73
+ ) ?? metadata.value.description,
74
+ }));
75
+
62
76
  // Link previews use the same title and description as the page; the
63
77
  // image is the record's first picture when the page has one, as a
64
78
  // complete address.
65
79
  const seo = {
66
- title: () => metadata.value.title,
67
- description: () => metadata.value.description,
80
+ title: () => paged.value.title,
81
+ description: () => paged.value.description,
68
82
  keywords: () => metadata.value.keywords,
69
83
  robots: () => toValue(values.robots),
70
- ogTitle: () => metadata.value.title,
71
- ogDescription: () => metadata.value.description,
84
+ ogTitle: () => paged.value.title,
85
+ ogDescription: () => paged.value.description,
72
86
  ogImage: () => storefrontAbsoluteUrl(toValue(values.image), origin),
73
87
  ogUrl: () => canonical.value,
74
- ogType: () => storefrontOpenGraphType(toValue(page)),
88
+ // Nuxt's head types leave out og:type `product` (Open Graph product
89
+ // pages); the tag renders as given.
90
+ ogType: () =>
91
+ storefrontOpenGraphType(toValue(page)) as 'website' | 'article',
75
92
  ogLocale: () => storefrontOpenGraphLocale(toValue(locale)),
76
93
  articlePublishedTime: () => toValue(values.published),
77
94
  articleModifiedTime: () => toValue(values.modified),
@@ -96,7 +113,7 @@ export function useOrionplacePageSeo(
96
113
  { key: 'canonical', rel: 'canonical', href: canonical.value },
97
114
  ...alternates.value.map((alternate) => ({
98
115
  key: `alternate-${alternate.hreflang}`,
99
- rel: 'alternate',
116
+ rel: 'alternate' as const,
100
117
  hreflang: alternate.hreflang,
101
118
  href: alternate.href,
102
119
  })),
@@ -104,6 +104,8 @@ export interface ICustomerSubscription {
104
104
  cancelled_date: IApiDateTime | null;
105
105
  returned_amount: ICurrencyAmounts;
106
106
  c_tickets: number;
107
+ /** Partner code a trial remembered, while it waits for the first payment; fill the promo field with it on upgrade. */
108
+ referral_code: string | null;
107
109
  renew_period: ICustomerSubscriptionRenewal | null;
108
110
  plugin: {
109
111
  remote_subscription_last_sync: IApiDateTime | null;
@@ -4,9 +4,11 @@
4
4
  import { useOrionplaceStore } from '@/stores/orionplace';
5
5
  import { useOrionplacePageSeo } from '@/composables/useOrionplacePageSeo';
6
6
  import { useOrionplaceServerData } from '@/composables/useOrionplaceServerData';
7
+ import { useOrionplaceMissingPage } from '@/composables/useOrionplaceMissingPage';
7
8
  import {
8
9
  blogPageNumber,
9
10
  blogPageSize,
11
+ journalPagePastEnd,
10
12
  blogPostsRequest,
11
13
  blogViewType,
12
14
  } from '@/utils/orionplace/blogLayout';
@@ -49,6 +51,11 @@
49
51
  );
50
52
 
51
53
  useOrionplacePageSeo('journal', () => store.localeCode);
54
+
55
+ // A page number past the last page has no articles: it answers 404, so
56
+ // search engines drop it instead of indexing an empty list.
57
+ const pastLastPage = journalPagePastEnd(page.value, serverPage.value);
58
+ useOrionplaceMissingPage(() => pastLastPage, 'Page not found');
52
59
  </script>
53
60
 
54
61
  <template>
@@ -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=`). Pages that are not listed, pages with
466
- * other query parameters (filters, page numbers, private keys) and noindex
467
- * pages get none.
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 = pathname.replace(/\/+$/, '') || '/';
477
- const query = new URLSearchParams(search);
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 = new URL(path, `${origin}/`);
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: string; href: string }[] {
659
+ ): { key: string; rel: 'icon'; href: string }[] {
609
660
  const href = storefrontAbsoluteUrl(icon, '');
610
661
  return href ? [{ key: 'icon', rel: 'icon', href }] : [];
611
662
  }