@shopbite-de/storefront 1.1.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.
Files changed (136) hide show
  1. package/.dockerignore +28 -0
  2. package/.env.example +11 -0
  3. package/.github/workflows/build.yaml +48 -0
  4. package/.github/workflows/ci.yaml +102 -0
  5. package/.prettierignore +6 -0
  6. package/.prettierrc +1 -0
  7. package/api-types/storeApiSchema.json +13863 -0
  8. package/api-types/storeApiTypes.d.ts +7010 -0
  9. package/app/app.config.ts +18 -0
  10. package/app/app.vue +99 -0
  11. package/app/assets/css/main.css +60 -0
  12. package/app/assets/fonts/Courier_Prime/CourierPrime-Bold.ttf +0 -0
  13. package/app/assets/fonts/Courier_Prime/CourierPrime-BoldItalic.ttf +0 -0
  14. package/app/assets/fonts/Courier_Prime/CourierPrime-Italic.ttf +0 -0
  15. package/app/assets/fonts/Courier_Prime/CourierPrime-Regular.ttf +0 -0
  16. package/app/assets/fonts/Courier_Prime/OFL.txt +93 -0
  17. package/app/assets/fonts/Kalam/Kalam-Bold.ttf +0 -0
  18. package/app/assets/fonts/Kalam/Kalam-Light.ttf +0 -0
  19. package/app/assets/fonts/Kalam/Kalam-Regular.ttf +0 -0
  20. package/app/assets/fonts/Kalam/OFL.txt +93 -0
  21. package/app/assets/fonts/Marcellus/Marcellus-Regular.ttf +0 -0
  22. package/app/assets/fonts/Marcellus/OFL.txt +93 -0
  23. package/app/assets/fonts/Sora/OFL.txt +93 -0
  24. package/app/assets/fonts/Sora/README.txt +70 -0
  25. package/app/assets/fonts/Sora/Sora-VariableFont_wght.ttf +0 -0
  26. package/app/assets/fonts/Sora/static/Sora-Bold.ttf +0 -0
  27. package/app/assets/fonts/Sora/static/Sora-ExtraBold.ttf +0 -0
  28. package/app/assets/fonts/Sora/static/Sora-ExtraLight.ttf +0 -0
  29. package/app/assets/fonts/Sora/static/Sora-Light.ttf +0 -0
  30. package/app/assets/fonts/Sora/static/Sora-Medium.ttf +0 -0
  31. package/app/assets/fonts/Sora/static/Sora-Regular.ttf +0 -0
  32. package/app/assets/fonts/Sora/static/Sora-SemiBold.ttf +0 -0
  33. package/app/assets/fonts/Sora/static/Sora-Thin.ttf +0 -0
  34. package/app/components/AddToWishlist.vue +55 -0
  35. package/app/components/Address/Card.vue +32 -0
  36. package/app/components/Address/Detail.vue +22 -0
  37. package/app/components/Address/Form.vue +117 -0
  38. package/app/components/AnimatedSection.vue +77 -0
  39. package/app/components/BottomNavi.vue +63 -0
  40. package/app/components/Cart/Item.vue +112 -0
  41. package/app/components/Cart/QuickView.vue +55 -0
  42. package/app/components/Category/Header.vue +53 -0
  43. package/app/components/Category/Listing.vue +295 -0
  44. package/app/components/Checkout/DeliveryTimeSelect.vue +177 -0
  45. package/app/components/Checkout/LoginOrRegister.vue +43 -0
  46. package/app/components/Checkout/PaymentAndDelivery.vue +101 -0
  47. package/app/components/Checkout/PaymentMethod.vue +30 -0
  48. package/app/components/Checkout/ShippingMethod.vue +30 -0
  49. package/app/components/Checkout/Summary.vue +125 -0
  50. package/app/components/Cta.vue +34 -0
  51. package/app/components/Features.vue +36 -0
  52. package/app/components/Food/Marquee.vue +35 -0
  53. package/app/components/Food/MarqueeItem.vue +72 -0
  54. package/app/components/Footer.vue +51 -0
  55. package/app/components/Header.vue +160 -0
  56. package/app/components/Hero.vue +77 -0
  57. package/app/components/ImageGallery.vue +46 -0
  58. package/app/components/Loading.vue +29 -0
  59. package/app/components/Navigation/DesktopLeft.vue +51 -0
  60. package/app/components/Navigation/DesktopLeft2.vue +43 -0
  61. package/app/components/Navigation/MobileTop.vue +59 -0
  62. package/app/components/Navigation/MobileTop2.vue +42 -0
  63. package/app/components/Order/Detail.vue +84 -0
  64. package/app/components/Product/Card.vue +132 -0
  65. package/app/components/Product/Category.vue +153 -0
  66. package/app/components/Product/Configurator.vue +65 -0
  67. package/app/components/Product/CrossSelling.vue +95 -0
  68. package/app/components/Product/DeselectIngredient.vue +46 -0
  69. package/app/components/Product/Detail.vue +187 -0
  70. package/app/components/Product/SearchBar.vue +109 -0
  71. package/app/components/PublicAnnouncement.vue +17 -0
  72. package/app/components/Topseller.vue +43 -0
  73. package/app/components/User/Detail.vue +47 -0
  74. package/app/components/User/LoginForm.vue +105 -0
  75. package/app/components/User/RegistrationForm.vue +340 -0
  76. package/app/components/Wishlist.vue +102 -0
  77. package/app/composables/useDeliveryTime.ts +139 -0
  78. package/app/composables/useInterval.ts +15 -0
  79. package/app/composables/usePizzaToppings.ts +31 -0
  80. package/app/composables/useProductEvents.test.ts +111 -0
  81. package/app/composables/useProductEvents.ts +22 -0
  82. package/app/composables/useProductVariants.ts +61 -0
  83. package/app/composables/useScrollAnimation.ts +39 -0
  84. package/app/composables/useTopSellers.ts +34 -0
  85. package/app/error.vue +30 -0
  86. package/app/layouts/account.vue +74 -0
  87. package/app/layouts/default.vue +6 -0
  88. package/app/layouts/listing.vue +32 -0
  89. package/app/layouts/listing2.vue +8 -0
  90. package/app/middleware/trailing-slash.global.ts +19 -0
  91. package/app/pages/account/recover/password/index.vue +143 -0
  92. package/app/pages/anmelden.vue +32 -0
  93. package/app/pages/bestellung.vue +103 -0
  94. package/app/pages/c/[...all].vue +49 -0
  95. package/app/pages/index.vue +59 -0
  96. package/app/pages/konto/adressen.vue +135 -0
  97. package/app/pages/konto/bestellung/[id].vue +41 -0
  98. package/app/pages/konto/bestellungen.vue +53 -0
  99. package/app/pages/konto/index.vue +74 -0
  100. package/app/pages/konto/profil.vue +160 -0
  101. package/app/pages/merkliste.vue +11 -0
  102. package/app/pages/order/[id].vue +69 -0
  103. package/app/pages/passwort-vergessen.vue +103 -0
  104. package/app/pages/registrierung/bestaetigen.vue +44 -0
  105. package/app/pages/registrierung/index.vue +24 -0
  106. package/app/pages/speisekarte.vue +58 -0
  107. package/app/pages/unternehmen/[slug].vue +66 -0
  108. package/app/types/Association.d.ts +11 -0
  109. package/app/utils/businessHours.ts +119 -0
  110. package/app/utils/formatDate.ts +9 -0
  111. package/app/utils/holidays.ts +43 -0
  112. package/app/utils/storeHours.ts +8 -0
  113. package/app/utils/time.ts +20 -0
  114. package/app/validation/addressSchema.ts +34 -0
  115. package/app/validation/registrationSchema.ts +156 -0
  116. package/bun.dockerfile +60 -0
  117. package/compose.yml +17 -0
  118. package/container +7 -0
  119. package/content/index.yml +91 -0
  120. package/content/navigation.yml +67 -0
  121. package/content/unternehmen/agb.md +1 -0
  122. package/content/unternehmen/datenschutz.md +1 -0
  123. package/content/unternehmen/impressum.md +39 -0
  124. package/content.config.ts +134 -0
  125. package/eslint.config.mjs +8 -0
  126. package/node.dockerfile +33 -0
  127. package/nuxt.config.ts +153 -0
  128. package/package.json +70 -0
  129. package/public/dark/Logo.svg +32 -0
  130. package/public/favicon.ico +0 -0
  131. package/public/light/Logo.svg +32 -0
  132. package/renovate.json +4 -0
  133. package/server/tsconfig.json +3 -0
  134. package/shopware.d.ts +19 -0
  135. package/tsconfig.json +4 -0
  136. package/vitest.config.mts +26 -0
