@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,37 @@
1
+ <script setup lang="ts">
2
+ import type { CmsBlockImageTwoColumn } from "@shopware/composables";
3
+ import { useCmsBlock } from "#imports";
4
+
5
+ const props = defineProps<{
6
+ content: CmsBlockImageTwoColumn;
7
+ }>();
8
+
9
+ const { getSlotContent } = useCmsBlock(props.content);
10
+
11
+ const leftContent = getSlotContent("left");
12
+ const rightContent = getSlotContent("right");
13
+ </script>
14
+ <template>
15
+ <div class="cms-block-image-two-column flex flex-col md:flex-row justify-start items-start gap-6 w-full">
16
+ <div class="w-full md:flex-1 md:min-w-0">
17
+ <CmsGenericElement :content="leftContent" />
18
+ </div>
19
+ <div class="w-full md:flex-1 md:min-w-0">
20
+ <CmsGenericElement :content="rightContent" />
21
+ </div>
22
+ </div>
23
+ </template>
24
+
25
+ <style scoped>
26
+ .cms-block-image-two-column :deep(.cms-element-image) {
27
+ @apply relative h-full w-full;
28
+ }
29
+
30
+ .cms-block-image-two-column :deep(.cms-element-image img) {
31
+ @apply w-full h-full object-cover;
32
+ }
33
+
34
+ .cms-block-image-two-column :deep(.cms-element-image-slider) {
35
+ @apply w-full;
36
+ }
37
+ </style>
@@ -13,7 +13,7 @@ const rightContent = getSlotContent("right");
13
13
  </script>
14
14
 
15
15
  <template>
16
- <div class="cms-block-product-heading flex justify-between">
16
+ <div class="cms-block-product-heading flex justify-between pt-4">
17
17
  <CmsGenericElement :content="leftContent" />
18
18
  <CmsGenericElement :content="rightContent" />
19
19
  </div>
@@ -14,9 +14,15 @@ const centerContent = getSlotContent("center");
14
14
  </script>
15
15
 
16
16
  <template>
17
- <div class="grid md:grid-cols-3 gap-10 place-items-center">
18
- <CmsGenericElement :content="leftContent" class="w-full" />
19
- <CmsGenericElement :content="centerContent" class="w-full" />
20
- <CmsGenericElement :content="rightContent" class="w-full" />
17
+ <div class="flex flex-col sm:flex-row justify-start items-start gap-6 w-full">
18
+ <div class="w-full sm:flex-1">
19
+ <CmsGenericElement :content="leftContent" class="w-full" />
20
+ </div>
21
+ <div class="w-full sm:flex-1">
22
+ <CmsGenericElement :content="centerContent" class="w-full" />
23
+ </div>
24
+ <div class="w-full sm:flex-1">
25
+ <CmsGenericElement :content="rightContent" class="w-full" />
26
+ </div>
21
27
  </div>
22
28
  </template>
@@ -10,7 +10,9 @@ const props = defineProps<{
10
10
  }>();
11
11
 
12
12
  const { getSlotContent } = useCmsBlock(props.content);
13
- const slotContent = getSlotContent("content") as CmsElementSidebarFilter;
13
+ const slotContent = getSlotContent(
14
+ "content",
15
+ ) as unknown as CmsElementSidebarFilter;
14
16
  </script>
15
17
  <template>
16
18
  <CmsElementSidebarFilter :content="slotContent" />
@@ -12,9 +12,12 @@ const slotContent = getSlotContent("content");
12
12
  </script>
13
13
 
14
14
  <template>
15
- <CmsGenericElement
16
- v-if="slotContent"
17
- :content="slotContent"
18
- class="cms-block-text-on-image min-h-md"
19
- />
15
+ <div
16
+ class="cms-block-text-on-image min-h-[500px] py-20 bg-cover bg-bottom bg-no-repeat relative"
17
+ >
18
+ <CmsGenericElement
19
+ v-if="slotContent"
20
+ :content="slotContent"
21
+ />
22
+ </div>
20
23
  </template>
