@shopbite-de/storefront 1.1.1 → 1.2.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.
- package/app/components/Cart/Item.vue +3 -1
- package/app/components/Category/Listing.vue +58 -57
- package/app/components/Cta.vue +10 -16
- package/app/components/Product/Card.vue +1 -1
- package/app/components/Product/Configurator2.vue +65 -0
- package/app/components/Product/CrossSelling.vue +18 -17
- package/app/components/Product/Detail2.vue +100 -0
- package/app/components/User/RegistrationForm.vue +0 -1
- package/app/composables/useAddToCart.ts +174 -0
- package/app/composables/useProductConfigurator.ts +107 -0
- package/app/composables/useProductVariantsZwei.ts +62 -0
- package/app/error.vue +7 -1
- package/app/pages/index.vue +6 -1
- package/app/pages/merkliste.vue +7 -1
- package/content/index.yml +21 -9
- package/content/unternehmen/impressum.md +10 -14
- package/content.config.ts +5 -0
- package/nuxt.config.ts +2 -2
- package/package.json +1 -1
|
@@ -60,7 +60,9 @@ const handleRemoveItem = () => {
|
|
|
60
60
|
</h3>
|
|
61
61
|
|
|
62
62
|
<p
|
|
63
|
-
v-for="option in cartItem?.
|
|
63
|
+
v-for="option in cartItem?.type === 'container'
|
|
64
|
+
? cartItem?.children?.[0]?.payload?.options
|
|
65
|
+
: cartItem?.payload?.options"
|
|
64
66
|
:key="option.group + option.option"
|
|
65
67
|
class="text-sm text-pretty text-toned mt-1"
|
|
66
68
|
>
|
|
@@ -7,63 +7,6 @@ const props = defineProps<{
|
|
|
7
7
|
|
|
8
8
|
const { id: categoryId } = toRefs(props);
|
|
9
9
|
|
|
10
|
-
const {
|
|
11
|
-
resetFilters,
|
|
12
|
-
loading,
|
|
13
|
-
search,
|
|
14
|
-
getElements,
|
|
15
|
-
getCurrentSortingOrder,
|
|
16
|
-
getSortingOrders,
|
|
17
|
-
changeCurrentSortingOrder,
|
|
18
|
-
getAvailableFilters,
|
|
19
|
-
getCurrentFilters,
|
|
20
|
-
setCurrentFilters,
|
|
21
|
-
} = useListing({
|
|
22
|
-
listingType: "categoryListing",
|
|
23
|
-
categoryId: props.id,
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
const { search: categorySearch } = useCategorySearch();
|
|
27
|
-
|
|
28
|
-
const { data: category } = await useAsyncData(
|
|
29
|
-
`category${categoryId.value}`,
|
|
30
|
-
async () => {
|
|
31
|
-
return await categorySearch(categoryId.value);
|
|
32
|
-
},
|
|
33
|
-
);
|
|
34
|
-
|
|
35
|
-
const pageTitle = computed(
|
|
36
|
-
() =>
|
|
37
|
-
`${category.value?.translated.name ?? category.value?.name} | Speisekarte`,
|
|
38
|
-
);
|
|
39
|
-
|
|
40
|
-
useSeoMeta({
|
|
41
|
-
title: pageTitle,
|
|
42
|
-
robots: "index,follow",
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
const currentSorting = ref(getCurrentSortingOrder.value ?? "Sortieren");
|
|
46
|
-
|
|
47
|
-
const propertyFilters = computed<Schemas["PropertyGroup"][]>(() =>
|
|
48
|
-
getAvailableFilters.value?.filter(
|
|
49
|
-
(availableFilter) => availableFilter.code === "properties",
|
|
50
|
-
),
|
|
51
|
-
);
|
|
52
|
-
|
|
53
|
-
const selectedPropertyFilters = ref(getCurrentFilters.value?.properties ?? []);
|
|
54
|
-
const selectedPropertyFiltersString = computed(() =>
|
|
55
|
-
selectedPropertyFilters.value?.join("|"),
|
|
56
|
-
);
|
|
57
|
-
|
|
58
|
-
const selectedListingFilters = computed<ShortcutFilterParam[]>(() => {
|
|
59
|
-
return [
|
|
60
|
-
{
|
|
61
|
-
code: "properties",
|
|
62
|
-
value: selectedPropertyFiltersString.value,
|
|
63
|
-
},
|
|
64
|
-
];
|
|
65
|
-
});
|
|
66
|
-
|
|
67
10
|
const query = {
|
|
68
11
|
includes: {
|
|
69
12
|
product: [
|
|
@@ -139,6 +82,64 @@ const query = {
|
|
|
139
82
|
},
|
|
140
83
|
} as operations["searchPage post /search"]["body"];
|
|
141
84
|
|
|
85
|
+
const {
|
|
86
|
+
resetFilters,
|
|
87
|
+
loading,
|
|
88
|
+
search,
|
|
89
|
+
getElements,
|
|
90
|
+
getCurrentSortingOrder,
|
|
91
|
+
getSortingOrders,
|
|
92
|
+
changeCurrentSortingOrder,
|
|
93
|
+
getAvailableFilters,
|
|
94
|
+
getCurrentFilters,
|
|
95
|
+
setCurrentFilters,
|
|
96
|
+
} = useListing({
|
|
97
|
+
listingType: "categoryListing",
|
|
98
|
+
categoryId: props.id,
|
|
99
|
+
defaultSearchCriteria: query,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const { search: categorySearch } = useCategorySearch();
|
|
103
|
+
|
|
104
|
+
const { data: category } = await useAsyncData(
|
|
105
|
+
`category${categoryId.value}`,
|
|
106
|
+
async () => {
|
|
107
|
+
return await categorySearch(categoryId.value);
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const pageTitle = computed(
|
|
112
|
+
() =>
|
|
113
|
+
`${category.value?.translated.name ?? category.value?.name} | Speisekarte`,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
useSeoMeta({
|
|
117
|
+
title: pageTitle,
|
|
118
|
+
robots: "index,follow",
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const currentSorting = ref(getCurrentSortingOrder.value ?? "Sortieren");
|
|
122
|
+
|
|
123
|
+
const propertyFilters = computed<Schemas["PropertyGroup"][]>(() =>
|
|
124
|
+
getAvailableFilters.value?.filter(
|
|
125
|
+
(availableFilter) => availableFilter.code === "properties",
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const selectedPropertyFilters = ref(getCurrentFilters.value?.properties ?? []);
|
|
130
|
+
const selectedPropertyFiltersString = computed(() =>
|
|
131
|
+
selectedPropertyFilters.value?.join("|"),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const selectedListingFilters = computed<ShortcutFilterParam[]>(() => {
|
|
135
|
+
return [
|
|
136
|
+
{
|
|
137
|
+
code: "properties",
|
|
138
|
+
value: selectedPropertyFiltersString.value,
|
|
139
|
+
},
|
|
140
|
+
];
|
|
141
|
+
});
|
|
142
|
+
|
|
142
143
|
await useAsyncData(`listing${categoryId.value}`, async () => {
|
|
143
144
|
await search(query);
|
|
144
145
|
});
|
package/app/components/Cta.vue
CHANGED
|
@@ -1,32 +1,26 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { ButtonProps } from "#ui/components/Button.vue";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
{
|
|
11
|
-
label: "Tisch reservieren",
|
|
12
|
-
variant: "outline",
|
|
13
|
-
trailingIcon: "i-lucide-phone",
|
|
14
|
-
to: "tel:+490610471427",
|
|
15
|
-
},
|
|
16
|
-
]);
|
|
4
|
+
defineProps<{
|
|
5
|
+
title: string;
|
|
6
|
+
description?: string | undefined;
|
|
7
|
+
backgroundImage?: string | undefined;
|
|
8
|
+
links?: ButtonProps[] | undefined;
|
|
9
|
+
}>();
|
|
17
10
|
</script>
|
|
18
11
|
|
|
19
12
|
<template>
|
|
20
13
|
<div class="relative overflow-hidden">
|
|
21
14
|
<NuxtImg
|
|
22
|
-
|
|
15
|
+
v-if="backgroundImage"
|
|
16
|
+
:src="backgroundImage"
|
|
23
17
|
class="absolute inset-0 z-0 w-full h-full object-cover opacity-40"
|
|
24
18
|
alt="CTA Background"
|
|
25
19
|
/>
|
|
26
20
|
<UPageCTA
|
|
27
21
|
variant="soft"
|
|
28
|
-
title="
|
|
29
|
-
description="
|
|
22
|
+
:title="title"
|
|
23
|
+
:description="description"
|
|
30
24
|
:links="links"
|
|
31
25
|
class="relative z-10"
|
|
32
26
|
/>
|
|
@@ -123,7 +123,7 @@ const mainIngredients = computed<Schemas["PropertyGroupOption"][]>(() => {
|
|
|
123
123
|
</div>
|
|
124
124
|
<UCollapsible v-model:open="openDetails" class="flex flex-col gap-2">
|
|
125
125
|
<template #content>
|
|
126
|
-
<
|
|
126
|
+
<ProductDetail2 :product="product" @product-added="toggleDetails" />
|
|
127
127
|
</template>
|
|
128
128
|
</UCollapsible>
|
|
129
129
|
</template>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { Schemas } from "#shopware";
|
|
3
|
+
import { useProductConfigurator } from "~/composables/useProductConfigurator";
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{
|
|
6
|
+
p: Schemas["Product"];
|
|
7
|
+
c: Schemas["PropertyGroup"][];
|
|
8
|
+
}>();
|
|
9
|
+
|
|
10
|
+
const { p, c } = toRefs(props);
|
|
11
|
+
const { product, changeVariant, configurator } = useProduct(p, c);
|
|
12
|
+
const { findVariantForSelectedOptions } = useProductConfigurator();
|
|
13
|
+
const { variants: selectableOptions } = useProductVariantsZwei(configurator);
|
|
14
|
+
|
|
15
|
+
const selectedOptions = ref<Record<string, string>>({});
|
|
16
|
+
|
|
17
|
+
function initialOptions(variant: Ref<Schemas["Product"]>) {
|
|
18
|
+
const options = variant.value.options as Schemas["PropertyGroupOption"][];
|
|
19
|
+
for (const option of options) {
|
|
20
|
+
if (option.group && option.id) {
|
|
21
|
+
selectedOptions.value[option.group.id] = option.id;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
onMounted(() => {
|
|
26
|
+
initialOptions(product);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
watch(
|
|
30
|
+
selectedOptions.value,
|
|
31
|
+
async () => {
|
|
32
|
+
const foundVariant = await findVariantForSelectedOptions(
|
|
33
|
+
selectedOptions.value,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
if (foundVariant) {
|
|
37
|
+
changeVariant(foundVariant);
|
|
38
|
+
emit("variant-switched", foundVariant);
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
{ deep: true },
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const emit = defineEmits<{
|
|
45
|
+
"variant-switched": [variant: Schemas["Product"]];
|
|
46
|
+
}>();
|
|
47
|
+
</script>
|
|
48
|
+
<template>
|
|
49
|
+
<div
|
|
50
|
+
v-for="(variantGroup, propertyGroupId) in selectableOptions"
|
|
51
|
+
:key="propertyGroupId"
|
|
52
|
+
class="my-6"
|
|
53
|
+
>
|
|
54
|
+
<div class="flex flex-row gap-2 items-center">
|
|
55
|
+
<div class="basis-1/3">{{ variantGroup.name }}:</div>
|
|
56
|
+
<USelect
|
|
57
|
+
v-model="selectedOptions[propertyGroupId]"
|
|
58
|
+
value-key="productId"
|
|
59
|
+
:items="variantGroup.options"
|
|
60
|
+
class="w-full"
|
|
61
|
+
icon="i-lucide-square-stack"
|
|
62
|
+
/>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</template>
|
|
@@ -1,31 +1,22 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { onMounted, watch, computed, ref, toRefs } from "vue";
|
|
3
2
|
import type { Schemas } from "#shopware";
|
|
4
3
|
import type {
|
|
5
4
|
AssociationItemProduct,
|
|
6
5
|
AssociationItem,
|
|
7
6
|
} from "~/types/Association";
|
|
7
|
+
import { useShopwareContext } from "#imports";
|
|
8
8
|
|
|
9
9
|
const DEFAULT_CURRENCY = "EUR";
|
|
10
10
|
const DEFAULT_LOCALE = "de-DE";
|
|
11
|
-
const ASSOCIATION_CONTEXT = "cross-selling";
|
|
12
11
|
const LOADING_ICON = "i-lucide-loader";
|
|
13
12
|
const DEFAULT_ICON = "i-lucide-plus";
|
|
14
|
-
const LOAD_ASSOCIATIONS_METHOD = "post";
|
|
15
13
|
|
|
16
14
|
const props = defineProps<{
|
|
17
15
|
product: Schemas["Product"];
|
|
18
16
|
}>();
|
|
19
|
-
const { product } = toRefs(props);
|
|
20
|
-
const selectedExtras = ref<AssociationItemProduct[]>([]);
|
|
21
17
|
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
isLoading: isAssociationsLoading,
|
|
25
|
-
productAssociations,
|
|
26
|
-
} = useProductAssociations(product, {
|
|
27
|
-
associationContext: ASSOCIATION_CONTEXT,
|
|
28
|
-
});
|
|
18
|
+
const product = computed(() => props.product);
|
|
19
|
+
const selectedExtras = ref<AssociationItemProduct[]>([]);
|
|
29
20
|
|
|
30
21
|
const { getFormattedPrice } = usePrice({
|
|
31
22
|
currencyCode: DEFAULT_CURRENCY,
|
|
@@ -48,14 +39,24 @@ function mapAssociationToItems(
|
|
|
48
39
|
}));
|
|
49
40
|
}
|
|
50
41
|
|
|
42
|
+
const { apiClient } = useShopwareContext();
|
|
43
|
+
|
|
44
|
+
const { data: productAssociations, pending: isAssociationsLoading } =
|
|
45
|
+
useAsyncData(`cross-selling-${product.value.id}`, async () => {
|
|
46
|
+
const response = await apiClient.invoke(
|
|
47
|
+
"readProductCrossSellings post /product/{productId}/cross-selling",
|
|
48
|
+
{
|
|
49
|
+
pathParams: { productId: product.value.id },
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return response.data;
|
|
54
|
+
});
|
|
55
|
+
|
|
51
56
|
const associationItems = computed(() =>
|
|
52
|
-
mapAssociationToItems(productAssociations.value),
|
|
57
|
+
mapAssociationToItems(productAssociations.value ?? []),
|
|
53
58
|
);
|
|
54
59
|
|
|
55
|
-
onMounted(() => {
|
|
56
|
-
loadAssociations({ method: LOAD_ASSOCIATIONS_METHOD, searchParams: {} });
|
|
57
|
-
});
|
|
58
|
-
|
|
59
60
|
watch(selectedExtras, () => emit("extras-selected", selectedExtras.value), {
|
|
60
61
|
deep: true,
|
|
61
62
|
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { Schemas } from "#shopware";
|
|
3
|
+
import type { AssociationItemProduct } from "~/types/Association";
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{
|
|
6
|
+
product: Schemas["Product"];
|
|
7
|
+
}>();
|
|
8
|
+
|
|
9
|
+
const { apiClient } = useShopwareContext();
|
|
10
|
+
|
|
11
|
+
const productId = toRef(props.product.id);
|
|
12
|
+
const { data: productDetails, pending } = useAsyncData(
|
|
13
|
+
() => `product-${productId.value ?? "none"}`,
|
|
14
|
+
async () => {
|
|
15
|
+
if (!productId.value) return null;
|
|
16
|
+
const response = await apiClient.invoke(
|
|
17
|
+
"readProductDetail post /product/{productId}",
|
|
18
|
+
{
|
|
19
|
+
pathParams: { productId: productId.value },
|
|
20
|
+
},
|
|
21
|
+
);
|
|
22
|
+
return response.data;
|
|
23
|
+
},
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const {
|
|
27
|
+
selectedProduct,
|
|
28
|
+
selectedQuantity,
|
|
29
|
+
addToCart,
|
|
30
|
+
setSelectedProduct,
|
|
31
|
+
setSelectedExtras,
|
|
32
|
+
setDeselectedIngredients,
|
|
33
|
+
} = useAddToCart();
|
|
34
|
+
|
|
35
|
+
// Initialize when productDetails is loaded
|
|
36
|
+
watch(
|
|
37
|
+
() => productDetails.value?.product,
|
|
38
|
+
(product) => {
|
|
39
|
+
if (product) {
|
|
40
|
+
setSelectedProduct(product);
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
{ immediate: true },
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const onVariantSwitched = (variant: Schemas["Product"]) => {
|
|
47
|
+
setSelectedProduct(variant);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const onExtrasSelected = (extras: AssociationItemProduct[]) => {
|
|
51
|
+
setSelectedExtras(extras);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const onIngredientsDeselected = (deselected: string[]) => {
|
|
55
|
+
setDeselectedIngredients(deselected);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const onAddToCart = () => emit("product-added");
|
|
59
|
+
|
|
60
|
+
const emit = defineEmits(["product-added"]);
|
|
61
|
+
</script>
|
|
62
|
+
|
|
63
|
+
<template>
|
|
64
|
+
<div v-if="!pending">
|
|
65
|
+
<div v-if="productDetails?.configurator">
|
|
66
|
+
<ProductConfigurator2
|
|
67
|
+
v-if="productDetails?.configurator"
|
|
68
|
+
:p="product"
|
|
69
|
+
:c="productDetails.configurator"
|
|
70
|
+
@variant-switched="onVariantSwitched"
|
|
71
|
+
/>
|
|
72
|
+
<ProductCrossSelling
|
|
73
|
+
v-if="selectedProduct"
|
|
74
|
+
:product="selectedProduct"
|
|
75
|
+
@extras-selected="onExtrasSelected"
|
|
76
|
+
/>
|
|
77
|
+
<ProductDeselectIngredient
|
|
78
|
+
v-if="selectedProduct"
|
|
79
|
+
:product="selectedProduct"
|
|
80
|
+
@ingredients-deselected="onIngredientsDeselected"
|
|
81
|
+
/>
|
|
82
|
+
</div>
|
|
83
|
+
<div class="flex flex-row gap-4 mt-8">
|
|
84
|
+
<UInputNumber
|
|
85
|
+
v-model="selectedQuantity"
|
|
86
|
+
size="xl"
|
|
87
|
+
placeholder="Anzahl"
|
|
88
|
+
:min="1"
|
|
89
|
+
:max="100"
|
|
90
|
+
/>
|
|
91
|
+
<UButton
|
|
92
|
+
size="xl"
|
|
93
|
+
label="In den Warenkorb"
|
|
94
|
+
icon="i-lucide-shopping-cart"
|
|
95
|
+
block
|
|
96
|
+
@click="addToCart(onAddToCart)"
|
|
97
|
+
/>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</template>
|
|
@@ -61,7 +61,6 @@ const toast = useToast();
|
|
|
61
61
|
async function onSubmit(event: FormSubmitEvent<RegistrationSchema>) {
|
|
62
62
|
const registrationData = { ...event.data };
|
|
63
63
|
|
|
64
|
-
console.log(registrationData);
|
|
65
64
|
if (
|
|
66
65
|
!registrationData.billingAddress.firstName &&
|
|
67
66
|
registrationData.firstName
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { Schemas, operations } from "#shopware";
|
|
2
|
+
import type { AssociationItemProduct } from "~/types/Association";
|
|
3
|
+
import { v5 as uuidv5 } from "uuid";
|
|
4
|
+
import { computed, ref } from "vue";
|
|
5
|
+
|
|
6
|
+
const UUID_NAMESPACE = "b098ef7e-0fa2-4073-b002-7ceec4360fbf";
|
|
7
|
+
const CART_SUCCESS_TITLE = "Gute Wahl!";
|
|
8
|
+
const LINE_ITEM_PRODUCT = "product";
|
|
9
|
+
const LINE_ITEM_CONTAINER = "container";
|
|
10
|
+
|
|
11
|
+
export function useAddToCart() {
|
|
12
|
+
const { addProducts, refreshCart } = useCart();
|
|
13
|
+
const toast = useToast();
|
|
14
|
+
const { triggerProductAdded } = useProductEvents();
|
|
15
|
+
|
|
16
|
+
const selectedExtras = ref<AssociationItemProduct[]>([]);
|
|
17
|
+
const deselectedIngredients = ref<string[]>([]);
|
|
18
|
+
const selectedQuantity = ref(1);
|
|
19
|
+
const selectedProduct = ref<Schemas["Product"] | null>(null);
|
|
20
|
+
|
|
21
|
+
const cartItemLabel = computed(() => {
|
|
22
|
+
if (!selectedProduct.value) return "";
|
|
23
|
+
|
|
24
|
+
const formatIngredientModifications = (
|
|
25
|
+
items: Array<{ label: string } | string>,
|
|
26
|
+
prefix: string,
|
|
27
|
+
): string => {
|
|
28
|
+
if (!items.length) return "";
|
|
29
|
+
|
|
30
|
+
const labels = items.map((item) =>
|
|
31
|
+
typeof item === "string" ? item : item.label,
|
|
32
|
+
);
|
|
33
|
+
const separator = " " + prefix;
|
|
34
|
+
return " " + prefix + labels.join(separator);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const extrasFormatted = formatIngredientModifications(
|
|
38
|
+
selectedExtras.value,
|
|
39
|
+
"+",
|
|
40
|
+
);
|
|
41
|
+
const removedFormatted = formatIngredientModifications(
|
|
42
|
+
deselectedIngredients.value,
|
|
43
|
+
"-",
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
console.log(
|
|
47
|
+
selectedProduct.value.translated.name,
|
|
48
|
+
extrasFormatted,
|
|
49
|
+
removedFormatted,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
return `${selectedProduct.value.translated.name}${extrasFormatted}${removedFormatted}`;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const createExtras = () =>
|
|
56
|
+
selectedExtras.value.map((extra) => ({
|
|
57
|
+
id: extra.value,
|
|
58
|
+
type: LINE_ITEM_PRODUCT,
|
|
59
|
+
quantity: selectedQuantity.value,
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
const generateSortedExtrasString = (extras: AssociationItemProduct[]) =>
|
|
63
|
+
extras
|
|
64
|
+
.map((extra) => extra.value)
|
|
65
|
+
.sort()
|
|
66
|
+
.join("");
|
|
67
|
+
|
|
68
|
+
const generateProductId = (
|
|
69
|
+
baseId: string,
|
|
70
|
+
extras: AssociationItemProduct[],
|
|
71
|
+
) => (extras.length ? baseId + generateSortedExtrasString(extras) : baseId);
|
|
72
|
+
|
|
73
|
+
function createCartItems(): operations["addLineItem post /checkout/cart/line-item"]["body"]["items"] {
|
|
74
|
+
if (!selectedProduct.value) return [];
|
|
75
|
+
|
|
76
|
+
const extras = createExtras();
|
|
77
|
+
|
|
78
|
+
// Simple product when no extras
|
|
79
|
+
if (extras.length === 0 && deselectedIngredients.value.length === 0) {
|
|
80
|
+
return [
|
|
81
|
+
{
|
|
82
|
+
id: selectedProduct.value.id,
|
|
83
|
+
quantity: selectedQuantity.value,
|
|
84
|
+
type: LINE_ITEM_PRODUCT,
|
|
85
|
+
},
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Container product when extras are selected
|
|
90
|
+
const generatedUuid = uuidv5(
|
|
91
|
+
generateProductId(selectedProduct.value.id, selectedExtras.value),
|
|
92
|
+
UUID_NAMESPACE,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
return [
|
|
96
|
+
{
|
|
97
|
+
id: generatedUuid,
|
|
98
|
+
quantity: selectedQuantity.value,
|
|
99
|
+
type: LINE_ITEM_CONTAINER,
|
|
100
|
+
label: cartItemLabel.value,
|
|
101
|
+
payload: {
|
|
102
|
+
productNumber: selectedProduct.value.productNumber,
|
|
103
|
+
},
|
|
104
|
+
children: [
|
|
105
|
+
{
|
|
106
|
+
id: selectedProduct.value.id,
|
|
107
|
+
quantity: selectedQuantity.value,
|
|
108
|
+
type: LINE_ITEM_PRODUCT,
|
|
109
|
+
},
|
|
110
|
+
...extras,
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function showSuccessToast() {
|
|
117
|
+
if (!selectedProduct.value) return;
|
|
118
|
+
|
|
119
|
+
toast.add({
|
|
120
|
+
title: CART_SUCCESS_TITLE,
|
|
121
|
+
description: `${selectedProduct.value.translated.name} wurde in den Warenkorb gelegt.`,
|
|
122
|
+
icon: "i-lucide-shopping-cart",
|
|
123
|
+
color: "primary",
|
|
124
|
+
progress: true,
|
|
125
|
+
duration: 2000,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function addToCart(onSuccess?: () => void) {
|
|
130
|
+
if (!selectedProduct.value) return;
|
|
131
|
+
|
|
132
|
+
const cartItems = createCartItems();
|
|
133
|
+
const newCart = await addProducts(cartItems);
|
|
134
|
+
await refreshCart(newCart);
|
|
135
|
+
await showSuccessToast();
|
|
136
|
+
|
|
137
|
+
triggerProductAdded();
|
|
138
|
+
|
|
139
|
+
useTrackEvent("add_to_cart", {
|
|
140
|
+
props: {
|
|
141
|
+
product_number: selectedProduct.value.productNumber,
|
|
142
|
+
quantity: selectedQuantity.value,
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
if (onSuccess) {
|
|
147
|
+
onSuccess();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function setSelectedProduct(product: Schemas["Product"]) {
|
|
152
|
+
selectedProduct.value = product;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function setSelectedExtras(extras: AssociationItemProduct[]) {
|
|
156
|
+
selectedExtras.value = extras;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function setDeselectedIngredients(ingredients: string[]) {
|
|
160
|
+
deselectedIngredients.value = ingredients;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
selectedProduct,
|
|
165
|
+
selectedExtras,
|
|
166
|
+
deselectedIngredients,
|
|
167
|
+
selectedQuantity,
|
|
168
|
+
cartItemLabel,
|
|
169
|
+
addToCart,
|
|
170
|
+
setSelectedProduct,
|
|
171
|
+
setSelectedExtras,
|
|
172
|
+
setDeselectedIngredients,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { computed, ref } from "vue";
|
|
2
|
+
import type { Schemas } from "#shopware";
|
|
3
|
+
import {
|
|
4
|
+
useShopwareContext,
|
|
5
|
+
useProductConfigurator as useProductConfiguratorOriginal,
|
|
6
|
+
useProduct,
|
|
7
|
+
} from "@shopware/composables";
|
|
8
|
+
import { getTranslatedProperty } from "@shopware/helpers";
|
|
9
|
+
|
|
10
|
+
export function useProductConfigurator() {
|
|
11
|
+
const { apiClient } = useShopwareContext();
|
|
12
|
+
|
|
13
|
+
const original = useProductConfiguratorOriginal();
|
|
14
|
+
|
|
15
|
+
const { configurator, product } = useProduct();
|
|
16
|
+
|
|
17
|
+
const selected = ref<{
|
|
18
|
+
[key: string]: string;
|
|
19
|
+
}>({});
|
|
20
|
+
const isLoadingOptions = ref(!!product.value.options?.length);
|
|
21
|
+
const parentProductId = computed(() => product.value?.parentId);
|
|
22
|
+
const getOptionGroups = computed<Schemas["PropertyGroup"][]>(() => {
|
|
23
|
+
return configurator.value || [];
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const findGroupCodeForOption = (optionId: string) => {
|
|
27
|
+
const group = getOptionGroups.value.find((optionGroup) => {
|
|
28
|
+
const optionFound = optionGroup.options?.find((option) => {
|
|
29
|
+
return option.id === optionId;
|
|
30
|
+
});
|
|
31
|
+
return !!optionFound;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return getTranslatedProperty(group, "name");
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// create a group -> optionId map
|
|
38
|
+
for (const optionId of product.value.optionIds || []) {
|
|
39
|
+
const optionGroupCode = findGroupCodeForOption(optionId);
|
|
40
|
+
if (optionGroupCode) {
|
|
41
|
+
selected.value[optionGroupCode] = optionId;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function findVariantForSelectedOptions(options?: {
|
|
46
|
+
[code: string]: string;
|
|
47
|
+
}): Promise<Schemas["Product"] | undefined> {
|
|
48
|
+
const filter: Schemas["Filters"] = [
|
|
49
|
+
{
|
|
50
|
+
type: "equals",
|
|
51
|
+
field: "parentId",
|
|
52
|
+
value: parentProductId.value as string,
|
|
53
|
+
},
|
|
54
|
+
...Object.values(options || selected.value).map(
|
|
55
|
+
(id) =>
|
|
56
|
+
({
|
|
57
|
+
type: "equals",
|
|
58
|
+
field: "optionIds",
|
|
59
|
+
value: id,
|
|
60
|
+
}) as Schemas["EqualsFilter"],
|
|
61
|
+
),
|
|
62
|
+
];
|
|
63
|
+
try {
|
|
64
|
+
const response = await apiClient.invoke("readProduct post /product", {
|
|
65
|
+
body: {
|
|
66
|
+
filter,
|
|
67
|
+
limit: 1,
|
|
68
|
+
includes: {
|
|
69
|
+
product: [
|
|
70
|
+
"id",
|
|
71
|
+
"name",
|
|
72
|
+
"translated",
|
|
73
|
+
"productNumber",
|
|
74
|
+
"options",
|
|
75
|
+
"properties",
|
|
76
|
+
],
|
|
77
|
+
product_option: ["id", "groupId", "name", "translated", "group"],
|
|
78
|
+
property: ["id", "name", "translated", "options"],
|
|
79
|
+
property_group_option: ["id", "name", "translated", "group"],
|
|
80
|
+
},
|
|
81
|
+
associations: {
|
|
82
|
+
options: {
|
|
83
|
+
associations: {
|
|
84
|
+
group: {},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
properties: {
|
|
88
|
+
associations: {
|
|
89
|
+
group: {},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
return response.data.elements?.[0]; // return first matching product
|
|
96
|
+
} catch (e) {
|
|
97
|
+
console.error("SwProductDetails:findVariantForSelectedOptions", e);
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
...original,
|
|
104
|
+
findVariantForSelectedOptions,
|
|
105
|
+
isLoadingOptions,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { computed, type ComputedRef, type Ref } from "vue";
|
|
2
|
+
import type { Schemas } from "#shopware";
|
|
3
|
+
|
|
4
|
+
// Define the SelectItem interface to match what USelect expects
|
|
5
|
+
interface SelectItem {
|
|
6
|
+
label: string;
|
|
7
|
+
value: string;
|
|
8
|
+
productId: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type VariantOptions = {
|
|
12
|
+
name: string;
|
|
13
|
+
options: SelectItem[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type useProductVariantsReturn = {
|
|
17
|
+
variants: ComputedRef<Record<string, VariantOptions>>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Accept configuratorSettings as a Ref of PropertyGroup[]
|
|
21
|
+
export function useProductVariantsZwei(
|
|
22
|
+
configuratorSettings: Ref<Schemas["PropertyGroup"][]>,
|
|
23
|
+
): useProductVariantsReturn {
|
|
24
|
+
const variants = computed(() => {
|
|
25
|
+
const groups = configuratorSettings.value || [];
|
|
26
|
+
const result: Record<string, VariantOptions> = {};
|
|
27
|
+
|
|
28
|
+
for (const group of groups) {
|
|
29
|
+
const groupId = group.id;
|
|
30
|
+
const groupName = group.translated?.name || group.name;
|
|
31
|
+
const options = group.options || [];
|
|
32
|
+
|
|
33
|
+
if (!groupId || !groupName) continue;
|
|
34
|
+
|
|
35
|
+
result[groupId] ??= {
|
|
36
|
+
name: groupName,
|
|
37
|
+
options: [],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
for (const opt of options) {
|
|
41
|
+
const optionId = opt.id;
|
|
42
|
+
const optionName = opt.translated?.name || opt.name;
|
|
43
|
+
if (!optionId || !optionName) continue;
|
|
44
|
+
|
|
45
|
+
const exists = result[groupId].options.find(
|
|
46
|
+
(o) => o.value === optionId,
|
|
47
|
+
);
|
|
48
|
+
if (!exists) {
|
|
49
|
+
result[groupId].options.push({
|
|
50
|
+
label: optionName,
|
|
51
|
+
value: optionId,
|
|
52
|
+
productId: optionId,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return result;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return { variants };
|
|
62
|
+
}
|
package/app/error.vue
CHANGED
|
@@ -5,12 +5,18 @@ const props = defineProps<{
|
|
|
5
5
|
error: NuxtError;
|
|
6
6
|
}>();
|
|
7
7
|
|
|
8
|
+
const {
|
|
9
|
+
public: { site },
|
|
10
|
+
} = useRuntimeConfig();
|
|
11
|
+
|
|
12
|
+
const pageTitle = computed(() => `${statusCode} | ${site?.name}`);
|
|
13
|
+
|
|
8
14
|
console.error(props.error);
|
|
9
15
|
|
|
10
16
|
const statusCode = props.error.statusCode;
|
|
11
17
|
|
|
12
18
|
useSeoMeta({
|
|
13
|
-
title:
|
|
19
|
+
title: pageTitle,
|
|
14
20
|
});
|
|
15
21
|
</script>
|
|
16
22
|
|
package/app/pages/index.vue
CHANGED
|
@@ -54,6 +54,11 @@ useSeoMeta({
|
|
|
54
54
|
:images="page.gallery.images"
|
|
55
55
|
:links="page.gallery.links"
|
|
56
56
|
/>
|
|
57
|
-
<Cta
|
|
57
|
+
<Cta
|
|
58
|
+
:title="page.cta.title"
|
|
59
|
+
:description="page.cta.description"
|
|
60
|
+
:background-image="page.cta.backgroundImage"
|
|
61
|
+
:links="page.cta.links"
|
|
62
|
+
/>
|
|
58
63
|
</div>
|
|
59
64
|
</template>
|
package/app/pages/merkliste.vue
CHANGED
package/content/index.yml
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
seo:
|
|
2
2
|
title: ShopBite – Kostenloses Online-Bestellsystem für Gastronomie
|
|
3
3
|
description: Dein eigenes Bestellsystem ohne Provisionen, ohne monatliche Kosten – 100% Open Source und individuell anpassbar. Perfekt für Pizzerien, Imbisse und Lieferdienste.
|
|
4
|
-
title:
|
|
5
|
-
description:
|
|
4
|
+
title: ShopBite
|
|
5
|
+
description: Reduziere Kosten und steigere deinen Umsatz
|
|
6
6
|
hero:
|
|
7
7
|
backgroundVideo: https://shopware.shopbite.de/media/10/59/96/1762465181/background.mp4
|
|
8
|
-
headline:
|
|
8
|
+
headline: SHOPBITE
|
|
9
9
|
usps:
|
|
10
10
|
- title: 4.5 ⭐
|
|
11
11
|
subtitle: 720+ Reviews
|
|
12
12
|
icon: i-simple-icons-google
|
|
13
|
-
link: https://www.google.com/maps/place/?q=place_id
|
|
14
|
-
- title:
|
|
13
|
+
link: https://www.google.com/maps/place/?q=place_id
|
|
14
|
+
- title: 20 Jahre
|
|
15
15
|
subtitle: in Obertshausen
|
|
16
16
|
icon: i-lucide-award
|
|
17
17
|
- title: Lieferservice
|
|
@@ -29,12 +29,12 @@ hero:
|
|
|
29
29
|
size: xl
|
|
30
30
|
color: neutral
|
|
31
31
|
variant: outline
|
|
32
|
-
to: 'tel:+
|
|
32
|
+
to: 'tel:+490610477777'
|
|
33
33
|
target: _blank
|
|
34
34
|
features:
|
|
35
35
|
title: Alle [Informationen]{.text-primary} auf einen Blick
|
|
36
36
|
description: Sie können Speisen und Getränke abholen, liefern lassen, oder vor Ort in unserem Restaurant genießen.
|
|
37
|
-
headline:
|
|
37
|
+
headline: SHOPBITE
|
|
38
38
|
features:
|
|
39
39
|
- title: Öffnungszeiten
|
|
40
40
|
description: Von 11:30 - 14:30 und 17:30 - 23:00 Uhr. Samstags von 17:30 - 23:30 Uhr. Dienstag ist Ruhetag.
|
|
@@ -54,7 +54,7 @@ features:
|
|
|
54
54
|
marquee:
|
|
55
55
|
title: Das lieben unsere Kunden
|
|
56
56
|
description: Kundenbilder
|
|
57
|
-
headline:
|
|
57
|
+
headline: SHOPBITE
|
|
58
58
|
items:
|
|
59
59
|
- productId: 019a4f2e717b7df7a349761a56c43ac3
|
|
60
60
|
image: https://shopware.shopbite.de/media/e4/82/55/1762465420/72.webp
|
|
@@ -67,7 +67,7 @@ marquee:
|
|
|
67
67
|
gallery:
|
|
68
68
|
title: Restaurant
|
|
69
69
|
description: Genießen Sie leckeres Essen in einem rustikalen und gemütlichen Ambiente.
|
|
70
|
-
headline:
|
|
70
|
+
headline: SHOPBITE
|
|
71
71
|
links:
|
|
72
72
|
- label: Tisch reservieren
|
|
73
73
|
to: tel:+49610471427
|
|
@@ -89,3 +89,15 @@ gallery:
|
|
|
89
89
|
alt: La Fattoria Restaurant Innenbereich 6
|
|
90
90
|
- image: https://nbg1.your-objectstorage.com/lafattoria-public/media/30/f7/76/1763383122/restaurant10.webp
|
|
91
91
|
alt: La Fattoria Restaurant Innenbereich 10
|
|
92
|
+
cta:
|
|
93
|
+
title: Jetzt bestellen!
|
|
94
|
+
description: Genieße die italienische Küche, frisch zubereitet und direkt zu dir geliefert oder vor Ort genießen.
|
|
95
|
+
backgroundImage: https://shopware.shopbite.de/media/f5/19/4a/1762546880/category-pizza-header.webp
|
|
96
|
+
links:
|
|
97
|
+
- label: Zur Speisekarte
|
|
98
|
+
to: /speisekarte
|
|
99
|
+
color: primary
|
|
100
|
+
- label: Tisch reservieren
|
|
101
|
+
to: tel:+49610471427
|
|
102
|
+
variant: outline
|
|
103
|
+
trailingIcon: i-lucide-phone
|
|
@@ -2,25 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## Angaben gemäß § 5 TMG
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Lirim Veliu<br>
|
|
6
|
+
Hausener Straße 31<br>
|
|
7
|
+
63165 Mühlheim am Main<br>
|
|
8
|
+
Deutschland
|
|
6
9
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
**Vertreten durch:**<br>Bekim Veliu
|
|
10
|
-
|
|
11
|
-
**Kontakt:**<br>Telefon: +49 6104 71427<br>E-Mail: service@pizzeria-lafattoria.de
|
|
12
|
-
|
|
13
|
-
## Umsatzsteuer-ID
|
|
14
|
-
|
|
15
|
-
Umsatzsteuer-Identifikationsnummer gemäß § 27 a Umsatzsteuergesetz:
|
|
10
|
+
lirim@veliu.net
|
|
16
11
|
|
|
17
12
|
## Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV
|
|
18
13
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
14
|
+
Lirim Veliu<br>
|
|
15
|
+
Hausener Straße 31<br>
|
|
16
|
+
63165 Mühlheim am Main<br>
|
|
17
|
+
Deutschland
|
|
22
18
|
|
|
23
|
-
|
|
19
|
+
lirim@veliu.net
|
|
24
20
|
|
|
25
21
|
## Haftungshinweis
|
|
26
22
|
|
package/content.config.ts
CHANGED
|
@@ -10,6 +10,7 @@ const createLinkSchema = () =>
|
|
|
10
10
|
icon: z.string().optional().editor({ input: "icon" }),
|
|
11
11
|
size: createEnum(["xs", "sm", "md", "lg", "xl"]),
|
|
12
12
|
trailing: z.boolean().optional(),
|
|
13
|
+
trailingIcon: z.string().optional().editor({ input: "icon" }),
|
|
13
14
|
target: createEnum(["_blank", "_self"]),
|
|
14
15
|
color: createEnum([
|
|
15
16
|
"primary",
|
|
@@ -107,6 +108,10 @@ export default defineContentConfig({
|
|
|
107
108
|
),
|
|
108
109
|
links: z.array(createLinkSchema()),
|
|
109
110
|
}),
|
|
111
|
+
cta: createBaseSchema().extend({
|
|
112
|
+
links: z.array(createLinkSchema()),
|
|
113
|
+
backgroundImage: z.string().optional(),
|
|
114
|
+
}),
|
|
110
115
|
}),
|
|
111
116
|
}),
|
|
112
117
|
navigation: defineCollection({
|
package/nuxt.config.ts
CHANGED
|
@@ -47,8 +47,8 @@ export default defineNuxtConfig({
|
|
|
47
47
|
apiClientConfig: {},
|
|
48
48
|
public: {
|
|
49
49
|
site: {
|
|
50
|
-
name: "
|
|
51
|
-
description: "
|
|
50
|
+
name: "ShopBite",
|
|
51
|
+
description: "Reduziere deine Kosten und steigere deinen Umsatz",
|
|
52
52
|
countryId: "",
|
|
53
53
|
},
|
|
54
54
|
storeUrl: "",
|