@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
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { createRegistrationSchema } from "~/validation/registrationSchema";
3
3
  import type { RegistrationSchema } from "~/validation/registrationSchema";
4
+ import { ApiClientError } from "@shopware/api-client";
4
5
  import type { FormSubmitEvent } from "@nuxt/ui";
5
6
 
6
7
  const config = useRuntimeConfig();
@@ -18,38 +19,40 @@ watch(isLoggedIn, (isLoggedIn) => {
18
19
 
19
20
  const state = reactive({
20
21
  accountType: "private",
21
- salutationId: undefined,
22
- firstName: undefined,
23
- lastName: undefined,
24
- email: undefined,
22
+ salutationId: "",
23
+ firstName: "",
24
+ lastName: "",
25
+ email: "",
25
26
  guest: true,
26
- password: undefined,
27
- passwordConfirm: undefined,
28
- acceptedDataProtection: true,
27
+ password: "",
28
+ passwordConfirm: "",
29
+ acceptedDataProtection: false,
29
30
  isShippingAddressDifferent: false,
30
31
  storefrontUrl: config.public.shopware.devStorefrontUrl,
31
32
  billingAddress: {
32
- company: undefined,
33
- department: undefined,
34
- salutationId: undefined,
35
- firstName: undefined,
36
- lastName: undefined,
37
- phoneNumber: undefined,
38
- street: undefined,
39
- zipcode: undefined,
40
- city: undefined,
33
+ company: "",
34
+ department: "",
35
+ salutationId: "",
36
+ firstName: "",
37
+ lastName: "",
38
+ phoneNumber: "",
39
+ additionalAddressLine1: "",
40
+ street: "",
41
+ zipcode: "",
42
+ city: "",
41
43
  countryId: config.public.site.countryId,
42
44
  },
43
45
  shippingAddress: {
44
- company: undefined,
45
- department: undefined,
46
- salutationId: undefined,
47
- firstName: undefined,
48
- lastName: undefined,
49
- phoneNumber: undefined,
50
- street: undefined,
51
- zipcode: undefined,
52
- city: undefined,
46
+ company: "",
47
+ department: "",
48
+ salutationId: "",
49
+ firstName: "",
50
+ lastName: "",
51
+ phoneNumber: "",
52
+ additionalAddressLine1: "",
53
+ street: "",
54
+ zipcode: "",
55
+ city: "",
53
56
  countryId: config.public.site.countryId,
54
57
  },
55
58
  });
@@ -58,9 +61,37 @@ const schema = computed(() => createRegistrationSchema(state));
58
61
 
59
62
  const toast = useToast();
60
63
 
64
+ const billingAddressFields = ref();
65
+ const shippingAddressFields = ref();
66
+
61
67
  async function onSubmit(event: FormSubmitEvent<RegistrationSchema>) {
62
68
  const registrationData = { ...event.data };
63
69
 
70
+ // Check for address corrections
71
+ // Only check if correction is not already shown (user might have ignored it)
72
+ let billingCorrectionFound = false;
73
+ if (!billingAddressFields.value?.showCorrection) {
74
+ billingCorrectionFound = await billingAddressFields.value?.checkAddress();
75
+ }
76
+
77
+ let shippingCorrectionFound = false;
78
+ if (
79
+ state.isShippingAddressDifferent &&
80
+ !shippingAddressFields.value?.showCorrection
81
+ ) {
82
+ shippingCorrectionFound = await shippingAddressFields.value?.checkAddress();
83
+ }
84
+
85
+ if (billingCorrectionFound || shippingCorrectionFound) {
86
+ toast.add({
87
+ title: "Adresskorrektur vorgeschlagen",
88
+ description:
89
+ "Bitte überprüfen Sie die vorgeschlagene Adresskorrektur, bevor Sie fortfahren.",
90
+ color: "info",
91
+ });
92
+ return;
93
+ }
94
+
64
95
  if (
65
96
  !registrationData.billingAddress.firstName &&
66
97
  registrationData.firstName
@@ -76,7 +107,13 @@ async function onSubmit(event: FormSubmitEvent<RegistrationSchema>) {
76
107
  delete registrationData.shippingAddress;
77
108
  }
78
109
 
110
+ if (registrationData.guest) {
111
+ delete registrationData.password;
112
+ delete registrationData.passwordConfirm;
113
+ }
114
+
79
115
  try {
116
+ // @ts-expect-error - password is required in the API type but not for guests
80
117
  await register(registrationData);
81
118
 
82
119
  toast.add({
@@ -86,9 +123,19 @@ async function onSubmit(event: FormSubmitEvent<RegistrationSchema>) {
86
123
  emit("registration-success", registrationData);
87
124
  } catch (error) {
88
125
  console.error("Registration failed:", error);
126
+ let description = "Bitte versuchen Sie es erneut.";
127
+ if (error instanceof ApiClientError) {
128
+ const errors = error.details?.errors;
129
+ if (Array.isArray(errors) && errors.length > 0) {
130
+ description = errors
131
+ .map((e) => e.detail || e.title)
132
+ .filter(Boolean)
133
+ .join("\n");
134
+ }
135
+ }
89
136
  toast.add({
90
137
  title: "Registrierung fehlgeschlagen",
91
- description: "Bitte versuchen Sie es erneut.",
138
+ description,
92
139
  color: "error",
93
140
  });
94
141
  }
@@ -101,15 +148,13 @@ const accountTypes = ref([
101
148
  },
102
149
  {
103
150
  label: "Geschäftskunde",
104
- value: "commercial",
151
+ value: "business",
105
152
  },
106
153
  ]);
107
154
 
108
155
  const emit = defineEmits<{
109
156
  "registration-success": [data: RegistrationSchema];
110
157
  }>();
111
-
112
- const allowedCities = ref(["Obertshausen", "Lämmerspiel"]);
113
158
  </script>
114
159
 
115
160
  <template>
@@ -118,7 +163,7 @@ const allowedCities = ref(["Obertshausen", "Lämmerspiel"]);
118
163
  :state="state"
119
164
  class="space-y-4"
120
165
  @submit="onSubmit"
121
- @error="(error) => console.log('Form validation error:', error)"
166
+ @error="(error: any) => console.log('Form validation error:', error)"
122
167
  >
123
168
  <UFormField name="accountType">
124
169
  <USelect
@@ -151,14 +196,6 @@ const allowedCities = ref(["Obertshausen", "Lämmerspiel"]);
151
196
  <UInput v-model="state.email" class="w-full" />
152
197
  </UFormField>
153
198
 
154
- <UFormField label="Telefon" name="billingAddress.phoneNumber" required>
155
- <UInput
156
- v-model="state.billingAddress.phoneNumber"
157
- type="text"
158
- class="w-full"
159
- />
160
- </UFormField>
161
-
162
199
  <UFormField v-if="!state.guest" label="Password" name="password">
163
200
  <UInput v-model="state.password" type="password" class="w-full" />
164
201
  </UFormField>
@@ -180,70 +217,14 @@ const allowedCities = ref(["Obertshausen", "Lämmerspiel"]);
180
217
  "
181
218
  />
182
219
 
183
- <UFormField
184
- v-if="state.accountType === 'commercial'"
185
- label="Unternehmen"
186
- name="billingAddress.company"
187
- >
188
- <UInput
189
- v-model="state.billingAddress.company"
190
- type="text"
191
- class="w-full"
192
- />
193
- </UFormField>
194
-
195
- <UFormField
196
- v-if="state.accountType === 'commercial'"
197
- label="Abteilung"
198
- name="billingAddress.department"
199
- >
200
- <UInput
201
- v-model="state.billingAddress.department"
202
- type="text"
203
- class="w-full"
204
- />
205
- </UFormField>
206
-
207
- <UFormField
208
- label="Straße und Hausnr."
209
- name="billingAddress.street"
210
- required
211
- >
212
- <UInput
213
- v-model="state.billingAddress.street"
214
- type="text"
215
- class="w-full"
216
- />
217
- </UFormField>
218
-
219
- <UFormField
220
- v-if="state.isShippingAddressDifferent"
221
- label="Postleitzahl"
222
- name="billingAddress.zipcode"
223
- >
224
- <UInput
225
- v-model="state.billingAddress.zipcode"
226
- type="text"
227
- class="w-full"
228
- />
229
- </UFormField>
230
-
231
- <UFormField label="Ort" name="billingAddress.city" required>
232
- <USelect
233
- v-if="!state.isShippingAddressDifferent"
234
- v-model="state.billingAddress.city"
235
- :items="allowedCities"
236
- class="w-full"
237
- />
238
- <UInput
239
- v-else
240
- v-model="state.billingAddress.city"
241
- type="text"
242
- class="w-full"
243
- />
244
- </UFormField>
220
+ <AddressFields
221
+ ref="billingAddressFields"
222
+ v-model="state.billingAddress"
223
+ prefix="billingAddress"
224
+ :account-type="state.accountType"
225
+ />
245
226
 
246
- <UFormField name="guest">
227
+ <UFormField name="isShippingAddressDifferent">
247
228
  <UCheckbox
248
229
  v-model="state.isShippingAddressDifferent"
249
230
  label="Rechnungsadresse weicht von Lieferadresse ab"
@@ -251,87 +232,31 @@ const allowedCities = ref(["Obertshausen", "Lämmerspiel"]);
251
232
  />
252
233
  </UFormField>
253
234
 
254
- <USeparator
255
- v-if="state.isShippingAddressDifferent"
256
- color="primary"
257
- label="Lieferadresse"
258
- />
259
-
260
- <UFormField
261
- v-if="
262
- state.accountType === 'commercial' && state.isShippingAddressDifferent
263
- "
264
- label="Unternehmen"
265
- name="shippingAddress.company"
266
- >
267
- <UInput
268
- v-model="state.shippingAddress.company"
269
- type="text"
270
- class="w-full"
271
- />
272
- </UFormField>
273
-
274
- <UFormField
275
- v-if="
276
- state.accountType === 'commercial' && state.isShippingAddressDifferent
277
- "
278
- label="Abteilung"
279
- name="billingAddress.department"
280
- >
281
- <UInput
282
- v-model="state.shippingAddress.department"
283
- type="text"
284
- class="w-full"
285
- />
286
- </UFormField>
287
-
288
- <UFormField
289
- v-if="state.isShippingAddressDifferent"
290
- label="Vorname"
291
- name="shippingAddress.firstName"
292
- required
293
- >
294
- <UInput
295
- v-model="state.shippingAddress.firstName"
296
- type="text"
297
- class="w-full"
298
- />
299
- </UFormField>
300
-
301
- <UFormField
302
- v-if="state.isShippingAddressDifferent"
303
- label="Nachname"
304
- name="shippingAddress.lastName"
305
- >
306
- <UInput
307
- v-model="state.shippingAddress.lastName"
308
- type="text"
309
- class="w-full"
310
- />
311
- </UFormField>
312
-
313
- <UFormField
314
- v-if="state.isShippingAddressDifferent"
315
- label="Straße und Hausnr."
316
- name="shippingAddress.street"
317
- >
318
- <UInput
319
- v-model="state.shippingAddress.street"
320
- type="text"
321
- class="w-full"
322
- />
323
- </UFormField>
235
+ <template v-if="state.isShippingAddressDifferent">
236
+ <USeparator color="primary" label="Lieferadresse" />
324
237
 
325
- <UFormField
326
- v-if="state.isShippingAddressDifferent"
327
- label="Ort"
328
- name="shippingAddress.city"
329
- >
330
- <USelect
331
- v-model="state.shippingAddress.city"
332
- :items="allowedCities"
333
- class="w-full"
238
+ <AddressFields
239
+ ref="shippingAddressFields"
240
+ v-model="state.shippingAddress"
241
+ prefix="shippingAddress"
242
+ :account-type="state.accountType"
243
+ show-names
244
+ is-shipping
334
245
  />
246
+ </template>
247
+
248
+ <UFormField name="acceptedDataProtection">
249
+ <UCheckbox v-model="state.acceptedDataProtection" class="w-full">
250
+ <template #label>
251
+ <span>
252
+ Ich habe die
253
+ <ULink to="/unternehmen/datenschutz">
254
+ Datenschutzbestimmungen
255
+ </ULink>
256
+ gelesen und akzeptiere diese.
257
+ </span>
258
+ </template>
259
+ </UCheckbox>
335
260
  </UFormField>
336
261
 
337
262
  <UButton block type="submit">Speichern</UButton>
@@ -0,0 +1,84 @@
1
+ export interface AddressSuggestion {
2
+ street: string;
3
+ city: string;
4
+ zipcode: string;
5
+ label: string;
6
+ }
7
+
8
+ interface GeoapifyAddressProperties {
9
+ street?: string;
10
+ housenumber?: string;
11
+ name?: string;
12
+ city?: string;
13
+ town?: string;
14
+ village?: string;
15
+ postcode?: string;
16
+ formatted?: string;
17
+ }
18
+
19
+ interface GeoapifyFeature {
20
+ properties: GeoapifyAddressProperties;
21
+ }
22
+
23
+ interface GeoapifyResponse {
24
+ features: GeoapifyFeature[];
25
+ }
26
+
27
+ export function useAddressAutocomplete() {
28
+ const { boundingBoxCoordinates, validCities } = useValidCitiesForDelivery();
29
+
30
+ async function getSuggestions(text: string): Promise<AddressSuggestion[]> {
31
+ if (!text || text.length < 3) {
32
+ return [];
33
+ }
34
+
35
+ try {
36
+ const query: Record<string, string | number> = {
37
+ text,
38
+ lang: "de",
39
+ limit: 5,
40
+ };
41
+
42
+ if (boundingBoxCoordinates.value) {
43
+ query.filter = `rect:${boundingBoxCoordinates.value}`;
44
+ }
45
+
46
+ const { data } = await useFetch<GeoapifyResponse>(
47
+ "/api/address/autocomplete",
48
+ {
49
+ query,
50
+ },
51
+ );
52
+
53
+ if (!data.value?.features) {
54
+ return [];
55
+ }
56
+
57
+ return data.value.features
58
+ .map((feature: GeoapifyFeature) => {
59
+ const props = feature.properties;
60
+ return {
61
+ street: props.street
62
+ ? `${props.street}${props.housenumber ? " " + props.housenumber : ""}`
63
+ : props.name || "",
64
+ city: props.city || "",
65
+ zipcode: props.postcode || "",
66
+ label: props.formatted || "",
67
+ };
68
+ })
69
+ .filter((suggestion: AddressSuggestion) => {
70
+ if (validCities.value.length === 0) return true;
71
+ return validCities.value.some(
72
+ (city) => city.toLowerCase() === suggestion.city.toLowerCase(),
73
+ );
74
+ });
75
+ } catch (error) {
76
+ console.error("Error fetching address suggestions:", error);
77
+ return [];
78
+ }
79
+ }
80
+
81
+ return {
82
+ getSuggestions,
83
+ };
84
+ }
@@ -0,0 +1,95 @@
1
+ import { ref, computed, watch, toValue, onUnmounted } from "vue";
2
+ import type { Ref, ComputedRef } from "vue";
3
+ import type { AddressSchema } from "~/validation/registrationSchema";
4
+ import type { AddressSuggestion } from "~/composables/useAddressAutocomplete";
5
+
6
+ export function useAddressValidation(
7
+ model: Ref<AddressSchema>,
8
+ options: {
9
+ isShipping?: Ref<boolean> | ComputedRef<boolean> | boolean;
10
+ getSuggestions: (query: string) => Promise<AddressSuggestion[] | undefined>;
11
+ validCities: Ref<string[]>;
12
+ },
13
+ ) {
14
+ const { isShipping = ref(true), getSuggestions, validCities } = options;
15
+
16
+ const showCorrection = ref(false);
17
+ const correction = ref<AddressSuggestion | null>(null);
18
+
19
+ async function checkAddress() {
20
+ const m = toValue(model);
21
+ if (m.street) {
22
+ const searchText = `${m.street}, ${m.zipcode} ${m.city}`;
23
+ const results = await getSuggestions(searchText);
24
+ if (results && results.length > 0) {
25
+ const bestMatch = results[0];
26
+ // Check if it's different enough to suggest correction
27
+ if (
28
+ bestMatch &&
29
+ (bestMatch.street.toLowerCase() !== m.street.toLowerCase() ||
30
+ bestMatch.city.toLowerCase() !== m.city.toLowerCase() ||
31
+ bestMatch.zipcode !== m.zipcode)
32
+ ) {
33
+ correction.value = bestMatch;
34
+ showCorrection.value = true;
35
+ return true;
36
+ }
37
+ }
38
+ }
39
+ showCorrection.value = false;
40
+ correction.value = null;
41
+ return false;
42
+ }
43
+
44
+ const isInvalidCity = computed(() => {
45
+ if (!toValue(isShipping)) {
46
+ return false;
47
+ }
48
+ const m = toValue(model);
49
+ if (!m.city || validCities.value.length === 0) {
50
+ return false;
51
+ }
52
+ return !validCities.value.some(
53
+ (city) => city.toLowerCase() === m.city.toLowerCase(),
54
+ );
55
+ });
56
+
57
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
58
+
59
+ watch(
60
+ () => ({ ...model.value }),
61
+ () => {
62
+ showCorrection.value = false;
63
+ correction.value = null;
64
+
65
+ if (debounceTimer) clearTimeout(debounceTimer);
66
+ debounceTimer = setTimeout(() => {
67
+ if (model.value.street) {
68
+ checkAddress();
69
+ }
70
+ }, 500);
71
+ },
72
+ { deep: true },
73
+ );
74
+
75
+ onUnmounted(() => {
76
+ if (debounceTimer) clearTimeout(debounceTimer);
77
+ });
78
+
79
+ function applyCorrection() {
80
+ if (correction.value) {
81
+ model.value.street = correction.value.street;
82
+ model.value.city = correction.value.city;
83
+ model.value.zipcode = correction.value.zipcode;
84
+ showCorrection.value = false;
85
+ }
86
+ }
87
+
88
+ return {
89
+ showCorrection,
90
+ correction,
91
+ isInvalidCity,
92
+ checkAddress,
93
+ applyCorrection,
94
+ };
95
+ }
@@ -0,0 +1,12 @@
1
+ export function useValidCitiesForDelivery() {
2
+ const validCities = computed(() => ["Obertshausen", "Lämmerspiel", "Hausen"]);
3
+
4
+ const boundingBoxCoordinates = computed(
5
+ () => "8.822251,50.055026,8.899077,50.104327",
6
+ );
7
+
8
+ return {
9
+ validCities,
10
+ boundingBoxCoordinates,
11
+ };
12
+ }
@@ -0,0 +1,28 @@
1
+ <script setup>
2
+ const route = useRoute();
3
+ const { data: page, error } = await useAsyncData(
4
+ `landingpages-${route.path}`,
5
+ () => {
6
+ return queryCollection("landingpages").path(route.path).first();
7
+ },
8
+ );
9
+
10
+ if (error.value || !page.value) {
11
+ throw createError({
12
+ statusCode: 404,
13
+ statusMessage: `Page ${route.path} not found!`,
14
+ fatal: true,
15
+ });
16
+ }
17
+
18
+ useSeoMeta({
19
+ title: page.value?.title,
20
+ description: page.value?.description,
21
+ });
22
+ </script>
23
+
24
+ <template>
25
+ <UContainer>
26
+ <ContentRenderer v-if="page" :value="page" class="content my-8" />
27
+ </UContainer>
28
+ </template>
@@ -13,8 +13,12 @@ if (!page.value) {
13
13
  useSeoMeta({
14
14
  title: page.value.seo?.title || page.value.title,
15
15
  ogTitle: page.value.seo?.title || page.value.title,
16
+ twitterTitle: page.value.seo?.title || page.value.title,
16
17
  description: page.value.seo?.description || page.value.description,
17
18
  ogDescription: page.value.seo?.description || page.value.description,
19
+ twitterDescription: page.value.seo?.description || page.value.description,
20
+ ogImage: page.value.seo?.image,
21
+ twitterImage: page.value.seo?.image,
18
22
  });
19
23
  </script>
20
24
  <template>
@@ -0,0 +1,52 @@
1
+ <script setup lang="ts">
2
+ import type { Schemas } from "#shopware";
3
+
4
+ definePageMeta({
5
+ layout: "listing2",
6
+ });
7
+ const { clearBreadcrumbs } = useBreadcrumbs();
8
+ const { resolvePath } = useNavigationSearch();
9
+ const route = useRoute();
10
+ const routePath = route.path;
11
+
12
+ const { data: seoResult, error } = await useAsyncData(
13
+ `cmsResponse${routePath}`,
14
+ async () => {
15
+ // For client links if the history state contains seo url information we can omit the api call
16
+ if (import.meta.client) {
17
+ if (history.state?.routeName) {
18
+ return {
19
+ routeName: history.state?.routeName,
20
+ foreignKey: history.state?.foreignKey,
21
+ };
22
+ }
23
+ }
24
+ const seoUrl = await resolvePath(routePath);
25
+
26
+ if (!seoUrl?.foreignKey) {
27
+ throw createError({
28
+ statusCode: 404,
29
+ statusMessage: `No data fetched from API for ${routePath}`,
30
+ });
31
+ }
32
+
33
+ return seoUrl;
34
+ },
35
+ );
36
+
37
+ if (error.value) {
38
+ throw error.value;
39
+ }
40
+
41
+ const { foreignKey } = useNavigationContext(
42
+ seoResult as Ref<Schemas["SeoUrl"]>,
43
+ );
44
+
45
+ onBeforeRouteLeave(() => {
46
+ clearBreadcrumbs();
47
+ });
48
+ </script>
49
+
50
+ <template>
51
+ <CategoryListing :id="foreignKey" />
52
+ </template>