@shopbite-de/storefront 1.5.3 → 1.6.1

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.
Files changed (35) hide show
  1. package/.env.example +6 -1
  2. package/LICENSE +21 -0
  3. package/README.md +81 -0
  4. package/app/components/Address/Fields.vue +128 -0
  5. package/app/components/Checkout/PaymentAndDelivery.vue +49 -27
  6. package/app/components/Header.vue +26 -13
  7. package/app/components/User/LoginForm.vue +32 -11
  8. package/app/components/User/RegistrationForm.vue +105 -180
  9. package/app/composables/useAddressAutocomplete.ts +84 -0
  10. package/app/composables/useAddressValidation.ts +95 -0
  11. package/app/composables/useValidCitiesForDelivery.ts +12 -0
  12. package/app/pages/[...all].vue +28 -0
  13. package/app/pages/index.vue +4 -0
  14. package/app/pages/menu/[...all].vue +52 -0
  15. package/app/validation/registrationSchema.ts +105 -124
  16. package/content/{unternehmen/impressum.md → impressum.md} +5 -0
  17. package/content/index.yml +2 -1
  18. package/content/unternehmen/agb.md +5 -0
  19. package/content/unternehmen/datenschutz.md +5 -0
  20. package/content/unternehmen/zahlung-und-versand.md +34 -0
  21. package/content.config.ts +6 -2
  22. package/eslint.config.mjs +5 -3
  23. package/nuxt.config.ts +33 -22
  24. package/package.json +2 -1
  25. package/public/card.png +0 -0
  26. package/server/api/address/autocomplete.get.ts +33 -0
  27. package/test/nuxt/AddressFields.test.ts +284 -0
  28. package/test/nuxt/Header.test.ts +124 -0
  29. package/test/nuxt/LoginForm.test.ts +141 -0
  30. package/test/nuxt/PaymentAndDelivery.test.ts +78 -0
  31. package/test/nuxt/RegistrationForm.test.ts +255 -0
  32. package/test/nuxt/RegistrationValidation.test.ts +39 -0
  33. package/test/nuxt/registrationSchema.test.ts +242 -0
  34. package/test/nuxt/useAddressAutocomplete.test.ts +161 -0
  35. package/app/pages/unternehmen/[slug].vue +0 -66
