@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.
Files changed (107) hide show
  1. package/README.md +64 -58
  2. package/app/components/SwCategoryNavigation.vue +1 -0
  3. package/app/components/SwCategoryNavigationLink.vue +16 -8
  4. package/app/components/SwContactForm.vue +1 -0
  5. package/app/components/SwFilterChips.vue +1 -0
  6. package/app/components/SwListingProductPrice.vue +16 -4
  7. package/app/components/SwMedia3D.vue +1 -4
  8. package/app/components/SwNewsletterForm.vue +4 -3
  9. package/app/components/SwProductAddToCart.vue +24 -7
  10. package/app/components/SwProductCard.vue +20 -3
  11. package/app/components/SwProductCardDetails.vue +27 -9
  12. package/app/components/SwProductCardImage.vue +31 -12
  13. package/app/components/SwProductCardSkeleton.vue +37 -14
  14. package/app/components/SwProductGallery.vue +1 -0
  15. package/app/components/SwProductListingFilter.vue +5 -1
  16. package/app/components/SwProductListingFilters.vue +57 -76
  17. package/app/components/SwProductListingFiltersHorizontal.vue +40 -71
  18. package/app/components/SwProductPrice.vue +1 -0
  19. package/app/components/SwProductReviews.vue +14 -6
  20. package/app/components/SwProductReviewsForm.vue +15 -3
  21. package/app/components/SwProductUnits.vue +1 -0
  22. package/app/components/SwQuantitySelect.vue +10 -3
  23. package/app/components/SwSharedPrice.vue +1 -0
  24. package/app/components/SwSlider.vue +1 -0
  25. package/app/components/SwStockInfo.vue +12 -6
  26. package/app/components/SwVariantConfigurator.vue +4 -1
  27. package/app/components/listing-filters/SwFilterPrice.vue +79 -30
  28. package/app/components/listing-filters/SwFilterProperties.vue +40 -25
  29. package/app/components/listing-filters/SwFilterRating.vue +21 -9
  30. package/app/components/listing-filters/SwFilterShippingFree.vue +16 -8
  31. package/app/components/public/cms/CmsBlockSpatialViewer.vue +1 -0
  32. package/app/components/public/cms/CmsGenericBlock.vue +3 -1
  33. package/app/components/public/cms/CmsGenericElement.vue +2 -1
  34. package/app/components/public/cms/CmsNoComponent.vue +2 -1
  35. package/app/components/public/cms/CmsPage.md +1 -1
  36. package/app/components/public/cms/CmsPage.vue +2 -0
  37. package/app/components/public/cms/block/CmsBlockCategoryNavigation.vue +1 -0
  38. package/app/components/public/cms/block/CmsBlockCenterText.vue +3 -1
  39. package/app/components/public/cms/block/CmsBlockCrossSelling.vue +1 -0
  40. package/app/components/public/cms/block/CmsBlockCustomForm.vue +1 -0
  41. package/app/components/public/cms/block/CmsBlockForm.vue +1 -0
  42. package/app/components/public/cms/block/CmsBlockGalleryBuybox.vue +4 -1
  43. package/app/components/public/cms/block/CmsBlockImage.vue +1 -0
  44. package/app/components/public/cms/block/CmsBlockImageBubbleRow.vue +9 -2
  45. package/app/components/public/cms/block/CmsBlockImageCover.vue +1 -0
  46. package/app/components/public/cms/block/CmsBlockImageFourColumn.vue +10 -3
  47. package/app/components/public/cms/block/CmsBlockImageGallery.vue +1 -0
  48. package/app/components/public/cms/block/CmsBlockImageGalleryBig.vue +10 -5
  49. package/app/components/public/cms/block/CmsBlockImageHighlightRow.vue +11 -3
  50. package/app/components/public/cms/block/CmsBlockImageSimpleGrid.vue +1 -0
  51. package/app/components/public/cms/block/CmsBlockImageSlider.vue +1 -0
  52. package/app/components/public/cms/block/CmsBlockImageText.vue +4 -1
  53. package/app/components/public/cms/block/CmsBlockImageTextBubble.vue +4 -1
  54. package/app/components/public/cms/block/CmsBlockImageTextCover.vue +4 -1
  55. package/app/components/public/cms/block/CmsBlockImageTextGallery.vue +4 -12
  56. package/app/components/public/cms/block/CmsBlockImageTextRow.vue +13 -4
  57. package/app/components/public/cms/block/CmsBlockImageThreeColumn.vue +1 -0
  58. package/app/components/public/cms/block/CmsBlockImageThreeCover.vue +11 -3
  59. package/app/components/public/cms/block/CmsBlockImageTwoColumn.vue +11 -4
  60. package/app/components/public/cms/block/CmsBlockProductDescriptionReviews.vue +1 -0
  61. package/app/components/public/cms/block/CmsBlockProductHeading.vue +3 -1
  62. package/app/components/public/cms/block/CmsBlockProductListing.vue +1 -0
  63. package/app/components/public/cms/block/CmsBlockProductThreeColumn.vue +1 -0
  64. package/app/components/public/cms/block/CmsBlockSidebarFilter.vue +1 -0
  65. package/app/components/public/cms/block/CmsBlockText.vue +1 -0
  66. package/app/components/public/cms/block/CmsBlockTextHero.vue +1 -0
  67. package/app/components/public/cms/block/CmsBlockTextOnImage.vue +2 -4
  68. package/app/components/public/cms/block/CmsBlockTextThreeColumn.vue +1 -0
  69. package/app/components/public/cms/block/CmsBlockTextTwoColumn.vue +4 -1
  70. package/app/components/public/cms/block/CmsBlockVimeoVideo.vue +1 -0
  71. package/app/components/public/cms/block/CmsBlockYoutubeVideo.vue +1 -0
  72. package/app/components/public/cms/element/CmsElementBuyBox.vue +90 -72
  73. package/app/components/public/cms/element/CmsElementCategoryNavigation.vue +14 -3
  74. package/app/components/public/cms/element/CmsElementCrossSelling.vue +3 -1
  75. package/app/components/public/cms/element/CmsElementCustomForm.vue +1 -0
  76. package/app/components/public/cms/element/CmsElementForm.vue +1 -0
  77. package/app/components/public/cms/element/CmsElementHtml.vue +0 -1
  78. package/app/components/public/cms/element/CmsElementImage.vue +2 -1
  79. package/app/components/public/cms/element/CmsElementImageGallery.vue +6 -2
  80. package/app/components/public/cms/element/CmsElementManufacturerLogo.vue +1 -0
  81. package/app/components/public/cms/element/CmsElementProductBox.vue +1 -0
  82. package/app/components/public/cms/element/CmsElementProductDescriptionReviews.vue +93 -36
  83. package/app/components/public/cms/element/CmsElementProductListing.vue +16 -5
  84. package/app/components/public/cms/element/CmsElementProductSlider.vue +6 -4
  85. package/app/components/public/cms/element/CmsElementText.vue +2 -0
  86. package/app/components/public/cms/element/CmsElementVimeoVideo.vue +1 -0
  87. package/app/components/public/cms/element/CmsElementYoutubeVideo.vue +1 -0
  88. package/app/components/public/cms/element/SwProductListingPagination.vue +8 -2
  89. package/app/components/public/cms/section/CmsSectionSidebar.vue +13 -8
  90. package/app/components/public/cms/skeleton/ProductCardSkeleton.vue +16 -5
  91. package/app/components/ui/SwitchButton.vue +53 -14
  92. package/app/components/ui/WishlistIcon.vue +8 -2
  93. package/app/composables/useLcpImagePreload.test.ts +1 -0
  94. package/app/composables/useLcpImagePreload.ts +2 -0
  95. package/app/composables/useTypedAppConfig.ts +1 -0
  96. package/app/helpers/cms/getImageSizes.test.ts +1 -0
  97. package/app/helpers/html-to-vue/ast.ts +1 -0
  98. package/app/helpers/html-to-vue/getOptionsFromNode.test.ts +1 -0
  99. package/app/helpers/html-to-vue/renderToHtml.ts +1 -0
  100. package/app/helpers/html-to-vue/renderer.ts +1 -0
  101. package/app/providers/shopware.test.ts +1 -0
  102. package/app/utils/routeQuery.test.ts +65 -0
  103. package/app/utils/routeQuery.ts +26 -0
  104. package/app/utils/useSelectedListingFilters.test.ts +82 -0
  105. package/app/utils/useSelectedListingFilters.ts +105 -0
  106. package/nuxt.config.ts +24 -1
  107. 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 class="self-stretch min-h-[350px] relative flex flex-col justify-start items-start overflow-hidden aspect-square">
