@shopbite-de/storefront 1.6.1 → 1.7.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/.env.example +6 -0
- package/app/components/Header/Body.vue +15 -0
- package/app/components/Header/Right.vue +114 -0
- package/app/components/Header/Title.vue +15 -0
- package/app/components/Header.vue +5 -155
- package/app/components/Hero.vue +1 -1
- package/app/components/Product/Card.vue +8 -1
- package/app/components/Product/Detail2.vue +2 -1
- package/app/components/SalesChannelSwitch.vue +63 -0
- package/app/composables/useAddToCart.ts +2 -0
- package/app/composables/useHeaderNavigation.ts +29 -0
- package/app/composables/useProductConfigurator.ts +1 -0
- package/content/navigation.yml +0 -6
- package/nuxt.config.ts +10 -1
- package/package.json +1 -1
- package/server/api/shopware/sales-channels.get.ts +79 -0
- package/server/utils/shopware/adminApiClient.ts +24 -0
- package/test/nuxt/Header.test.ts +30 -87
- package/test/nuxt/HeaderBody.test.ts +33 -0
- package/test/nuxt/HeaderRight.test.ts +141 -0
- package/test/nuxt/HeaderTitle.test.ts +42 -0
- package/test/nuxt/SalesChannelSwitch.test.ts +201 -0
- package/test/unit/sales-channels.test.ts +66 -0
package/.env.example
CHANGED
|
@@ -7,6 +7,12 @@ NUXT_PUBLIC_SHOPWARE_ENDPOINT="https://shopware.shopbite.de/store-api"
|
|
|
7
7
|
NUXT_PUBLIC_SHOPWARE_ACCESS_TOKEN="TOKEN"
|
|
8
8
|
NUXT_PUBLIC_SHOPWARE_COUNTRY_ID=019a17a0f67b706a8ec9ead3059e12ba
|
|
9
9
|
|
|
10
|
+
NUXT_SHOPWARE_ADMIN_ENDPOINT=https://your-shopware-instance.com
|
|
11
|
+
NUXT_SHOPWARE_ADMIN_CLIENT_ID=your_client_id
|
|
12
|
+
NUXT_SHOPWARE_ADMIN_CLIENT_SECRET=your_client_secret
|
|
13
|
+
|
|
14
|
+
NUXT_PUBLIC_SHOP_BITE_FEATURE_MULTI_CHANNEL=false
|
|
15
|
+
|
|
10
16
|
OPENAPI_ACCESS_KEY=key
|
|
11
17
|
OPENAPI_JSON_URL="https://shopware.shopbite.net"
|
|
12
18
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const { navi } = useHeaderNavigation();
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<template>
|
|
6
|
+
<UNavigationMenu
|
|
7
|
+
color="primary"
|
|
8
|
+
:items="navi"
|
|
9
|
+
orientation="vertical"
|
|
10
|
+
class="-mx-2.5"
|
|
11
|
+
/>
|
|
12
|
+
<div class="my-4">
|
|
13
|
+
<SalesChannelSwitch />
|
|
14
|
+
</div>
|
|
15
|
+
</template>
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useUser } from "@shopware/composables";
|
|
3
|
+
import type { DropdownMenuItem } from "@nuxt/ui";
|
|
4
|
+
|
|
5
|
+
const cartQuickViewOpen = ref(false);
|
|
6
|
+
const { count } = useCart();
|
|
7
|
+
const { isCheckoutEnabled } = useShopBiteConfig();
|
|
8
|
+
const { isLoggedIn, isGuestSession, user, logout } = useUser();
|
|
9
|
+
const toast = useToast();
|
|
10
|
+
|
|
11
|
+
const logoutHandler = () => {
|
|
12
|
+
logout();
|
|
13
|
+
toast.add({
|
|
14
|
+
title: "Tschüss!",
|
|
15
|
+
description: "Erfolreich abgemeldet.",
|
|
16
|
+
color: "success",
|
|
17
|
+
});
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const { data: navigationData } = await useAsyncData("header-navigation", () =>
|
|
21
|
+
queryCollection("navigation").first(),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const accountHoverText = computed(() => {
|
|
25
|
+
return isLoggedIn.value || isGuestSession.value
|
|
26
|
+
? `${user.value?.firstName} ${user.value?.lastName}`
|
|
27
|
+
: "Hallo";
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const loggedInDropDown = computed<DropdownMenuItem[][]>(() => {
|
|
31
|
+
if (!navigationData.value?.account.loggedIn) return [];
|
|
32
|
+
|
|
33
|
+
return navigationData.value.account.loggedIn
|
|
34
|
+
.map((group) =>
|
|
35
|
+
group
|
|
36
|
+
.filter((item) => {
|
|
37
|
+
if (isGuestSession.value) {
|
|
38
|
+
return item.type === "label" || item.action === "logout";
|
|
39
|
+
}
|
|
40
|
+
return true;
|
|
41
|
+
})
|
|
42
|
+
.map((item) => ({
|
|
43
|
+
label: item.type === "label" ? accountHoverText.value : item.label,
|
|
44
|
+
type: item.type,
|
|
45
|
+
icon: item.icon,
|
|
46
|
+
to: item.to,
|
|
47
|
+
onSelect: item.action === "logout" ? logoutHandler : undefined,
|
|
48
|
+
})),
|
|
49
|
+
)
|
|
50
|
+
.filter((group) => group.length > 0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const loggedOutDropDown = computed<DropdownMenuItem[][]>(() => {
|
|
54
|
+
if (!navigationData.value?.account.loggedOut) return [];
|
|
55
|
+
|
|
56
|
+
return navigationData.value.account.loggedOut.map((group) =>
|
|
57
|
+
group.map((item) => ({
|
|
58
|
+
label: item.label,
|
|
59
|
+
type: item.type,
|
|
60
|
+
icon: item.icon,
|
|
61
|
+
to: item.to,
|
|
62
|
+
})),
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
</script>
|
|
66
|
+
|
|
67
|
+
<template>
|
|
68
|
+
<UButton
|
|
69
|
+
color="neutral"
|
|
70
|
+
variant="ghost"
|
|
71
|
+
to="tel:+49610471427"
|
|
72
|
+
target="_blank"
|
|
73
|
+
icon="i-lucide-phone"
|
|
74
|
+
aria-label="Anrufen"
|
|
75
|
+
/>
|
|
76
|
+
<UDropdownMenu
|
|
77
|
+
:items="isLoggedIn || isGuestSession ? loggedInDropDown : loggedOutDropDown"
|
|
78
|
+
>
|
|
79
|
+
<UChip v-if="isLoggedIn || isGuestSession" size="3xl" text="✓">
|
|
80
|
+
<UButton icon="i-lucide-user" color="neutral" variant="outline" />
|
|
81
|
+
</UChip>
|
|
82
|
+
<UButton v-else icon="i-lucide-user" color="neutral" variant="outline" />
|
|
83
|
+
</UDropdownMenu>
|
|
84
|
+
<UDrawer
|
|
85
|
+
v-if="isCheckoutEnabled"
|
|
86
|
+
v-model:open="cartQuickViewOpen"
|
|
87
|
+
title="Warenkorb"
|
|
88
|
+
direction="right"
|
|
89
|
+
>
|
|
90
|
+
<UChip :text="count" size="3xl">
|
|
91
|
+
<UButton
|
|
92
|
+
color="neutral"
|
|
93
|
+
variant="outline"
|
|
94
|
+
icon="i-lucide-shopping-cart"
|
|
95
|
+
/>
|
|
96
|
+
</UChip>
|
|
97
|
+
|
|
98
|
+
<template #header>
|
|
99
|
+
<h2 class="text-3xl md:text-4xl mt-8 mb-3 pb-2">
|
|
100
|
+
<UIcon name="i-lucide-shopping-cart" class="size-8" color="primary" />
|
|
101
|
+
Warenkorb
|
|
102
|
+
</h2>
|
|
103
|
+
</template>
|
|
104
|
+
<template #body>
|
|
105
|
+
<CartQuickView
|
|
106
|
+
:with-to-cart-button="true"
|
|
107
|
+
class="md:min-w-90"
|
|
108
|
+
@go-to-cart="cartQuickViewOpen = false"
|
|
109
|
+
/>
|
|
110
|
+
</template>
|
|
111
|
+
</UDrawer>
|
|
112
|
+
</template>
|
|
113
|
+
|
|
114
|
+
<style scoped></style>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const config = useRuntimeConfig();
|
|
3
|
+
const siteName = computed(() => config.public.site.name);
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<template>
|
|
7
|
+
<NuxtLink to="/" class="-m-1.5 p-1.5">
|
|
8
|
+
<span class="sr-only">{{ siteName }}</span>
|
|
9
|
+
<UColorModeImage
|
|
10
|
+
light="/light/Logo.png"
|
|
11
|
+
dark="/dark/Logo.png"
|
|
12
|
+
class="h-12 w-auto"
|
|
13
|
+
/>
|
|
14
|
+
</NuxtLink>
|
|
15
|
+
</template>
|
|
@@ -1,175 +1,25 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
|
|
3
|
-
import { useRoute } from "vue-router";
|
|
4
|
-
import { useUser } from "@shopware/composables";
|
|
5
|
-
|
|
6
|
-
const route = useRoute();
|
|
7
|
-
const toast = useToast();
|
|
8
|
-
|
|
9
|
-
const { isLoggedIn, isGuestSession, user, logout } = useUser();
|
|
10
|
-
const { isCheckoutEnabled } = useShopBiteConfig();
|
|
11
|
-
const { count } = useCart();
|
|
12
|
-
const runtimeConfig = useRuntimeConfig();
|
|
13
|
-
|
|
14
|
-
// Fetch navigation from content
|
|
15
|
-
const { data: navigationData } = await useAsyncData("header-navigation", () =>
|
|
16
|
-
queryCollection("navigation").first(),
|
|
17
|
-
);
|
|
18
|
-
|
|
19
|
-
const siteName = computed(() => runtimeConfig.public.site?.name ?? "ShopBite");
|
|
20
|
-
|
|
21
|
-
const navi = computed<NavigationMenuItem[]>(() => {
|
|
22
|
-
if (!navigationData.value?.main) return [];
|
|
23
|
-
|
|
24
|
-
return navigationData.value.main.map((item) => ({
|
|
25
|
-
label: item.label,
|
|
26
|
-
icon: item.icon,
|
|
27
|
-
to: item.to,
|
|
28
|
-
target: item.target,
|
|
29
|
-
active:
|
|
30
|
-
item.to === "/"
|
|
31
|
-
? route.path.length === 1
|
|
32
|
-
: route.path.startsWith(item.to),
|
|
33
|
-
}));
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
const accountHoverText = computed(() => {
|
|
37
|
-
return isLoggedIn.value || isGuestSession.value
|
|
38
|
-
? `${user.value?.firstName} ${user.value?.lastName}`
|
|
39
|
-
: "Hallo";
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
const logoutHandler = () => {
|
|
43
|
-
logout();
|
|
44
|
-
toast.add({
|
|
45
|
-
title: "Tschüss!",
|
|
46
|
-
description: "Erfolreich abgemeldet.",
|
|
47
|
-
color: "success",
|
|
48
|
-
});
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
const loggedInDropDown = computed<DropdownMenuItem[][]>(() => {
|
|
52
|
-
if (!navigationData.value?.account.loggedIn) return [];
|
|
53
|
-
|
|
54
|
-
return navigationData.value.account.loggedIn
|
|
55
|
-
.map((group) =>
|
|
56
|
-
group
|
|
57
|
-
.filter((item) => {
|
|
58
|
-
if (isGuestSession.value) {
|
|
59
|
-
return item.type === "label" || item.action === "logout";
|
|
60
|
-
}
|
|
61
|
-
return true;
|
|
62
|
-
})
|
|
63
|
-
.map((item) => ({
|
|
64
|
-
label: item.type === "label" ? accountHoverText.value : item.label,
|
|
65
|
-
type: item.type,
|
|
66
|
-
icon: item.icon,
|
|
67
|
-
to: item.to,
|
|
68
|
-
onSelect: item.action === "logout" ? logoutHandler : undefined,
|
|
69
|
-
})),
|
|
70
|
-
)
|
|
71
|
-
.filter((group) => group.length > 0);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
const loggedOutDropDown = computed<DropdownMenuItem[][]>(() => {
|
|
75
|
-
if (!navigationData.value?.account.loggedOut) return [];
|
|
76
|
-
|
|
77
|
-
return navigationData.value.account.loggedOut.map((group) =>
|
|
78
|
-
group.map((item) => ({
|
|
79
|
-
label: item.label,
|
|
80
|
-
type: item.type,
|
|
81
|
-
icon: item.icon,
|
|
82
|
-
to: item.to,
|
|
83
|
-
})),
|
|
84
|
-
);
|
|
85
|
-
});
|
|
86
|
-
|
|
2
|
+
const { navi } = useHeaderNavigation();
|
|
87
3
|
const loginSlide = ref(false);
|
|
88
|
-
const cartQuickViewOpen = ref(false);
|
|
89
4
|
</script>
|
|
90
5
|
|
|
91
6
|
<template>
|
|
92
7
|
<UHeader>
|
|
93
8
|
<template #title>
|
|
94
|
-
<
|
|
95
|
-
<span class="sr-only">{{ siteName }}</span>
|
|
96
|
-
<UColorModeImage
|
|
97
|
-
light="/light/Logo.png"
|
|
98
|
-
dark="/dark/Logo.png"
|
|
99
|
-
class="h-12 w-auto"
|
|
100
|
-
/>
|
|
101
|
-
</NuxtLink>
|
|
9
|
+
<HeaderTitle />
|
|
102
10
|
</template>
|
|
103
11
|
|
|
104
12
|
<UNavigationMenu color="primary" variant="pill" :items="navi" />
|
|
105
13
|
|
|
106
14
|
<template #right>
|
|
107
|
-
<
|
|
108
|
-
color="neutral"
|
|
109
|
-
variant="ghost"
|
|
110
|
-
to="tel:+49610471427"
|
|
111
|
-
target="_blank"
|
|
112
|
-
icon="i-lucide-phone"
|
|
113
|
-
aria-label="Anrufen"
|
|
114
|
-
/>
|
|
115
|
-
<UDropdownMenu
|
|
116
|
-
:items="
|
|
117
|
-
isLoggedIn || isGuestSession ? loggedInDropDown : loggedOutDropDown
|
|
118
|
-
"
|
|
119
|
-
>
|
|
120
|
-
<UChip v-if="isLoggedIn || isGuestSession" size="3xl" text="✓">
|
|
121
|
-
<UButton icon="i-lucide-user" color="neutral" variant="outline" />
|
|
122
|
-
</UChip>
|
|
123
|
-
<UButton
|
|
124
|
-
v-else
|
|
125
|
-
icon="i-lucide-user"
|
|
126
|
-
color="neutral"
|
|
127
|
-
variant="outline"
|
|
128
|
-
/>
|
|
129
|
-
</UDropdownMenu>
|
|
130
|
-
<UDrawer
|
|
131
|
-
v-if="isCheckoutEnabled"
|
|
132
|
-
v-model:open="cartQuickViewOpen"
|
|
133
|
-
title="Warenkorb"
|
|
134
|
-
direction="right"
|
|
135
|
-
>
|
|
136
|
-
<UChip :text="count" size="3xl">
|
|
137
|
-
<UButton
|
|
138
|
-
color="neutral"
|
|
139
|
-
variant="outline"
|
|
140
|
-
icon="i-lucide-shopping-cart"
|
|
141
|
-
/>
|
|
142
|
-
</UChip>
|
|
143
|
-
|
|
144
|
-
<template #header>
|
|
145
|
-
<h2 class="text-3xl md:text-4xl mt-8 mb-3 pb-2">
|
|
146
|
-
<UIcon
|
|
147
|
-
name="i-lucide-shopping-cart"
|
|
148
|
-
class="size-8"
|
|
149
|
-
color="primary"
|
|
150
|
-
/>
|
|
151
|
-
Warenkorb
|
|
152
|
-
</h2>
|
|
153
|
-
</template>
|
|
154
|
-
<template #body>
|
|
155
|
-
<CartQuickView
|
|
156
|
-
:with-to-cart-button="true"
|
|
157
|
-
class="md:min-w-90"
|
|
158
|
-
@go-to-cart="cartQuickViewOpen = false"
|
|
159
|
-
/>
|
|
160
|
-
</template>
|
|
161
|
-
</UDrawer>
|
|
15
|
+
<HeaderRight />
|
|
162
16
|
</template>
|
|
163
17
|
|
|
164
18
|
<template #body>
|
|
165
|
-
<
|
|
166
|
-
color="primary"
|
|
167
|
-
:items="navi"
|
|
168
|
-
orientation="vertical"
|
|
169
|
-
class="-mx-2.5"
|
|
170
|
-
/>
|
|
19
|
+
<HeaderBody />
|
|
171
20
|
</template>
|
|
172
21
|
</UHeader>
|
|
22
|
+
|
|
173
23
|
<USlideover
|
|
174
24
|
v-model:open="loginSlide"
|
|
175
25
|
title="Konto"
|
package/app/components/Hero.vue
CHANGED
|
@@ -16,6 +16,8 @@ const { getFormattedPrice } = usePrice({
|
|
|
16
16
|
localeCode: "de-DE",
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
+
const price = ref(product.value.calculatedPrice.totalPrice);
|
|
20
|
+
|
|
19
21
|
const isVegi = computed<boolean>(() => {
|
|
20
22
|
if (!product.value?.properties) {
|
|
21
23
|
return false;
|
|
@@ -47,6 +49,10 @@ const mainIngredients = computed<Schemas["PropertyGroupOption"][]>(() => {
|
|
|
47
49
|
);
|
|
48
50
|
return mainIngredientsProperty?.options ?? [];
|
|
49
51
|
});
|
|
52
|
+
|
|
53
|
+
function onVariantSelected(variant: Schemas["Product"]) {
|
|
54
|
+
price.value = variant.calculatedPrice.totalPrice;
|
|
55
|
+
}
|
|
50
56
|
</script>
|
|
51
57
|
|
|
52
58
|
<template>
|
|
@@ -111,7 +117,7 @@ const mainIngredients = computed<Schemas["PropertyGroupOption"][]>(() => {
|
|
|
111
117
|
|
|
112
118
|
<template #footer>
|
|
113
119
|
<div class="flex flex-row justify-between content-center w-full">
|
|
114
|
-
<p>{{ getFormattedPrice(
|
|
120
|
+
<p>{{ getFormattedPrice(price) }}</p>
|
|
115
121
|
<div class="flex flex-row gap-2">
|
|
116
122
|
<AddToWishlist v-if="withFavoriteButton" :product="product" />
|
|
117
123
|
<UButton
|
|
@@ -127,6 +133,7 @@ const mainIngredients = computed<Schemas["PropertyGroupOption"][]>(() => {
|
|
|
127
133
|
<ProductDetail2
|
|
128
134
|
:product-id="product.id"
|
|
129
135
|
@product-added="toggleDetails"
|
|
136
|
+
@variant-selected="onVariantSelected"
|
|
130
137
|
/>
|
|
131
138
|
</template>
|
|
132
139
|
</UCollapsible>
|
|
@@ -114,6 +114,7 @@ watch(
|
|
|
114
114
|
|
|
115
115
|
const onVariantSwitched = (variant: Schemas["Product"]) => {
|
|
116
116
|
setSelectedProduct(variant);
|
|
117
|
+
emit("variant-selected", variant);
|
|
117
118
|
};
|
|
118
119
|
|
|
119
120
|
const onExtrasSelected = (extras: AssociationItemProduct[]) => {
|
|
@@ -126,7 +127,7 @@ const onIngredientsDeselected = (deselected: string[]) => {
|
|
|
126
127
|
|
|
127
128
|
const onAddToCart = () => emit("product-added");
|
|
128
129
|
|
|
129
|
-
const emit = defineEmits(["product-added"]);
|
|
130
|
+
const emit = defineEmits(["product-added", "variant-selected"]);
|
|
130
131
|
</script>
|
|
131
132
|
|
|
132
133
|
<template>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { SelectMenuItem } from "@nuxt/ui";
|
|
3
|
+
import type { Schemas } from "#shopware";
|
|
4
|
+
|
|
5
|
+
const config = useRuntimeConfig();
|
|
6
|
+
const isMultiChannel = computed(
|
|
7
|
+
() => !!config.public.shopBite.feature.multiChannel,
|
|
8
|
+
);
|
|
9
|
+
const storeUrl = computed(() => config.public.storeUrl);
|
|
10
|
+
|
|
11
|
+
const { data: salesChannels, status } = await useFetch(
|
|
12
|
+
"/api/shopware/sales-channels",
|
|
13
|
+
{
|
|
14
|
+
key: "sales-channels",
|
|
15
|
+
transform: (data: Schemas["SalesChannel"][]) => {
|
|
16
|
+
const currentUrl = storeUrl.value;
|
|
17
|
+
return data?.map((channel) => {
|
|
18
|
+
const domains = channel.domains || [];
|
|
19
|
+
const matchingDomain =
|
|
20
|
+
currentUrl != null
|
|
21
|
+
? domains.find((domain) => domain?.url === currentUrl)
|
|
22
|
+
: undefined;
|
|
23
|
+
const fallbackDomain = domains.find((domain) => !!domain?.url);
|
|
24
|
+
const domainUrl = matchingDomain?.url ?? fallbackDomain?.url ?? "";
|
|
25
|
+
return {
|
|
26
|
+
label: channel.translated.name ?? channel.name,
|
|
27
|
+
value: domainUrl,
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
},
|
|
31
|
+
lazy: true,
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const selectedStore = ref<SelectMenuItem>();
|
|
36
|
+
|
|
37
|
+
watchEffect(() => {
|
|
38
|
+
if (salesChannels.value && storeUrl.value && !selectedStore.value) {
|
|
39
|
+
const matchingChannel = salesChannels.value.find(
|
|
40
|
+
(channel) => channel.value === storeUrl.value,
|
|
41
|
+
);
|
|
42
|
+
if (matchingChannel) {
|
|
43
|
+
selectedStore.value = matchingChannel;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
watch(selectedStore, (newStore, oldStore) => {
|
|
49
|
+
if (newStore && oldStore && newStore.value !== oldStore.value) {
|
|
50
|
+
window.location.href = newStore.value;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<template>
|
|
56
|
+
<USelectMenu
|
|
57
|
+
v-if="isMultiChannel"
|
|
58
|
+
v-model="selectedStore"
|
|
59
|
+
:items="salesChannels"
|
|
60
|
+
:loading="status === 'pending'"
|
|
61
|
+
icon="i-lucide-store"
|
|
62
|
+
/>
|
|
63
|
+
</template>
|
|
@@ -98,6 +98,8 @@ export function useAddToCart() {
|
|
|
98
98
|
id: generatedUuid,
|
|
99
99
|
quantity: selectedQuantity.value,
|
|
100
100
|
type: LINE_ITEM_CONTAINER,
|
|
101
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
102
|
+
// @ts-expect-error
|
|
101
103
|
label: cartItemLabel.value,
|
|
102
104
|
payload: {
|
|
103
105
|
productNumber: selectedProduct.value.productNumber,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { NavigationMenuItem } from "@nuxt/ui";
|
|
2
|
+
|
|
3
|
+
export const useHeaderNavigation = () => {
|
|
4
|
+
const route = useRoute();
|
|
5
|
+
|
|
6
|
+
const { data: navigationData } = useAsyncData("header-navigation", () =>
|
|
7
|
+
queryCollection("navigation").first(),
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
const navi = computed<NavigationMenuItem[]>(() => {
|
|
11
|
+
if (!navigationData.value?.main) return [];
|
|
12
|
+
|
|
13
|
+
return navigationData.value.main.map((item) => ({
|
|
14
|
+
label: item.label,
|
|
15
|
+
icon: item.icon,
|
|
16
|
+
to: item.to,
|
|
17
|
+
target: item.target,
|
|
18
|
+
active:
|
|
19
|
+
item.to === "/"
|
|
20
|
+
? route.path.length === 1
|
|
21
|
+
: route.path.startsWith(item.to),
|
|
22
|
+
}));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
navigationData,
|
|
27
|
+
navi,
|
|
28
|
+
};
|
|
29
|
+
};
|
package/content/navigation.yml
CHANGED
package/nuxt.config.ts
CHANGED
|
@@ -39,10 +39,19 @@ export default defineNuxtConfig({
|
|
|
39
39
|
},
|
|
40
40
|
|
|
41
41
|
runtimeConfig: {
|
|
42
|
-
shopware: {
|
|
42
|
+
shopware: {
|
|
43
|
+
adminClientId: "",
|
|
44
|
+
adminClientSecret: "",
|
|
45
|
+
adminEndpoint: "",
|
|
46
|
+
},
|
|
43
47
|
apiClientConfig: {},
|
|
44
48
|
geoapifyApiKey: "",
|
|
45
49
|
public: {
|
|
50
|
+
shopBite: {
|
|
51
|
+
feature: {
|
|
52
|
+
multiChannel: "",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
46
55
|
site: {
|
|
47
56
|
name: "ShopBite",
|
|
48
57
|
description: "Reduziere deine Kosten und steigere deinen Umsatz",
|
package/package.json
CHANGED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { operations } from "@shopware/api-client/admin-api-types";
|
|
2
|
+
import { createAdminAPIClient } from "@shopware/api-client";
|
|
3
|
+
|
|
4
|
+
export default defineEventHandler(async () => {
|
|
5
|
+
const config = useRuntimeConfig();
|
|
6
|
+
|
|
7
|
+
// Get admin API credentials from runtime config
|
|
8
|
+
const adminEndpoint = config.shopware.adminEndpoint;
|
|
9
|
+
const adminClientId = config.shopware.adminClientId;
|
|
10
|
+
const adminClientSecret = config.shopware.adminClientSecret;
|
|
11
|
+
|
|
12
|
+
if (!adminEndpoint || !adminClientId || !adminClientSecret) {
|
|
13
|
+
throw createError({
|
|
14
|
+
statusCode: 500,
|
|
15
|
+
statusMessage: "Shopware admin API credentials not configured",
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const adminApiClient = createAdminAPIClient<operations>({
|
|
21
|
+
baseURL: adminEndpoint,
|
|
22
|
+
credentials: {
|
|
23
|
+
grant_type: "client_credentials",
|
|
24
|
+
client_id: adminClientId,
|
|
25
|
+
client_secret: adminClientSecret,
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const data = await adminApiClient.invoke(
|
|
30
|
+
"searchSalesChannel post /search/sales-channel",
|
|
31
|
+
{
|
|
32
|
+
body: {
|
|
33
|
+
includes: {
|
|
34
|
+
sales_channel: ["id", "translated", "name", "domains", "active"],
|
|
35
|
+
sales_channel_domain: ["url"],
|
|
36
|
+
},
|
|
37
|
+
associations: {
|
|
38
|
+
domains: {},
|
|
39
|
+
},
|
|
40
|
+
filter: [
|
|
41
|
+
{
|
|
42
|
+
field: "active",
|
|
43
|
+
type: "equals",
|
|
44
|
+
value: true,
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// Validate response data
|
|
52
|
+
if (!data || !data.data) {
|
|
53
|
+
throw createError({
|
|
54
|
+
statusCode: 500,
|
|
55
|
+
statusMessage: "Invalid response from Shopware API",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return data.data.data;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
// Handle specific error cases
|
|
62
|
+
if (error && typeof error === "object" && "statusCode" in error) {
|
|
63
|
+
// Re-throw createError instances
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Log the error for debugging
|
|
68
|
+
console.error("Failed to fetch sales channels:", error);
|
|
69
|
+
|
|
70
|
+
// Return a user-friendly error
|
|
71
|
+
throw createError({
|
|
72
|
+
statusCode: 503,
|
|
73
|
+
statusMessage: "Failed to fetch sales channels from Shopware",
|
|
74
|
+
data: {
|
|
75
|
+
originalError: error instanceof Error ? error.message : String(error),
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createAdminAPIClient } from "@shopware/api-client";
|
|
2
|
+
import type { operations } from "@shopware/api-client/admin-api-types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates a Shopware Admin API client
|
|
6
|
+
*/
|
|
7
|
+
export function createAdminApiClient(baseURL: string, accessToken: string) {
|
|
8
|
+
return createAdminAPIClient<operations>({
|
|
9
|
+
baseURL,
|
|
10
|
+
accessToken,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Fetches sales channels using the Admin API client
|
|
16
|
+
*/
|
|
17
|
+
export async function getSalesChannels(client: any) {
|
|
18
|
+
const response = await client.invoke({
|
|
19
|
+
method: "GET",
|
|
20
|
+
path: "/api/v3/sales-channel",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
return response.data;
|
|
24
|
+
}
|
package/test/nuxt/Header.test.ts
CHANGED
|
@@ -1,23 +1,17 @@
|
|
|
1
|
-
import { describe, it, expect, vi
|
|
2
|
-
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
|
-
import { mockNuxtImport } from "@nuxt/test-utils/runtime";
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { mountSuspended, mockNuxtImport } from "@nuxt/test-utils/runtime";
|
|
4
3
|
import Header from "~/components/Header.vue";
|
|
5
|
-
import { ref
|
|
4
|
+
import { ref } from "vue";
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
isLoggedIn: false,
|
|
10
|
-
isGuestSession: false,
|
|
11
|
-
user: null as any,
|
|
12
|
-
},
|
|
13
|
-
logout: vi.fn(),
|
|
6
|
+
mockNuxtImport("useHeaderNavigation", () => () => ({
|
|
7
|
+
navi: ref([{ label: "Home", to: "/" }]),
|
|
14
8
|
}));
|
|
15
9
|
|
|
16
|
-
const reactiveState = reactive(mocks.state);
|
|
17
|
-
|
|
18
10
|
mockNuxtImport("useUser", () => () => ({
|
|
19
|
-
|
|
20
|
-
|
|
11
|
+
isLoggedIn: ref(false),
|
|
12
|
+
isGuestSession: ref(false),
|
|
13
|
+
user: ref(null),
|
|
14
|
+
logout: vi.fn(),
|
|
21
15
|
}));
|
|
22
16
|
|
|
23
17
|
mockNuxtImport("useShopBiteConfig", () => () => ({
|
|
@@ -28,97 +22,46 @@ mockNuxtImport("useCart", () => () => ({
|
|
|
28
22
|
count: ref(0),
|
|
29
23
|
}));
|
|
30
24
|
|
|
25
|
+
mockNuxtImport("useToast", () => () => ({
|
|
26
|
+
add: vi.fn(),
|
|
27
|
+
}));
|
|
28
|
+
|
|
31
29
|
mockNuxtImport("useRuntimeConfig", () => () => ({
|
|
32
|
-
app: {
|
|
33
|
-
|
|
34
|
-
},
|
|
35
|
-
public: {
|
|
36
|
-
site: {
|
|
37
|
-
name: "ShopBite",
|
|
38
|
-
},
|
|
39
|
-
shopware: {
|
|
40
|
-
devStorefrontUrl: "http://localhost:3000",
|
|
41
|
-
},
|
|
42
|
-
},
|
|
30
|
+
app: { baseURL: "/" },
|
|
31
|
+
public: { site: { name: "ShopBite" } },
|
|
43
32
|
}));
|
|
44
33
|
|
|
45
34
|
// Mock Nuxt Content queryCollection
|
|
46
35
|
mockNuxtImport("queryCollection", () => (collection: string) => ({
|
|
47
36
|
first: () =>
|
|
48
37
|
Promise.resolve({
|
|
49
|
-
main: [],
|
|
50
38
|
account: {
|
|
51
|
-
loggedIn: [
|
|
52
|
-
|
|
53
|
-
[
|
|
54
|
-
{ label: "Konto", icon: "i-lucide-user", to: "/konto" },
|
|
55
|
-
{
|
|
56
|
-
label: "Bestellungen",
|
|
57
|
-
icon: "i-lucide-pizza",
|
|
58
|
-
to: "/konto/bestellungen",
|
|
59
|
-
},
|
|
60
|
-
],
|
|
61
|
-
[{ label: "Abmelden", icon: "i-lucide-log-out", action: "logout" }],
|
|
62
|
-
],
|
|
63
|
-
loggedOut: [
|
|
64
|
-
[{ label: "Jetzt anmelden", type: "label" }],
|
|
65
|
-
[{ label: "Zur Anmeldung", icon: "i-lucide-user", to: "/anmelden" }],
|
|
66
|
-
],
|
|
39
|
+
loggedIn: [],
|
|
40
|
+
loggedOut: [],
|
|
67
41
|
},
|
|
68
42
|
}),
|
|
69
43
|
}));
|
|
70
44
|
|
|
71
45
|
describe("Header", () => {
|
|
72
|
-
|
|
73
|
-
reactiveState.isLoggedIn = false;
|
|
74
|
-
reactiveState.isGuestSession = false;
|
|
75
|
-
reactiveState.user = null;
|
|
76
|
-
vi.clearAllMocks();
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("renders correctly when logged out", async () => {
|
|
46
|
+
it("renders structure correctly", async () => {
|
|
80
47
|
const component = await mountSuspended(Header);
|
|
81
|
-
// Should show user icon
|
|
82
|
-
expect(component.html()).toContain("i-lucide:user");
|
|
83
|
-
// Should NOT show checkmark chip
|
|
84
|
-
expect(component.html()).not.toContain("✓");
|
|
85
|
-
});
|
|
86
48
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
49
|
+
const srOnlySiteName = component.find(".sr-only");
|
|
50
|
+
expect(srOnlySiteName.exists()).toBe(true);
|
|
51
|
+
expect(srOnlySiteName.text()).toBe("ShopBite");
|
|
52
|
+
expect(component.html()).toContain("i-lucide:phone");
|
|
53
|
+
expect(component.html()).toContain("i-lucide:shopping-cart");
|
|
91
54
|
|
|
92
|
-
|
|
93
|
-
expect(
|
|
55
|
+
const navMenu = component.findComponent({ name: "UNavigationMenu" });
|
|
56
|
+
expect(navMenu.exists()).toBe(true);
|
|
94
57
|
});
|
|
95
58
|
|
|
96
|
-
it("renders
|
|
97
|
-
reactiveState.isGuestSession = true;
|
|
98
|
-
reactiveState.user = { firstName: "Guest", lastName: "User" };
|
|
59
|
+
it("renders login slideover", async () => {
|
|
99
60
|
const component = await mountSuspended(Header);
|
|
100
61
|
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
it("filters menu items for guest session", async () => {
|
|
106
|
-
reactiveState.isGuestSession = true;
|
|
107
|
-
reactiveState.user = { firstName: "Guest", lastName: "User" };
|
|
108
|
-
const component = await mountSuspended(Header);
|
|
109
|
-
|
|
110
|
-
// Find the dropdown menu
|
|
111
|
-
const dropdown = component.findComponent({ name: "UDropdownMenu" });
|
|
112
|
-
expect(dropdown.exists()).toBe(true);
|
|
113
|
-
|
|
114
|
-
const items = dropdown.props("items");
|
|
115
|
-
|
|
116
|
-
// Group 0 should have the user label
|
|
117
|
-
// Group 1 should be gone (filtered out because it only contained links)
|
|
118
|
-
// Group 2 (now index 1) should have logout
|
|
119
|
-
expect(items.length).toBe(2);
|
|
120
|
-
expect(items[0][0].label).toBe("Guest User");
|
|
121
|
-
expect(items[1][0].label).toBe("Abmelden");
|
|
122
|
-
expect(items[1][0].onSelect).toBeDefined();
|
|
62
|
+
// USlideover might not be directly in HTML if it uses teleport and is closed
|
|
63
|
+
const slideover = component.findComponent({ name: "USlideover" });
|
|
64
|
+
expect(slideover.exists()).toBe(true);
|
|
65
|
+
expect(slideover.props("title")).toBe("Konto");
|
|
123
66
|
});
|
|
124
67
|
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mountSuspended, mockNuxtImport } from "@nuxt/test-utils/runtime";
|
|
3
|
+
import HeaderBody from "~/components/Header/Body.vue";
|
|
4
|
+
import { ref } from "vue";
|
|
5
|
+
|
|
6
|
+
const mockNavi = [
|
|
7
|
+
{ label: "Home", to: "/", icon: "i-lucide-home" },
|
|
8
|
+
{ label: "Products", to: "/products", icon: "i-lucide-package" },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
mockNuxtImport("useHeaderNavigation", () => () => ({
|
|
12
|
+
navi: ref(mockNavi),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
describe("HeaderBody", () => {
|
|
16
|
+
it("renders navigation menu with correct items", async () => {
|
|
17
|
+
const component = await mountSuspended(HeaderBody);
|
|
18
|
+
const navMenu = component.findComponent({ name: "UNavigationMenu" });
|
|
19
|
+
|
|
20
|
+
expect(navMenu.exists()).toBe(true);
|
|
21
|
+
expect(navMenu.props("items")).toEqual(mockNavi);
|
|
22
|
+
expect(navMenu.props("orientation")).toBe("vertical");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("renders SalesChannelSwitch", async () => {
|
|
26
|
+
const component = await mountSuspended(HeaderBody);
|
|
27
|
+
const switchComponent = component.findComponent({
|
|
28
|
+
name: "SalesChannelSwitch",
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(switchComponent.exists()).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { mountSuspended, mockNuxtImport } from "@nuxt/test-utils/runtime";
|
|
3
|
+
import HeaderRight from "~/components/Header/Right.vue";
|
|
4
|
+
import { ref, reactive, computed } from "vue";
|
|
5
|
+
|
|
6
|
+
const mocks = vi.hoisted(() => ({
|
|
7
|
+
state: {
|
|
8
|
+
isLoggedIn: false,
|
|
9
|
+
isGuestSession: false,
|
|
10
|
+
user: null as any,
|
|
11
|
+
},
|
|
12
|
+
toastAddCalled: { value: false },
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
const reactiveState = reactive(mocks.state);
|
|
16
|
+
|
|
17
|
+
mockNuxtImport("useUser", () => () => ({
|
|
18
|
+
isLoggedIn: computed(() => reactiveState.isLoggedIn),
|
|
19
|
+
isGuestSession: computed(() => reactiveState.isGuestSession),
|
|
20
|
+
user: computed(() => reactiveState.user),
|
|
21
|
+
logout: () => {
|
|
22
|
+
reactiveState.isLoggedIn = false;
|
|
23
|
+
reactiveState.user = null;
|
|
24
|
+
},
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
mockNuxtImport("useToast", () => () => ({
|
|
28
|
+
add: (payload: any) => {
|
|
29
|
+
if (typeof global !== "undefined" && (global as any).toastAddCalled) {
|
|
30
|
+
(global as any).toastAddCalled.value = true;
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
mockNuxtImport("useShopBiteConfig", () => () => ({
|
|
36
|
+
isCheckoutEnabled: ref(true),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
mockNuxtImport("useCart", () => () => ({
|
|
40
|
+
count: ref(5),
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
mockNuxtImport("useRuntimeConfig", () => () => ({
|
|
44
|
+
app: { baseURL: "/" },
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
// Mock Nuxt Content queryCollection
|
|
48
|
+
mockNuxtImport("queryCollection", () => (collection: string) => ({
|
|
49
|
+
first: () =>
|
|
50
|
+
Promise.resolve({
|
|
51
|
+
account: {
|
|
52
|
+
loggedIn: [
|
|
53
|
+
[{ label: "Mein Konto", type: "label" }],
|
|
54
|
+
[
|
|
55
|
+
{
|
|
56
|
+
label: "Bestellungen",
|
|
57
|
+
icon: "i-lucide-pizza",
|
|
58
|
+
to: "/konto/bestellungen",
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
[{ label: "Abmelden", icon: "i-lucide-log-out", action: "logout" }],
|
|
62
|
+
],
|
|
63
|
+
loggedOut: [
|
|
64
|
+
[{ label: "Jetzt anmelden", type: "label" }],
|
|
65
|
+
[{ label: "Zur Anmeldung", icon: "i-lucide-user", to: "/anmelden" }],
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
}),
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
describe("HeaderRight", () => {
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
reactiveState.isLoggedIn = false;
|
|
74
|
+
reactiveState.isGuestSession = false;
|
|
75
|
+
reactiveState.user = null;
|
|
76
|
+
mocks.toastAddCalled.value = false;
|
|
77
|
+
(global as any).toastAddCalled = mocks.toastAddCalled;
|
|
78
|
+
vi.clearAllMocks();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("renders phone link", async () => {
|
|
82
|
+
const component = await mountSuspended(HeaderRight);
|
|
83
|
+
const phoneButton = component.find('a[href="tel:+49610471427"]');
|
|
84
|
+
expect(phoneButton.exists()).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("shows logged out dropdown items when not logged in", async () => {
|
|
88
|
+
const component = await mountSuspended(HeaderRight);
|
|
89
|
+
const dropdown = component.findComponent({ name: "UDropdownMenu" });
|
|
90
|
+
const items = dropdown.props("items");
|
|
91
|
+
|
|
92
|
+
expect(items[0][0].label).toBe("Jetzt anmelden");
|
|
93
|
+
expect(items[1][0].to).toBe("/anmelden");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("shows logged in dropdown items when logged in", async () => {
|
|
97
|
+
reactiveState.isLoggedIn = true;
|
|
98
|
+
reactiveState.user = { firstName: "Jane", lastName: "Doe" };
|
|
99
|
+
|
|
100
|
+
const component = await mountSuspended(HeaderRight);
|
|
101
|
+
const dropdown = component.findComponent({ name: "UDropdownMenu" });
|
|
102
|
+
const items = dropdown.props("items");
|
|
103
|
+
|
|
104
|
+
expect(items[0][0].label).toBe("Jane Doe");
|
|
105
|
+
expect(items[1][0].to).toBe("/konto/bestellungen");
|
|
106
|
+
expect(items[2][0].label).toBe("Abmelden");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("shows cart drawer with correct count when checkout is enabled", async () => {
|
|
110
|
+
const component = await mountSuspended(HeaderRight);
|
|
111
|
+
const drawer = component.findComponent({ name: "UDrawer" });
|
|
112
|
+
expect(drawer.exists()).toBe(true);
|
|
113
|
+
|
|
114
|
+
const chip = component.findComponent({ name: "UChip" });
|
|
115
|
+
expect(chip.props("text")).toBe(5);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("calls logout and updates state when logout is selected", async () => {
|
|
119
|
+
reactiveState.isLoggedIn = true;
|
|
120
|
+
const component = await mountSuspended(HeaderRight);
|
|
121
|
+
const dropdown = component.findComponent({ name: "UDropdownMenu" });
|
|
122
|
+
const items = dropdown.props("items");
|
|
123
|
+
|
|
124
|
+
// Find the item with logout handler
|
|
125
|
+
let logoutItem;
|
|
126
|
+
for (const group of items) {
|
|
127
|
+
for (const item of group) {
|
|
128
|
+
if (item.label === "Abmelden") {
|
|
129
|
+
logoutItem = item;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
expect(logoutItem).toBeDefined();
|
|
136
|
+
logoutItem.onSelect();
|
|
137
|
+
|
|
138
|
+
expect(reactiveState.isLoggedIn).toBe(false);
|
|
139
|
+
expect(mocks.toastAddCalled.value).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { mountSuspended, mockNuxtImport } from "@nuxt/test-utils/runtime";
|
|
3
|
+
import HeaderTitle from "~/components/Header/Title.vue";
|
|
4
|
+
|
|
5
|
+
const { mockRuntimeConfig } = vi.hoisted(() => ({
|
|
6
|
+
mockRuntimeConfig: {
|
|
7
|
+
app: {
|
|
8
|
+
baseURL: "/",
|
|
9
|
+
},
|
|
10
|
+
public: {
|
|
11
|
+
site: {
|
|
12
|
+
name: "ShopBite Test Store",
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
mockNuxtImport("useRuntimeConfig", () => () => mockRuntimeConfig);
|
|
19
|
+
|
|
20
|
+
describe("HeaderTitle", () => {
|
|
21
|
+
it("renders the site name for screen readers", async () => {
|
|
22
|
+
const component = await mountSuspended(HeaderTitle);
|
|
23
|
+
const srOnly = component.find(".sr-only");
|
|
24
|
+
expect(srOnly.exists()).toBe(true);
|
|
25
|
+
expect(srOnly.text()).toBe("ShopBite Test Store");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("renders a link to the home page", async () => {
|
|
29
|
+
const component = await mountSuspended(HeaderTitle);
|
|
30
|
+
const link = component.findComponent({ name: "NuxtLink" });
|
|
31
|
+
expect(link.exists()).toBe(true);
|
|
32
|
+
expect(link.props("to")).toBe("/");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("renders the logo image", async () => {
|
|
36
|
+
const component = await mountSuspended(HeaderTitle);
|
|
37
|
+
const image = component.findComponent({ name: "UColorModeImage" });
|
|
38
|
+
expect(image.exists()).toBe(true);
|
|
39
|
+
expect(image.props("light")).toBe("/light/Logo.png");
|
|
40
|
+
expect(image.props("dark")).toBe("/dark/Logo.png");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { mountSuspended, mockNuxtImport } from "@nuxt/test-utils/runtime";
|
|
3
|
+
import SalesChannelSwitch from "~/components/SalesChannelSwitch.vue";
|
|
4
|
+
import { ref } from "vue";
|
|
5
|
+
|
|
6
|
+
// Mock window.location
|
|
7
|
+
const mockWindowLocation = {
|
|
8
|
+
href: "",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// Mock the sales channels data that would be returned by useFetch
|
|
12
|
+
const mockSalesChannels = [
|
|
13
|
+
{
|
|
14
|
+
label: "Main Store",
|
|
15
|
+
value: "https://main-store.example.com",
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
label: "Secondary Store",
|
|
19
|
+
value: "https://secondary-store.example.com",
|
|
20
|
+
},
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
// Use vi.hoisted to avoid hoisting issues
|
|
24
|
+
const { mockUseFetch, mockRuntimeConfig } = vi.hoisted(() => ({
|
|
25
|
+
mockUseFetch: vi.fn(() => {
|
|
26
|
+
return {
|
|
27
|
+
data: ref(mockSalesChannels),
|
|
28
|
+
status: ref("success"),
|
|
29
|
+
};
|
|
30
|
+
}),
|
|
31
|
+
mockRuntimeConfig: {
|
|
32
|
+
public: {
|
|
33
|
+
shopBite: {
|
|
34
|
+
feature: {
|
|
35
|
+
multiChannel: true,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
storeUrl: "https://main-store.example.com",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
// Set up the mocks at the top level
|
|
44
|
+
mockNuxtImport("useRuntimeConfig", () => () => mockRuntimeConfig);
|
|
45
|
+
mockNuxtImport("useFetch", () => mockUseFetch);
|
|
46
|
+
|
|
47
|
+
describe("SalesChannelSwitch", () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
// Reset window.location mock
|
|
50
|
+
mockWindowLocation.href = "";
|
|
51
|
+
global.window = { location: mockWindowLocation } as any;
|
|
52
|
+
vi.clearAllMocks();
|
|
53
|
+
|
|
54
|
+
// Reset default mock implementation for useFetch
|
|
55
|
+
mockUseFetch.mockImplementation(() => {
|
|
56
|
+
return {
|
|
57
|
+
data: ref(mockSalesChannels),
|
|
58
|
+
status: ref("success"),
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("should not render when multiChannel feature is disabled", async () => {
|
|
64
|
+
// Temporarily disable multiChannel
|
|
65
|
+
mockRuntimeConfig.public.shopBite.feature.multiChannel = false;
|
|
66
|
+
|
|
67
|
+
const component = await mountSuspended(SalesChannelSwitch);
|
|
68
|
+
|
|
69
|
+
// Should not render the select menu
|
|
70
|
+
expect(component.findComponent({ name: "USelectMenu" }).exists()).toBe(
|
|
71
|
+
false,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// Re-enable for other tests
|
|
75
|
+
mockRuntimeConfig.public.shopBite.feature.multiChannel = true;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("should render select menu when multiChannel feature is enabled", async () => {
|
|
79
|
+
const component = await mountSuspended(SalesChannelSwitch);
|
|
80
|
+
|
|
81
|
+
// Should render the select menu
|
|
82
|
+
const selectMenu = component.findComponent({ name: "USelectMenu" });
|
|
83
|
+
expect(selectMenu.exists()).toBe(true);
|
|
84
|
+
expect(selectMenu.props("icon")).toBe("i-lucide-store");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("should load and transform sales channels correctly", async () => {
|
|
88
|
+
const component = await mountSuspended(SalesChannelSwitch);
|
|
89
|
+
|
|
90
|
+
const selectMenu = component.findComponent({ name: "USelectMenu" });
|
|
91
|
+
const items = selectMenu.props("items");
|
|
92
|
+
|
|
93
|
+
expect(items).toHaveLength(2);
|
|
94
|
+
expect(items[0]).toEqual({
|
|
95
|
+
label: "Main Store",
|
|
96
|
+
value: "https://main-store.example.com",
|
|
97
|
+
});
|
|
98
|
+
expect(items[1]).toEqual({
|
|
99
|
+
label: "Secondary Store",
|
|
100
|
+
value: "https://secondary-store.example.com",
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("should select the current store URL by default", async () => {
|
|
105
|
+
const component = await mountSuspended(SalesChannelSwitch);
|
|
106
|
+
|
|
107
|
+
const selectMenu = component.findComponent({ name: "USelectMenu" });
|
|
108
|
+
const modelValue = selectMenu.props("modelValue");
|
|
109
|
+
|
|
110
|
+
expect(modelValue).toEqual({
|
|
111
|
+
label: "Main Store",
|
|
112
|
+
value: "https://main-store.example.com",
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("should show loading state when fetching data", async () => {
|
|
117
|
+
// Mock pending state
|
|
118
|
+
mockUseFetch.mockImplementationOnce(() => {
|
|
119
|
+
return {
|
|
120
|
+
data: ref(null),
|
|
121
|
+
status: ref("pending"),
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const component = await mountSuspended(SalesChannelSwitch);
|
|
126
|
+
|
|
127
|
+
const selectMenu = component.findComponent({ name: "USelectMenu" });
|
|
128
|
+
expect(selectMenu.props("loading")).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("should redirect to new store URL when selection changes", async () => {
|
|
132
|
+
const component = await mountSuspended(SalesChannelSwitch);
|
|
133
|
+
|
|
134
|
+
const selectMenu = component.findComponent({ name: "USelectMenu" });
|
|
135
|
+
|
|
136
|
+
// Change the selection
|
|
137
|
+
await selectMenu.setValue({
|
|
138
|
+
label: "Secondary Store",
|
|
139
|
+
value: "https://secondary-store.example.com",
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Should have redirected
|
|
143
|
+
expect(mockWindowLocation.href).toBe("https://secondary-store.example.com");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("should not redirect on initial selection", async () => {
|
|
147
|
+
// Mock that no initial selection was made
|
|
148
|
+
mockRuntimeConfig.public.storeUrl = "https://unknown-store.example.com";
|
|
149
|
+
|
|
150
|
+
const component = await mountSuspended(SalesChannelSwitch);
|
|
151
|
+
|
|
152
|
+
// Should not redirect immediately
|
|
153
|
+
expect(mockWindowLocation.href).toBe("");
|
|
154
|
+
|
|
155
|
+
// Restore original store URL
|
|
156
|
+
mockRuntimeConfig.public.storeUrl = "https://main-store.example.com";
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("should handle empty sales channels gracefully", async () => {
|
|
160
|
+
// Mock empty sales channels
|
|
161
|
+
mockUseFetch.mockImplementationOnce(() => {
|
|
162
|
+
return {
|
|
163
|
+
data: ref([]),
|
|
164
|
+
status: ref("success"),
|
|
165
|
+
};
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const component = await mountSuspended(SalesChannelSwitch);
|
|
169
|
+
|
|
170
|
+
const selectMenu = component.findComponent({ name: "USelectMenu" });
|
|
171
|
+
const items = selectMenu.props("items");
|
|
172
|
+
|
|
173
|
+
expect(items).toHaveLength(0);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("should handle sales channels without domains", async () => {
|
|
177
|
+
const mockChannelsWithoutDomains = [
|
|
178
|
+
{
|
|
179
|
+
label: "Store Without Domain",
|
|
180
|
+
value: "",
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
mockUseFetch.mockImplementationOnce(() => {
|
|
185
|
+
return {
|
|
186
|
+
data: ref(mockChannelsWithoutDomains),
|
|
187
|
+
status: ref("success"),
|
|
188
|
+
};
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const component = await mountSuspended(SalesChannelSwitch);
|
|
192
|
+
|
|
193
|
+
const selectMenu = component.findComponent({ name: "USelectMenu" });
|
|
194
|
+
const items = selectMenu.props("items");
|
|
195
|
+
|
|
196
|
+
expect(items[0]).toEqual({
|
|
197
|
+
label: "Store Without Domain",
|
|
198
|
+
value: "",
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createAdminApiClient,
|
|
4
|
+
getSalesChannels,
|
|
5
|
+
} from "../../server/utils/shopware/adminApiClient";
|
|
6
|
+
|
|
7
|
+
describe("Shopware Admin API Client", () => {
|
|
8
|
+
describe("createAdminApiClient", () => {
|
|
9
|
+
it("should create a client with correct configuration", () => {
|
|
10
|
+
const endpoint = "https://shopware.example.com/api";
|
|
11
|
+
const accessToken = "test-token";
|
|
12
|
+
|
|
13
|
+
const client = createAdminApiClient(endpoint, accessToken);
|
|
14
|
+
|
|
15
|
+
expect(client).toBeDefined();
|
|
16
|
+
// The client should have the invoke method
|
|
17
|
+
expect(client).toHaveProperty("invoke");
|
|
18
|
+
expect(typeof client.invoke).toBe("function");
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("getSalesChannels", () => {
|
|
23
|
+
it("should fetch sales channels successfully", async () => {
|
|
24
|
+
// Mock the client
|
|
25
|
+
const mockClient = {
|
|
26
|
+
invoke: vi.fn().mockResolvedValue({
|
|
27
|
+
data: [
|
|
28
|
+
{ id: "1", name: "Storefront", typeId: "storefront" },
|
|
29
|
+
{ id: "2", name: "Headless", typeId: "headless" },
|
|
30
|
+
],
|
|
31
|
+
}),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const result = await getSalesChannels(mockClient);
|
|
35
|
+
|
|
36
|
+
expect(result).toEqual([
|
|
37
|
+
{ id: "1", name: "Storefront", typeId: "storefront" },
|
|
38
|
+
{ id: "2", name: "Headless", typeId: "headless" },
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
expect(mockClient.invoke).toHaveBeenCalledWith({
|
|
42
|
+
method: "GET",
|
|
43
|
+
path: "/api/v3/sales-channel",
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("should handle errors when fetching sales channels", async () => {
|
|
48
|
+
const mockClient = {
|
|
49
|
+
invoke: vi.fn().mockRejectedValue(new Error("API error")),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
await expect(getSalesChannels(mockClient)).rejects.toThrow("API error");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("should handle empty response gracefully", async () => {
|
|
56
|
+
const mockClient = {
|
|
57
|
+
invoke: vi.fn().mockResolvedValue({
|
|
58
|
+
data: [],
|
|
59
|
+
}),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const result = await getSalesChannels(mockClient);
|
|
63
|
+
expect(result).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
});
|