@scayle/storefront-product-detail 0.1.0 → 0.1.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/dist/module.mjs +1 -0
- package/dist/runtime/composables/useProductSeoData.d.ts +42 -0
- package/dist/runtime/composables/useProductSeoData.js +36 -0
- package/dist/runtime/composables/useProductSeoData.test.d.ts +1 -0
- package/dist/runtime/composables/useProductSeoData.test.js +68 -0
- package/dist/runtime/index.d.ts +2 -0
- package/dist/runtime/index.js +2 -0
- package/dist/runtime/utils/attribute.d.ts +2 -0
- package/dist/runtime/utils/attribute.js +19 -0
- package/dist/runtime/utils/attribute.test.d.ts +1 -0
- package/dist/runtime/utils/attribute.test.js +39 -0
- package/dist/runtime/utils/product.test.js +1 -1
- package/dist/runtime/utils/seo.d.ts +10 -0
- package/dist/runtime/utils/seo.js +38 -0
- package/dist/runtime/utils/seo.test.d.ts +1 -0
- package/dist/runtime/utils/seo.test.js +38 -0
- package/package.json +3 -3
package/dist/module.mjs
CHANGED
|
@@ -16,6 +16,7 @@ const module = defineNuxtModule({
|
|
|
16
16
|
nuxt.options.alias["#storefront-product-detail"] = resolve("./runtime");
|
|
17
17
|
nuxt.options.build.transpile.push("#storefront-product-detail/runtime");
|
|
18
18
|
if (options.autoImports) {
|
|
19
|
+
addImportsDir(resolve("./runtime/composables"));
|
|
19
20
|
addImportsDir(resolve("./runtime/utils"));
|
|
20
21
|
}
|
|
21
22
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type ComputedRef } from 'vue';
|
|
2
|
+
import type { BreadcrumbItem, Variant } from '@scayle/storefront-nuxt';
|
|
3
|
+
import type { WithContext, Product, BreadcrumbList } from 'schema-dts';
|
|
4
|
+
type CanonicalLink = Record<'rel' | 'key' | 'href', string>;
|
|
5
|
+
interface UrlParams {
|
|
6
|
+
baseUrl: string;
|
|
7
|
+
fullPath: string;
|
|
8
|
+
}
|
|
9
|
+
interface ProductInfo {
|
|
10
|
+
name: string;
|
|
11
|
+
brand: string;
|
|
12
|
+
productDescription: string;
|
|
13
|
+
variants: Variant[];
|
|
14
|
+
}
|
|
15
|
+
interface UseProductSeoDataReturn {
|
|
16
|
+
robots: string;
|
|
17
|
+
canonicalLink: ComputedRef<CanonicalLink[]>;
|
|
18
|
+
title: string;
|
|
19
|
+
productJsonLd: ComputedRef<WithContext<Product>>;
|
|
20
|
+
productBreadcrumbJsonLd: ComputedRef<WithContext<BreadcrumbList>>;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Composable function to generate SEO-related data for a product page.
|
|
24
|
+
*
|
|
25
|
+
* @param {BreadcrumbItem[]} breadcrumbs - Array of breadcrumb items representing the navigation path to the product.
|
|
26
|
+
* @param {UrlParams} urlParams - Object containing baseUrl and fullPath to build and sanitize canonicalUrl.
|
|
27
|
+
* @param {string[]} images - Array of image URLs for the product.
|
|
28
|
+
* @param {ProductInfo} productInfo - Object containing product information including name, brand, description, and variants.
|
|
29
|
+
* @property {string} name - The name of the product.
|
|
30
|
+
* @property {string} brand - The brand of the product.
|
|
31
|
+
* @property {string} productDescription - A description of the product.
|
|
32
|
+
* @property {Variant[]} variants - Array of product variants.
|
|
33
|
+
*
|
|
34
|
+
* @returns {UseProductSeoDataReturn} An object containing SEO data for the product page with following properties:
|
|
35
|
+
* @property {string} robots - Robots meta tag value, typically "index, follow".
|
|
36
|
+
* @property {ComputedRef<CanonicalLink[]>} canonicalLink - Computed ref for canonical link tag metadata.
|
|
37
|
+
* @property {string} title - The title of the product.
|
|
38
|
+
* @property {ComputedRef<WithContext<Product>>} productJsonLd - Computed ref containing JSON-LD structured data for the product.
|
|
39
|
+
* @property {ComputedRef<WithContext<BreadcrumbList>>} productBreadcrumbJsonLd - Computed ref containing JSON-LD structured data for product breadcrumbs.
|
|
40
|
+
*/
|
|
41
|
+
export declare function useProductSeoData(breadcrumbs: BreadcrumbItem[], urlParams: UrlParams, images: string[], productInfo: ProductInfo): UseProductSeoDataReturn;
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { computed } from "vue";
|
|
2
|
+
import {
|
|
3
|
+
generateCategoryBreadcrumbSchema,
|
|
4
|
+
sanitizeCanonicalURL
|
|
5
|
+
} from "@scayle/storefront-nuxt";
|
|
6
|
+
import { generateProductSchema } from "~/src/runtime/utils/seo";
|
|
7
|
+
export function useProductSeoData(breadcrumbs, urlParams, images, productInfo) {
|
|
8
|
+
const canonicalUrl = computed(() => {
|
|
9
|
+
const url = `${urlParams.baseUrl}${urlParams.fullPath}`;
|
|
10
|
+
return sanitizeCanonicalURL(url, []);
|
|
11
|
+
});
|
|
12
|
+
const canonicalLink = computed(() => {
|
|
13
|
+
return [{ rel: "canonical", key: "canonical", href: canonicalUrl.value }];
|
|
14
|
+
});
|
|
15
|
+
const robots = "index, follow";
|
|
16
|
+
const productJsonLd = computed(() => {
|
|
17
|
+
return generateProductSchema({
|
|
18
|
+
productName: productInfo.name || "",
|
|
19
|
+
brandName: productInfo.brand || "",
|
|
20
|
+
url: canonicalUrl.value,
|
|
21
|
+
variants: productInfo.variants || [],
|
|
22
|
+
images,
|
|
23
|
+
description: productInfo.productDescription
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
const productBreadcrumbJsonLd = computed(() => {
|
|
27
|
+
return generateCategoryBreadcrumbSchema(breadcrumbs);
|
|
28
|
+
});
|
|
29
|
+
return {
|
|
30
|
+
robots,
|
|
31
|
+
canonicalLink,
|
|
32
|
+
title: productInfo.name,
|
|
33
|
+
productJsonLd,
|
|
34
|
+
productBreadcrumbJsonLd
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { useProductSeoData } from "~/src/runtime";
|
|
3
|
+
import { variantFactory } from "@scayle/storefront-nuxt/dist/test/factories";
|
|
4
|
+
describe("useProductSeoData", () => {
|
|
5
|
+
it("should return product seo data from product", () => {
|
|
6
|
+
const {
|
|
7
|
+
title,
|
|
8
|
+
productBreadcrumbJsonLd,
|
|
9
|
+
productJsonLd,
|
|
10
|
+
robots,
|
|
11
|
+
canonicalLink
|
|
12
|
+
} = useProductSeoData(
|
|
13
|
+
[{ value: "value", to: "/" }],
|
|
14
|
+
{ baseUrl: "baseUrl", fullPath: "fullPath" },
|
|
15
|
+
[],
|
|
16
|
+
{
|
|
17
|
+
name: "name",
|
|
18
|
+
variants: [variantFactory.build({ id: 1 })],
|
|
19
|
+
brand: "brand",
|
|
20
|
+
productDescription: "description"
|
|
21
|
+
}
|
|
22
|
+
);
|
|
23
|
+
expect(title).toBe("name");
|
|
24
|
+
expect(productBreadcrumbJsonLd.value).toStrictEqual({
|
|
25
|
+
"@context": "https://schema.org",
|
|
26
|
+
"@type": "BreadcrumbList",
|
|
27
|
+
"itemListElement": [
|
|
28
|
+
{
|
|
29
|
+
"@type": "ListItem",
|
|
30
|
+
"item": "/",
|
|
31
|
+
"name": "value",
|
|
32
|
+
"position": 1
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
});
|
|
36
|
+
expect(productJsonLd.value).toStrictEqual({
|
|
37
|
+
"@context": "https://schema.org",
|
|
38
|
+
"@type": "Product",
|
|
39
|
+
"brand": {
|
|
40
|
+
"@type": "Brand",
|
|
41
|
+
"name": "brand"
|
|
42
|
+
},
|
|
43
|
+
"description": "description",
|
|
44
|
+
"image": [],
|
|
45
|
+
"name": "name",
|
|
46
|
+
"offers": [
|
|
47
|
+
{
|
|
48
|
+
"@type": "Offer",
|
|
49
|
+
"availability": "https://schema.org/InStock",
|
|
50
|
+
"itemCondition": "https://schema.org/NewCondition",
|
|
51
|
+
"mpn": void 0,
|
|
52
|
+
"price": 1,
|
|
53
|
+
"priceCurrency": "USD",
|
|
54
|
+
"sku": "1"
|
|
55
|
+
}
|
|
56
|
+
],
|
|
57
|
+
"url": "baseUrlfullPath"
|
|
58
|
+
});
|
|
59
|
+
expect(robots).toBe("index, follow");
|
|
60
|
+
expect(canonicalLink.value).toStrictEqual([
|
|
61
|
+
{
|
|
62
|
+
"href": "baseUrlfullPath",
|
|
63
|
+
"key": "canonical",
|
|
64
|
+
"rel": "canonical"
|
|
65
|
+
}
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
});
|
package/dist/runtime/index.d.ts
CHANGED
package/dist/runtime/index.js
CHANGED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const getFormattedLabel = (attributeGroup) => {
|
|
2
|
+
if (!attributeGroup) {
|
|
3
|
+
return [];
|
|
4
|
+
}
|
|
5
|
+
const attributeGroupValues = Array.isArray(attributeGroup.values) ? attributeGroup.values : [attributeGroup.values];
|
|
6
|
+
return attributeGroupValues.map(
|
|
7
|
+
(value) => attributeGroup.type === "extras" ? value.label : `${attributeGroup.label}: ${value.label}`
|
|
8
|
+
);
|
|
9
|
+
};
|
|
10
|
+
export const getFilteredAttributeGroups = (attributes, filterTypes) => {
|
|
11
|
+
return Object.values(attributes).filter(
|
|
12
|
+
(attribute) => attribute?.type && filterTypes.includes(attribute.type)
|
|
13
|
+
).reduce((map, attributeValue) => {
|
|
14
|
+
const key = attributeValue?.type || "";
|
|
15
|
+
const value = getFormattedLabel(attributeValue);
|
|
16
|
+
map.set(key, (map.get(key) || []).concat(value));
|
|
17
|
+
return map;
|
|
18
|
+
}, /* @__PURE__ */ new Map());
|
|
19
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { it, describe, expect } from "vitest";
|
|
2
|
+
import { attributeGroupFactory } from "@scayle/storefront-nuxt/dist/test/factories";
|
|
3
|
+
import { getFilteredAttributeGroups } from "./attribute.js";
|
|
4
|
+
describe("attribute", () => {
|
|
5
|
+
describe("getFilteredAttributeGroups", () => {
|
|
6
|
+
it("should return correct filtered and formatted attributes", () => {
|
|
7
|
+
const attributes = {
|
|
8
|
+
design: attributeGroupFactory.build({
|
|
9
|
+
type: "design",
|
|
10
|
+
label: "Jackenart",
|
|
11
|
+
values: { id: 2534, label: "Bomberjacke", value: "bomberjacke" }
|
|
12
|
+
}),
|
|
13
|
+
extras: attributeGroupFactory.build({
|
|
14
|
+
label: "Extras",
|
|
15
|
+
type: "extras",
|
|
16
|
+
multiSelect: true,
|
|
17
|
+
values: [
|
|
18
|
+
{ id: 2427, label: "Weicher Griff", value: "weicher_griff" },
|
|
19
|
+
{ id: 2435, label: "Ton-in-Ton-N\xE4hte", value: "tonintonnhte" },
|
|
20
|
+
{ id: 2438, label: "Label-Stickerei", value: "labelstickerei" }
|
|
21
|
+
]
|
|
22
|
+
})
|
|
23
|
+
};
|
|
24
|
+
const formattedAttributes = getFilteredAttributeGroups(
|
|
25
|
+
attributes,
|
|
26
|
+
["design", "extras"]
|
|
27
|
+
);
|
|
28
|
+
expect(formattedAttributes).toHaveLength(2);
|
|
29
|
+
expect(formattedAttributes.get("design")).toStrictEqual([
|
|
30
|
+
"Jackenart: Bomberjacke"
|
|
31
|
+
]);
|
|
32
|
+
expect(formattedAttributes.get("extras")).toStrictEqual([
|
|
33
|
+
"Weicher Griff",
|
|
34
|
+
"Ton-in-Ton-N\xE4hte",
|
|
35
|
+
"Label-Stickerei"
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { it, describe } from "vitest";
|
|
2
2
|
import { getCombineWithProductIds } from "~/src/runtime/utils/product";
|
|
3
|
-
import { advancedAttributeFactory } from "@scayle/storefront-nuxt";
|
|
3
|
+
import { advancedAttributeFactory } from "@scayle/storefront-nuxt/dist/test/factories";
|
|
4
4
|
describe("getCombineWithProductIds", () => {
|
|
5
5
|
it("should return productIds from data", ({ expect }) => {
|
|
6
6
|
const combineWithAttribute = advancedAttributeFactory.build({
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Variant } from '@scayle/storefront-nuxt';
|
|
2
|
+
import type { Product, WithContext } from 'schema-dts';
|
|
3
|
+
export declare const generateProductSchema: ({ productName, brandName, variants, url, images, description, }: {
|
|
4
|
+
productName: string;
|
|
5
|
+
brandName: string;
|
|
6
|
+
variants: Variant[];
|
|
7
|
+
url: string;
|
|
8
|
+
description: string;
|
|
9
|
+
images?: string[];
|
|
10
|
+
}) => WithContext<Product>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { isInStock } from "@scayle/storefront-nuxt";
|
|
2
|
+
const generateSchemaProductOffers = ({
|
|
3
|
+
variants
|
|
4
|
+
}) => {
|
|
5
|
+
return variants.map((variant) => ({
|
|
6
|
+
"@type": "Offer",
|
|
7
|
+
mpn: variant.referenceKey,
|
|
8
|
+
sku: variant.id.toString(),
|
|
9
|
+
price: variant.price.withTax / 100,
|
|
10
|
+
priceCurrency: variant.price.currencyCode,
|
|
11
|
+
availability: isInStock(variant) ? "https://schema.org/InStock" : "https://schema.org/OutOfStock",
|
|
12
|
+
itemCondition: "https://schema.org/NewCondition"
|
|
13
|
+
}));
|
|
14
|
+
};
|
|
15
|
+
export const generateProductSchema = ({
|
|
16
|
+
productName,
|
|
17
|
+
brandName,
|
|
18
|
+
variants,
|
|
19
|
+
url,
|
|
20
|
+
images = [],
|
|
21
|
+
description
|
|
22
|
+
}) => {
|
|
23
|
+
const offers = generateSchemaProductOffers({ variants });
|
|
24
|
+
const brand = {
|
|
25
|
+
"@type": "Brand",
|
|
26
|
+
name: brandName
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
"@context": "https://schema.org",
|
|
30
|
+
"@type": "Product",
|
|
31
|
+
url,
|
|
32
|
+
name: productName,
|
|
33
|
+
image: images,
|
|
34
|
+
description,
|
|
35
|
+
brand,
|
|
36
|
+
offers
|
|
37
|
+
};
|
|
38
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { generateProductSchema } from "~/src/runtime/utils/seo";
|
|
3
|
+
import { variantFactory } from "@scayle/storefront-nuxt/dist/test/factories";
|
|
4
|
+
describe("generateProductSchema", () => {
|
|
5
|
+
it("should return productSchema from product", () => {
|
|
6
|
+
const productSchema = generateProductSchema({
|
|
7
|
+
productName: "productName",
|
|
8
|
+
brandName: "brandName",
|
|
9
|
+
variants: [variantFactory.build({ id: 1 })],
|
|
10
|
+
url: "url",
|
|
11
|
+
images: [],
|
|
12
|
+
description: "description"
|
|
13
|
+
});
|
|
14
|
+
expect(productSchema).toStrictEqual({
|
|
15
|
+
"@context": "https://schema.org",
|
|
16
|
+
"@type": "Product",
|
|
17
|
+
"brand": {
|
|
18
|
+
"@type": "Brand",
|
|
19
|
+
"name": "brandName"
|
|
20
|
+
},
|
|
21
|
+
"description": "description",
|
|
22
|
+
"image": [],
|
|
23
|
+
"name": "productName",
|
|
24
|
+
"offers": [
|
|
25
|
+
{
|
|
26
|
+
"@type": "Offer",
|
|
27
|
+
"availability": "https://schema.org/InStock",
|
|
28
|
+
"itemCondition": "https://schema.org/NewCondition",
|
|
29
|
+
"mpn": void 0,
|
|
30
|
+
"price": 1,
|
|
31
|
+
"priceCurrency": "USD",
|
|
32
|
+
"sku": "1"
|
|
33
|
+
}
|
|
34
|
+
],
|
|
35
|
+
"url": "url"
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scayle/storefront-product-detail",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Collection of essential composables and utilities to work with product detail",
|
|
5
5
|
"author": "SCAYLE Commerce Engine",
|
|
6
6
|
"license": "MIT",
|
|
@@ -52,13 +52,13 @@
|
|
|
52
52
|
"@nuxt/schema": "3.13.2",
|
|
53
53
|
"@nuxt/test-utils": "3.14.4",
|
|
54
54
|
"@scayle/eslint-config-storefront": "4.3.2",
|
|
55
|
-
"@scayle/storefront-nuxt": "7.
|
|
55
|
+
"@scayle/storefront-nuxt": "7.95.0",
|
|
56
56
|
"@types/node": "22.9.0",
|
|
57
57
|
"@vue/test-utils": "2.4.6",
|
|
58
58
|
"@vueuse/core": "11.2.0",
|
|
59
59
|
"dprint": "0.47.5",
|
|
60
60
|
"eslint": "9.14.0",
|
|
61
|
-
"happy-dom": "15.11.
|
|
61
|
+
"happy-dom": "15.11.4",
|
|
62
62
|
"eslint-formatter-gitlab": "5.1.0",
|
|
63
63
|
"nuxt": "3.13.2",
|
|
64
64
|
"publint": "0.2.12",
|