@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 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
- const pageTitle = computed(
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
 
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- const { data: navigationData } = await useAsyncData("navigation", () =>
2
+ const { data: navigationData } = await useAsyncData("navigation:footer", () =>
3
3
  queryCollection("navigation").first(),
4
4
  );
5
5
 
@@ -17,7 +17,7 @@ const logoutHandler = () => {
17
17
  });
18
18
  };
19
19
 
20
- const { data: navigationData } = await useAsyncData("navigation", () =>
20
+ const { data: navigationData } = await useAsyncData("navigation:right", () =>
21
21
  queryCollection("navigation").first(),
22
22
  );
23
23
 
@@ -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
+ }
@@ -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
- ogImage: page.value.seo?.image,
21
- twitterImage: page.value.seo?.image,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shopbite-de/storefront",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "main": "nuxt.config.ts",
5
5
  "description": "Shopware storefront for food delivery shops",
6
6
  "keywords": [
@@ -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
+ });