@shopware/cms-base-layer 2.0.0 → 2.1.0

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