@shopbite-de/storefront 1.19.0 → 1.21.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/Cart/QuickView.vue +9 -2
- package/app/components/Checkout/Summary.vue +43 -15
- package/app/components/Order/Detail.vue +99 -71
- 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/useAddToCart.ts +6 -0
- package/app/composables/useProductConfigurator.ts +12 -52
- package/app/composables/useProductCrossSelling.ts +45 -0
- package/app/composables/useProductDetail.ts +62 -0
- package/app/pages/bestellung/[id]/erfolg.vue +37 -0
- package/app/pages/bestellung/[id]/fehler.vue +128 -0
- package/app/pages/{order/[id].vue → bestellung/[id]/index.vue} +20 -0
- package/app/pages/bestellung.vue +11 -1
- 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
|
@@ -34,8 +34,15 @@ const emit = defineEmits(["go-to-cart"]);
|
|
|
34
34
|
</div>
|
|
35
35
|
<div class="flex flex-col gap-4">
|
|
36
36
|
<div class="flex flex-row justify-between">
|
|
37
|
-
<
|
|
38
|
-
|
|
37
|
+
<template v-if="shippingTotal === 0">
|
|
38
|
+
<div class="text-success font-medium">
|
|
39
|
+
Versandkostenfreie Lieferung
|
|
40
|
+
</div>
|
|
41
|
+
</template>
|
|
42
|
+
<template v-else>
|
|
43
|
+
<div>Versandkosten:</div>
|
|
44
|
+
<div>{{ getFormattedPrice(shippingTotal) }}</div>
|
|
45
|
+
</template>
|
|
39
46
|
</div>
|
|
40
47
|
<div class="flex flex-row justify-between font-bold">
|
|
41
48
|
<div>Summe:</div>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import QuickView from "~/components/Cart/QuickView.vue";
|
|
3
3
|
import { useIntervalFn } from "@vueuse/core";
|
|
4
|
+
import { useOrderPayment } from "@shopware/composables";
|
|
4
5
|
|
|
5
6
|
const { createOrder, selectedPaymentMethod, selectedShippingMethod } =
|
|
6
7
|
useCheckout();
|
|
@@ -9,6 +10,10 @@ const { isLoggedIn, isGuestSession, refreshUser } = useUser();
|
|
|
9
10
|
const { isCheckoutEnabled, refresh } = useShopBiteConfig();
|
|
10
11
|
const { trackOrder } = useTrackEvent();
|
|
11
12
|
|
|
13
|
+
const {
|
|
14
|
+
public: { storeUrl },
|
|
15
|
+
} = useRuntimeConfig();
|
|
16
|
+
|
|
12
17
|
const toast = useToast();
|
|
13
18
|
|
|
14
19
|
onMounted(() => {
|
|
@@ -25,21 +30,42 @@ onMounted(() => {
|
|
|
25
30
|
|
|
26
31
|
useIntervalFn(refresh, 10000);
|
|
27
32
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
trackOrder(order);
|
|
33
|
+
const createdOrder = ref<Awaited<ReturnType<typeof createOrder>> | null>(null);
|
|
34
|
+
const { handlePayment, paymentUrl } = useOrderPayment(
|
|
35
|
+
computed(() => createdOrder.value),
|
|
36
|
+
);
|
|
34
37
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
async function handleCreateOrder() {
|
|
39
|
+
isPlacingOrder.value = true;
|
|
40
|
+
try {
|
|
41
|
+
const order = await createOrder({
|
|
42
|
+
customerComment: "Wunschlieferzeit: " + selectedDeliveryTime.value,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
trackOrder(order);
|
|
46
|
+
createdOrder.value = order;
|
|
47
|
+
|
|
48
|
+
await handlePayment(
|
|
49
|
+
`${storeUrl}/bestellung/${order.id}/erfolg`,
|
|
50
|
+
`${storeUrl}/bestellung/${order.id}/fehler`,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
if (paymentUrl.value) {
|
|
54
|
+
await navigateTo(paymentUrl.value, { external: true });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
await refreshCart();
|
|
59
|
+
toast.add({
|
|
60
|
+
title: "Bestellung aufgegeben!",
|
|
61
|
+
icon: "i-lucide-shopping-cart",
|
|
62
|
+
color: "success",
|
|
63
|
+
progress: false,
|
|
64
|
+
});
|
|
65
|
+
navigateTo(`/bestellung/${order.id}/erfolg`);
|
|
66
|
+
} finally {
|
|
67
|
+
isPlacingOrder.value = false;
|
|
68
|
+
}
|
|
43
69
|
}
|
|
44
70
|
|
|
45
71
|
const customerDataAvailable = computed<boolean>(
|
|
@@ -58,6 +84,7 @@ const isValidToProceed = computed(
|
|
|
58
84
|
shippingAndPaymentSet.value,
|
|
59
85
|
);
|
|
60
86
|
|
|
87
|
+
const isPlacingOrder = ref(false);
|
|
61
88
|
const selectedDeliveryTime = ref("");
|
|
62
89
|
const isValidTime = ref(true);
|
|
63
90
|
|
|
@@ -106,7 +133,8 @@ const checkoutButtonLabel = computed<string>(() => {
|
|
|
106
133
|
<CheckoutVoucherInput />
|
|
107
134
|
<UButton
|
|
108
135
|
:icon="isValidToProceed ? 'i-lucide-shopping-cart' : 'i-lucide-lock'"
|
|
109
|
-
:disabled="!isValidToProceed"
|
|
136
|
+
:disabled="!isValidToProceed || isPlacingOrder"
|
|
137
|
+
:loading="isPlacingOrder"
|
|
110
138
|
:label="checkoutButtonLabel"
|
|
111
139
|
size="xl"
|
|
112
140
|
block
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { Schemas } from "#shopware";
|
|
3
|
-
import type { TableColumn } from "#ui/components/Table.vue";
|
|
4
3
|
|
|
5
4
|
const props = defineProps<{
|
|
6
5
|
order: Schemas["Order"];
|
|
@@ -14,89 +13,118 @@ const { getFormattedPrice } = usePrice({
|
|
|
14
13
|
localeCode: "de-DE",
|
|
15
14
|
});
|
|
16
15
|
|
|
17
|
-
const
|
|
16
|
+
const lineItems = computed(() =>
|
|
17
|
+
order.value?.lineItems?.filter(
|
|
18
|
+
(item: Schemas["OrderLineItem"]) => item.parentId === null,
|
|
19
|
+
),
|
|
20
|
+
);
|
|
18
21
|
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
header: "#",
|
|
23
|
-
},
|
|
24
|
-
{
|
|
25
|
-
accessorKey: "label",
|
|
26
|
-
header: "Name",
|
|
27
|
-
},
|
|
28
|
-
{
|
|
29
|
-
accessorKey: "unitPrice",
|
|
30
|
-
header: "Preis",
|
|
31
|
-
cell: ({ row }) => {
|
|
32
|
-
return getFormattedPrice(row.getValue("unitPrice"));
|
|
33
|
-
},
|
|
34
|
-
},
|
|
35
|
-
{
|
|
36
|
-
accessorKey: "quantity",
|
|
37
|
-
header: "Anzahl",
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
accessorKey: "totalPrice",
|
|
41
|
-
header: () => h("div", { class: "text-right" }, "Gesamt"),
|
|
42
|
-
cell: ({ row }) => {
|
|
43
|
-
const formatted = getFormattedPrice(row.getValue("totalPrice"));
|
|
22
|
+
const paymentState = computed(
|
|
23
|
+
() => order.value?.transactions?.at(-1)?.stateMachineState,
|
|
24
|
+
);
|
|
44
25
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
26
|
+
type BadgeColor =
|
|
27
|
+
| "success"
|
|
28
|
+
| "error"
|
|
29
|
+
| "warning"
|
|
30
|
+
| "info"
|
|
31
|
+
| "neutral"
|
|
32
|
+
| "primary";
|
|
49
33
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
34
|
+
const paymentStateColor = computed((): BadgeColor => {
|
|
35
|
+
switch (paymentState.value?.technicalName) {
|
|
36
|
+
case "paid":
|
|
37
|
+
case "authorized":
|
|
38
|
+
return "success";
|
|
39
|
+
case "failed":
|
|
40
|
+
case "cancelled":
|
|
41
|
+
case "chargeback":
|
|
42
|
+
return "error";
|
|
43
|
+
case "open":
|
|
44
|
+
case "reminded":
|
|
45
|
+
case "unconfirmed":
|
|
46
|
+
return "warning";
|
|
47
|
+
case "in_progress":
|
|
48
|
+
return "info";
|
|
49
|
+
default:
|
|
50
|
+
return "neutral";
|
|
51
|
+
}
|
|
58
52
|
});
|
|
59
53
|
</script>
|
|
60
54
|
|
|
61
55
|
<template>
|
|
62
|
-
<div>
|
|
63
|
-
<div class="flex flex-
|
|
64
|
-
<UBadge variant="
|
|
65
|
-
|
|
56
|
+
<div class="flex flex-col gap-6">
|
|
57
|
+
<div class="flex flex-wrap gap-2">
|
|
58
|
+
<UBadge variant="subtle" color="neutral" icon="i-lucide-package">
|
|
59
|
+
{{ status }}
|
|
60
|
+
</UBadge>
|
|
61
|
+
<UBadge
|
|
62
|
+
v-if="order?.deliveries?.[0]?.shippingMethod?.name"
|
|
63
|
+
variant="subtle"
|
|
64
|
+
color="neutral"
|
|
65
|
+
icon="i-lucide-truck"
|
|
66
66
|
>
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
{{ order.deliveries[0].shippingMethod.name }}
|
|
68
|
+
</UBadge>
|
|
69
|
+
<UBadge
|
|
70
|
+
v-if="paymentState"
|
|
71
|
+
variant="subtle"
|
|
72
|
+
:color="paymentStateColor"
|
|
73
|
+
icon="i-lucide-credit-card"
|
|
69
74
|
>
|
|
75
|
+
{{ paymentState.translated?.name ?? paymentState.name }}
|
|
76
|
+
</UBadge>
|
|
70
77
|
</div>
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
78
|
+
|
|
79
|
+
<ul class="flex flex-col divide-y divide-default">
|
|
80
|
+
<li
|
|
81
|
+
v-for="item in lineItems"
|
|
82
|
+
:key="item.id"
|
|
83
|
+
class="flex items-start gap-4 py-4 first:pt-0 last:pb-0"
|
|
84
|
+
>
|
|
85
|
+
<div
|
|
86
|
+
class="size-10 rounded-md bg-elevated flex items-center justify-center shrink-0 text-sm font-semibold text-muted"
|
|
87
|
+
>
|
|
88
|
+
{{ item.quantity }}×
|
|
89
|
+
</div>
|
|
90
|
+
<div class="flex flex-col gap-0.5 flex-1 min-w-0">
|
|
91
|
+
<p class="font-medium truncate">{{ item.label }}</p>
|
|
92
|
+
<p
|
|
93
|
+
v-if="item.payload?.productNumber"
|
|
94
|
+
class="text-xs text-muted truncate"
|
|
95
|
+
>
|
|
96
|
+
#{{ item.payload.productNumber }}
|
|
97
|
+
</p>
|
|
98
|
+
</div>
|
|
99
|
+
<p class="font-semibold shrink-0">
|
|
100
|
+
{{ getFormattedPrice(item.totalPrice) }}
|
|
101
|
+
</p>
|
|
102
|
+
</li>
|
|
103
|
+
</ul>
|
|
104
|
+
|
|
105
|
+
<USeparator />
|
|
106
|
+
|
|
107
|
+
<div class="flex flex-col gap-2 text-sm">
|
|
108
|
+
<div class="flex justify-between text-muted">
|
|
109
|
+
<span>Lieferkosten</span>
|
|
110
|
+
<span>{{ getFormattedPrice(order?.shippingTotal) }}</span>
|
|
84
111
|
</div>
|
|
85
|
-
<div class="
|
|
86
|
-
|
|
112
|
+
<div class="flex justify-between text-muted">
|
|
113
|
+
<span>Netto</span>
|
|
114
|
+
<span>{{ getFormattedPrice(order?.amountNet) }}</span>
|
|
87
115
|
</div>
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
116
|
+
<div
|
|
117
|
+
v-for="tax in order?.price?.calculatedTaxes"
|
|
118
|
+
:key="tax.taxRate"
|
|
119
|
+
class="flex justify-between text-muted"
|
|
120
|
+
>
|
|
121
|
+
<span>MwSt. {{ tax.taxRate }}%</span>
|
|
122
|
+
<span>{{ getFormattedPrice(tax.tax) }}</span>
|
|
123
|
+
</div>
|
|
124
|
+
<div class="flex justify-between font-semibold text-base pt-1">
|
|
125
|
+
<span>Gesamt</span>
|
|
126
|
+
<span>{{ getFormattedPrice(order?.amountTotal) }}</span>
|
|
92
127
|
</div>
|
|
93
|
-
<UButton
|
|
94
|
-
label="Anrufen"
|
|
95
|
-
variant="outline"
|
|
96
|
-
color="primary"
|
|
97
|
-
to="tel:+4917623456789"
|
|
98
|
-
icon="i-heroicons-phone"
|
|
99
|
-
/>
|
|
100
128
|
</div>
|
|
101
129
|
</div>
|
|
102
130
|
</template>
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const {
|
|
3
|
+
public: { site },
|
|
4
|
+
} = useRuntimeConfig();
|
|
5
|
+
|
|
6
|
+
useSeoMeta({
|
|
7
|
+
title: `Zahlung erfolgreich | ${site.name}`,
|
|
8
|
+
robots: "noindex, nofollow",
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const route = useRoute();
|
|
12
|
+
const orderId = route.params.id as string;
|
|
13
|
+
|
|
14
|
+
const { refreshCart } = useCart();
|
|
15
|
+
|
|
16
|
+
onMounted(async () => {
|
|
17
|
+
await refreshCart();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const links = [
|
|
21
|
+
{
|
|
22
|
+
label: "Zur Bestellung",
|
|
23
|
+
to: `/bestellung/${orderId}`,
|
|
24
|
+
icon: "i-lucide-receipt",
|
|
25
|
+
size: "xl" as const,
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<template>
|
|
31
|
+
<UPageSection
|
|
32
|
+
icon="i-lucide-circle-check"
|
|
33
|
+
title="Bestellung erfolgreich erstellt!"
|
|
34
|
+
description="Wir bereiten deine Bestellung jetzt vor."
|
|
35
|
+
:links="links"
|
|
36
|
+
/>
|
|
37
|
+
</template>
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useOrderPayment, useOrderDetails } from "@shopware/composables";
|
|
3
|
+
import type { Schemas } from "#shopware";
|
|
4
|
+
|
|
5
|
+
const {
|
|
6
|
+
public: { site, storeUrl },
|
|
7
|
+
} = useRuntimeConfig();
|
|
8
|
+
|
|
9
|
+
useSeoMeta({
|
|
10
|
+
title: `Zahlung fehlgeschlagen | ${site.name}`,
|
|
11
|
+
robots: "noindex, nofollow",
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const route = useRoute();
|
|
15
|
+
const orderId = route.params.id as string;
|
|
16
|
+
|
|
17
|
+
const { order, loadOrderDetails, status } = useOrderDetails(orderId);
|
|
18
|
+
const { paymentMethods, getPaymentMethods } = useCheckout();
|
|
19
|
+
|
|
20
|
+
onMounted(async () => {
|
|
21
|
+
await Promise.all([loadOrderDetails(), getPaymentMethods()]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const orderRef = computed(() => order.value);
|
|
25
|
+
const { handlePayment, paymentUrl, changePaymentMethod } =
|
|
26
|
+
useOrderPayment(orderRef);
|
|
27
|
+
|
|
28
|
+
const currentPaymentMethodId = computed(
|
|
29
|
+
() => order.value?.transactions?.at(-1)?.paymentMethodId ?? "",
|
|
30
|
+
);
|
|
31
|
+
const selectedPaymentMethodId = ref("");
|
|
32
|
+
|
|
33
|
+
watch(
|
|
34
|
+
currentPaymentMethodId,
|
|
35
|
+
(id) => {
|
|
36
|
+
if (id && !selectedPaymentMethodId.value) {
|
|
37
|
+
selectedPaymentMethodId.value = id;
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
{ immediate: true },
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const selectablePaymentMethods = computed(() =>
|
|
44
|
+
paymentMethods.value?.map((m: Schemas["PaymentMethod"]) => ({
|
|
45
|
+
label: m.distinguishableName ?? m.name,
|
|
46
|
+
value: m.id,
|
|
47
|
+
})),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const isRetrying = ref(false);
|
|
51
|
+
|
|
52
|
+
async function retryPayment() {
|
|
53
|
+
isRetrying.value = true;
|
|
54
|
+
try {
|
|
55
|
+
if (selectedPaymentMethodId.value !== currentPaymentMethodId.value) {
|
|
56
|
+
await changePaymentMethod(selectedPaymentMethodId.value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
await handlePayment(
|
|
60
|
+
`${storeUrl}/bestellung/${orderId}/erfolg`,
|
|
61
|
+
`${storeUrl}/bestellung/${orderId}/fehler`,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
if (paymentUrl.value) {
|
|
65
|
+
await navigateTo(paymentUrl.value, { external: true });
|
|
66
|
+
} else {
|
|
67
|
+
await navigateTo(`/bestellung/${orderId}/erfolg`);
|
|
68
|
+
}
|
|
69
|
+
} finally {
|
|
70
|
+
isRetrying.value = false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<template>
|
|
76
|
+
<UPageSection>
|
|
77
|
+
<div class="flex flex-col gap-8">
|
|
78
|
+
<UPageHero
|
|
79
|
+
icon="i-lucide-circle-x"
|
|
80
|
+
title="Zahlung fehlgeschlagen"
|
|
81
|
+
description="Bei der Verarbeitung deiner Zahlung ist ein Fehler aufgetreten. Wähle eine Zahlungsmethode und versuche es erneut."
|
|
82
|
+
/>
|
|
83
|
+
|
|
84
|
+
<USeparator />
|
|
85
|
+
|
|
86
|
+
<div v-if="order" class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
|
87
|
+
<div class="flex flex-col gap-4">
|
|
88
|
+
<p class="text-sm font-semibold text-muted uppercase tracking-wide">
|
|
89
|
+
Bestellung {{ order.orderNumber }}
|
|
90
|
+
</p>
|
|
91
|
+
<UCard>
|
|
92
|
+
<OrderDetail :order="order" :status="status ?? ''" />
|
|
93
|
+
</UCard>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<div class="flex flex-col gap-6">
|
|
97
|
+
<div class="flex flex-col gap-3">
|
|
98
|
+
<p class="text-sm font-semibold text-muted uppercase tracking-wide">
|
|
99
|
+
Zahlungsmethode
|
|
100
|
+
</p>
|
|
101
|
+
<URadioGroup
|
|
102
|
+
v-model="selectedPaymentMethodId"
|
|
103
|
+
:items="selectablePaymentMethods"
|
|
104
|
+
variant="card"
|
|
105
|
+
/>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<UButton
|
|
109
|
+
label="Jetzt bezahlen"
|
|
110
|
+
icon="i-lucide-refresh-cw"
|
|
111
|
+
size="xl"
|
|
112
|
+
block
|
|
113
|
+
:loading="isRetrying"
|
|
114
|
+
:disabled="!selectedPaymentMethodId"
|
|
115
|
+
@click="retryPayment"
|
|
116
|
+
/>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<div v-else class="flex justify-center py-16">
|
|
121
|
+
<UIcon
|
|
122
|
+
name="i-lucide-loader-circle"
|
|
123
|
+
class="size-10 animate-spin text-primary"
|
|
124
|
+
/>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
</UPageSection>
|
|
128
|
+
</template>
|
|
@@ -55,6 +55,26 @@ const links = ref<ButtonProps[]>([
|
|
|
55
55
|
/>
|
|
56
56
|
<UPageBody>
|
|
57
57
|
<OrderDetail :order="order" :status="status ?? 'laden...'" />
|
|
58
|
+
<UCard class="mt-6">
|
|
59
|
+
<div
|
|
60
|
+
class="flex flex-col sm:flex-row items-start sm:items-center gap-4"
|
|
61
|
+
>
|
|
62
|
+
<div class="flex-1">
|
|
63
|
+
<p class="font-semibold">Stimmt etwas nicht?</p>
|
|
64
|
+
<p class="text-sm text-muted mt-0.5">
|
|
65
|
+
Ruf uns sofort an, damit wir deine Bestellung noch rechtzeitig
|
|
66
|
+
korrigieren können.
|
|
67
|
+
</p>
|
|
68
|
+
</div>
|
|
69
|
+
<UButton
|
|
70
|
+
label="Jetzt anrufen"
|
|
71
|
+
color="primary"
|
|
72
|
+
to="tel:+4917623456789"
|
|
73
|
+
icon="i-lucide-phone"
|
|
74
|
+
shrink-0
|
|
75
|
+
/>
|
|
76
|
+
</div>
|
|
77
|
+
</UCard>
|
|
58
78
|
</UPageBody>
|
|
59
79
|
</UContainer>
|
|
60
80
|
|
package/app/pages/bestellung.vue
CHANGED
|
@@ -31,14 +31,24 @@ const items = computed(
|
|
|
31
31
|
] satisfies StepperItem[],
|
|
32
32
|
);
|
|
33
33
|
|
|
34
|
+
const route = useRoute();
|
|
35
|
+
const isPaymentReturnRoute = computed(() =>
|
|
36
|
+
/^\/bestellung\/[0-9a-f]{32}(\/erfolg|\/fehler)?$/.test(route.path),
|
|
37
|
+
);
|
|
34
38
|
watch(step, (newStep) => {
|
|
39
|
+
if (isPaymentReturnRoute.value) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
35
42
|
navigateTo(stepRoutes[newStep]);
|
|
36
43
|
});
|
|
37
44
|
</script>
|
|
38
45
|
|
|
39
46
|
<template>
|
|
40
47
|
<UPageSection>
|
|
41
|
-
<
|
|
48
|
+
<template v-if="isPaymentReturnRoute">
|
|
49
|
+
<NuxtPage />
|
|
50
|
+
</template>
|
|
51
|
+
<UStepper v-else ref="stepper" v-model="step" :items="items" size="lg">
|
|
42
52
|
<template #content>
|
|
43
53
|
<NuxtPage />
|
|
44
54
|
</template>
|
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();
|