@shopware/cms-base-layer 1.5.0 → 2.0.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 (184) hide show
  1. package/README.md +328 -13
  2. package/app/app.config.ts +7 -0
  3. package/app/assets/icons/check-circle.svg +3 -0
  4. package/app/assets/icons/checkmark.svg +3 -0
  5. package/app/assets/icons/chevron.svg +3 -0
  6. package/app/assets/icons/exclamation-circle.svg +3 -0
  7. package/app/assets/icons/star-empty.svg +3 -0
  8. package/app/assets/icons/star-filled.svg +3 -0
  9. package/app/assets/icons/user.svg +1 -0
  10. package/app/components/SwCategoryNavigation.vue +76 -0
  11. package/app/components/SwCategoryNavigationLink.vue +128 -0
  12. package/{components → app/components}/SwContactForm.vue +27 -27
  13. package/app/components/SwFilterChips.vue +144 -0
  14. package/app/components/SwListingProductPrice.vue +89 -0
  15. package/{components → app/components}/SwNewsletterForm.vue +45 -34
  16. package/{components → app/components}/SwPagination.vue +3 -5
  17. package/{components → app/components}/SwProductAddToCart.vue +22 -27
  18. package/app/components/SwProductCard.vue +170 -0
  19. package/app/components/SwProductCardDetails.vue +57 -0
  20. package/app/components/SwProductCardImage.vue +87 -0
  21. package/app/components/SwProductCardSkeleton.vue +33 -0
  22. package/app/components/SwProductListingFilter.vue +64 -0
  23. package/app/components/SwProductListingFilters.vue +308 -0
  24. package/{components → app/components}/SwProductReviews.vue +28 -13
  25. package/app/components/SwProductReviewsForm.vue +292 -0
  26. package/app/components/SwQuantitySelect.vue +106 -0
  27. package/{components → app/components}/SwSlider.vue +4 -4
  28. package/app/components/SwSortDropdown.vue +83 -0
  29. package/app/components/SwStockInfo.vue +44 -0
  30. package/{components → app/components}/SwVariantConfigurator.vue +1 -1
  31. package/app/components/listing-filters/SwFilterPrice.vue +214 -0
  32. package/app/components/listing-filters/SwFilterProperties.vue +113 -0
  33. package/app/components/listing-filters/SwFilterRating.vue +90 -0
  34. package/app/components/listing-filters/SwFilterShippingFree.vue +107 -0
  35. package/{components → app/components}/public/cms/CmsPage.vue +19 -4
  36. package/{components → app/components}/public/cms/block/CmsBlockGalleryBuybox.vue +5 -5
  37. package/{components → app/components}/public/cms/block/CmsBlockImageBubbleRow.vue +5 -5
  38. package/app/components/public/cms/block/CmsBlockImageFourColumn.vue +41 -0
  39. package/app/components/public/cms/block/CmsBlockImageGalleryBig.vue +42 -0
  40. package/app/components/public/cms/block/CmsBlockImageHighlightRow.vue +37 -0
  41. package/{components → app/components}/public/cms/block/CmsBlockImageSimpleGrid.vue +11 -5
  42. package/{components → app/components}/public/cms/block/CmsBlockImageText.vue +7 -3
  43. package/{components → app/components}/public/cms/block/CmsBlockImageTextBubble.vue +13 -16
  44. package/{components → app/components}/public/cms/block/CmsBlockImageTextCover.vue +7 -9
  45. package/app/components/public/cms/block/CmsBlockImageTextGallery.vue +88 -0
  46. package/app/components/public/cms/block/CmsBlockImageTextRow.vue +53 -0
  47. package/{components → app/components}/public/cms/block/CmsBlockImageThreeColumn.vue +10 -4
  48. package/app/components/public/cms/block/CmsBlockImageThreeCover.vue +37 -0
  49. package/app/components/public/cms/block/CmsBlockImageTwoColumn.vue +37 -0
  50. package/{components → app/components}/public/cms/block/CmsBlockProductHeading.vue +1 -1
  51. package/{components → app/components}/public/cms/block/CmsBlockProductThreeColumn.vue +10 -4
  52. package/{components → app/components}/public/cms/block/CmsBlockSidebarFilter.vue +3 -1
  53. package/app/components/public/cms/block/CmsBlockTextOnImage.vue +30 -0
  54. package/{components → app/components}/public/cms/block/CmsBlockTextTeaserSection.vue +4 -4
  55. package/{components → app/components}/public/cms/block/CmsBlockTextTwoColumn.vue +3 -5
  56. package/app/components/public/cms/element/CmsElementBuyBox.vue +145 -0
  57. package/app/components/public/cms/element/CmsElementCategoryNavigation.vue +53 -0
  58. package/{components → app/components}/public/cms/element/CmsElementCrossSelling.vue +3 -3
  59. package/{components → app/components}/public/cms/element/CmsElementImage.vue +52 -13
  60. package/app/components/public/cms/element/CmsElementImageGallery.vue +158 -0
  61. package/{components → app/components}/public/cms/element/CmsElementImageSlider.vue +2 -2
  62. package/{components → app/components}/public/cms/element/CmsElementProductBox.vue +2 -1
  63. package/app/components/public/cms/element/CmsElementProductDescriptionReviews.vue +217 -0
  64. package/{components → app/components}/public/cms/element/CmsElementProductListing.vue +23 -94
  65. package/app/components/public/cms/element/CmsElementProductName.vue +11 -0
  66. package/{components → app/components}/public/cms/element/CmsElementProductSlider.vue +4 -4
  67. package/{components → app/components}/public/cms/element/CmsElementText.vue +8 -2
  68. package/{components → app/components}/public/cms/element/CmsElementYoutubeVideo.vue +8 -2
  69. package/app/components/public/cms/element/SwProductListingPagination.vue +70 -0
  70. package/{components → app/components}/public/cms/section/CmsSectionDefault.vue +1 -1
  71. package/app/components/public/cms/section/CmsSectionSidebar.vue +36 -0
  72. package/app/components/public/cms/skeleton/ProductCardSkeleton.vue +28 -0
  73. package/app/components/ui/BaseButton.vue +99 -0
  74. package/app/components/ui/BaseIcon.vue +15 -0
  75. package/app/components/ui/Checkbox.vue +49 -0
  76. package/app/components/ui/CheckmarkIcon.vue +23 -0
  77. package/app/components/ui/ChevronIcon.vue +37 -0
  78. package/app/components/ui/ExclamationIcon.vue +11 -0
  79. package/app/components/ui/IconButton.vue +32 -0
  80. package/app/components/ui/RadioButton.vue +26 -0
  81. package/app/components/ui/StarIcon.vue +18 -0
  82. package/app/components/ui/SwitchButton.vue +100 -0
  83. package/app/components/ui/UserIcon.vue +11 -0
  84. package/app/components/ui/WishlistIcon.vue +20 -0
  85. package/app/composables/useImagePlaceholder.ts +27 -0
  86. package/{helpers → app/helpers}/clientOnly.ts +5 -0
  87. package/app/providers/shopware.test.ts +213 -0
  88. package/app/providers/shopware.ts +107 -0
  89. package/dist/index.d.mts +3 -3
  90. package/dist/index.d.ts +3 -3
  91. package/dist/index.mjs +2 -2
  92. package/index.d.ts +12 -0
  93. package/nuxt.config.ts +80 -6
  94. package/package.json +29 -21
  95. package/uno.config.ts +83 -0
  96. package/components/SwCategoryNavigation.vue +0 -44
  97. package/components/SwCategoryNavigationLink.vue +0 -57
  98. package/components/SwListingProductPrice.vue +0 -89
  99. package/components/SwProductCard.vue +0 -286
  100. package/components/SwProductListingFilter.vue +0 -42
  101. package/components/SwProductListingFilters.vue +0 -292
  102. package/components/listing-filters/SwFilterPrice.vue +0 -160
  103. package/components/listing-filters/SwFilterProperties.vue +0 -123
  104. package/components/listing-filters/SwFilterRating.vue +0 -101
  105. package/components/listing-filters/SwFilterShippingFree.vue +0 -104
  106. package/components/public/cms/block/CmsBlockImageFourColumn.vue +0 -29
  107. package/components/public/cms/block/CmsBlockImageHighlightRow.vue +0 -27
  108. package/components/public/cms/block/CmsBlockImageTextGallery.vue +0 -85
  109. package/components/public/cms/block/CmsBlockImageTextRow.vue +0 -43
  110. package/components/public/cms/block/CmsBlockImageThreeCover.vue +0 -27
  111. package/components/public/cms/block/CmsBlockImageTwoColumn.vue +0 -25
  112. package/components/public/cms/block/CmsBlockTextOnImage.vue +0 -20
  113. package/components/public/cms/element/CmsBlockHtml.md +0 -1
  114. package/components/public/cms/element/CmsElementBuyBox.vue +0 -190
  115. package/components/public/cms/element/CmsElementCategoryNavigation.vue +0 -167
  116. package/components/public/cms/element/CmsElementImageGallery.vue +0 -249
  117. package/components/public/cms/element/CmsElementProductDescriptionReviews.vue +0 -123
  118. package/components/public/cms/element/CmsElementProductName.vue +0 -10
  119. package/components/public/cms/section/CmsSectionSidebar.vue +0 -49
  120. package/components/public/cms/skeleton/ProductCardSkeleton.vue +0 -44
  121. /package/{components → app/components}/SwMedia3D.vue +0 -0
  122. /package/{components → app/components}/SwProductGallery.vue +0 -0
  123. /package/{components → app/components}/SwProductPrice.vue +0 -0
  124. /package/{components → app/components}/SwProductUnits.vue +0 -0
  125. /package/{components → app/components}/SwSharedPrice.vue +0 -0
  126. /package/{components → app/components}/public/cms/CmsGenericBlock.md +0 -0
  127. /package/{components → app/components}/public/cms/CmsGenericBlock.vue +0 -0
  128. /package/{components → app/components}/public/cms/CmsGenericElement.md +0 -0
  129. /package/{components → app/components}/public/cms/CmsGenericElement.vue +0 -0
  130. /package/{components → app/components}/public/cms/CmsNoComponent.vue +0 -0
  131. /package/{components → app/components}/public/cms/CmsPage.md +0 -0
  132. /package/{components → app/components}/public/cms/block/CmsBlockCategoryNavigation.vue +0 -0
  133. /package/{components → app/components}/public/cms/block/CmsBlockCenterText.vue +0 -0
  134. /package/{components → app/components}/public/cms/block/CmsBlockCrossSelling.vue +0 -0
  135. /package/{components → app/components}/public/cms/block/CmsBlockCustomForm.vue +0 -0
  136. /package/{components → app/components}/public/cms/block/CmsBlockDefault.vue +0 -0
  137. /package/{components → app/components}/public/cms/block/CmsBlockForm.vue +0 -0
  138. /package/{components/public/cms/element → app/components/public/cms/block}/CmsBlockHtml.vue +0 -0
  139. /package/{components → app/components}/public/cms/block/CmsBlockImage.vue +0 -0
  140. /package/{components → app/components}/public/cms/block/CmsBlockImageCover.vue +0 -0
  141. /package/{components → app/components}/public/cms/block/CmsBlockImageGallery.vue +0 -0
  142. /package/{components → app/components}/public/cms/block/CmsBlockImageSlider.vue +0 -0
  143. /package/{components → app/components}/public/cms/block/CmsBlockProductDescriptionReviews.vue +0 -0
  144. /package/{components → app/components}/public/cms/block/CmsBlockProductListing.vue +0 -0
  145. /package/{components → app/components}/public/cms/block/CmsBlockProductSlider.vue +0 -0
  146. /package/{components → app/components}/public/cms/block/CmsBlockText.vue +0 -0
  147. /package/{components → app/components}/public/cms/block/CmsBlockTextHero.vue +0 -0
  148. /package/{components → app/components}/public/cms/block/CmsBlockTextTeaser.vue +0 -0
  149. /package/{components → app/components}/public/cms/block/CmsBlockTextThreeColumn.vue +0 -0
  150. /package/{components → app/components}/public/cms/block/CmsBlockVimeoVideo.vue +0 -0
  151. /package/{components → app/components}/public/cms/block/CmsBlockYoutubeVideo.vue +0 -0
  152. /package/{components → app/components}/public/cms/element/CmsElementBuyBox.md +0 -0
  153. /package/{components → app/components}/public/cms/element/CmsElementCategoryNavigation.md +0 -0
  154. /package/{components → app/components}/public/cms/element/CmsElementCrossSelling.md +0 -0
  155. /package/{components → app/components}/public/cms/element/CmsElementCustomForm.md +0 -0
  156. /package/{components → app/components}/public/cms/element/CmsElementCustomForm.vue +0 -0
  157. /package/{components → app/components}/public/cms/element/CmsElementForm.md +0 -0
  158. /package/{components → app/components}/public/cms/element/CmsElementForm.vue +0 -0
  159. /package/{components → app/components}/public/cms/element/CmsElementHtml.vue +0 -0
  160. /package/{components → app/components}/public/cms/element/CmsElementImage.md +0 -0
  161. /package/{components → app/components}/public/cms/element/CmsElementImageGallery.md +0 -0
  162. /package/{components → app/components}/public/cms/element/CmsElementImageGallery3dPlaceholder.vue +0 -0
  163. /package/{components → app/components}/public/cms/element/CmsElementImageSlider.md +0 -0
  164. /package/{components → app/components}/public/cms/element/CmsElementManufacturerLogo.md +0 -0
  165. /package/{components → app/components}/public/cms/element/CmsElementManufacturerLogo.vue +0 -0
  166. /package/{components → app/components}/public/cms/element/CmsElementProductBox.md +0 -0
  167. /package/{components → app/components}/public/cms/element/CmsElementProductDescriptionReviews.md +0 -0
  168. /package/{components → app/components}/public/cms/element/CmsElementProductListing.md +0 -0
  169. /package/{components → app/components}/public/cms/element/CmsElementProductName.md +0 -0
  170. /package/{components → app/components}/public/cms/element/CmsElementProductSlider.md +0 -0
  171. /package/{components → app/components}/public/cms/element/CmsElementSidebarFilter.md +0 -0
  172. /package/{components → app/components}/public/cms/element/CmsElementSidebarFilter.vue +0 -0
  173. /package/{components → app/components}/public/cms/element/CmsElementText.md +0 -0
  174. /package/{components → app/components}/public/cms/element/CmsElementVimeoVideo.md +0 -0
  175. /package/{components → app/components}/public/cms/element/CmsElementVimeoVideo.vue +0 -0
  176. /package/{components → app/components}/public/cms/element/CmsElementYoutubeVideo.md +0 -0
  177. /package/{components → app/components}/public/cms/section/CmsSectionDefault.md +0 -0
  178. /package/{components → app/components}/public/cms/section/CmsSectionSidebar.md +0 -0
  179. /package/{helpers → app/helpers}/html-to-vue/ast.ts +0 -0
  180. /package/{helpers → app/helpers}/html-to-vue/getOptionsFromNode.test.ts +0 -0
  181. /package/{helpers → app/helpers}/html-to-vue/getOptionsFromNode.ts +0 -0
  182. /package/{helpers → app/helpers}/html-to-vue/renderToHtml.ts +0 -0
  183. /package/{helpers → app/helpers}/html-to-vue/renderer.ts +0 -0
  184. /package/{helpers → app/helpers}/media/isSpatial.ts +0 -0
