@shopware/cms-base-layer 1.5.1 → 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 (198) hide show
  1. package/README.md +398 -12
  2. package/app/app.config.ts +18 -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 +83 -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/SwFilterDropdown.vue +54 -0
  15. package/app/components/SwListingProductPrice.vue +89 -0
  16. package/{components → app/components}/SwMedia3D.vue +4 -2
  17. package/{components → app/components}/SwNewsletterForm.vue +45 -34
  18. package/{components → app/components}/SwPagination.vue +3 -5
  19. package/{components → app/components}/SwProductAddToCart.vue +22 -27
  20. package/app/components/SwProductCard.vue +169 -0
  21. package/app/components/SwProductCardDetails.vue +74 -0
  22. package/app/components/SwProductCardImage.vue +90 -0
  23. package/app/components/SwProductCardSkeleton.vue +33 -0
  24. package/app/components/SwProductGallery.vue +43 -0
  25. package/app/components/SwProductListingFilter.vue +75 -0
  26. package/app/components/SwProductListingFilters.vue +304 -0
  27. package/app/components/SwProductListingFiltersHorizontal.vue +306 -0
  28. package/{components → app/components}/SwProductPrice.vue +3 -3
  29. package/app/components/SwProductRating.vue +40 -0
  30. package/{components → app/components}/SwProductReviews.vue +25 -23
  31. package/app/components/SwProductReviewsForm.vue +292 -0
  32. package/{components → app/components}/SwProductUnits.vue +10 -15
  33. package/app/components/SwQuantitySelect.vue +103 -0
  34. package/{components → app/components}/SwSlider.vue +154 -55
  35. package/app/components/SwSortDropdown.vue +87 -0
  36. package/app/components/SwStockInfo.vue +44 -0
  37. package/{components → app/components}/SwVariantConfigurator.vue +13 -12
  38. package/app/components/listing-filters/SwFilterPrice.vue +219 -0
  39. package/app/components/listing-filters/SwFilterProperties.vue +120 -0
  40. package/app/components/listing-filters/SwFilterRating.vue +99 -0
  41. package/app/components/listing-filters/SwFilterShippingFree.vue +114 -0
  42. package/app/components/public/cms/CmsBlockSpatialViewer.vue +94 -0
  43. package/app/components/public/cms/CmsGenericBlock.md +42 -0
  44. package/{components → app/components}/public/cms/CmsGenericBlock.vue +15 -1
  45. package/{components → app/components}/public/cms/CmsPage.md +19 -2
  46. package/{components → app/components}/public/cms/CmsPage.vue +30 -5
  47. package/{components → app/components}/public/cms/block/CmsBlockCenterText.vue +1 -1
  48. package/{components → app/components}/public/cms/block/CmsBlockGalleryBuybox.vue +5 -5
  49. package/{components → app/components}/public/cms/block/CmsBlockImageBubbleRow.vue +5 -5
  50. package/app/components/public/cms/block/CmsBlockImageFourColumn.vue +41 -0
  51. package/app/components/public/cms/block/CmsBlockImageGalleryBig.vue +42 -0
  52. package/app/components/public/cms/block/CmsBlockImageHighlightRow.vue +37 -0
  53. package/{components → app/components}/public/cms/block/CmsBlockImageSimpleGrid.vue +11 -5
  54. package/{components → app/components}/public/cms/block/CmsBlockImageText.vue +7 -3
  55. package/{components → app/components}/public/cms/block/CmsBlockImageTextBubble.vue +13 -16
  56. package/{components → app/components}/public/cms/block/CmsBlockImageTextCover.vue +7 -9
  57. package/app/components/public/cms/block/CmsBlockImageTextGallery.vue +88 -0
  58. package/app/components/public/cms/block/CmsBlockImageTextRow.vue +53 -0
  59. package/{components → app/components}/public/cms/block/CmsBlockImageThreeColumn.vue +10 -4
  60. package/app/components/public/cms/block/CmsBlockImageThreeCover.vue +37 -0
  61. package/app/components/public/cms/block/CmsBlockImageTwoColumn.vue +37 -0
  62. package/{components → app/components}/public/cms/block/CmsBlockProductHeading.vue +1 -1
  63. package/{components → app/components}/public/cms/block/CmsBlockProductThreeColumn.vue +10 -4
  64. package/{components → app/components}/public/cms/block/CmsBlockSidebarFilter.vue +3 -1
  65. package/{components → app/components}/public/cms/block/CmsBlockTextOnImage.vue +8 -5
  66. package/app/components/public/cms/element/CmsElementBuyBox.vue +145 -0
  67. package/app/components/public/cms/element/CmsElementCategoryNavigation.vue +53 -0
  68. package/{components → app/components}/public/cms/element/CmsElementCrossSelling.vue +22 -6
  69. package/{components → app/components}/public/cms/element/CmsElementImage.vue +58 -21
  70. package/app/components/public/cms/element/CmsElementImageGallery.vue +225 -0
  71. package/{components → app/components}/public/cms/element/CmsElementImageSlider.vue +2 -2
  72. package/{components → app/components}/public/cms/element/CmsElementProductBox.vue +8 -1
  73. package/app/components/public/cms/element/CmsElementProductDescriptionReviews.vue +217 -0
  74. package/{components → app/components}/public/cms/element/CmsElementProductListing.vue +31 -95
  75. package/app/components/public/cms/element/CmsElementProductName.vue +16 -0
  76. package/app/components/public/cms/element/CmsElementProductSlider.vue +101 -0
  77. package/app/components/public/cms/element/CmsElementSidebarFilter.vue +20 -0
  78. package/{components → app/components}/public/cms/element/CmsElementText.vue +17 -12
  79. package/app/components/public/cms/element/SwProductListingPagination.vue +70 -0
  80. package/{components → app/components}/public/cms/section/CmsSectionDefault.vue +2 -2
  81. package/app/components/public/cms/section/CmsSectionSidebar.vue +39 -0
  82. package/app/components/public/cms/skeleton/ProductCardSkeleton.vue +28 -0
  83. package/app/components/ui/BaseButton.vue +102 -0
  84. package/app/components/ui/BaseIcon.vue +15 -0
  85. package/app/components/ui/Checkbox.vue +49 -0
  86. package/app/components/ui/CheckmarkIcon.vue +23 -0
  87. package/app/components/ui/ChevronIcon.vue +34 -0
  88. package/app/components/ui/ExclamationIcon.vue +11 -0
  89. package/app/components/ui/IconButton.vue +32 -0
  90. package/app/components/ui/RadioButton.vue +26 -0
  91. package/app/components/ui/StarIcon.vue +18 -0
  92. package/app/components/ui/SwitchButton.vue +100 -0
  93. package/app/components/ui/UserIcon.vue +11 -0
  94. package/app/components/ui/WishlistIcon.vue +15 -0
  95. package/app/composables/useImagePlaceholder.ts +27 -0
  96. package/app/composables/useLcpImagePreload.test.ts +229 -0
  97. package/app/composables/useLcpImagePreload.ts +39 -0
  98. package/{helpers → app/helpers}/clientOnly.ts +5 -0
  99. package/app/helpers/cms/findFirstCmsImageUrl.ts +86 -0
  100. package/app/helpers/cms/getImageSizes.test.ts +50 -0
  101. package/app/helpers/cms/getImageSizes.ts +36 -0
  102. package/app/helpers/html-to-vue/ast.ts +106 -0
  103. package/{helpers → app/helpers}/html-to-vue/getOptionsFromNode.ts +1 -1
  104. package/{helpers → app/helpers}/html-to-vue/renderToHtml.ts +7 -11
  105. package/app/helpers/html-to-vue/renderer.ts +116 -0
  106. package/app/plugins/unocss-runtime.client.ts +23 -0
  107. package/app/providers/shopware.test.ts +213 -0
  108. package/app/providers/shopware.ts +107 -0
  109. package/dist/index.d.mts +3 -3
  110. package/dist/index.d.ts +3 -3
  111. package/dist/index.mjs +2 -2
  112. package/index.d.ts +36 -0
  113. package/nuxt.config.ts +100 -6
  114. package/package.json +33 -23
  115. package/uno.config.ts +94 -0
  116. package/components/SwCategoryNavigation.vue +0 -44
  117. package/components/SwCategoryNavigationLink.vue +0 -57
  118. package/components/SwListingProductPrice.vue +0 -89
  119. package/components/SwProductCard.vue +0 -286
  120. package/components/SwProductGallery.vue +0 -39
  121. package/components/SwProductListingFilter.vue +0 -42
  122. package/components/SwProductListingFilters.vue +0 -292
  123. package/components/listing-filters/SwFilterPrice.vue +0 -160
  124. package/components/listing-filters/SwFilterProperties.vue +0 -123
  125. package/components/listing-filters/SwFilterRating.vue +0 -101
  126. package/components/listing-filters/SwFilterShippingFree.vue +0 -104
  127. package/components/public/cms/CmsGenericBlock.md +0 -27
  128. package/components/public/cms/block/CmsBlockImageFourColumn.vue +0 -29
  129. package/components/public/cms/block/CmsBlockImageHighlightRow.vue +0 -27
  130. package/components/public/cms/block/CmsBlockImageTextGallery.vue +0 -85
  131. package/components/public/cms/block/CmsBlockImageTextRow.vue +0 -43
  132. package/components/public/cms/block/CmsBlockImageThreeCover.vue +0 -27
  133. package/components/public/cms/block/CmsBlockImageTwoColumn.vue +0 -25
  134. package/components/public/cms/element/CmsElementBuyBox.vue +0 -190
  135. package/components/public/cms/element/CmsElementCategoryNavigation.vue +0 -167
  136. package/components/public/cms/element/CmsElementImageGallery.vue +0 -249
  137. package/components/public/cms/element/CmsElementProductDescriptionReviews.vue +0 -123
  138. package/components/public/cms/element/CmsElementProductName.vue +0 -10
  139. package/components/public/cms/element/CmsElementProductSlider.vue +0 -80
  140. package/components/public/cms/element/CmsElementSidebarFilter.vue +0 -12
  141. package/components/public/cms/section/CmsSectionSidebar.vue +0 -41
  142. package/components/public/cms/skeleton/ProductCardSkeleton.vue +0 -44
  143. package/helpers/html-to-vue/ast.ts +0 -72
  144. package/helpers/html-to-vue/renderer.ts +0 -56
  145. /package/{components → app/components}/SwSharedPrice.vue +0 -0
  146. /package/{components → app/components}/public/cms/CmsGenericElement.md +0 -0
  147. /package/{components → app/components}/public/cms/CmsGenericElement.vue +0 -0
  148. /package/{components → app/components}/public/cms/CmsNoComponent.vue +0 -0
  149. /package/{components → app/components}/public/cms/block/CmsBlockCategoryNavigation.vue +0 -0
  150. /package/{components → app/components}/public/cms/block/CmsBlockCrossSelling.vue +0 -0
  151. /package/{components → app/components}/public/cms/block/CmsBlockCustomForm.vue +0 -0
  152. /package/{components → app/components}/public/cms/block/CmsBlockDefault.vue +0 -0
  153. /package/{components → app/components}/public/cms/block/CmsBlockForm.vue +0 -0
  154. /package/{components → app/components}/public/cms/block/CmsBlockHtml.vue +0 -0
  155. /package/{components → app/components}/public/cms/block/CmsBlockImage.vue +0 -0
  156. /package/{components → app/components}/public/cms/block/CmsBlockImageCover.vue +0 -0
  157. /package/{components → app/components}/public/cms/block/CmsBlockImageGallery.vue +0 -0
  158. /package/{components → app/components}/public/cms/block/CmsBlockImageSlider.vue +0 -0
  159. /package/{components → app/components}/public/cms/block/CmsBlockProductDescriptionReviews.vue +0 -0
  160. /package/{components → app/components}/public/cms/block/CmsBlockProductListing.vue +0 -0
  161. /package/{components → app/components}/public/cms/block/CmsBlockProductSlider.vue +0 -0
  162. /package/{components → app/components}/public/cms/block/CmsBlockText.vue +0 -0
  163. /package/{components → app/components}/public/cms/block/CmsBlockTextHero.vue +0 -0
  164. /package/{components → app/components}/public/cms/block/CmsBlockTextTeaser.vue +0 -0
  165. /package/{components → app/components}/public/cms/block/CmsBlockTextTeaserSection.vue +0 -0
  166. /package/{components → app/components}/public/cms/block/CmsBlockTextThreeColumn.vue +0 -0
  167. /package/{components → app/components}/public/cms/block/CmsBlockTextTwoColumn.vue +0 -0
  168. /package/{components → app/components}/public/cms/block/CmsBlockVimeoVideo.vue +0 -0
  169. /package/{components → app/components}/public/cms/block/CmsBlockYoutubeVideo.vue +0 -0
  170. /package/{components → app/components}/public/cms/element/CmsElementBuyBox.md +0 -0
  171. /package/{components → app/components}/public/cms/element/CmsElementCategoryNavigation.md +0 -0
  172. /package/{components → app/components}/public/cms/element/CmsElementCrossSelling.md +0 -0
  173. /package/{components → app/components}/public/cms/element/CmsElementCustomForm.md +0 -0
  174. /package/{components → app/components}/public/cms/element/CmsElementCustomForm.vue +0 -0
  175. /package/{components → app/components}/public/cms/element/CmsElementForm.md +0 -0
  176. /package/{components → app/components}/public/cms/element/CmsElementForm.vue +0 -0
  177. /package/{components → app/components}/public/cms/element/CmsElementHtml.vue +0 -0
  178. /package/{components → app/components}/public/cms/element/CmsElementImage.md +0 -0
  179. /package/{components → app/components}/public/cms/element/CmsElementImageGallery.md +0 -0
  180. /package/{components → app/components}/public/cms/element/CmsElementImageGallery3dPlaceholder.vue +0 -0
  181. /package/{components → app/components}/public/cms/element/CmsElementImageSlider.md +0 -0
  182. /package/{components → app/components}/public/cms/element/CmsElementManufacturerLogo.md +0 -0
  183. /package/{components → app/components}/public/cms/element/CmsElementManufacturerLogo.vue +0 -0
  184. /package/{components → app/components}/public/cms/element/CmsElementProductBox.md +0 -0
  185. /package/{components → app/components}/public/cms/element/CmsElementProductDescriptionReviews.md +0 -0
  186. /package/{components → app/components}/public/cms/element/CmsElementProductListing.md +0 -0
  187. /package/{components → app/components}/public/cms/element/CmsElementProductName.md +0 -0
  188. /package/{components → app/components}/public/cms/element/CmsElementProductSlider.md +0 -0
  189. /package/{components → app/components}/public/cms/element/CmsElementSidebarFilter.md +0 -0
  190. /package/{components → app/components}/public/cms/element/CmsElementText.md +0 -0
  191. /package/{components → app/components}/public/cms/element/CmsElementVimeoVideo.md +0 -0
  192. /package/{components → app/components}/public/cms/element/CmsElementVimeoVideo.vue +0 -0
  193. /package/{components → app/components}/public/cms/element/CmsElementYoutubeVideo.md +0 -0
  194. /package/{components → app/components}/public/cms/element/CmsElementYoutubeVideo.vue +0 -0
  195. /package/{components → app/components}/public/cms/section/CmsSectionDefault.md +0 -0
  196. /package/{components → app/components}/public/cms/section/CmsSectionSidebar.md +0 -0
  197. /package/{helpers → app/helpers}/html-to-vue/getOptionsFromNode.test.ts +0 -0
  198. /package/{helpers → app/helpers}/media/isSpatial.ts +0 -0
