@shopware/cms-base-layer 2.0.0 → 3.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 (67) hide show
  1. package/README.md +167 -125
  2. package/app/app.config.ts +12 -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 +14 -5
  7. package/app/components/SwProductCard.vue +24 -21
  8. package/app/components/SwProductCardDetails.vue +29 -12
  9. package/app/components/SwProductCardImage.vue +30 -29
  10. package/app/components/SwProductGallery.vue +18 -14
  11. package/app/components/SwProductListingFilter.vue +20 -9
  12. package/app/components/SwProductListingFilters.vue +3 -7
  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 +13 -13
  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 +21 -2
  29. package/app/components/public/cms/CmsGenericElement.vue +7 -2
  30. package/app/components/public/cms/CmsNoComponent.vue +87 -8
  31. package/app/components/public/cms/CmsPage.md +19 -2
  32. package/app/components/public/cms/CmsPage.vue +7 -0
  33. package/app/components/public/cms/FrontendAccountCustomerGroupRegistrationPage.vue +52 -0
  34. package/app/components/public/cms/block/CmsBlockCenterText.vue +1 -1
  35. package/app/components/public/cms/block/CmsBlockImageText.vue +5 -5
  36. package/app/components/public/cms/block/CmsBlockTextOnImage.vue +5 -12
  37. package/app/components/public/cms/element/CmsElementBuyBox.vue +3 -3
  38. package/app/components/public/cms/element/CmsElementCrossSelling.vue +19 -3
  39. package/app/components/public/cms/element/CmsElementImage.vue +12 -35
  40. package/app/components/public/cms/element/CmsElementImageGallery.vue +117 -50
  41. package/app/components/public/cms/element/CmsElementProductBox.vue +7 -1
  42. package/app/components/public/cms/element/CmsElementProductListing.vue +15 -4
  43. package/app/components/public/cms/element/CmsElementProductName.vue +6 -1
  44. package/app/components/public/cms/element/CmsElementProductSlider.vue +56 -35
  45. package/app/components/public/cms/element/CmsElementSidebarFilter.vue +10 -2
  46. package/app/components/public/cms/element/CmsElementText.vue +10 -11
  47. package/app/components/public/cms/element/SwProductListingPagination.vue +2 -2
  48. package/app/components/public/cms/section/CmsSectionDefault.vue +2 -2
  49. package/app/components/public/cms/section/CmsSectionSidebar.vue +6 -3
  50. package/app/components/ui/BaseButton.vue +18 -15
  51. package/app/components/ui/ChevronIcon.vue +10 -13
  52. package/app/components/ui/WishlistIcon.vue +3 -8
  53. package/app/composables/useImagePlaceholder.ts +3 -3
  54. package/app/composables/useLcpImagePreload.test.ts +229 -0
  55. package/app/composables/useLcpImagePreload.ts +43 -0
  56. package/app/composables/useTypedAppConfig.ts +15 -0
  57. package/app/helpers/cms/findFirstCmsImageUrl.ts +86 -0
  58. package/app/helpers/cms/getImageSizes.test.ts +50 -0
  59. package/app/helpers/cms/getImageSizes.ts +36 -0
  60. package/app/helpers/html-to-vue/ast.ts +53 -19
  61. package/app/helpers/html-to-vue/getOptionsFromNode.ts +1 -1
  62. package/app/helpers/html-to-vue/renderToHtml.ts +7 -11
  63. package/app/helpers/html-to-vue/renderer.ts +86 -26
  64. package/index.d.ts +37 -5
  65. package/nuxt.config.ts +25 -0
  66. package/package.json +21 -21
  67. package/uno.config.ts +0 -83
@@ -8,12 +8,15 @@ import { pascalCase } from "scule";
8
8
  import { computed, h, resolveComponent, watchEffect } from "vue";
9
9
  import { createCategoryListingContext, useNavigationContext } from "#imports";
10
10
  import type { Schemas } from "#shopware";
11
+ import { useLcpImagePreload } from "../../../composables/useLcpImagePreload";
12
+ import { useTypedAppConfig } from "../../../composables/useTypedAppConfig";
11
13
 
