@shopbite-de/storefront 1.8.0 → 1.9.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/app/app.vue +13 -0
- package/app/components/Category/Listing.vue +1 -9
- package/app/components/Footer.vue +1 -1
- package/app/components/Header/Right.vue +1 -1
- package/app/composables/useCategorySeo.ts +169 -0
- package/app/pages/index.vue +6 -2
- package/package.json +1 -1
- package/test/e2e/simple-checkout-as-recurring-customer.test.ts +1 -1
- package/test/unit/useCategorySeo.spec.ts +142 -0
package/app/app.vue
CHANGED
|
@@ -89,6 +89,19 @@ onMounted(async () => {
|
|
|
89
89
|
await Promise.all([refreshCart(), getWishlistProducts()]);
|
|
90
90
|
displayStoreStatus();
|
|
91
91
|
});
|
|
92
|
+
|
|
93
|
+
useHead({
|
|
94
|
+
htmlAttrs: {
|
|
95
|
+
lang: "de",
|
|
96
|
+
},
|
|
97
|
+
link: [
|
|
98
|
+
{
|
|
99
|
+
rel: "icon",
|
|
100
|
+
type: "image/png",
|
|
101
|
+
href: "/favicon.ico",
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
});
|
|
92
105
|
</script>
|
|
93
106
|
|
|
94
107
|
<template>
|
|
@@ -65,15 +65,7 @@ const { data: category } = await useAsyncData(
|
|
|
65
65
|
},
|
|
66
66
|
);
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
() =>
|
|
70
|
-
`${category.value?.translated.name ?? category.value?.name} | Speisekarte`,
|
|
71
|
-
);
|
|
72
|
-
|
|
73
|
-
useSeoMeta({
|
|
74
|
-
title: pageTitle,
|
|
75
|
-
robots: "index,follow",
|
|
76
|
-
});
|
|
68
|
+
useCategorySeo(category);
|
|
77
69
|
|
|
78
70
|
const currentSorting = ref(getCurrentSortingOrder.value ?? "Sortieren");
|
|
79
71
|
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { Schemas } from "#shopware";
|
|
2
|
+
|
|
3
|
+
export function useCategorySeo(category: Ref<Schemas["Category"] | undefined>) {
|
|
4
|
+
const config = useRuntimeConfig();
|
|
5
|
+
const storeName = config.public.site?.name || "";
|
|
6
|
+
|
|
7
|
+
const pageTitle = computed(() => {
|
|
8
|
+
const categoryName =
|
|
9
|
+
category.value?.translated?.metaTitle ??
|
|
10
|
+
category.value?.metaTitle ??
|
|
11
|
+
category.value?.translated?.name ??
|
|
12
|
+
category.value?.name;
|
|
13
|
+
|
|
14
|
+
return categoryName + " | Speisekarte | " + storeName;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const pageDescription = computed(
|
|
18
|
+
() =>
|
|
19
|
+
category.value?.translated?.metaDescription ??
|
|
20
|
+
category.value?.metaDescription ??
|
|
21
|
+
category.value?.translated?.description ??
|
|
22
|
+
category.value?.description,
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const seoUrl = computed(() => {
|
|
26
|
+
const base = config.public.storeUrl || "";
|
|
27
|
+
const path = category.value?.seoUrl || "";
|
|
28
|
+
return base + path;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const breadcrumb = computed<string[]>(
|
|
32
|
+
() =>
|
|
33
|
+
category.value?.translated?.breadcrumb ??
|
|
34
|
+
category.value?.breadcrumb ??
|
|
35
|
+
[],
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// Build BreadcrumbList items from category breadcrumb
|
|
39
|
+
type BreadcrumbListItem = {
|
|
40
|
+
"@type": "ListItem";
|
|
41
|
+
position: number;
|
|
42
|
+
name: string;
|
|
43
|
+
item?: string;
|
|
44
|
+
};
|
|
45
|
+
const breadcrumbItems = computed(() => {
|
|
46
|
+
const names = (breadcrumb.value || []) as string[];
|
|
47
|
+
const items: BreadcrumbListItem[] = [
|
|
48
|
+
{
|
|
49
|
+
"@type": "ListItem",
|
|
50
|
+
position: 1,
|
|
51
|
+
name: "Home",
|
|
52
|
+
item: config.public.storeUrl || "/",
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
if (names.length > 0) {
|
|
57
|
+
names.forEach((name: string, idx: number) => {
|
|
58
|
+
const isLast = idx === names.length - 1;
|
|
59
|
+
items.push({
|
|
60
|
+
"@type": "ListItem",
|
|
61
|
+
position: idx + 2,
|
|
62
|
+
name,
|
|
63
|
+
...(isLast && canonicalUrl.value ? { item: canonicalUrl.value } : {}),
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
} else if (pageTitle.value) {
|
|
67
|
+
items.push({
|
|
68
|
+
"@type": "ListItem",
|
|
69
|
+
position: 2,
|
|
70
|
+
name: pageTitle.value,
|
|
71
|
+
...(canonicalUrl.value ? { item: canonicalUrl.value } : {}),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return items;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const ogImage = computed(() => category.value?.media?.url);
|
|
79
|
+
|
|
80
|
+
const siteName = computed(() => config.public.site?.name || "");
|
|
81
|
+
const locale = computed(() => {
|
|
82
|
+
try {
|
|
83
|
+
const lang = import.meta.client
|
|
84
|
+
? document?.documentElement?.lang
|
|
85
|
+
: undefined;
|
|
86
|
+
return (lang || "de").replace("_", "-");
|
|
87
|
+
} catch {
|
|
88
|
+
return "de";
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const robots = computed(() => {
|
|
93
|
+
const active = category.value?.active;
|
|
94
|
+
return active === false ? "noindex,nofollow" : "index,follow";
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const canonicalUrl = computed(() => seoUrl.value || "");
|
|
98
|
+
|
|
99
|
+
const ogImageAlt = computed(() => pageTitle.value);
|
|
100
|
+
|
|
101
|
+
useSeoMeta({
|
|
102
|
+
title: pageTitle,
|
|
103
|
+
description: pageDescription,
|
|
104
|
+
ogTitle: pageTitle,
|
|
105
|
+
ogDescription: pageDescription,
|
|
106
|
+
ogUrl: seoUrl,
|
|
107
|
+
ogImage,
|
|
108
|
+
ogType: "website",
|
|
109
|
+
ogSiteName: siteName,
|
|
110
|
+
ogLocale: locale,
|
|
111
|
+
ogImageAlt,
|
|
112
|
+
twitterTitle: pageTitle,
|
|
113
|
+
twitterDescription: pageDescription,
|
|
114
|
+
twitterImage: ogImage,
|
|
115
|
+
twitterCard: "summary_large_image",
|
|
116
|
+
robots,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Add canonical link tag and JSON-LD schema
|
|
120
|
+
useHead({
|
|
121
|
+
link: [
|
|
122
|
+
{
|
|
123
|
+
rel: "canonical",
|
|
124
|
+
href: canonicalUrl.value,
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
script: [
|
|
128
|
+
{
|
|
129
|
+
type: "application/ld+json",
|
|
130
|
+
innerHTML: JSON.stringify({
|
|
131
|
+
"@context": "https://schema.org",
|
|
132
|
+
"@type": "CollectionPage",
|
|
133
|
+
name: pageTitle.value,
|
|
134
|
+
description: pageDescription.value,
|
|
135
|
+
url: canonicalUrl.value,
|
|
136
|
+
...(siteName.value
|
|
137
|
+
? {
|
|
138
|
+
isPartOf: {
|
|
139
|
+
"@type": "WebSite",
|
|
140
|
+
name: siteName.value,
|
|
141
|
+
url: config.public.storeUrl || "",
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
: {}),
|
|
145
|
+
...(ogImage.value ? { image: [ogImage.value] } : {}),
|
|
146
|
+
}),
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
type: "application/ld+json",
|
|
150
|
+
innerHTML: JSON.stringify({
|
|
151
|
+
"@context": "https://schema.org",
|
|
152
|
+
"@type": "BreadcrumbList",
|
|
153
|
+
itemListElement: breadcrumbItems.value,
|
|
154
|
+
}),
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
pageTitle,
|
|
161
|
+
pageDescription,
|
|
162
|
+
seoUrl,
|
|
163
|
+
ogImage,
|
|
164
|
+
canonicalUrl,
|
|
165
|
+
robots,
|
|
166
|
+
siteName,
|
|
167
|
+
locale,
|
|
168
|
+
};
|
|
169
|
+
}
|
package/app/pages/index.vue
CHANGED
|
@@ -10,6 +10,8 @@ if (!page.value) {
|
|
|
10
10
|
});
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
const config = useRuntimeConfig();
|
|
14
|
+
|
|
13
15
|
useSeoMeta({
|
|
14
16
|
title: page.value.seo?.title || page.value.title,
|
|
15
17
|
ogTitle: page.value.seo?.title || page.value.title,
|
|
@@ -17,8 +19,10 @@ useSeoMeta({
|
|
|
17
19
|
description: page.value.seo?.description || page.value.description,
|
|
18
20
|
ogDescription: page.value.seo?.description || page.value.description,
|
|
19
21
|
twitterDescription: page.value.seo?.description || page.value.description,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
twitterCard: "summary",
|
|
23
|
+
ogImage: page.value.seo?.image as string | undefined,
|
|
24
|
+
twitterImage: page.value.seo?.image as string | undefined,
|
|
25
|
+
ogUrl: config.public.storeUrl,
|
|
22
26
|
});
|
|
23
27
|
</script>
|
|
24
28
|
<template>
|
package/package.json
CHANGED
|
@@ -50,7 +50,7 @@ async function clearCart(page: Page) {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
async function navigateToCategoryAndVerifyProducts(page: Page) {
|
|
53
|
-
await page.goto("/c/Pizza", { waitUntil: "load" });
|
|
53
|
+
await page.goto("/c/Pizza/", { waitUntil: "load" });
|
|
54
54
|
await expect(page.locator("h1")).toHaveText("Pizza");
|
|
55
55
|
|
|
56
56
|
const productCards = page.locator('[id^="product-card-"]');
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { ref } from "vue";
|
|
3
|
+
|
|
4
|
+
// Create shared mocks to be exported by both '#imports' and '#app' in a hoisted-safe way
|
|
5
|
+
const shared = vi.hoisted(() => ({
|
|
6
|
+
useHead: vi.fn(),
|
|
7
|
+
useSeoMeta: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
// Mock Nuxt auto-imports via `#imports`
|
|
11
|
+
vi.mock("#imports", async () => {
|
|
12
|
+
const vue = await import("vue");
|
|
13
|
+
|
|
14
|
+
// Expose mocks for inspection in tests
|
|
15
|
+
return {
|
|
16
|
+
...vue,
|
|
17
|
+
useRuntimeConfig: () => ({
|
|
18
|
+
public: {
|
|
19
|
+
site: { name: "My Store" },
|
|
20
|
+
storeUrl: "https://example.com",
|
|
21
|
+
},
|
|
22
|
+
}),
|
|
23
|
+
useHead: shared.useHead,
|
|
24
|
+
useSeoMeta: shared.useSeoMeta,
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Some auto-imports may resolve from '#app' depending on transform, mirror the same mocks
|
|
29
|
+
vi.mock("#app", async () => {
|
|
30
|
+
const vue = await import("vue");
|
|
31
|
+
return {
|
|
32
|
+
...vue,
|
|
33
|
+
useRuntimeConfig: () => ({
|
|
34
|
+
public: {
|
|
35
|
+
site: { name: "My Store" },
|
|
36
|
+
storeUrl: "https://example.com",
|
|
37
|
+
},
|
|
38
|
+
}),
|
|
39
|
+
useHead: shared.useHead,
|
|
40
|
+
useSeoMeta: shared.useSeoMeta,
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Re-import the mocks for assertions
|
|
45
|
+
import { useHead, useSeoMeta } from "#imports";
|
|
46
|
+
|
|
47
|
+
// Target under test will be dynamically imported after setting up globals
|
|
48
|
+
let useCategorySeo: (arg: any) => any;
|
|
49
|
+
|
|
50
|
+
describe("useCategorySeo", () => {
|
|
51
|
+
beforeEach(async () => {
|
|
52
|
+
vi.clearAllMocks();
|
|
53
|
+
// Provide globals for auto-imported functions (when not transformed in unit env)
|
|
54
|
+
const vue = await import("vue");
|
|
55
|
+
(globalThis as any).computed = vue.computed;
|
|
56
|
+
(globalThis as any).ref = vue.ref;
|
|
57
|
+
(globalThis as any).useRuntimeConfig = () => ({
|
|
58
|
+
public: { site: { name: "My Store" }, storeUrl: "https://example.com" },
|
|
59
|
+
});
|
|
60
|
+
(globalThis as any).useHead = useHead;
|
|
61
|
+
(globalThis as any).useSeoMeta = useSeoMeta;
|
|
62
|
+
|
|
63
|
+
// Dynamic import after globals are ready
|
|
64
|
+
useCategorySeo = (await import("../../app/composables/useCategorySeo"))
|
|
65
|
+
.useCategorySeo;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("computes core SEO refs and injects head tags", () => {
|
|
69
|
+
const category = ref<any>({
|
|
70
|
+
translated: {
|
|
71
|
+
metaTitle: "Pizza & Pasta",
|
|
72
|
+
metaDescription: "Leckere Pizza und Pasta bestellen",
|
|
73
|
+
breadcrumb: ["Speisen", "Italienisch", "Pasta"],
|
|
74
|
+
},
|
|
75
|
+
seoUrl: "/c/pasta",
|
|
76
|
+
active: true,
|
|
77
|
+
media: { url: "https://example.com/img/pasta.jpg" },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const result = useCategorySeo(category);
|
|
81
|
+
|
|
82
|
+
// Returned refs
|
|
83
|
+
expect(result.pageTitle.value).toBe(
|
|
84
|
+
"Pizza & Pasta | Speisekarte | My Store",
|
|
85
|
+
);
|
|
86
|
+
expect(result.canonicalUrl.value).toBe("https://example.com/c/pasta");
|
|
87
|
+
expect(result.robots.value).toBe("index,follow");
|
|
88
|
+
|
|
89
|
+
// useSeoMeta should be called once with expected keys
|
|
90
|
+
expect(
|
|
91
|
+
useSeoMeta as unknown as ReturnType<typeof vi.fn>,
|
|
92
|
+
).toHaveBeenCalledTimes(1);
|
|
93
|
+
|
|
94
|
+
// useHead should receive canonical link and JSON-LD scripts
|
|
95
|
+
expect(
|
|
96
|
+
useHead as unknown as ReturnType<typeof vi.fn>,
|
|
97
|
+
).toHaveBeenCalledTimes(1);
|
|
98
|
+
const headArg = (useHead as unknown as ReturnType<typeof vi.fn>).mock
|
|
99
|
+
.calls[0][0];
|
|
100
|
+
|
|
101
|
+
// Canonical link
|
|
102
|
+
const link = headArg.link?.[0];
|
|
103
|
+
expect(link).toMatchObject({
|
|
104
|
+
rel: "canonical",
|
|
105
|
+
href: "https://example.com/c/pasta",
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// JSON-LD scripts
|
|
109
|
+
const scripts = headArg.script || [];
|
|
110
|
+
expect(scripts.length).toBeGreaterThanOrEqual(2);
|
|
111
|
+
|
|
112
|
+
const collection = JSON.parse(scripts[0].innerHTML);
|
|
113
|
+
expect(collection["@type"]).toBe("CollectionPage");
|
|
114
|
+
expect(collection.url).toBe("https://example.com/c/pasta");
|
|
115
|
+
expect(collection.image?.[0]).toBe("https://example.com/img/pasta.jpg");
|
|
116
|
+
|
|
117
|
+
const breadcrumb = JSON.parse(scripts[1].innerHTML);
|
|
118
|
+
expect(breadcrumb["@type"]).toBe("BreadcrumbList");
|
|
119
|
+
const items = breadcrumb.itemListElement;
|
|
120
|
+
// Home item
|
|
121
|
+
expect(items[0]).toMatchObject({
|
|
122
|
+
"@type": "ListItem",
|
|
123
|
+
position: 1,
|
|
124
|
+
name: "Home",
|
|
125
|
+
item: "https://example.com",
|
|
126
|
+
});
|
|
127
|
+
// Last item should include canonical URL
|
|
128
|
+
const last = items[items.length - 1];
|
|
129
|
+
expect(last.item).toBe("https://example.com/c/pasta");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("sets robots to noindex when category is inactive", () => {
|
|
133
|
+
const category = ref<any>({
|
|
134
|
+
translated: { name: "Salate" },
|
|
135
|
+
active: false,
|
|
136
|
+
seoUrl: "/c/salate",
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const result = useCategorySeo(category);
|
|
140
|
+
expect(result.robots.value).toBe("noindex,nofollow");
|
|
141
|
+
});
|
|
142
|
+
});
|