@@ -0,0 +1,145 @@
1
+ <script setup lang="ts">
2
+ import type { CmsElementBuyBox } from "@shopware/composables";
3
+ import { useCmsTranslations } from "@shopware/composables";
4
+ import { defu } from "defu";
5
+ import { computed } from "vue";
6
+ import {
7
+ useCmsElementConfig,
8
+ usePrice,
9
+ useProduct,
10
+ useProductPrice,
11
+ useSessionContext,
12
+ } from "#imports";
13
+
14
+ const props = defineProps<{
15
+ content: CmsElementBuyBox;
16
+ }>();
17
+
18
+ type Translations = {
19
+ product: {
20
+ previously: string;
21
+ amount: string;
22
+ price: {
23
+ [key: string]: string;
24
+ };
25
+ to: string;
26
+ from: string;
27
+ content: string;
28
+ pricesIncl: string;
29
+ pricesExcl: string;
30
+ };
31
+ };
32
+
33
+ let translations: Translations = {
34
+ product: {
35
+ previously: "Previously",
36
+ amount: "Amount",
37
+ price: {
38
+ label: "Price",
39
+ to: "To",
40
+ from: "From",
41
+ },
42
+ to: "To",
43
+ from: "From",
44
+ content: "Content",
45
+ pricesIncl: "Prices incl. VAT plus shipping costs",
46
+ pricesExcl: "Prices excl. VAT plus shipping costs",
47
+ },
48
+ };
49
+
50
+ translations = defu(useCmsTranslations(), translations) as Translations;
51
+
52
+ const { getConfigValue } = useCmsElementConfig(props.content);
53
+ const alignment = computed(() => getConfigValue("alignment"));
54
+ const { taxState, currency } = useSessionContext();
55
+ const { product, changeVariant } = useProduct(
56
+ props.content.data.product,
57
+ props.content.data.configuratorSettings || [],
58
+ );
59
+ const { unitPrice, price, tierPrices, hasListPrice } = useProductPrice(product);
60
+ const regulationPrice = computed(() => price.value?.regulationPrice?.price);
61
+ const { getFormattedPrice } = usePrice();
62
+ const referencePrice = computed(
63
+ () => product.value?.calculatedPrice?.referencePrice,
64
+ );
65
+ const purchaseUnit = computed(() => product.value?.purchaseUnit);
66
+ const unitName = computed(() => product.value?.unit?.name);
67
+ const productName = computed(() => product.value?.translated?.name || "");
68
+ </script>
69
+ <template>
70
+ <div v-if="product" :class="{
71
+ 'h-full w-full flex flex-col': true,
72
+ 'justify-start': alignment === 'flex-start',
73
+ 'justify-end': alignment === 'flex-end',
74
+ 'justify-center': alignment === 'center',
75
+ }">
76
+ <div class="self-stretch inline-flex flex-col justify-start items-start gap-8 mt-4 min-w-0">
77
+ <div
78
+ class="md:hidden self-stretch text-surface-on-surface text-4xl font-normal font-serif leading-[60px]">
79
+ {{ productName }}</div>
80
+
81
+ <div v-if="tierPrices.length <= 1">
82
+ <SwSharedPrice v-if="hasListPrice"
83
+ class="text-1xl text-secondary-900 basis-2/6 justify-start line-through"
84
+ :value="price?.listPrice?.price" />
85
+ <SwSharedPrice v-if="unitPrice"
86
+ class="text-surface-on-surface text-base font-bold leading-normal"
87
+ :class="{
88
+ 'text-red': hasListPrice,
89
+ }" :value="unitPrice" />
90
+ <div v-if="regulationPrice" class="text-xs flex text-secondary-500">
91
+ {{ translations.product.previously }}
92
+ <SwSharedPrice class="ml-1" :value="regulationPrice" />
93
+ </div>
94
+ </div>
95
+ <div v-else>
96
+ <table class="border-collapse table-auto w-full text-sm mb-8">
97
+ <thead>
98
+ <tr>
99
+ <th
100
+ class="border-b dark:border-secondary-600 font-medium p-4 pl-8 pt-0 pb-3 text-secondary-600 dark:text-secondary-200 text-left">
101
+ {{ translations.product.amount }}
102
+ </th>
103
+
104
+ <th
105
+ class="border-b dark:border-secondary-600 font-medium p-4 pr-8 pt-0 pb-3 text-secondary-600 dark:text-secondary-200 text-left">
106
+ {{ translations.product.price.label }}
107
+ </th>
108
+ </tr>
109
+ </thead>
110
+ <tbody class="bg-white dark:bg-secondary-800">
111
+ <tr v-for="(tierPrice, index) in tierPrices" :key="tierPrice.label">
112
+ <td
113
+ class="border-b border-secondary-100 dark:border-secondary-700 p-4 pl-8 font-medium text-secondary-500 dark:text-secondary-400">
114
+ <span v-if="index < tierPrices.length - 1">{{
115
+ translations.product.to
116
+ }}</span><span v-else>{{ translations.product.from }}</span>
117
+ {{ tierPrice.quantity }}
118
+ </td>
119
+ <td
120
+ class="border-b border-secondary-100 dark:border-secondary-700 p-4 pr-8 font-medium text-current-500 dark:text-secondary-400">
121
+ {{ getFormattedPrice(tierPrice.unitPrice) }}
122
+ </td>
123
+ </tr>
124
+ </tbody>
125
+ </table>
126
+ </div>
127
+ <div v-if="purchaseUnit && unitName" class="mt-1">
128
+ <span class="font-light"> {{ translations.product.content }}: </span>
129
+ <span class="font-light"> {{ purchaseUnit }} {{ unitName }} </span>
130
+ <span v-if="referencePrice" class="font-light">
131
+ {{ currency?.symbol }} {{ referencePrice?.price }} / /
132
+ {{ referencePrice?.referenceUnit }} {{ referencePrice?.unitName }}
133
+ </span>
134
+ </div>
135
+ <span class="text-brand-primary">
136
+ <template v-if="taxState === 'gross'">
137
+ {{ translations.product.pricesIncl }}
138
+ </template>
139
+ <template v-else> {{ translations.product.pricesExcl }} </template>
140
+ </span>
141
+ <SwVariantConfigurator @change="changeVariant" />
142
+ <SwProductAddToCart :product="product" />
143
+ </div>
144
+ </div>
145
+ </template>
@@ -0,0 +1,53 @@
1
+ <script setup lang="ts">
2
+ import type { Ref } from "vue";
3
+ import { onMounted, ref } from "vue";
4
+ import { useCategory, useNavigation } from "#imports";
5
+ import type { Schemas } from "#shopware";
6
+
7
+ const { category: activeCategory } = useCategory();
8
+ const loading: Ref<boolean> = ref(true);
9
+ const flagAllowSubcategories: boolean = true; // could be passed maybe as a prop in the future
10
+ const categoryNavigation: Ref<Schemas["Category"][]> = ref([]);
11
+
12
+ const currentCategoryId = activeCategory.value?.id ?? "main-navigation";
13
+ const type = flagAllowSubcategories ? currentCategoryId : "main-navigation";
14
+ const { loadNavigationElements } = useNavigation({
15
+ type: type,
16
+ });
17
+ const removeChildrenIfNotActiveCategory = () => {
18
+ const navigation: Schemas["Category"][] = JSON.parse(
19
+ JSON.stringify(categoryNavigation.value),
20
+ );
21
+ return navigation?.map((navigationElement) => {
22
+ navigationElement.children =
23
+ activeCategory.value?.id === navigationElement.id
24
+ ? navigationElement.children
25
+ : [];
26
+ return navigationElement;
27
+ });
28
+ };
29
+
30
+ onMounted(async () => {
31
+ // depth 0 means, we load only first level of categories, depth 1 means we load first and second level of categories ...
32
+ const depth = flagAllowSubcategories ? 2 : 1;
33
+ categoryNavigation.value = await loadNavigationElements({ depth });
34
+ if (!flagAllowSubcategories) {
35
+ categoryNavigation.value = removeChildrenIfNotActiveCategory();
36
+ }
37
+ loading.value = false;
38
+ });
39
+ </script>
40
+ <template>
41
+ <ClientOnly>
42
+ <div v-if="!loading && categoryNavigation && categoryNavigation.length" class="self-stretch inline-flex flex-col justify-start items-start gap-3">
43
+ <SwCategoryNavigation
44
+ :level="0"
45
+ :elements="categoryNavigation"
46
+ :active-category="activeCategory"
47
+ />
48
+ </div>
49
+ <div v-else-if="loading" class="self-stretch flex flex-col justify-start items-start gap-4 animate-pulse">
50
+ <div v-for="i in 3" :key="i" class="w-full h-12 bg-surface-surface-container rounded"></div>
51
+ </div>
52
+ </ClientOnly>
53
+ </template>
@@ -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<{
@@ -13,7 +13,7 @@ const props = defineProps<{
13
13
 
14
14
  const { getConfigValue } = useCmsElementConfig(props.content);
15
15
  const currentTabIndex = ref<number>(0);
16
- const crossSellContainer = useTemplateRef("crossSellContainer");
16
+ const crossSellContainer = useTemplateRef<HTMLDivElement>("crossSellContainer");
17
17
  const config = computed<SliderElementConfig>(() => ({
18
18
  minHeight: {
19
19
  value: "300px",
@@ -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) => {
@@ -63,9 +78,9 @@ const toggleTab = (index: number) => {
63
78
  <a
64
79
  v-for="(collection, index) of crossSellCollections"
65
80
  :key="index"
66
- class="transition text-lg font-bold text-secondary-700 cursor-pointer"
81
+ class="transition text-lg font-semibold text-surface-on-surface-variant cursor-pointer"
67
82
  :class="{
68
- 'border-b-3 border-primary text-primary': currentTabIndex === index,
83
+ 'border-b-3 border-brand-primary text-brand-primary': currentTabIndex === index,
69
84
  }"
70
85
  @click="toggleTab(index)"
71
86
  >
@@ -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,11 +3,14 @@ import type {
3
3
  CmsElementImage,
4
4
  CmsElementManufacturerLogo,
5
5
  } from "@shopware/composables";
6
- import { buildUrlPrefix } 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";
10
- import { ClientOnly } from "../../../../helpers/clientOnly";
12
+ import { computed, defineAsyncComponent, inject, useTemplateRef } from "vue";
13
+ import { useAppConfig, useCmsElementImage, useUrlResolver } from "#imports";
11
14
  import { isSpatial } from "../../../../helpers/media/isSpatial";
12
15
 
13
16
  const props = defineProps<{
@@ -26,21 +29,36 @@ const {
26
29
  mimeType,
27
30
  } = useCmsElementImage(props.content);
28
31
 
29
- const DEFAULT_THUMBNAIL_SIZE = 10;
30
- const imageElement = useTemplateRef("imageElement");
32
+ const imageSizes = inject<string>("cms-image-sizes", "100vw");
33
+ const appConfig = useAppConfig();
34
+
35
+ const imageElement = useTemplateRef<HTMLImageElement>("imageElement");
31
36
  const { width, height } = useElementSize(imageElement);
32
37
 
33
- function roundUp(num: number) {
34
- return num ? Math.ceil(num / 100) * 100 : DEFAULT_THUMBNAIL_SIZE;
35
- }
38
+ const cdnOptions = computed(() => ({
39
+ format: appConfig.backgroundImage?.format,
40
+ quality: appConfig.backgroundImage?.quality,
41
+ }));
42
+
43
+ const srcSet = computed(
44
+ () =>
45
+ imageAttrs.value.srcset ||
46
+ generateCdnSrcSet(imageAttrs.value.src, undefined, cdnOptions.value),
47
+ );
36
48
 
37
49
  const srcPath = computed(() => {
38
- const biggestParam =
39
- width.value > height.value
40
- ? `width=${roundUp(width.value)}`
41
- : `height=${roundUp(height.value)}`;
42
- return `${imageAttrs.value.src}?${biggestParam}&fit=crop,smart`;
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
+ );
58
+ }
59
+ return imageAttrs.value.src || "";
43
60
  });
61
+
44
62
  const imageComputedContainerAttrs = computed(() => {
45
63
  const imageAttrsCopy = Object.assign({}, imageContainerAttrs.value);
46
64
  if (imageAttrsCopy?.href) {
@@ -64,7 +82,7 @@ const SwMedia3D = computed(() => {
64
82
  <component
65
83
  :is="imageLink.url ? 'a' : 'div'"
66
84
  v-if="imageAttrs.src"
67
- class="cms-element-image relative h-full w-full"
85
+ class="cms-element-image self-stretch relative"
68
86
  :class="{
69
87
  'flex justify-center items-center': imageGallery,
70
88
  }"
@@ -75,9 +93,10 @@ const SwMedia3D = computed(() => {
75
93
  v-if="isVideoElement"
76
94
  controls
77
95
  :class="{
78
- 'h-full w-full': true,
96
+ 'w-full h-full': true,
79
97
  'absolute inset-0': ['cover', 'stretch'].includes(displayMode),
80
98
  'object-cover': displayMode === 'cover',
99
+ 'object-contain': displayMode !== 'cover',
81
100
  }"
82
101
  >
83
102
  <source :src="imageAttrs.src" :type="mimeType" />
@@ -86,20 +105,38 @@ const SwMedia3D = computed(() => {
86
105
  <ClientOnly v-else-if="isSpatial(props.content.data.media)">
87
106
  <component :is="SwMedia3D" :src="props.content.data.media.url" />
88
107
  </ClientOnly>
89
- <img
108
+ <NuxtImg
90
109
  v-else
91
110
  ref="imageElement"
111
+ preset="productDetail"
92
112
  loading="lazy"
113
+ :sizes="imageSizes"
93
114
  :class="{
94
- 'w-full h-full': !imageGallery,
115
+ 'w-full': !imageGallery,
116
+ 'h-full': !imageGallery && ['cover', 'stretch'].includes(displayMode),
95
117
  'w-4/5': imageGallery,
96
- 'absolute inset-0': ['cover', 'stretch'].includes(displayMode),
118
+ 'absolute left-0 top-0': ['cover', 'stretch'].includes(displayMode),
97
119
  'object-cover': displayMode === 'cover',
98
- 'object-contain': imageGallery,
120
+ 'object-contain': imageGallery || displayMode !== 'cover',
99
121
  }"
100
122
  :alt="imageAttrs.alt"
101
123
  :src="srcPath"
102
- :srcset="imageAttrs.srcset"
124
+ :srcset="srcSet"
103
125
  />
104
126
  </component>
105
127
  </template>
128
+
129
+ <style scoped>
130
+ .cms-element-image {
131
+ display: block;
132
+ width: 100%;
133
+ height: 100%;
134
+ }
135
+
136
+ /* Ensure anchor tags within the element fill the container */
137
+ .cms-element-image a {
138
+ display: block;
139
+ width: 100%;
140
+ height: 100%;
141
+ }
142
+ </style>