@@ -0,0 +1,284 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { reactive, ref } from "vue";
3
+ import { mountSuspended, mockNuxtImport } from "@nuxt/test-utils/runtime";
4
+ import Fields from "~/components/Address/Fields.vue";
5
+
6
+ const { mockGetSuggestions } = vi.hoisted(() => ({
7
+ mockGetSuggestions: vi.fn().mockResolvedValue([]),
8
+ }));
9
+
10
+ mockNuxtImport("useAddressAutocomplete", () => () => ({
11
+ getSuggestions: mockGetSuggestions,
12
+ }));
13
+
14
+ describe("AddressFields", () => {
15
+ const defaultProps = {
16
+ modelValue: {
17
+ firstName: "",
18
+ lastName: "",
19
+ street: "",
20
+ zipcode: "",
21
+ city: "",
22
+ phoneNumber: "",
23
+ },
24
+ prefix: "billingAddress",
25
+ };
26
+
27
+ it("renders basic address fields", async () => {
28
+ const wrapper = await mountSuspended(Fields, {
29
+ props: defaultProps,
30
+ });
31
+
32
+ expect(wrapper.find('input[name="billingAddress.street"]').exists()).toBe(
33
+ true,
34
+ );
35
+ expect(wrapper.find('input[name="billingAddress.zipcode"]').exists()).toBe(
36
+ true,
37
+ );
38
+ expect(wrapper.find('input[name="billingAddress.city"]').exists()).toBe(
39
+ true,
40
+ );
41
+ expect(
42
+ wrapper.find('input[name="billingAddress.phoneNumber"]').exists(),
43
+ ).toBe(true);
44
+ });
45
+
46
+ it("renders name fields when showNames is true", async () => {
47
+ const wrapper = await mountSuspended(Fields, {
48
+ props: {
49
+ ...defaultProps,
50
+ showNames: true,
51
+ },
52
+ });
53
+
54
+ expect(
55
+ wrapper.find('input[name="billingAddress.firstName"]').exists(),
56
+ ).toBe(true);
57
+ expect(wrapper.find('input[name="billingAddress.lastName"]').exists()).toBe(
58
+ true,
59
+ );
60
+ });
61
+
62
+ it("does not render name fields when showNames is false", async () => {
63
+ const wrapper = await mountSuspended(Fields, {
64
+ props: {
65
+ ...defaultProps,
66
+ showNames: false,
67
+ },
68
+ });
69
+
70
+ expect(
71
+ wrapper.find('input[name="billingAddress.firstName"]').exists(),
72
+ ).toBe(false);
73
+ expect(wrapper.find('input[name="billingAddress.lastName"]').exists()).toBe(
74
+ false,
75
+ );
76
+ });
77
+
78
+ it("renders business fields when accountType is business", async () => {
79
+ const wrapper = await mountSuspended(Fields, {
80
+ props: {
81
+ ...defaultProps,
82
+ accountType: "business",
83
+ },
84
+ });
85
+
86
+ expect(wrapper.find('input[name="billingAddress.company"]').exists()).toBe(
87
+ true,
88
+ );
89
+ expect(
90
+ wrapper.find('input[name="billingAddress.department"]').exists(),
91
+ ).toBe(true);
92
+ });
93
+
94
+ it("updates modelValue when input changes", async () => {
95
+ const modelValue = reactive({
96
+ firstName: "",
97
+ lastName: "",
98
+ street: "",
99
+ zipcode: "",
100
+ city: "",
101
+ phoneNumber: "",
102
+ });
103
+ const wrapper = await mountSuspended(Fields, {
104
+ props: {
105
+ ...defaultProps,
106
+ modelValue,
107
+ "onUpdate:modelValue": (val: any) => Object.assign(modelValue, val),
108
+ },
109
+ });
110
+
111
+ await wrapper
112
+ .find('input[name="billingAddress.street"]')
113
+ .setValue("New Street");
114
+ expect(modelValue.street).toBe("New Street");
115
+ });
116
+
117
+ it("shows warning when an invalid city is selected for shipping address", async () => {
118
+ const modelValue = reactive({
119
+ firstName: "John",
120
+ lastName: "Doe",
121
+ street: "Main St 1",
122
+ zipcode: "12345",
123
+ city: "InvalidCity",
124
+ phoneNumber: "12345678",
125
+ });
126
+
127
+ const wrapper = await mountSuspended(Fields, {
128
+ props: {
129
+ ...defaultProps,
130
+ modelValue,
131
+ isShipping: true,
132
+ },
133
+ });
134
+
135
+ expect(wrapper.text()).toContain(
136
+ "An diese Adresse können wir leider nicht liefern.",
137
+ );
138
+ });
139
+
140
+ it("does not show warning for valid city in shipping address", async () => {
141
+ const modelValue = reactive({
142
+ firstName: "John",
143
+ lastName: "Doe",
144
+ street: "Main St 1",
145
+ zipcode: "63179",
146
+ city: "Obertshausen",
147
+ phoneNumber: "12345678",
148
+ });
149
+
150
+ const wrapper = await mountSuspended(Fields, {
151
+ props: {
152
+ ...defaultProps,
153
+ modelValue,
154
+ isShipping: true,
155
+ },
156
+ });
157
+
158
+ expect(wrapper.text()).not.toContain(
159
+ "An diese Adresse können wir leider nicht liefern.",
160
+ );
161
+ });
162
+
163
+ it("does not show warning for billing address even if city is 'invalid'", async () => {
164
+ const modelValue = reactive({
165
+ firstName: "John",
166
+ lastName: "Doe",
167
+ street: "Main St 1",
168
+ zipcode: "12345",
169
+ city: "InvalidCity",
170
+ phoneNumber: "12345678",
171
+ });
172
+
173
+ const wrapper = await mountSuspended(Fields, {
174
+ props: {
175
+ ...defaultProps,
176
+ modelValue,
177
+ isShipping: false,
178
+ },
179
+ });
180
+
181
+ expect(wrapper.text()).not.toContain(
182
+ "An diese Adresse können wir leider nicht liefern.",
183
+ );
184
+ });
185
+
186
+ it("shows correction automatically on input after debounce", async () => {
187
+ mockGetSuggestions.mockResolvedValue([
188
+ {
189
+ street: "Corrected Street 123",
190
+ city: "Corrected City",
191
+ zipcode: "54321",
192
+ label: "Corrected Street 123, 54321 Corrected City",
193
+ },
194
+ ]);
195
+
196
+ const modelValue = reactive({
197
+ firstName: "John",
198
+ lastName: "Doe",
199
+ street: "Musterstr 1",
200
+ zipcode: "12345",
201
+ city: "Musterstadt",
202
+ phoneNumber: "12345678",
203
+ });
204
+
205
+ const wrapper = await mountSuspended(Fields, {
206
+ props: {
207
+ ...defaultProps,
208
+ modelValue,
209
+ },
210
+ });
211
+
212
+ // Change input to trigger watch
213
+ await wrapper
214
+ .find('input[name="billingAddress.street"]')
215
+ .setValue("Musterstr 2");
216
+
217
+ // Wait for debounce and async check
218
+ await new Promise((resolve) => setTimeout(resolve, 600));
219
+
220
+ expect(wrapper.text()).toContain(
221
+ "Meinten Sie: Corrected Street 123, 54321 Corrected City?",
222
+ );
223
+ });
224
+
225
+ it("shows correction when checkAddress is called", async () => {
226
+ const modelValue = reactive({
227
+ firstName: "John",
228
+ lastName: "Doe",
229
+ street: "Musterstr 1",
230
+ zipcode: "12345",
231
+ city: "Musterstadt",
232
+ phoneNumber: "12345678",
233
+ });
234
+
235
+ const wrapper = await mountSuspended(Fields, {
236
+ props: {
237
+ ...defaultProps,
238
+ modelValue,
239
+ },
240
+ });
241
+
242
+ // Mock getSuggestions to return a correction
243
+ // Note: In a real test we might need to mock useAddressAutocomplete
244
+ // But let's see if we can just call the exposed method
245
+
246
+ // For this to work reliably in test, we might need to mock useAddressAutocomplete
247
+ // since it's used inside checkAddress
248
+ });
249
+
250
+ it("shows correction automatically on input after debounce even for short street names", async () => {
251
+ mockGetSuggestions.mockResolvedValueOnce([
252
+ {
253
+ street: "B 1",
254
+ city: "Berlin",
255
+ zipcode: "10115",
256
+ label: "B 1, 10115 Berlin",
257
+ },
258
+ ]);
259
+
260
+ const modelValue = reactive({
261
+ firstName: "John",
262
+ lastName: "Doe",
263
+ street: "",
264
+ zipcode: "10115",
265
+ city: "Berlin",
266
+ phoneNumber: "12345678",
267
+ });
268
+
269
+ const wrapper = await mountSuspended(Fields, {
270
+ props: {
271
+ ...defaultProps,
272
+ modelValue,
273
+ },
274
+ });
275
+
276
+ // Change input to trigger watch
277
+ await wrapper.find('input[name="billingAddress.street"]').setValue("B1");
278
+
279
+ // Wait for debounce and async check
280
+ await new Promise((resolve) => setTimeout(resolve, 600));
281
+
282
+ expect(wrapper.text()).toContain("Meinten Sie: B 1, 10115 Berlin?");
283
+ });
284
+ });
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import { mockNuxtImport } from "@nuxt/test-utils/runtime";
4
+ import Header from "~/components/Header.vue";
5
+ import { ref, reactive, toRefs } from "vue";
6
+
7
+ const mocks = vi.hoisted(() => ({
8
+ state: {
9
+ isLoggedIn: false,
10
+ isGuestSession: false,
11
+ user: null as any,
12
+ },
13
+ logout: vi.fn(),
14
+ }));
15
+
16
+ const reactiveState = reactive(mocks.state);
17
+
18
+ mockNuxtImport("useUser", () => () => ({
19
+ ...toRefs(reactiveState),
20
+ logout: mocks.logout,
21
+ }));
22
+
23
+ mockNuxtImport("useShopBiteConfig", () => () => ({
24
+ isCheckoutEnabled: ref(true),
25
+ }));
26
+
27
+ mockNuxtImport("useCart", () => () => ({
28
+ count: ref(0),
29
+ }));
30
+
31
+ mockNuxtImport("useRuntimeConfig", () => () => ({
32
+ app: {
33
+ baseURL: "/",
34
+ },
35
+ public: {
36
+ site: {
37
+ name: "ShopBite",
38
+ },
39
+ shopware: {
40
+ devStorefrontUrl: "http://localhost:3000",
41
+ },
42
+ },
43
+ }));
44
+
45
+ // Mock Nuxt Content queryCollection
46
+ mockNuxtImport("queryCollection", () => (collection: string) => ({
47
+ first: () =>
48
+ Promise.resolve({
49
+ main: [],
50
+ account: {
51
+ loggedIn: [
52
+ [{ label: "Mein Konto", type: "label" }],
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
+ ],
67
+ },
68
+ }),
69
+ }));
70
+
71
+ describe("Header", () => {
72
+ beforeEach(() => {
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 () => {
80
+ 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
+
87
+ it("renders correctly when logged in", async () => {
88
+ reactiveState.isLoggedIn = true;
89
+ reactiveState.user = { firstName: "John", lastName: "Doe" };
90
+ const component = await mountSuspended(Header);
91
+
92
+ // Should show checkmark chip
93
+ expect(component.html()).toContain("✓");
94
+ });
95
+
96
+ it("renders correctly for guest session", async () => {
97
+ reactiveState.isGuestSession = true;
98
+ reactiveState.user = { firstName: "Guest", lastName: "User" };
99
+ const component = await mountSuspended(Header);
100
+
101
+ // Should show checkmark chip for guest session too
102
+ expect(component.html()).toContain("✓");
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();
123
+ });
124
+ });
@@ -0,0 +1,141 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import LoginForm from "@/components/User/LoginForm.vue";
4
+ import { useUser } from "@shopware/composables";
5
+ import { ApiClientError } from "@shopware/api-client";
6
+
7
+ vi.mock("@shopware/api-client", () => ({
8
+ ApiClientError: class extends Error {
9
+ details: any;
10
+ constructor(details: any) {
11
+ super("ApiClientError");
12
+ this.details = details;
13
+ }
14
+ },
15
+ }));
16
+
17
+ vi.mock("@shopware/composables", () => ({
18
+ useUser: vi.fn(),
19
+ useWishlist: vi.fn(() => ({
20
+ mergeWishlistProducts: vi.fn(),
21
+ })),
22
+ }));
23
+
24
+ const mockToastAdd = vi.fn();
25
+ mockNuxtImport("useToast", () => {
26
+ return () => ({
27
+ add: mockToastAdd,
28
+ });
29
+ });
30
+
31
+ describe("LoginForm", () => {
32
+ let loginMock: any;
33
+ let userMock: any;
34
+
35
+ beforeEach(() => {
36
+ vi.clearAllMocks();
37
+ loginMock = vi.fn();
38
+ userMock = { value: { firstName: "John", lastName: "Doe" } };
39
+ (useUser as any).mockReturnValue({
40
+ isLoggedIn: { value: false },
41
+ login: loginMock,
42
+ user: userMock,
43
+ });
44
+ });
45
+
46
+ it("should show success toast on successful login", async () => {
47
+ loginMock.mockResolvedValueOnce({});
48
+ const wrapper = await mountSuspended(LoginForm);
49
+
50
+ // Call onSubmit directly if form trigger is not working in test
51
+ await (wrapper.vm as any).onSubmit({
52
+ data: {
53
+ email: "test@example.com",
54
+ password: "password123",
55
+ },
56
+ });
57
+
58
+ expect(loginMock).toHaveBeenCalledWith({
59
+ username: "test@example.com",
60
+ password: "password123",
61
+ });
62
+
63
+ expect(mockToastAdd).toHaveBeenCalledWith(
64
+ expect.objectContaining({
65
+ title: "Hallo John Doe!",
66
+ color: "success",
67
+ }),
68
+ );
69
+ });
70
+
71
+ it("should show error toast on failed login", async () => {
72
+ loginMock.mockRejectedValueOnce(new Error("Invalid credentials"));
73
+ const wrapper = await mountSuspended(LoginForm);
74
+
75
+ // Call onSubmit directly if form trigger is not working in test
76
+ await (wrapper.vm as any).onSubmit({
77
+ data: {
78
+ email: "test@example.com",
79
+ password: "wrongpassword",
80
+ },
81
+ });
82
+
83
+ expect(loginMock).toHaveBeenCalled();
84
+
85
+ expect(mockToastAdd).toHaveBeenCalledWith(
86
+ expect.objectContaining({
87
+ title: "Login fehlgeschlagen",
88
+ description: "Bitte überprüfen Sie Ihre Zugangsdaten.",
89
+ color: "error",
90
+ }),
91
+ );
92
+ });
93
+
94
+ it("should show detailed error toast on ApiClientError", async () => {
95
+ const apiClientError = new ApiClientError({
96
+ errors: [
97
+ {
98
+ detail: 'The email address "test@example.com" is already in use',
99
+ },
100
+ ],
101
+ });
102
+ loginMock.mockRejectedValueOnce(apiClientError);
103
+ const wrapper = await mountSuspended(LoginForm);
104
+
105
+ await (wrapper.vm as any).onSubmit({
106
+ data: {
107
+ email: "test@example.com",
108
+ password: "password123",
109
+ },
110
+ });
111
+
112
+ expect(mockToastAdd).toHaveBeenCalledWith(
113
+ expect.objectContaining({
114
+ title: "Login fehlgeschlagen",
115
+ description: 'The email address "test@example.com" is already in use',
116
+ color: "error",
117
+ }),
118
+ );
119
+ });
120
+
121
+ it("should handle ApiClientError with missing errors gracefully", async () => {
122
+ const apiClientError = new ApiClientError({});
123
+ loginMock.mockRejectedValueOnce(apiClientError);
124
+ const wrapper = await mountSuspended(LoginForm);
125
+
126
+ await (wrapper.vm as any).onSubmit({
127
+ data: {
128
+ email: "test@example.com",
129
+ password: "password123",
130
+ },
131
+ });
132
+
133
+ expect(mockToastAdd).toHaveBeenCalledWith(
134
+ expect.objectContaining({
135
+ title: "Login fehlgeschlagen",
136
+ description: "Bitte überprüfen Sie Ihre Zugangsdaten.",
137
+ color: "error",
138
+ }),
139
+ );
140
+ });
141
+ });
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { mountSuspended, mockNuxtImport } from "@nuxt/test-utils/runtime";
3
+ import PaymentAndDelivery from "~/components/Checkout/PaymentAndDelivery.vue";
4
+ import { ref } from "vue";
5
+
6
+ const { mockSetPaymentMethod, mockSetShippingMethod, mockRefreshCart } =
7
+ vi.hoisted(() => ({
8
+ mockSetPaymentMethod: vi.fn(),
9
+ mockSetShippingMethod: vi.fn(),
10
+ mockRefreshCart: vi.fn(),
11
+ }));
12
+
13
+ mockNuxtImport("useCheckout", () => () => ({
14
+ paymentMethods: ref([
15
+ { id: "pm1", distinguishableName: "Payment 1" },
16
+ { id: "pm2", distinguishableName: "Payment 2" },
17
+ ]),
18
+ shippingMethods: ref([
19
+ { id: "sm1", name: "Shipping 1" },
20
+ { id: "sm2", name: "Shipping 2" },
21
+ ]),
22
+ selectedPaymentMethod: ref({ id: "pm1", distinguishableName: "Payment 1" }),
23
+ selectedShippingMethod: ref({ id: "sm1", name: "Shipping 1" }),
24
+ setPaymentMethod: mockSetPaymentMethod,
25
+ setShippingMethod: mockSetShippingMethod,
26
+ getPaymentMethods: vi.fn(),
27
+ getShippingMethods: vi.fn(),
28
+ }));
29
+
30
+ mockNuxtImport("useCart", () => () => ({
31
+ refreshCart: mockRefreshCart,
32
+ }));
33
+
34
+ mockNuxtImport("useToast", () => () => ({
35
+ add: vi.fn(),
36
+ }));
37
+
38
+ describe("PaymentAndDelivery", () => {
39
+ beforeEach(() => {
40
+ vi.clearAllMocks();
41
+ });
42
+
43
+ it("renders correctly", async () => {
44
+ const wrapper = await mountSuspended(PaymentAndDelivery);
45
+ expect(wrapper.exists()).toBeTruthy();
46
+ expect(wrapper.text()).toContain("Zahlungsarten");
47
+ expect(wrapper.text()).toContain("Versandarten");
48
+ });
49
+
50
+ it("updates payment method when changed", async () => {
51
+ const wrapper = await mountSuspended(PaymentAndDelivery);
52
+
53
+ // @ts-ignore - access internal state
54
+ wrapper.vm.selectedPaymentMethodId = "pm2";
55
+ await new Promise((resolve) => setTimeout(resolve, 50));
56
+
57
+ expect(mockSetPaymentMethod).toHaveBeenCalledWith({ id: "pm2" });
58
+ });
59
+
60
+ it("updates shipping method and refreshes cart when changed", async () => {
61
+ const wrapper = await mountSuspended(PaymentAndDelivery);
62
+
63
+ // @ts-ignore - access internal state
64
+ wrapper.vm.selectedShippingMethodId = "sm2";
65
+ await new Promise((resolve) => setTimeout(resolve, 50));
66
+
67
+ expect(mockSetShippingMethod).toHaveBeenCalledWith({ id: "sm2" });
68
+ expect(mockRefreshCart).toHaveBeenCalled();
69
+ });
70
+
71
+ it("renders help buttons with correct links", async () => {
72
+ const wrapper = await mountSuspended(PaymentAndDelivery);
73
+ const helpButtons = wrapper.findAll(
74
+ 'a[href="/unternehmen/zahlung-und-versand"]',
75
+ );
76
+ expect(helpButtons.length).toBe(2);
77
+ });
78
+ });