@@ -0,0 +1,340 @@
1
+ <script setup lang="ts">
2
+ import { createRegistrationSchema } from "~/validation/registrationSchema";
3
+ import type { RegistrationSchema } from "~/validation/registrationSchema";
4
+ import type { FormSubmitEvent } from "@nuxt/ui";
5
+
6
+ const config = useRuntimeConfig();
7
+ const { register, isLoggedIn } = useUser();
8
+
9
+ if (import.meta.client && isLoggedIn.value) {
10
+ navigateTo({ path: "/konto" });
11
+ }
12
+
13
+ watch(isLoggedIn, (isLoggedIn) => {
14
+ if (isLoggedIn) {
15
+ navigateTo({ path: "/konto" });
16
+ }
17
+ });
18
+
19
+ const state = reactive({
20
+ accountType: "private",
21
+ salutationId: undefined,
22
+ firstName: undefined,
23
+ lastName: undefined,
24
+ email: undefined,
25
+ guest: true,
26
+ password: undefined,
27
+ passwordConfirm: undefined,
28
+ acceptedDataProtection: true,
29
+ isShippingAddressDifferent: false,
30
+ storefrontUrl: config.public.shopware.devStorefrontUrl,
31
+ 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,
41
+ countryId: config.public.site.countryId,
42
+ },
43
+ 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,
53
+ countryId: config.public.site.countryId,
54
+ },
55
+ });
56
+
57
+ const schema = computed(() => createRegistrationSchema(state));
58
+
59
+ const toast = useToast();
60
+
61
+ async function onSubmit(event: FormSubmitEvent<RegistrationSchema>) {
62
+ const registrationData = { ...event.data };
63
+
64
+ console.log(registrationData);
65
+ if (
66
+ !registrationData.billingAddress.firstName &&
67
+ registrationData.firstName
68
+ ) {
69
+ registrationData.billingAddress.firstName = registrationData.firstName;
70
+ }
71
+
72
+ if (!registrationData.billingAddress.lastName && registrationData.lastName) {
73
+ registrationData.billingAddress.lastName = registrationData.lastName;
74
+ }
75
+
76
+ if (!state.isShippingAddressDifferent) {
77
+ delete registrationData.shippingAddress;
78
+ }
79
+
80
+ try {
81
+ await register(registrationData);
82
+
83
+ toast.add({
84
+ title: "Erfolgreich Kundendaten erfasst",
85
+ color: "success",
86
+ });
87
+ emit("registration-success", registrationData);
88
+ } catch (error) {
89
+ console.error("Registration failed:", error);
90
+ toast.add({
91
+ title: "Registrierung fehlgeschlagen",
92
+ description: "Bitte versuchen Sie es erneut.",
93
+ color: "error",
94
+ });
95
+ }
96
+ }
97
+
98
+ const accountTypes = ref([
99
+ {
100
+ label: "Privatkunde",
101
+ value: "private",
102
+ },
103
+ {
104
+ label: "Geschäftskunde",
105
+ value: "commercial",
106
+ },
107
+ ]);
108
+
109
+ const emit = defineEmits<{
110
+ "registration-success": [data: RegistrationSchema];
111
+ }>();
112
+
113
+ const allowedCities = ref(["Obertshausen", "Lämmerspiel"]);
114
+ </script>
115
+
116
+ <template>
117
+ <UForm
118
+ :schema="schema"
119
+ :state="state"
120
+ class="space-y-4"
121
+ @submit="onSubmit"
122
+ @error="(error) => console.log('Form validation error:', error)"
123
+ >
124
+ <UFormField name="accountType">
125
+ <USelect
126
+ v-model="state.accountType"
127
+ value-key="value"
128
+ :items="accountTypes"
129
+ class="w-full"
130
+ />
131
+ </UFormField>
132
+
133
+ <UFormField name="guest">
134
+ <USwitch
135
+ v-model="state.guest"
136
+ label="Als Gast bestellen"
137
+ class="w-full"
138
+ />
139
+ </UFormField>
140
+
141
+ <div class="flex flex-row justify-between gap-4">
142
+ <UFormField label="Vorname" name="firstName" required class="w-full">
143
+ <UInput v-model="state.firstName" type="text" class="w-full" />
144
+ </UFormField>
145
+
146
+ <UFormField label="Nachname" name="lastName" required class="w-full">
147
+ <UInput v-model="state.lastName" type="text" class="w-full" />
148
+ </UFormField>
149
+ </div>
150
+
151
+ <UFormField label="Email" name="email" required>
152
+ <UInput v-model="state.email" class="w-full" />
153
+ </UFormField>
154
+
155
+ <UFormField label="Telefon" name="billingAddress.phoneNumber" required>
156
+ <UInput
157
+ v-model="state.billingAddress.phoneNumber"
158
+ type="text"
159
+ class="w-full"
160
+ />
161
+ </UFormField>
162
+
163
+ <UFormField v-if="!state.guest" label="Password" name="password">
164
+ <UInput v-model="state.password" type="password" class="w-full" />
165
+ </UFormField>
166
+
167
+ <UFormField
168
+ v-if="!state.guest"
169
+ label="Password wiederholen"
170
+ name="passwordConfirm"
171
+ >
172
+ <UInput v-model="state.passwordConfirm" type="password" class="w-full" />
173
+ </UFormField>
174
+
175
+ <USeparator
176
+ color="primary"
177
+ :label="
178
+ state.isShippingAddressDifferent
179
+ ? 'Rechnungsadresse'
180
+ : 'Rechnungs- und Lieferadresse'
181
+ "
182
+ />
183
+
184
+ <UFormField
185
+ v-if="state.accountType === 'commercial'"
186
+ label="Unternehmen"
187
+ name="billingAddress.company"
188
+ >
189
+ <UInput
190
+ v-model="state.billingAddress.company"
191
+ type="text"
192
+ class="w-full"
193
+ />
194
+ </UFormField>
195
+
196
+ <UFormField
197
+ v-if="state.accountType === 'commercial'"
198
+ label="Abteilung"
199
+ name="billingAddress.department"
200
+ >
201
+ <UInput
202
+ v-model="state.billingAddress.department"
203
+ type="text"
204
+ class="w-full"
205
+ />
206
+ </UFormField>
207
+
208
+ <UFormField
209
+ label="Straße und Hausnr."
210
+ name="billingAddress.street"
211
+ required
212
+ >
213
+ <UInput
214
+ v-model="state.billingAddress.street"
215
+ type="text"
216
+ class="w-full"
217
+ />
218
+ </UFormField>
219
+
220
+ <UFormField
221
+ v-if="state.isShippingAddressDifferent"
222
+ label="Postleitzahl"
223
+ name="billingAddress.zipcode"
224
+ >
225
+ <UInput
226
+ v-model="state.billingAddress.zipcode"
227
+ type="text"
228
+ class="w-full"
229
+ />
230
+ </UFormField>
231
+
232
+ <UFormField label="Ort" name="billingAddress.city" required>
233
+ <USelect
234
+ v-if="!state.isShippingAddressDifferent"
235
+ v-model="state.billingAddress.city"
236
+ :items="allowedCities"
237
+ class="w-full"
238
+ />
239
+ <UInput
240
+ v-else
241
+ v-model="state.billingAddress.city"
242
+ type="text"
243
+ class="w-full"
244
+ />
245
+ </UFormField>
246
+
247
+ <UFormField name="guest">
248
+ <UCheckbox
249
+ v-model="state.isShippingAddressDifferent"
250
+ label="Rechnungsadresse weicht von Lieferadresse ab"
251
+ class="w-full"
252
+ />
253
+ </UFormField>
254
+
255
+ <USeparator
256
+ v-if="state.isShippingAddressDifferent"
257
+ color="primary"
258
+ label="Lieferadresse"
259
+ />
260
+
261
+ <UFormField
262
+ v-if="
263
+ state.accountType === 'commercial' && state.isShippingAddressDifferent
264
+ "
265
+ label="Unternehmen"
266
+ name="shippingAddress.company"
267
+ >
268
+ <UInput
269
+ v-model="state.shippingAddress.company"
270
+ type="text"
271
+ class="w-full"
272
+ />
273
+ </UFormField>
274
+
275
+ <UFormField
276
+ v-if="
277
+ state.accountType === 'commercial' && state.isShippingAddressDifferent
278
+ "
279
+ label="Abteilung"
280
+ name="billingAddress.department"
281
+ >
282
+ <UInput
283
+ v-model="state.shippingAddress.department"
284
+ type="text"
285
+ class="w-full"
286
+ />
287
+ </UFormField>
288
+
289
+ <UFormField
290
+ v-if="state.isShippingAddressDifferent"
291
+ label="Vorname"
292
+ name="shippingAddress.firstName"
293
+ required
294
+ >
295
+ <UInput
296
+ v-model="state.shippingAddress.firstName"
297
+ type="text"
298
+ class="w-full"
299
+ />
300
+ </UFormField>
301
+
302
+ <UFormField
303
+ v-if="state.isShippingAddressDifferent"
304
+ label="Nachname"
305
+ name="shippingAddress.lastName"
306
+ >
307
+ <UInput
308
+ v-model="state.shippingAddress.lastName"
309
+ type="text"
310
+ class="w-full"
311
+ />
312
+ </UFormField>
313
+
314
+ <UFormField
315
+ v-if="state.isShippingAddressDifferent"
316
+ label="Straße und Hausnr."
317
+ name="shippingAddress.street"
318
+ >
319
+ <UInput
320
+ v-model="state.shippingAddress.street"
321
+ type="text"
322
+ class="w-full"
323
+ />
324
+ </UFormField>
325
+
326
+ <UFormField
327
+ v-if="state.isShippingAddressDifferent"
328
+ label="Ort"
329
+ name="shippingAddress.city"
330
+ >
331
+ <USelect
332
+ v-model="state.shippingAddress.city"
333
+ :items="allowedCities"
334
+ class="w-full"
335
+ />
336
+ </UFormField>
337
+
338
+ <UButton block type="submit">Speichern</UButton>
339
+ </UForm>
340
+ </template>
@@ -0,0 +1,102 @@
1
+ <script setup lang="ts">
2
+ import type { Schemas } from "#shopware";
3
+
4
+ const { getWishlistProducts, items, clearWishlist } = useWishlist();
5
+ const { apiClient } = useShopwareContext();
6
+ const products = ref<Schemas["Product"][]>([]);
7
+ const isLoading = ref(false);
8
+
9
+ const clearWishlistHandler = async () => {
10
+ try {
11
+ isLoading.value = true;
12
+ clearWishlist();
13
+ } finally {
14
+ isLoading.value = false;
15
+ }
16
+ };
17
+ const loadProductsByItemIds = async (itemIds: string[]): Promise<void> => {
18
+ isLoading.value = true;
19
+
20
+ try {
21
+ const { data } = await apiClient.invoke("readProduct post /product", {
22
+ body: { ids: itemIds || items.value },
23
+ });
24
+
25
+ if (data?.elements) {
26
+ products.value = data.elements;
27
+ }
28
+ } catch (error) {
29
+ console.error("[wishlist][loadProductsByItemIds]", error);
30
+ }
31
+
32
+ isLoading.value = false;
33
+ };
34
+
35
+ watch(
36
+ items,
37
+ (items, oldItems) => {
38
+ if (items.length !== oldItems?.length) {
39
+ products.value = products.value.filter(({ id }: { id: string }) =>
40
+ items.includes(id),
41
+ );
42
+ }
43
+ if (!items.length) {
44
+ return;
45
+ }
46
+ loadProductsByItemIds(items);
47
+ },
48
+ {
49
+ immediate: true,
50
+ },
51
+ );
52
+
53
+ onMounted(async () => {
54
+ await getWishlistProducts();
55
+ });
56
+ </script>
57
+
58
+ <template>
59
+ <div>
60
+ <h2 class="text-2xl">Merkliste</h2>
61
+ <div class="flex flex-col gap-4 h-full">
62
+ <div
63
+ v-if="products.length === 0"
64
+ class="flex flex-row items-center gap-2"
65
+ >
66
+ <UIcon name="i-lucide-frown" class="size-5" />
67
+ <p>Keine Produkte</p>
68
+ </div>
69
+ <div class="flex flex-col gap-4">
70
+ <ProductCard
71
+ v-for="product in products"
72
+ :key="product.id"
73
+ :product="product"
74
+ :with-favorite-button="true"
75
+ :with-add-to-cart-button="false"
76
+ />
77
+ <UButton
78
+ v-if="products.length > 0"
79
+ color="neutral"
80
+ variant="outline"
81
+ icon="i-lucide-trash"
82
+ @click="clearWishlistHandler"
83
+ >
84
+ Alles löschen
85
+ </UButton>
86
+ </div>
87
+ </div>
88
+ </div>
89
+ </template>
90
+
91
+ <style scoped>
92
+ @import "tailwindcss";
93
+ @import "@nuxt/ui";
94
+
95
+ h1 {
96
+ @apply text-3xl sm:text-4xl lg:text-5xl text-pretty tracking-tight font-bold text-highlighted my-14;
97
+ }
98
+
99
+ h2 {
100
+ @apply mt-0 text-2xl sm:text-3xl lg:text-4xl text-pretty tracking-tight font-bold text-highlighted my-10;
101
+ }
102
+ </style>
@@ -0,0 +1,139 @@
1
+ import { computed, type Ref } from "vue";
2
+ import { toTimeString, parseTimeString } from "~/utils/time";
3
+ import {
4
+ getEarliestSelectableTime,
5
+ findActiveInterval,
6
+ getServiceIntervals,
7
+ isTuesday,
8
+ type ServiceInterval,
9
+ } from "~/utils/businessHours";
10
+
11
+ function isTimeWithinBounds(
12
+ time: string,
13
+ min: string | null,
14
+ max: string | null,
15
+ ): boolean {
16
+ if (!min || !max) return false;
17
+ return time >= min && time <= max;
18
+ }
19
+
20
+ export function useDeliveryTime(now: Ref<Date>) {
21
+ const { deliveryTime } = usePizzaToppings();
22
+ const earliest = computed<Date>(() =>
23
+ getEarliestSelectableTime(now.value, deliveryTime.value),
24
+ );
25
+
26
+ const activeInterval = computed<ServiceInterval | null>(() =>
27
+ findActiveInterval(now.value, deliveryTime.value),
28
+ );
29
+
30
+ const minTime = computed<string | null>(() => {
31
+ const interval = activeInterval.value;
32
+ if (!interval) return null;
33
+ const minDate = new Date(
34
+ Math.max(earliest.value.getTime(), interval.start.getTime()),
35
+ );
36
+ return toTimeString(minDate);
37
+ });
38
+
39
+ const maxTime = computed<string | null>(() => {
40
+ const interval = activeInterval.value;
41
+ return interval ? toTimeString(interval.end) : null;
42
+ });
43
+
44
+ const isClosedToday = computed<boolean>(() => activeInterval.value === null);
45
+
46
+ const helperText = computed<string>(() => {
47
+ const interval = activeInterval.value;
48
+ if (!interval) {
49
+ if (isTuesday(now.value)) {
50
+ return "Heute (Dienstag) ist Ruhetag. Bitte an einem anderen Tag bestellen.";
51
+ }
52
+ const intervals = getServiceIntervals(now.value);
53
+ const lastInterval = intervals.at(-1);
54
+ if (lastInterval && earliest.value > lastInterval.end) {
55
+ return "Heute sind keine Lieferzeiten mehr verfügbar.";
56
+ }
57
+ return "Aktuell keine Lieferzeit verfügbar. Bitte später erneut versuchen.";
58
+ }
59
+
60
+ const startStr = toTimeString(interval.start);
61
+ const endStr = toTimeString(interval.end);
62
+ const earliestStr = toTimeString(earliest.value);
63
+
64
+ return earliest.value <= interval.start
65
+ ? `Nächster möglicher Zeitraum: ${startStr} – ${endStr}`
66
+ : `Frühestmöglich ab ${earliestStr} (heute), erlaubt: ${startStr} – ${endStr}`;
67
+ });
68
+
69
+ function clampTimeToInterval(
70
+ selectedTime: string,
71
+ min: string,
72
+ max: string,
73
+ baseDate: Date,
74
+ ): string {
75
+ const selected = parseTimeString(selectedTime, baseDate);
76
+ const minDate = parseTimeString(min, baseDate);
77
+ const maxDate = parseTimeString(max, baseDate);
78
+
79
+ let clamped = selected;
80
+ if (selected < minDate) clamped = minDate;
81
+ if (selected > maxDate) clamped = maxDate;
82
+
83
+ return toTimeString(clamped);
84
+ }
85
+
86
+ /**
87
+ * Validate a selected time and return a human-readable error message,
88
+ * or null if the time is valid (or empty).
89
+ */
90
+ function validate(selectedTime: string | null | undefined): string | null {
91
+ // No value -> no error (let required-validation happen elsewhere if needed)
92
+ if (!selectedTime) return null;
93
+
94
+ // Simple HH:MM check – prevents parse errors and immediate feedback
95
+ const isHHMM = /^\d{2}:\d{2}$/.test(selectedTime);
96
+ if (!isHHMM) {
97
+ return "Bitte eine gültige Uhrzeit im Format HH:MM eingeben.";
98
+ }
99
+
100
+ // Closed or no selectable window
101
+ if (isClosedToday.value || !minTime.value || !maxTime.value) {
102
+ return helperText.value;
103
+ }
104
+
105
+ // Bound checks (string compare works for HH:MM)
106
+ if (selectedTime < minTime.value) {
107
+ return `Die Zeit liegt vor dem frühestmöglichen Zeitpunkt. Frühestens ab ${minTime.value}.`;
108
+ }
109
+ if (selectedTime > maxTime.value) {
110
+ return `Die Zeit liegt nach dem spätestmöglichen Zeitpunkt. Spätestens bis ${maxTime.value}.`;
111
+ }
112
+
113
+ // Optional: enforce 5-minute steps (step="300" in input)
114
+ try {
115
+ const parsed = parseTimeString(selectedTime, now.value);
116
+ const minutes = parsed.getHours() * 60 + parsed.getMinutes();
117
+ if (minutes % 5 !== 0) {
118
+ return "Bitte eine Zeit in 5-Minuten-Schritten auswählen.";
119
+ }
120
+ } catch {
121
+ // If parsing fails for some reason, show a generic message
122
+ return "Bitte eine gültige Uhrzeit auswählen.";
123
+ }
124
+
125
+ return null;
126
+ }
127
+
128
+ return {
129
+ earliest,
130
+ activeInterval,
131
+ minTime,
132
+ maxTime,
133
+ isClosedToday,
134
+ helperText,
135
+ clampTimeToInterval,
136
+ isTimeWithinBounds,
137
+ validate,
138
+ };
139
+ }
@@ -0,0 +1,15 @@
1
+ import { onMounted, onUnmounted } from "vue";
2
+
3
+ export function useInterval(callback: () => void, interval: number) {
4
+ let timer: NodeJS.Timeout | null = null;
5
+
6
+ onMounted(() => {
7
+ timer = setInterval(callback, interval);
8
+ });
9
+
10
+ onUnmounted(() => {
11
+ if (timer !== null) {
12
+ clearInterval(timer);
13
+ }
14
+ });
15
+ }
@@ -0,0 +1,31 @@
1
+ import { useContext, useShopwareContext } from "#imports";
2
+ import type { Schemas } from "#shopware";
3
+
4
+ type usePizzaToppingsReturn = {
5
+ deliveryTime: ComputedRef<number>;
6
+ isCheckoutEnabled: ComputedRef<boolean>;
7
+ refresh(): Promise<Schemas["PizzaToppings"]>;
8
+ };
9
+
10
+ export function usePizzaToppings(): usePizzaToppingsReturn {
11
+ const { apiClient } = useShopwareContext();
12
+
13
+ const _deliveryTime = useContext<number>("deliveryTime");
14
+ const _isCheckoutEnabled = useContext<boolean>("isCheckoutActive");
15
+
16
+ async function refresh(): Promise<Schemas["PizzaToppings"]> {
17
+ const { data } = await apiClient.invoke(
18
+ "pizza-toppings.get get /pizza-toppings",
19
+ );
20
+ _deliveryTime.value = data.deliveryTime;
21
+ _isCheckoutEnabled.value = data.isCheckoutEnabled;
22
+
23
+ return data;
24
+ }
25
+
26
+ return {
27
+ deliveryTime: computed(() => _deliveryTime.value),
28
+ isCheckoutEnabled: computed(() => _isCheckoutEnabled.value),
29
+ refresh,
30
+ };
31
+ }