orionplace-svelte 0.6.2 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/package.json +1 -1
- package/runtime/marketplace.d.mts +5 -0
- package/runtime/marketplace.mjs +13 -1
- package/src/project-readme.mjs +7 -0
- package/templates/src/hooks.server.ts +5 -3
- package/templates/src/lib/composables/useOrionplacePageSeo.svelte.ts +13 -0
- package/templates/src/lib/interfaces/orionplace/appBase/IAppLoadCommon.ts +2 -0
- package/templates/src/lib/layouts/StorefrontPageHead.svelte +8 -0
- package/templates/src/lib/orionplace/navigation.svelte.ts +10 -1
- package/templates/src/lib/utils/orionplace/productStructuredData.ts +221 -0
- package/templates/src/routes/product/[id]/+page.svelte +38 -14
package/README.md
CHANGED
|
@@ -480,7 +480,9 @@ The corresponding `.cfg` is read on every request. Backend URLs must use HTTPS,
|
|
|
480
480
|
end in `/api`, and belong to `*.orionplace.app`. API methods, query strings, bodies,
|
|
481
481
|
authorization and cookies are forwarded. Backend statuses are retained and cookie
|
|
482
482
|
Domain attributes are removed to bind cookies to the storefront. Gateway responses
|
|
483
|
-
use `Cache-Control: no-store
|
|
483
|
+
use `Cache-Control: no-store`, except public blog images and videos: they keep the
|
|
484
|
+
backend's `private, max-age` rule, so the browser does not download them again on
|
|
485
|
+
every page. Network failures return `502`.
|
|
484
486
|
|
|
485
487
|
A changing request (POST, PUT, PATCH, DELETE) from another site is rejected with
|
|
486
488
|
`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-svelte",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "SvelteKit 2 storefront for your Orionplace marketplace: setup CLI, server API gateway, typed store and ready-made catalog, cart, checkout and account components in English and Russian, plus an Android app build.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,6 +37,11 @@ export declare function marketplacePublicOrigin(
|
|
|
37
37
|
tenantDomain?: string,
|
|
38
38
|
): string;
|
|
39
39
|
|
|
40
|
+
export declare function marketplaceCacheControl(
|
|
41
|
+
media: boolean,
|
|
42
|
+
upstream: Response,
|
|
43
|
+
): string;
|
|
44
|
+
|
|
40
45
|
export declare function noindexPathPrefixes(value?: string): string[];
|
|
41
46
|
|
|
42
47
|
export declare function marketplaceNoindexPath(
|
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
|
@@ -403,6 +403,13 @@ for example \`ORIONPLACE_NOINDEX_PATHS="/catalog /category /product"\`. Those
|
|
|
403
403
|
pages answer with \`X-Robots-Tag: noindex, follow\` and the sitemap leaves them
|
|
404
404
|
out; the journal and other pages stay indexable. Leave it empty on a live shop.
|
|
405
405
|
|
|
406
|
+
Every product page carries schema.org product data for Google and Yandex: the
|
|
407
|
+
price, the crossed-out old price, stock, photos, brand, seller, rating and the
|
|
408
|
+
return terms the page shows. The return terms name the shop's shipping
|
|
409
|
+
countries, which Application Load sends in \`init.shipping_countries\`; without
|
|
410
|
+
a shipping country they are left out. The delivery cost is not included: it
|
|
411
|
+
depends on the buyer's address.
|
|
412
|
+
|
|
406
413
|
This setup does not add analytics.
|
|
407
414
|
|
|
408
415
|
## Android app
|
|
@@ -12,10 +12,12 @@ function escapeAttribute(value: string) {
|
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Personalized pages are never shared between visitors or storefront domains.
|
|
15
|
-
* The API gateway, robots.txt and the sitemap set their own
|
|
16
|
-
* requests are left alone
|
|
15
|
+
* The API gateway (/api, /ps3 media), robots.txt and the sitemap set their own
|
|
16
|
+
* headers, so those requests are left alone: public blog images keep the
|
|
17
|
+
* backend's browser cache rule.
|
|
17
18
|
*/
|
|
18
19
|
const ownHeaders = ['/robots.txt', '/sitemap.xml'];
|
|
20
|
+
const gatewayPaths = ['/api/', '/ps3/'];
|
|
19
21
|
|
|
20
22
|
export const handle: Handle = async ({ event, resolve }) => {
|
|
21
23
|
const response = await resolve(event, {
|
|
@@ -26,7 +28,7 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|
|
26
28
|
),
|
|
27
29
|
});
|
|
28
30
|
if (
|
|
29
|
-
!event.url.pathname.startsWith(
|
|
31
|
+
!gatewayPaths.some((path) => event.url.pathname.startsWith(path)) &&
|
|
30
32
|
!ownHeaders.includes(event.url.pathname)
|
|
31
33
|
) {
|
|
32
34
|
response.headers.set('Cache-Control', 'private, no-store');
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
storefrontPublicOrigin,
|
|
11
11
|
} from '@/lib/utils/orionplace/pageSeo';
|
|
12
12
|
import type { StorefrontSeoPage } from '@/lib/utils/orionplace/pageSeo';
|
|
13
|
+
import { structuredDataJson } from '@/lib/utils/orionplace/productStructuredData';
|
|
13
14
|
|
|
14
15
|
export function useOrionplacePageSeo(
|
|
15
16
|
page: MaybeRefOrGetter<StorefrontSeoPage>,
|
|
@@ -20,6 +21,12 @@ export function useOrionplacePageSeo(
|
|
|
20
21
|
keywords?: MaybeRefOrGetter<string | undefined>;
|
|
21
22
|
robots?: MaybeRefOrGetter<string | undefined>;
|
|
22
23
|
image?: MaybeRefOrGetter<string | undefined>;
|
|
24
|
+
/**
|
|
25
|
+
* The page's schema.org data (a record page, e.g. the product), built
|
|
26
|
+
* from its canonical address and the visitor's origin. A page without a
|
|
27
|
+
* canonical address (preview, filtered, noindex) gets none.
|
|
28
|
+
*/
|
|
29
|
+
structuredData?: (canonical: string, origin: string) => unknown;
|
|
23
30
|
} = {},
|
|
24
31
|
) {
|
|
25
32
|
// The address the visitor used: the proxy's host during the server render.
|
|
@@ -56,8 +63,14 @@ export function useOrionplacePageSeo(
|
|
|
56
63
|
ogImage: () => storefrontAbsoluteUrl(toValue(values.image), origin),
|
|
57
64
|
ogUrl: () => canonical.value,
|
|
58
65
|
};
|
|
66
|
+
const structured = computed(() =>
|
|
67
|
+
canonical.value && values.structuredData
|
|
68
|
+
? structuredDataJson(values.structuredData(canonical.value, origin))
|
|
69
|
+
: '',
|
|
70
|
+
);
|
|
59
71
|
useSeoMeta(seo);
|
|
60
72
|
useHead(() => ({
|
|
61
73
|
link: canonical.value ? [{ rel: 'canonical', href: canonical.value }] : [],
|
|
74
|
+
jsonLd: structured.value || undefined,
|
|
62
75
|
}));
|
|
63
76
|
}
|
|
@@ -24,6 +24,8 @@ export interface IAppLoadCommon {
|
|
|
24
24
|
weights: Record<string, IAppLoadCommonUnit>;
|
|
25
25
|
lengths: Record<string, IAppLoadCommonUnit>;
|
|
26
26
|
geo_location_enabled: boolean;
|
|
27
|
+
/** ISO codes of the shop's shipping countries, sorted. */
|
|
28
|
+
shipping_countries?: string[];
|
|
27
29
|
is_authenticated: boolean;
|
|
28
30
|
wallet?: Record<string, IAppLoadCommonWalletValue>;
|
|
29
31
|
logos: IAppLoadCommonLogos;
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
import type { PageHead } from '@/lib/orionplace/navigation.svelte';
|
|
3
3
|
|
|
4
4
|
let { metadata }: { metadata: PageHead } = $props();
|
|
5
|
+
|
|
6
|
+
// The JSON is escaped by structuredDataJson, so it cannot close the tag.
|
|
7
|
+
const scriptOpen = '<script type="application/ld+json">';
|
|
8
|
+
const scriptClose = '</' + 'script>';
|
|
5
9
|
</script>
|
|
6
10
|
|
|
7
11
|
<svelte:head>
|
|
@@ -23,6 +27,10 @@
|
|
|
23
27
|
{/if}
|
|
24
28
|
{/each}
|
|
25
29
|
|
|
30
|
+
{#if metadata.jsonLd}
|
|
31
|
+
{@html scriptOpen + metadata.jsonLd + scriptClose}
|
|
32
|
+
{/if}
|
|
33
|
+
|
|
26
34
|
{#each metadata.link ?? [] as link (link.rel)}
|
|
27
35
|
{#if link.href}
|
|
28
36
|
<link
|
|
@@ -105,16 +105,20 @@ export interface PageHead {
|
|
|
105
105
|
meta?: Record<string, any>[];
|
|
106
106
|
// <link> tags, one per `rel` (the canonical address).
|
|
107
107
|
link?: { rel: string; href: string }[];
|
|
108
|
+
// The page's schema.org data as JSON (structuredDataJson), if it has any.
|
|
109
|
+
jsonLd?: string;
|
|
108
110
|
}
|
|
109
111
|
type HeadReader = () => PageHead;
|
|
110
112
|
|
|
111
113
|
function collectHead(readers: HeadReader[]): PageHead {
|
|
112
114
|
let title: string | undefined;
|
|
115
|
+
let jsonLd: string | undefined;
|
|
113
116
|
const meta = new Map<string, Record<string, any>>();
|
|
114
117
|
const link = new Map<string, { rel: string; href: string }>();
|
|
115
118
|
for (const read of readers) {
|
|
116
119
|
const head = read();
|
|
117
120
|
title = head.title ?? title;
|
|
121
|
+
jsonLd = head.jsonLd || jsonLd;
|
|
118
122
|
for (const tag of head.meta ?? []) {
|
|
119
123
|
const key = tag.property ?? tag.name;
|
|
120
124
|
if (key) {
|
|
@@ -125,7 +129,12 @@ function collectHead(readers: HeadReader[]): PageHead {
|
|
|
125
129
|
link.set(tag.rel, tag);
|
|
126
130
|
}
|
|
127
131
|
}
|
|
128
|
-
return {
|
|
132
|
+
return {
|
|
133
|
+
title,
|
|
134
|
+
meta: [...meta.values()],
|
|
135
|
+
link: [...link.values()],
|
|
136
|
+
...(jsonLd ? { jsonLd } : {}),
|
|
137
|
+
};
|
|
129
138
|
}
|
|
130
139
|
|
|
131
140
|
interface HeadRegistry {
|
|
@@ -0,0 +1,221 @@
|
|
|
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
|
+
/** App Load `init.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: App Load `init.shipping_countries` (the
|
|
58
|
+
* backend's countries marked as shipping countries), as valid ISO 3166-1
|
|
59
|
+
* alpha-2 codes.
|
|
60
|
+
*/
|
|
61
|
+
export function shippingCountryCodes(value: unknown): string[] {
|
|
62
|
+
const codes = (Array.isArray(value) ? value.join(' ') : String(value ?? ''))
|
|
63
|
+
.split(/[\s,;]+/)
|
|
64
|
+
.map((code) => code.trim().toUpperCase())
|
|
65
|
+
.filter((code) => /^[A-Z]{2}$/.test(code));
|
|
66
|
+
|
|
67
|
+
return [...new Set(codes)];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function wholeDays(value: unknown) {
|
|
71
|
+
const days = Math.floor(Number(value));
|
|
72
|
+
|
|
73
|
+
return Number.isFinite(days) && days > 0 ? days : 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The return terms the page shows: the days for a product without a defect
|
|
78
|
+
* are the return window (sent back by mail at the buyer's cost); a defective
|
|
79
|
+
* product goes back at the shop's cost. A digital product or one without
|
|
80
|
+
* days cannot be returned. Without a shipping country there is no policy:
|
|
81
|
+
* search engines require the country.
|
|
82
|
+
*/
|
|
83
|
+
export function productReturnPolicy(
|
|
84
|
+
product: Pick<StorefrontStructuredProduct, 'is_digital' | 'refunds'>,
|
|
85
|
+
countries: string[],
|
|
86
|
+
) {
|
|
87
|
+
if (!countries.length) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const applicableCountry = countries.length === 1 ? countries[0] : countries;
|
|
92
|
+
const returnDays = product.is_digital
|
|
93
|
+
? 0
|
|
94
|
+
: wholeDays(product.refunds?.customer_max_refund_days);
|
|
95
|
+
const defectDays = product.is_digital
|
|
96
|
+
? 0
|
|
97
|
+
: wholeDays(product.refunds?.max_refund_days_with_defect);
|
|
98
|
+
|
|
99
|
+
if (!returnDays) {
|
|
100
|
+
return {
|
|
101
|
+
'@type': 'MerchantReturnPolicy',
|
|
102
|
+
applicableCountry,
|
|
103
|
+
'returnPolicyCategory': `${SCHEMA_ORG}MerchantReturnNotPermitted`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
'@type': 'MerchantReturnPolicy',
|
|
109
|
+
applicableCountry,
|
|
110
|
+
'returnPolicyCategory': `${SCHEMA_ORG}MerchantReturnFiniteReturnWindow`,
|
|
111
|
+
'merchantReturnDays': returnDays,
|
|
112
|
+
'returnMethod': `${SCHEMA_ORG}ReturnByMail`,
|
|
113
|
+
'returnFees': `${SCHEMA_ORG}ReturnFeesCustomerResponsibility`,
|
|
114
|
+
...(defectDays ? { itemDefectReturnFees: `${SCHEMA_ORG}FreeReturn` } : {}),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function priceValue(
|
|
119
|
+
prices: Record<string, StructuredPrice> | undefined,
|
|
120
|
+
currencyId: string,
|
|
121
|
+
) {
|
|
122
|
+
const value = Number(prices?.[currencyId]?.transform_value);
|
|
123
|
+
|
|
124
|
+
return prices?.[currencyId] && Number.isFinite(value) ? value : undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The schema.org Product of the page, or undefined when it cannot be built. */
|
|
128
|
+
export function storefrontProductStructuredData(
|
|
129
|
+
product: StorefrontStructuredProduct | undefined,
|
|
130
|
+
options: StorefrontProductDataOptions,
|
|
131
|
+
) {
|
|
132
|
+
const name = options.name?.trim();
|
|
133
|
+
const currencyCode = options.currencyCode?.trim().toUpperCase() ?? '';
|
|
134
|
+
|
|
135
|
+
if (!product || !options.url || !name) {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The price the page shows, as ProductCardPrice picks it.
|
|
140
|
+
const currencyId =
|
|
141
|
+
options.currencyId ||
|
|
142
|
+
Object.keys(product.product_price_dict ?? {})[0] ||
|
|
143
|
+
Object.keys(product.base_price_dict ?? {})[0] ||
|
|
144
|
+
'';
|
|
145
|
+
const basePrice = priceValue(product.base_price_dict, currencyId);
|
|
146
|
+
const price =
|
|
147
|
+
priceValue(product.product_price_dict, currencyId) ?? basePrice;
|
|
148
|
+
const images = [
|
|
149
|
+
...(product.images?.full ?? []),
|
|
150
|
+
...(product.images?.small ?? []),
|
|
151
|
+
]
|
|
152
|
+
.map((image) => options.absoluteUrl(image))
|
|
153
|
+
.filter((image, index, list): image is string =>
|
|
154
|
+
Boolean(image) && list.indexOf(image) === index,
|
|
155
|
+
)
|
|
156
|
+
.slice(0, MAX_IMAGES);
|
|
157
|
+
const inStock =
|
|
158
|
+
ON_SALE_STATUSES.includes(product.status?.status) &&
|
|
159
|
+
product.c_real_left > 0;
|
|
160
|
+
const returns = productReturnPolicy(product, options.shippingCountries);
|
|
161
|
+
const brand = product.manufacture?.name?.trim();
|
|
162
|
+
const seller = options.showSeller ? product.seller?.name?.trim() : '';
|
|
163
|
+
const reviews = Number(product.feedback_stat?.c_feedbacks ?? 0);
|
|
164
|
+
const rating = Number(product.feedback_stat?.avg_raiting ?? 0);
|
|
165
|
+
const offer =
|
|
166
|
+
price !== undefined && /^[A-Z]{3}$/.test(currencyCode)
|
|
167
|
+
? {
|
|
168
|
+
'@type': 'Offer',
|
|
169
|
+
'url': options.url,
|
|
170
|
+
price,
|
|
171
|
+
'priceCurrency': currencyCode,
|
|
172
|
+
// The crossed-out old price, when the page shows one.
|
|
173
|
+
...(basePrice !== undefined && price < basePrice
|
|
174
|
+
? {
|
|
175
|
+
priceSpecification: {
|
|
176
|
+
'@type': 'UnitPriceSpecification',
|
|
177
|
+
'priceType': `${SCHEMA_ORG}StrikethroughPrice`,
|
|
178
|
+
'price': basePrice,
|
|
179
|
+
'priceCurrency': currencyCode,
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
: {}),
|
|
183
|
+
'availability': `${SCHEMA_ORG}${inStock ? 'InStock' : 'OutOfStock'}`,
|
|
184
|
+
'itemCondition': `${SCHEMA_ORG}NewCondition`,
|
|
185
|
+
...(returns ? { hasMerchantReturnPolicy: returns } : {}),
|
|
186
|
+
...(seller
|
|
187
|
+
? { seller: { '@type': 'Organization', 'name': seller } }
|
|
188
|
+
: {}),
|
|
189
|
+
}
|
|
190
|
+
: undefined;
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
'@context': 'https://schema.org',
|
|
194
|
+
'@type': 'Product',
|
|
195
|
+
name,
|
|
196
|
+
'url': options.url,
|
|
197
|
+
'sku': String(product.id),
|
|
198
|
+
...(options.description?.trim()
|
|
199
|
+
? { description: options.description.trim() }
|
|
200
|
+
: {}),
|
|
201
|
+
...(images.length ? { image: images } : {}),
|
|
202
|
+
...(brand ? { brand: { '@type': 'Brand', 'name': brand } } : {}),
|
|
203
|
+
...(offer ? { offers: offer } : {}),
|
|
204
|
+
...(reviews > 0 && Number.isFinite(rating) && rating > 0
|
|
205
|
+
? {
|
|
206
|
+
aggregateRating: {
|
|
207
|
+
'@type': 'AggregateRating',
|
|
208
|
+
'ratingValue': Math.round(rating * 10) / 10,
|
|
209
|
+
'reviewCount': reviews,
|
|
210
|
+
},
|
|
211
|
+
}
|
|
212
|
+
: {}),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The data as the text of a <script type="application/ld+json"> tag. */
|
|
217
|
+
export function structuredDataJson(value: unknown): string {
|
|
218
|
+
return value === undefined
|
|
219
|
+
? ''
|
|
220
|
+
: JSON.stringify(value).replace(/</g, '\\u003c');
|
|
221
|
+
}
|
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
import { useOrionplaceMissingPage } from '@/lib/composables/useOrionplaceMissingPage.svelte';
|
|
7
7
|
import { useOrionplaceServerData } from '@/lib/composables/useOrionplaceServerData.svelte';
|
|
8
8
|
import { requestProductPage } from '@/lib/utils/orionplace/productPage';
|
|
9
|
+
import { storefrontAbsoluteUrl } from '@/lib/utils/orionplace/pageSeo';
|
|
10
|
+
import {
|
|
11
|
+
shippingCountryCodes,
|
|
12
|
+
storefrontProductStructuredData,
|
|
13
|
+
} from '@/lib/utils/orionplace/productStructuredData';
|
|
9
14
|
import {
|
|
10
15
|
getAvailableNestedValueFromObject,
|
|
11
16
|
plainText,
|
|
@@ -72,28 +77,47 @@
|
|
|
72
77
|
return images?.full?.[0] ?? images?.small?.[0];
|
|
73
78
|
});
|
|
74
79
|
|
|
75
|
-
|
|
76
|
-
|
|
80
|
+
const productName = computed(() =>
|
|
81
|
+
getAvailableNestedValueFromObject(
|
|
82
|
+
product.value?.locale,
|
|
83
|
+
store.localeCode,
|
|
84
|
+
'product_name',
|
|
85
|
+
),
|
|
86
|
+
);
|
|
87
|
+
const productSummary = computed(() =>
|
|
88
|
+
plainText(
|
|
77
89
|
getAvailableNestedValueFromObject(
|
|
78
90
|
product.value?.locale,
|
|
79
91
|
store.localeCode,
|
|
80
|
-
'
|
|
81
|
-
)
|
|
82
|
-
description: () =>
|
|
83
|
-
plainText(
|
|
92
|
+
'meta_description',
|
|
93
|
+
) ||
|
|
84
94
|
getAvailableNestedValueFromObject(
|
|
85
95
|
product.value?.locale,
|
|
86
96
|
store.localeCode,
|
|
87
|
-
'
|
|
88
|
-
)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
97
|
+
'short_description',
|
|
98
|
+
),
|
|
99
|
+
).slice(0, 160),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
useOrionplacePageSeo('product', () => store.localeCode, {
|
|
103
|
+
title: () => productName.value,
|
|
104
|
+
description: () => productSummary.value,
|
|
95
105
|
image: () => image.value,
|
|
96
106
|
robots: () => (previewKey.value ? 'noindex, nofollow' : undefined),
|
|
107
|
+
structuredData: (canonical, origin) =>
|
|
108
|
+
storefrontProductStructuredData(product.value, {
|
|
109
|
+
url: canonical,
|
|
110
|
+
name: productName.value,
|
|
111
|
+
description: productSummary.value,
|
|
112
|
+
currencyId: store.currencyId,
|
|
113
|
+
currencyCode: store.currentCurrency?.currency_code,
|
|
114
|
+
// The backend's shipping countries, for the return terms.
|
|
115
|
+
shippingCountries: shippingCountryCodes(
|
|
116
|
+
store.init?.shipping_countries,
|
|
117
|
+
),
|
|
118
|
+
showSeller: !store.init?.visual_settings?.single_seller_mode,
|
|
119
|
+
absoluteUrl: (value) => storefrontAbsoluteUrl(value, origin),
|
|
120
|
+
}),
|
|
97
121
|
});
|
|
98
122
|
|
|
99
123
|
// An invalid ID, or a product that the selected endpoint does not return,
|