@@ -0,0 +1,308 @@
1
+ <script setup lang="ts">
2
+ import type {
3
+ CmsElementProductListing,
4
+ CmsElementSidebarFilter,
5
+ } from "@shopware/composables";
6
+ import { useCmsTranslations } from "@shopware/composables";
7
+ import { defu } from "defu";
8
+ import { computed, reactive } from "vue";
9
+ import type { ComputedRef, UnwrapNestedRefs } from "vue";
10
+ import { type LocationQueryRaw, useRoute, useRouter } from "vue-router";
11
+ import { useCategoryListing } from "#imports";
12
+ import type { Schemas, operations } from "#shopware";
13
+
14
+ const props = defineProps<{
15
+ content: CmsElementProductListing | CmsElementSidebarFilter;
16
+ listingType?: string;
17
+ }>();
18
+
19
+ type Translations = {
20
+ listing: {
21
+ filters: string;
22
+ sort: string;
23
+ resetFilters: string;
24
+ };
25
+ };
26
+
27
+ type FilterState = {
28
+ manufacturer: Set<string>;
29
+ properties: Set<string>;
30
+ "min-price": number | undefined;
31
+ "max-price": number | undefined;
32
+ rating: number | undefined;
33
+ "shipping-free": boolean | undefined;
34
+ };
35
+
36
+ let translations: Translations = {
37
+ listing: {
38
+ filters: "Filters",
39
+ sort: "Sort",
40
+ resetFilters: "Reset filters",
41
+ },
42
+ };
43
+
44
+ translations = defu(useCmsTranslations(), translations) as Translations;
45
+
46
+ const route = useRoute();
47
+ const router = useRouter();
48
+
49
+ const {
50
+ changeCurrentSortingOrder,
51
+ getCurrentFilters,
52
+ getCurrentSortingOrder,
53
+ getInitialFilters,
54
+ getSortingOrders,
55
+ search,
56
+ } = useCategoryListing();
57
+
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
+ });
67
+
68
+ const showResetFiltersButton = computed<boolean>(() => {
69
+ if (
70
+ sidebarSelectedFilters.manufacturer.size !== 0 ||
71
+ sidebarSelectedFilters.properties.size !== 0 ||
72
+ sidebarSelectedFilters["max-price"] ||
73
+ sidebarSelectedFilters["min-price"] ||
74
+ sidebarSelectedFilters.rating ||
75
+ sidebarSelectedFilters["shipping-free"]
76
+ ) {
77
+ return true;
78
+ }
79
+
80
+ return false;
81
+ });
82
+
83
+ const searchCriteriaForRequest: ComputedRef<Schemas["ProductListingCriteria"]> =
84
+ computed(() => ({
85
+ manufacturer: [
86
+ ...(sidebarSelectedFilters.manufacturer as Set<string>),
87
+ ]?.join("|"),
88
+ properties: [...(sidebarSelectedFilters.properties as Set<string>)]?.join(
89
+ "|",
90
+ ),
91
+ "min-price": sidebarSelectedFilters["min-price"] as number,
92
+ "max-price": sidebarSelectedFilters["max-price"] as number,
93
+ order: getCurrentSortingOrder.value as string,
94
+ "shipping-free": sidebarSelectedFilters["shipping-free"] as boolean,
95
+ rating: sidebarSelectedFilters.rating as number,
96
+ search: "",
97
+ limit: route.query.limit ? Number(route.query.limit) : 15,
98
+ }));
99
+
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
+ const handleFilterChange = async (event: {
142
+ code: string;
143
+ value: string | number | boolean;
144
+ }) => {
145
+ try {
146
+ const { code, value } = event;
147
+
148
+ if (code === "manufacturer" || code === "properties") {
149
+ const filterSet = sidebarSelectedFilters[code];
150
+ const stringValue = String(value);
151
+
152
+ if (filterSet.has(stringValue)) {
153
+ filterSet.delete(stringValue);
154
+ } else {
155
+ filterSet.add(stringValue);
156
+ }
157
+ } else if (code === "min-price" || code === "max-price") {
158
+ sidebarSelectedFilters[code] =
159
+ typeof value === "number" ? value : Number(value);
160
+ } else if (code === "rating") {
161
+ sidebarSelectedFilters.rating = Number(value);
162
+ } else if (code === "shipping-free") {
163
+ sidebarSelectedFilters["shipping-free"] = Boolean(value);
164
+ }
165
+
166
+ await executeSearch();
167
+ } catch (error) {
168
+ console.error("Filter update failed:", error);
169
+ }
170
+ };
171
+
172
+ const executeSearch = async () => {
173
+ try {
174
+ await search(searchCriteriaForRequest.value);
175
+
176
+ // Build query directly from searchCriteriaForRequest which already has pipe-separated strings
177
+ const criteria = searchCriteriaForRequest.value;
178
+ const query: Record<string, unknown> = {};
179
+
180
+ if (criteria.manufacturer) query.manufacturer = criteria.manufacturer;
181
+ if (criteria.properties) query.properties = criteria.properties;
182
+ if (criteria["min-price"]) query["min-price"] = criteria["min-price"];
183
+ if (criteria["max-price"]) query["max-price"] = criteria["max-price"];
184
+ if (criteria.rating) query.rating = criteria.rating;
185
+ if (criteria["shipping-free"])
186
+ query["shipping-free"] = criteria["shipping-free"];
187
+ if (criteria.order) query.order = criteria.order;
188
+
189
+ await router.push({
190
+ query: query as LocationQueryRaw,
191
+ });
192
+ } catch (error) {
193
+ console.error("Search execution failed:", error);
194
+ }
195
+ };
196
+
197
+ const clearFilters = () => {
198
+ (sidebarSelectedFilters.manufacturer as Set<string>).clear();
199
+ (sidebarSelectedFilters.properties as Set<string>).clear();
200
+ sidebarSelectedFilters["min-price"] = undefined;
201
+ sidebarSelectedFilters["max-price"] = undefined;
202
+ sidebarSelectedFilters.rating = undefined;
203
+ sidebarSelectedFilters["shipping-free"] = undefined;
204
+ };
205
+
206
+ const currentSortingOrder = computed({
207
+ get: (): string => getCurrentSortingOrder.value || "",
208
+ set: async (order: string): Promise<void> => {
209
+ try {
210
+ await router.push({
211
+ query: {
212
+ ...route.query,
213
+ order,
214
+ },
215
+ });
216
+
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
+ });
221
+ } catch (error) {
222
+ console.error("Sorting order change failed:", error);
223
+ }
224
+ },
225
+ });
226
+
227
+ async function invokeCleanFilters() {
228
+ try {
229
+ clearFilters();
230
+ await executeSearch();
231
+ } catch (error) {
232
+ console.error("Clear filters failed:", error);
233
+ }
234
+ }
235
+
236
+ const isDefaultSidebarFilter =
237
+ props.content.type === "sidebar-filter" &&
238
+ props.content.config?.boxLayout?.value === "standard";
239
+
240
+ const handleSortChange = (sortKey: string) => {
241
+ currentSortingOrder.value = sortKey;
242
+ };
243
+
244
+ const handleRemoveFilterChip = async (chip: {
245
+ code: string;
246
+ value: string | number;
247
+ }) => {
248
+ if (chip.code === "properties" || chip.code === "manufacturer") {
249
+ const filterSet = sidebarSelectedFilters[chip.code] as Set<string>;
250
+ filterSet.delete(String(chip.value));
251
+ } else if (chip.code === "price") {
252
+ sidebarSelectedFilters["min-price"] = undefined;
253
+ sidebarSelectedFilters["max-price"] = undefined;
254
+ } else if (chip.code === "rating") {
255
+ sidebarSelectedFilters.rating = undefined;
256
+ } else if (chip.code === "shipping-free") {
257
+ sidebarSelectedFilters["shipping-free"] = undefined;
258
+ }
259
+
260
+ await executeSearch();
261
+ };
262
+ </script>
263
+ <template>
264
+ <div>
265
+ <!-- Active Filter Chips -->
266
+ <SwFilterChips
267
+ :filters="sidebarSelectedFilters"
268
+ :available-filters="getInitialFilters"
269
+ @remove="handleRemoveFilterChip"
270
+ />
271
+
272
+ <!-- Filters Header -->
273
+ <div class="self-stretch flex flex-col justify-start items-start gap-4">
274
+ <div
275
+ class="flex flex-row items-center justify-between w-full mb-4 py-3 border-b border-outline-outline-variant">
276
+ <div class="flex-1 text-surface-on-surface text-base font-bold leading-normal">
277
+ {{ translations.listing.filters }}
278
+ </div>
279
+ <SwSortDropdown
280
+ :sort-options="getSortingOrders ?? []"
281
+ :current-sort="getCurrentSortingOrder ?? ''"
282
+ :label="translations.listing.sort"
283
+ @sort-change="handleSortChange"
284
+ />
285
+ </div>
286
+ </div>
287
+
288
+ <!-- Filters List -->
289
+ <div class="self-stretch flex flex-col justify-start items-start gap-4">
290
+ <SwProductListingFilter v-for="filter in getInitialFilters" :key="filter.id"
291
+ :filter="filter"
292
+ :selected-manufacturer="sidebarSelectedFilters.manufacturer"
293
+ :selected-properties="sidebarSelectedFilters.properties"
294
+ :selected-min-price="sidebarSelectedFilters['min-price']"
295
+ :selected-max-price="sidebarSelectedFilters['max-price']"
296
+ :selected-rating="sidebarSelectedFilters.rating"
297
+ :selected-shipping-free="sidebarSelectedFilters['shipping-free']"
298
+ @filter-change="handleFilterChange"
299
+ class="w-full" />
300
+ <div v-if="showResetFiltersButton" class="w-full">
301
+ <SwBaseButton variant="primary" size="medium" block @click="invokeCleanFilters" type="button">
302
+ {{ translations.listing.resetFilters }}
303
+ <span class="w-6 h-6 i-carbon-close-filled inline-block align-middle ml-2"></span>
304
+ </SwBaseButton>
305
+ </div>
306
+ </div>
307
+ </div>
308
+ </template>
@@ -17,6 +17,7 @@ type Translations = {
17
17
  product: {
18
18
  noReviews: string;
19
19
  reviewNotAccepted: string;
20
+ reviewFeedback: string;
20
21
  };
21
22
  };
