@shopbite-de/storefront 1.19.0 → 1.20.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.
@@ -14,17 +14,12 @@ const { variants: selectableOptions } = useProductVariantsZwei(configurator);
14
14
 
15
15
  const selectedOptions = ref<Record<string, string>>({});
16
16
 
17
- function initialOptions(variant: Ref<Schemas["Product"]>) {
18
- const options = variant.value.options as Schemas["PropertyGroupOption"][];
19
- for (const option of options) {
20
- if (option.group && option.id) {
21
- selectedOptions.value[option.group.id] = option.id;
22
- }
17
+ const options = product.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;
23
21
  }
24
22
  }
25
- onMounted(() => {
26
- initialOptions(product);
27
- });
28
23
 
29
24
  watch(
30
25
  selectedOptions.value,
@@ -1,69 +1,26 @@
1
1
  <script setup lang="ts">
2
2
  import type { Schemas } from "#shopware";
3
- import type {
4
- AssociationItemProduct,
5
- AssociationItem,
6
- } from "~/types/Association";
7
- import { useShopwareContext } from "#imports";
3
+ import type { AssociationItemProduct } from "~/types/Association";
8
4
 
9
- const DEFAULT_CURRENCY = "EUR";
10
- const DEFAULT_LOCALE = "de-DE";
11
5
  const LOADING_ICON = "i-lucide-loader";
12
- const DEFAULT_ICON = "i-lucide-plus";
13
6
 
14
7
  const props = defineProps<{
15
8
  product: Schemas["Product"];
16
9
  }>();
17
10
 
18
- const product = computed(() => props.product);
19
- const selectedExtras = ref<AssociationItemProduct[]>([]);
20
-
21
- const { getFormattedPrice } = usePrice({
22
- currencyCode: DEFAULT_CURRENCY,
23
- localeCode: DEFAULT_LOCALE,
24
- });
25
-
26
- function mapAssociationToItems(
27
- associations: Schemas["CrossSellingElement"][],
28
- ): AssociationItem[] {
29
- return associations.map((crossSellingElement) => ({
30
- label: crossSellingElement.crossSelling.name,
31
- products: crossSellingElement.products.map(
32
- (product: Schemas["Product"]) => ({
33
- label: product.name,
34
- value: product.id,
35
- price: getFormattedPrice(product.calculatedPrice.unitPrice),
36
- icon: DEFAULT_ICON,
37
- }),
38
- ),
39
- }));
40
- }
41
-
42
- const { apiClient } = useShopwareContext();
43
-
44
- const { data: productAssociations, pending: isAssociationsLoading } =
45
- useAsyncData(`cross-selling-${product.value.id}`, async () => {
46
- const response = await apiClient.invoke(
47
- "readProductCrossSellings post /product/{productId}/cross-selling",
48
- {
49
- pathParams: { productId: product.value.id },
50
- },
51
- );
11
+ const emit = defineEmits<{
12
+ "extras-selected": [selectedExtras: AssociationItemProduct[]];
13
+ }>();
52
14
 
53
- return response.data;
54
- });
15
+ const selectedExtras = ref<AssociationItemProduct[]>([]);
55
16
 
56
- const associationItems = computed(() =>
57
- mapAssociationToItems(productAssociations.value ?? []),
17
+ const { associationItems, isAssociationsLoading } = useProductCrossSelling(
18
+ () => props.product.id,
58
19
  );
59
20
 
60
21
  watch(selectedExtras, () => emit("extras-selected", selectedExtras.value), {
61
22
  deep: true,
62
23
  });
63
-
64
- const emit = defineEmits<{
65
- "extras-selected": [selectedExtras: AssociationItemProduct[]];
66
- }>();
67
24
  </script>
68
25
 
69
26
  <template>
@@ -1,142 +1,30 @@
1
1
  <script setup lang="ts">
2
- import type { operations, Schemas } from "#shopware";
3
- import type { AssociationItemProduct } from "~/types/Association";
4
- import { useTrackEvent } from "~/composables/useTrackEvent";
2
+ import type { Schemas } from "#shopware";
5
3
 
6
4
  const props = defineProps<{
7
5
  productId: string;
8
6
  }>();
9
7
 
10
- const { apiClient } = useShopwareContext();
11
- const { trackProductView } = useTrackEvent();
12
-
13
- const productId = toRef(props.productId);
14
-
15
- const searchCriteria = {
16
- includes: {
17
- product: [
18
- "id",
19
- "productNumber",
20
- "name",
21
- "description",
22
- "calculatedPrice",
23
- "translated",
24
- "properties",
25
- "propertyIds",
26
- "options",
27
- "optionIds",
28
- "seoCategory",
29
- "configuratorSettings",
30
- "children",
31
- "parentId",
32
- "sortedProperties",
33
- "cover",
34
- ],
35
- property: ["id", "name", "translated", "options"],
36
- property_group_option: ["id", "name", "translated", "group"],
37
- product_configurator_setting: ["id", "optionId", "option", "productId"],
38
- product_option: ["id", "groupId", "name", "translated", "group"],
39
- category: ["id", "name", "translated"],
40
- },
41
- associations: {
42
- cover: {
43
- associations: {
44
- media: {},
45
- },
46
- },
47
- properties: {
48
- associations: {
49
- group: {},
50
- },
51
- },
52
- options: {
53
- associations: {
54
- group: {},
55
- },
56
- },
57
- configuratorSettings: {
58
- associations: {
59
- option: {
60
- associations: {
61
- group: {},
62
- },
63
- },
64
- },
65
- },
66
- children: {
67
- associations: {
68
- properties: {
69
- associations: {
70
- group: {},
71
- },
72
- },
73
- options: {
74
- associations: {
75
- group: {},
76
- },
77
- },
78
- },
79
- },
80
- },
81
- } as operations["searchPage post /search"]["body"];
82
-
83
- const { data: productDetails, pending } = useAsyncData(
84
- () => `product-${productId.value ?? "none"}`,
85
- async () => {
86
- if (!productId.value) return null;
87
- const response = await apiClient.invoke(
88
- "readProductDetail post /product/{productId}",
89
- {
90
- pathParams: { productId: productId.value },
91
- body: searchCriteria,
92
- },
93
- );
94
- return response.data;
95
- },
96
- );
8
+ const emit = defineEmits(["product-added", "variant-selected"]);
97
9
 
98
10
  const {
11
+ productDetails,
12
+ pending,
99
13
  selectedProduct,
100
14
  selectedQuantity,
101
15
  isLoading,
102
16
  addToCart,
103
17
  setSelectedProduct,
104
- setSelectedExtras,
105
- setDeselectedIngredients,
106
- } = useAddToCart();
107
-
108
- // Initialize when productDetails is loaded
109
- watch(
110
- () => productDetails.value?.product,
111
- (product) => {
112
- if (product) {
113
- setSelectedProduct(product);
114
- }
115
- },
116
- { immediate: true },
117
- );
18
+ onExtrasSelected,
19
+ onIngredientsDeselected,
20
+ } = useProductDetail(() => props.productId);
118
21
 
119
22
  const onVariantSwitched = (variant: Schemas["Product"]) => {
120
23
  setSelectedProduct(variant);
121
24
  emit("variant-selected", variant);
122
25
  };
123
26
 
124
- const onExtrasSelected = (extras: AssociationItemProduct[]) => {
125
- setSelectedExtras(extras);
126
- };
127
-
128
- const onIngredientsDeselected = (deselected: string[]) => {
129
- setDeselectedIngredients(deselected);
130
- };
131
-
132
27
  const onAddToCart = () => emit("product-added");
133
-
134
- const emit = defineEmits(["product-added", "variant-selected"]);
135
-
136
- watch(productDetails, () => {
137
- if (!productDetails.value) return;
138
- trackProductView(productDetails.value.product);
139
- });
140
28
  </script>
141
29
 
142
30
  <template>
@@ -1,15 +1,12 @@
1
1
  import { computed, ref } from "vue";
2
2
  import type { Schemas } from "#shopware";
3
3
  import {
4
- useShopwareContext,
5
4
  useProductConfigurator as useProductConfiguratorOriginal,
6
5
  useProduct,
7
6
  } from "@shopware/composables";
8
7
  import { getTranslatedProperty } from "@shopware/helpers";
9
8
 
10
9
  export function useProductConfigurator() {
11
- const { apiClient } = useShopwareContext();
12
-
13
10
  const original = useProductConfiguratorOriginal();
14
11
 
15
12
  const { configurator, product } = useProduct();
@@ -18,7 +15,9 @@ export function useProductConfigurator() {
18
15
  [key: string]: string;
19
16
  }>({});
20
17
  const isLoadingOptions = ref(!!product.value.options?.length);
21
- const parentProductId = computed(() => product.value?.parentId);
18
+ const parentProductId = computed(
19
+ () => product.value?.parentId ?? product.value?.id,
20
+ );
22
21
  const getOptionGroups = computed<Schemas["PropertyGroup"][]>(() => {
23
22
  return configurator.value || [];
24
23
  });
@@ -45,57 +44,18 @@ export function useProductConfigurator() {
45
44
  async function findVariantForSelectedOptions(options?: {
46
45
  [code: string]: string;
47
46
  }): Promise<Schemas["Product"] | undefined> {
48
- const filter: Schemas["Filters"] = [
49
- {
50
- type: "equals",
51
- field: "parentId",
52
- value: parentProductId.value as string,
53
- },
54
- ...Object.values(options || selected.value).map(
55
- (id) =>
56
- ({
57
- type: "equals",
58
- field: "optionIds",
59
- value: id,
60
- }) as Schemas["EqualsFilter"],
61
- ),
62
- ];
47
+ if (!parentProductId.value) return undefined;
63
48
  try {
64
- const response = await apiClient.invoke("readProduct post /product", {
65
- body: {
66
- filter,
67
- limit: 1,
68
- includes: {
69
- product: [
70
- "id",
71
- "name",
72
- "description",
73
- "translated",
74
- "productNumber",
75
- "options",
76
- "properties",
77
- "calculatedPrice",
78
- "translated",
79
- ],
80
- product_option: ["id", "groupId", "name", "translated", "group"],
81
- property: ["id", "name", "translated", "options"],
82
- property_group_option: ["id", "name", "translated", "group"],
83
- },
84
- associations: {
85
- options: {
86
- associations: {
87
- group: {},
88
- },
89
- },
90
- properties: {
91
- associations: {
92
- group: {},
93
- },
94
- },
49
+ const result = await $fetch<Schemas["Product"] | null>(
50
+ "/api/product/variant",
51
+ {
52
+ query: {
53
+ parentId: parentProductId.value,
54
+ optionIds: Object.values(options || selected.value),
95
55
  },
96
56
  },
97
- });
98
- return response.data.elements?.[0]; // return first matching product
57
+ );
58
+ return result ?? undefined;
99
59
  } catch (e) {
100
60
  console.error("SwProductDetails:findVariantForSelectedOptions", e);
101
61
  return undefined;
@@ -0,0 +1,45 @@
1
+ import type { Schemas } from "#shopware";
2
+ import type { AssociationItem } from "~/types/Association";
3
+
4
+ const DEFAULT_CURRENCY = "EUR";
5
+ const DEFAULT_LOCALE = "de-DE";
6
+ const DEFAULT_ICON = "i-lucide-plus";
7
+
8
+ function mapAssociationToItems(
9
+ associations: Schemas["CrossSellingElement"][],
10
+ getFormattedPrice: (price: number) => string,
11
+ ): AssociationItem[] {
12
+ return associations.map((crossSellingElement) => ({
13
+ label: crossSellingElement.crossSelling.name,
14
+ products: crossSellingElement.products.map(
15
+ (product: Schemas["Product"]) => ({
16
+ label: product.name,
17
+ value: product.id,
18
+ price: getFormattedPrice(product.calculatedPrice.unitPrice),
19
+ icon: DEFAULT_ICON,
20
+ }),
21
+ ),
22
+ }));
23
+ }
24
+
25
+ export function useProductCrossSelling(getProductId: () => string) {
26
+ const { getFormattedPrice } = usePrice({
27
+ currencyCode: DEFAULT_CURRENCY,
28
+ localeCode: DEFAULT_LOCALE,
29
+ });
30
+
31
+ const { data, pending: isAssociationsLoading } = useFetch<
32
+ Schemas["CrossSellingElement"][]
33
+ >(() => `/api/product/${getProductId()}/cross-selling`, {
34
+ key: () => `cross-selling-${getProductId()}`,
35
+ });
36
+
37
+ const associationItems = computed(() =>
38
+ mapAssociationToItems(data.value ?? [], getFormattedPrice),
39
+ );
40
+
41
+ return {
42
+ associationItems,
43
+ isAssociationsLoading,
44
+ };
45
+ }
@@ -0,0 +1,62 @@
1
+ import type { Schemas } from "#shopware";
2
+ import type { AssociationItemProduct } from "~/types/Association";
3
+
4
+ export function useProductDetail(getProductId: () => string) {
5
+ const { trackProductView } = useTrackEvent();
6
+
7
+ const { data: productDetails, pending } = useFetch<{
8
+ product: Schemas["Product"];
9
+ configurator?: Schemas["PropertyGroup"][];
10
+ }>(() => `/api/product/${getProductId()}`, {
11
+ key: () => `product-${getProductId() ?? "none"}`,
12
+ });
13
+
14
+ const {
15
+ selectedProduct,
16
+ selectedQuantity,
17
+ isLoading,
18
+ addToCart,
19
+ setSelectedProduct,
20
+ setSelectedExtras,
21
+ setDeselectedIngredients,
22
+ } = useAddToCart();
23
+
24
+ watch(
25
+ () => productDetails.value?.product,
26
+ (product) => {
27
+ if (product) {
28
+ setSelectedProduct(product);
29
+ }
30
+ },
31
+ { immediate: true },
32
+ );
33
+
34
+ watch(
35
+ productDetails,
36
+ () => {
37
+ if (!productDetails.value) return;
38
+ trackProductView(productDetails.value.product);
39
+ },
40
+ { immediate: true },
41
+ );
42
+
43
+ const onExtrasSelected = (extras: AssociationItemProduct[]) => {
44
+ setSelectedExtras(extras);
45
+ };
46
+
47
+ const onIngredientsDeselected = (deselected: string[]) => {
48
+ setDeselectedIngredients(deselected);
49
+ };
50
+
51
+ return {
52
+ productDetails,
53
+ pending,
54
+ selectedProduct,
55
+ selectedQuantity,
56
+ isLoading,
57
+ addToCart,
58
+ setSelectedProduct,
59
+ onExtrasSelected,
60
+ onIngredientsDeselected,
61
+ };
62
+ }
package/nuxt.config.ts CHANGED
@@ -55,6 +55,13 @@ export default defineNuxtConfig({
55
55
  geoapifyApiKey: "",
56
56
  public: {
57
57
  shopBite: {
58
+ cacheTtl: {
59
+ product: 86400,
60
+ crossSelling: 86400,
61
+ listing: 86400,
62
+ variant: 3600,
63
+ category: 86400,
64
+ },
58
65
  menuCategoryId: "",
59
66
  feature: {
60
67
  multiChannel: false,
@@ -72,9 +79,6 @@ export default defineNuxtConfig({
72
79
  },
73
80
 
74
81
  routeRules: {
75
- "/": {
76
- prerender: true,
77
- },
78
82
  "/merkliste": {
79
83
  ssr: false,
80
84
  },
@@ -113,8 +117,6 @@ export default defineNuxtConfig({
113
117
  experimental: { sqliteConnector: "native" },
114
118
  },
115
119
 
116
- vitalizer: {},
117
-
118
120
  pwa: {
119
121
  strategies: sw ? "injectManifest" : "generateSW",
120
122
  srcDir: sw ? "service-worker" : undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shopbite-de/storefront",
3
- "version": "1.19.0",
3
+ "version": "1.20.0",
4
4
  "main": "nuxt.config.ts",
5
5
  "description": "Shopware storefront for food delivery shops",
6
6
  "keywords": [
@@ -1,5 +1,6 @@
1
1
  import { encodeForQuery } from "@shopware/api-client/helpers";
2
- import type { Schemas } from "#shopware";
2
+ import type { components } from "~~/api-types/storeApiTypes";
3
+ type Schemas = components["schemas"];
3
4
 
4
5
  const criteria = encodeForQuery({
5
6
  includes: {
@@ -18,7 +19,7 @@ export default defineCachedEventHandler(
18
19
  });
19
20
  },
20
21
  {
21
- maxAge: 86400,
22
+ maxAge: useRuntimeConfig().public.shopBite.cacheTtl.category,
22
23
  name: "category",
23
24
  getKey: (event) => `category-${getRouterParam(event, "categoryId")}`,
24
25
  },
@@ -87,7 +87,7 @@ export default defineCachedEventHandler(
87
87
  });
88
88
  },
89
89
  {
90
- maxAge: 86400,
90
+ maxAge: useRuntimeConfig().public.shopBite.cacheTtl.listing,
91
91
  name: "listing",
92
92
  getKey: (event) => {
93
93
  const categoryId = getRouterParam(event, "categoryId") ?? "";
@@ -0,0 +1,31 @@
1
+ import { encodeForQuery } from "@shopware/api-client/helpers";
2
+ import type { components } from "~~/api-types/storeApiTypes";
3
+
4
+ type Schemas = components["schemas"];
5
+
6
+ const criteria = encodeForQuery({
7
+ includes: {
8
+ cross_selling_element: ["crossSelling", "products"],
9
+ cross_selling: ["name"],
10
+ product: ["id", "name", "calculatedPrice", "translated"],
11
+ calculated_price: ["unitPrice"],
12
+ },
13
+ });
14
+
15
+ export default defineCachedEventHandler(
16
+ async (event): Promise<Schemas["CrossSellingElement"][]> => {
17
+ const productId = getRouterParam(event, "productId")!;
18
+ const { endpoint, accessToken } = useRuntimeConfig().public.shopware;
19
+
20
+ return await $fetch(`${endpoint}/product/${productId}/cross-selling`, {
21
+ method: "POST",
22
+ headers: { "sw-access-key": accessToken },
23
+ query: { _criteria: criteria },
24
+ });
25
+ },
26
+ {
27
+ maxAge: useRuntimeConfig().public.shopBite.cacheTtl.crossSelling,
28
+ name: "cross-selling",
29
+ getKey: (event) => `cross-selling-${getRouterParam(event, "productId")}`,
30
+ },
31
+ );
@@ -0,0 +1,68 @@
1
+ import { encodeForQuery } from "@shopware/api-client/helpers";
2
+ import type { components } from "~~/api-types/storeApiTypes";
3
+
4
+ type Schemas = components["schemas"];
5
+
6
+ const criteria = encodeForQuery({
7
+ includes: {
8
+ product: [
9
+ "id",
10
+ "productNumber",
11
+ "name",
12
+ "description",
13
+ "calculatedPrice",
14
+ "translated",
15
+ "properties",
16
+ "propertyIds",
17
+ "options",
18
+ "optionIds",
19
+ "seoCategory",
20
+ "configuratorSettings",
21
+ "children",
22
+ "parentId",
23
+ "sortedProperties",
24
+ "cover",
25
+ ],
26
+ property: ["id", "name", "translated", "options"],
27
+ property_group_option: ["id", "name", "translated", "group"],
28
+ product_configurator_setting: ["id", "optionId", "option", "productId"],
29
+ product_option: ["id", "groupId", "name", "translated", "group"],
30
+ category: ["id", "name", "translated"],
31
+ },
32
+ associations: {
33
+ cover: { associations: { media: {} } },
34
+ properties: { associations: { group: {} } },
35
+ options: { associations: { group: {} } },
36
+ configuratorSettings: {
37
+ associations: { option: { associations: { group: {} } } },
38
+ },
39
+ children: {
40
+ associations: {
41
+ properties: { associations: { group: {} } },
42
+ options: { associations: { group: {} } },
43
+ },
44
+ },
45
+ },
46
+ });
47
+
48
+ export default defineCachedEventHandler(
49
+ async (
50
+ event,
51
+ ): Promise<{
52
+ product: Schemas["Product"];
53
+ configurator?: Schemas["PropertyGroup"][];
54
+ }> => {
55
+ const productId = getRouterParam(event, "productId")!;
56
+ const { endpoint, accessToken } = useRuntimeConfig().public.shopware;
57
+
58
+ return await $fetch(`${endpoint}/product/${productId}`, {
59
+ headers: { "sw-access-key": accessToken },
60
+ query: { _criteria: criteria },
61
+ });
62
+ },
63
+ {
64
+ maxAge: useRuntimeConfig().public.shopBite.cacheTtl.product,
65
+ name: "product",
66
+ getKey: (event) => `product-${getRouterParam(event, "productId")}`,
67
+ },
68
+ );
@@ -0,0 +1,99 @@
1
+ import type { components } from "~~/api-types/storeApiTypes";
2
+
3
+ type Schemas = components["schemas"];
4
+
5
+ const MAX_OPTION_IDS = 20;
6
+ const MAX_OPTION_ID_LENGTH = 64;
7
+
8
+ function parseOptionIds(raw: unknown): string[] {
9
+ const arr = Array.isArray(raw) ? raw : [raw];
10
+ const seen = new Set<string>();
11
+ for (const v of arr) {
12
+ if (
13
+ typeof v !== "string" ||
14
+ v.trim() === "" ||
15
+ v.length > MAX_OPTION_ID_LENGTH
16
+ )
17
+ continue;
18
+ seen.add(v);
19
+ if (seen.size >= MAX_OPTION_IDS) break;
20
+ }
21
+ return [...seen];
22
+ }
23
+
24
+ export default defineCachedEventHandler(
25
+ async (event): Promise<Schemas["Product"] | null> => {
26
+ const { parentId, optionIds: rawOptionIds } = getQuery(event);
27
+
28
+ if (typeof parentId !== "string" || parentId.trim() === "") {
29
+ throw createError({
30
+ statusCode: 400,
31
+ message: "Missing or invalid parentId",
32
+ });
33
+ }
34
+
35
+ const optionIds = parseOptionIds(rawOptionIds);
36
+ if (optionIds.length === 0) {
37
+ throw createError({
38
+ statusCode: 400,
39
+ message: "At least one optionId is required",
40
+ });
41
+ }
42
+
43
+ const { endpoint, accessToken } = useRuntimeConfig().public.shopware;
44
+
45
+ const filter: Schemas["Filters"] = [
46
+ { type: "equals", field: "parentId", value: parentId },
47
+ ...optionIds.map(
48
+ (id) =>
49
+ ({
50
+ type: "equals",
51
+ field: "optionIds",
52
+ value: id,
53
+ }) as Schemas["EqualsFilter"],
54
+ ),
55
+ ];
56
+
57
+ const response = await $fetch<{ elements?: Schemas["Product"][] }>(
58
+ `${endpoint}/product`,
59
+ {
60
+ method: "POST",
61
+ headers: { "sw-access-key": accessToken },
62
+ body: {
63
+ filter,
64
+ limit: 1,
65
+ includes: {
66
+ product: [
67
+ "id",
68
+ "name",
69
+ "description",
70
+ "translated",
71
+ "productNumber",
72
+ "options",
73
+ "properties",
74
+ "calculatedPrice",
75
+ ],
76
+ product_option: ["id", "groupId", "name", "translated", "group"],
77
+ property: ["id", "name", "translated", "options"],
78
+ property_group_option: ["id", "name", "translated", "group"],
79
+ },
80
+ associations: {
81
+ options: { associations: { group: {} } },
82
+ properties: { associations: { group: {} } },
83
+ },
84
+ },
85
+ },
86
+ );
87
+
88
+ return response.elements?.[0] ?? null;
89
+ },
90
+ {
91
+ maxAge: useRuntimeConfig().public.shopBite.cacheTtl.variant,
92
+ name: "variant",
93
+ getKey: (event) => {
94
+ const { parentId, optionIds } = getQuery(event);
95
+ const ids = parseOptionIds(optionIds).sort();
96
+ return `variant-${parentId}-${ids.join("|")}`;
97
+ },
98
+ },
99
+ );
@@ -1,19 +1,15 @@
1
1
  import { describe, it, expect, vi, beforeEach } from "vitest";
2
2
  import { useProductConfigurator } from "../../app/composables/useProductConfigurator";
3
3
 
4
- const { mockInvoke, mockConfigurator, mockProduct } = vi.hoisted(() => ({
5
- mockInvoke: vi.fn(),
4
+ const { mockFetch, mockConfigurator, mockProduct } = vi.hoisted(() => ({
5
+ mockFetch: vi.fn(),
6
6
  mockConfigurator: { value: [] },
7
7
  mockProduct: { value: { id: "p1", optionIds: [], options: [] } },
8
8
  }));
9
9
 
10
- // Use vi.mock for explicit imports
10
+ vi.stubGlobal("$fetch", mockFetch);
11
+
11
12
  vi.mock("@shopware/composables", () => ({
12
- useShopwareContext: () => ({
13
- apiClient: {
14
- invoke: mockInvoke,
15
- },
16
- }),
17
13
  useProductConfigurator: () => ({
18
14
  handleChange: vi.fn(),
19
15
  }),
@@ -57,24 +53,25 @@ describe("useProductConfigurator", () => {
57
53
  mockProduct.value = {
58
54
  parentId: "parent-1",
59
55
  } as unknown as typeof mockProduct.value;
60
- mockInvoke.mockResolvedValue({
61
- data: {
62
- elements: [{ id: "variant-1" }],
63
- },
64
- });
56
+ mockFetch.mockResolvedValue({ id: "variant-1" });
65
57
 
66
58
  const { findVariantForSelectedOptions } = useProductConfigurator();
67
59
  const result = await findVariantForSelectedOptions({ Size: "o1" });
68
60
 
69
- expect(mockInvoke).toHaveBeenCalledWith(
70
- "readProduct post /product",
71
- expect.any(Object),
61
+ expect(mockFetch).toHaveBeenCalledWith(
62
+ "/api/product/variant",
63
+ expect.objectContaining({
64
+ query: {
65
+ parentId: "parent-1",
66
+ optionIds: ["o1"],
67
+ },
68
+ }),
72
69
  );
73
70
  expect(result).toEqual({ id: "variant-1" });
74
71
  });
75
72
 
76
73
  it("should return undefined on error in findVariantForSelectedOptions", async () => {
77
- mockInvoke.mockRejectedValue(new Error("API Error"));
74
+ mockFetch.mockRejectedValue(new Error("API Error"));
78
75
  const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
79
76
 
80
77
  const { findVariantForSelectedOptions } = useProductConfigurator();