@shopbite-de/storefront 1.7.10 → 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>
@@ -1,6 +1,5 @@
1
1
  <script setup lang="ts">
2
2
  import type { Schemas } from "#shopware";
3
- import { useTrackEvent } from "#imports";
4
3
 
5
4
  const props = defineProps<{
6
5
  product: Schemas["Product"];
@@ -10,25 +9,17 @@ const { addToWishlist, isInWishlist, removeFromWishlist } = useProductWishlist(
10
9
  props.product.id,
11
10
  );
12
11
  const { getWishlistProducts } = useWishlist();
13
-
14
- const trackWishlistEvent = (
15
- action: "add_to_wishlist" | "remove_from_wishlist",
16
- ) => {
17
- useTrackEvent(action, {
18
- props: { product_number: props.product.productNumber },
19
- });
20
- };
12
+ const { trackAddToWishlist } = useTrackEvent();
21
13
 
22
14
  const toggleWishlistProduct = async () => {
23
15
  try {
24
16
  if (isInWishlist.value) {
25
17
  await removeFromWishlist();
26
- trackWishlistEvent("remove_from_wishlist");
27
18
  await getWishlistProducts();
28
19
  } else {
29
20
  await addToWishlist();
30
- trackWishlistEvent("add_to_wishlist");
31
21
  await getWishlistProducts();
22
+ trackAddToWishlist(props.product);
32
23
  }
33
24
  } catch (error) {
34
25
  console.error("[wishlist][handleWishlistError]", error);
@@ -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,13 +1,13 @@
1
1
  <script setup lang="ts">
2
2
  import QuickView from "~/components/Cart/QuickView.vue";
3
3
  import { useIntervalFn } from "@vueuse/core";
4
- import { useTrackEvent } from "#imports";
5
4
 
6
5
  const { selectedPaymentMethod, selectedShippingMethod } = useCheckout();
7
6
  const { createOrder } = useCheckout();
8
7
  const { refreshCart } = useCart();
9
8
  const { isLoggedIn, isGuestSession } = useUser();
10
9
  const { isCheckoutEnabled, refresh } = useShopBiteConfig();
10
+ const { trackOrder } = useTrackEvent();
11
11
 
12
12
  const toast = useToast();
13
13
 
@@ -24,12 +24,7 @@ async function handleCreateOrder() {
24
24
  customerComment: "Wunschlieferzeit: " + selectedDeliveryTime.value,
25
25
  });
26
26
 
27
- useTrackEvent("checkout", {
28
- revenue: {
29
- currency: "EUR",
30
- amount: order.amountTotal as number,
31
- },
32
- });
27
+ trackOrder(order);
33
28
 
34
29
  await refreshCart();
35
30
  toast.add({
@@ -1,5 +1,4 @@
1
1
  <script setup lang="ts">
2
- import { useTrackEvent } from "#imports";
3
2
  import { useProductSearch } from "@shopware/composables";
4
3
  import type { Schemas } from "#shopware";
5
4
 
@@ -42,12 +41,6 @@ async function addToCart(productId: string) {
42
41
  });
43
42
  await refreshCart(newCart);
44
43
  await showSuccessToast();
45
- useTrackEvent("add_to_cart", {
46
- props: {
47
- product_number: product.productNumber,
48
- quantity: 1,
49
- },
50
- });
51
44
  }
52
45
  </script>
53
46
 
@@ -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
 
@@ -1,7 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { NavigationMenuItem } from "@nuxt/ui";
3
3
  import type { Schemas } from "#shopware";
4
- import { useTrackEvent } from "#imports";
5
4
 
6
5
  const { loadNavigationElements, navigationElements } = useNavigation();
7
6
 
@@ -17,9 +16,6 @@ const scrollToElement = (elementId: string, margin = 0) => {
17
16
  top: offsetPosition,
18
17
  behavior: "smooth",
19
18
  });
20
- useTrackEvent("scroll_to_category", {
21
- props: { category_name: elementId },
22
- });
23
19
  }
24
20
  };
25
21
 
@@ -1,7 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { NavigationMenuItem } from "@nuxt/ui";
3
3
  import type { Schemas } from "#shopware";
4
- import { useTrackEvent } from "#imports";
5
4
 
6
5
  const { loadNavigationElements, navigationElements } = useNavigation();
7
6
 
@@ -17,9 +16,6 @@ const scrollToElement = (elementId: string, margin = 0) => {
17
16
  top: offsetPosition,
18
17
  behavior: "smooth",
19
18
  });
