@shopbite-de/storefront 1.11.0 → 1.12.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/api-types/storeApiSchema.json +3205 -645
- package/api-types/storeApiTypes.d.ts +1537 -202
- package/app/components/SalesChannelSwitch.vue +50 -24
- package/app/pages/c/[...all].vue +1 -1
- package/app/pages/menu/[...all].vue +1 -1
- package/app/pages/speisekarte/[...all].vue +1 -1
- package/content/navigation.yml +3 -3
- package/nuxt.config.ts +0 -1
- package/package.json +2 -2
- package/server/api/shopware/sales-channels.get.ts +0 -79
- package/test/nuxt/SalesChannelSwitch.test.ts +0 -201
|
@@ -2,42 +2,68 @@
|
|
|
2
2
|
import type { SelectMenuItem } from "@nuxt/ui";
|
|
3
3
|
import type { Schemas } from "#shopware";
|
|
4
4
|
|
|
5
|
+
const { apiClient } = useShopwareContext();
|
|
6
|
+
|
|
5
7
|
const config = useRuntimeConfig();
|
|
8
|
+
|
|
6
9
|
const isMultiChannel = computed(
|
|
7
10
|
() => !!config.public.shopBite.feature.multiChannel,
|
|
8
11
|
);
|
|
12
|
+
|
|
9
13
|
const storeUrl = computed(() => config.public.storeUrl);
|
|
10
14
|
|
|
11
|
-
const { data: salesChannels, status } =
|
|
12
|
-
"
|
|
15
|
+
const { data: salesChannels, pending: status } = useAsyncData(
|
|
16
|
+
"multi-channel-group",
|
|
17
|
+
async () => {
|
|
18
|
+
const response = await apiClient.invoke(
|
|
19
|
+
"shopbite.multi-channel-group.get get /shopbite/multi-channel-group",
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
return response.data;
|
|
23
|
+
},
|
|
13
24
|
{
|
|
14
|
-
|
|
15
|
-
|
|
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,
|
|
25
|
+
transform: (response: Schemas["MultiChannelGroupStruct"]) =>
|
|
26
|
+
transform(response),
|
|
32
27
|
},
|
|
33
28
|
);
|
|
34
29
|
|
|
30
|
+
function transform(
|
|
31
|
+
multiChannelGroups: Schemas["MultiChannelGroupStruct"],
|
|
32
|
+
): SelectMenuItem[] {
|
|
33
|
+
const group = multiChannelGroups.multiChannelGroup?.[0];
|
|
34
|
+
if (!group) return [];
|
|
35
|
+
|
|
36
|
+
const storeUrlValue = storeUrl.value;
|
|
37
|
+
|
|
38
|
+
const getBestDomainUrl = (
|
|
39
|
+
domains: Schemas["Domain"][] | undefined,
|
|
40
|
+
preferredUrl: string | null | undefined,
|
|
41
|
+
): string => {
|
|
42
|
+
const safeDomains = domains ?? [];
|
|
43
|
+
const matchingDomain =
|
|
44
|
+
preferredUrl != null
|
|
45
|
+
? safeDomains.find((domain) => domain?.url === preferredUrl)
|
|
46
|
+
: undefined;
|
|
47
|
+
const fallbackDomain = safeDomains.find((domain) => Boolean(domain?.url));
|
|
48
|
+
return matchingDomain?.url ?? fallbackDomain?.url ?? "";
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const salesChannels = group.salesChannels ?? [];
|
|
52
|
+
return salesChannels.map((channel) => ({
|
|
53
|
+
label: channel.name,
|
|
54
|
+
value: getBestDomainUrl(channel.domains, storeUrlValue),
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
|
|
35
58
|
const selectedStore = ref<SelectMenuItem>();
|
|
36
59
|
|
|
37
60
|
watchEffect(() => {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
61
|
+
const scValue = (salesChannels as any)?.value as
|
|
62
|
+
| SelectMenuItem[]
|
|
63
|
+
| undefined;
|
|
64
|
+
if (Array.isArray(scValue) && storeUrl.value && !selectedStore.value) {
|
|
65
|
+
const matchingChannel = scValue.find(
|
|
66
|
+
(channel) => channel?.value === storeUrl.value,
|
|
41
67
|
);
|
|
42
68
|
if (matchingChannel) {
|
|
43
69
|
selectedStore.value = matchingChannel;
|
|
@@ -57,7 +83,7 @@ watch(selectedStore, (newStore, oldStore) => {
|
|
|
57
83
|
v-if="isMultiChannel"
|
|
58
84
|
v-model="selectedStore"
|
|
59
85
|
:items="salesChannels"
|
|
60
|
-
:loading="status
|
|
86
|
+
:loading="status"
|
|
61
87
|
icon="i-lucide-store"
|
|
62
88
|
/>
|
|
63
89
|
</template>
|
package/app/pages/c/[...all].vue
CHANGED
package/content/navigation.yml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
main:
|
|
2
2
|
- label: Speisekarte
|
|
3
3
|
icon: i-lucide-utensils
|
|
4
|
-
to: /
|
|
4
|
+
to: /speisekarte/
|
|
5
5
|
- label: Routenplaner
|
|
6
6
|
icon: i-lucide-map-pinned
|
|
7
7
|
to: https://www.openstreetmap.org/directions?from=&to=50.080610%2C8.863783#map=19/50.080323/8.864079
|
|
@@ -48,8 +48,8 @@ footer:
|
|
|
48
48
|
to: /agb
|
|
49
49
|
- label: Top Kategorien
|
|
50
50
|
children:
|
|
51
|
-
- label:
|
|
52
|
-
to: /
|
|
51
|
+
- label: Pizza
|
|
52
|
+
to: /speisekarte/pizza/
|
|
53
53
|
- label: Unternehmen
|
|
54
54
|
children:
|
|
55
55
|
- label: 'Tel: 06104 71427'
|
package/nuxt.config.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shopbite-de/storefront",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"main": "nuxt.config.ts",
|
|
5
5
|
"description": "Shopware storefront for food delivery shops",
|
|
6
6
|
"keywords": [
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"@nuxt/scripts": "0.13.2",
|
|
24
24
|
"@nuxt/ui": "^4.1.0",
|
|
25
25
|
"@nuxtjs/robots": "^5.5.6",
|
|
26
|
-
"@sentry/nuxt": "^10.
|
|
26
|
+
"@sentry/nuxt": "^10.38.0",
|
|
27
27
|
"@shopware/api-client": "^1.3.0",
|
|
28
28
|
"@shopware/api-gen": "^1.3.3",
|
|
29
29
|
"@shopware/composables": "^1.9.1",
|
|
@@ -1,79 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,201 +0,0 @@
|
|
|
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
|
-
});
|