12
14
  const props = defineProps<{
13
15
  content: Schemas["CmsPage"];
14
16
  }>();
15
17
 
16
18
  const { routeName } = useNavigationContext();
19
+ const appConfig = useTypedAppConfig();
17
20
 
18
21
  // Function to initialize or update listing context
19
22
  function updateListingContext(content: Schemas["CmsPage"]) {
@@ -36,6 +39,8 @@ const cmsSections = computed<Schemas["CmsSection"][]>(() => {
36
39
  return props.content?.sections || [];
37
40
  });
38
41
 
42
+ useLcpImagePreload(props.content?.sections || []);
43
+
39
44
  const DynamicRender = () => {
40
45
  const componentsMap = cmsSections.value.map((section) => {
41
46
  return {
@@ -56,6 +61,7 @@ const DynamicRender = () => {
56
61
  layoutStyles.backgroundImage = getBackgroundImageUrl(
57
62
  layoutStyles.backgroundImage,
58
63
  componentObject.section,
64
+ appConfig.backgroundImage,
59
65
  );
60
66
  }
61
67
 
@@ -64,6 +70,7 @@ const DynamicRender = () => {
64
70
  class: {
65
71
  ...cssClasses,
66
72
  "max-w-screen-2xl w-full mx-auto": layoutStyles?.sizingMode === "boxed",
73
+ "w-full": layoutStyles?.sizingMode === "full_width",
67
74
  },
68
75
  style: {
69
76
  backgroundColor: layoutStyles?.backgroundColor,
@@ -0,0 +1,52 @@
1
+ <script setup lang="ts">
2
+ const props = defineProps<{
3
+ navigationId: string;
4
+ }>();
5
+
6
+ const { apiClient } = useShopwareContext();
7
+
8
+ const { data: registrationResponse } = await useAsyncData(
9
+ `cmsNavigation${props.navigationId}`,
10
+ async () => {
11
+ const response = await apiClient.invoke(
12
+ "getCustomerGroupRegistrationInfo get /customer-group-registration/config/{customerGroupId}",
13
+ {
14
+ pathParams: { customerGroupId: props.navigationId },
15
+ },
16
+ );
17
+ return response.data || {};
18
+ },
19
+ );
20
+
21
+ useSeoMeta({
22
+ description: () =>
23
+ registrationResponse.value?.translated?.registrationSeoMetaDescription ||
24
+ undefined,
25
+ });
26
+ </script>
27
+
28
+ <template>
29
+ <div class="container mx-auto bg-surface-surface flex flex-col mt-10">
30
+ <h1
31
+ class="mb-4 text-2xl font-extrabold leading-none tracking-tight text-surface-on-surface md:text-3xl lg:text-5xl text-center"
32
+ >
33
+ {{ registrationResponse?.translated.registrationTitle }}
34
+ </h1>
35
+ <div
36
+ v-if="registrationResponse?.registrationActive"
37
+ class="text-lg font-normal text-surface-on-surface-variant lg:text-xl"
38
+ >
39
+ <div
40
+ v-if="registrationResponse?.translated.registrationIntroduction"
41
+ class="px-6 sm:px-4 mb-6"
42
+ v-html="registrationResponse.translated.registrationIntroduction"
43
+ />
44
+ <AccountRegistrationForm
45
+ :customer-group-id="registrationResponse?.id"
46
+ :company-only="
47
+ registrationResponse?.translated?.registrationOnlyCompanyRegistration
48
+ "
49
+ />
50
+ </div>
51
+ </div>
52
+ </template>
@@ -21,6 +21,6 @@ const slotCenterContent = getSlotContent("center");
21
21
 
22
22
  <style scoped>
23
23
  .cms-block-center-text .cms-element-image {
24
- @apply aspect-square object-cover;
24
+ @apply self-stretch min-h-12;
25
25
  }
26
26
  </style>
@@ -12,12 +12,12 @@ const leftContent = getSlotContent("left");
12
12
  const rightContent = getSlotContent("right");
13
13
  </script>
14
14
  <template>
15
- <div class="flex flex-col md:flex-row justify-start items-start gap-6 w-full">
16
- <div class="w-full md:flex-1 p-4">
17
- <CmsGenericElement :content="leftContent" />
15
+ <div class="flex flex-col md:flex-row justify-start items-stretch gap-6 w-full">
16
+ <div class="w-full md:flex-1 min-w-0 p-4 flex flex-col">
17
+ <CmsGenericElement :content="leftContent" class="flex-1" />
18
18
  </div>
19
- <div class="w-full md:flex-1 p-4">
20
- <CmsGenericElement :content="rightContent" />
19
+ <div class="w-full md:flex-1 min-w-0 p-4 flex flex-col">
20
+ <CmsGenericElement :content="rightContent" class="flex-1" />
21
21
  </div>
22
22
  </div>
23
23
  </template>
@@ -1,6 +1,5 @@
1
1
  <script setup lang="ts">
2
2
  import type { CmsBlockTextOnImage } from "@shopware/composables";
3
- import { getCmsLayoutConfiguration } from "@shopware/helpers";
4
3
  import { useCmsBlock } from "#imports";
5
4
 
6
5
  const props = defineProps<{
@@ -8,23 +7,17 @@ const props = defineProps<{
8
7
  }>();
9
8
 
10
9
  const { getSlotContent } = useCmsBlock(props.content);
11
- const { cssClasses, layoutStyles } = getCmsLayoutConfiguration(props.content);
12
10
 
13
11
  const slotContent = getSlotContent("content");
14
12
  </script>
15
13
 
16
14
  <template>
17
15
  <div
18
- class="cms-block-text-on-image min-h-[500px] flex items-center justify-center py-20 px-4 bg-cover bg-center bg-no-repeat relative"
19
- :class="cssClasses"
20
- :style="layoutStyles as any"
16
+ class="cms-block-text-on-image min-h-[500px] py-20 bg-cover bg-bottom bg-no-repeat relative"
21
17
  >
22
- <div class="relative z-10 text-center max-w-6xl">
23
- <CmsGenericElement
24
- v-if="slotContent"
25
- :content="slotContent"
26
-
27
- />
28
- </div>
18
+ <CmsGenericElement
19
+ v-if="slotContent"
20
+ :content="slotContent"
21
+ />
29
22
  </div>
30
23
  </template>
@@ -56,7 +56,7 @@ const { product, changeVariant } = useProduct(
56
56
  props.content.data.product,
57
57
  props.content.data.configuratorSettings || [],
58
58
  );
59
- const { unitPrice, price, tierPrices, isListPrice } = useProductPrice(product);
59
+ const { unitPrice, price, tierPrices, hasListPrice } = useProductPrice(product);
60
60
  const regulationPrice = computed(() => price.value?.regulationPrice?.price);
61
61
  const { getFormattedPrice } = usePrice();
62
62
  const referencePrice = computed(
@@ -79,13 +79,13 @@ const productName = computed(() => product.value?.translated?.name || "");
79
79
  {{ productName }}</div>
80
80
 
81
81
  <div v-if="tierPrices.length <= 1">
82
- <SwSharedPrice v-if="isListPrice"
82
+ <SwSharedPrice v-if="hasListPrice"
83
83
  class="text-1xl text-secondary-900 basis-2/6 justify-start line-through"
84
84
  :value="price?.listPrice?.price" />
85
85
  <SwSharedPrice v-if="unitPrice"
86
86
  class="text-surface-on-surface text-base font-bold leading-normal"
87
87
  :class="{
88
- 'text-red': isListPrice,
88
+ 'text-red': hasListPrice,
89
89
  }" :value="unitPrice" />
90
90
  <div v-if="regulationPrice" class="text-xs flex text-secondary-500">
91
91
  {{ translations.product.previously }}
@@ -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,7 +3,7 @@ import type {
3
3
  CmsElementImage,
4
4
  CmsElementManufacturerLogo,
5
5
  } from "@shopware/composables";
6
- import { buildUrlPrefix, encodeUrlPath } from "@shopware/helpers";
6
+ import { buildUrlPrefix } from "@shopware/helpers";
7
7
  import { useElementSize } from "@vueuse/core";
8
8
  import { computed, defineAsyncComponent, useTemplateRef } from "vue";
9
9
  import { useCmsElementImage, useUrlResolver } from "#imports";
@@ -25,44 +25,19 @@ const {
25
25
  mimeType,
26
26
  } = useCmsElementImage(props.content);
27
27
 
28
- const DEFAULT_THUMBNAIL_SIZE = 10;
29
28
  const imageElement = useTemplateRef<HTMLImageElement>("imageElement");
30
29
  const { width, height } = useElementSize(imageElement);
31
30
 
32
31
  function roundUp(num: number) {
33
- return num ? Math.ceil(num / 100) * 100 : DEFAULT_THUMBNAIL_SIZE;
32
+ return Math.ceil(num / 100) * 100;
34
33
  }
35
34
 
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);
43
-
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);
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;
64
- }
35
+ const imageSize = computed(() => {
36
+ const containerSize = Math.max(width.value || 0, height.value || 0);
37
+ if (!containerSize) return undefined;
38
+ return roundUp(containerSize * 2);
65
39
  });
40
+
66
41
  const imageComputedContainerAttrs = computed(() => {
67
42
  const imageAttrsCopy = Object.assign({}, imageContainerAttrs.value);
68
43
  if (imageAttrsCopy?.href) {
@@ -114,16 +89,18 @@ const SwMedia3D = computed(() => {
114
89
  ref="imageElement"
115
90
  preset="productDetail"
116
91
  loading="lazy"
92
+ :width="imageSize"
93
+ :height="imageSize"
117
94
  :class="{
118
- 'w-full h-full': !imageGallery,
95
+ 'w-full': !imageGallery,
96
+ 'h-full': !imageGallery && ['cover', 'stretch'].includes(displayMode),
119
97
  'w-4/5': imageGallery,
120
98
  'absolute left-0 top-0': ['cover', 'stretch'].includes(displayMode),
121
99
  'object-cover': displayMode === 'cover',
122
100
  'object-contain': imageGallery || displayMode !== 'cover',
123
101
  }"
124
102
  :alt="imageAttrs.alt"
125
- :src="srcPath"
126
- :srcset="imageAttrs.srcset"
103
+ :src="imageAttrs.src"
127
104
  />
128
105
  </component>
129
106
  </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>
@@ -3,14 +3,19 @@ import type { CmsElementProductListing } from "@shopware/composables";
3
3
  import { useCmsTranslations } from "@shopware/composables";
4
4
  import { defu } from "defu";
5
5
  import { computed, ref, useTemplateRef, watch } from "vue";
6
- import { useRoute, useRouter } from "vue-router";
7
- import { useCategoryListing } from "#imports";
6
+ import {
7
+ useCategoryListing,
8
+ useCmsElementConfig,
9
+ useRoute,
10
+ useRouter,
11
+ } from "#imports";
8
12
  import type { Schemas, operations } from "#shopware";
9
13
 
10
14
  const props = defineProps<{
11
15
  content: CmsElementProductListing;
12
16
  }>();
13
17
 
18
+ const { getConfigValue } = useCmsElementConfig(props.content);
14
19
  const defaultLimit = 15;
15
20
  const defaultPage = 1;
16
21
  const defaultOrder = "name-asc";
@@ -154,8 +159,14 @@ compareRouteQueryWithInitialListing();
154
159
  {{ translations.listing.noProducts }}
155
160
  </div>
156
161
  <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" />
162
+ <SwProductCard
163
+ v-for="product in getElements"
164
+ :key="product.id"
165
+ :product="product"
166
+ :is-product-listing="isProductListing"
167
+ :layout-type="getConfigValue('boxLayout')"
168
+ class="w-full"
169
+ />
159
170
  </div>
160
171
  <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
172
  <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>