@@ -0,0 +1,219 @@
1
+ <script setup lang="ts" generic="
2
+ ListingFilter extends {
3
+ code: string;
4
+ min?: number;
5
+ max?: number;
6
+ label: string;
7
+ }
8
+ ">
9
+ import { useCmsTranslations } from "@shopware/composables";
10
+ import { onClickOutside, useDebounceFn, useEventListener } from "@vueuse/core";
11
+ import { defu } from "defu";
12
+ import { onMounted, reactive, ref, watch } from "vue";
13
+ import type { Schemas } from "#shopware";
14
+
15
+ const emits =
16
+ defineEmits<
17
+ (e: "select-value", value: { code: string; value: unknown }) => void
18
+ >();
19
+
20
+ const {
21
+ filter,
22
+ selectedFilters,
23
+ displayMode = "accordion",
24
+ } = defineProps<{
25
+ filter: ListingFilter;
26
+ selectedFilters: Schemas["ProductListingResult"]["currentFilters"];
27
+ displayMode?: "accordion" | "dropdown";
28
+ }>();
29
+
30
+ type Translations = {
31
+ listing: {
32
+ min: string;
33
+ max: string;
34
+ };
35
+ };
36
+ let translations: Translations = {
37
+ listing: {
38
+ min: "Min",
39
+ max: "Max",
40
+ },
41
+ };
42
+ translations = defu(useCmsTranslations(), translations) as Translations;
43
+
44
+ const prices = reactive<{ min: number; max: number }>({
45
+ min: 0,
46
+ max: 0,
47
+ });
48
+
49
+ onMounted(() => {
50
+ prices.min = Math.floor(selectedFilters.price?.min ?? filter.min ?? 0);
51
+ prices.max = Math.floor(selectedFilters.price?.max ?? filter.max ?? 0);
52
+ });
53
+
54
+ const isFilterVisible = ref<boolean>(false);
55
+ const toggle = () => {
56
+ isFilterVisible.value = !isFilterVisible.value;
57
+ };
58
+
59
+ const dropdownElement = ref(null);
60
+ onClickOutside(dropdownElement, () => {
61
+ isFilterVisible.value = false;
62
+ });
63
+
64
+ function onMinPriceChange(newPrice: number, oldPrice: number) {
65
+ if (newPrice === oldPrice || oldPrice === 0) return;
66
+ emits("select-value", {
67
+ code: "min-price",
68
+ value: newPrice,
69
+ });
70
+ }
71
+ const debounceMinPriceUpdate = useDebounceFn(onMinPriceChange, 500);
72
+ watch(() => prices.min, debounceMinPriceUpdate);
73
+
74
+ function onMaxPriceChange(newPrice: number, oldPrice: number) {
75
+ if (newPrice === oldPrice || oldPrice === 0) return;
76
+ emits("select-value", {
77
+ code: "max-price",
78
+ value: newPrice,
79
+ });
80
+ }
81
+ const debounceMaxPriceUpdate = useDebounceFn(onMaxPriceChange, 500);
82
+ watch(() => prices.max, debounceMaxPriceUpdate);
83
+
84
+ // Slider drag logic
85
+ type DragType = "min" | "max" | null;
86
+ const dragging = ref<DragType>(null);
87
+ const sliderRect = ref<DOMRect | null>(null);
88
+
89
+ const getClientX = (event: MouseEvent | TouchEvent): number =>
90
+ event instanceof MouseEvent ? event.clientX : event.touches[0]?.clientX || 0;
91
+
92
+ const updateSliderValue = (clientX: number) => {
93
+ if (!dragging.value || !sliderRect.value) return;
94
+
95
+ const min = filter.min ?? 0;
96
+ const max = filter.max ?? 100;
97
+ const percent = Math.min(
98
+ Math.max((clientX - sliderRect.value.left) / sliderRect.value.width, 0),
99
+ 1,
100
+ );
101
+ const value = Math.round(min + percent * (max - min));
102
+
103
+ if (dragging.value === "min") {
104
+ if (value >= min && value <= prices.max) prices.min = value;
105
+ } else {
106
+ if (value <= max && value >= prices.min) prices.max = value;
107
+ }
108
+ };
109
+
110
+ const onDrag = (event: MouseEvent | TouchEvent) => {
111
+ if (!dragging.value) return;
112
+ event.preventDefault();
113
+ updateSliderValue(getClientX(event));
114
+ };
115
+
116
+ const stopDrag = () => {
117
+ dragging.value = null;
118
+ sliderRect.value = null;
119
+ };
120
+
121
+ useEventListener(window, "mousemove", onDrag);
122
+ useEventListener(window, "mouseup", stopDrag);
123
+ useEventListener(window, "touchmove", onDrag, { passive: false });
124
+ useEventListener(window, "touchend", stopDrag);
125
+
126
+ const startDrag = (type: "min" | "max", event: MouseEvent | TouchEvent) => {
127
+ event.preventDefault();
128
+ dragging.value = type;
129
+ const slider = (event.target as HTMLElement).closest(".relative.w-64.h-10");
130
+ if (slider) {
131
+ sliderRect.value = slider.getBoundingClientRect();
132
+ }
133
+ };
134
+ </script>
135
+
136
+ <template>
137
+ <div class="self-stretch flex flex-col justify-start items-start gap-4">
138
+ <!-- Accordion header (only in accordion mode) -->
139
+ <template v-if="displayMode === 'accordion'">
140
+ <div class="self-stretch flex flex-col justify-center items-center">
141
+ <div
142
+ class="self-stretch py-3 border-b border-outline-outline-variant inline-flex justify-between items-center gap-1 cursor-pointer"
143
+ @click="toggle"
144
+ role="button"
145
+ tabindex="0"
146
+ :aria-expanded="isFilterVisible"
147
+ :aria-controls="`filter-${filter.code}`"
148
+ @keydown.enter="toggle"
149
+ @keydown.space.prevent="toggle"
150
+ >
151
+ <div class="flex-1 flex items-center gap-2.5">
152
+ <div class="flex-1 text-surface-on-surface text-base font-bold leading-normal text-left">
153
+ {{ filter.label }}
154
+ </div>
155
+ </div>
156
+ <span
157
+ class="flex items-center justify-center"
158
+ aria-hidden="true"
159
+ >
160
+ <SwChevronIcon :direction="isFilterVisible ? 'up' : 'down'" :size="24" />
161
+ </span>
162
+ </div>
163
+ </div>
164
+ </template>
165
+
166
+ <!-- Filter content -->
167
+ <transition name="filter-collapse">
168
+ <div v-if="isFilterVisible || displayMode === 'dropdown'" :id="filter.code"
169
+ class="self-stretch flex flex-col justify-start items-start gap-2.5">
170
+ <div class="self-stretch flex flex-col justify-start items-start gap-1">
171
+ <div class="self-stretch inline-flex justify-between items-center gap-2">
172
+ <div
173
+ class="w-16 h-10 px-2 py-1 rounded-lg outline outline-1 outline-offset-[-1px] outline-outline-outline-variant inline-flex flex-col justify-center items-start gap-2.5">
174
+ <input type="number" :placeholder="translations.listing.min" v-model.number="prices.min"
175
+ class="w-full bg-transparent border-none outline-none text-surface-on-surface text-sm font-normal leading-tight"
176
+ @change="emits('select-value', { code: filter.code, value: { min: prices.min, max: prices.max } })"
177
+ :min="filter.min" :max="prices.max" />
178
+ </div>
179
+ <div
180
+ class="w-16 h-10 px-2 py-1 rounded-lg outline outline-1 outline-offset-[-1px] outline-outline-outline-variant inline-flex flex-col justify-center items-start gap-2.5">
181
+ <input type="number" :placeholder="translations.listing.max" v-model.number="prices.max"
182
+ class="w-full bg-transparent border-none outline-none text-surface-on-surface text-sm font-normal leading-tight"
183
+ @change="emits('select-value', { code: filter.code, value: { min: prices.min, max: prices.max } })"
184
+ :min="prices.min" :max="filter.max" />
185
+ </div>
186
+ </div>
187
+ <!-- Custom slider UI -->
188
+ <div class="relative w-64 h-10 mt-2 mx-auto flex items-center select-none">
189
+ <!-- Track -->
190
+ <div
191
+ class="absolute left-0 top-1/2 -translate-y-1/2 w-full h-2 bg-surface-surface-container-highest rounded-full">
192
+ </div>
193
+ <!-- Active range -->
194
+ <div class="absolute top-1/2 -translate-y-1/2 h-2 bg-surface-surface-primary rounded-full" :style="{
195
+ left: ((prices.min - (filter.min ?? 0)) / ((filter.max ?? 100) - (filter.min ?? 0))) * 100 + '%',
196
+ width: ((prices.max - prices.min) / ((filter.max ?? 100) - (filter.min ?? 0))) * 100 + '%',
197
+ }"></div>
198
+ <!-- Min thumb -->
199
+ <div
200
+ class="absolute top-1/2 -translate-y-1/2 w-5 h-5 bg-brand-primary rounded-full shadow-[2px_2px_10px_0px_rgba(0,0,0,0.15)] cursor-pointer touch-none"
201
+ :style="{
202
+ left: `calc(${((prices.min - (filter.min ?? 0)) / ((filter.max ?? 100) - (filter.min ?? 0))) * 100}% - 10px)`
203
+ }"
204
+ @mousedown.prevent="startDrag('min', $event)"
205
+ @touchstart.prevent="startDrag('min', $event)"></div>
206
+ <!-- Max thumb -->
207
+ <div
208
+ class="absolute top-1/2 -translate-y-1/2 w-5 h-5 bg-brand-primary rounded-full shadow-[2px_2px_10px_0px_rgba(0,0,0,0.15)] cursor-pointer touch-none"
209
+ :style="{
210
+ left: `calc(${((prices.max - (filter.min ?? 0)) / ((filter.max ?? 100) - (filter.min ?? 0))) * 100}% - 10px)`
211
+ }"
212
+ @mousedown.prevent="startDrag('max', $event)"
213
+ @touchstart.prevent="startDrag('max', $event)"></div>
214
+ </div>
215
+ </div>
216
+ </div>
217
+ </transition>
218
+ </div>
219
+ </template>
@@ -0,0 +1,120 @@
1
+ <script
2
+ setup
3
+ lang="ts"
4
+ generic="
5
+ ListingFilter extends {
6
+ code: string;
7
+ label: string;
8
+ name: string;
9
+ options: Array<Schemas['PropertyGroupOption']>;
10
+ entities: Array<Schemas['ProductManufacturer']>;
11
+ }
12
+ "
13
+ >
14
+ import { getTranslatedProperty } from "@shopware/helpers";
15
+ import { computed, ref } from "vue";
16
+ import type { Schemas } from "#shopware";
17
+
18
+ const {
19
+ filter,
20
+ selectedFilters,
21
+ displayMode = "accordion",
22
+ } = defineProps<{
23
+ filter: ListingFilter;
24
+ selectedFilters: {
25
+ manufacturer?: string[];
26
+ properties?: string[];
27
+ [key: string]: unknown;
28
+ };
29
+ displayMode?: "accordion" | "dropdown";
30
+ }>();
31
+
32
+ const emits =
33
+ defineEmits<
34
+ (e: "select-value", value: { code: string; value: unknown }) => void
35
+ >();
36
+
37
+ const isFilterVisible = ref<boolean>(false);
38
+ const toggle = () => {
39
+ isFilterVisible.value = !isFilterVisible.value;
40
+ };
41
+
42
+ const selectedIds = computed(() => {
43
+ if (filter.code === "manufacturer") {
44
+ return selectedFilters?.manufacturer || [];
45
+ }
46
+ return selectedFilters?.properties || [];
47
+ });
48
+
49
+ const isChecked = (id: string) => selectedIds.value.includes(id);
50
+
51
+ const selectValue = (id: string) => {
52
+ const emitCode =
53
+ filter.code === "manufacturer" ? "manufacturer" : "properties";
54
+ emits("select-value", {
55
+ code: emitCode,
56
+ value: id,
57
+ });
58
+ };
59
+ </script>
60
+
61
+ <template>
62
+ <div class="self-stretch flex flex-col justify-start items-start gap-4">
63
+ <!-- Accordion header (only in accordion mode) -->
64
+ <template v-if="displayMode === 'accordion'">
65
+ <div class="self-stretch flex flex-col justify-center items-center">
66
+ <div
67
+ class="self-stretch py-3 border-b border-outline-outline-variant inline-flex justify-between items-center gap-1 cursor-pointer"
68
+ @click="toggle"
69
+ role="button"
70
+ tabindex="0"
71
+ :aria-expanded="isFilterVisible"
72
+ :aria-controls="filter.code"
73
+ :aria-label="filter.label"
74
+ @keydown.enter="toggle"
75
+ @keydown.space.prevent="toggle"
76
+ >
77
+ <div class="flex-1 flex items-center gap-2.5">
78
+ <div class="flex-1 text-surface-on-surface text-base font-bold leading-normal text-left">
79
+ {{ filter.label }}
80
+ </div>
81
+ </div>
82
+ <span
83
+ class="flex items-center justify-center"
84
+ aria-hidden="true"
85
+ >
86
+ <SwChevronIcon :direction="isFilterVisible ? 'up' : 'down'" :size="24" />
87
+ </span>
88
+ </div>
89
+ </div>
90
+ </template>
91
+
92
+ <!-- Filter content -->
93
+ <transition name="filter-collapse">
94
+ <div v-if="isFilterVisible || displayMode === 'dropdown'" :id="filter.code" class="self-stretch flex flex-col justify-start items-start gap-4">
95
+ <fieldset class="self-stretch flex flex-col justify-start items-start gap-4">
96
+ <legend class="sr-only">{{ filter.name }}</legend>
97
+ <label
98
+ v-for="option in filter.options || filter.entities"
99
+ :key="`${option.id}-${isChecked(option.id)}`"
100
+ class="self-stretch inline-flex justify-start items-start gap-2 cursor-pointer"
101
+ >
102
+ <div class="w-4 self-stretch pt-[3px] flex justify-start items-start gap-2.5">
103
+ <SwCheckbox
104
+ :model-value="isChecked(option.id)"
105
+ @update:model-value="() => selectValue(option.id)"
106
+ />
107
+ </div>
108
+ <div class="flex-1 inline-flex flex-col justify-start items-start gap-0.5">
109
+ <div class="inline-flex justify-start items-center gap-1">
110
+ <div class="flex-1 text-surface-on-surface text-base font-normal leading-normal">
111
+ {{ getTranslatedProperty(option, 'name') }}
112
+ </div>
113
+ </div>
114
+ </div>
115
+ </label>
116
+ </fieldset>
117
+ </div>
118
+ </transition>
119
+ </div>
120
+ </template>
@@ -0,0 +1,99 @@
1
+ <script
2
+ setup
3
+ lang="ts"
4
+ generic="
5
+ ListingFilter extends {
6
+ code: string;
7
+ label: string;
8
+ }
9
+ "
10
+ >
11
+ import { computed, ref } from "vue";
12
+ import type { Schemas } from "#shopware";
13
+
14
+ const emits =
15
+ defineEmits<
16
+ (e: "select-value", value: { code: string; value: unknown }) => void
17
+ >();
18
+
19
+ const {
20
+ filter,
21
+ selectedFilters,
22
+ displayMode = "accordion",
23
+ } = defineProps<{
24
+ filter: ListingFilter;
25
+ selectedFilters: Schemas["ProductListingResult"]["currentFilters"];
26
+ displayMode?: "accordion" | "dropdown";
27
+ }>();
28
+ const isHoverActive = ref(false);
29
+ const hoveredIndex = ref(0);
30
+ const displayedScore = computed(() =>
31
+ isHoverActive.value ? hoveredIndex.value : selectedFilters?.rating || 0,
32
+ );
33
+
34
+ const hoverRating = (key: number) => {
35
+ hoveredIndex.value = key;
36
+ isHoverActive.value = true;
37
+ };
38
+ const onChangeRating = () => {
39
+ const newValue =
40
+ selectedFilters?.rating !== hoveredIndex.value
41
+ ? hoveredIndex.value
42
+ : undefined;
43
+ emits("select-value", { code: filter?.code, value: newValue });
44
+ };
45
+
46
+ const isFilterVisible = ref<boolean>(false);
47
+ const toggle = () => {
48
+ isFilterVisible.value = !isFilterVisible.value;
49
+ };
50
+ </script>
51
+
52
+ <template>
53
+ <div class="self-stretch flex flex-col justify-start items-start gap-4">
54
+ <!-- Accordion header (only in accordion mode) -->
55
+ <template v-if="displayMode === 'accordion'">
56
+ <div class="self-stretch flex flex-col justify-center items-center">
57
+ <div
58
+ class="self-stretch py-3 border-b border-outline-outline-variant inline-flex justify-between items-center gap-1 cursor-pointer"
59
+ @click="toggle"
60
+ role="button"
61
+ tabindex="0"
62
+ :aria-expanded="isFilterVisible"
63
+ :aria-controls="`filter-rating`"
64
+ @keydown.enter="toggle"
65
+ @keydown.space.prevent="toggle"
66
+ >
67
+ <div class="flex-1 flex items-center gap-2.5">
68
+ <div class="flex-1 text-surface-on-surface text-base font-bold leading-normal text-left">
69
+ {{ filter.label }}
70
+ </div>
71
+ </div>
72
+ <span
73
+ class="flex items-center justify-center"
74
+ aria-hidden="true"
75
+ >
76
+ <SwChevronIcon :direction="isFilterVisible ? 'up' : 'down'" :size="24" />
77
+ </span>
78
+ </div>
79
+ </div>
80
+ </template>
81
+
82
+ <!-- Filter content -->
83
+ <transition name="filter-collapse">
84
+ <div v-if="isFilterVisible || displayMode === 'dropdown'" class="self-stretch flex flex-col justify-start items-start gap-4">
85
+ <div class="flex flex-row items-center gap-2 mt-2">
86
+ <div
87
+ v-for="i in 5"
88
+ :key="i"
89
+ :class="['h-6 w-6 cursor-pointer', displayedScore >= i ? 'i-carbon-star-filled' : 'i-carbon-star']"
90
+ @mouseleave="isHoverActive = false"
91
+ @click="hoverRating(i); onChangeRating()"
92
+ @mouseover="hoverRating(i)"
93
+ :aria-label="`${i} star${i !== 1 ? 's' : ''}`"
94
+ />
95
+ </div>
96
+ </div>
97
+ </transition>
98
+ </div>
99
+ </template>
@@ -0,0 +1,114 @@
1
+ <script
2
+ setup
3
+ lang="ts"
4
+ generic="
5
+ ListingFilter extends {
6
+ id: string;
7
+ code: keyof Schemas['ProductListingResult']['currentFilters'];
8
+ label: string;
9
+ name: string;
10
+ }
11
+ "
12
+ >
13
+ import { useCmsTranslations } from "@shopware/composables";
14
+ import { onClickOutside } from "@vueuse/core";
15
+ import { defu } from "defu";
16
+ import { computed, ref } from "vue";
17
+ import type { Schemas } from "#shopware";
18
+
19
+ const {
20
+ filter,
21
+ selectedFilters,
22
+ description,
23
+ displayMode = "accordion",
24
+ } = defineProps<{
25
+ filter: ListingFilter;
26
+ selectedFilters: Schemas["ProductListingResult"]["currentFilters"];
27
+ description?: string; // Optional description for i18n
28
+ displayMode?: "accordion" | "dropdown";
29
+ }>();
30
+
31
+ type Translations = {
32
+ listing: {
33
+ freeShipping: string;
34
+ };
35
+ };
36
+ let translations: Translations = {
37
+ listing: {
38
+ freeShipping: "Free shipping",
39
+ },
40
+ };
41
+ translations = defu(useCmsTranslations(), translations) as Translations;
42
+
43
+ const emits =
44
+ defineEmits<
45
+ (e: "select-value", value: { code: string; value: unknown }) => void
46
+ >();
47
+ const currentFilterData = computed(() => !!selectedFilters[filter?.code]);
48
+
49
+ const isFilterVisible = ref<boolean>(false);
50
+ const toggle = () => {
51
+ isFilterVisible.value = !isFilterVisible.value;
52
+ };
53
+
54
+ const dropdownElement = ref(null);
55
+ onClickOutside(dropdownElement, () => {
56
+ isFilterVisible.value = false;
57
+ });
58
+
59
+ const handleRadioUpdate = (val: string | null | boolean | undefined) => {
60
+ emits("select-value", { code: filter.code, value: !!val });
61
+ };
62
+ </script>
63
+
64
+ <template>
65
+ <div class="self-stretch flex flex-col justify-start items-start gap-4">
66
+ <!-- Accordion header (only in accordion mode) -->
67
+ <template v-if="displayMode === 'accordion'">
68
+ <div class="self-stretch flex flex-col justify-center items-center">
69
+ <div
70
+ class="self-stretch py-3 border-b border-outline-outline-variant inline-flex justify-between items-center gap-1 cursor-pointer"
71
+ @click="toggle"
72
+ role="button"
73
+ tabindex="0"
74
+ :aria-expanded="isFilterVisible"
75
+ :aria-controls="`filter-${filter.code}`"
76
+ @keydown.enter="toggle"
77
+ @keydown.space.prevent="toggle"
78
+ >
79
+ <div class="flex-1 flex items-center gap-2.5">
80
+ <div class="flex-1 text-surface-on-surface text-base font-bold leading-normal text-left">
81
+ {{ filter.label }}
82
+ </div>
83
+ </div>
84
+ <span
85
+ class="flex items-center justify-center"
86
+ aria-hidden="true"
87
+ >
88
+ <SwChevronIcon :direction="isFilterVisible ? 'up' : 'down'" :size="24" />
89
+ </span>
90
+ </div>
91
+ </div>
92
+ </template>
93
+
94
+ <!-- Filter content -->
95
+ <transition name="filter-collapse">
96
+ <div v-if="isFilterVisible || displayMode === 'dropdown'" class="self-stretch">
97
+ <div class="pt-6 space-y-4">
98
+ <div class="self-stretch inline-flex justify-start items-start gap-2 w-full">
99
+ <div class="flex-1 pt-[3px]">
100
+ <SwSwitchButton
101
+ :model-value="currentFilterData"
102
+ @update:model-value="handleRadioUpdate"
103
+ :name="filter.code"
104
+ :aria-label="filter.label"
105
+ :label="filter.label"
106
+ :description="description || translations.listing.freeShipping"
107
+ />
108
+ </div>
109
+ </div>
110
+ </div>
111
+ </div>
112
+ </transition>
113
+ </div>
114
+ </template>
@@ -0,0 +1,94 @@
1
+ <script lang="ts" setup>
2
+ import { computed, defineAsyncComponent } from "vue";
3
+ import { useCmsBlock } from "#imports";
4
+ import type { Schemas } from "#shopware";
5
+
6
+ const SwMedia3DAsync = defineAsyncComponent(
7
+ () => import("../../SwMedia3D.vue"),
8
+ );
9
+
10
+ const props = defineProps<{
11
+ content: Schemas["CmsBlock"];
12
+ }>();
13
+
14
+ const { getSlotContent } = useCmsBlock(props.content);
15
+ const slotContent = getSlotContent("default");
16
+
17
+ function getConfigValue(key: string): unknown {
18
+ if (!slotContent?.config) return null;
19
+ const configEntry =
20
+ slotContent.config[key as keyof typeof slotContent.config];
21
+ if (
22
+ configEntry &&
23
+ typeof configEntry === "object" &&
24
+ "value" in configEntry &&
25
+ configEntry !== null
26
+ ) {
27
+ return (configEntry as { value: unknown }).value;
28
+ }
29
+ return null;
30
+ }
31
+
32
+ const modelUrl = computed(() => {
33
+ if (slotContent?.data) {
34
+ const data = slotContent.data as unknown as Schemas["Media"];
35
+ if (data?.url && typeof data.url === "string") {
36
+ return data.url;
37
+ }
38
+ }
39
+
40
+ const configUrl = getConfigValue("url");
41
+ if (typeof configUrl === "string" && configUrl) {
42
+ return configUrl;
43
+ }
44
+ return null;
45
+ });
46
+
47
+ const maxHeight = computed(() => {
48
+ const height = getConfigValue("maxHeight");
49
+ return typeof height === "string" ? height : "600px";
50
+ });
51
+
52
+ const formFactor = computed(() => {
53
+ const factor = getConfigValue("formFactor");
54
+ return typeof factor === "string" ? factor : "square";
55
+ });
56
+
57
+ const aspectRatio = computed(() => {
58
+ switch (formFactor.value) {
59
+ case "square":
60
+ return 1;
61
+ case "landscape":
62
+ return 16 / 9;
63
+ case "portrait":
64
+ return 9 / 16;
65
+ default:
66
+ return 1;
67
+ }
68
+ });
69
+
70
+ const containerStyle = computed(() => ({
71
+ width: "100%",
72
+ height: "100%",
73
+ minHeight: "400px",
74
+ maxHeight: maxHeight.value,
75
+ aspectRatio: `${aspectRatio.value}`,
76
+ position: "relative" as const,
77
+ }));
78
+ </script>
79
+
80
+ <template>
81
+ <div class="cms-block-spatial-viewer" :style="containerStyle">
82
+ <client-only>
83
+ <div v-if="modelUrl" class="w-full h-full">
84
+ <SwMedia3DAsync :src="modelUrl" />
85
+ </div>
86
+
87
+ <template #fallback>
88
+ <div class="w-full h-full flex items-center justify-center bg-gray-100">
89
+ <span class="text-gray-500">3D Viewer</span>
90
+ </div>
91
+ </template>
92
+ </client-only>
93
+ </div>
94
+ </template>
@@ -0,0 +1,42 @@
1
+ Renders a Block type structure.
2
+
3
+ Resolves the correct CMS block component dynamically and applies layout configuration (CSS classes, background color, background image). When a block has a `backgroundMedia` set, the component automatically optimizes the background image URL using the `getBackgroundImageUrl` helper from `@shopware/helpers`, appending `format` and `quality` parameters from the `backgroundImage` app config.
4
+
5
+ ### Background Image Optimization
6
+
7
+ Background image settings are read from `app.config.ts`:
8
+
9
+ ```ts
10
+ export default defineAppConfig({
11
+ backgroundImage: {
12
+ format: "webp",
13
+ quality: 85,
14
+ },
15
+ });
16
+ ```
17
+
18
+ ### Example usage
19
+
20
+ ```vue{14-19}
21
+ <script setup lang="ts">
22
+ import type { CmsSectionDefault } from "@shopware/composables";
23
+ import { getCmsLayoutConfiguration } from "@shopware/helpers";
24
+
25
+ const props = defineProps<{
26
+ content: CmsSectionDefault;
27
+ }>();
28
+
29
+ const { cssClasses, layoutStyles } = getCmsLayoutConfiguration(props.content);
30
+ </script>
31
+
32
+ <template>
33
+ <div class="cms-section-default" :class="cssClasses" :styles="layoutStyles">
34
+ <CmsGenericBlock
35
+ v-for="cmsBlock in content.blocks"
36
+ class="overflow-auto"
37
+ :key="cmsBlock.id"
38
+ :content="cmsBlock"
39
+ />
40
+ </div>
41
+ </template>
42
+ ```