22
23
 
@@ -24,6 +25,7 @@ let translations: Translations = {
24
25
  product: {
25
26
  noReviews: "No reviews yet.",
26
27
  reviewNotAccepted: "Your review has not been approved yet",
28
+ reviewFeedback: "Shop feedback",
27
29
  },
28
30
  };
29
31
 
@@ -64,40 +66,50 @@ const formatDate = (date: string) => {
64
66
  <template>
65
67
  <div
66
68
  v-if="loadingReviews"
67
- class="absolute inset-0 flex items-center justify-center z-10 bg-white/75"
69
+ class="absolute inset-0 flex items-center justify-center z-10 bg-surface-surface/75"
68
70
  >
69
71
  <div
70
- class="h-15 w-15 i-carbon-progress-bar-round animate-spin c-gray-500"
72
+ class="h-15 w-15 i-carbon-progress-bar-round animate-spin text-outline-outline"
71
73
  />
72
74
  </div>
73
- <div v-else-if="reviewsList.length">
74
- <div v-for="review in reviews" :key="review.id">
75
+ <div v-else-if="reviewsList.length" class="flex flex-col gap-6">
76
+ <div
77
+ v-for="(review, index) in reviewsList"
78
+ :key="review.id"
79
+ class="pb-6"
80
+ :class="{ 'border-b border-surface-surface-container-highest': index < reviewsList.length - 1 }"
81
+ >
75
82
  <div
76
83
  v-if="review.createdAt"
77
- class="cms-block-product-description-reviews__reviews-time mt-3 text-gray-600 text-sm"
84
+ class="cms-block-product-description-reviews__reviews-time text-surface-on-surface-variant text-sm"
78
85
  >
86
+ <span v-if="review.externalUser">{{ review.externalUser }} - </span>
79
87
  <span>{{ formatDate(review.createdAt) }}</span>
80
88
  </div>
81
89
  <div
82
90
  v-if="!review.status"
83
- class="mt-2 text-3 p-2 bg-[#d4f0f5] flex gap-2 items-center"
91
+ class="mt-2 text-3 p-2 bg-states-info-container text-states-on-info-container flex gap-2 items-center"
84
92
  >
85
93
  <div class="w-6 h-6 i-carbon-warning" />
86
94
  {{ translations.product.reviewNotAccepted }}
87
95
  </div>
88
96
  <div
89
97
  class="cms-block-product-description-reviews__reviews-rating inline-flex items-center mt-2"
98
+ role="img"
99
+ :aria-label="`${review.points} out of 5 stars`"
90
100
  >
91
- <div
101
+ <SwStarIcon
92
102
  v-for="_ in review.points"
93
103
  :key="`filled-star-${_}`"
94
- class="w-5 h-5 i-carbon-star-filled"
95
- ></div>
96
- <div
104
+ :filled="true"
105
+ :size="20"
106
+ />
107
+ <SwStarIcon
97
108
  v-for="_ in 5 - (review.points || 0)"
98
109
  :key="`empty-star-${_}`"
99
- class="w-5 h-5 i-carbon-star"
100
- ></div>
110
+ :filled="false"
111
+ :size="20"
112
+ />
101
113
  <div
102
114
  class="cms-block-product-description-reviews__reviews-title font-semibold ml-2"
103
115
  >
@@ -105,7 +117,10 @@ const formatDate = (date: string) => {
105
117
  </div>
106
118
  </div>
107
119
  <div class="cms-block-product-description-reviews__reviews-content mt-2">
108
- <span>{{ review.content }}</span>
120
+ <p class="break-words">{{ review.content }}</p>
121
+ <p v-if="review.comment" class="text-surface-on-surface-variant mt-2">
122
+ - {{ translations.product.reviewFeedback }}: {{ review.comment }}
123
+ </p>
109
124
  </div>
110
125
  </div>
111
126
  </div>
@@ -0,0 +1,292 @@
1
+ <script setup lang="ts">
2
+ import { ApiClientError } from "@shopware/api-client";
3
+ import type { ApiError } from "@shopware/api-client";
4
+ import { useCmsTranslations } from "@shopware/composables";
5
+ import { useVuelidate } from "@vuelidate/core";
6
+ import type { ValidationRuleWithoutParams } from "@vuelidate/core";
7
+ import { minLength, required } from "@vuelidate/validators";
8
+ import { defu } from "defu";
9
+ import { computed, reactive, ref } from "vue";
10
+ import { useShopwareContext } from "#imports";
11
+
12
+ const props = defineProps<{
13
+ productId: string;
14
+ }>();
15
+
16
+ const emit = defineEmits<{
17
+ success: [];
18
+ }>();
19
+
20
+ type Translations = {
21
+ product: {
22
+ addReview: string;
23
+ reviewsForm: {
24
+ title: string;
25
+ titlePlaceholder: string;
26
+ review: string;
27
+ reviewPlaceholder: string;
28
+ submit: string;
29
+ rating: string;
30
+ };
31
+ errors: {
32
+ reviewAlreadyExists: string;
33
+ };
34
+ };
35
+ errors?: {
36
+ [key: string]: string;
37
+ };
38
+ };
39
+
40
+ let translations: Translations = {
41
+ product: {
42
+ addReview: "Add review",
43
+ reviewsForm: {
44
+ title: "Title",
45
+ titlePlaceholder: "Enter a title for your review",
46
+ review: "Your review",
47
+ reviewPlaceholder:
48
+ "Share your experience with this product (minimum 40 characters)",
49
+ submit: "Submit",
50
+ rating: "Your rating",
51
+ },
52
+ errors: {
53
+ reviewAlreadyExists:
54
+ "You have already submitted a review for this product",
55
+ },
56
+ },
57
+ };
58
+
59
+ translations = defu(useCmsTranslations(), translations) as Translations;
60
+
61
+ type State = {
62
+ rating: number | null;
63
+ title: string;
64
+ review: string;
65
+ };
66
+
67
+ const state = reactive<State>({
68
+ rating: null,
69
+ title: "",
70
+ review: "",
71
+ });
72
+
73
+ const isLoading = ref(false);
74
+ const errorMessages = ref<ApiError[]>([]);
75
+
76
+ const rules = computed(() => ({
77
+ rating: {
78
+ required: required as ValidationRuleWithoutParams,
79
+ },
80
+ title: {
81
+ required: required as ValidationRuleWithoutParams,
82
+ minLength: minLength(5),
83
+ },
84
+ review: {
85
+ required: required as ValidationRuleWithoutParams,
86
+ minLength: minLength(40),
87
+ },
88
+ }));
89
+
90
+ const { apiClient } = useShopwareContext();
91
+
92
+ const $v = useVuelidate(rules, state);
93
+
94
+ const invokeSend = async () => {
95
+ $v.value.$touch();
96
+ const valid = await $v.value.$validate();
97
+
98
+ if (!valid) {
99
+ return;
100
+ }
101
+
102
+ try {
103
+ isLoading.value = true;
104
+ await apiClient.invoke(
105
+ "saveProductReview post /product/{productId}/review",
106
+ {
107
+ pathParams: {
108
+ productId: props.productId,
109
+ },
110
+ body: {
111
+ title: state.title,
112
+ content: state.review,
113
+ points: state.rating || 0,
114
+ },
115
+ },
116
+ );
117
+
118
+ // Reset form state after successful submission
119
+ state.rating = null;
120
+ state.title = "";
121
+ state.review = "";
122
+ $v.value.$reset();
123
+ errorMessages.value = [];
124
+
125
+ emit("success");
126
+ } catch (error) {
127
+ if (error instanceof ApiClientError) {
128
+ // Resolve error messages using i18n translations when available
129
+ errorMessages.value = error.details.errors.map((err: ApiError) => {
130
+ // Try to get translation from i18n errors namespace (if template provides it)
131
+ const translatedMessage =
132
+ (err.code && translations.errors?.[err.code]) ??
133
+ // Fall back to product-specific error translations
134
+ (err.code === "VIOLATION::ENTITY_EXISTS"
135
+ ? translations.product.errors.reviewAlreadyExists
136
+ : undefined) ??
137
+ // Fall back to API error detail
138
+ err.detail;
139
+
140
+ return {
141
+ ...err,
142
+ detail: translatedMessage,
143
+ };
144
+ });
145
+ }
146
+ } finally {
147
+ isLoading.value = false;
148
+ }
149
+ };
150
+
151
+ const invokeRating = (value: number) => {
152
+ state.rating = value;
153
+ };
154
+ </script>
155
+
156
+ <template>
157
+ <form class="flex flex-col gap-4 md:gap-5 relative" @submit.prevent="invokeSend">
158
+ <div
159
+ v-if="isLoading"
160
+ class="absolute inset-0 flex items-center justify-center z-10 bg-surface-surface/80 rounded-md"
161
+ >
162
+ <div
163
+ class="h-12 w-12 i-carbon-progress-bar-round animate-spin text-brand-primary"
164
+ />
165
+ </div>
166
+ <div>
167
+ <div class="flex flex-col gap-2">
168
+ <h4 class="text-lg md:text-xl font-bold text-surface-on-surface mt-3">
169
+ {{ translations.product.addReview }}
170
+ </h4>
171
+ <span class="text-sm text-surface-on-surface-variant">{{
172
+ translations.product.reviewsForm.rating
173
+ }}</span>
174
+ <div class="flex flex-row gap-2" role="group" :aria-label="translations.product.reviewsForm.rating">
175
+ <SwStarIcon
176
+ v-for="index in state.rating || 0"
177
+ :key="`filled-${index}`"
178
+ :filled="true"
179
+ :size="24"
180
+ role="button"
181
+ :aria-label="`Rate ${index} out of 5 stars`"
182
+ tabindex="0"
183
+ class="cursor-pointer hover:opacity-80 transition-opacity active:scale-95 focus:outline-none focus:ring-2 focus:ring-brand-primary rounded"
184
+ data-testid="review-filled-star"
185
+ @click="invokeRating(index)"
186
+ @keydown.enter.prevent="invokeRating(index)"
187
+ @keydown.space.prevent="invokeRating(index)"
188
+ />
189
+ <SwStarIcon
190
+ v-for="index in 5 - (state.rating || 0)"
191
+ :key="`empty-${index}`"
192
+ :filled="false"
193
+ :size="24"
194
+ role="button"
195
+ :aria-label="`Rate ${(state.rating || 0) + index} out of 5 stars`"
196
+ tabindex="0"
197
+ class="cursor-pointer hover:opacity-80 transition-opacity active:scale-95 focus:outline-none focus:ring-2 focus:ring-brand-primary rounded"
198
+ data-testid="review-empty-star"
199
+ @click="invokeRating((state.rating || 0) + index)"
200
+ @keydown.enter.prevent="invokeRating((state.rating || 0) + index)"
201
+ @keydown.space.prevent="invokeRating((state.rating || 0) + index)"
202
+ />
203
+ </div>
204
+ <span
205
+ v-if="$v.rating.$error && $v.rating.$errors[0]?.$message"
206
+ class="pt-1 text-sm text-states-error"
207
+ >
208
+ {{ $v.rating.$errors[0].$message }}
209
+ </span>
210
+ </div>
211
+ </div>
212
+ <div
213
+ v-if="errorMessages.length"
214
+ class="p-3 mb-4 bg-surface-surface-container border border-states-error rounded-md flex gap-2 md:gap-3 items-start"
215
+ >
216
+ <div class="w-5 h-5 text-states-error flex-shrink-0 mt-0.5">
217
+ <SwExclamationIcon :size="20" />
218
+ </div>
219
+ <div class="flex-1">
220
+ <p v-for="(error, index) in errorMessages" :key="index" class="text-sm text-states-error">
221
+ {{ error.detail }}
222
+ </p>
223
+ </div>
224
+ </div>
225
+ <div>
226
+ <label
227
+ for="title"
228
+ class="block mb-2 text-sm font-medium text-surface-on-surface"
229
+ >{{ translations.product.reviewsForm.title }}</label
230
+ >
231
+ <input
232
+ id="title"
233
+ v-model="state.title"
234
+ class="block w-full px-3 py-2.5 md:py-2 border rounded-md text-base md:text-sm text-surface-on-surface bg-surface-surface placeholder-surface-on-surface-variant focus:outline-none focus:ring-2 focus:ring-outline-outline"
235
+ :class="[
236
+ $v.title.$error
237
+ ? 'border-states-error focus:ring-states-error'
238
+ : 'border-outline-outline',
239
+ ]"
240
+ type="text"
241
+ :placeholder="translations.product.reviewsForm.titlePlaceholder"
242
+ :disabled="isLoading"
243
+ data-testid="review-title-input"
244
+ @blur="$v.title.$touch()"
245
+ />
246
+ <span
247
+ v-if="$v.title.$error && $v.title.$errors[0]?.$message"
248
+ class="pt-1 text-sm text-states-error"
249
+ >
250
+ {{ $v.title.$errors[0].$message }}
251
+ </span>
252
+ </div>
253
+ <div>
254
+ <label
255
+ for="review"
256
+ class="block mb-2 text-sm font-medium text-surface-on-surface"
257
+ >{{ translations.product.reviewsForm.review }}</label
258
+ >
259
+ <textarea
260
+ id="review"
261
+ v-model="state.review"
262
+ class="block w-full px-3 py-2.5 md:py-2 border rounded-md text-base md:text-sm text-surface-on-surface bg-surface-surface placeholder-surface-on-surface-variant focus:outline-none focus:ring-2 focus:ring-outline-outline min-h-32 md:min-h-40"
263
+ :class="[
264
+ $v.review.$error
265
+ ? 'border-states-error focus:ring-states-error'
266
+ : 'border-outline-outline',
267
+ ]"
268
+ :placeholder="translations.product.reviewsForm.reviewPlaceholder"
269
+ :disabled="isLoading"
270
+ data-testid="review-text-input"
271
+ @blur="$v.review.$touch()"
272
+ />
273
+ <span
274
+ v-if="$v.review.$error && $v.review.$errors[0]?.$message"
275
+ class="pt-1 text-sm text-states-error"
276
+ >
277
+ {{ $v.review.$errors[0].$message }}
278
+ </span>
279
+ </div>
280
+ <SwBaseButton
281
+ type="submit"
282
+ variant="primary"
283
+ size="medium"
284
+ :disabled="isLoading"
285
+ :loading="isLoading"
286
+ class="mt-4 w-full md:w-auto md:self-start"
287
+ data-testid="review-submit-button"
288
+ >
289
+ {{ translations.product.reviewsForm.submit }}
290
+ </SwBaseButton>
291
+ </form>
292
+ </template>