@shopware/cms-base-layer 3.1.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +79 -23
  2. package/app/app.config.ts +3 -3
  3. package/app/assets/css/rich-text.css +80 -0
  4. package/app/components/SwCategoryNavigationLink.vue +4 -5
  5. package/app/components/SwContactForm.vue +2 -1
  6. package/app/components/SwFilterChips.vue +29 -7
  7. package/app/components/SwMedia3D.vue +8 -3
  8. package/app/components/SwNewsletterForm.vue +2 -1
  9. package/app/components/SwProductAddToCart.vue +27 -4
  10. package/app/components/SwProductCard.vue +26 -4
  11. package/app/components/SwProductCardDetails.vue +9 -2
  12. package/app/components/SwProductCardImage.vue +5 -1
  13. package/app/components/SwProductListingFilter.vue +6 -1
  14. package/app/components/SwProductListingFilters.vue +13 -189
  15. package/app/components/SwProductListingFiltersHorizontal.vue +20 -172
  16. package/app/components/SwStockInfo.vue +5 -2
  17. package/app/components/SwVariantConfigurator.vue +8 -3
  18. package/app/components/listing-filters/SwFilterCategories.vue +158 -0
  19. package/app/components/public/cms/CmsBlockSpatialViewer.vue +7 -7
  20. package/app/components/public/cms/CmsGenericBlock.vue +6 -10
  21. package/app/components/public/cms/CmsGenericElement.md +11 -5
  22. package/app/components/public/cms/CmsGenericElement.vue +17 -11
  23. package/app/components/public/cms/FrontendAccountCustomerGroupRegistrationPage.vue +1 -1
  24. package/app/components/public/cms/block/CmsBlockAppRenderer.vue +48 -0
  25. package/app/components/public/cms/block/CmsBlockCategoryHeading.vue +19 -0
  26. package/app/components/public/cms/block/CmsBlockCategoryNavigation.vue +3 -2
  27. package/app/components/public/cms/block/CmsBlockCenterText.vue +5 -4
  28. package/app/components/public/cms/block/CmsBlockCrossSelling.vue +3 -2
  29. package/app/components/public/cms/block/CmsBlockCustomForm.vue +3 -2
  30. package/app/components/public/cms/block/CmsBlockForm.vue +3 -2
  31. package/app/components/public/cms/block/CmsBlockGalleryBuybox.vue +4 -3
  32. package/app/components/public/cms/block/CmsBlockImage.vue +3 -2
  33. package/app/components/public/cms/block/CmsBlockImageBubbleRow.vue +5 -4
  34. package/app/components/public/cms/block/CmsBlockImageCover.vue +3 -2
  35. package/app/components/public/cms/block/CmsBlockImageFourColumn.vue +6 -5
  36. package/app/components/public/cms/block/CmsBlockImageGallery.vue +3 -2
  37. package/app/components/public/cms/block/CmsBlockImageGalleryBig.vue +3 -2
  38. package/app/components/public/cms/block/CmsBlockImageHighlightRow.vue +5 -4
  39. package/app/components/public/cms/block/CmsBlockImageSimpleGrid.vue +5 -4
  40. package/app/components/public/cms/block/CmsBlockImageSlider.vue +3 -2
  41. package/app/components/public/cms/block/CmsBlockImageText.vue +4 -3
  42. package/app/components/public/cms/block/CmsBlockImageTextBubble.vue +8 -7
  43. package/app/components/public/cms/block/CmsBlockImageTextCover.vue +4 -3
  44. package/app/components/public/cms/block/CmsBlockImageTextGallery.vue +39 -16
  45. package/app/components/public/cms/block/CmsBlockImageTextRow.vue +8 -7
  46. package/app/components/public/cms/block/CmsBlockImageThreeColumn.vue +5 -4
  47. package/app/components/public/cms/block/CmsBlockImageThreeCover.vue +5 -4
  48. package/app/components/public/cms/block/CmsBlockImageTwoColumn.vue +4 -3
  49. package/app/components/public/cms/block/CmsBlockProductDescriptionReviews.vue +3 -2
  50. package/app/components/public/cms/block/CmsBlockProductHeading.vue +4 -3
  51. package/app/components/public/cms/block/CmsBlockProductListing.vue +3 -2
  52. package/app/components/public/cms/block/CmsBlockProductThreeColumn.vue +5 -4
  53. package/app/components/public/cms/block/CmsBlockSidebarFilter.vue +7 -5
  54. package/app/components/public/cms/block/CmsBlockText.vue +3 -2
  55. package/app/components/public/cms/block/CmsBlockTextHero.vue +3 -2
  56. package/app/components/public/cms/block/CmsBlockTextOnImage.vue +3 -2
  57. package/app/components/public/cms/block/CmsBlockTextThreeColumn.vue +5 -4
  58. package/app/components/public/cms/block/CmsBlockTextTwoColumn.vue +4 -3
  59. package/app/components/public/cms/block/CmsBlockVideo.vue +19 -0
  60. package/app/components/public/cms/block/CmsBlockVimeoVideo.vue +3 -2
  61. package/app/components/public/cms/block/CmsBlockYoutubeVideo.vue +3 -2
  62. package/app/components/public/cms/element/CmsElementBuyBox.vue +4 -1
  63. package/app/components/public/cms/element/CmsElementCategoryName.md +8 -0
  64. package/app/components/public/cms/element/CmsElementCategoryName.vue +35 -0
  65. package/app/components/public/cms/element/CmsElementCrossSelling.vue +2 -1
  66. package/app/components/public/cms/element/CmsElementHtml.vue +1 -1
  67. package/app/components/public/cms/element/CmsElementImage.vue +41 -1
  68. package/app/components/public/cms/element/CmsElementImageGallery.vue +2 -1
  69. package/app/components/public/cms/element/CmsElementProductDescriptionReviews.vue +2 -2
  70. package/app/components/public/cms/element/CmsElementProductListing.vue +58 -25
  71. package/app/components/public/cms/element/CmsElementText.md +77 -0
  72. package/app/components/public/cms/element/CmsElementText.vue +15 -41
  73. package/app/components/public/cms/element/CmsElementVideo.md +27 -0
  74. package/app/components/public/cms/element/CmsElementVideo.vue +193 -0
  75. package/app/components/public/cms/section/CmsSectionSidebar.vue +4 -4
  76. package/app/composables/useListingFilters.ts +189 -0
  77. package/app/composables/useSelectedListingFilters.ts +46 -0
  78. package/app/helpers/cms/getAppRendererLayout.test.ts +93 -0
  79. package/app/helpers/cms/getAppRendererLayout.ts +36 -0
  80. package/app/helpers/cms/getCategoryNameContent.test.ts +57 -0
  81. package/app/helpers/cms/getCategoryNameContent.ts +15 -0
  82. package/app/helpers/cms/getTextElementContent.test.ts +79 -0
  83. package/app/helpers/cms/getTextElementContent.ts +37 -0
  84. package/app/helpers/cms/getVideoElementOptions.test.ts +384 -0
  85. package/app/helpers/cms/getVideoElementOptions.ts +142 -0
  86. package/app/helpers/html-to-vue/renderToHtml.test.ts +71 -0
  87. package/app/helpers/html-to-vue/renderToHtml.ts +5 -1
  88. package/app/helpers/html-to-vue/renderer.ts +13 -4
  89. package/app/helpers/product/shouldShowBuyButton.test.ts +21 -0
  90. package/app/helpers/product/shouldShowBuyButton.ts +6 -0
  91. package/app/utils/{useSelectedListingFilters.test.ts → listingFilterState.test.ts} +14 -4
  92. package/app/utils/{useSelectedListingFilters.ts → listingFilterState.ts} +4 -33
  93. package/app/utils/listingFilters.test.ts +39 -0
  94. package/app/utils/listingFilters.ts +19 -0
  95. package/component-dirs.json +20 -0
  96. package/index.d.ts +2 -2
  97. package/nuxt.config.ts +11 -28
  98. package/package.json +23 -26
  99. package/dist/index.d.mts +0 -5
  100. package/dist/index.d.ts +0 -5
  101. package/dist/index.mjs +0 -31
  102. package/index.cjs +0 -7