61
- <RouterLink :to="productLink" class="self-stretch h-full relative overflow-hidden">
62
- <NuxtImg preset="productCard"
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" :alt="coverAlt"
65
- width="400" height="400"
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 v-if="isTopseller || isOnSale"
72
- class="px-1.5 py-1 left-2 bottom-2 absolute bg-other-sale rounded inline-flex justify-center items-center">
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 type="secondary"
80
- :aria-label="isInWishlist ? translations.product.removeFromWishlist : translations.product.addToWishlist"
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" @click="handleWishlistClick">
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 class="inline-flex flex-col items-start justify-start self-stretch overflow-hidden p-px w-full"
3
- aria-hidden="true">
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 class="relative flex h-80 flex-col items-start justify-start self-stretch overflow-hidden">
6
- <div class="relative h-80 w-full bg-gray-200 dark:bg-gray-700 animate-pulse"></div>
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
- <span class="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded block"></span>
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 class="absolute top-4 right-4 h-10 w-10 rounded-full bg-gray-300 dark:bg-gray-600 animate-pulse"></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 class="h-4 w-32 rounded bg-gray-200 dark:bg-gray-700 animate-pulse"></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 class="h-7 w-3/4 rounded bg-gray-200 dark:bg-gray-700 mb-2 animate-pulse"></div>
22
- <div class="h-7 w-1/2 rounded bg-gray-200 dark:bg-gray-700 animate-pulse"></div>
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 class="h-6 w-24 rounded bg-gray-200 dark:bg-gray-700 animate-pulse"></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 class="flex-1 h-10 rounded bg-gray-200 dark:bg-gray-700 animate-pulse"></div>
29
- <div class="w-24 h-10 rounded bg-gray-200 dark:bg-gray-700 animate-pulse"></div>
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>
@@ -4,6 +4,7 @@ import type {
4
4
  SliderElementConfig,
5
5
  } from "@shopware/composables";
