@shopware/cms-base-layer 3.0.0 → 3.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 +64 -58
- package/app/components/SwCategoryNavigation.vue +1 -0
- package/app/components/SwCategoryNavigationLink.vue +16 -8
- package/app/components/SwContactForm.vue +1 -0
- package/app/components/SwFilterChips.vue +1 -0
- package/app/components/SwListingProductPrice.vue +16 -4
- package/app/components/SwMedia3D.vue +1 -4
- package/app/components/SwNewsletterForm.vue +4 -3
- package/app/components/SwProductAddToCart.vue +24 -7
- package/app/components/SwProductCard.vue +20 -3
- package/app/components/SwProductCardDetails.vue +27 -9
- package/app/components/SwProductCardImage.vue +31 -12
- package/app/components/SwProductCardSkeleton.vue +37 -14
- package/app/components/SwProductGallery.vue +1 -0
- package/app/components/SwProductListingFilter.vue +5 -1
- package/app/components/SwProductListingFilters.vue +57 -76
- package/app/components/SwProductListingFiltersHorizontal.vue +40 -71
- package/app/components/SwProductPrice.vue +1 -0
- package/app/components/SwProductReviews.vue +14 -6
- package/app/components/SwProductReviewsForm.vue +15 -3
- package/app/components/SwProductUnits.vue +1 -0
- package/app/components/SwQuantitySelect.vue +10 -3
- package/app/components/SwSharedPrice.vue +1 -0
- package/app/components/SwSlider.vue +1 -0
- package/app/components/SwStockInfo.vue +12 -6
- package/app/components/SwVariantConfigurator.vue +4 -1
- package/app/components/listing-filters/SwFilterPrice.vue +79 -30
- package/app/components/listing-filters/SwFilterProperties.vue +40 -25
- package/app/components/listing-filters/SwFilterRating.vue +21 -9
- package/app/components/listing-filters/SwFilterShippingFree.vue +16 -8
- package/app/components/public/cms/CmsBlockSpatialViewer.vue +1 -0
- package/app/components/public/cms/CmsGenericBlock.vue +3 -1
- package/app/components/public/cms/CmsGenericElement.vue +2 -1
- package/app/components/public/cms/CmsNoComponent.vue +2 -1
- package/app/components/public/cms/CmsPage.md +1 -1
- package/app/components/public/cms/CmsPage.vue +2 -0
- package/app/components/public/cms/block/CmsBlockCategoryNavigation.vue +1 -0
- package/app/components/public/cms/block/CmsBlockCenterText.vue +3 -1
- package/app/components/public/cms/block/CmsBlockCrossSelling.vue +1 -0
- package/app/components/public/cms/block/CmsBlockCustomForm.vue +1 -0
- package/app/components/public/cms/block/CmsBlockForm.vue +1 -0
- package/app/components/public/cms/block/CmsBlockGalleryBuybox.vue +4 -1
- package/app/components/public/cms/block/CmsBlockImage.vue +1 -0
- package/app/components/public/cms/block/CmsBlockImageBubbleRow.vue +9 -2
- package/app/components/public/cms/block/CmsBlockImageCover.vue +1 -0
- package/app/components/public/cms/block/CmsBlockImageFourColumn.vue +10 -3
- package/app/components/public/cms/block/CmsBlockImageGallery.vue +1 -0
- package/app/components/public/cms/block/CmsBlockImageGalleryBig.vue +10 -5
- package/app/components/public/cms/block/CmsBlockImageHighlightRow.vue +11 -3
- package/app/components/public/cms/block/CmsBlockImageSimpleGrid.vue +1 -0
- package/app/components/public/cms/block/CmsBlockImageSlider.vue +1 -0
- package/app/components/public/cms/block/CmsBlockImageText.vue +4 -1
- package/app/components/public/cms/block/CmsBlockImageTextBubble.vue +4 -1
- package/app/components/public/cms/block/CmsBlockImageTextCover.vue +4 -1
- package/app/components/public/cms/block/CmsBlockImageTextGallery.vue +4 -12
- package/app/components/public/cms/block/CmsBlockImageTextRow.vue +13 -4
- package/app/components/public/cms/block/CmsBlockImageThreeColumn.vue +1 -0
- package/app/components/public/cms/block/CmsBlockImageThreeCover.vue +11 -3
- package/app/components/public/cms/block/CmsBlockImageTwoColumn.vue +11 -4
- package/app/components/public/cms/block/CmsBlockProductDescriptionReviews.vue +1 -0
- package/app/components/public/cms/block/CmsBlockProductHeading.vue +3 -1
- package/app/components/public/cms/block/CmsBlockProductListing.vue +1 -0
- package/app/components/public/cms/block/CmsBlockProductThreeColumn.vue +1 -0
- package/app/components/public/cms/block/CmsBlockSidebarFilter.vue +1 -0
- package/app/components/public/cms/block/CmsBlockText.vue +1 -0
- package/app/components/public/cms/block/CmsBlockTextHero.vue +1 -0
- package/app/components/public/cms/block/CmsBlockTextOnImage.vue +2 -4
- package/app/components/public/cms/block/CmsBlockTextThreeColumn.vue +1 -0
- package/app/components/public/cms/block/CmsBlockTextTwoColumn.vue +4 -1
- package/app/components/public/cms/block/CmsBlockVimeoVideo.vue +1 -0
- package/app/components/public/cms/block/CmsBlockYoutubeVideo.vue +1 -0
- package/app/components/public/cms/element/CmsElementBuyBox.vue +90 -72
- package/app/components/public/cms/element/CmsElementCategoryNavigation.vue +14 -3
- package/app/components/public/cms/element/CmsElementCrossSelling.vue +3 -1
- package/app/components/public/cms/element/CmsElementCustomForm.vue +1 -0
- package/app/components/public/cms/element/CmsElementForm.vue +1 -0
- package/app/components/public/cms/element/CmsElementHtml.vue +0 -1
- package/app/components/public/cms/element/CmsElementImage.vue +2 -1
- package/app/components/public/cms/element/CmsElementImageGallery.vue +6 -2
- package/app/components/public/cms/element/CmsElementManufacturerLogo.vue +1 -0
- package/app/components/public/cms/element/CmsElementProductBox.vue +1 -0
- package/app/components/public/cms/element/CmsElementProductDescriptionReviews.vue +93 -36
- package/app/components/public/cms/element/CmsElementProductListing.vue +16 -5
- package/app/components/public/cms/element/CmsElementProductSlider.vue +6 -4
- package/app/components/public/cms/element/CmsElementText.vue +2 -0
- package/app/components/public/cms/element/CmsElementVimeoVideo.vue +1 -0
- package/app/components/public/cms/element/CmsElementYoutubeVideo.vue +1 -0
- package/app/components/public/cms/element/SwProductListingPagination.vue +8 -2
- package/app/components/public/cms/section/CmsSectionSidebar.vue +13 -8
- package/app/components/public/cms/skeleton/ProductCardSkeleton.vue +16 -5
- package/app/components/ui/SwitchButton.vue +53 -14
- package/app/components/ui/WishlistIcon.vue +8 -2
- package/app/composables/useLcpImagePreload.test.ts +1 -0
- package/app/composables/useLcpImagePreload.ts +2 -0
- package/app/composables/useTypedAppConfig.ts +1 -0
- package/app/helpers/cms/getImageSizes.test.ts +1 -0
- package/app/helpers/html-to-vue/ast.ts +1 -0
- package/app/helpers/html-to-vue/getOptionsFromNode.test.ts +1 -0
- package/app/helpers/html-to-vue/renderToHtml.ts +1 -0
- package/app/helpers/html-to-vue/renderer.ts +1 -0
- package/app/providers/shopware.test.ts +1 -0
- package/app/utils/routeQuery.test.ts +65 -0
- package/app/utils/routeQuery.ts +26 -0
- package/app/utils/useSelectedListingFilters.test.ts +82 -0
- package/app/utils/useSelectedListingFilters.ts +105 -0
- package/nuxt.config.ts +24 -1
- package/package.json +22 -24
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
isProductTopSeller,
|
|
7
7
|
} from "@shopware/helpers";
|
|
8
8
|
import { computed, inject } from "vue";
|
|
9
|
+
|
|
9
10
|
import { useUser } from "#imports";
|
|
10
11
|
import type { Schemas } from "#shopware";
|
|
11
12
|
|
|
@@ -57,32 +58,50 @@ function handleWishlistClick() {
|
|
|
57
58
|
</script>
|
|
58
59
|
|
|
59
60
|
<template>
|
|
60
|
-
<div
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
<div
|
|
62
|
+
class="self-stretch min-h-[350px] relative flex flex-col justify-start items-start overflow-hidden aspect-square"
|
|
63
|
+
>
|
|
64
|
+
<RouterLink
|
|
65
|
+
:to="productLink"
|
|
66
|
+
class="self-stretch h-full relative overflow-hidden"
|
|
67
|
+
>
|
|
68
|
+
<NuxtImg
|
|
69
|
+
preset="productCard"
|
|
63
70
|
class="w-full h-full absolute top-0 left-0 object-cover"
|
|
64
|
-
:src="coverSrcPath"
|
|
65
|
-
|
|
71
|
+
:src="coverSrcPath"
|
|
72
|
+
:alt="coverAlt"
|
|
73
|
+
width="400"
|
|
74
|
+
height="400"
|
|
66
75
|
densities="1x"
|
|
67
76
|
loading="lazy"
|
|
68
|
-
data-testid="product-box-img"
|
|
77
|
+
data-testid="product-box-img"
|
|
78
|
+
/>
|
|
69
79
|
</RouterLink>
|
|
70
80
|
|
|
71
|
-
<div
|
|
72
|
-
|
|
81
|
+
<div
|
|
82
|
+
v-if="isTopseller || isOnSale"
|
|
83
|
+
class="px-1.5 py-1 left-2 bottom-2 absolute bg-other-sale rounded inline-flex justify-center items-center"
|
|
84
|
+
>
|
|
73
85
|
<div class="text-states-on-error text-xs font-bold leading-none">
|
|
74
86
|
{{ translations.product.badges.topseller }}
|
|
75
87
|
</div>
|
|
76
88
|
</div>
|
|
77
89
|
|
|
78
90
|
<client-only>
|
|
79
|
-
<SwIconButton
|
|
80
|
-
|
|
91
|
+
<SwIconButton
|
|
92
|
+
type="secondary"
|
|
93
|
+
:aria-label="
|
|
94
|
+
isInWishlist
|
|
95
|
+
? translations.product.removeFromWishlist
|
|
96
|
+
: translations.product.addToWishlist
|
|
97
|
+
"
|
|
81
98
|
:disabled="isLoading"
|
|
82
99
|
class="w-10 h-10 right-4 top-4 absolute bg-brand-secondary rounded-full flex items-center justify-center"
|
|
83
|
-
data-testid="product-box-toggle-wishlist-button"
|
|
100
|
+
data-testid="product-box-toggle-wishlist-button"
|
|
101
|
+
@click="handleWishlistClick"
|
|
102
|
+
>
|
|
84
103
|
<SwWishlistIcon :filled="isInWishlist" />
|
|
85
104
|
</SwIconButton>
|
|
86
105
|
</client-only>
|
|
87
106
|
</div>
|
|
88
|
-
</template>
|
|
107
|
+
</template>
|
|
@@ -1,33 +1,56 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div
|
|
3
|
-
|
|
2
|
+
<div
|
|
3
|
+
class="inline-flex flex-col items-start justify-start self-stretch overflow-hidden p-px w-full"
|
|
4
|
+
aria-hidden="true"
|
|
5
|
+
>
|
|
4
6
|
<!-- image skeleton -->
|
|
5
|
-
<div
|
|
6
|
-
|
|
7
|
+
<div
|
|
8
|
+
class="relative flex h-80 flex-col items-start justify-start self-stretch overflow-hidden"
|
|
9
|
+
>
|
|
10
|
+
<div
|
|
11
|
+
class="relative h-80 w-full bg-gray-200 dark:bg-gray-700 animate-pulse"
|
|
12
|
+
></div>
|
|
7
13
|
|
|
8
14
|
<div
|
|
9
|
-
class="absolute top-[281px] left-2 inline-flex items-center justify-center rounded bg-gray-300 dark:bg-gray-600 px-3 py-1 animate-pulse"
|
|
10
|
-
|
|
15
|
+
class="absolute top-[281px] left-2 inline-flex items-center justify-center rounded bg-gray-300 dark:bg-gray-600 px-3 py-1 animate-pulse"
|
|
16
|
+
>
|
|
17
|
+
<span
|
|
18
|
+
class="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded block"
|
|
19
|
+
></span>
|
|
11
20
|
</div>
|
|
12
21
|
|
|
13
|
-
<div
|
|
22
|
+
<div
|
|
23
|
+
class="absolute top-4 right-4 h-10 w-10 rounded-full bg-gray-300 dark:bg-gray-600 animate-pulse"
|
|
24
|
+
></div>
|
|
14
25
|
</div>
|
|
15
26
|
|
|
16
27
|
<!-- details skeleton -->
|
|
17
28
|
<div class="flex flex-col items-start justify-start gap-4 self-stretch p-2">
|
|
18
|
-
<div
|
|
29
|
+
<div
|
|
30
|
+
class="h-4 w-32 rounded bg-gray-200 dark:bg-gray-700 animate-pulse"
|
|
31
|
+
></div>
|
|
19
32
|
|
|
20
33
|
<div class="w-full">
|
|
21
|
-
<div
|
|
22
|
-
|
|
34
|
+
<div
|
|
35
|
+
class="h-7 w-3/4 rounded bg-gray-200 dark:bg-gray-700 mb-2 animate-pulse"
|
|
36
|
+
></div>
|
|
37
|
+
<div
|
|
38
|
+
class="h-7 w-1/2 rounded bg-gray-200 dark:bg-gray-700 animate-pulse"
|
|
39
|
+
></div>
|
|
23
40
|
</div>
|
|
24
41
|
|
|
25
|
-
<div
|
|
42
|
+
<div
|
|
43
|
+
class="h-6 w-24 rounded bg-gray-200 dark:bg-gray-700 animate-pulse"
|
|
44
|
+
></div>
|
|
26
45
|
|
|
27
46
|
<div class="flex w-full gap-3">
|
|
28
|
-
<div
|
|
29
|
-
|
|
47
|
+
<div
|
|
48
|
+
class="flex-1 h-10 rounded bg-gray-200 dark:bg-gray-700 animate-pulse"
|
|
49
|
+
></div>
|
|
50
|
+
<div
|
|
51
|
+
class="w-24 h-10 rounded bg-gray-200 dark:bg-gray-700 animate-pulse"
|
|
52
|
+
></div>
|
|
30
53
|
</div>
|
|
31
54
|
</div>
|
|
32
55
|
</div>
|
|
33
|
-
</template>
|
|
56
|
+
</template>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts" generic="ListingFilter extends { code: string }">
|
|
2
2
|
import { computed } from "vue";
|
|
3
3
|
import type { Component } from "vue";
|
|
4
|
+
|
|
4
5
|
import SwFilterPriceVue from "./listing-filters/SwFilterPrice.vue";
|
|
5
6
|
import SwFilterPropertiesVue from "./listing-filters/SwFilterProperties.vue";
|
|
6
7
|
import SwFilterRatingVue from "./listing-filters/SwFilterRating.vue";
|
|
@@ -58,7 +59,10 @@ const filterComponent = computed<Component | undefined>(() => {
|
|
|
58
59
|
const handleSelectValue = ({
|
|
59
60
|
code,
|
|
60
61
|
value,
|
|
61
|
-
}: {
|
|
62
|
+
}: {
|
|
63
|
+
code: string;
|
|
64
|
+
value: string | number | boolean;
|
|
65
|
+
}) => {
|
|
62
66
|
emit("filter-change", { code, value });
|
|
63
67
|
};
|
|
64
68
|
</script>
|
|
@@ -5,10 +5,19 @@ import type {
|
|
|
5
5
|
} from "@shopware/composables";
|
|
6
6
|
import { useCmsTranslations } from "@shopware/composables";
|
|
7
7
|
import { defu } from "defu";
|
|
8
|
-
import { computed
|
|
9
|
-
import type { ComputedRef
|
|
8
|
+
import { computed } from "vue";
|
|
9
|
+
import type { ComputedRef } from "vue";
|
|
10
10
|
import type { LocationQueryRaw } from "vue-router";
|
|
11
|
-
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
firstQueryValue,
|
|
14
|
+
toNumber,
|
|
15
|
+
useCategoryListing,
|
|
16
|
+
useProductSearchListing,
|
|
17
|
+
useRoute,
|
|
18
|
+
useRouter,
|
|
19
|
+
useSelectedListingFilters,
|
|
20
|
+
} from "#imports";
|
|
12
21
|
import type { Schemas, operations } from "#shopware";
|
|
13
22
|
|
|
14
23
|
const props = defineProps<{
|
|
@@ -16,6 +25,8 @@ const props = defineProps<{
|
|
|
16
25
|
listingType?: string;
|
|
17
26
|
}>();
|
|
18
27
|
|
|
28
|
+
const isProductSearch = props.listingType === "productSearchListing";
|
|
29
|
+
|
|
19
30
|
type Translations = {
|
|
20
31
|
listing: {
|
|
21
32
|
filters: string;
|
|
@@ -24,15 +35,6 @@ type Translations = {
|
|
|
24
35
|
};
|
|
25
36
|
};
|
|
26
37
|
|
|
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
38
|
let translations: Translations = {
|
|
37
39
|
listing: {
|
|
38
40
|
filters: "Filters",
|
|
@@ -53,17 +55,9 @@ const {
|
|
|
53
55
|
getInitialFilters,
|
|
54
56
|
getSortingOrders,
|
|
55
57
|
search,
|
|
56
|
-
} = useCategoryListing();
|
|
58
|
+
} = isProductSearch ? useProductSearchListing() : useCategoryListing();
|
|
57
59
|
|
|
58
|
-
const sidebarSelectedFilters
|
|
59
|
-
reactive<FilterState>({
|
|
60
|
-
manufacturer: new Set(),
|
|
61
|
-
properties: new Set(),
|
|
62
|
-
"min-price": undefined,
|
|
63
|
-
"max-price": undefined,
|
|
64
|
-
rating: undefined,
|
|
65
|
-
"shipping-free": undefined,
|
|
66
|
-
});
|
|
60
|
+
const sidebarSelectedFilters = useSelectedListingFilters();
|
|
67
61
|
|
|
68
62
|
const showResetFiltersButton = computed<boolean>(() => {
|
|
69
63
|
if (
|
|
@@ -93,51 +87,10 @@ const searchCriteriaForRequest: ComputedRef<Schemas["ProductListingCriteria"]> =
|
|
|
93
87
|
order: getCurrentSortingOrder.value as string,
|
|
94
88
|
"shipping-free": sidebarSelectedFilters["shipping-free"] as boolean,
|
|
95
89
|
rating: sidebarSelectedFilters.rating as number,
|
|
96
|
-
search: "",
|
|
97
|
-
limit:
|
|
90
|
+
search: isProductSearch ? (firstQueryValue(route.query.search) ?? "") : "",
|
|
91
|
+
limit: toNumber(firstQueryValue(route.query.limit)) ?? 15,
|
|
98
92
|
}));
|
|
99
93
|
|
|
100
|
-
for (const param in route.query) {
|
|
101
|
-
if (param in sidebarSelectedFilters) {
|
|
102
|
-
const queryValue = route.query[param];
|
|
103
|
-
|
|
104
|
-
// Skip arrays
|
|
105
|
-
if (Array.isArray(queryValue)) continue;
|
|
106
|
-
|
|
107
|
-
if (["manufacturer", "properties"].includes(param)) {
|
|
108
|
-
if (typeof queryValue === "string") {
|
|
109
|
-
const elements = queryValue.split("|");
|
|
110
|
-
const targetSet = sidebarSelectedFilters[
|
|
111
|
-
param as keyof FilterState
|
|
112
|
-
] as Set<string>;
|
|
113
|
-
for (const element of elements) {
|
|
114
|
-
targetSet.add(element);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
} else if (queryValue && typeof queryValue === "string") {
|
|
118
|
-
// Fix: Use specific property assignments instead of generic keyof
|
|
119
|
-
if (param === "min-price") {
|
|
120
|
-
const numValue = Number(queryValue);
|
|
121
|
-
if (!Number.isNaN(numValue)) {
|
|
122
|
-
sidebarSelectedFilters["min-price"] = numValue;
|
|
123
|
-
}
|
|
124
|
-
} else if (param === "max-price") {
|
|
125
|
-
const numValue = Number(queryValue);
|
|
126
|
-
if (!Number.isNaN(numValue)) {
|
|
127
|
-
sidebarSelectedFilters["max-price"] = numValue;
|
|
128
|
-
}
|
|
129
|
-
} else if (param === "rating") {
|
|
130
|
-
const numValue = Number(queryValue);
|
|
131
|
-
if (!Number.isNaN(numValue)) {
|
|
132
|
-
sidebarSelectedFilters.rating = numValue;
|
|
133
|
-
}
|
|
134
|
-
} else if (param === "shipping-free") {
|
|
135
|
-
sidebarSelectedFilters["shipping-free"] = queryValue === "true";
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
94
|
const handleFilterChange = async (event: {
|
|
142
95
|
code: string;
|
|
143
96
|
value: string | number | boolean;
|
|
@@ -171,7 +124,12 @@ const handleFilterChange = async (event: {
|
|
|
171
124
|
|
|
172
125
|
const executeSearch = async () => {
|
|
173
126
|
try {
|
|
174
|
-
|
|
127
|
+
// Search context refetches from the URL (the page's useAsyncData watches
|
|
128
|
+
// route.query), so navigating below is enough; calling search() here too
|
|
129
|
+
// would double-fetch and flicker. Category listings have no URL watcher.
|
|
130
|
+
if (!isProductSearch) {
|
|
131
|
+
await search(searchCriteriaForRequest.value);
|
|
132
|
+
}
|
|
175
133
|
|
|
176
134
|
// Build query directly from searchCriteriaForRequest which already has pipe-separated strings
|
|
177
135
|
const criteria = searchCriteriaForRequest.value;
|
|
@@ -185,6 +143,12 @@ const executeSearch = async () => {
|
|
|
185
143
|
if (criteria["shipping-free"])
|
|
186
144
|
query["shipping-free"] = criteria["shipping-free"];
|
|
187
145
|
if (criteria.order) query.order = criteria.order;
|
|
146
|
+
// Keep the search term in the URL so filtering on the search page does not reset the query
|
|
147
|
+
if (isProductSearch && route.query.search)
|
|
148
|
+
query.search = route.query.search;
|
|
149
|
+
// Preserve the page-size selection; rebuilding the query from scratch would
|
|
150
|
+
// otherwise drop `limit` and revert to the default on refresh/navigation.
|
|
151
|
+
if (route.query.limit) query.limit = route.query.limit;
|
|
188
152
|
|
|
189
153
|
await router.push({
|
|
190
154
|
query: query as LocationQueryRaw,
|
|
@@ -214,10 +178,13 @@ const currentSortingOrder = computed({
|
|
|
214
178
|
},
|
|
215
179
|
});
|
|
216
180
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
181
|
+
// Search context refetches from the URL; category fetches directly.
|
|
182
|
+
if (!isProductSearch) {
|
|
183
|
+
await changeCurrentSortingOrder(order, {
|
|
184
|
+
...(route.query as unknown as operations["searchPage post /search"]["body"]),
|
|
185
|
+
limit: toNumber(firstQueryValue(route.query.limit)) ?? 15,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
221
188
|
} catch (error) {
|
|
222
189
|
console.error("Sorting order change failed:", error);
|
|
223
190
|
}
|
|
@@ -268,8 +235,11 @@ const handleRemoveFilterChip = async (chip: {
|
|
|
268
235
|
<!-- Filters Header -->
|
|
269
236
|
<div class="self-stretch flex flex-col justify-start items-start gap-4">
|
|
270
237
|
<div
|
|
271
|
-
class="flex flex-row items-center justify-between w-full mb-4 py-3 border-b border-outline-outline-variant"
|
|
272
|
-
|
|
238
|
+
class="flex flex-row items-center justify-between w-full mb-4 py-3 border-b border-outline-outline-variant"
|
|
239
|
+
>
|
|
240
|
+
<div
|
|
241
|
+
class="flex-1 text-surface-on-surface text-base font-bold leading-normal"
|
|
242
|
+
>
|
|
273
243
|
{{ translations.listing.filters }}
|
|
274
244
|
</div>
|
|
275
245
|
<SwSortDropdown
|
|
@@ -283,7 +253,9 @@ const handleRemoveFilterChip = async (chip: {
|
|
|
283
253
|
|
|
284
254
|
<!-- Filters List -->
|
|
285
255
|
<div class="self-stretch flex flex-col justify-start items-start gap-4">
|
|
286
|
-
<SwProductListingFilter
|
|
256
|
+
<SwProductListingFilter
|
|
257
|
+
v-for="filter in getInitialFilters"
|
|
258
|
+
:key="filter.id"
|
|
287
259
|
:filter="filter"
|
|
288
260
|
:selected-manufacturer="sidebarSelectedFilters.manufacturer"
|
|
289
261
|
:selected-properties="sidebarSelectedFilters.properties"
|
|
@@ -292,11 +264,20 @@ const handleRemoveFilterChip = async (chip: {
|
|
|
292
264
|
:selected-rating="sidebarSelectedFilters.rating"
|
|
293
265
|
:selected-shipping-free="sidebarSelectedFilters['shipping-free']"
|
|
294
266
|
@filter-change="handleFilterChange"
|
|
295
|
-
class="w-full"
|
|
267
|
+
class="w-full"
|
|
268
|
+
/>
|
|
296
269
|
<div v-if="showResetFiltersButton" class="w-full">
|
|
297
|
-
<SwBaseButton
|
|
270
|
+
<SwBaseButton
|
|
271
|
+
variant="primary"
|
|
272
|
+
size="medium"
|
|
273
|
+
block
|
|
274
|
+
@click="invokeCleanFilters"
|
|
275
|
+
type="button"
|
|
276
|
+
>
|
|
298
277
|
{{ translations.listing.resetFilters }}
|
|
299
|
-
<span
|
|
278
|
+
<span
|
|
279
|
+
class="w-6 h-6 i-carbon-close-filled inline-block align-middle ml-2"
|
|
280
|
+
></span>
|
|
300
281
|
</SwBaseButton>
|
|
301
282
|
</div>
|
|
302
283
|
</div>
|
|
@@ -5,10 +5,19 @@ import type {
|
|
|
5
5
|
} from "@shopware/composables";
|
|
6
6
|
import { useCmsTranslations } from "@shopware/composables";
|
|
7
7
|
import { defu } from "defu";
|
|
8
|
-
import { computed
|
|
9
|
-
import type { ComputedRef
|
|
8
|
+
import { computed } from "vue";
|
|
9
|
+
import type { ComputedRef } from "vue";
|
|
10
10
|
import type { LocationQueryRaw } from "vue-router";
|
|
11
|
-
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
firstQueryValue,
|
|
14
|
+
toNumber,
|
|
15
|
+
useCategoryListing,
|
|
16
|
+
useProductSearchListing,
|
|
17
|
+
useRoute,
|
|
18
|
+
useRouter,
|
|
19
|
+
useSelectedListingFilters,
|
|
20
|
+
} from "#imports";
|
|
12
21
|
import type { Schemas, operations } from "#shopware";
|
|
13
22
|
|
|
14
23
|
const props = defineProps<{
|
|
@@ -16,6 +25,8 @@ const props = defineProps<{
|
|
|
16
25
|
listingType?: string;
|
|
17
26
|
}>();
|
|
18
27
|
|
|
28
|
+
const isProductSearch = props.listingType === "productSearchListing";
|
|
29
|
+
|
|
19
30
|
type Translations = {
|
|
20
31
|
listing: {
|
|
21
32
|
filters: string;
|
|
@@ -24,15 +35,6 @@ type Translations = {
|
|
|
24
35
|
};
|
|
25
36
|
};
|
|
26
37
|
|
|
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
38
|
let translations: Translations = {
|
|
37
39
|
listing: {
|
|
38
40
|
filters: "Filters",
|
|
@@ -52,17 +54,9 @@ const {
|
|
|
52
54
|
getInitialFilters,
|
|
53
55
|
getSortingOrders,
|
|
54
56
|
search,
|
|
55
|
-
} = useCategoryListing();
|
|
57
|
+
} = isProductSearch ? useProductSearchListing() : useCategoryListing();
|
|
56
58
|
|
|
57
|
-
const sidebarSelectedFilters
|
|
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
|
-
});
|
|
59
|
+
const sidebarSelectedFilters = useSelectedListingFilters();
|
|
66
60
|
|
|
67
61
|
const showResetFiltersButton = computed<boolean>(() => {
|
|
68
62
|
if (
|
|
@@ -92,51 +86,10 @@ const searchCriteriaForRequest: ComputedRef<Schemas["ProductListingCriteria"]> =
|
|
|
92
86
|
order: getCurrentSortingOrder.value as string,
|
|
93
87
|
"shipping-free": sidebarSelectedFilters["shipping-free"] as boolean,
|
|
94
88
|
rating: sidebarSelectedFilters.rating as number,
|
|
95
|
-
search: "",
|
|
96
|
-
limit:
|
|
89
|
+
search: isProductSearch ? (firstQueryValue(route.query.search) ?? "") : "",
|
|
90
|
+
limit: toNumber(firstQueryValue(route.query.limit)) ?? 15,
|
|
97
91
|
}));
|
|
98
92
|
|
|
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
93
|
const handleFilterChange = async (event: {
|
|
141
94
|
code: string;
|
|
142
95
|
value: string | number | boolean;
|
|
@@ -170,7 +123,12 @@ const handleFilterChange = async (event: {
|
|
|
170
123
|
|
|
171
124
|
const executeSearch = async () => {
|
|
172
125
|
try {
|
|
173
|
-
|
|
126
|
+
// Search context refetches from the URL (the page's useAsyncData watches
|
|
127
|
+
// route.query), so navigating below is enough; calling search() here too
|
|
128
|
+
// would double-fetch and flicker. Category listings have no URL watcher.
|
|
129
|
+
if (!isProductSearch) {
|
|
130
|
+
await search(searchCriteriaForRequest.value);
|
|
131
|
+
}
|
|
174
132
|
|
|
175
133
|
// Build query directly from searchCriteriaForRequest which already has pipe-separated strings
|
|
176
134
|
const criteria = searchCriteriaForRequest.value;
|
|
@@ -184,6 +142,12 @@ const executeSearch = async () => {
|
|
|
184
142
|
if (criteria["shipping-free"])
|
|
185
143
|
query["shipping-free"] = criteria["shipping-free"];
|
|
186
144
|
if (criteria.order) query.order = criteria.order;
|
|
145
|
+
// Keep the search term in the URL so filtering on the search page does not reset the query
|
|
146
|
+
if (isProductSearch && route.query.search)
|
|
147
|
+
query.search = route.query.search;
|
|
148
|
+
// Preserve the page-size selection; rebuilding the query from scratch would
|
|
149
|
+
// otherwise drop `limit` and revert to the default on refresh/navigation.
|
|
150
|
+
if (route.query.limit) query.limit = route.query.limit;
|
|
187
151
|
|
|
188
152
|
await router.push({
|
|
189
153
|
query: query as LocationQueryRaw,
|
|
@@ -213,10 +177,13 @@ const currentSortingOrder = computed({
|
|
|
213
177
|
},
|
|
214
178
|
});
|
|
215
179
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
180
|
+
// Search context refetches from the URL; category fetches directly.
|
|
181
|
+
if (!isProductSearch) {
|
|
182
|
+
await changeCurrentSortingOrder(order, {
|
|
183
|
+
...(route.query as unknown as operations["searchPage post /search"]["body"]),
|
|
184
|
+
limit: toNumber(firstQueryValue(route.query.limit)) ?? 15,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
220
187
|
} catch (error) {
|
|
221
188
|
console.error("Sorting order change failed:", error);
|
|
222
189
|
}
|
|
@@ -299,7 +266,9 @@ const hasActiveFilter = (filter: { code: string }) => {
|
|
|
299
266
|
type="button"
|
|
300
267
|
>
|
|
301
268
|
{{ translations.listing.resetFilters }}
|
|
302
|
-
<span
|
|
269
|
+
<span
|
|
270
|
+
class="w-5 h-5 i-carbon-close inline-block align-middle ml-1"
|
|
271
|
+
></span>
|
|
303
272
|
</SwBaseButton>
|
|
304
273
|
</div>
|
|
305
274
|
</div>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { defu } from "defu";
|
|
3
3
|
import { computed, onMounted, ref, toRefs } from "vue";
|
|
4
|
+
|
|
4
5
|
import {
|
|
5
6
|
useCmsTranslations,
|
|
6
7
|
useProductReviews,
|
|
@@ -77,7 +78,10 @@ const formatDate = (date: string) => {
|
|
|
77
78
|
v-for="(review, index) in reviewsList"
|
|
78
79
|
:key="review.id"
|
|
79
80
|
class="pb-6"
|
|
80
|
-
:class="{
|
|
81
|
+
:class="{
|
|
82
|
+
'border-b border-surface-surface-container-highest':
|
|
83
|
+
index < reviewsList.length - 1,
|
|
84
|
+
}"
|
|
81
85
|
>
|
|
82
86
|
<div
|
|
83
87
|
v-if="review.createdAt"
|
|
@@ -87,19 +91,23 @@ const formatDate = (date: string) => {
|
|
|
87
91
|
<span>{{ formatDate(review.createdAt) }}</span>
|
|
88
92
|
</div>
|
|
89
93
|
<div
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
94
|
+
v-if="!review.status"
|
|
95
|
+
class="mt-2 text-3 p-2 bg-states-info-container text-states-on-info-container flex gap-2 items-center"
|
|
96
|
+
>
|
|
93
97
|
<div class="w-6 h-6 i-carbon-warning" />
|
|
94
98
|
{{ translations.product.reviewNotAccepted }}
|
|
95
99
|
</div>
|
|
96
|
-
<div
|
|
100
|
+
<div
|
|
101
|
+
class="cms-block-product-description-reviews__reviews-rating inline-flex items-center mt-2"
|
|
102
|
+
>
|
|
97
103
|
<SwProductRating
|
|
98
104
|
:rating="review.points ?? 0"
|
|
99
105
|
:star-size="20"
|
|
100
106
|
:show-count="false"
|
|
101
107
|
/>
|
|
102
|
-
<div
|
|
108
|
+
<div
|
|
109
|
+
class="cms-block-product-description-reviews__reviews-title font-semibold ml-2"
|
|
110
|
+
>
|
|
103
111
|
<p>{{ review.title }}</p>
|
|
104
112
|
</div>
|
|
105
113
|
</div>
|
|
@@ -7,6 +7,7 @@ import type { ValidationRuleWithoutParams } from "@vuelidate/core";
|
|
|
7
7
|
import { minLength, required } from "@vuelidate/validators";
|
|
8
8
|
import { defu } from "defu";
|
|
9
9
|
import { computed, reactive, ref } from "vue";
|
|
10
|
+
|
|
10
11
|
import { useShopwareContext } from "#imports";
|
|
11
12
|
|
|
12
13
|
const props = defineProps<{
|
|
@@ -154,7 +155,10 @@ const invokeRating = (value: number) => {
|
|
|
154
155
|
</script>
|
|
155
156
|
|
|
156
157
|
<template>
|
|
157
|
-
<form
|
|
158
|
+
<form
|
|
159
|
+
class="flex flex-col gap-4 md:gap-5 relative"
|
|
160
|
+
@submit.prevent="invokeSend"
|
|
161
|
+
>
|
|
158
162
|
<div
|
|
159
163
|
v-if="isLoading"
|
|
160
164
|
class="absolute inset-0 flex items-center justify-center z-10 bg-surface-surface/80 rounded-md"
|
|
@@ -171,7 +175,11 @@ const invokeRating = (value: number) => {
|
|
|
171
175
|
<span class="text-sm text-surface-on-surface-variant">{{
|
|
172
176
|
translations.product.reviewsForm.rating
|
|
173
177
|
}}</span>
|
|
174
|
-
<div
|
|
178
|
+
<div
|
|
179
|
+
class="flex flex-row gap-2"
|
|
180
|
+
role="group"
|
|
181
|
+
:aria-label="translations.product.reviewsForm.rating"
|
|
182
|
+
>
|
|
175
183
|
<SwStarIcon
|
|
176
184
|
v-for="index in state.rating || 0"
|
|
177
185
|
:key="`filled-${index}`"
|
|
@@ -217,7 +225,11 @@ const invokeRating = (value: number) => {
|
|
|
217
225
|
<SwExclamationIcon :size="20" />
|
|
218
226
|
</div>
|
|
219
227
|
<div class="flex-1">
|
|
220
|
-
<p
|
|
228
|
+
<p
|
|
229
|
+
v-for="(error, index) in errorMessages"
|
|
230
|
+
:key="index"
|
|
231
|
+
class="text-sm text-states-error"
|
|
232
|
+
>
|
|
221
233
|
{{ error.detail }}
|
|
222
234
|
</p>
|
|
223
235
|
</div>
|