orionplace-nuxt 0.6.1 → 0.7.0

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
@@ -466,7 +466,9 @@ The corresponding `.cfg` is read on every request. Backend URLs must use HTTPS,
466
466
  end in `/api`, and belong to `*.orionplace.app`. API methods, query strings, bodies,
467
467
  authorization and cookies are forwarded. Backend statuses are retained and cookie
468
468
  Domain attributes are removed to bind cookies to the storefront. Gateway responses
469
- use `Cache-Control: no-store`. Network failures return `502`.
469
+ use `Cache-Control: no-store`, except public blog images and videos: they keep the
470
+ backend's `private, max-age` rule, so the browser does not download them again on
471
+ every page. Network failures return `502`.
470
472
 
471
473
  A changing request (POST, PUT, PATCH, DELETE) from another site is rejected with
472
474
  `403`. A request from the page's own host or the storefront domain passes, and the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orionplace-nuxt",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
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",
@@ -41,6 +41,18 @@ const hostnamePattern =
41
41
  const blogMediaPattern =
42
42
  /^\/ps3\/blog\/posts\/(?:images\/\d+|videos\/\d+(?:\/preview)?)$/;
43
43
 
44
+ /**
45
+ * API answers are never stored. A media file keeps the backend's own browser
46
+ * cache rule when the backend allows it (a published public blog image:
47
+ * "private, max-age=…"); "private" keeps it out of shared caches.
48
+ */
49
+ export function marketplaceCacheControl(media, upstream) {
50
+ const value = upstream.headers.get('cache-control') ?? '';
51
+ return media && upstream.ok && /\bprivate\b/i.test(value) && /\bmax-age=\d+/i.test(value) && !/no-store|no-cache/i.test(value)
52
+ ? value
53
+ : 'no-store';
54
+ }
55
+
44
56
  /** @param {string} pathname */
45
57
  export function isMarketplaceMediaPath(pathname) {
46
58
  return blogMediaPattern.test(pathname);
@@ -307,7 +319,7 @@ export async function forwardMarketplace(request, configuration = {}) {
307
319
  : destination.href,
308
320
  );
309
321
  }
310
- responseHeaders.set('Cache-Control', 'no-store');
322
+ responseHeaders.set('Cache-Control', marketplaceCacheControl(media, upstream));
311
323
  responseHeaders.set('Vary', 'Host, X-Forwarded-Host, X-Front-Domain');
312
324
  // fetch decodes compressed bodies: do not reuse upstream Content-Length/Encoding.
