@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.
- package/app/components/Product/Configurator.vue +4 -9
- package/app/components/Product/CrossSelling.vue +7 -50
- package/app/components/Product/Detail.vue +7 -119
- package/app/composables/useProductConfigurator.ts +12 -52
- package/app/composables/useProductCrossSelling.ts +45 -0
- package/app/composables/useProductDetail.ts +62 -0
- package/nuxt.config.ts +7 -5
- package/package.json +1 -1
- package/server/api/category/[categoryId].get.ts +3 -2
- package/server/api/listing/[categoryId].get.ts +1 -1
- package/server/api/product/[productId]/cross-selling.get.ts +31 -0
- package/server/api/product/[productId].get.ts +68 -0
- package/server/api/product/variant.get.ts +99 -0
- package/test/nuxt/useProductConfigurator.test.ts +14 -17
|
@@ -14,17 +14,12 @@ const { variants: selectableOptions } = useProductVariantsZwei(configurator);
|
|
|
14
14
|
|
|
15
15
|
const selectedOptions = ref<Record<string, string>>({});
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
19
|
-
|
|
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
|
-
|
|
54
|
-
});
|
|
15
|
+
const selectedExtras = ref<AssociationItemProduct[]>([]);
|
|
55
16
|
|
|
56
|
-
const associationItems =
|
|
57
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
} =
|
|
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(
|
|
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
|
-
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
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,5 +1,6 @@
|
|
|
1
1
|
import { encodeForQuery } from "@shopware/api-client/helpers";
|
|
2
|
-
import type {
|
|
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:
|
|
22
|
+
maxAge: useRuntimeConfig().public.shopBite.cacheTtl.category,
|
|
22
23
|
name: "category",
|
|
23
24
|
getKey: (event) => `category-${getRouterParam(event, "categoryId")}`,
|
|
24
25
|
},
|
|
@@ -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 {
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
70
|
-
"
|
|
71
|
-
expect.
|
|
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
|
-
|
|
74
|
+
mockFetch.mockRejectedValue(new Error("API Error"));
|
|
78
75
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
79
76
|
|
|
80
77
|
const { findVariantForSelectedOptions } = useProductConfigurator();
|