@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.
@@ -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
- <div>Versandkosten:</div>
38
- <div>{{ getFormattedPrice(shippingTotal) }}</div>
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
- async function handleCreateOrder() {
29
- const order = await createOrder({
30
- customerComment: "Wunschlieferzeit: " + selectedDeliveryTime.value,
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
- await refreshCart();
36
- toast.add({
37
- title: "Bestellung aufgegeben!",
38
- icon: "i-lucide-shopping-cart",
39
- color: "success",
40
- progress: false,
41
- });
42
- navigateTo("/order/" + order.id);
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 isLoadingData = ref(true);
16
+ const lineItems = computed(() =>
17
+ order.value?.lineItems?.filter(
18
+ (item: Schemas["OrderLineItem"]) => item.parentId === null,
19
+ ),
20
+ );
18
21
 
19
- const columns: TableColumn<Schemas["OrderLineItem"]>[] = [
20
- {
21
- accessorKey: "payload.productNumber",
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
- return h("div", { class: "text-right" }, formatted);
46
- },
47
- },
48
- ];
26
+ type BadgeColor =
27
+ | "success"
28
+ | "error"
29
+ | "warning"
30
+ | "info"
31
+ | "neutral"
32
+ | "primary";
49
33
 
50
- onMounted(() => {
51
- isLoadingData.value = false;
52
- });
53
-
54
- const columnRows = computed(() => {
55
- return order.value?.lineItems?.filter(
56
- (lineItem: Schemas["OrderLineItem"]) => lineItem.parentId === null,
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-row justify-between">
64
- <UBadge variant="outline" color="neutral" size="xl"
65
- >Status: {{ status }}</UBadge
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
- <UBadge variant="outline" color="neutral" size="xl"
68
- >Versandart: {{ order?.deliveries?.[0]?.shippingMethod?.name }}</UBadge
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
- <UTable
72
- :columns="columns"
73
- :loading="isLoadingData"
74
- loading-color="primary"
75
- loading-animation="carousel"
76
- :data="columnRows"
77
- class="flex-1"
78
- />
79
- <div class="flex flex-col items-end w-full pr-4">
80
- <div>Lieferkosten: {{ getFormattedPrice(order?.shippingTotal) }}</div>
81
- <div>Gesamtkosten Netto: {{ getFormattedPrice(order?.amountNet) }}</div>
82
- <div v-for="tax in order?.price.calculatedTaxes" :key="tax.taxRate">
83
- inkl. {{ tax.taxRate }}% MwSt. {{ getFormattedPrice(tax.tax) }}
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="font-bold">
86
- Gesamtkosten Brutto: {{ getFormattedPrice(order?.amountTotal) }}
112
+ <div class="flex justify-between text-muted">
113
+ <span>Netto</span>
114
+ <span>{{ getFormattedPrice(order?.amountNet) }}</span>
87
115
  </div>
88
- </div>
89
- <div class="p-2 text-balance mt-4">
90
- <div class="text-sm text-muted">
91
- Bei Fragen oder Problemen wenden Sie sich bitte telefonisch an uns.
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
- 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>
@@ -128,6 +128,12 @@ export function useAddToCart() {
128
128
  color: "primary",
129
129
  progress: true,
130
130
  duration: 2000,
131
+ actions: [
132
+ {
133
+ label: "Zum Warenkorb",
134
+ to: "/bestellung/warenkorb",
135
+ },
136
+ ],
131
137
  });
132
138
  }
133
139
 
@@ -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
+ }
@@ -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
 
@@ -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
- <UStepper ref="stepper" v-model="step" :items="items" size="lg">
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@shopbite-de/storefront",
3
- "version": "1.19.0",
3
+ "version": "1.21.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();