20
- useTrackEvent("scroll_to_category", {
21
- props: { category_name: elementId },
22
- });
23
19
  }
24
20
  };
25
21
 
@@ -1,12 +1,14 @@
1
1
  <script setup lang="ts">
2
2
  import type { operations, Schemas } from "#shopware";
3
3
  import type { AssociationItemProduct } from "~/types/Association";
4
+ import { useTrackEvent } from "~/composables/useTrackEvent";
4
5
 
5
6
  const props = defineProps<{
6
7
  productId: string;
7
8
  }>();
8
9
 
9
10
  const { apiClient } = useShopwareContext();
11
+ const { trackProductView } = useTrackEvent();
10
12
 
11
13
  const productId = toRef(props.productId);
12
14
 
@@ -23,6 +25,7 @@ const searchCriteria = {
23
25
  "propertyIds",
24
26
  "options",
25
27
  "optionIds",
28
+ "seoCategory",
26
29
  "configuratorSettings",
27
30
  "children",
28
31
  "parentId",
@@ -33,6 +36,7 @@ const searchCriteria = {
33
36
  property_group_option: ["id", "name", "translated", "group"],
34
37
  product_configurator_setting: ["id", "optionId", "option", "productId"],
35
38
  product_option: ["id", "groupId", "name", "translated", "group"],
39
+ category: ["id", "name", "translated"],
36
40
  },
37
41
  associations: {
38
42
  cover: {
@@ -128,6 +132,11 @@ const onIngredientsDeselected = (deselected: string[]) => {
128
132
  const onAddToCart = () => emit("product-added");
129
133
 
130
134
  const emit = defineEmits(["product-added", "variant-selected"]);
135
+
136
+ watch(productDetails, () => {
137
+ if (!productDetails.value) return;
138
+ trackProductView(productDetails.value.product);
139
+ });
131
140
  </script>
132
141
 
133
142
  <template>
@@ -1,8 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { ref } from "vue";
3
3
  import { onClickOutside, useDebounceFn, useFocus } from "@vueuse/core";
4
- import type { Schemas } from "#shopware";
5
- import { useTrackEvent } from "#imports";
6
4
 
7
5
  withDefaults(
8
6
  defineProps<{
@@ -53,18 +51,6 @@ watch(typingQuery, (value) => {
53
51
  const performSuggestSearch = useDebounceFn((value) => {
54
52
  searchTerm.value = value;
55
53
  search();
56
- trackEvent(value);
57
- }, 500);
58
-
59
- const trackEvent = useDebounceFn((searchTerm: string) => {
60
- const searchResult = getProducts.value.map(function (
61
- product: Schemas["Product"],
62
- ) {
63
- return product.productNumber;
64
- });
65
- useTrackEvent("search", {
66
- props: { search_term: searchTerm, search_result: searchResult.join(",") },
67
- });
68
54
  }, 500);
69
55
 
70
56
  if (import.meta.client) {
@@ -12,6 +12,7 @@ export function useAddToCart() {
12
12
  const { addProducts, refreshCart } = useCart();
13
13
  const toast = useToast();
14
14
  const { triggerProductAdded } = useProductEvents();
15
+ const { trackAddToCart: trackAddToCartEvent } = useTrackEvent();
15
16
 
16
17
  const selectedExtras = ref<AssociationItemProduct[]>([]);
17
18
  const deselectedIngredients = ref<string[]>([]);
@@ -140,13 +141,7 @@ export function useAddToCart() {
140
141
  await showSuccessToast();
141
142
 
142
143
  triggerProductAdded();
143
-
144
- useTrackEvent("add_to_cart", {
145
- props: {
146
- product_number: selectedProduct.value.productNumber,
147
- quantity: selectedQuantity.value,
148
- },
149
- });
144
+ trackAddToCartEvent(selectedProduct.value, selectedQuantity.value);
150
145
 
151
146
  isLoading.value = false;
152
147
  if (onSuccess) {
@@ -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
+ }
@@ -0,0 +1,62 @@
1
+ import type { Schemas } from "#shopware";
2
+
3
+ export function useTrackEvent() {
4
+ const { proxy } = useScriptMatomoAnalytics();
5
+
6
+ function trackProductView(product: Schemas["Product"]) {
7
+ proxy._paq.push([
8
+ "setEcommerceView",
9
+ product.productNumber,
10
+ product.translated.name ?? product.name,
11
+ product.seoCategory.name,
12
+ product.calculatedPrice.unitPrice,
13
+ ]);
14
+ proxy._paq.push(["trackPageView", product.productNumber]);
15
+ }
16
+
17
+ function trackOrder(order: Schemas["Order"]) {
18
+ order.lineItems?.forEach((item) => {
19
+ if (item.type === "container") return;
20
+ proxy._paq.push([
21
+ "addEcommerceItem",
22
+ item.product?.productNumber ?? item.id,
23
+ item.label,
24
+ item.product?.seoCategory.name ?? "Default",
25
+ item.unitPrice,
26
+ item.quantity,
27
+ ]);
28
+ });
29
+
30
+ proxy._paq.push([
31
+ "trackEcommerceOrder",
32
+ order.orderNumber,
33
+ order.price.totalPrice,
34
+ ]);
35
+ }
36
+
37
+ function trackAddToWishlist(product: Schemas["Product"]) {
38
+ proxy._paq.push([
39
+ "trackEvent",
40
+ "Product",
41
+ "AddToWishlist",
42
+ product.productNumber,
43
+ ]);
44
+ }
45
+
46
+ function trackAddToCart(product: Schemas["Product"], quantity: number) {
47
+ proxy._paq.push([
48
+ "trackEvent",
49
+ "Cart",
50
+ "AddToCart",
51
+ product.productNumber,
52
+ quantity,
53
+ ]);
54
+ }
55
+
56
+ return {
57
+ trackProductView,
58
+ trackOrder,
59
+ trackAddToWishlist,
60
+ trackAddToCart,
61
+ };
62
+ }
@@ -5,6 +5,7 @@ export function useWishlistActions() {
5
5
  const toast = useToast();
6
6
  const { triggerProductAdded } = useProductEvents();
7
7
  const { clearWishlist } = useWishlist();
8
+ const { trackAddToCart: trackAddToCartEvent } = useTrackEvent();
8
9
 
9
10
  const isAddingToCart = ref(false);
10
11
  const addingItemId = ref<string | null>(null);
@@ -53,6 +54,7 @@ export function useWishlistActions() {
53
54
  await refreshCart(newCart);
54
55
 
55
56
  triggerProductAdded();
57
+ trackAddToCartEvent(product, 1);
56
58
 
57
59
  toast.add({
58
60
  title: "In den Warenkorb gelegt",
@@ -60,13 +62,6 @@ export function useWishlistActions() {
60
62
  icon: "i-lucide-shopping-cart",
61
63
  color: "primary",
62
64
  });
63
-
64
- useTrackEvent("add_to_cart", {
65
- props: {
66
- product_number: product.productNumber,
67
- quantity: 1,
68
- },
69
- });
70
65
  } catch (error) {
71
66
  console.error("[wishlist][addSingleItemToCart] Error details:", error);
72
67
  toast.add({
@@ -128,14 +123,6 @@ export function useWishlistActions() {
128
123
  icon: "i-lucide-shopping-cart",
129
124
  color: "primary",
130
125
  });
131
-
132
- useTrackEvent("add_to_cart", {
133
- props: {
134
- product_count: addableProducts.length,
135
- skipped_count: products.length - addableProducts.length,
136
- source: "wishlist_bulk",
137
- },
138
- });
139
126
  } catch (error) {
140
127
  console.error("[wishlist][addAllItemsToCart] Error:", error);
141
128
  toast.add({
@@ -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/content/index.yml CHANGED
@@ -23,7 +23,7 @@ hero:
23
23
  icon: i-lucide-arrow-right
24
24
  trailing: true
25
25
  color: primary
26
- to: /speisekarte
26
+ to: /menu/
27
27
  size: xl
28
28
  - label: Tisch reservieren
29
29
  icon: i-lucide-phone
@@ -87,7 +87,7 @@ cta:
87
87
  backgroundImage: https://shopware.shopbite.de/media/f5/19/4a/1762546880/category-pizza-header.webp
88
88
  links:
89
89
  - label: Zur Speisekarte
90
- to: /speisekarte
90
+ to: /menu/
91
91
  color: primary
92
92
  - label: Tisch reservieren
93
93
  to: tel:+49610471427
@@ -48,12 +48,8 @@ footer:
48
48
  to: /agb
49
49
  - label: Top Kategorien
50
50
  children:
51
- - label: Pizza
52
- to: /c/Pizza/
53
- - label: Nudeln
54
- to: /c/Nudeln/
55
- - label: Fleischgerichte
56
- to: /c/Fleischgerichte/
51
+ - label: Burger
52
+ to: /menu/burger/
57
53
  - label: Unternehmen
58
54
  children:
59
55
  - label: 'Tel: 06104 71427'
package/nuxt.config.ts CHANGED
@@ -97,14 +97,9 @@ export default defineNuxtConfig({
97
97
  "@sentry/nuxt/module",
98
98
  "@nuxt/ui",
99
99
  "@nuxt/scripts",
100
- "@nuxtjs/plausible",
101
100
  "nuxt-vitalizer",
102
101
  ],
103
102
 
104
- plausible: {
105
- ignoredHostnames: ["localhost"],
106
- },
107
-
108
103
  content: {
109
104
  experimental: { sqliteConnector: "native" },
110
105
  },
@@ -184,7 +179,6 @@ export default defineNuxtConfig({
184
179
  "@sentry/nuxt/module",
185
180
  "@nuxt/ui",
186
181
  "@nuxt/scripts",
187
- "@nuxtjs/plausible",
188
182
  "@nuxt/test-utils/module",
189
183
  "@nuxt/eslint",
190
184
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shopbite-de/storefront",
3
- "version": "1.7.10",
3
+ "version": "1.9.0",
4
4
  "main": "nuxt.config.ts",
5
5
  "description": "Shopware storefront for food delivery shops",
6
6
  "keywords": [
@@ -22,7 +22,6 @@
22
22
  "@nuxt/image": "^2.0.0",
23
23
  "@nuxt/scripts": "0.13.2",
24
24
  "@nuxt/ui": "^4.1.0",
25
- "@nuxtjs/plausible": "2.0.1",
26
25
  "@nuxtjs/robots": "^5.5.6",
27
26
  "@sentry/nuxt": "^10.25.0",
28
27
  "@shopware/api-client": "^1.3.0",
@@ -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-"]');
@@ -39,7 +39,9 @@ mockNuxtImport("useProductEvents", () => {
39
39
  });
40
40
 
41
41
  mockNuxtImport("useTrackEvent", () => {
42
- return mockTrackEvent;
42
+ return () => ({
43
+ trackAddToCart: mockTrackEvent,
44
+ });
43
45
  });
44
46
 
45
47
  // Provide the mocks globally or in a way that they are picked up
@@ -53,7 +55,9 @@ vi.stubGlobal("useToast", () => ({
53
55
  vi.stubGlobal("useProductEvents", () => ({
54
56
  triggerProductAdded: mockTriggerProductAdded,
55
57
  }));
56
- vi.stubGlobal("useTrackEvent", mockTrackEvent);
58
+ vi.stubGlobal("useTrackEvent", () => ({
59
+ trackAddToCart: mockTrackEvent,
60
+ }));
57
61
 
58
62
  describe("useAddToCart", () => {
59
63
  const mockProduct = {
@@ -131,10 +135,7 @@ describe("useAddToCart", () => {
131
135
  expect(mockRefreshCart).toHaveBeenCalled();
132
136
  expect(mockToastAdd).toHaveBeenCalled();
133
137
  expect(mockTriggerProductAdded).toHaveBeenCalled();
134
- expect(mockTrackEvent).toHaveBeenCalledWith(
135
- "add_to_cart",
136
- expect.any(Object),
137
- );
138
+ expect(mockTrackEvent).toHaveBeenCalledWith(mockProduct, 1);
138
139
  });
139
140
 
140
141
  it("should add product with extras to cart as container", async () => {
@@ -35,7 +35,9 @@ mockNuxtImport("useWishlist", () => () => ({
35
35
  clearWishlist: mockClearWishlist,
36
36
  }));
37
37
 
38
- mockNuxtImport("useTrackEvent", () => mockTrackEvent);
38
+ mockNuxtImport("useTrackEvent", () => () => ({
39
+ trackAddToCart: mockTrackEvent,
40
+ }));
39
41
 
40
42
  describe("useWishlistActions", () => {
41
43
  const mockProduct = {
@@ -72,10 +74,7 @@ describe("useWishlistActions", () => {
72
74
  expect(mockToastAdd).toHaveBeenCalledWith(
73
75
  expect.objectContaining({ title: "In den Warenkorb gelegt" }),
74
76
  );
75
- expect(mockTrackEvent).toHaveBeenCalledWith(
76
- "add_to_cart",
77
- expect.any(Object),
78
- );
77
+ expect(mockTrackEvent).toHaveBeenCalledWith(mockProduct, 1);
79
78
  });
80
79
 
81
80
  it("should warn when adding a base product with variants", async () => {
@@ -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
+ });
@@ -1,65 +0,0 @@
1
- <script setup lang="ts">
2
- import type { Schemas } from "#shopware";
3
- const props = defineProps<{
4
- parentProduct: Schemas["Product"];
5
- }>();
6
- const { parentProduct } = toRefs(props);
7
- const { product, changeVariant, configurator } = useProduct(
8
- parentProduct.value?.children?.length > 0
9
- ? parentProduct.value.children[0]
10
- : parentProduct.value,
11
- parentProduct.value?.configuratorSettings || [],
12
- );
13
- const { findVariantForSelectedOptions } = useProductConfigurator();
14
- const { variants: selectableOptions } = useProductVariants(configurator);
15
- const selectedOptions = ref<Record<string, string>>({});
16
- function initialOptions(variant: Ref<Schemas["Product"]>) {
17
- const options = variant.value.options as Schemas["PropertyGroupOption"][];
18
- for (const option of options) {
19
- if (option.group && option.id) {
20
- selectedOptions.value[option.group.id] = option.id;
21
- }
22
- }
23
- }
24
- onMounted(() => {
25
- initialOptions(product);
26
- });
27
- watch(
28
- selectedOptions.value,
29
- async () => {
30
- const foundVariant = await findVariantForSelectedOptions(
31
- selectedOptions.value,
32
- );
33
- const variant = parentProduct.value.children?.find(
34
- (child: Schemas["Product"]) => child.id === foundVariant?.id,
35
- );
36
-
37
- if (variant) {
38
- changeVariant(variant);
39
- emit("variant-switched", variant);
40
- }
41
- },
42
- { deep: true },
43
- );
44
- const emit = defineEmits<{
45
- "variant-switched": [variant: Schemas["Product"]];
46
- }>();
47
- </script>
48
- <template>
49
- <div
50
- v-for="(variantGroup, propertyGroupId) in selectableOptions"
51
- :key="propertyGroupId"
52
- class="my-6"
53
- >
54
- <div class="flex flex-row gap-2 items-center">
55
- <div class="basis-1/3">{{ variantGroup.name }}:</div>
56
- <USelect
57
- v-model="selectedOptions[propertyGroupId]"
58
- value-key="productId"
59
- :items="variantGroup.options"
60
- class="w-full"
61
- icon="i-lucide-square-stack"
62
- />
63
- </div>
64
- </div>
65
- </template>
@@ -1,187 +0,0 @@
1
- <script setup lang="ts">
2
- import type { Schemas, operations } from "#shopware";
3
- import type { AssociationItemProduct } from "~/types/Association";
4
- import { v5 as uuidv5 } from "uuid";
5
- import { computed, ref, toRefs } from "vue";
6
- import { useTrackEvent } from "#imports";
7
-
8
- const UUID_NAMESPACE = "b098ef7e-0fa2-4073-b002-7ceec4360fbf";
9
- const CART_SUCCESS_TITLE = "Gute Wahl!";
10
- const LINE_ITEM_PRODUCT = "product";
11
- const LINE_ITEM_CONTAINER = "container";
12
-
13
- const props = defineProps<{
14
- product: Schemas["Product"];
15
- }>();
16
- const { product } = toRefs(props);
17
- const { addProducts, refreshCart } = useCart();
18
- const toast = useToast();
19
- const { triggerProductAdded } = useProductEvents();
20
-
21
- const selectedExtras = ref<AssociationItemProduct[]>([]);
22
- const deselectedIngredients = ref<string[]>([]);
23
- const selectedQuantity = ref(1);
24
- const selectedProduct = ref<Schemas["Product"]>(product.value);
25
-
26
- const cartItemLabel = computed(() => {
27
- const formatIngredientModifications = (
28
- items: Array<{ label: string } | string>,
29
- prefix: string,
30
- ): string => {
31
- if (!items.length) return "";
32
-
33
- const labels = items.map((item) =>
34
- typeof item === "string" ? item : item.label,
35
- );
36
- const separator = " " + prefix;
37
- return " " + prefix + labels.join(separator);
38
- };
39
-
40
- const extrasFormatted = formatIngredientModifications(
41
- selectedExtras.value,
42
- "+",
43
- );
44
- const removedFormatted = formatIngredientModifications(
45
- deselectedIngredients.value,
46
- "-",
47
- );
48
-
49
- return `${selectedProduct.value.translated.name}${extrasFormatted}${removedFormatted}`;
50
- });
51
-
52
- const createExtras = () =>
53
- selectedExtras.value.map((extra) => ({
54
- id: extra.value,
55
- type: LINE_ITEM_PRODUCT,
56
- quantity: selectedQuantity.value,
57
- }));
58
-
59
- const generateSortedExtrasString = (extras: AssociationItemProduct[]) =>
60
- extras
61
- .map((extra) => extra.value)
62
- .sort()
63
- .join("");
64
-
65
- const generateProductId = (baseId: string, extras: AssociationItemProduct[]) =>
66
- extras.length ? baseId + generateSortedExtrasString(extras) : baseId;
67
-
68
- function createCartItems(): operations["addLineItem post /checkout/cart/line-item"]["body"]["items"] {
69
- const extras = createExtras();
70
-
71
- // Simple product when no extras
72
- if (extras.length === 0 && deselectedIngredients.value.length === 0) {
73
- return [
74
- {
75
- id: selectedProduct.value.id,
76
- quantity: selectedQuantity.value,
77
- type: LINE_ITEM_PRODUCT,
78
- label: cartItemLabel.value,
79
- },
80
- ];
81
- }
82
-
83
- // Container product when extras are selected
84
- const generatedUuid = uuidv5(
85
- generateProductId(selectedProduct.value.id, selectedExtras.value),
86
- UUID_NAMESPACE,
87
- );
88
-
89
- return [
90
- {
91
- id: generatedUuid,
92
- quantity: selectedQuantity.value,
93
- type: LINE_ITEM_CONTAINER,
94
- label: cartItemLabel.value,
95
- payload: {
96
- productNumber: selectedProduct.value.productNumber,
97
- },
98
- children: [
99
- {
100
- id: selectedProduct.value.id,
101
- quantity: selectedQuantity.value,
102
- type: LINE_ITEM_PRODUCT,
103
- },
104
- ...extras,
105
- ],
106
- },
107
- ];
108
- }
109
-
110
- // Toast Notification
111
- async function showSuccessToast() {
112
- toast.add({
113
- title: CART_SUCCESS_TITLE,
114
- description: `${selectedProduct.value.translated.name} wurde in den Warenkorb gelegt.`,
115
- icon: "i-lucide-shopping-cart",
116
- color: "success",
117
- progress: true,
118
- color: "primary",
119
- duration: 2000,
120
- });
121
- }
122
-
123
- // Add to Cart
124
- async function addToCart() {
125
- const cartItems = createCartItems();
126
- const newCart = await addProducts(cartItems);
127
- await refreshCart(newCart);
128
- await showSuccessToast();
129
- emit("product-added");
130
- triggerProductAdded(); // Clear search globally
131
- useTrackEvent("add_to_cart", {
132
- props: {
133
- product_number: selectedProduct.value.productNumber,
134
- quantity: selectedQuantity.value,
135
- },
136
- });
137
- }
138
-
139
- // Event Handlers
140
- const onVariantSwitched = (variant: Schemas["Product"]) => {
141
- selectedProduct.value = variant;
142
- };
143
-
144
- const onExtrasSelected = (extras: AssociationItemProduct[]) => {
145
- selectedExtras.value = extras;
146
- };
147
-
148
- const onIngredientsDeselected = (deselected: string[]) => {
149
- deselectedIngredients.value = deselected;
150
- };
151
- const emit = defineEmits(["product-added"]);
152
- </script>
153
-
154
- <template>
155
- <div class="flex flex-col justify-between w-full">
156
- <div>
157
- <ProductConfigurator
158
- :parent-product="product"
159
- @variant-switched="onVariantSwitched"
160
- />
161
- <ProductCrossSelling
162
- :product="selectedProduct"
163
- @extras-selected="onExtrasSelected"
164
- />
165
- <ProductDeselectIngredient
166
- :product="selectedProduct"
167
- @ingredients-deselected="onIngredientsDeselected"
168
- />
169
- </div>
170
- <div class="flex flex-row gap-4 mt-8">
171
- <UInputNumber
172
- v-model="selectedQuantity"
173
- size="xl"
174
- placeholder="Anzahl"
175
- :min="1"
176
- :max="100"
177
- />
178
- <UButton
179
- size="xl"
180
- label="In den Warenkorb"
181
- icon="i-lucide-shopping-cart"
182
- block
183
- @click="addToCart"
184
- />
185
- </div>
186
- </div>
187
- </template>