@shopware/cms-base-layer 2.0.0 → 2.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/README.md +168 -100
- package/app/app.config.ts +11 -0
- package/app/components/SwCategoryNavigation.vue +25 -18
- package/app/components/SwFilterDropdown.vue +54 -0
- package/app/components/SwListingProductPrice.vue +2 -2
- package/app/components/SwMedia3D.vue +4 -2
- package/app/components/SwProductCard.vue +20 -21
- package/app/components/SwProductCardDetails.vue +29 -12
- package/app/components/SwProductCardImage.vue +4 -1
- package/app/components/SwProductGallery.vue +18 -14
- package/app/components/SwProductListingFilter.vue +20 -9
- package/app/components/SwProductListingFilters.vue +1 -5
- package/app/components/SwProductListingFiltersHorizontal.vue +306 -0
- package/app/components/SwProductPrice.vue +3 -3
- package/app/components/SwProductRating.vue +40 -0
- package/app/components/SwProductReviews.vue +6 -19
- package/app/components/SwProductUnits.vue +10 -15
- package/app/components/SwQuantitySelect.vue +4 -7
- package/app/components/SwSlider.vue +150 -51
- package/app/components/SwSortDropdown.vue +10 -6
- package/app/components/SwVariantConfigurator.vue +12 -11
- package/app/components/listing-filters/SwFilterPrice.vue +45 -40
- package/app/components/listing-filters/SwFilterProperties.vue +40 -33
- package/app/components/listing-filters/SwFilterRating.vue +36 -27
- package/app/components/listing-filters/SwFilterShippingFree.vue +39 -32
- package/app/components/public/cms/CmsBlockSpatialViewer.vue +94 -0
- package/app/components/public/cms/CmsGenericBlock.md +17 -2
- package/app/components/public/cms/CmsGenericBlock.vue +15 -1
- package/app/components/public/cms/CmsPage.md +19 -2
- package/app/components/public/cms/CmsPage.vue +11 -1
- package/app/components/public/cms/block/CmsBlockCenterText.vue +1 -1
- package/app/components/public/cms/block/CmsBlockImageText.vue +5 -5
- package/app/components/public/cms/block/CmsBlockTextOnImage.vue +5 -12
- package/app/components/public/cms/element/CmsElementBuyBox.vue +3 -3
- package/app/components/public/cms/element/CmsElementCrossSelling.vue +19 -3
- package/app/components/public/cms/element/CmsElementImage.vue +34 -36
- package/app/components/public/cms/element/CmsElementImageGallery.vue +117 -50
- package/app/components/public/cms/element/CmsElementProductBox.vue +7 -1
- package/app/components/public/cms/element/CmsElementProductListing.vue +10 -3
- package/app/components/public/cms/element/CmsElementProductName.vue +6 -1
- package/app/components/public/cms/element/CmsElementProductSlider.vue +56 -35
- package/app/components/public/cms/element/CmsElementSidebarFilter.vue +10 -2
- package/app/components/public/cms/element/CmsElementText.vue +10 -11
- package/app/components/public/cms/element/SwProductListingPagination.vue +2 -2
- package/app/components/public/cms/section/CmsSectionDefault.vue +2 -2
- package/app/components/public/cms/section/CmsSectionSidebar.vue +6 -3
- package/app/components/ui/BaseButton.vue +18 -15
- package/app/components/ui/ChevronIcon.vue +10 -13
- package/app/components/ui/WishlistIcon.vue +3 -8
- package/app/composables/useImagePlaceholder.ts +1 -1
- package/app/composables/useLcpImagePreload.test.ts +229 -0
- package/app/composables/useLcpImagePreload.ts +39 -0
- package/app/helpers/cms/findFirstCmsImageUrl.ts +86 -0
- package/app/helpers/cms/getImageSizes.test.ts +50 -0
- package/app/helpers/cms/getImageSizes.ts +36 -0
- package/app/helpers/html-to-vue/ast.ts +53 -19
- package/app/helpers/html-to-vue/getOptionsFromNode.ts +1 -1
- package/app/helpers/html-to-vue/renderToHtml.ts +7 -11
- package/app/helpers/html-to-vue/renderer.ts +86 -26
- package/app/plugins/unocss-runtime.client.ts +23 -0
- package/index.d.ts +24 -0
- package/nuxt.config.ts +20 -0
- package/package.json +23 -21
- package/uno.config.ts +11 -0
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import type { BoxLayout } from "@shopware/composables";
|
|
2
3
|
import type { UrlRouteOutput } from "@shopware/helpers";
|
|
4
|
+
import { computed } from "vue";
|
|
3
5
|
import type { Schemas } from "#shopware";
|
|
4
6
|
|
|
5
7
|
type Translations = {
|
|
@@ -12,7 +14,7 @@ type Translations = {
|
|
|
12
14
|
};
|
|
13
15
|
};
|
|
14
16
|
|
|
15
|
-
defineProps<{
|
|
17
|
+
const props = defineProps<{
|
|
16
18
|
product: Schemas["Product"];
|
|
17
19
|
productName: string | null;
|
|
18
20
|
productManufacturer?: string | null;
|
|
@@ -20,7 +22,10 @@ defineProps<{
|
|
|
20
22
|
fromPrice?: number;
|
|
21
23
|
addToCartProxy: () => Promise<void>;
|
|
22
24
|
productLink: UrlRouteOutput;
|
|
25
|
+
layoutType?: BoxLayout;
|
|
23
26
|
}>();
|
|
27
|
+
|
|
28
|
+
const isMinimalLayout = computed(() => props.layoutType === "minimal");
|
|
24
29
|
</script>
|
|
25
30
|
<template>
|
|
26
31
|
<div class="self-stretch p-2 flex flex-col justify-between items-start gap-4 flex-1">
|
|
@@ -33,25 +38,37 @@ defineProps<{
|
|
|
33
38
|
</div>
|
|
34
39
|
|
|
35
40
|
<RouterLink :to="productLink"
|
|
36
|
-
class="self-stretch text-surface-on-surface text-2xl font-normal font-serif leading-9 overflow-hidden line-clamp-2 break-words"
|
|
41
|
+
class="self-stretch text-surface-on-surface text-2xl font-normal font-serif leading-9 overflow-hidden line-clamp-2 break-words min-h-[4.5rem]"
|
|
37
42
|
data-testid="product-box-product-name-link">
|
|
38
43
|
{{ productName }}
|
|
39
44
|
</RouterLink>
|
|
40
45
|
</div>
|
|
41
46
|
</div>
|
|
42
47
|
|
|
43
|
-
|
|
44
|
-
|
|
48
|
+
<!-- Star rating for minimal layout -->
|
|
49
|
+
<SwProductRating
|
|
50
|
+
v-if="isMinimalLayout"
|
|
51
|
+
:rating="product?.ratingAverage ?? 0"
|
|
52
|
+
:review-count="product?.productReviews?.length ?? 0"
|
|
53
|
+
class="mt-4"
|
|
54
|
+
/>
|
|
45
55
|
|
|
46
|
-
|
|
47
|
-
data-testid="
|
|
48
|
-
|
|
49
|
-
</SwBaseButton>
|
|
56
|
+
<!-- Price for standard layout -->
|
|
57
|
+
<SwListingProductPrice v-else :product="product" data-testid="product-box-product-price" />
|
|
58
|
+
</div>
|
|
50
59
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
60
|
+
<!-- CTA buttons only for non-minimal layout -->
|
|
61
|
+
<template v-if="!isMinimalLayout">
|
|
62
|
+
<SwBaseButton variant="primary" v-if="!fromPrice" size="medium" :disabled="!product?.available" block
|
|
63
|
+
data-testid="add-to-cart-button" @click="addToCartProxy">
|
|
64
|
+
{{ translations.product.addToCart }}
|
|
54
65
|
</SwBaseButton>
|
|
55
|
-
|
|
66
|
+
|
|
67
|
+
<RouterLink v-else :to="productLink" class="self-stretch">
|
|
68
|
+
<SwBaseButton block>
|
|
69
|
+
{{ translations.product.details }}
|
|
70
|
+
</SwBaseButton>
|
|
71
|
+
</RouterLink>
|
|
72
|
+
</template>
|
|
56
73
|
</div>
|
|
57
74
|
</template>
|
|
@@ -36,7 +36,10 @@ function roundUp(num: number) {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
const coverSrcPath = computed(() => {
|
|
39
|
-
return
|
|
39
|
+
return (
|
|
40
|
+
getSmallestThumbnailUrl(props.product?.cover?.media) ||
|
|
41
|
+
props.product?.cover?.media?.url
|
|
42
|
+
);
|
|
40
43
|
});
|
|
41
44
|
|
|
42
45
|
const imageModifiers = computed(() => {
|
|
@@ -1,30 +1,34 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
CmsElementImageGallery,
|
|
4
|
+
SliderElementConfig,
|
|
5
|
+
} from "@shopware/composables";
|
|
3
6
|
import { ref, watch } from "vue";
|
|
4
7
|
import type { Schemas } from "#shopware";
|
|
5
8
|
|
|
6
|
-
const
|
|
9
|
+
const { product, config = {} } = defineProps<{
|
|
7
10
|
product: Schemas["Product"];
|
|
11
|
+
config?: Partial<SliderElementConfig>;
|
|
8
12
|
}>();
|
|
13
|
+
|
|
14
|
+
const defaultConfig: SliderElementConfig = {
|
|
15
|
+
minHeight: { value: "300px", source: "static" },
|
|
16
|
+
navigationArrows: { value: "inside", source: "static" },
|
|
17
|
+
navigationDots: { value: "inside", source: "static" },
|
|
18
|
+
};
|
|
19
|
+
|
|
9
20
|
const content = ref<CmsElementImageGallery>();
|
|
10
21
|
|
|
11
22
|
watch(
|
|
12
|
-
() =>
|
|
13
|
-
(
|
|
14
|
-
const media = value.media;
|
|
23
|
+
[() => product, () => config],
|
|
24
|
+
([currentProduct, currentConfig]) => {
|
|
15
25
|
content.value = {
|
|
16
26
|
config: {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
source: "static",
|
|
20
|
-
},
|
|
21
|
-
navigationArrows: {
|
|
22
|
-
value: "inside",
|
|
23
|
-
source: "static",
|
|
24
|
-
},
|
|
27
|
+
...defaultConfig,
|
|
28
|
+
...currentConfig,
|
|
25
29
|
},
|
|
26
30
|
data: {
|
|
27
|
-
sliderItems: media,
|
|
31
|
+
sliderItems: currentProduct.media,
|
|
28
32
|
},
|
|
29
33
|
} as CmsElementImageGallery;
|
|
30
34
|
},
|
|
@@ -6,7 +6,16 @@ import SwFilterPropertiesVue from "./listing-filters/SwFilterProperties.vue";
|
|
|
6
6
|
import SwFilterRatingVue from "./listing-filters/SwFilterRating.vue";
|
|
7
7
|
import SwFilterShippingFreeVue from "./listing-filters/SwFilterShippingFree.vue";
|
|
8
8
|
|
|
9
|
-
const
|
|
9
|
+
const {
|
|
10
|
+
filter,
|
|
11
|
+
selectedManufacturer,
|
|
12
|
+
selectedProperties,
|
|
13
|
+
selectedMinPrice,
|
|
14
|
+
selectedMaxPrice,
|
|
15
|
+
selectedRating,
|
|
16
|
+
selectedShippingFree,
|
|
17
|
+
displayMode = "accordion",
|
|
18
|
+
} = defineProps<{
|
|
10
19
|
filter: ListingFilter;
|
|
11
20
|
selectedManufacturer: Set<string>;
|
|
12
21
|
selectedProperties: Set<string>;
|
|
@@ -14,6 +23,7 @@ const props = defineProps<{
|
|
|
14
23
|
selectedMaxPrice: number | undefined;
|
|
15
24
|
selectedRating: number | undefined;
|
|
16
25
|
selectedShippingFree: boolean | undefined;
|
|
26
|
+
displayMode?: "accordion" | "dropdown";
|
|
17
27
|
}>();
|
|
18
28
|
|
|
19
29
|
const emit = defineEmits<{
|
|
@@ -22,13 +32,13 @@ const emit = defineEmits<{
|
|
|
22
32
|
|
|
23
33
|
const transformedFilters = computed(() => ({
|
|
24
34
|
price: {
|
|
25
|
-
min:
|
|
26
|
-
max:
|
|
35
|
+
min: selectedMinPrice,
|
|
36
|
+
max: selectedMaxPrice,
|
|
27
37
|
},
|
|
28
|
-
rating:
|
|
29
|
-
"shipping-free":
|
|
30
|
-
manufacturer: [...
|
|
31
|
-
properties: [...
|
|
38
|
+
rating: selectedRating,
|
|
39
|
+
"shipping-free": selectedShippingFree,
|
|
40
|
+
manufacturer: [...selectedManufacturer],
|
|
41
|
+
properties: [...selectedProperties],
|
|
32
42
|
}));
|
|
33
43
|
|
|
34
44
|
const filterComponent = computed<Component | undefined>(() => {
|
|
@@ -40,8 +50,8 @@ const filterComponent = computed<Component | undefined>(() => {
|
|
|
40
50
|
};
|
|
41
51
|
|
|
42
52
|
return (
|
|
43
|
-
componentMap[
|
|
44
|
-
("options" in
|
|
53
|
+
componentMap[filter.code] ||
|
|
54
|
+
("options" in filter ? SwFilterPropertiesVue : undefined)
|
|
45
55
|
);
|
|
46
56
|
});
|
|
47
57
|
|
|
@@ -58,6 +68,7 @@ const handleSelectValue = ({
|
|
|
58
68
|
:is="filterComponent"
|
|
59
69
|
:filter="filter"
|
|
60
70
|
:selected-filters="transformedFilters"
|
|
71
|
+
:display-mode="displayMode"
|
|
61
72
|
@select-value="handleSelectValue"
|
|
62
73
|
/>
|
|
63
74
|
</div>
|
|
@@ -12,7 +12,7 @@ import { useCategoryListing } from "#imports";
|
|
|
12
12
|
import type { Schemas, operations } from "#shopware";
|
|
13
13
|
|
|
14
14
|
const props = defineProps<{
|
|
15
|
-
content
|
|
15
|
+
content?: CmsElementProductListing | CmsElementSidebarFilter;
|
|
16
16
|
listingType?: string;
|
|
17
17
|
}>();
|
|
18
18
|
|
|
@@ -233,10 +233,6 @@ async function invokeCleanFilters() {
|
|
|
233
233
|
}
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
-
const isDefaultSidebarFilter =
|
|
237
|
-
props.content.type === "sidebar-filter" &&
|
|
238
|
-
props.content.config?.boxLayout?.value === "standard";
|
|
239
|
-
|
|
240
236
|
const handleSortChange = (sortKey: string) => {
|
|
241
237
|
currentSortingOrder.value = sortKey;
|
|
242
238
|
};
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type {
|
|
3
|
+
CmsElementProductListing,
|
|
4
|
+
CmsElementSidebarFilter,
|
|
5
|
+
} from "@shopware/composables";
|
|
6
|
+
import { useCmsTranslations } from "@shopware/composables";
|
|
7
|
+
import { defu } from "defu";
|
|
8
|
+
import { computed, reactive } from "vue";
|
|
9
|
+
import type { ComputedRef, UnwrapNestedRefs } from "vue";
|
|
10
|
+
import { type LocationQueryRaw, useRoute, useRouter } from "vue-router";
|
|
11
|
+
import { useCategoryListing } from "#imports";
|
|
12
|
+
import type { Schemas, operations } from "#shopware";
|
|
13
|
+
|
|
14
|
+
const props = defineProps<{
|
|
15
|
+
content: CmsElementProductListing | CmsElementSidebarFilter;
|
|
16
|
+
listingType?: string;
|
|
17
|
+
}>();
|
|
18
|
+
|
|
19
|
+
type Translations = {
|
|
20
|
+
listing: {
|
|
21
|
+
filters: string;
|
|
22
|
+
sort: string;
|
|
23
|
+
resetFilters: string;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type FilterState = {
|
|
28
|
+
manufacturer: Set<string>;
|
|
29
|
+
properties: Set<string>;
|
|
30
|
+
"min-price": number | undefined;
|
|
31
|
+
"max-price": number | undefined;
|
|
32
|
+
rating: number | undefined;
|
|
33
|
+
"shipping-free": boolean | undefined;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let translations: Translations = {
|
|
37
|
+
listing: {
|
|
38
|
+
filters: "Filters",
|
|
39
|
+
sort: "Sort",
|
|
40
|
+
resetFilters: "Reset filters",
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
translations = defu(useCmsTranslations(), translations) as Translations;
|
|
45
|
+
|
|
46
|
+
const route = useRoute();
|
|
47
|
+
const router = useRouter();
|
|
48
|
+
|
|
49
|
+
const {
|
|
50
|
+
changeCurrentSortingOrder,
|
|
51
|
+
getCurrentSortingOrder,
|
|
52
|
+
getInitialFilters,
|
|
53
|
+
getSortingOrders,
|
|
54
|
+
search,
|
|
55
|
+
} = useCategoryListing();
|
|
56
|
+
|
|
57
|
+
const sidebarSelectedFilters: UnwrapNestedRefs<FilterState> =
|
|
58
|
+
reactive<FilterState>({
|
|
59
|
+
manufacturer: new Set(),
|
|
60
|
+
properties: new Set(),
|
|
61
|
+
"min-price": undefined,
|
|
62
|
+
"max-price": undefined,
|
|
63
|
+
rating: undefined,
|
|
64
|
+
"shipping-free": undefined,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const showResetFiltersButton = computed<boolean>(() => {
|
|
68
|
+
if (
|
|
69
|
+
sidebarSelectedFilters.manufacturer.size !== 0 ||
|
|
70
|
+
sidebarSelectedFilters.properties.size !== 0 ||
|
|
71
|
+
sidebarSelectedFilters["max-price"] ||
|
|
72
|
+
sidebarSelectedFilters["min-price"] ||
|
|
73
|
+
sidebarSelectedFilters.rating ||
|
|
74
|
+
sidebarSelectedFilters["shipping-free"]
|
|
75
|
+
) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return false;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const searchCriteriaForRequest: ComputedRef<Schemas["ProductListingCriteria"]> =
|
|
83
|
+
computed(() => ({
|
|
84
|
+
manufacturer: [
|
|
85
|
+
...(sidebarSelectedFilters.manufacturer as Set<string>),
|
|
86
|
+
]?.join("|"),
|
|
87
|
+
properties: [...(sidebarSelectedFilters.properties as Set<string>)]?.join(
|
|
88
|
+
"|",
|
|
89
|
+
),
|
|
90
|
+
"min-price": sidebarSelectedFilters["min-price"] as number,
|
|
91
|
+
"max-price": sidebarSelectedFilters["max-price"] as number,
|
|
92
|
+
order: getCurrentSortingOrder.value as string,
|
|
93
|
+
"shipping-free": sidebarSelectedFilters["shipping-free"] as boolean,
|
|
94
|
+
rating: sidebarSelectedFilters.rating as number,
|
|
95
|
+
search: "",
|
|
96
|
+
limit: route.query.limit ? Number(route.query.limit) : 15,
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
for (const param in route.query) {
|
|
100
|
+
if (param in sidebarSelectedFilters) {
|
|
101
|
+
const queryValue = route.query[param];
|
|
102
|
+
|
|
103
|
+
// Skip arrays
|
|
104
|
+
if (Array.isArray(queryValue)) continue;
|
|
105
|
+
|
|
106
|
+
if (["manufacturer", "properties"].includes(param)) {
|
|
107
|
+
if (typeof queryValue === "string") {
|
|
108
|
+
const elements = queryValue.split("|");
|
|
109
|
+
const targetSet = sidebarSelectedFilters[
|
|
110
|
+
param as keyof FilterState
|
|
111
|
+
] as Set<string>;
|
|
112
|
+
for (const element of elements) {
|
|
113
|
+
targetSet.add(element);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} else if (queryValue && typeof queryValue === "string") {
|
|
117
|
+
// Fix: Use specific property assignments instead of generic keyof
|
|
118
|
+
if (param === "min-price") {
|
|
119
|
+
const numValue = Number(queryValue);
|
|
120
|
+
if (!Number.isNaN(numValue)) {
|
|
121
|
+
sidebarSelectedFilters["min-price"] = numValue;
|
|
122
|
+
}
|
|
123
|
+
} else if (param === "max-price") {
|
|
124
|
+
const numValue = Number(queryValue);
|
|
125
|
+
if (!Number.isNaN(numValue)) {
|
|
126
|
+
sidebarSelectedFilters["max-price"] = numValue;
|
|
127
|
+
}
|
|
128
|
+
} else if (param === "rating") {
|
|
129
|
+
const numValue = Number(queryValue);
|
|
130
|
+
if (!Number.isNaN(numValue)) {
|
|
131
|
+
sidebarSelectedFilters.rating = numValue;
|
|
132
|
+
}
|
|
133
|
+
} else if (param === "shipping-free") {
|
|
134
|
+
sidebarSelectedFilters["shipping-free"] = queryValue === "true";
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const handleFilterChange = async (event: {
|
|
141
|
+
code: string;
|
|
142
|
+
value: string | number | boolean;
|
|
143
|
+
}) => {
|
|
144
|
+
try {
|
|
145
|
+
const { code, value } = event;
|
|
146
|
+
|
|
147
|
+
if (code === "manufacturer" || code === "properties") {
|
|
148
|
+
const filterSet = sidebarSelectedFilters[code];
|
|
149
|
+
const stringValue = String(value);
|
|
150
|
+
|
|
151
|
+
if (filterSet.has(stringValue)) {
|
|
152
|
+
filterSet.delete(stringValue);
|
|
153
|
+
} else {
|
|
154
|
+
filterSet.add(stringValue);
|
|
155
|
+
}
|
|
156
|
+
} else if (code === "min-price" || code === "max-price") {
|
|
157
|
+
sidebarSelectedFilters[code] =
|
|
158
|
+
typeof value === "number" ? value : Number(value);
|
|
159
|
+
} else if (code === "rating") {
|
|
160
|
+
sidebarSelectedFilters.rating = Number(value);
|
|
161
|
+
} else if (code === "shipping-free") {
|
|
162
|
+
sidebarSelectedFilters["shipping-free"] = Boolean(value);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await executeSearch();
|
|
166
|
+
} catch (error) {
|
|
167
|
+
console.error("Filter update failed:", error);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const executeSearch = async () => {
|
|
172
|
+
try {
|
|
173
|
+
await search(searchCriteriaForRequest.value);
|
|
174
|
+
|
|
175
|
+
// Build query directly from searchCriteriaForRequest which already has pipe-separated strings
|
|
176
|
+
const criteria = searchCriteriaForRequest.value;
|
|
177
|
+
const query: Record<string, unknown> = {};
|
|
178
|
+
|
|
179
|
+
if (criteria.manufacturer) query.manufacturer = criteria.manufacturer;
|
|
180
|
+
if (criteria.properties) query.properties = criteria.properties;
|
|
181
|
+
if (criteria["min-price"]) query["min-price"] = criteria["min-price"];
|
|
182
|
+
if (criteria["max-price"]) query["max-price"] = criteria["max-price"];
|
|
183
|
+
if (criteria.rating) query.rating = criteria.rating;
|
|
184
|
+
if (criteria["shipping-free"])
|
|
185
|
+
query["shipping-free"] = criteria["shipping-free"];
|
|
186
|
+
if (criteria.order) query.order = criteria.order;
|
|
187
|
+
|
|
188
|
+
await router.push({
|
|
189
|
+
query: query as LocationQueryRaw,
|
|
190
|
+
});
|
|
191
|
+
} catch (error) {
|
|
192
|
+
console.error("Search execution failed:", error);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const clearFilters = () => {
|
|
197
|
+
(sidebarSelectedFilters.manufacturer as Set<string>).clear();
|
|
198
|
+
(sidebarSelectedFilters.properties as Set<string>).clear();
|
|
199
|
+
sidebarSelectedFilters["min-price"] = undefined;
|
|
200
|
+
sidebarSelectedFilters["max-price"] = undefined;
|
|
201
|
+
sidebarSelectedFilters.rating = undefined;
|
|
202
|
+
sidebarSelectedFilters["shipping-free"] = undefined;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const currentSortingOrder = computed({
|
|
206
|
+
get: (): string => getCurrentSortingOrder.value || "",
|
|
207
|
+
set: async (order: string): Promise<void> => {
|
|
208
|
+
try {
|
|
209
|
+
await router.push({
|
|
210
|
+
query: {
|
|
211
|
+
...route.query,
|
|
212
|
+
order,
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
await changeCurrentSortingOrder(order, {
|
|
217
|
+
...(route.query as unknown as operations["searchPage post /search"]["body"]),
|
|
218
|
+
limit: route.query.limit ? Number(route.query.limit) : 15,
|
|
219
|
+
});
|
|
220
|
+
} catch (error) {
|
|
221
|
+
console.error("Sorting order change failed:", error);
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
async function invokeCleanFilters() {
|
|
227
|
+
try {
|
|
228
|
+
clearFilters();
|
|
229
|
+
await executeSearch();
|
|
230
|
+
} catch (error) {
|
|
231
|
+
console.error("Clear filters failed:", error);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const handleSortChange = (sortKey: string) => {
|
|
236
|
+
currentSortingOrder.value = sortKey;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// Helper to check if a filter has active selections
|
|
240
|
+
const hasActiveFilter = (filter: { code: string }) => {
|
|
241
|
+
if (filter.code === "manufacturer") {
|
|
242
|
+
return sidebarSelectedFilters.manufacturer.size > 0;
|
|
243
|
+
}
|
|
244
|
+
if (filter.code === "price") {
|
|
245
|
+
return (
|
|
246
|
+
sidebarSelectedFilters["min-price"] !== undefined ||
|
|
247
|
+
sidebarSelectedFilters["max-price"] !== undefined
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
if (filter.code === "rating") {
|
|
251
|
+
return sidebarSelectedFilters.rating !== undefined;
|
|
252
|
+
}
|
|
253
|
+
if (filter.code === "shipping-free") {
|
|
254
|
+
return sidebarSelectedFilters["shipping-free"] === true;
|
|
255
|
+
}
|
|
256
|
+
// Properties filter - check if any property from this filter group is selected
|
|
257
|
+
return sidebarSelectedFilters.properties.size > 0;
|
|
258
|
+
};
|
|
259
|
+
</script>
|
|
260
|
+
|
|
261
|
+
<template>
|
|
262
|
+
<div>
|
|
263
|
+
<!-- Horizontal Filters Row -->
|
|
264
|
+
<div class="flex flex-wrap items-center justify-start gap-4 z-10">
|
|
265
|
+
<!-- Filter dropdowns -->
|
|
266
|
+
<SwFilterDropdown
|
|
267
|
+
v-for="filter in getInitialFilters"
|
|
268
|
+
:key="filter.id"
|
|
269
|
+
:label="filter.label"
|
|
270
|
+
:is-active="hasActiveFilter(filter)"
|
|
271
|
+
>
|
|
272
|
+
<SwProductListingFilter
|
|
273
|
+
:filter="filter"
|
|
274
|
+
display-mode="dropdown"
|
|
275
|
+
:selected-manufacturer="sidebarSelectedFilters.manufacturer"
|
|
276
|
+
:selected-properties="sidebarSelectedFilters.properties"
|
|
277
|
+
:selected-min-price="sidebarSelectedFilters['min-price']"
|
|
278
|
+
:selected-max-price="sidebarSelectedFilters['max-price']"
|
|
279
|
+
:selected-rating="sidebarSelectedFilters.rating"
|
|
280
|
+
:selected-shipping-free="sidebarSelectedFilters['shipping-free']"
|
|
281
|
+
@filter-change="handleFilterChange"
|
|
282
|
+
/>
|
|
283
|
+
</SwFilterDropdown>
|
|
284
|
+
|
|
285
|
+
<!-- Sort dropdown -->
|
|
286
|
+
<SwSortDropdown
|
|
287
|
+
:sort-options="getSortingOrders ?? []"
|
|
288
|
+
:current-sort="getCurrentSortingOrder ?? ''"
|
|
289
|
+
:label="translations.listing.sort"
|
|
290
|
+
@sort-change="handleSortChange"
|
|
291
|
+
/>
|
|
292
|
+
|
|
293
|
+
<!-- Reset filters button -->
|
|
294
|
+
<SwBaseButton
|
|
295
|
+
v-if="showResetFiltersButton"
|
|
296
|
+
variant="ghost"
|
|
297
|
+
size="medium"
|
|
298
|
+
@click="invokeCleanFilters"
|
|
299
|
+
type="button"
|
|
300
|
+
>
|
|
301
|
+
{{ translations.listing.resetFilters }}
|
|
302
|
+
<span class="w-5 h-5 i-carbon-close inline-block align-middle ml-1"></span>
|
|
303
|
+
</SwBaseButton>
|
|
304
|
+
</div>
|
|
305
|
+
</div>
|
|
306
|
+
</template>
|
|
@@ -37,7 +37,7 @@ translations = defu(useCmsTranslations(), translations) as Translations;
|
|
|
37
37
|
|
|
38
38
|
const { product } = toRefs(props);
|
|
39
39
|
|
|
40
|
-
const { unitPrice, price, tierPrices,
|
|
40
|
+
const { unitPrice, price, tierPrices, hasListPrice } = useProductPrice(product);
|
|
41
41
|
const { getFormattedPrice } = usePrice();
|
|
42
42
|
</script>
|
|
43
43
|
|
|
@@ -45,7 +45,7 @@ const { getFormattedPrice } = usePrice();
|
|
|
45
45
|
<div>
|
|
46
46
|
<div v-if="!tierPrices.length">
|
|
47
47
|
<SwSharedPrice
|
|
48
|
-
v-if="
|
|
48
|
+
v-if="hasListPrice"
|
|
49
49
|
class="text-1xl text-gray-900 basis-2/6 justify-end line-through"
|
|
50
50
|
:value="price?.listPrice?.price"
|
|
51
51
|
/>
|
|
@@ -53,7 +53,7 @@ const { getFormattedPrice } = usePrice();
|
|
|
53
53
|
v-if="unitPrice"
|
|
54
54
|
class="text-3xl text-gray-900 basis-2/6 justify-end"
|
|
55
55
|
:class="{
|
|
56
|
-
'text-red':
|
|
56
|
+
'text-red': hasListPrice,
|
|
57
57
|
}"
|
|
58
58
|
:value="unitPrice"
|
|
59
59
|
/>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from "vue";
|
|
3
|
+
|
|
4
|
+
const {
|
|
5
|
+
rating = 0,
|
|
6
|
+
reviewCount = 0,
|
|
7
|
+
starSize = 16,
|
|
8
|
+
showCount = true,
|
|
9
|
+
} = defineProps<{
|
|
10
|
+
rating: number;
|
|
11
|
+
reviewCount?: number;
|
|
12
|
+
starSize?: number;
|
|
13
|
+
showCount?: boolean;
|
|
14
|
+
}>();
|
|
15
|
+
|
|
16
|
+
const filledStars = computed(() => Math.round(rating));
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<div class="flex items-center">
|
|
21
|
+
<div
|
|
22
|
+
class="flex items-center gap-1.5"
|
|
23
|
+
role="img"
|
|
24
|
+
:aria-label="`${rating} out of 5 stars`"
|
|
25
|
+
>
|
|
26
|
+
<SwStarIcon
|
|
27
|
+
v-for="i in 5"
|
|
28
|
+
:key="`star-${i}`"
|
|
29
|
+
:filled="i <= filledStars"
|
|
30
|
+
:size="starSize"
|
|
31
|
+
/>
|
|
32
|
+
</div>
|
|
33
|
+
<span
|
|
34
|
+
v-if="showCount && reviewCount > 0"
|
|
35
|
+
class="ml-1 text-surface-on-surface-variant text-base leading-normal"
|
|
36
|
+
>
|
|
37
|
+
({{ reviewCount }})
|
|
38
|
+
</span>
|
|
39
|
+
</div>
|
|
40
|
+
</template>
|
|
@@ -93,26 +93,13 @@ const formatDate = (date: string) => {
|
|
|
93
93
|
<div class="w-6 h-6 i-carbon-warning" />
|
|
94
94
|
{{ translations.product.reviewNotAccepted }}
|
|
95
95
|
</div>
|
|
96
|
-
<div
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
<SwStarIcon
|
|
102
|
-
v-for="_ in review.points"
|
|
103
|
-
:key="`filled-star-${_}`"
|
|
104
|
-
:filled="true"
|
|
105
|
-
:size="20"
|
|
96
|
+
<div class="cms-block-product-description-reviews__reviews-rating inline-flex items-center mt-2">
|
|
97
|
+
<SwProductRating
|
|
98
|
+
:rating="review.points ?? 0"
|
|
99
|
+
:star-size="20"
|
|
100
|
+
:show-count="false"
|
|
106
101
|
/>
|
|
107
|
-
<
|
|
108
|
-
v-for="_ in 5 - (review.points || 0)"
|
|
109
|
-
:key="`empty-star-${_}`"
|
|
110
|
-
:filled="false"
|
|
111
|
-
:size="20"
|
|
112
|
-
/>
|
|
113
|
-
<div
|
|
114
|
-
class="cms-block-product-description-reviews__reviews-title font-semibold ml-2"
|
|
115
|
-
>
|
|
102
|
+
<div class="cms-block-product-description-reviews__reviews-title font-semibold ml-2">
|
|
116
103
|
<p>{{ review.title }}</p>
|
|
117
104
|
</div>
|
|
118
105
|
</div>
|
|
@@ -4,15 +4,10 @@ import { defu } from "defu";
|
|
|
4
4
|
import { computed } from "vue";
|
|
5
5
|
import type { Schemas } from "#shopware";
|
|
6
6
|
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
}>(),
|
|
12
|
-
{
|
|
13
|
-
showContent: true,
|
|
14
|
-
},
|
|
15
|
-
);
|
|
7
|
+
const { product, showContent = true } = defineProps<{
|
|
8
|
+
product: Schemas["Product"];
|
|
9
|
+
showContent?: boolean;
|
|
10
|
+
}>();
|
|
16
11
|
|
|
17
12
|
type Translations = {
|
|
18
13
|
product: {
|
|
@@ -28,22 +23,22 @@ let translations: Translations = {
|
|
|
28
23
|
|
|
29
24
|
translations = defu(useCmsTranslations(), translations) as Translations;
|
|
30
25
|
|
|
31
|
-
const purchaseUnit = computed(() =>
|
|
32
|
-
const unitName = computed(() =>
|
|
26
|
+
const purchaseUnit = computed(() => product?.purchaseUnit);
|
|
27
|
+
const unitName = computed(() => product?.unit?.translated.name);
|
|
33
28
|
const referencePrice = computed(
|
|
34
|
-
() =>
|
|
29
|
+
() => product?.calculatedPrice?.referencePrice?.price,
|
|
35
30
|
);
|
|
36
31
|
const referenceUnit = computed(
|
|
37
|
-
() =>
|
|
32
|
+
() => product?.calculatedPrice?.referencePrice?.referenceUnit,
|
|
38
33
|
);
|
|
39
34
|
const referenceUnitName = computed(
|
|
40
|
-
() =>
|
|
35
|
+
() => product?.calculatedPrice?.referencePrice?.unitName,
|
|
41
36
|
);
|
|
42
37
|
</script>
|
|
43
38
|
|
|
44
39
|
<template>
|
|
45
40
|
<div v-if="purchaseUnit" class="flex text-gray-500 justify-end gap-1">
|
|
46
|
-
<template v-if="
|
|
41
|
+
<template v-if="showContent">
|
|
47
42
|
{{ translations.product.content }}: {{ purchaseUnit }} {{ unitName }}
|
|
48
43
|
</template>
|
|
49
44
|
<template v-if="referencePrice">
|