6
6
  import { ref, watch } from "vue";
7
+
7
8
  import type { Schemas } from "#shopware";
8
9
 
9
10
  const { product, config = {} } = defineProps<{
@@ -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
- }: { code: string; value: string | number | boolean }) => {
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, reactive } from "vue";
9
- import type { ComputedRef, UnwrapNestedRefs } from "vue";
8
+ import { computed } from "vue";
9
+ import type { ComputedRef } from "vue";
10
10
  import type { LocationQueryRaw } from "vue-router";
11
- import { useCategoryListing, useRoute, useRouter } from "#imports";
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: UnwrapNestedRefs<FilterState> =
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: route.query.limit ? Number(route.query.limit) : 15,
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
- await search(searchCriteriaForRequest.value);
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
- await changeCurrentSortingOrder(order, {
218
- ...(route.query as unknown as operations["searchPage post /search"]["body"]),
219
- limit: route.query.limit ? Number(route.query.limit) : 15,
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
- <div class="flex-1 text-surface-on-surface text-base font-bold leading-normal">
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 v-for="filter in getInitialFilters" :key="filter.id"
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 variant="primary" size="medium" block @click="invokeCleanFilters" type="button">
270
+ <SwBaseButton
271
+ variant="primary"
272
+ size="medium"
273
+ block
274
+ @click="invokeCleanFilters"
275
+ type="button"
276
+ >
298
277
  {{ translations.listing.resetFilters }}
299
- <span class="w-6 h-6 i-carbon-close-filled inline-block align-middle ml-2"></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, reactive } from "vue";
9
- import type { ComputedRef, UnwrapNestedRefs } from "vue";
8
+ import { computed } from "vue";
9
+ import type { ComputedRef } from "vue";
10
10
  import type { LocationQueryRaw } from "vue-router";
11
- import { useCategoryListing, useRoute, useRouter } from "#imports";
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: 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
- });
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: route.query.limit ? Number(route.query.limit) : 15,
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
- await search(searchCriteriaForRequest.value);
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
- 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
- });
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 class="w-5 h-5 i-carbon-close inline-block align-middle ml-1"></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>
@@ -2,6 +2,7 @@
2
2
  import { useCmsTranslations } from "@shopware/composables";
3
3
  import { defu } from "defu";
4
4
  import { toRefs } from "vue";
5
+
5
6
  import { usePrice, useProductPrice } from "#imports";
6
7
  import type { Schemas } from "#shopware";
7
8
 
@@ -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="{ 'border-b border-surface-surface-container-highest': index < reviewsList.length - 1 }"
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
- v-if="!review.status"
91
- class="mt-2 text-3 p-2 bg-states-info-container text-states-on-info-container flex gap-2 items-center"
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 class="cms-block-product-description-reviews__reviews-rating inline-flex items-center mt-2">
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 class="cms-block-product-description-reviews__reviews-title font-semibold ml-2">
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 class="flex flex-col gap-4 md:gap-5 relative" @submit.prevent="invokeSend">
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 class="flex flex-row gap-2" role="group" :aria-label="translations.product.reviewsForm.rating">
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 v-for="(error, index) in errorMessages" :key="index" class="text-sm text-states-error">
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>
@@ -2,6 +2,7 @@
2
2
  import { useCmsTranslations } from "@shopware/composables";
3
3
  import { defu } from "defu";
4
4
  import { computed } from "vue";
5
+
5
6
  import type { Schemas } from "#shopware";
6
7
 
7
8
  const { product, showContent = true } = defineProps<{