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 +3 -1
- package/package.json +1 -1
- package/runtime/marketplace.mjs +13 -1
- package/src/project-readme.mjs +8 -0
- package/templates/app/components/orionplace/blog/BlogArticle.README.md +2 -2
- package/templates/app/components/orionplace/blog/BlogArticle.vue +188 -71
- package/templates/app/components/orionplace/products/ProductCard.vue +14 -0
- package/templates/app/components/orionplace/products/ProductGroup.vue +3 -1
- package/templates/app/composables/useOrionplacePageSeo.ts +22 -0
- package/templates/app/pages/product/[id].vue +43 -15
- package/templates/app/utils/orionplace/blogArticleHtml.ts +1 -1
- package/templates/app/utils/orionplace/blogProductPlacements.ts +105 -0
- package/templates/app/utils/orionplace/productStructuredData.ts +220 -0
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
|
|
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.
|
|
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",
|
package/runtime/marketplace.mjs
CHANGED
|
@@ -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',
|
|
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, {
|
package/src/project-readme.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
'
|
|
75
|
-
)
|
|
76
|
-
description: () =>
|
|
77
|
-
plainText(
|
|
93
|
+
'meta_description',
|
|
94
|
+
) ||
|
|
78
95
|
getAvailableNestedValueFromObject(
|
|
79
96
|
product.value?.locale,
|
|
80
97
|
store.localeCode,
|
|
81
|
-
'
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
+
}
|