@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.
- package/.dockerignore +28 -0
- package/.env.example +11 -0
- package/.github/workflows/build.yaml +48 -0
- package/.github/workflows/ci.yaml +102 -0
- package/.prettierignore +6 -0
- package/.prettierrc +1 -0
- package/api-types/storeApiSchema.json +13863 -0
- package/api-types/storeApiTypes.d.ts +7010 -0
- package/app/app.config.ts +18 -0
- package/app/app.vue +99 -0
- package/app/assets/css/main.css +60 -0
- package/app/assets/fonts/Courier_Prime/CourierPrime-Bold.ttf +0 -0
- package/app/assets/fonts/Courier_Prime/CourierPrime-BoldItalic.ttf +0 -0
- package/app/assets/fonts/Courier_Prime/CourierPrime-Italic.ttf +0 -0
- package/app/assets/fonts/Courier_Prime/CourierPrime-Regular.ttf +0 -0
- package/app/assets/fonts/Courier_Prime/OFL.txt +93 -0
- package/app/assets/fonts/Kalam/Kalam-Bold.ttf +0 -0
- package/app/assets/fonts/Kalam/Kalam-Light.ttf +0 -0
- package/app/assets/fonts/Kalam/Kalam-Regular.ttf +0 -0
- package/app/assets/fonts/Kalam/OFL.txt +93 -0
- package/app/assets/fonts/Marcellus/Marcellus-Regular.ttf +0 -0
- package/app/assets/fonts/Marcellus/OFL.txt +93 -0
- package/app/assets/fonts/Sora/OFL.txt +93 -0
- package/app/assets/fonts/Sora/README.txt +70 -0
- package/app/assets/fonts/Sora/Sora-VariableFont_wght.ttf +0 -0
- package/app/assets/fonts/Sora/static/Sora-Bold.ttf +0 -0
- package/app/assets/fonts/Sora/static/Sora-ExtraBold.ttf +0 -0
- package/app/assets/fonts/Sora/static/Sora-ExtraLight.ttf +0 -0
- package/app/assets/fonts/Sora/static/Sora-Light.ttf +0 -0
- package/app/assets/fonts/Sora/static/Sora-Medium.ttf +0 -0
- package/app/assets/fonts/Sora/static/Sora-Regular.ttf +0 -0
- package/app/assets/fonts/Sora/static/Sora-SemiBold.ttf +0 -0
- package/app/assets/fonts/Sora/static/Sora-Thin.ttf +0 -0
- package/app/components/AddToWishlist.vue +55 -0
- package/app/components/Address/Card.vue +32 -0
- package/app/components/Address/Detail.vue +22 -0
- package/app/components/Address/Form.vue +117 -0
- package/app/components/AnimatedSection.vue +77 -0
- package/app/components/BottomNavi.vue +63 -0
- package/app/components/Cart/Item.vue +112 -0
- package/app/components/Cart/QuickView.vue +55 -0
- package/app/components/Category/Header.vue +53 -0
- package/app/components/Category/Listing.vue +295 -0
- package/app/components/Checkout/DeliveryTimeSelect.vue +177 -0
- package/app/components/Checkout/LoginOrRegister.vue +43 -0
- package/app/components/Checkout/PaymentAndDelivery.vue +101 -0
- package/app/components/Checkout/PaymentMethod.vue +30 -0
- package/app/components/Checkout/ShippingMethod.vue +30 -0
- package/app/components/Checkout/Summary.vue +125 -0
- package/app/components/Cta.vue +34 -0
- package/app/components/Features.vue +36 -0
- package/app/components/Food/Marquee.vue +35 -0
- package/app/components/Food/MarqueeItem.vue +72 -0
- package/app/components/Footer.vue +51 -0
- package/app/components/Header.vue +160 -0
- package/app/components/Hero.vue +77 -0
- package/app/components/ImageGallery.vue +46 -0
- package/app/components/Loading.vue +29 -0
- package/app/components/Navigation/DesktopLeft.vue +51 -0
- package/app/components/Navigation/DesktopLeft2.vue +43 -0
- package/app/components/Navigation/MobileTop.vue +59 -0
- package/app/components/Navigation/MobileTop2.vue +42 -0
- package/app/components/Order/Detail.vue +84 -0
- package/app/components/Product/Card.vue +132 -0
- package/app/components/Product/Category.vue +153 -0
- package/app/components/Product/Configurator.vue +65 -0
- package/app/components/Product/CrossSelling.vue +95 -0
- package/app/components/Product/DeselectIngredient.vue +46 -0
- package/app/components/Product/Detail.vue +187 -0
- package/app/components/Product/SearchBar.vue +109 -0
- package/app/components/PublicAnnouncement.vue +17 -0
- package/app/components/Topseller.vue +43 -0
- package/app/components/User/Detail.vue +47 -0
- package/app/components/User/LoginForm.vue +105 -0
- package/app/components/User/RegistrationForm.vue +340 -0
- package/app/components/Wishlist.vue +102 -0
- package/app/composables/useDeliveryTime.ts +139 -0
- package/app/composables/useInterval.ts +15 -0
- package/app/composables/usePizzaToppings.ts +31 -0
- package/app/composables/useProductEvents.test.ts +111 -0
- package/app/composables/useProductEvents.ts +22 -0
- package/app/composables/useProductVariants.ts +61 -0
- package/app/composables/useScrollAnimation.ts +39 -0
- package/app/composables/useTopSellers.ts +34 -0
- package/app/error.vue +30 -0
- package/app/layouts/account.vue +74 -0
- package/app/layouts/default.vue +6 -0
- package/app/layouts/listing.vue +32 -0
- package/app/layouts/listing2.vue +8 -0
- package/app/middleware/trailing-slash.global.ts +19 -0
- package/app/pages/account/recover/password/index.vue +143 -0
- package/app/pages/anmelden.vue +32 -0
- package/app/pages/bestellung.vue +103 -0
- package/app/pages/c/[...all].vue +49 -0
- package/app/pages/index.vue +59 -0
- package/app/pages/konto/adressen.vue +135 -0
- package/app/pages/konto/bestellung/[id].vue +41 -0
- package/app/pages/konto/bestellungen.vue +53 -0
- package/app/pages/konto/index.vue +74 -0
- package/app/pages/konto/profil.vue +160 -0
- package/app/pages/merkliste.vue +11 -0
- package/app/pages/order/[id].vue +69 -0
- package/app/pages/passwort-vergessen.vue +103 -0
- package/app/pages/registrierung/bestaetigen.vue +44 -0
- package/app/pages/registrierung/index.vue +24 -0
- package/app/pages/speisekarte.vue +58 -0
- package/app/pages/unternehmen/[slug].vue +66 -0
- package/app/types/Association.d.ts +11 -0
- package/app/utils/businessHours.ts +119 -0
- package/app/utils/formatDate.ts +9 -0
- package/app/utils/holidays.ts +43 -0
- package/app/utils/storeHours.ts +8 -0
- package/app/utils/time.ts +20 -0
- package/app/validation/addressSchema.ts +34 -0
- package/app/validation/registrationSchema.ts +156 -0
- package/bun.dockerfile +60 -0
- package/compose.yml +17 -0
- package/container +7 -0
- package/content/index.yml +91 -0
- package/content/navigation.yml +67 -0
- package/content/unternehmen/agb.md +1 -0
- package/content/unternehmen/datenschutz.md +1 -0
- package/content/unternehmen/impressum.md +39 -0
- package/content.config.ts +134 -0
- package/eslint.config.mjs +8 -0
- package/node.dockerfile +33 -0
- package/nuxt.config.ts +153 -0
- package/package.json +70 -0
- package/public/dark/Logo.svg +32 -0
- package/public/favicon.ico +0 -0
- package/public/light/Logo.svg +32 -0
- package/renovate.json +4 -0
- package/server/tsconfig.json +3 -0
- package/shopware.d.ts +19 -0
- package/tsconfig.json +4 -0
- package/vitest.config.mts +26 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onMounted, watch, computed, ref, toRefs } from "vue";
|
|
3
|
+
import type { Schemas } from "#shopware";
|
|
4
|
+
import type {
|
|
5
|
+
AssociationItemProduct,
|
|
6
|
+
AssociationItem,
|
|
7
|
+
} from "~/types/Association";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_CURRENCY = "EUR";
|
|
10
|
+
const DEFAULT_LOCALE = "de-DE";
|
|
11
|
+
const ASSOCIATION_CONTEXT = "cross-selling";
|
|
12
|
+
const LOADING_ICON = "i-lucide-loader";
|
|
13
|
+
const DEFAULT_ICON = "i-lucide-plus";
|
|
14
|
+
const LOAD_ASSOCIATIONS_METHOD = "post";
|
|
15
|
+
|
|
16
|
+
const props = defineProps<{
|
|
17
|
+
product: Schemas["Product"];
|
|
18
|
+
}>();
|
|
19
|
+
const { product } = toRefs(props);
|
|
20
|
+
const selectedExtras = ref<AssociationItemProduct[]>([]);
|
|
21
|
+
|
|
22
|
+
const {
|
|
23
|
+
loadAssociations,
|
|
24
|
+
isLoading: isAssociationsLoading,
|
|
25
|
+
productAssociations,
|
|
26
|
+
} = useProductAssociations(product, {
|
|
27
|
+
associationContext: ASSOCIATION_CONTEXT,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const { getFormattedPrice } = usePrice({
|
|
31
|
+
currencyCode: DEFAULT_CURRENCY,
|
|
32
|
+
localeCode: DEFAULT_LOCALE,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function mapAssociationToItems(
|
|
36
|
+
associations: Schemas["CrossSellingElement"][],
|
|
37
|
+
): AssociationItem[] {
|
|
38
|
+
return associations.map((crossSellingElement) => ({
|
|
39
|
+
label: crossSellingElement.crossSelling.name,
|
|
40
|
+
products: crossSellingElement.products.map(
|
|
41
|
+
(product: Schemas["Product"]) => ({
|
|
42
|
+
label: product.name,
|
|
43
|
+
value: product.id,
|
|
44
|
+
price: getFormattedPrice(product.calculatedPrice.unitPrice),
|
|
45
|
+
icon: DEFAULT_ICON,
|
|
46
|
+
}),
|
|
47
|
+
),
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const associationItems = computed(() =>
|
|
52
|
+
mapAssociationToItems(productAssociations.value),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
onMounted(() => {
|
|
56
|
+
loadAssociations({ method: LOAD_ASSOCIATIONS_METHOD, searchParams: {} });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
watch(selectedExtras, () => emit("extras-selected", selectedExtras.value), {
|
|
60
|
+
deep: true,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const emit = defineEmits<{
|
|
64
|
+
"extras-selected": [selectedExtras: AssociationItemProduct[]];
|
|
65
|
+
}>();
|
|
66
|
+
</script>
|
|
67
|
+
|
|
68
|
+
<template>
|
|
69
|
+
<div v-if="!isAssociationsLoading" class="my-4">
|
|
70
|
+
<div
|
|
71
|
+
v-for="association in associationItems"
|
|
72
|
+
:key="association.label"
|
|
73
|
+
class=""
|
|
74
|
+
>
|
|
75
|
+
<div class="flex flex-row gap-2 items-center">
|
|
76
|
+
<div class="basis-1/3">{{ association.label }}:</div>
|
|
77
|
+
<UInputMenu
|
|
78
|
+
v-model="selectedExtras"
|
|
79
|
+
class="w-full"
|
|
80
|
+
:loading="isAssociationsLoading"
|
|
81
|
+
:loading-icon="LOADING_ICON"
|
|
82
|
+
multiple
|
|
83
|
+
:items="association.products"
|
|
84
|
+
icon="i-lucide-plus"
|
|
85
|
+
>
|
|
86
|
+
<template #item-trailing="{ item }">
|
|
87
|
+
<span class="">
|
|
88
|
+
{{ item.price }}
|
|
89
|
+
</span>
|
|
90
|
+
</template>
|
|
91
|
+
</UInputMenu>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</template>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { Schemas } from "#shopware";
|
|
3
|
+
import { toRefs, watch } from "vue";
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{
|
|
6
|
+
product: Schemas["Product"];
|
|
7
|
+
}>();
|
|
8
|
+
const { product } = toRefs(props);
|
|
9
|
+
|
|
10
|
+
const deselected = ref([]);
|
|
11
|
+
|
|
12
|
+
const list = computed(() => {
|
|
13
|
+
const propsList = product.value?.properties ?? [];
|
|
14
|
+
return propsList
|
|
15
|
+
.filter(
|
|
16
|
+
(propertyGroupOption: Schemas["PropertyGroupOption"]) =>
|
|
17
|
+
propertyGroupOption.group.name === "Hauptzutaten",
|
|
18
|
+
)
|
|
19
|
+
.map(
|
|
20
|
+
(propertyGroupOption: Schemas["PropertyGroupOption"]) =>
|
|
21
|
+
propertyGroupOption.translated.name,
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
watch(deselected, () => emit("ingredients-deselected", deselected.value), {
|
|
26
|
+
deep: true,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const emit = defineEmits<{
|
|
30
|
+
"ingredients-deselected": [deselected: string[]];
|
|
31
|
+
}>();
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<template>
|
|
35
|
+
<div v-if="list.length > 0" class="flex flex-row gap-2 my-6 items-center">
|
|
36
|
+
<div class="basis-1/3">Aber ohne:</div>
|
|
37
|
+
<USelect
|
|
38
|
+
v-model="deselected"
|
|
39
|
+
value-key="productId"
|
|
40
|
+
:items="list"
|
|
41
|
+
class="w-full"
|
|
42
|
+
icon="i-lucide-minus"
|
|
43
|
+
multiple
|
|
44
|
+
/>
|
|
45
|
+
</div>
|
|
46
|
+
</template>
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { Schemas, operations } from "#shopware";
|
|
3
|
+
import type { AssociationItemProduct } from "~/types/Association";
|
|
4
|
+
import { v5 as uuidv5 } from "uuid";
|
|
5
|
+
import { computed, ref, toRefs } from "vue";
|
|
6
|
+
import { useTrackEvent } from "#imports";
|
|
7
|
+
|
|
8
|
+
const UUID_NAMESPACE = "b098ef7e-0fa2-4073-b002-7ceec4360fbf";
|
|
9
|
+
const CART_SUCCESS_TITLE = "Gute Wahl!";
|
|
10
|
+
const LINE_ITEM_PRODUCT = "product";
|
|
11
|
+
const LINE_ITEM_CONTAINER = "container";
|
|
12
|
+
|
|
13
|
+
const props = defineProps<{
|
|
14
|
+
product: Schemas["Product"];
|
|
15
|
+
}>();
|
|
16
|
+
const { product } = toRefs(props);
|
|
17
|
+
const { addProducts, refreshCart } = useCart();
|
|
18
|
+
const toast = useToast();
|
|
19
|
+
const { triggerProductAdded } = useProductEvents();
|
|
20
|
+
|
|
21
|
+
const selectedExtras = ref<AssociationItemProduct[]>([]);
|
|
22
|
+
const deselectedIngredients = ref<string[]>([]);
|
|
23
|
+
const selectedQuantity = ref(1);
|
|
24
|
+
const selectedProduct = ref<Schemas["Product"]>(product.value);
|
|
25
|
+
|
|
26
|
+
const cartItemLabel = computed(() => {
|
|
27
|
+
const formatIngredientModifications = (
|
|
28
|
+
items: Array<{ label: string } | string>,
|
|
29
|
+
prefix: string,
|
|
30
|
+
): string => {
|
|
31
|
+
if (!items.length) return "";
|
|
32
|
+
|
|
33
|
+
const labels = items.map((item) =>
|
|
34
|
+
typeof item === "string" ? item : item.label,
|
|
35
|
+
);
|
|
36
|
+
const separator = " " + prefix;
|
|
37
|
+
return " " + prefix + labels.join(separator);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const extrasFormatted = formatIngredientModifications(
|
|
41
|
+
selectedExtras.value,
|
|
42
|
+
"+",
|
|
43
|
+
);
|
|
44
|
+
const removedFormatted = formatIngredientModifications(
|
|
45
|
+
deselectedIngredients.value,
|
|
46
|
+
"-",
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
return `${selectedProduct.value.translated.name}${extrasFormatted}${removedFormatted}`;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const createExtras = () =>
|
|
53
|
+
selectedExtras.value.map((extra) => ({
|
|
54
|
+
id: extra.value,
|
|
55
|
+
type: LINE_ITEM_PRODUCT,
|
|
56
|
+
quantity: selectedQuantity.value,
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
const generateSortedExtrasString = (extras: AssociationItemProduct[]) =>
|
|
60
|
+
extras
|
|
61
|
+
.map((extra) => extra.value)
|
|
62
|
+
.sort()
|
|
63
|
+
.join("");
|
|
64
|
+
|
|
65
|
+
const generateProductId = (baseId: string, extras: AssociationItemProduct[]) =>
|
|
66
|
+
extras.length ? baseId + generateSortedExtrasString(extras) : baseId;
|
|
67
|
+
|
|
68
|
+
function createCartItems(): operations["addLineItem post /checkout/cart/line-item"]["body"]["items"] {
|
|
69
|
+
const extras = createExtras();
|
|
70
|
+
|
|
71
|
+
// Simple product when no extras
|
|
72
|
+
if (extras.length === 0 && deselectedIngredients.value.length === 0) {
|
|
73
|
+
return [
|
|
74
|
+
{
|
|
75
|
+
id: selectedProduct.value.id,
|
|
76
|
+
quantity: selectedQuantity.value,
|
|
77
|
+
type: LINE_ITEM_PRODUCT,
|
|
78
|
+
label: cartItemLabel.value,
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Container product when extras are selected
|
|
84
|
+
const generatedUuid = uuidv5(
|
|
85
|
+
generateProductId(selectedProduct.value.id, selectedExtras.value),
|
|
86
|
+
UUID_NAMESPACE,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return [
|
|
90
|
+
{
|
|
91
|
+
id: generatedUuid,
|
|
92
|
+
quantity: selectedQuantity.value,
|
|
93
|
+
type: LINE_ITEM_CONTAINER,
|
|
94
|
+
label: cartItemLabel.value,
|
|
95
|
+
payload: {
|
|
96
|
+
productNumber: selectedProduct.value.productNumber,
|
|
97
|
+
},
|
|
98
|
+
children: [
|
|
99
|
+
{
|
|
100
|
+
id: selectedProduct.value.id,
|
|
101
|
+
quantity: selectedQuantity.value,
|
|
102
|
+
type: LINE_ITEM_PRODUCT,
|
|
103
|
+
},
|
|
104
|
+
...extras,
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Toast Notification
|
|
111
|
+
async function showSuccessToast() {
|
|
112
|
+
toast.add({
|
|
113
|
+
title: CART_SUCCESS_TITLE,
|
|
114
|
+
description: `${selectedProduct.value.translated.name} wurde in den Warenkorb gelegt.`,
|
|
115
|
+
icon: "i-lucide-shopping-cart",
|
|
116
|
+
color: "success",
|
|
117
|
+
progress: true,
|
|
118
|
+
color: "primary",
|
|
119
|
+
duration: 2000,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Add to Cart
|
|
124
|
+
async function addToCart() {
|
|
125
|
+
const cartItems = createCartItems();
|
|
126
|
+
const newCart = await addProducts(cartItems);
|
|
127
|
+
await refreshCart(newCart);
|
|
128
|
+
await showSuccessToast();
|
|
129
|
+
emit("product-added");
|
|
130
|
+
triggerProductAdded(); // Clear search globally
|
|
131
|
+
useTrackEvent("add_to_cart", {
|
|
132
|
+
props: {
|
|
133
|
+
product_number: selectedProduct.value.productNumber,
|
|
134
|
+
quantity: selectedQuantity.value,
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Event Handlers
|
|
140
|
+
const onVariantSwitched = (variant: Schemas["Product"]) => {
|
|
141
|
+
selectedProduct.value = variant;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const onExtrasSelected = (extras: AssociationItemProduct[]) => {
|
|
145
|
+
selectedExtras.value = extras;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const onIngredientsDeselected = (deselected: string[]) => {
|
|
149
|
+
deselectedIngredients.value = deselected;
|
|
150
|
+
};
|
|
151
|
+
const emit = defineEmits(["product-added"]);
|
|
152
|
+
</script>
|
|
153
|
+
|
|
154
|
+
<template>
|
|
155
|
+
<div class="flex flex-col justify-between w-full">
|
|
156
|
+
<div>
|
|
157
|
+
<ProductConfigurator
|
|
158
|
+
:parent-product="product"
|
|
159
|
+
@variant-switched="onVariantSwitched"
|
|
160
|
+
/>
|
|
161
|
+
<ProductCrossSelling
|
|
162
|
+
:product="selectedProduct"
|
|
163
|
+
@extras-selected="onExtrasSelected"
|
|
164
|
+
/>
|
|
165
|
+
<ProductDeselectIngredient
|
|
166
|
+
:product="selectedProduct"
|
|
167
|
+
@ingredients-deselected="onIngredientsDeselected"
|
|
168
|
+
/>
|
|
169
|
+
</div>
|
|
170
|
+
<div class="flex flex-row gap-4 mt-8">
|
|
171
|
+
<UInputNumber
|
|
172
|
+
v-model="selectedQuantity"
|
|
173
|
+
size="xl"
|
|
174
|
+
placeholder="Anzahl"
|
|
175
|
+
:min="1"
|
|
176
|
+
:max="100"
|
|
177
|
+
/>
|
|
178
|
+
<UButton
|
|
179
|
+
size="xl"
|
|
180
|
+
label="In den Warenkorb"
|
|
181
|
+
icon="i-lucide-shopping-cart"
|
|
182
|
+
block
|
|
183
|
+
@click="addToCart"
|
|
184
|
+
/>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
</template>
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref } from "vue";
|
|
3
|
+
import { onClickOutside, useDebounceFn, useFocus } from "@vueuse/core";
|
|
4
|
+
import type { Schemas } from "#shopware";
|
|
5
|
+
import { useTrackEvent } from "#imports";
|
|
6
|
+
|
|
7
|
+
withDefaults(
|
|
8
|
+
defineProps<{
|
|
9
|
+
searchInProgress?: boolean;
|
|
10
|
+
}>(),
|
|
11
|
+
{
|
|
12
|
+
searchInProgress: false,
|
|
13
|
+
},
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const emit =
|
|
17
|
+
defineEmits<(e: "update:searchInProgress", value: boolean) => void>();
|
|
18
|
+
|
|
19
|
+
const { searchTerm, search, getProducts, loading } = useProductSearchSuggest();
|
|
20
|
+
const { onProductAdded } = useProductEvents();
|
|
21
|
+
|
|
22
|
+
const searchContainer = ref(null);
|
|
23
|
+
const searchInput = ref();
|
|
24
|
+
const minSearchLength = 1;
|
|
25
|
+
|
|
26
|
+
const active = ref(false);
|
|
27
|
+
|
|
28
|
+
// Clear search when product is added to cart
|
|
29
|
+
onProductAdded(() => {
|
|
30
|
+
typingQuery.value = "";
|
|
31
|
+
active.value = false;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
watch(active, (value) => {
|
|
35
|
+
const { focused } = useFocus(searchInput);
|
|
36
|
+
|
|
37
|
+
focused.value = value;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// String the user has typed in the search field
|
|
41
|
+
const typingQuery = ref("");
|
|
42
|
+
|
|
43
|
+
watch(typingQuery, (value) => {
|
|
44
|
+
if (value.length >= minSearchLength) {
|
|
45
|
+
performSuggestSearch(value);
|
|
46
|
+
emit("update:searchInProgress", true);
|
|
47
|
+
} else {
|
|
48
|
+
emit("update:searchInProgress", false);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Defer the search request to prevent the search from being triggered too after typing
|
|
53
|
+
const performSuggestSearch = useDebounceFn((value) => {
|
|
54
|
+
searchTerm.value = value;
|
|
55
|
+
search();
|
|
56
|
+
trackEvent(value);
|
|
57
|
+
}, 500);
|
|
58
|
+
|
|
59
|
+
const trackEvent = useDebounceFn((searchTerm: string) => {
|
|
60
|
+
const searchResult = getProducts.value.map(function (
|
|
61
|
+
product: Schemas["Product"],
|
|
62
|
+
) {
|
|
63
|
+
return product.productNumber;
|
|
64
|
+
});
|
|
65
|
+
useTrackEvent("search", {
|
|
66
|
+
props: { search_term: searchTerm, search_result: searchResult.join(",") },
|
|
67
|
+
});
|
|
68
|
+
}, 500);
|
|
69
|
+
|
|
70
|
+
if (import.meta.client) {
|
|
71
|
+
onClickOutside(searchContainer, () => {
|
|
72
|
+
active.value = false;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const showSuggest = computed(() => {
|
|
77
|
+
return typingQuery.value.length >= minSearchLength && active.value;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
defineExpose({
|
|
81
|
+
showSuggest,
|
|
82
|
+
products: getProducts,
|
|
83
|
+
loading,
|
|
84
|
+
});
|
|
85
|
+
</script>
|
|
86
|
+
|
|
87
|
+
<template>
|
|
88
|
+
<UInput
|
|
89
|
+
v-model="typingQuery"
|
|
90
|
+
name="search"
|
|
91
|
+
class="w-full my-2"
|
|
92
|
+
size="xl"
|
|
93
|
+
:loading="loading"
|
|
94
|
+
placeholder="Suche..."
|
|
95
|
+
:ui="{}"
|
|
96
|
+
@input="active = true"
|
|
97
|
+
>
|
|
98
|
+
<template v-if="typingQuery?.length" #trailing>
|
|
99
|
+
<UButton
|
|
100
|
+
color="neutral"
|
|
101
|
+
variant="link"
|
|
102
|
+
size="sm"
|
|
103
|
+
icon="i-lucide-circle-x"
|
|
104
|
+
aria-label="Clear input"
|
|
105
|
+
@click="typingQuery = ''"
|
|
106
|
+
/>
|
|
107
|
+
</template>
|
|
108
|
+
</UInput>
|
|
109
|
+
</template>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ExclamationTriangleIcon } from "@heroicons/vue/24/outline";
|
|
3
|
+
|
|
4
|
+
defineProps<{
|
|
5
|
+
message?: string;
|
|
6
|
+
}>();
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<div
|
|
11
|
+
v-if="message"
|
|
12
|
+
class="bg-red-100 z-50 text-blackish flex p-8 gap-4 shadow-md rounded-md my-4"
|
|
13
|
+
>
|
|
14
|
+
<ExclamationTriangleIcon class="w-14 h-14 min-w-12" />
|
|
15
|
+
<div>{{ message }}</div>
|
|
16
|
+
</div>
|
|
17
|
+
</template>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { ButtonProps } from "#ui/components/Button.vue";
|
|
3
|
+
|
|
4
|
+
const { loadTopSellers } = useTopSellers();
|
|
5
|
+
|
|
6
|
+
const topSellers = await loadTopSellers();
|
|
7
|
+
|
|
8
|
+
const links = ref<ButtonProps[]>([
|
|
9
|
+
{
|
|
10
|
+
label: "Zur Speisekarte",
|
|
11
|
+
to: "/speisekarte",
|
|
12
|
+
color: "primary",
|
|
13
|
+
variant: "subtle",
|
|
14
|
+
trailingIcon: "i-lucide-arrow-right",
|
|
15
|
+
},
|
|
16
|
+
]);
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<div v-if="topSellers.length > 0">
|
|
21
|
+
<AnimatedSection
|
|
22
|
+
animation="fade-up"
|
|
23
|
+
duration="duration-1000"
|
|
24
|
+
delay="delay-100"
|
|
25
|
+
>
|
|
26
|
+
<UPageSection
|
|
27
|
+
headline="Bestseller"
|
|
28
|
+
title="Am meisten bestellt"
|
|
29
|
+
:links="links"
|
|
30
|
+
>
|
|
31
|
+
<UPageGrid>
|
|
32
|
+
<ProductCard
|
|
33
|
+
v-for="product in topSellers"
|
|
34
|
+
:key="product.productNumber"
|
|
35
|
+
:product="product"
|
|
36
|
+
:with-favorite-button="false"
|
|
37
|
+
:with-add-to-cart-button="false"
|
|
38
|
+
/>
|
|
39
|
+
</UPageGrid>
|
|
40
|
+
</UPageSection>
|
|
41
|
+
</AnimatedSection>
|
|
42
|
+
</div>
|
|
43
|
+
</template>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const { user, userDefaultBillingAddress, userDefaultShippingAddress } =
|
|
3
|
+
useUser();
|
|
4
|
+
|
|
5
|
+
const fullName = computed(
|
|
6
|
+
() => user.value?.firstName + " " + user.value?.lastName,
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
const areAddressesDifferent = computed(() => {
|
|
10
|
+
if (!userDefaultBillingAddress.value || !userDefaultShippingAddress.value) {
|
|
11
|
+
return false; // Or true depending on your requirements
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Deep compare the address objects
|
|
15
|
+
return (
|
|
16
|
+
JSON.stringify(userDefaultBillingAddress.value) !==
|
|
17
|
+
JSON.stringify(userDefaultShippingAddress.value)
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<template>
|
|
23
|
+
<div class="shadow-md rounded-md mb-4 p-6 bg-elevated">
|
|
24
|
+
<div>{{ fullName }}</div>
|
|
25
|
+
<div>{{ user?.email }}</div>
|
|
26
|
+
<USeparator
|
|
27
|
+
color="primary"
|
|
28
|
+
class="my-6"
|
|
29
|
+
:decorative="true"
|
|
30
|
+
:label="
|
|
31
|
+
areAddressesDifferent ? 'Lieferadresse' : 'Liefer- und Rechnungsadresse'
|
|
32
|
+
"
|
|
33
|
+
/>
|
|
34
|
+
<AddressDetail :address="userDefaultShippingAddress" />
|
|
35
|
+
<USeparator
|
|
36
|
+
v-if="areAddressesDifferent"
|
|
37
|
+
class="my-6"
|
|
38
|
+
color="primary"
|
|
39
|
+
:decorative="true"
|
|
40
|
+
label="Rechnungsadresse"
|
|
41
|
+
/>
|
|
42
|
+
<AddressDetail
|
|
43
|
+
v-if="areAddressesDifferent"
|
|
44
|
+
:address="userDefaultBillingAddress"
|
|
45
|
+
/>
|
|
46
|
+
</div>
|
|
47
|
+
</template>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { useWishlist, useUser } from "@shopware/composables";
|
|
4
|
+
import type { FormSubmitEvent } from "@nuxt/ui";
|
|
5
|
+
|
|
6
|
+
const { isLoggedIn, login, user } = useUser();
|
|
7
|
+
const { mergeWishlistProducts } = useWishlist();
|
|
8
|
+
const toast = useToast();
|
|
9
|
+
|
|
10
|
+
const props = withDefaults(
|
|
11
|
+
defineProps<{
|
|
12
|
+
title?: string;
|
|
13
|
+
icon?: string;
|
|
14
|
+
withRegisterHint?: boolean;
|
|
15
|
+
}>(),
|
|
16
|
+
{
|
|
17
|
+
title: "",
|
|
18
|
+
icon: "",
|
|
19
|
+
withRegisterHint: false,
|
|
20
|
+
},
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const emit = defineEmits<{
|
|
24
|
+
"login-success": [data: string];
|
|
25
|
+
}>();
|
|
26
|
+
|
|
27
|
+
const { title, icon } = toRefs(props);
|
|
28
|
+
|
|
29
|
+
if (isLoggedIn.value) {
|
|
30
|
+
navigateTo({ path: "/konto" });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const fields = [
|
|
34
|
+
{
|
|
35
|
+
name: "email",
|
|
36
|
+
type: "text" as const,
|
|
37
|
+
label: "Email",
|
|
38
|
+
placeholder: "Email-Adresse eingeben",
|
|
39
|
+
required: true,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "password",
|
|
43
|
+
label: "Passwort",
|
|
44
|
+
type: "password" as const,
|
|
45
|
+
placeholder: "Passwort eingeben",
|
|
46
|
+
required: true,
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const schema = z.object({
|
|
51
|
+
email: z.string().email("Email Adresse nicht gültig"),
|
|
52
|
+
password: z.string().min(8, "Mindestens 8 Zeichen"),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
type Schema = z.output<typeof schema>;
|
|
56
|
+
|
|
57
|
+
async function onSubmit(payload: FormSubmitEvent<Schema>) {
|
|
58
|
+
await login({
|
|
59
|
+
username: payload.data.email,
|
|
60
|
+
password: payload.data.password,
|
|
61
|
+
});
|
|
62
|
+
toast.add({
|
|
63
|
+
title: "Hallo " + user.value.firstName + " " + user.value.lastName + "!",
|
|
64
|
+
description: "Erfolreich angemeldet.",
|
|
65
|
+
color: "success",
|
|
66
|
+
});
|
|
67
|
+
mergeWishlistProducts();
|
|
68
|
+
emit("login-success", payload.data.email);
|
|
69
|
+
}
|
|
70
|
+
</script>
|
|
71
|
+
|
|
72
|
+
<template>
|
|
73
|
+
<UAuthForm
|
|
74
|
+
:schema="schema"
|
|
75
|
+
:title="title"
|
|
76
|
+
:icon="icon"
|
|
77
|
+
:fields="fields"
|
|
78
|
+
:submit="{
|
|
79
|
+
label: 'Anmelden',
|
|
80
|
+
}"
|
|
81
|
+
@submit="onSubmit"
|
|
82
|
+
>
|
|
83
|
+
<template v-if="withRegisterHint" #description>
|
|
84
|
+
Noch kein Konto erstellt?
|
|
85
|
+
<ULink to="registrierung" class="text-primary font-medium"
|
|
86
|
+
>Jetzt erstellen</ULink
|
|
87
|
+
>.
|
|
88
|
+
</template>
|
|
89
|
+
<template #password-hint>
|
|
90
|
+
<ULink
|
|
91
|
+
to="passwort-vergessen"
|
|
92
|
+
class="text-primary font-medium"
|
|
93
|
+
tabindex="-1"
|
|
94
|
+
>Passwort vergessen?</ULink
|
|
95
|
+
>
|
|
96
|
+
</template>
|
|
97
|
+
<template #footer>
|
|
98
|
+
Bei Anmeldung stimmst du unseren
|
|
99
|
+
<ULink to="datenschutz" class="text-primary font-medium"
|
|
100
|
+
>Datenschutzbestimmungen</ULink
|
|
101
|
+
>
|
|
102
|
+
zu.
|
|
103
|
+
</template>
|
|
104
|
+
</UAuthForm>
|
|
105
|
+
</template>
|