313
325
  return new Response(upstream.body, {
@@ -577,6 +577,14 @@ for example \`ORIONPLACE_NOINDEX_PATHS="/catalog /category /product"\`. Those
577
577
  pages answer with \`X-Robots-Tag: noindex, follow\` and the sitemap leaves them
578
578
  out; the journal and other pages stay indexable. Leave it empty on a live shop.
579
579
 
580
+ Every product page carries schema.org product data for Google and Yandex: the
581
+ price, the crossed-out old price, stock, photos, brand, seller, rating and the
582
+ return terms the page shows. To include the return terms, set
583
+ \`ORIONPLACE_SHIPPING_COUNTRIES\` in \`.env\` to the countries you ship to, as
584
+ ISO codes separated by spaces, for example
585
+ \`ORIONPLACE_SHIPPING_COUNTRIES="RU BY"\`; without it the return terms are left
586
+ out. The delivery cost is not included: it depends on the buyer's address.
587
+
580
588
  This setup does not add analytics.
581
589
 
582
590
  ## Android app
@@ -8,7 +8,7 @@ The supplied storefront uses this component at `/journal`.
8
8
 
9
9
  ## Contract and dependencies
10
10
 
11
- The supplied `app/pages/journal/[id].vue` loads the article during the server render with `blog_posts_get` and `filters.id = postId`, using the visitor's cookies, and passes it in `initialPost`; the browser shows it without another request. Without `initialPost` (after client-side navigation) the component sends the same request itself. Full text appears only when both `is_full` and `has_full_access` are true; otherwise the preview and the access panel remain. Article HTML is sanitized with DOMPurify in `app/utils/orionplace/blogArticleHtml.ts` and keeps what the marketplace blog editor writes: headings, paragraphs and line breaks, bold, italic, underline, strikethrough, subscript and superscript, links, bullet, numbered and task lists, quotes, dividers, inline code and code blocks, tables with merged cells, images and galleries, video and audio with controls, collapsible sections, alignment, text color, highlight, font and size. External links open in a new tab with `rel="noopener noreferrer"`; editor links to other articles open `/journal/<id>`. Formulas are shown as their LaTeX source. Wide tables and code blocks scroll inside the article, and floated images stack on narrow screens. Product placements from the editor are not shown inside the text. Frames, scripts, forms, event attributes and other styles are removed. The server render shows the text as plain paragraphs, which search engines can read; the formatted HTML replaces them in the browser.
11
+ The supplied `app/pages/journal/[id].vue` loads the article during the server render with `blog_posts_get` and `filters.id = postId`, using the visitor's cookies, and passes it in `initialPost`; the browser shows it without another request. Without `initialPost` (after client-side navigation) the component sends the same request itself. Full text appears only when both `is_full` and `has_full_access` are true; otherwise the preview and the access panel remain. Article HTML is sanitized with DOMPurify in `app/utils/orionplace/blogArticleHtml.ts` and keeps what the marketplace blog editor writes: headings, paragraphs and line breaks, bold, italic, underline, strikethrough, subscript and superscript, links, bullet, numbered and task lists, quotes, dividers, inline code and code blocks, tables with merged cells, images and galleries, video and audio with controls, collapsible sections, alignment, text color, highlight, font and size. External links open in a new tab with `rel="noopener noreferrer"`; editor links to other articles open `/journal/<id>`. Formulas are shown as their LaTeX source. Wide tables and code blocks scroll inside the article, and floated images stack on narrow screens. Products the author places in the text appear at those positions: an empty block with `data-product-id="12"` shows one product card, `data-product-ids="12,15,9"` a row of up to 24 cards. The block keeps the editor's float or centred style, and `data-product-hidden-badges` (`new`, `popular`, `good-deal`) turns card badges off. Products missing from `related_products` are loaded with `products_search` and `filters.id_in`, up to 35 IDs per request, and stored with `store.setProduct()`; a block whose products are not found stays hidden. Article text styles do not reach the cards, and in a narrow article a floated card stands alone in the middle. The helper is `app/utils/orionplace/blogProductPlacements.ts`. Frames, scripts, forms, event attributes and other styles are removed. The server render shows the text as plain paragraphs, which search engines can read; the formatted HTML replaces them in the browser.
12
12
 
13
13
  Reading accounting for an accessible paid article starts after interaction with its visible text. `blog_reading_start` starts a session; `blog_reading_heartbeat` follows the returned interval. Leaving, losing focus, hiding the text or 60 seconds without interaction stops requests. Resuming and delayed timers start a fresh baseline. Confirmed loss of access removes full text and offers a refresh.
14
14
 
@@ -41,4 +41,4 @@ pages supplied by the full starter.
41
41
  - `blog_posts_get`
42
42
  - `blog_reading_start`
43
43
  - `blog_reading_heartbeat`
44
- - `products_search` (variant targets, through the host's variant controller)
44
+ - `products_search` (products placed in the text by `filters.id_in`; variant targets, through the host's variant controller)
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed, nextTick, onMounted, ref, watch } from 'vue';
2
+ import { computed, nextTick, onMounted, ref, shallowRef, watch } from 'vue';
3
3
  import { useRuntimeConfig } from '#imports';
4
4
  import { useOrionplaceStore } from '@/stores/orionplace';
5
5
  import { useOrionplaceProductsStore } from '@/stores/orionplaceProducts';
@@ -25,6 +25,13 @@
25
25
  nativeAppOrigin,
26
26
  } from '@/utils/orionplace/nativeApp';
27
27
  import { requestBlogPost } from '@/utils/orionplace/blogLayout';
28
+ import {
29
+ blogPlacementProductIds,
30
+ blogPlacementRequests,
31
+ blogProductPlacements,
32
+ } from '@/utils/orionplace/blogProductPlacements';
33
+ import type { IBlogProductPlacement } from '@/utils/orionplace/blogProductPlacements';
34
+ import type { IProductSearchSeller } from '@/interfaces/orionplace/products/IProduct';
28
35
  import {
29
36
  blogViewerImage,
30
37
  blogViewerImages,
@@ -46,6 +53,7 @@
46
53
  import { NuxtLink } from '#components';
47
54
  import BlogComments from '@/components/orionplace/blog/BlogComments.vue';
48
55
  import ProductGroup from '@/components/orionplace/products/ProductGroup.vue';
56
+ import ProductCard from '@/components/orionplace/products/ProductCard.vue';
49
57
  import '@/components/orionplace/shared/features.css';
50
58
  import OrionplaceEmptyState from '@/components/orionplace/shared/OrionplaceEmptyState.vue';
51
59
 
@@ -95,6 +103,8 @@
95
103
  useRuntimeConfig().public.orionplaceAppOrigin,
96
104
  );
97
105
  const productIds = ref<number[]>([]);
106
+ // Product cards the author placed in the text (see blogProductPlacements).
107
+ const placements = shallowRef<IBlogProductPlacement[]>([]);
98
108
  const wasShown = ref(false);
99
109
  const settled = ref(false);
100
110
  const labels = computed(() => blogLabels(selectionStore.localeCode));
@@ -244,6 +254,48 @@
244
254
  wasShown.value = true;
245
255
  showProducts(props.initialPost.related_products);
246
256
  }
257
+ function placementShown(placement: IBlogProductPlacement) {
258
+ return placement.productIds.some((id) => productStore.getProduct(id, 'products'));
259
+ }
260
+
261
+ // Placed products that no list loaded yet come with one request per 35.
262
+ async function loadPlacementProducts(ids: number[]) {
263
+ const missing = ids.filter((id) => !productStore.getProduct(id, 'products'));
264
+
265
+ for (const chunk of blogPlacementRequests(missing)) {
266
+ try {
267
+ const data = await api.request<{
268
+ products?: { data?: IProduct[] };
269
+ selects?: { sellers?: IProductSearchSeller[] };
270
+ }>('products_search', {
271
+ filters: { id_in: chunk.join(',') },
272
+ paginate: { page: 1, pp_items: chunk.length },
273
+ });
274
+
275
+ for (const product of data?.products?.data ?? []) {
276
+ productStore.setProduct(product, 'products', data?.selects?.sellers ?? []);
277
+ }
278
+ } catch {
279
+ // A placement without its products stays empty and hidden.
280
+ }
281
+ }
282
+ }
283
+
284
+ // New text replaces the placement blocks: the cards leave them first.
285
+ watch(html, () => {
286
+ placements.value = [];
287
+ }, { flush: 'pre' });
288
+ watch(
289
+ [html, mounted, () => post.value?.id],
290
+ () => {
291
+ void nextTick(() => {
292
+ placements.value = mounted.value ? blogProductPlacements(article.value) : [];
293
+ void loadPlacementProducts(blogPlacementProductIds(placements.value));
294
+ });
295
+ },
296
+ { flush: 'post' },
297
+ );
298
+
247
299
  function openableImages() {
248
300
  return blogViewerImages([coverImage.value, article.value]);
249
301
  }
@@ -388,6 +440,52 @@
388
440
  {{ paragraph }}
389
441
  </p>
390
442
  </div>
443
+ <template
444
+ v-for="(placement, index) in placements"
445
+ :key="index"
446
+ >
447
+ <Teleport
448
+ v-if="placementShown(placement)"
449
+ :to="placement.element"
450
+ >
451
+ <ProductCard
452
+ v-if="placement.layout === 'single'"
453
+ :product-id="placement.productIds[0]!"
454
+ :hidden-badges="placement.hiddenBadges"
455
+ :disabled="props.productActionsDisabled"
456
+ data-blog-placement="single"
457
+ @select="emit('selectProduct', $event)"
458
+ @select-seller="emit('selectSeller', $event)"
459
+ @select-variant="emit('selectVariant', $event)"
460
+ @request-secondary-variants="emit('requestSecondaryVariants', $event)"
461
+ @select-secondary-variant="emit('selectSecondaryVariant', $event)"
462
+ @toggle-favorite="emit('toggleFavorite', $event)"
463
+ @add="emit('add', $event)"
464
+ @remove="emit('remove', $event)"
465
+ @subscribe="emit('subscribe', $event)"
466
+ @unsubscribe="emit('unsubscribe', $event)"
467
+ @preorder="emit('preorder', $event)"
468
+ />
469
+ <ProductGroup
470
+ v-else
471
+ :product-ids="placement.productIds"
472
+ :hidden-badges="placement.hiddenBadges"
473
+ :disabled="props.productActionsDisabled"
474
+ data-blog-placement="list"
475
+ @select="emit('selectProduct', $event)"
476
+ @select-seller="emit('selectSeller', $event)"
477
+ @select-variant="emit('selectVariant', $event)"
478
+ @request-secondary-variants="emit('requestSecondaryVariants', $event)"
479
+ @select-secondary-variant="emit('selectSecondaryVariant', $event)"
480
+ @toggle-favorite="emit('toggleFavorite', $event)"
481
+ @add="emit('add', $event)"
482
+ @remove="emit('remove', $event)"
483
+ @subscribe="emit('subscribe', $event)"
484
+ @unsubscribe="emit('unsubscribe', $event)"
485
+ @preorder="emit('preorder', $event)"
486
+ />
487
+ </Teleport>
488
+ </template>
391
489
  <div
392
490
  v-if="!full"
393
491
  class="op-feature-panel op-feature-stack"
@@ -555,143 +653,144 @@
555
653
  clear: both;
556
654
  }
557
655
 
558
- .op-feature-prose :deep(:where(p, ul, ol, blockquote, pre, figure)) {
656
+ /* Product cards placed in the text keep their own look: the text rules skip placement blocks. */
657
+ .op-feature-prose :deep(:where(p, ul, ol, blockquote, pre, figure):where(:not([data-product-id] *, [data-product-ids] *))) {
559
658
  margin: 1rem 0;
560
659
  }
561
660
 
562
661
  /* A new section or wide block starts below a floated image. */
563
- .op-feature-prose :deep(:where(h1, h2, h3, h4, h5, h6, figure, table, pre)) {
662
+ .op-feature-prose :deep(:where(h1, h2, h3, h4, h5, h6, figure, table, pre):where(:not([data-product-id] *, [data-product-ids] *))) {
564
663
  clear: both;
565
664
  }
566
665
 
567
- .op-feature-prose :deep(:where(h1, h2, h3, h4, h5, h6)) {
666
+ .op-feature-prose :deep(:where(h1, h2, h3, h4, h5, h6):where(:not([data-product-id] *, [data-product-ids] *))) {
568
667
  margin: 2rem 0 1rem;
569
668
  font-weight: 500;
570
669
  line-height: 1.3;
571
670
  }
572
671
 
573
- .op-feature-prose :deep(h1) {
672
+ .op-feature-prose :deep(h1:where(:not([data-product-id] *, [data-product-ids] *))) {
574
673
  font-size: 1.875rem;
575
674
  }
576
675
 
577
- .op-feature-prose :deep(h2) {
676
+ .op-feature-prose :deep(h2:where(:not([data-product-id] *, [data-product-ids] *))) {
578
677
  font-size: 1.5rem;
579
678
  }
580
679
 
581
- .op-feature-prose :deep(h3) {
680
+ .op-feature-prose :deep(h3:where(:not([data-product-id] *, [data-product-ids] *))) {
582
681
  font-size: 1.25rem;
583
682
  }
584
683
 
585
- .op-feature-prose :deep(h4) {
684
+ .op-feature-prose :deep(h4:where(:not([data-product-id] *, [data-product-ids] *))) {
586
685
  font-size: 1.125rem;
587
686
  }
588
687
 
589
- .op-feature-prose :deep(h5) {
688
+ .op-feature-prose :deep(h5:where(:not([data-product-id] *, [data-product-ids] *))) {
590
689
  font-size: 1rem;
591
690
  }
592
691
 
593
- .op-feature-prose :deep(h6) {
692
+ .op-feature-prose :deep(h6:where(:not([data-product-id] *, [data-product-ids] *))) {
594
693
  font-size: 0.875rem;
595
694
  text-transform: uppercase;
596
695
  letter-spacing: 0.08em;
597
696
  }
598
697
 
599
- .op-feature-prose :deep(:where(strong, b)) {
698
+ .op-feature-prose :deep(:where(strong, b):where(:not([data-product-id] *, [data-product-ids] *))) {
600
699
  font-weight: 500;
601
700
  }
602
701
 
603
- .op-feature-prose :deep(:where(sub, sup)) {
702
+ .op-feature-prose :deep(:where(sub, sup):where(:not([data-product-id] *, [data-product-ids] *))) {
604
703
  font-size: 0.75em;
605
704
  line-height: 0;
606
705
  }
607
706
 
608
- .op-feature-prose :deep(mark) {
707
+ .op-feature-prose :deep(mark:where(:not([data-product-id] *, [data-product-ids] *))) {
609
708
  color: inherit;
610
709
  background: var(--orionplace-attention-surface);
611
710
  }
612
711
 
613
- .op-feature-prose :deep(a) {
712
+ .op-feature-prose :deep(a:where(:not([data-product-id] *, [data-product-ids] *))) {
614
713
  color: var(--orionplace-text);
615
714
  text-decoration: none;
616
715
  border-bottom: 1px solid currentColor;
617
716
  }
618
717
 
619
- .op-feature-prose :deep(a:not([href])) {
718
+ .op-feature-prose :deep(a:not([href]):where(:not([data-product-id] *, [data-product-ids] *))) {
620
719
  border-bottom: 0;
621
720
  }
622
721
 
623
- .op-feature-prose :deep(a:focus-visible) {
722
+ .op-feature-prose :deep(a:focus-visible:where(:not([data-product-id] *, [data-product-ids] *))) {
624
723
  color: var(--orionplace-accent);
625
724
  outline: none;
626
725
  }
627
726
 
628
727
  @media (hover: hover) {
629
- .op-feature-prose :deep(a:hover) {
728
+ .op-feature-prose :deep(a:hover:where(:not([data-product-id] *, [data-product-ids] *))) {
630
729
  color: var(--orionplace-accent);
631
730
  outline: none;
632
731
  }
633
732
  }
634
733
 
635
- .op-feature-prose :deep(:where(ul, ol)) {
734
+ .op-feature-prose :deep(:where(ul, ol):where(:not([data-product-id] *, [data-product-ids] *))) {
636
735
  padding-left: 1.5rem;
637
736
  }
638
737
 
639
- .op-feature-prose :deep(ul) {
738
+ .op-feature-prose :deep(ul:where(:not([data-product-id] *, [data-product-ids] *))) {
640
739
  list-style: disc;
641
740
  }
642
741
 
643
- .op-feature-prose :deep(ol) {
742
+ .op-feature-prose :deep(ol:where(:not([data-product-id] *, [data-product-ids] *))) {
644
743
  list-style: decimal;
645
744
  }
646
745
 
647
- .op-feature-prose :deep(li > :where(ul, ol)) {
746
+ .op-feature-prose :deep(li > :where(ul, ol):where(:not([data-product-id] *, [data-product-ids] *))) {
648
747
  margin: 0.25rem 0;
649
748
  }
650
749
 
651
- .op-feature-prose :deep(li > p) {
750
+ .op-feature-prose :deep(li > p:where(:not([data-product-id] *, [data-product-ids] *))) {
652
751
  margin: 0;
653
752
  }
654
753
 
655
- .op-feature-prose :deep(ul[data-type='taskList']) {
754
+ .op-feature-prose :deep(ul[data-type='taskList']:where(:not([data-product-id] *, [data-product-ids] *))) {
656
755
  list-style: none;
657
756
  padding-left: 0;
658
757
  }
659
758
 
660
- .op-feature-prose :deep(li[data-type='taskItem']) {
759
+ .op-feature-prose :deep(li[data-type='taskItem']:where(:not([data-product-id] *, [data-product-ids] *))) {
661
760
  display: flex;
662
761
  align-items: flex-start;
663
762
  gap: 0.5rem;
664
763
  }
665
764
 
666
- .op-feature-prose :deep(li[data-type='taskItem'] > label) {
765
+ .op-feature-prose :deep(li[data-type='taskItem'] > label:where(:not([data-product-id] *, [data-product-ids] *))) {
667
766
  display: flex;
668
767
  align-items: center;
669
768
  min-height: 1.85em;
670
769
  }
671
770
 
672
- .op-feature-prose :deep(li[data-type='taskItem'] > div > p) {
771
+ .op-feature-prose :deep(li[data-type='taskItem'] > div > p:where(:not([data-product-id] *, [data-product-ids] *))) {
673
772
  margin: 0;
674
773
  }
675
774
 
676
- .op-feature-prose :deep(blockquote) {
775
+ .op-feature-prose :deep(blockquote:where(:not([data-product-id] *, [data-product-ids] *))) {
677
776
  border-left: 2px solid var(--orionplace-border);
678
777
  padding-left: 1rem;
679
778
  color: var(--orionplace-muted);
680
779
  }
681
780
 
682
- .op-feature-prose :deep(hr) {
781
+ .op-feature-prose :deep(hr:where(:not([data-product-id] *, [data-product-ids] *))) {
683
782
  margin: 2rem 0;
684
783
  border: 0;
685
784
  border-top: 1px solid var(--orionplace-border);
686
785
  }
687
786
 
688
- .op-feature-prose :deep(code) {
787
+ .op-feature-prose :deep(code:where(:not([data-product-id] *, [data-product-ids] *))) {
689
788
  padding: 0.1em 0.3em;
690
789
  font-size: 0.875em;
691
790
  background: var(--orionplace-muted-surface);
692
791
  }
693
792
 
694
- .op-feature-prose :deep(pre) {
793
+ .op-feature-prose :deep(pre:where(:not([data-product-id] *, [data-product-ids] *))) {
695
794
  overflow-x: auto;
696
795
  padding: 1rem;
697
796
  line-height: 1.6;
@@ -699,13 +798,13 @@
699
798
  background: var(--orionplace-muted-surface);
700
799
  }
701
800
 
702
- .op-feature-prose :deep(pre code) {
801
+ .op-feature-prose :deep(pre code:where(:not([data-product-id] *, [data-product-ids] *))) {
703
802
  padding: 0;
704
803
  background: transparent;
705
804
  }
706
805
 
707
806
  /* A wide table scrolls inside the article instead of widening the page. */
708
- .op-feature-prose :deep(table) {
807
+ .op-feature-prose :deep(table:where(:not([data-product-id] *, [data-product-ids] *))) {
709
808
  display: block;
710
809
  max-width: 100%;
711
810
  overflow-x: auto;
@@ -713,7 +812,7 @@
713
812
  border-collapse: collapse;
714
813
  }
715
814
 
716
- .op-feature-prose :deep(:where(th, td)) {
815
+ .op-feature-prose :deep(:where(th, td):where(:not([data-product-id] *, [data-product-ids] *))) {
717
816
  min-width: 6rem;
718
817
  padding: 0.5rem 0.75rem;
719
818
  vertical-align: top;
@@ -721,46 +820,46 @@
721
820
  border: 1px solid var(--orionplace-border);
722
821
  }
723
822
 
724
- .op-feature-prose :deep(th) {
823
+ .op-feature-prose :deep(th:where(:not([data-product-id] *, [data-product-ids] *))) {
725
824
  font-weight: 500;
726
825
  background: var(--orionplace-muted-surface);
727
826
  }
728
827
 
729
- .op-feature-prose :deep(caption) {
828
+ .op-feature-prose :deep(caption:where(:not([data-product-id] *, [data-product-ids] *))) {
730
829
  padding: 0.5rem 0;
731
830
  color: var(--orionplace-muted);
732
831
  text-align: left;
733
832
  }
734
833
 
735
- .op-feature-prose :deep([data-text-align='center']) {
834
+ .op-feature-prose :deep([data-text-align='center']:where(:not([data-product-id] *, [data-product-ids] *))) {
736
835
  text-align: center;
737
836
  }
738
837
 
739
- .op-feature-prose :deep([data-text-align='right']) {
838
+ .op-feature-prose :deep([data-text-align='right']:where(:not([data-product-id] *, [data-product-ids] *))) {
740
839
  text-align: right;
741
840
  }
742
841
 
743
- .op-feature-prose :deep([data-vertical-align='middle']) {
842
+ .op-feature-prose :deep([data-vertical-align='middle']:where(:not([data-product-id] *, [data-product-ids] *))) {
744
843
  vertical-align: middle;
745
844
  }
746
845
 
747
- .op-feature-prose :deep([data-vertical-align='bottom']) {
846
+ .op-feature-prose :deep([data-vertical-align='bottom']:where(:not([data-product-id] *, [data-product-ids] *))) {
748
847
  vertical-align: bottom;
749
848
  }
750
849
 
751
850
  /* Images that open in the viewer. */
752
851
  [data-blog-article-cover][data-blog-image-viewer],
753
- .op-feature-prose :deep(img[data-blog-image-viewer]) {
852
+ .op-feature-prose :deep(img[data-blog-image-viewer]:where(:not([data-product-id] *, [data-product-ids] *))) {
754
853
  cursor: zoom-in;
755
854
  }
756
855
 
757
856
  [data-blog-article-cover][data-blog-image-viewer]:focus-visible,
758
- .op-feature-prose :deep(img[data-blog-image-viewer]:focus-visible) {
857
+ .op-feature-prose :deep(img[data-blog-image-viewer]:focus-visible:where(:not([data-product-id] *, [data-product-ids] *))) {
759
858
  outline: 2px solid var(--orionplace-accent);
760
859
  outline-offset: 3px;
761
860
  }
762
861
 
763
- .op-feature-prose :deep(:where(img, video)) {
862
+ .op-feature-prose :deep(:where(img, video):where(:not([data-product-id] *, [data-product-ids] *))) {
764
863
  display: block;
765
864
  max-width: 100%;
766
865
  height: auto;
@@ -770,31 +869,31 @@
770
869
  /* A narrow article has no room for text beside a floated image; 592px is
771
870
  the article's width in the /journal page on a 640px screen. */
772
871
  @container article (max-width: 591px) {
773
- .op-feature-prose :deep(:where(img, video)[style*='float']) {
872
+ .op-feature-prose :deep(:where(img, video)[style*='float']:where(:not([data-product-id] *, [data-product-ids] *))) {
774
873
  float: none !important;
775
874
  margin: 1rem auto !important;
776
875
  }
777
876
  }
778
877
 
779
- .op-feature-prose :deep(audio) {
878
+ .op-feature-prose :deep(audio:where(:not([data-product-id] *, [data-product-ids] *))) {
780
879
  display: block;
781
880
  width: 100%;
782
881
  }
783
882
 
784
- .op-feature-prose :deep(figcaption) {
883
+ .op-feature-prose :deep(figcaption:where(:not([data-product-id] *, [data-product-ids] *))) {
785
884
  margin-top: 0.5rem;
786
885
  font-size: 0.875rem;
787
886
  color: var(--orionplace-muted);
788
887
  }
789
888
 
790
- .op-feature-prose :deep(.blog-image-gallery__track) {
889
+ .op-feature-prose :deep(.blog-image-gallery__track:where(:not([data-product-id] *, [data-product-ids] *))) {
791
890
  display: flex;
792
891
  gap: 0.5rem;
793
892
  overflow-x: auto;
794
893
  scroll-snap-type: x mandatory;
795
894
  }
796
895
 
797
- .op-feature-prose :deep(.blog-image-gallery__track img) {
896
+ .op-feature-prose :deep(.blog-image-gallery__track img:where(:not([data-product-id] *, [data-product-ids] *))) {
798
897
  flex: 0 0 auto;
799
898
  width: auto;
800
899
  max-width: min(80%, 32rem);
@@ -803,104 +902,122 @@
803
902
  scroll-snap-align: start;
804
903
  }
805
904
 
806
- .op-feature-prose :deep(details) {
905
+ .op-feature-prose :deep(details:where(:not([data-product-id] *, [data-product-ids] *))) {
807
906
  margin: 1rem 0;
808
907
  padding: 0.75rem 1rem;
809
908
  border: 1px solid var(--orionplace-border);
810
909
  }
811
910
 
812
- .op-feature-prose :deep(summary) {
911
+ .op-feature-prose :deep(summary:where(:not([data-product-id] *, [data-product-ids] *))) {
813
912
  cursor: pointer;
814
913
  }
815
914
 
816
- .op-feature-prose :deep([data-type='math-inline']),
817
- .op-feature-prose :deep([data-type='math-block']) {
915
+ .op-feature-prose :deep([data-type='math-inline']:where(:not([data-product-id] *, [data-product-ids] *))),
916
+ .op-feature-prose :deep([data-type='math-block']:where(:not([data-product-id] *, [data-product-ids] *))) {
818
917
  font-family: ui-monospace, monospace;
819
918
  font-size: 0.9em;
820
919
  }
821
920
 
822
- .op-feature-prose :deep([data-type='math-block']) {
921
+ .op-feature-prose :deep([data-type='math-block']:where(:not([data-product-id] *, [data-product-ids] *))) {
823
922
  display: block;
824
923
  margin: 1rem 0;
825
924
  overflow-x: auto;
826
925
  text-align: center;
827
926
  }
828
927
 
829
- /* Product placements from the editor have no content in the text. */
928
+ /* Product placements: cards fill them; one without its products stays hidden. */
830
929
  .op-feature-prose :deep(:where([data-product-id], [data-product-ids]):empty) {
831
930
  display: none;
832
931
  }
833
932
 
933
+ .op-feature-prose :deep([data-product-ids]) {
934
+ clear: both;
935
+ margin: 1.5rem 0;
936
+ }
937
+
938
+ .op-feature-prose :deep([data-product-id]:not([data-product-ids])) {
939
+ width: min(100%, 260px);
940
+ margin-block: 1.5rem;
941
+ }
942
+
943
+ /* The editor's float has no room beside a card on a narrow page. */
944
+ @container article (max-width: 480px) {
945
+ .op-feature-prose :deep([data-product-id]:not([data-product-ids])) {
946
+ float: none !important;
947
+ margin-inline: auto !important;
948
+ }
949
+ }
950
+
834
951
  /* The editor's named colors, mixed with the theme text for contrast. */
835
- .op-feature-prose :deep([data-text-color='gray']) {
952
+ .op-feature-prose :deep([data-text-color='gray']:where(:not([data-product-id] *, [data-product-ids] *))) {
836
953
  color: color-mix(in srgb, gray 70%, var(--orionplace-text));
837
954
  }
838
955
 
839
- .op-feature-prose :deep([data-bg-color='gray']) {
956
+ .op-feature-prose :deep([data-bg-color='gray']:where(:not([data-product-id] *, [data-product-ids] *))) {
840
957
  background: color-mix(in srgb, gray 18%, transparent);
841
958
  }
842
959
 
843
- .op-feature-prose :deep([data-text-color='brown']) {
960
+ .op-feature-prose :deep([data-text-color='brown']:where(:not([data-product-id] *, [data-product-ids] *))) {
844
961
  color: color-mix(in srgb, brown 70%, var(--orionplace-text));
845
962
  }
846
963
 
847
- .op-feature-prose :deep([data-bg-color='brown']) {
964
+ .op-feature-prose :deep([data-bg-color='brown']:where(:not([data-product-id] *, [data-product-ids] *))) {
848
965
  background: color-mix(in srgb, brown 18%, transparent);
849
966
  }
850
967
 
851
- .op-feature-prose :deep([data-text-color='orange']) {
968
+ .op-feature-prose :deep([data-text-color='orange']:where(:not([data-product-id] *, [data-product-ids] *))) {
852
969
  color: color-mix(in srgb, orange 70%, var(--orionplace-text));
853
970
  }
854
971
 
855
- .op-feature-prose :deep([data-bg-color='orange']) {
972
+ .op-feature-prose :deep([data-bg-color='orange']:where(:not([data-product-id] *, [data-product-ids] *))) {
856
973
  background: color-mix(in srgb, orange 18%, transparent);
857
974
  }
858
975
 
859
- .op-feature-prose :deep([data-text-color='yellow']) {
976
+ .op-feature-prose :deep([data-text-color='yellow']:where(:not([data-product-id] *, [data-product-ids] *))) {
860
977
  color: color-mix(in srgb, yellow 70%, var(--orionplace-text));
861
978
  }
862
979
 
863
- .op-feature-prose :deep([data-bg-color='yellow']) {
980
+ .op-feature-prose :deep([data-bg-color='yellow']:where(:not([data-product-id] *, [data-product-ids] *))) {
864
981
  background: color-mix(in srgb, yellow 18%, transparent);
865
982
  }
866
983
 
867
- .op-feature-prose :deep([data-text-color='green']) {
984
+ .op-feature-prose :deep([data-text-color='green']:where(:not([data-product-id] *, [data-product-ids] *))) {
868
985
  color: color-mix(in srgb, green 70%, var(--orionplace-text));
869
986
  }
870
987
 
871
- .op-feature-prose :deep([data-bg-color='green']) {
988
+ .op-feature-prose :deep([data-bg-color='green']:where(:not([data-product-id] *, [data-product-ids] *))) {
872
989
  background: color-mix(in srgb, green 18%, transparent);
873
990
  }
874
991
 
875
- .op-feature-prose :deep([data-text-color='blue']) {
992
+ .op-feature-prose :deep([data-text-color='blue']:where(:not([data-product-id] *, [data-product-ids] *))) {
876
993
  color: color-mix(in srgb, blue 70%, var(--orionplace-text));
877
994
  }
878
995
 
879
- .op-feature-prose :deep([data-bg-color='blue']) {
996
+ .op-feature-prose :deep([data-bg-color='blue']:where(:not([data-product-id] *, [data-product-ids] *))) {
880
997
  background: color-mix(in srgb, blue 18%, transparent);
881
998
  }
882
999
 
883
- .op-feature-prose :deep([data-text-color='purple']) {
1000
+ .op-feature-prose :deep([data-text-color='purple']:where(:not([data-product-id] *, [data-product-ids] *))) {
884
1001
  color: color-mix(in srgb, purple 70%, var(--orionplace-text));
885
1002
  }
886
1003
 
887
- .op-feature-prose :deep([data-bg-color='purple']) {
1004
+ .op-feature-prose :deep([data-bg-color='purple']:where(:not([data-product-id] *, [data-product-ids] *))) {
888
1005
  background: color-mix(in srgb, purple 18%, transparent);
889
1006
  }
890
1007
 
891
- .op-feature-prose :deep([data-text-color='pink']) {
1008
+ .op-feature-prose :deep([data-text-color='pink']:where(:not([data-product-id] *, [data-product-ids] *))) {
892
1009
  color: color-mix(in srgb, pink 70%, var(--orionplace-text));
893
1010
  }
894
1011
 
895
- .op-feature-prose :deep([data-bg-color='pink']) {
1012
+ .op-feature-prose :deep([data-bg-color='pink']:where(:not([data-product-id] *, [data-product-ids] *))) {
896
1013
  background: color-mix(in srgb, pink 18%, transparent);
897
1014
  }
898
1015
 
899
- .op-feature-prose :deep([data-text-color='red']) {
1016
+ .op-feature-prose :deep([data-text-color='red']:where(:not([data-product-id] *, [data-product-ids] *))) {
900
1017
  color: color-mix(in srgb, red 70%, var(--orionplace-text));
901
1018
  }
902
1019
 
903
- .op-feature-prose :deep([data-bg-color='red']) {
1020
+ .op-feature-prose :deep([data-bg-color='red']:where(:not([data-product-id] *, [data-product-ids] *))) {
904
1021
  background: color-mix(in srgb, red 18%, transparent);
905
1022
  }
906
1023
  </style>
@@ -118,6 +118,9 @@
118
118
  :product-id="displayedProductId"
119
119
  :collection="collection"
120
120
  low-stock-label=""
121
+ :new-label="hiddenBadgeLabel('new')"
122
+ :popular-label="hiddenBadgeLabel('popular')"
123
+ :deal-label="hiddenBadgeLabel('deal')"
121
124
  class="pointer-events-none absolute"
122
125
  />
123
126
  <ProductCardActions
@@ -208,6 +211,9 @@
208
211
  :product-id="displayedProductId"
209
212
  :collection="collection"
210
213
  low-stock-label=""
214
+ :new-label="hiddenBadgeLabel('new')"
215
+ :popular-label="hiddenBadgeLabel('popular')"
216
+ :deal-label="hiddenBadgeLabel('deal')"
211
217
  />
212
218
  <ProductCardPrice
213
219
  data-product-part="ProductCardPrice"
@@ -359,6 +365,7 @@
359
365
  const card = ref<HTMLElement>();
360
366
  const { minHeight } = useOrionplaceStableHeight(card);
361
367
 
368
+ // hiddenBadges: badge keys not to show on this card (new / popular / deal).
362
369
  const props = withDefaults(
363
370
  defineProps<{
364
371
  productId: number;
@@ -369,6 +376,7 @@
369
376
  actionsDisabled?: boolean;
370
377
  purchaseActions?: boolean;
371
378
  variantProductIds?: number[];
379
+ hiddenBadges?: string[];
372
380
  }>(),
373
381
  {
374
382
  collection: 'products',
@@ -377,9 +385,15 @@
377
385
  disabled: false,
378
386
  actionsDisabled: false,
379
387
  purchaseActions: true,
388
+ hiddenBadges: () => [],
380
389
  },
381
390
  );
382
391
 
392
+ // An empty label hides the badge; undefined keeps the locale's label.
393
+ function hiddenBadgeLabel(key: string) {
394
+ return props.hiddenBadges.includes(key) ? '' : undefined;
395
+ }
396
+
383
397
  const store = useOrionplaceStore();
384
398
  const productStore = useOrionplaceProductsStore();
385
399
  const selectionStore = useOrionplaceSelectionsStore();
@@ -20,6 +20,7 @@
20
20
  :product-id="product.id"
21
21
  :collection="collection"
22
22
  :disabled="disabled"
23
+ :hidden-badges="hiddenBadges"
23
24
  @select="emit('select', $event)"
24
25
  @select-seller="emit('selectSeller', $event)"
25
26
  @select-variant="emit('selectVariant', $event)"
@@ -120,8 +121,9 @@
120
121
  limit?: number;
121
122
  productIds?: number[];
122
123
  layout?: 'responsive' | 'grid';
124
+ hiddenBadges?: string[];
123
125
  }>(),
124
- { collection: 'products', disabled: false, limit: 0, layout: 'responsive' },
126
+ { collection: 'products', disabled: false, limit: 0, layout: 'responsive', hiddenBadges: () => [] },
125
127
  );
126
128
 
127
129
  const store = useOrionplaceStore();
@@ -8,6 +8,7 @@ import {
8
8
  storefrontPublicOrigin,
9
9
  } from '@/utils/orionplace/pageSeo';
10
10
  import type { StorefrontSeoPage } from '@/utils/orionplace/pageSeo';
11
+ import { structuredDataJson } from '@/utils/orionplace/productStructuredData';
11
12
 
12
13
  export function useOrionplacePageSeo(
13
14
  page: MaybeRefOrGetter<StorefrontSeoPage>,
@@ -18,6 +19,12 @@ export function useOrionplacePageSeo(
18
19
  keywords?: MaybeRefOrGetter<string | undefined>;
19
20
  robots?: MaybeRefOrGetter<string | undefined>;
20
21
  image?: MaybeRefOrGetter<string | undefined>;
22
+ /**
23
+ * The page's schema.org data (a record page, e.g. the product), built
24
+ * from its canonical address and the visitor's origin. A page without a
25
+ * canonical address (preview, filtered, noindex) gets none.
26
+ */
27
+ structuredData?: (canonical: string, origin: string) => unknown;
21
28
  } = {},
22
29
  ) {
23
30
  const route = useRoute();
@@ -57,11 +64,26 @@ export function useOrionplacePageSeo(
57
64
  ogImage: () => storefrontAbsoluteUrl(toValue(values.image), origin),
58
65
  ogUrl: () => canonical.value,
59
66
  };
67
+ const structured = computed(() =>
68
+ canonical.value && values.structuredData
69
+ ? structuredDataJson(values.structuredData(canonical.value, origin))
70
+ : '',
71
+ );
60
72
  useSeoMeta(seo);
61
73
  useHead({
62
74
  link: () =>
63
75
  canonical.value
64
76
  ? [{ key: 'canonical', rel: 'canonical', href: canonical.value }]
65
77
  : [],
78
+ script: () =>
79
+ structured.value
80
+ ? [
81
+ {
82
+ key: 'orionplace-structured-data',
83
+ type: 'application/ld+json',
84
+ innerHTML: structured.value,
85
+ },
86
+ ]
87
+ : [],
66
88
  });
67
89
  }
@@ -1,11 +1,16 @@
1
1
  <script setup lang="ts">
2
2
  import { useOrionplaceStore } from '@/stores/orionplace';
3
3
  import { computed, ref, watch } from 'vue';
4
- import { navigateTo, useRoute } from '#imports';
4
+ import { navigateTo, useRoute, useState } from '#imports';
5
5
  import { useOrionplacePageSeo } from '@/composables/useOrionplacePageSeo';
6
6
  import { useOrionplaceMissingPage } from '@/composables/useOrionplaceMissingPage';
7
7
  import { useOrionplaceServerData } from '@/composables/useOrionplaceServerData';
8
8
  import { requestProductPage } from '@/utils/orionplace/productPage';
9
+ import { storefrontAbsoluteUrl } from '@/utils/orionplace/pageSeo';
10
+ import {
11
+ shippingCountryCodes,
12
+ storefrontProductStructuredData,
13
+ } from '@/utils/orionplace/productStructuredData';
9
14
  import {
10
15
  getAvailableNestedValueFromObject,
11
16
  plainText,
@@ -66,29 +71,52 @@
66
71
  return images?.full?.[0] ?? images?.small?.[0];
67
72
  });
68
73
 
69
- useOrionplacePageSeo('product', () => store.localeCode, {
70
- title: () =>
74
+ // ORIONPLACE_SHIPPING_COUNTRIES from the server's .env, for the return
75
+ // terms in the page's schema.org data; the browser reuses the value.
76
+ const shippingCountries = useState('orionplace:shipping-countries', () =>
77
+ shippingCountryCodes(
78
+ import.meta.server ? process.env.ORIONPLACE_SHIPPING_COUNTRIES : '',
79
+ ),
80
+ );
81
+ const productName = computed(() =>
82
+ getAvailableNestedValueFromObject(
83
+ product.value?.locale,
84
+ store.localeCode,
85
+ 'product_name',
86
+ ),
87
+ );
88
+ const productSummary = computed(() =>
89
+ plainText(
71
90
  getAvailableNestedValueFromObject(
72
91
  product.value?.locale,
73
92
  store.localeCode,
74
- 'product_name',
75
- ),
76
- description: () =>
77
- plainText(
93
+ 'meta_description',
94
+ ) ||
78
95
  getAvailableNestedValueFromObject(
79
96
  product.value?.locale,
80
97
  store.localeCode,
81
- 'meta_description',
82
- ) ||
83
- getAvailableNestedValueFromObject(
84
- product.value?.locale,
85
- store.localeCode,
86
- 'short_description',
87
- ),
88
- ).slice(0, 160),
98
+ 'short_description',
99
+ ),
100
+ ).slice(0, 160),
101
+ );
102
+
103
+ useOrionplacePageSeo('product', () => store.localeCode, {
104
+ title: () => productName.value,
105
+ description: () => productSummary.value,
89
106
  image: () => image.value,
90
107
  robots: () =>
91
108
  previewKey.value ? 'noindex, nofollow' : undefined,
109
+ structuredData: (canonical, origin) =>
110
+ storefrontProductStructuredData(product.value, {
111
+ url: canonical,
112
+ name: productName.value,
113
+ description: productSummary.value,
114
+ currencyId: store.currencyId,
115
+ currencyCode: store.currentCurrency?.currency_code,
116
+ shippingCountries: shippingCountries.value,
117
+ showSeller: !store.init?.visual_settings?.single_seller_mode,
118
+ absoluteUrl: (value) => storefrontAbsoluteUrl(value, origin),
119
+ }),
92
120
  });
93
121
 
94
122
  // An invalid ID, or a product that the selected endpoint does not return,
@@ -45,7 +45,7 @@ const attributes = [
45
45
  'data-text-align', 'data-vertical-align', 'data-colwidth',
46
46
  'data-code-scheme', 'data-gallery-width', 'data-list-offset',
47
47
  'data-float', 'data-blog-video', 'data-details-content',
48
- 'data-product-id', 'data-product-ids',
48
+ 'data-product-id', 'data-product-ids', 'data-product-hidden-badges',
49
49
  ];
50
50
 
51
51
  const marginLength = '(-?\\d+(\\.\\d+)?(px|em|rem|%)?|auto)';
@@ -0,0 +1,105 @@
1
+ /*
2
+ * Product placements in article text. The marketplace blog editor writes an
3
+ * empty block where the author put products:
4
+ * <div data-product-id="12"></div> one product card
5
+ * <div data-product-ids="12,15,9"></div> a row of cards (up to 24)
6
+ * with optional data-product-hidden-badges="new,popular,good-deal" and a
7
+ * float or centered style. The article shows product cards in those blocks.
8
+ */
9
+
10
+ export const blogPlacementMaxProducts = 24;
11
+
12
+ // The backend lists at most 35 products per page.
13
+ export const blogPlacementPageSize = 35;
14
+
15
+ export const blogPlacementSelector = '[data-product-id], [data-product-ids]';
16
+
17
+ // Editor badge names → product card badge keys.
18
+ const placementBadges = {
19
+ 'new': 'new',
20
+ 'popular': 'popular',
21
+ 'good-deal': 'deal',
22
+ } as const;
23
+
24
+ export type TBlogPlacementBadge = (typeof placementBadges)[keyof typeof placementBadges];
25
+
26
+ export interface IBlogProductPlacement {
27
+ element: HTMLElement;
28
+ layout: 'single' | 'list';
29
+ productIds: number[];
30
+ hiddenBadges: TBlogPlacementBadge[];
31
+ }
32
+
33
+ /** Positive whole ids from «12,15,9», without repeats, at most `limit`. */
34
+ export function blogPlacementIds(value: string | null | undefined, limit = blogPlacementMaxProducts): number[] {
35
+ const ids: number[] = [];
36
+
37
+ if (!value || value.length > 512) {
38
+ return ids;
39
+ }
40
+ for (const part of value.split(',')) {
41
+ const source = part.trim();
42
+ const id = /^\d+$/.test(source) ? Number(source) : 0;
43
+
44
+ if (Number.isSafeInteger(id) && id > 0 && !ids.includes(id)) {
45
+ ids.push(id);
46
+ }
47
+ if (ids.length >= limit) {
48
+ break;
49
+ }
50
+ }
51
+ return ids;
52
+ }
53
+
54
+ /** Card badge keys the author turned off for this placement. */
55
+ export function blogPlacementHiddenBadges(value: string | null | undefined): TBlogPlacementBadge[] {
56
+ if (!value || value.length > 512) {
57
+ return [];
58
+ }
59
+ const names = value.split(',').map((name) => name.trim().toLowerCase());
60
+
61
+ return Object.entries(placementBadges)
62
+ .filter(([name]) => names.includes(name))
63
+ .map(([, key]) => key);
64
+ }
65
+
66
+ /** The placements of a rendered article, in text order. */
67
+ export function blogProductPlacements(root: ParentNode | null | undefined): IBlogProductPlacement[] {
68
+ if (typeof root?.querySelectorAll !== 'function') {
69
+ return [];
70
+ }
71
+ return Array.from(root.querySelectorAll<HTMLElement>(blogPlacementSelector)).flatMap((element) => {
72
+ // Product cards inside a filled placement carry data-product-id too.
73
+ if (element.parentElement?.closest(blogPlacementSelector)) {
74
+ return [];
75
+ }
76
+ const list = element.hasAttribute('data-product-ids');
77
+ const productIds = list
78
+ ? blogPlacementIds(element.getAttribute('data-product-ids'))
79
+ : blogPlacementIds(element.getAttribute('data-product-id'), 1);
80
+
81
+ return productIds.length
82
+ ? [{
83
+ element,
84
+ layout: list ? 'list' : 'single',
85
+ productIds,
86
+ hiddenBadges: blogPlacementHiddenBadges(element.getAttribute('data-product-hidden-badges')),
87
+ }]
88
+ : [];
89
+ });
90
+ }
91
+
92
+ /** Every product id of the placements, once, in text order. */
93
+ export function blogPlacementProductIds(placements: Pick<IBlogProductPlacement, 'productIds'>[]): number[] {
94
+ return [...new Set(placements.flatMap((placement) => placement.productIds))];
95
+ }
96
+
97
+ /** Ids split into requests the backend answers in one page each. */
98
+ export function blogPlacementRequests(ids: number[]): number[][] {
99
+ const requests: number[][] = [];
100
+
101
+ for (let start = 0; start < ids.length; start += blogPlacementPageSize) {
102
+ requests.push(ids.slice(start, start + blogPlacementPageSize));
103
+ }
104
+ return requests;
105
+ }
@@ -0,0 +1,220 @@
1
+ // The product page's schema.org data for search engines: Google merchant
2
+ // listings and Yandex product snippets read the same block. Every value
3
+ // repeats what the page shows: the price and the crossed-out old price,
4
+ // stock, return terms, photos, brand, seller and rating. The delivery cost is
5
+ // left out: it depends on the buyer's address.
6
+ // The same file is in the three packages; it has no imports so that it stays
7
+ // identical.
8
+
9
+ const SCHEMA_ORG = 'https://schema.org/';
10
+ const MAX_IMAGES = 10;
11
+ // EProductStatus.PUBLISHED and PUBLISHED_AS_OPTION: the product is on sale.
12
+ const ON_SALE_STATUSES = [2, 1];
13
+
14
+ interface StructuredPrice {
15
+ transform_value: number;
16
+ }
17
+
18
+ /** The product fields the data uses (IProduct has them all). */
19
+ export interface StorefrontStructuredProduct {
20
+ id: number;
21
+ is_digital?: boolean;
22
+ discount: number;
23
+ base_price_dict: Record<string, StructuredPrice>;
24
+ product_price_dict: Record<string, StructuredPrice>;
25
+ images?: { full?: string[] | null; small?: string[] | null } | null;
26
+ status: { status: number };
27
+ c_real_left: number;
28
+ seller?: { name?: string | null } | null;
29
+ manufacture?: { name?: string | null } | null;
30
+ refunds?: {
31
+ customer_max_refund_days: number;
32
+ max_refund_days_with_defect: number;
33
+ } | null;
34
+ feedback_stat?: {
35
+ c_feedbacks?: number | null;
36
+ avg_raiting?: number | string | null;
37
+ } | null;
38
+ }
39
+
40
+ export interface StorefrontProductDataOptions {
41
+ /** The page's canonical address; without one the page gets no data. */
42
+ url: string | undefined;
43
+ name: string | undefined;
44
+ description?: string;
45
+ /** The currency the page shows prices in (its ID and code). */
46
+ currencyId: string;
47
+ currencyCode: string | undefined;
48
+ /** ORIONPLACE_SHIPPING_COUNTRIES, parsed by shippingCountryCodes(). */
49
+ shippingCountries: string[];
50
+ /** False when the page hides the seller (single-seller storefront). */
51
+ showSeller: boolean;
52
+ /** Makes an image address complete (storefrontAbsoluteUrl). */
53
+ absoluteUrl: (value: string | undefined) => string | undefined;
54
+ }
55
+
56
+ /**
57
+ * The countries the shop ships to, from ORIONPLACE_SHIPPING_COUNTRIES:
58
+ * ISO 3166-1 alpha-2 codes separated by spaces or commas, e.g. "RU BY".
59
+ */
60
+ export function shippingCountryCodes(value: unknown): string[] {
61
+ const codes = String(value ?? '')
62
+ .split(/[\s,;]+/)
63
+ .map((code) => code.trim().toUpperCase())
64
+ .filter((code) => /^[A-Z]{2}$/.test(code));
65
+
66
+ return [...new Set(codes)];
67
+ }
68
+
69
+ function wholeDays(value: unknown) {
70
+ const days = Math.floor(Number(value));
71
+
72
+ return Number.isFinite(days) && days > 0 ? days : 0;
73
+ }
74
+
75
+ /**
76
+ * The return terms the page shows: the days for a product without a defect
77
+ * are the return window (sent back by mail at the buyer's cost); a defective
78
+ * product goes back at the shop's cost. A digital product or one without
79
+ * days cannot be returned. Without a shipping country there is no policy:
80
+ * search engines require the country.
81
+ */
82
+ export function productReturnPolicy(
83
+ product: Pick<StorefrontStructuredProduct, 'is_digital' | 'refunds'>,
84
+ countries: string[],
85
+ ) {
86
+ if (!countries.length) {
87
+ return undefined;
88
+ }
89
+
90
+ const applicableCountry = countries.length === 1 ? countries[0] : countries;
91
+ const returnDays = product.is_digital
92
+ ? 0
93
+ : wholeDays(product.refunds?.customer_max_refund_days);
94
+ const defectDays = product.is_digital
95
+ ? 0
96
+ : wholeDays(product.refunds?.max_refund_days_with_defect);
97
+
98
+ if (!returnDays) {
99
+ return {
100
+ '@type': 'MerchantReturnPolicy',
101
+ applicableCountry,
102
+ 'returnPolicyCategory': `${SCHEMA_ORG}MerchantReturnNotPermitted`,
103
+ };
104
+ }
105
+
106
+ return {
107
+ '@type': 'MerchantReturnPolicy',
108
+ applicableCountry,
109
+ 'returnPolicyCategory': `${SCHEMA_ORG}MerchantReturnFiniteReturnWindow`,
110
+ 'merchantReturnDays': returnDays,
111
+ 'returnMethod': `${SCHEMA_ORG}ReturnByMail`,
112
+ 'returnFees': `${SCHEMA_ORG}ReturnFeesCustomerResponsibility`,
113
+ ...(defectDays ? { itemDefectReturnFees: `${SCHEMA_ORG}FreeReturn` } : {}),
114
+ };
115
+ }
116
+
117
+ function priceValue(
118
+ prices: Record<string, StructuredPrice> | undefined,
119
+ currencyId: string,
120
+ ) {
121
+ const value = Number(prices?.[currencyId]?.transform_value);
122
+
123
+ return prices?.[currencyId] && Number.isFinite(value) ? value : undefined;
124
+ }
125
+
126
+ /** The schema.org Product of the page, or undefined when it cannot be built. */
127
+ export function storefrontProductStructuredData(
128
+ product: StorefrontStructuredProduct | undefined,
129
+ options: StorefrontProductDataOptions,
130
+ ) {
131
+ const name = options.name?.trim();
132
+ const currencyCode = options.currencyCode?.trim().toUpperCase() ?? '';
133
+
134
+ if (!product || !options.url || !name) {
135
+ return undefined;
136
+ }
137
+
138
+ // The price the page shows, as ProductCardPrice picks it.
139
+ const currencyId =
140
+ options.currencyId ||
141
+ Object.keys(product.product_price_dict ?? {})[0] ||
142
+ Object.keys(product.base_price_dict ?? {})[0] ||
143
+ '';
144
+ const basePrice = priceValue(product.base_price_dict, currencyId);
145
+ const price =
146
+ priceValue(product.product_price_dict, currencyId) ?? basePrice;
147
+ const images = [
148
+ ...(product.images?.full ?? []),
149
+ ...(product.images?.small ?? []),
150
+ ]
151
+ .map((image) => options.absoluteUrl(image))
152
+ .filter((image, index, list): image is string =>
153
+ Boolean(image) && list.indexOf(image) === index,
154
+ )
155
+ .slice(0, MAX_IMAGES);
156
+ const inStock =
157
+ ON_SALE_STATUSES.includes(product.status?.status) &&
158
+ product.c_real_left > 0;
159
+ const returns = productReturnPolicy(product, options.shippingCountries);
160
+ const brand = product.manufacture?.name?.trim();
161
+ const seller = options.showSeller ? product.seller?.name?.trim() : '';
162
+ const reviews = Number(product.feedback_stat?.c_feedbacks ?? 0);
163
+ const rating = Number(product.feedback_stat?.avg_raiting ?? 0);
164
+ const offer =
165
+ price !== undefined && /^[A-Z]{3}$/.test(currencyCode)
166
+ ? {
167
+ '@type': 'Offer',
168
+ 'url': options.url,
169
+ price,
170
+ 'priceCurrency': currencyCode,
171
+ // The crossed-out old price, when the page shows one.
172
+ ...(basePrice !== undefined && price < basePrice
173
+ ? {
174
+ priceSpecification: {
175
+ '@type': 'UnitPriceSpecification',
176
+ 'priceType': `${SCHEMA_ORG}StrikethroughPrice`,
177
+ 'price': basePrice,
178
+ 'priceCurrency': currencyCode,
179
+ },
180
+ }
181
+ : {}),
182
+ 'availability': `${SCHEMA_ORG}${inStock ? 'InStock' : 'OutOfStock'}`,
183
+ 'itemCondition': `${SCHEMA_ORG}NewCondition`,
184
+ ...(returns ? { hasMerchantReturnPolicy: returns } : {}),
185
+ ...(seller
186
+ ? { seller: { '@type': 'Organization', 'name': seller } }
187
+ : {}),
188
+ }
189
+ : undefined;
190
+
191
+ return {
192
+ '@context': 'https://schema.org',
193
+ '@type': 'Product',
194
+ name,
195
+ 'url': options.url,
196
+ 'sku': String(product.id),
197
+ ...(options.description?.trim()
198
+ ? { description: options.description.trim() }
199
+ : {}),
200
+ ...(images.length ? { image: images } : {}),
201
+ ...(brand ? { brand: { '@type': 'Brand', 'name': brand } } : {}),
202
+ ...(offer ? { offers: offer } : {}),
203
+ ...(reviews > 0 && Number.isFinite(rating) && rating > 0
204
+ ? {
205
+ aggregateRating: {
206
+ '@type': 'AggregateRating',
207
+ 'ratingValue': Math.round(rating * 10) / 10,
208
+ 'reviewCount': reviews,
209
+ },
210
+ }
211
+ : {}),
212
+ };
213
+ }
214
+
215
+ /** The data as the text of a <script type="application/ld+json"> tag. */
216
+ export function structuredDataJson(value: unknown): string {
217
+ return value === undefined
218
+ ? ''
219
+ : JSON.stringify(value).replace(/</g, '\\u003c');
220
+ }