@@ -2,6 +2,7 @@
2
2
  import { computed } from "vue";
3
3
  import type { Component } from "vue";
4
4
 
5
+ import SwFilterCategoriesVue from "./listing-filters/SwFilterCategories.vue";
5
6
  import SwFilterPriceVue from "./listing-filters/SwFilterPrice.vue";
6
7
  import SwFilterPropertiesVue from "./listing-filters/SwFilterProperties.vue";
7
8
  import SwFilterRatingVue from "./listing-filters/SwFilterRating.vue";
@@ -11,6 +12,7 @@ const {
11
12
  filter,
12
13
  selectedManufacturer,
13
14
  selectedProperties,
15
+ selectedCategories = new Set<string>(),
14
16
  selectedMinPrice,
15
17
  selectedMaxPrice,
16
18
  selectedRating,
@@ -20,6 +22,7 @@ const {
20
22
  filter: ListingFilter;
21
23
  selectedManufacturer: Set<string>;
22
24
  selectedProperties: Set<string>;
25
+ selectedCategories?: Set<string>;
23
26
  selectedMinPrice: number | undefined;
24
27
  selectedMaxPrice: number | undefined;
25
28
  selectedRating: number | undefined;
@@ -40,11 +43,13 @@ const transformedFilters = computed(() => ({
40
43
  "shipping-free": selectedShippingFree,
41
44
  manufacturer: [...selectedManufacturer],
42
45
  properties: [...selectedProperties],
46
+ categories: [...selectedCategories],
43
47
  }));
44
48
 
45
49
  const filterComponent = computed<Component | undefined>(() => {
46
50
  const componentMap: Record<string, Component> = {
47
51
  manufacturer: SwFilterPropertiesVue,
52
+ categories: SwFilterCategoriesVue,
48
53
  price: SwFilterPriceVue,
49
54
  rating: SwFilterRatingVue,
50
55
  "shipping-free": SwFilterShippingFreeVue,
@@ -67,7 +72,7 @@ const handleSelectValue = ({
67
72
  };
68
73
  </script>
69
74
  <template>
70
- <div>
75
+ <div :data-testid="`listing-filter-${filter.code}`">
71
76
  <component
72
77
  :is="filterComponent"
73
78
  :filter="filter"
@@ -5,20 +5,8 @@ import type {
5
5
  } from "@shopware/composables";
6
6
  import { useCmsTranslations } from "@shopware/composables";
7
7
  import { defu } from "defu";
8
- import { computed } from "vue";
9
- import type { ComputedRef } from "vue";
10
- import type { LocationQueryRaw } from "vue-router";
11
8
 
12
- import {
13
- firstQueryValue,
14
- toNumber,
15
- useCategoryListing,
16
- useProductSearchListing,
17
- useRoute,
18
- useRouter,
19
- useSelectedListingFilters,
20
- } from "#imports";
21
- import type { Schemas, operations } from "#shopware";
9
+ import { useListingFilters } from "#imports";
22
10
 
23
11
  const props = defineProps<{
24
12
  content?: CmsElementProductListing | CmsElementSidebarFilter;
@@ -45,190 +33,25 @@ let translations: Translations = {
45
33
 
46
34
  translations = defu(useCmsTranslations(), translations) as Translations;
47
35
 
48
- const route = useRoute();
49
- const router = useRouter();
50
-
51
36
  const {
52
- changeCurrentSortingOrder,
53
- getCurrentFilters,
37
+ currentSortingOrder,
54
38
  getCurrentSortingOrder,
55
- getInitialFilters,
56
39
  getSortingOrders,
57
- search,
58
- } = isProductSearch ? useProductSearchListing() : useCategoryListing();
59
-
60
- const sidebarSelectedFilters = useSelectedListingFilters();
61
-
62
- const showResetFiltersButton = computed<boolean>(() => {
63
- if (
64
- sidebarSelectedFilters.manufacturer.size !== 0 ||
65
- sidebarSelectedFilters.properties.size !== 0 ||
66
- sidebarSelectedFilters["max-price"] ||
67
- sidebarSelectedFilters["min-price"] ||
68
- sidebarSelectedFilters.rating ||
69
- sidebarSelectedFilters["shipping-free"]
70
- ) {
71
- return true;
72
- }
73
-
74
- return false;
75
- });
76
-
77
- const searchCriteriaForRequest: ComputedRef<Schemas["ProductListingCriteria"]> =
78
- computed(() => ({
79
- manufacturer: [
80
- ...(sidebarSelectedFilters.manufacturer as Set<string>),
81
- ]?.join("|"),
82
- properties: [...(sidebarSelectedFilters.properties as Set<string>)]?.join(
83
- "|",
84
- ),
85
- "min-price": sidebarSelectedFilters["min-price"] as number,
86
- "max-price": sidebarSelectedFilters["max-price"] as number,
87
- order: getCurrentSortingOrder.value as string,
88
- "shipping-free": sidebarSelectedFilters["shipping-free"] as boolean,
89
- rating: sidebarSelectedFilters.rating as number,
90
- search: isProductSearch ? (firstQueryValue(route.query.search) ?? "") : "",
91
- limit: toNumber(firstQueryValue(route.query.limit)) ?? 15,
92
- }));
93
-
94
- const handleFilterChange = async (event: {
95
- code: string;
96
- value: string | number | boolean;
97
- }) => {
98
- try {
99
- const { code, value } = event;
100
-
101
- if (code === "manufacturer" || code === "properties") {
102
- const filterSet = sidebarSelectedFilters[code];
103
- const stringValue = String(value);
104
-
105
- if (filterSet.has(stringValue)) {
106
- filterSet.delete(stringValue);
107
- } else {
108
- filterSet.add(stringValue);
109
- }
110
- } else if (code === "min-price" || code === "max-price") {
111
- sidebarSelectedFilters[code] =
112
- typeof value === "number" ? value : Number(value);
113
- } else if (code === "rating") {
114
- sidebarSelectedFilters.rating = Number(value);
115
- } else if (code === "shipping-free") {
116
- sidebarSelectedFilters["shipping-free"] = Boolean(value);
117
- }
118
-
119
- await executeSearch();
120
- } catch (error) {
121
- console.error("Filter update failed:", error);
122
- }
123
- };
124
-
125
- const executeSearch = async () => {
126
- try {
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
- }
133
-
134
- // Build query directly from searchCriteriaForRequest which already has pipe-separated strings
135
- const criteria = searchCriteriaForRequest.value;
136
- const query: Record<string, unknown> = {};
137
-
138
- if (criteria.manufacturer) query.manufacturer = criteria.manufacturer;
139
- if (criteria.properties) query.properties = criteria.properties;
140
- if (criteria["min-price"]) query["min-price"] = criteria["min-price"];
141
- if (criteria["max-price"]) query["max-price"] = criteria["max-price"];
142
- if (criteria.rating) query.rating = criteria.rating;
143
- if (criteria["shipping-free"])
144
- query["shipping-free"] = criteria["shipping-free"];
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;
152
-
153
- await router.push({
154
- query: query as LocationQueryRaw,
155
- });
156
- } catch (error) {
157
- console.error("Search execution failed:", error);
158
- }
159
- };
160
-
161
- const clearFilters = () => {
162
- (sidebarSelectedFilters.manufacturer as Set<string>).clear();
163
- (sidebarSelectedFilters.properties as Set<string>).clear();
164
- sidebarSelectedFilters["min-price"] = undefined;
165
- sidebarSelectedFilters["max-price"] = undefined;
166
- sidebarSelectedFilters.rating = undefined;
167
- sidebarSelectedFilters["shipping-free"] = undefined;
168
- };
169
-
170
- const currentSortingOrder = computed({
171
- get: (): string => getCurrentSortingOrder.value || "",
172
- set: async (order: string): Promise<void> => {
173
- try {
174
- await router.push({
175
- query: {
176
- ...route.query,
177
- order,
178
- },
179
- });
180
-
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
- }
188
- } catch (error) {
189
- console.error("Sorting order change failed:", error);
190
- }
191
- },
192
- });
193
-
194
- async function invokeCleanFilters() {
195
- try {
196
- clearFilters();
197
- await executeSearch();
198
- } catch (error) {
199
- console.error("Clear filters failed:", error);
200
- }
201
- }
202
-
203
- const handleSortChange = (sortKey: string) => {
204
- currentSortingOrder.value = sortKey;
205
- };
206
-
207
- const handleRemoveFilterChip = async (chip: {
208
- code: string;
209
- value: string | number;
210
- }) => {
211
- if (chip.code === "properties" || chip.code === "manufacturer") {
212
- const filterSet = sidebarSelectedFilters[chip.code] as Set<string>;
213
- filterSet.delete(String(chip.value));
214
- } else if (chip.code === "price") {
215
- sidebarSelectedFilters["min-price"] = undefined;
216
- sidebarSelectedFilters["max-price"] = undefined;
217
- } else if (chip.code === "rating") {
218
- sidebarSelectedFilters.rating = undefined;
219
- } else if (chip.code === "shipping-free") {
220
- sidebarSelectedFilters["shipping-free"] = undefined;
221
- }
222
-
223
- await executeSearch();
224
- };
40
+ handleFilterChange,
41
+ handleRemoveFilterChip,
42
+ handleSortChange,
43
+ invokeCleanFilters,
44
+ selectedFilters: sidebarSelectedFilters,
45
+ showResetFiltersButton,
46
+ visibleFilters,
47
+ } = useListingFilters(isProductSearch);
225
48
  </script>
226
49
  <template>
227
50
  <div>
228
51
  <!-- Active Filter Chips -->
229
52
  <SwFilterChips
230
53
  :filters="sidebarSelectedFilters"
231
- :available-filters="getInitialFilters"
54
+ :available-filters="visibleFilters"
232
55
  @remove="handleRemoveFilterChip"
233
56
  />
234
57
 
@@ -254,11 +77,12 @@ const handleRemoveFilterChip = async (chip: {
254
77
  <!-- Filters List -->
255
78
  <div class="self-stretch flex flex-col justify-start items-start gap-4">
256
79
  <SwProductListingFilter
257
- v-for="filter in getInitialFilters"
80
+ v-for="filter in visibleFilters"
258
81
  :key="filter.id"
259
82
  :filter="filter"
260
83
  :selected-manufacturer="sidebarSelectedFilters.manufacturer"
261
84
  :selected-properties="sidebarSelectedFilters.properties"
85
+ :selected-categories="sidebarSelectedFilters.categories"
262
86
  :selected-min-price="sidebarSelectedFilters['min-price']"
263
87
  :selected-max-price="sidebarSelectedFilters['max-price']"
264
88
  :selected-rating="sidebarSelectedFilters.rating"
@@ -5,20 +5,8 @@ import type {
5
5
  } from "@shopware/composables";
6
6
  import { useCmsTranslations } from "@shopware/composables";
7
7
  import { defu } from "defu";
8
- import { computed } from "vue";
9
- import type { ComputedRef } from "vue";
10
- import type { LocationQueryRaw } from "vue-router";
11
8
 
12
- import {
13
- firstQueryValue,
14
- toNumber,
15
- useCategoryListing,
16
- useProductSearchListing,
17
- useRoute,
18
- useRouter,
19
- useSelectedListingFilters,
20
- } from "#imports";
21
- import type { Schemas, operations } from "#shopware";
9
+ import { useListingFilters } from "#imports";
22
10
 
23
11
  const props = defineProps<{
24
12
  content: CmsElementProductListing | CmsElementSidebarFilter;
@@ -45,169 +33,27 @@ let translations: Translations = {
45
33
 
46
34
  translations = defu(useCmsTranslations(), translations) as Translations;
47
35
 
48
- const route = useRoute();
49
- const router = useRouter();
50
-
51
36
  const {
52
- changeCurrentSortingOrder,
37
+ currentSortingOrder,
53
38
  getCurrentSortingOrder,
54
- getInitialFilters,
55
39
  getSortingOrders,
56
- search,
57
- } = isProductSearch ? useProductSearchListing() : useCategoryListing();
58
-
59
- const sidebarSelectedFilters = useSelectedListingFilters();
60
-
61
- const showResetFiltersButton = computed<boolean>(() => {
62
- if (
63
- sidebarSelectedFilters.manufacturer.size !== 0 ||
64
- sidebarSelectedFilters.properties.size !== 0 ||
65
- sidebarSelectedFilters["max-price"] ||
66
- sidebarSelectedFilters["min-price"] ||
67
- sidebarSelectedFilters.rating ||
68
- sidebarSelectedFilters["shipping-free"]
69
- ) {
70
- return true;
71
- }
72
-
73
- return false;
74
- });
75
-
76
- const searchCriteriaForRequest: ComputedRef<Schemas["ProductListingCriteria"]> =
77
- computed(() => ({
78
- manufacturer: [
79
- ...(sidebarSelectedFilters.manufacturer as Set<string>),
80
- ]?.join("|"),
81
- properties: [...(sidebarSelectedFilters.properties as Set<string>)]?.join(
82
- "|",
83
- ),
84
- "min-price": sidebarSelectedFilters["min-price"] as number,
85
- "max-price": sidebarSelectedFilters["max-price"] as number,
86
- order: getCurrentSortingOrder.value as string,
87
- "shipping-free": sidebarSelectedFilters["shipping-free"] as boolean,
88
- rating: sidebarSelectedFilters.rating as number,
89
- search: isProductSearch ? (firstQueryValue(route.query.search) ?? "") : "",
90
- limit: toNumber(firstQueryValue(route.query.limit)) ?? 15,
91
- }));
92
-
93
- const handleFilterChange = async (event: {
94
- code: string;
95
- value: string | number | boolean;
96
- }) => {
97
- try {
98
- const { code, value } = event;
99
-
100
- if (code === "manufacturer" || code === "properties") {
101
- const filterSet = sidebarSelectedFilters[code];
102
- const stringValue = String(value);
103
-
104
- if (filterSet.has(stringValue)) {
105
- filterSet.delete(stringValue);
106
- } else {
107
- filterSet.add(stringValue);
108
- }
109
- } else if (code === "min-price" || code === "max-price") {
110
- sidebarSelectedFilters[code] =
111
- typeof value === "number" ? value : Number(value);
112
- } else if (code === "rating") {
113
- sidebarSelectedFilters.rating = Number(value);
114
- } else if (code === "shipping-free") {
115
- sidebarSelectedFilters["shipping-free"] = Boolean(value);
116
- }
117
-
118
- await executeSearch();
119
- } catch (error) {
120
- console.error("Filter update failed:", error);
121
- }
122
- };
123
-
124
- const executeSearch = async () => {
125
- try {
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
- }
132
-
133
- // Build query directly from searchCriteriaForRequest which already has pipe-separated strings
134
- const criteria = searchCriteriaForRequest.value;
135
- const query: Record<string, unknown> = {};
136
-
137
- if (criteria.manufacturer) query.manufacturer = criteria.manufacturer;
138
- if (criteria.properties) query.properties = criteria.properties;
139
- if (criteria["min-price"]) query["min-price"] = criteria["min-price"];
140
- if (criteria["max-price"]) query["max-price"] = criteria["max-price"];
141
- if (criteria.rating) query.rating = criteria.rating;
142
- if (criteria["shipping-free"])
143
- query["shipping-free"] = criteria["shipping-free"];
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;
151
-
152
- await router.push({
153
- query: query as LocationQueryRaw,
154
- });
155
- } catch (error) {
156
- console.error("Search execution failed:", error);
157
- }
158
- };
159
-
160
- const clearFilters = () => {
161
- (sidebarSelectedFilters.manufacturer as Set<string>).clear();
162
- (sidebarSelectedFilters.properties as Set<string>).clear();
163
- sidebarSelectedFilters["min-price"] = undefined;
164
- sidebarSelectedFilters["max-price"] = undefined;
165
- sidebarSelectedFilters.rating = undefined;
166
- sidebarSelectedFilters["shipping-free"] = undefined;
167
- };
168
-
169
- const currentSortingOrder = computed({
170
- get: (): string => getCurrentSortingOrder.value || "",
171
- set: async (order: string): Promise<void> => {
172
- try {
173
- await router.push({
174
- query: {
175
- ...route.query,
176
- order,
177
- },
178
- });
179
-
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
- }
187
- } catch (error) {
188
- console.error("Sorting order change failed:", error);
189
- }
190
- },
191
- });
192
-
193
- async function invokeCleanFilters() {
194
- try {
195
- clearFilters();
196
- await executeSearch();
197
- } catch (error) {
198
- console.error("Clear filters failed:", error);
199
- }
200
- }
201
-
202
- const handleSortChange = (sortKey: string) => {
203
- currentSortingOrder.value = sortKey;
204
- };
205
-
206
- // Helper to check if a filter has active selections
40
+ handleFilterChange,
41
+ handleRemoveFilterChip,
42
+ handleSortChange,
43
+ invokeCleanFilters,
44
+ selectedFilters: sidebarSelectedFilters,
45
+ showResetFiltersButton,
46
+ visibleFilters,
47
+ } = useListingFilters(isProductSearch);
48
+
49
+ // Whether a filter has an active selection, for the collapsed chip state.
207
50
  const hasActiveFilter = (filter: { code: string }) => {
208
51
  if (filter.code === "manufacturer") {
209
52
  return sidebarSelectedFilters.manufacturer.size > 0;
210
53
  }
54
+ if (filter.code === "categories") {
55
+ return sidebarSelectedFilters.categories.size > 0;
56
+ }
211
57
  if (filter.code === "price") {
212
58
  return (
213
59
  sidebarSelectedFilters["min-price"] !== undefined ||
@@ -218,9 +64,10 @@ const hasActiveFilter = (filter: { code: string }) => {
218
64
  return sidebarSelectedFilters.rating !== undefined;
219
65
  }
220
66
  if (filter.code === "shipping-free") {
221
- return sidebarSelectedFilters["shipping-free"] === true;
67
+ return sidebarSelectedFilters["shipping-free"] !== undefined;
222
68
  }
223
- // Properties filter - check if any property from this filter group is selected
69
+ // Any other code is a property group. Each carries its own code and only
70
+ // normalises to "properties" when it emits, so this has to stay a fallback.
224
71
  return sidebarSelectedFilters.properties.size > 0;
225
72
  };
226
73
  </script>
@@ -231,7 +78,7 @@ const hasActiveFilter = (filter: { code: string }) => {
231
78
  <div class="flex flex-wrap items-center justify-start gap-4 z-10">
232
79
  <!-- Filter dropdowns -->
233
80
  <SwFilterDropdown
234
- v-for="filter in getInitialFilters"
81
+ v-for="filter in visibleFilters"
235
82
  :key="filter.id"
236
83
  :label="filter.label"
237
84
  :is-active="hasActiveFilter(filter)"
@@ -241,6 +88,7 @@ const hasActiveFilter = (filter: { code: string }) => {
241
88
  display-mode="dropdown"
242
89
  :selected-manufacturer="sidebarSelectedFilters.manufacturer"
243
90
  :selected-properties="sidebarSelectedFilters.properties"
91
+ :selected-categories="sidebarSelectedFilters.categories"
244
92
  :selected-min-price="sidebarSelectedFilters['min-price']"
245
93
  :selected-max-price="sidebarSelectedFilters['max-price']"
246
94
  :selected-rating="sidebarSelectedFilters.rating"
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { getTranslatedProperty } from "@shopware/helpers";
2
3
  import { defu } from "defu";
3
4
 
4
5
  import { useCmsTranslations } from "#imports";
@@ -37,13 +38,15 @@ translations = defu(useCmsTranslations(), translations) as Translations;
37
38
  ></div>
38
39
  <div class="w-2 h-2 bg-states-error rounded-full" v-else></div>
39
40
  <span v-if="availableStock >= minPurchase && deliveryTime"
40
- >{{ translations.product.deliveryTime }} {{ deliveryTime?.name }}
41
+ >{{ translations.product.deliveryTime }}
42
+ {{ getTranslatedProperty(deliveryTime, "name") }}
41
43
  </span>
42
44
  <span
43
45
  v-else-if="availableStock < minPurchase && deliveryTime && restockTime"
44
46
  >
45
47
  {{ translations.product.deliveryTime }} {{ restockTime }}
46
- {{ translations.product.days }} {{ deliveryTime?.name }}</span
48
+ {{ translations.product.days }}
49
+ {{ getTranslatedProperty(deliveryTime, "name") }}</span
47
50
  >
48
51
  <span v-else>{{ translations.product.noAvailable }}</span>
49
52
  </div>
@@ -1,6 +1,10 @@
1
1
  <script setup lang="ts">
2
2
  import { useCmsTranslations } from "@shopware/composables";
3
- import { buildUrlPrefix, getProductRoute } from "@shopware/helpers";
3
+ import {
4
+ buildUrlPrefix,
5
+ getProductRoute,
6
+ getTranslatedProperty,
7
+ } from "@shopware/helpers";
4
8
  import { defu } from "defu";
5
9
  import { computed, ref, unref } from "vue";
6
10
  import type { ComputedRef } from "vue";
@@ -86,11 +90,12 @@ const onHandleChange = async () => {
86
90
  class="mt-6"
87
91
  >
88
92
  <div class="text-sm text-gray-900 font-medium">
89
- {{ optionGroup.name }}
93
+ {{ getTranslatedProperty(optionGroup, "name") }}
90
94
  </div>
91
95
  <fieldset class="mt-4 flex-1">
92
96
  <legend class="sr-only">
93
- {{ translations.product.chooseA }} {{ optionGroup.name }}
97
+ {{ translations.product.chooseA }}
98
+ {{ getTranslatedProperty(optionGroup, "name") }}
94
99
  </legend>
95
100
  <div class="flex gap-3">
96
101
  <label