@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
@@ -4,7 +4,7 @@ import type {
4
4
  SliderElementConfig,
5
5
  } from "@shopware/composables";
6
6
  import { useElementSize } from "@vueuse/core";
7
- import { computed, ref, useTemplateRef } from "vue";
7
+ import { computed, inject, ref, useTemplateRef } from "vue";
8
8
  import { useCmsElementConfig } from "#imports";
9
9
 
10
10
  const props = defineProps<{
@@ -46,9 +46,24 @@ const crossSellCollections = computed(() => {
46
46
  });
47
47
 
48
48
  const { width } = useElementSize(crossSellContainer);
49
+ const slotCount = inject<number>("cms-block-slot-count", 1);
50
+ const elMinWidth = computed(
51
+ () => +(config.value.minWidth?.value.replace(/\D+/g, "") || 300),
52
+ );
49
53
  const slidesToShow = computed(() => {
50
- const minWidth = +(config.value.minWidth?.value.replace(/\D+/g, "") || 0);
51
- return Math.floor(width.value / (minWidth * 1.2));
54
+ // SSR: useElementSize returns 0, fallback to 1200px estimate divided by slot count
55
+ const containerWidth = width.value || 1200 / slotCount;
56
+ return Math.max(1, Math.floor(containerWidth / elMinWidth.value));
57
+ });
58
+
59
+ // Responsive SSR breakpoints: scale by slotCount since container is ~1/slotCount of viewport
60
+ const ssrBreakpoints = computed(() => {
61
+ const max = slidesToShow.value;
62
+ const bp: Record<string, number> = {};
63
+ for (let n = 2; n <= max; n++) {
64
+ bp[`(min-width: ${elMinWidth.value * n * slotCount}px)`] = n;
65
+ }
66
+ return bp;
52
67
  });
53
68
 
54
69
  const toggleTab = (index: number) => {
@@ -80,6 +95,7 @@ const toggleTab = (index: number) => {
80
95
  :slides-to-show="slidesToShow"
81
96
  :slides-to-scroll="1"
82
97
  :autoplay="false"
98
+ :ssr-breakpoints="ssrBreakpoints"
83
99
  >
84
100
  <SwProductCard
85
101
  v-for="product of crossSellCollections[currentTabIndex]?.products"
@@ -3,10 +3,14 @@ import type {
3
3
  CmsElementImage,
4
4
  CmsElementManufacturerLogo,
5
5
  } from "@shopware/composables";
6
- import { buildUrlPrefix, encodeUrlPath } from "@shopware/helpers";
6
+ import {
7
+ buildCdnImageUrl,
8
+ buildUrlPrefix,
9
+ generateCdnSrcSet,
10
+ } from "@shopware/helpers";
7
11
  import { useElementSize } from "@vueuse/core";
8
- import { computed, defineAsyncComponent, useTemplateRef } from "vue";
9
- import { useCmsElementImage, useUrlResolver } from "#imports";
12
+ import { computed, defineAsyncComponent, inject, useTemplateRef } from "vue";
13
+ import { useAppConfig, useCmsElementImage, useUrlResolver } from "#imports";
10
14
  import { isSpatial } from "../../../../helpers/media/isSpatial";
11
15
 
12
16
  const props = defineProps<{
@@ -25,44 +29,36 @@ const {
25
29
  mimeType,
26
30
  } = useCmsElementImage(props.content);
27
31
 
28
- const DEFAULT_THUMBNAIL_SIZE = 10;
32
+ const imageSizes = inject<string>("cms-image-sizes", "100vw");
33
+ const appConfig = useAppConfig();
34
+
29
35
  const imageElement = useTemplateRef<HTMLImageElement>("imageElement");
30
36
  const { width, height } = useElementSize(imageElement);
31
37
 
32
- function roundUp(num: number) {
33
- return num ? Math.ceil(num / 100) * 100 : DEFAULT_THUMBNAIL_SIZE;
34
- }
35
-
36
- const srcPath = computed(() => {
37
- if (!imageAttrs.value.src) return "";
38
-
39
- try {
40
- // Encode the URL first to handle special characters
41
- const encodedUrl = encodeUrlPath(imageAttrs.value.src);
42
- const url = new URL(encodedUrl);
38
+ const cdnOptions = computed(() => ({
39
+ format: appConfig.backgroundImage?.format,
40
+ quality: appConfig.backgroundImage?.quality,
41
+ }));
43
42
 
44
- // Only add size parameters if dimensions are available (after mount)
45
- // This prevents hydration mismatch
46
- const w = roundUp(width.value);
47
- const h = roundUp(height.value);
43
+ const srcSet = computed(
44
+ () =>
45
+ imageAttrs.value.srcset ||
46
+ generateCdnSrcSet(imageAttrs.value.src, undefined, cdnOptions.value),
47
+ );
48
48
 
49
- if (w > DEFAULT_THUMBNAIL_SIZE || h > DEFAULT_THUMBNAIL_SIZE) {
50
- if (width.value > height.value) {
51
- url.searchParams.set("width", String(w));
52
- } else {
53
- url.searchParams.set("height", String(h));
54
- }
55
- }
56
-
57
- // Add fit parameter
58
- url.searchParams.set("fit", "crop,smart");
59
-
60
- return url.toString();
61
- } catch {
62
- // Fallback if URL parsing fails
63
- return imageAttrs.value.src;
49
+ const srcPath = computed(() => {
50
+ // Only add dimension params after mount to avoid hydration mismatch
51
+ // (useElementSize returns 0 during SSR). The srcset handles responsive loading.
52
+ if (width.value || height.value) {
53
+ return buildCdnImageUrl(
54
+ imageAttrs.value.src,
55
+ { width: width.value, height: height.value },
56
+ cdnOptions.value,
57
+ );
64
58
  }
59
+ return imageAttrs.value.src || "";
65
60
  });
61
+
66
62
  const imageComputedContainerAttrs = computed(() => {
67
63
  const imageAttrsCopy = Object.assign({}, imageContainerAttrs.value);
68
64
  if (imageAttrsCopy?.href) {
@@ -114,8 +110,10 @@ const SwMedia3D = computed(() => {
114
110
  ref="imageElement"
115
111
  preset="productDetail"
116
112
  loading="lazy"
113
+ :sizes="imageSizes"
117
114
  :class="{
118
- 'w-full h-full': !imageGallery,
115
+ 'w-full': !imageGallery,
116
+ 'h-full': !imageGallery && ['cover', 'stretch'].includes(displayMode),
119
117
  'w-4/5': imageGallery,
120
118
  'absolute left-0 top-0': ['cover', 'stretch'].includes(displayMode),
121
119
  'object-cover': displayMode === 'cover',
@@ -123,7 +121,7 @@ const SwMedia3D = computed(() => {
123
121
  }"
124
122
  :alt="imageAttrs.alt"
125
123
  :src="srcPath"
126
- :srcset="imageAttrs.srcset"
124
+ :srcset="srcSet"
127
125
  />
128
126
  </component>
129
127
  </template>
@@ -1,25 +1,36 @@
1
1
  <script setup lang="ts">
2
2
  import type { CmsElementImageGallery } from "@shopware/composables";
3
- import { computed, ref } from "vue";
4
- import { useCmsElementConfig } from "#imports";
3
+ import { computed, defineAsyncComponent, ref } from "vue";
4
+ import { useCmsElementConfig, useImagePlaceholder } from "#imports";
5
5
  import { isSpatial } from "../../../../helpers/media/isSpatial";
6
6
 
7
- const props = withDefaults(
8
- defineProps<{
9
- content: CmsElementImageGallery;
10
- slidesToShow?: number;
11
- slidesToScroll?: number;
12
- }>(),
13
- {
14
- slidesToShow: 5,
15
- slidesToScroll: 4,
16
- },
7
+ // Load SwMedia3D only on client-side to avoid SSR issues with three.js packages
8
+ const SwMedia3DAsync = defineAsyncComponent(
9
+ () => import("../../../SwMedia3D.vue"),
17
10
  );
18
11
 
12
+ const props = defineProps<{
13
+ content: CmsElementImageGallery;
14
+ }>();
15
+
19
16
  const { getConfigValue } = useCmsElementConfig(props.content);
20
17
 
18
+ const DEFAULT_MIN_HEIGHT = "500px";
19
+ const DEFAULT_NAVIGATION = "inside";
20
+
21
+ const minHeight = computed(
22
+ () => getConfigValue("minHeight") || DEFAULT_MIN_HEIGHT,
23
+ );
24
+ const navigationArrows = computed(
25
+ () => getConfigValue("navigationArrows") || DEFAULT_NAVIGATION,
26
+ );
27
+ const navigationDots = computed(
28
+ () => getConfigValue("navigationDots") || DEFAULT_NAVIGATION,
29
+ );
30
+
21
31
  const currentIndex = ref(0);
22
32
  const mediaGallery = computed(() => props.content.data?.sliderItems ?? []);
33
+ const placeholderSvg = useImagePlaceholder();
23
34
 
24
35
  function goToSlide(index: number) {
25
36
  if (index >= 0 && index < mediaGallery.value.length) {
@@ -43,7 +54,7 @@ const currentImage = computed(() => {
43
54
  return mediaGallery.value[currentIndex.value]?.media;
44
55
  });
45
56
 
46
- // Touch event handling for mobile swipe gestures - mobile
57
+ // Touch event handling for mobile swipe gestures
47
58
  const touchStartX = ref(0);
48
59
  const touchEndX = ref(0);
49
60
 
@@ -57,77 +68,133 @@ function onTouchMove(event: TouchEvent) {
57
68
 
58
69
  function onTouchEnd() {
59
70
  const deltaX = touchEndX.value - touchStartX.value;
60
-
61
- // Define a threshold for swipe detection
62
71
  const threshold = 50; // pixels
63
72
 
64
73
  if (Math.abs(deltaX) > threshold) {
65
74
  if (deltaX < 0) {
66
- // Swipe Left
67
75
  next();
68
76
  } else {
69
- // Swipe Right
70
77
  previous();
71
78
  }
72
79
  }
73
80
 
74
- // Reset values
75
81
  touchStartX.value = 0;
76
82
  touchEndX.value = 0;
77
83
  }
78
84
  </script>
79
85
 
80
86
  <template>
81
- <div class="w-full max-w-full relative inline-flex flex-col justify-center items-center gap-2 mx-auto">
87
+ <div
88
+ class="w-full max-w-full relative inline-flex flex-col justify-center items-center gap-2 mx-auto"
89
+ >
82
90
  <div class="w-full">
83
91
  <!-- Main Image Display -->
84
-
85
- <div class="w-full h-[400px] sm:h-[500px] lg:h-[600px] xl:h-[700px] relative overflow-hidden rounded-lg"
86
- @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd">
92
+ <div
93
+ class="w-full relative overflow-hidden"
94
+ :style="{ minHeight }"
95
+ @touchstart="onTouchStart"
96
+ @touchmove="onTouchMove"
97
+ @touchend="onTouchEnd"
98
+ >
87
99
  <Transition name="gallery-fade" mode="out-in">
88
- <div v-if="currentImage && isSpatial(currentImage)" class="w-full h-full relative">
89
- <CmsElementImageGallery3dPlaceholder class="w-full h-full absolute inset-0 object-cover" />
90
- <span class="absolute bottom-4 right-4 text-sm bg-gray-800 rounded px-2 py-1 text-white">
91
- 3D
92
- </span>
100
+ <!-- 3D media -->
101
+ <div
102
+ v-if="currentImage && isSpatial(currentImage)"
103
+ :key="currentImage.url + '-3d'"
104
+ class="w-full h-full relative"
105
+ :style="{ minHeight }"
106
+ >
107
+ <client-only>
108
+ <SwMedia3DAsync :src="currentImage.url" />
109
+ <template #fallback>
110
+ <CmsElementImageGallery3dPlaceholder
111
+ class="w-full h-full absolute inset-0 object-cover"
112
+ />
113
+ <span
114
+ class="absolute bottom-4 right-4 text-sm bg-gray-800 rounded px-2 py-1 text-white"
115
+ >
116
+ 3D
117
+ </span>
118
+ </template>
119
+ </client-only>
93
120
  </div>
94
- <NuxtImg v-else-if="currentImage" preset="hero" loading="lazy"
95
- class="w-full h-full absolute inset-0 object-cover" :src="currentImage.url"
96
- :key="currentImage.url" :alt="currentImage.alt || 'Product image'" />
97
- <NuxtImg v-else preset="hero" class="w-full h-full absolute inset-0 object-cover"
98
- src="https://placehold.co/600x500" alt="Placeholder image" />
121
+ <!-- Regular image -->
122
+ <NuxtImg
123
+ v-else-if="currentImage"
124
+ :key="currentImage.url"
125
+ preset="hero"
126
+ loading="lazy"
127
+ class="w-full h-full absolute inset-0 object-cover"
128
+ :placeholder="placeholderSvg"
129
+ :src="currentImage.url"
130
+ :alt="currentImage.alt || 'Product image'"
131
+ />
132
+ <!-- Placeholder -->
133
+ <img
134
+ v-else
135
+ class="w-full h-full absolute inset-0 object-cover"
136
+ :src="placeholderSvg"
137
+ alt="Placeholder image"
138
+ />
99
139
  </Transition>
100
-
101
140
  </div>
141
+
102
142
  <!-- Navigation Arrows -->
103
- <div v-if="mediaGallery.length > 1"
104
- class="absolute inset-0 flex items-center justify-between px-2 sm:px-4 pointer-events-none">
143
+ <div
144
+ v-if="mediaGallery.length > 1 && navigationArrows !== 'none'"
145
+ class="absolute inset-0 flex items-center justify-between px-2 sm:px-4 pointer-events-none"
146
+ >
105
147
  <!-- Previous Button -->
106
148
  <button
107
- class="w-10 h-10 bg-brand-tertiary rounded-full hover:bg-brand-tertiary-hover transition-colors disabled:opacity-50 pointer-events-auto shadow-lg"
108
- :disabled="currentIndex === 0" @click="previous" aria-label="Previous image">
109
- <div class="flex items-center justify-center w-full h-full">
110
- <div class="i-carbon-chevron-left w-5 h-5 text-brand-on-tertiary"></div>
111
- </div>
149
+ :class="[
150
+ 'w-10 h-10 rounded-full transition disabled:opacity-50 pointer-events-auto shadow-lg flex items-center justify-center',
151
+ navigationArrows === 'outside'
152
+ ? 'bg-brand-tertiary text-surface-on-surface'
153
+ : 'bg-surface-surface/20 hover:bg-surface-surface/50',
154
+ ]"
155
+ :disabled="currentIndex === 0"
156
+ aria-label="Previous image"
157
+ @click="previous"
158
+ >
159
+ <SwChevronIcon direction="left" />
112
160
  </button>
113
161
 
114
162
  <!-- Next Button -->
115
163
  <button
116
- class="w-10 h-10 bg-brand-tertiary rounded-full hover:bg-brand-tertiary-hover transition-colors disabled:opacity-50 pointer-events-auto shadow-lg"
117
- :disabled="currentIndex === mediaGallery.length - 1" @click="next" aria-label="Next image">
118
- <div class="flex items-center justify-center w-full h-full">
119
- <div class="i-carbon-chevron-right w-5 h-5 text-brand-on-tertiary"></div>
120
- </div>
164
+ :class="[
165
+ 'w-10 h-10 rounded-full transition disabled:opacity-50 pointer-events-auto shadow-lg flex items-center justify-center',
166
+ navigationArrows === 'outside'
167
+ ? 'bg-brand-tertiary text-surface-on-surface'
168
+ : 'bg-surface-surface/20 hover:bg-surface-surface/50',
169
+ ]"
170
+ :disabled="currentIndex === mediaGallery.length - 1"
171
+ aria-label="Next image"
172
+ @click="next"
173
+ >
174
+ <SwChevronIcon direction="right" />
121
175
  </button>
122
176
  </div>
123
177
 
124
178
  <!-- Dot Indicators -->
125
- <div v-if="mediaGallery.length > 1" class="flex justify-center items-center gap-2 mt-2">
126
- <button v-for="(image, index) in mediaGallery" :key="image.media.url"
127
- class="relative rounded-full transition-all duration-200 hover:scale-110" :class="{
179
+ <div
180
+ v-if="mediaGallery.length > 1 && navigationDots !== 'none'"
181
+ :class="[
182
+ 'flex justify-center items-center gap-2',
183
+ navigationDots === 'outside' ? 'mt-4' : 'absolute bottom-4 left-1/2 transform -translate-x-1/2',
184
+ ]"
185
+ >
186
+ <button
187
+ v-for="(image, index) in mediaGallery"
188
+ :key="image.media?.url"
189
+ class="relative rounded-full transition-all duration-200 hover:scale-110"
190
+ :class="{
128
191
  'w-6 h-2 bg-surface-on-surface-variant': index === currentIndex,
129
- 'w-2 h-2 bg-surface-surface-container-highest': index !== currentIndex
130
- }" @click="goToSlide(index)" :aria-label="`Go to image ${index + 1}`" />
192
+ 'w-2 h-2 bg-surface-surface-container-highest':
193
+ index !== currentIndex,
194
+ }"
195
+ :aria-label="`Go to image ${index + 1}`"
196
+ @click="goToSlide(index)"
197
+ />
131
198
  </div>
132
199
  </div>
133
200
  </div>
@@ -1,15 +1,21 @@
1
1
  <script setup lang="ts">
2
2
  import type { CmsElementProductBox } from "@shopware/composables";
3
3
  import { computed } from "vue";
4
+ import { useCmsElementConfig } from "#imports";
4
5
 
5
6
  const props = defineProps<{
6
7
  content: CmsElementProductBox;
7
8
  }>();
8
9
 
10
+ const { getConfigValue } = useCmsElementConfig(props.content);
9
11
  const product = computed(() => props.content.data?.product || {});
10
12
  </script>
11
13
 
12
14
  <template>
13
- <SwProductCard v-if="product?.id" :product="product" />
15
+ <SwProductCard
16
+ v-if="product?.id"
17
+ :product="product"
18
+ :layout-type="getConfigValue('boxLayout')"
19
+ />
14
20
  <SwProductCardSkeleton v-else />
15
21
  </template>
@@ -4,13 +4,14 @@ import { useCmsTranslations } from "@shopware/composables";
4
4
  import { defu } from "defu";
5
5
  import { computed, ref, useTemplateRef, watch } from "vue";
6
6
  import { useRoute, useRouter } from "vue-router";
7
- import { useCategoryListing } from "#imports";
7
+ import { useCategoryListing, useCmsElementConfig } from "#imports";
8
8
  import type { Schemas, operations } from "#shopware";
9
9
 
10
10
  const props = defineProps<{
11
11
  content: CmsElementProductListing;
12
12
  }>();
13
13
 
14
+ const { getConfigValue } = useCmsElementConfig(props.content);
14
15
  const defaultLimit = 15;
15
16
  const defaultPage = 1;
16
17
  const defaultOrder = "name-asc";
@@ -154,8 +155,14 @@ compareRouteQueryWithInitialListing();
154
155
  {{ translations.listing.noProducts }}
155
156
  </div>
156
157
  <div v-if="!loading" ref="productListElement" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 auto-rows-fr gap-x-4 sm:gap-x-6 lg:gap-x-8 gap-y-8 sm:gap-y-12 lg:gap-y-16">
157
- <SwProductCard v-for="product in getElements" :key="product.id" :product="product"
158
- :is-product-listing="isProductListing" class="w-full" />
158
+ <SwProductCard
159
+ v-for="product in getElements"
160
+ :key="product.id"
161
+ :product="product"
162
+ :is-product-listing="isProductListing"
163
+ :layout-type="getConfigValue('boxLayout')"
164
+ class="w-full"
165
+ />
159
166
  </div>
160
167
  <div v-if="loading" data-testid="loading" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 auto-rows-fr gap-x-4 sm:gap-x-6 lg:gap-x-8 gap-y-8 sm:gap-y-12 lg:gap-y-16">
161
168
  <ProductCardSkeleton v-for="index in limit" :key="index"
@@ -7,5 +7,10 @@ defineProps<{
7
7
  </script>
8
8
  <template>
9
9
  <!-- there is no css config coming from API for this element so we don't need to merge -->
10
- <CmsElementText :content="content as any" class="self-stretch text-surface-on-surface text-4xl font-normal font-serif leading-[60px]" />
10
+ <div role="heading" aria-level="1">
11
+ <CmsElementText
12
+ :content="content as any"
13
+ class="self-stretch text-surface-on-surface text-4xl font-normal font-serif leading-[60px]"
14
+ />
15
+ </div>
11
16
  </template>
@@ -3,8 +3,9 @@ import type {
3
3
  CmsElementProductSlider,
4
4
  SliderElementConfig,
5
5
  } from "@shopware/composables";
6
- import { computed, onMounted, ref, useTemplateRef } from "vue";
7
- import type { ComputedRef } from "vue";
6
+ import { useElementSize } from "@vueuse/core";
7
+ import { computed, inject, useTemplateRef } from "vue";
8
+ import type { CSSProperties, ComputedRef } from "vue";
8
9
  import { useCmsElementConfig } from "#imports";
9
10
 
10
11
  const props = defineProps<{
@@ -13,7 +14,16 @@ const props = defineProps<{
13
14
  const { getConfigValue } = useCmsElementConfig(props.content);
14
15
 
15
16
  const productSlider = useTemplateRef<HTMLDivElement>("productSlider");
16
- const slidesToShow = ref<number>();
17
+ const slotCount = inject<number>("cms-block-slot-count", 1);
18
+ const elMinWidth = computed(
19
+ () => +getConfigValue("elMinWidth").replace(/\D+/g, "") || 300,
20
+ );
21
+ const { width } = useElementSize(productSlider);
22
+ const slidesToShow = computed(() => {
23
+ // SSR: useElementSize returns 0, fallback to 1200px estimate divided by slot count
24
+ const containerWidth = width.value || 1200 / slotCount;
25
+ return Math.max(1, Math.floor(containerWidth / elMinWidth.value));
26
+ });
17
27
  const products = computed(() => props.content?.data?.products ?? []);
18
28
  const config: ComputedRef<SliderElementConfig> = computed(() => ({
19
29
  minHeight: {
@@ -29,52 +39,63 @@ const config: ComputedRef<SliderElementConfig> = computed(() => ({
29
39
  source: "static",
30
40
  },
31
41
  navigationDots: {
32
- value: "",
42
+ value: getConfigValue("navigation") === true ? "outside" : "",
33
43
  source: "static",
34
44
  },
35
45
  navigationArrows: {
36
- value: getConfigValue("navigation") ? "outside" : "",
46
+ value: getConfigValue("navigation") === true ? "outside" : "",
37
47
  source: "static",
38
48
  },
39
49
  }));
40
50
 
41
- onMounted(() => {
42
- setTimeout(() => {
43
- let temp = 1;
44
- const minWidth = +getConfigValue("elMinWidth").replace(/\D+/g, "");
45
- if (productSlider.value?.clientWidth) {
46
- temp = Math.ceil(productSlider.value?.clientWidth / (minWidth * 1.2));
47
- }
48
- slidesToShow.value = temp;
49
- }, 100);
51
+ // Responsive SSR breakpoints: scale by slotCount since container is ~1/slotCount of viewport
52
+ const ssrBreakpoints = computed(() => {
53
+ const max = slidesToShow.value;
54
+ const bp: Record<string, number> = {};
55
+ for (let n = 2; n <= max; n++) {
56
+ bp[`(min-width: ${elMinWidth.value * n * slotCount}px)`] = n;
57
+ }
58
+ return bp;
50
59
  });
51
60
 
52
61
  const autoplay = computed(() => getConfigValue("rotate"));
53
62
  const title = computed(() => getConfigValue("title"));
54
63
  const border = computed(() => getConfigValue("border"));
64
+
65
+ const verticalAlignStyle = computed<CSSProperties>(() => ({
66
+ alignContent: getConfigValue("verticalAlign"),
67
+ }));
68
+ const hasVerticalAlignment = computed(
69
+ () => !!verticalAlignStyle.value.alignContent,
70
+ );
55
71
  </script>
56
72
  <template>
57
- <div ref="productSlider" class="cms-element-product-slider">
58
- <h3 v-if="title" class="pl-6 pb-6 text-center md:text-left text-surface-on-surface">
59
- {{ title }}
60
- </h3>
61
- <div :class="{ 'py-5 border border-outline-outline-variant': border }">
62
- <SwSlider
63
- :config="config"
64
- gap="1.25rem"
65
- :slides-to-show="slidesToShow"
66
- :slides-to-scroll="1"
67
- :autoplay="autoplay"
68
- >
69
- <SwProductCard
70
- v-for="product of products"
71
- :key="product.id"
72
- class="h-full"
73
- :product="product"
74
- :layout-type="getConfigValue('boxLayout')"
75
- :display-mode="getConfigValue('displayMode')"
76
- />
77
- </SwSlider>
73
+ <div
74
+ :style="hasVerticalAlignment ? verticalAlignStyle : undefined"
75
+ >
76
+ <div ref="productSlider" class="cms-element-product-slider">
77
+ <h3 v-if="title" class="pl-6 pb-6 text-center md:text-left text-surface-on-surface">
78
+ {{ title }}
79
+ </h3>
80
+ <div :class="{ 'py-5 border border-outline-outline-variant': border }">
81
+ <SwSlider
82
+ :config="config"
83
+ gap="1.25rem"
84
+ :slides-to-show="slidesToShow"
85
+ :slides-to-scroll="1"
86
+ :autoplay="autoplay"
87
+ :ssr-breakpoints="ssrBreakpoints"
88
+ >
89
+ <SwProductCard
90
+ v-for="product of products"
91
+ :key="product.id"
92
+ class="h-full"
93
+ :product="product"
94
+ :layout-type="getConfigValue('boxLayout')"
95
+ :display-mode="getConfigValue('displayMode')"
96
+ />
97
+ </SwSlider>
98
+ </div>
78
99
  </div>
79
100
  </div>
80
101
  </template>
@@ -1,12 +1,20 @@
1
1
  <script setup lang="ts">
2
2
  import type { CmsElementSidebarFilter } from "@shopware/composables";
3
+ import { inject } from "vue";
3
4
 
4
5
  defineProps<{
5
6
  content: CmsElementSidebarFilter;
6
7
  }>();
8
+
9
+ // Inject layout context from parent section
10
+ // If in sidebar section -> use vertical accordion filters
11
+ // Otherwise -> use horizontal dropdown filters
12
+ const sectionLayout = inject<string>("cms-section-layout", "default");
13
+ const isInSidebar = sectionLayout === "sidebar";
7
14
  </script>
8
15
  <template>
9
- <div class="max-w-screen-xl mx-auto">
10
- <SwProductListingFilters :content="content" />
16
+ <div>
17
+ <SwProductListingFilters v-if="isInSidebar" :content="content" />
18
+ <SwProductListingFiltersHorizontal v-else :content="content" />
11
19
  </div>
12
20
  </template>