@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,100 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from "vue";
3
+
4
+ const props = defineProps<{
5
+ name?: string;
6
+ ariaLabel?: string;
7
+ label?: string;
8
+ description?: string;
9
+ disabled?: boolean;
10
+ }>();
11
+
12
+ const modelValue = defineModel<boolean | null>();
13
+
14
+ const emits = defineEmits<{
15
+ change: [v: boolean | null];
16
+ }>();
17
+
18
+ const value = computed(() => !!modelValue.value);
19
+
20
+ const localChecked = ref<boolean>(value.value);
21
+ watch(value, (v) => {
22
+ localChecked.value = v;
23
+ });
24
+
25
+ const inputName = props.name ?? "switch-button";
26
+ const inputId = `switch-${inputName}`;
27
+ const inputRef = ref<HTMLInputElement | null>(null);
28
+
29
+ const activateByKeyboard = () => {
30
+ inputRef.value?.click();
31
+ };
32
+ const toggleState = (next?: boolean) => {
33
+ if (props.disabled) return;
34
+ const v = typeof next === "boolean" ? next : !localChecked.value;
35
+ localChecked.value = v;
36
+ modelValue.value = v;
37
+ emits("change", v);
38
+ };
39
+
40
+ const activateByClick = (ev?: Event) => {
41
+ ev?.stopPropagation();
42
+ toggleState();
43
+ };
44
+ </script>
45
+
46
+ <template>
47
+ <div class="w-full inline-flex flex-col justify-start items-start gap-2">
48
+ <div class="self-stretch inline-flex justify-start items-center gap-3">
49
+ <!-- left label that toggles the input via for="#inputId" -->
50
+ <label :for="inputId"
51
+ class="flex-1 flex justify-start items-center gap-1 text-surface-on-surface text-base font-normal leading-normal cursor-pointer"
52
+ :class="{ 'cursor-not-allowed': disabled }">
53
+ <span v-if="$slots.default">
54
+ <slot />
55
+ </span>
56
+ <span v-else-if="label">{{ label }}</span>
57
+ </label>
58
+
59
+ <div class="w-10 h-6 relative">
60
+ <label class="inline-block cursor-pointer" :class="{ 'cursor-not-allowed': disabled }">
61
+ <input ref="inputRef" :id="inputId" type="checkbox" :name="inputName" class="sr-only" :checked="localChecked"
62
+ @change="toggleState()"
63
+ :disabled="disabled" :aria-label="ariaLabel || undefined" v-bind="$attrs" />
64
+ <span role="switch" :aria-checked="localChecked" :tabindex="disabled ? -1 : 0"
65
+ class="w-10 h-6 relative rounded-full flex-shrink-0 inline-block switch-track cursor-pointer"
66
+ :class="localChecked ? 'bg-brand-secondary switch-track--on' : 'bg-surface-surface-container-highest'"
67
+ @keydown.space.prevent="activateByKeyboard" @click.prevent="activateByClick">
68
+ <span class="w-4 h-4 rounded-full absolute switch-knob"
69
+ :style="{ left: localChecked ? '19px' : '4px', top: '4px' }"
70
+ :class="localChecked ? 'bg-brand-on-secondary' : 'bg-surface-on-surface-variant'"></span>
71
+ </span>
72
+ </label>
73
+ </div>
74
+ </div>
75
+ <div v-if="description || $slots.description" class="self-stretch inline-flex justify-start items-center gap-2.5">
76
+ <div class="flex-1 justify-start text-surface-on-surface-variant text-sm font-normal leading-tight">
77
+ <slot name="description">{{ description }}</slot>
78
+ </div>
79
+ </div>
80
+ </div>
81
+ </template>
82
+
83
+ <style scoped>
84
+ .switch-track {
85
+ transition: background-color 180ms ease-in-out, box-shadow 180ms ease-in-out;
86
+ }
87
+
88
+ .switch-track--on {
89
+ box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.08);
90
+ /* subtle glow when on */
91
+ }
92
+
93
+ .switch-knob {
94
+ transition: left 180ms cubic-bezier(.2, .9, .2, 1), top 180ms cubic-bezier(.2, .9, .2, 1), background-color 120ms linear;
95
+ }
96
+
97
+ .switch-track:focus {
98
+ outline: none;
99
+ }
100
+ </style>
@@ -0,0 +1,11 @@
1
+ <script setup lang="ts">
2
+ import UserSvg from "@cms-assets/icons/user.svg";
3
+
4
+ const { size = 24 } = defineProps<{
5
+ size?: number;
6
+ }>();
7
+ </script>
8
+
9
+ <template>
10
+ <SwBaseIcon :src="UserSvg" :size="size" alt="User" />
11
+ </template>
@@ -0,0 +1,15 @@
1
+ <script setup lang="ts">
2
+ const { filled = false } = defineProps<{
3
+ filled?: boolean;
4
+ }>();
5
+ </script>
6
+ <template>
7
+ <div class="relative">
8
+ <!-- use when filled icon is ready -->
9
+ <!-- <Icon size="1rem" name="shopware:heart" v-if="type !== 'filled'" class="w-6 h-5 block hover:cursor-pointer" :class="[
10
+ styles[type]
11
+ ]" /> -->
12
+ <div class="i-carbon-favorite w-6 h-5 hover:cursor-pointer" v-if="!filled"></div>
13
+ <div class="i-carbon-favorite-filled w-6 h-5 hover:cursor-pointer" v-else></div>
14
+ </div>
15
+ </template>
@@ -0,0 +1,27 @@
1
+ import { useAppConfig } from "nuxt/app";
2
+
3
+ /**
4
+ * Composable that provides an SVG placeholder image as a data URI
5
+ * Note: CSS variables and currentColor don't work in data URIs since SVGs are rendered in isolation
6
+ *
7
+ * @param color - Hex color for the placeholder (optional - defaults to appConfig.imagePlaceholder.color or #543B95)
8
+ * @returns Base64-encoded SVG data URI
9
+ */
10
+ export function useImagePlaceholder(color?: string) {
11
+ const appConfig = useAppConfig();
12
+ const placeholderColor =
13
+ color || appConfig.imagePlaceholder?.color || "#543B95";
14
+
15
+ const placeholderSvg = `data:image/svg+xml;base64,${btoa(
16
+ `
17
+ <svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
18
+ <rect width="96" height="96" fill="${placeholderColor}" opacity="0.08"/>
19
+ <g transform="translate(36, 36)">
20
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M3 22H21C21.5523 22 22 21.5523 22 21V17L17.7071 12.7071C17.3166 12.3166 16.6834 12.3166 16.2929 12.7071L10.5 18.5C10.2239 18.7761 9.77614 18.7761 9.5 18.5C9.22386 18.2239 9.22386 17.7761 9.5 17.5L11 16L8.70711 13.7071C8.31658 13.3166 7.68342 13.3166 7.29289 13.7071L2 19V21C2 21.5523 2.44772 22 3 22ZM21 24H3C1.34315 24 0 22.6569 0 21V3C0 1.34315 1.34315 0 3 0H21C22.6569 0 24 1.34315 24 3V21C24 22.6569 22.6569 24 21 24ZM6.5 9C7.88071 9 9 7.88071 9 6.5C9 5.11929 7.88071 4 6.5 4C5.11929 4 4 5.11929 4 6.5C4 7.88071 5.11929 9 6.5 9Z" fill="${placeholderColor}" opacity="0.4"/>
21
+ </g>
22
+ </svg>
23
+ `.trim(),
24
+ )}`;
25
+
26
+ return placeholderSvg;
27
+ }
@@ -0,0 +1,229 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { findFirstCmsImageUrl } from "../helpers/cms/findFirstCmsImageUrl";
3
+
4
+ type Sections = Parameters<typeof findFirstCmsImageUrl>[0];
5
+
6
+ function makeSection(overrides: Record<string, unknown> = {}) {
7
+ return {
8
+ id: "s1",
9
+ position: 0,
10
+ type: "default",
11
+ sizingMode: "boxed",
12
+ mobileBehavior: "wrap",
13
+ visibility: {},
14
+ ...overrides,
15
+ } as Sections[number];
16
+ }
17
+
18
+ function makeBlock(overrides: Record<string, unknown> = {}) {
19
+ return {
20
+ id: "b1",
21
+ position: 0,
22
+ type: "image",
23
+ sectionPosition: "main",
24
+ marginTop: "0",
25
+ marginBottom: "0",
26
+ marginLeft: "0",
27
+ marginRight: "0",
28
+ visibility: {},
29
+ ...overrides,
30
+ };
31
+ }
32
+
33
+ describe("findFirstCmsImageUrl", () => {
34
+ it("should return undefined for empty sections", () => {
35
+ expect(findFirstCmsImageUrl([])).toBeUndefined();
36
+ });
37
+
38
+ it("should return undefined when no images exist", () => {
39
+ const sections = [
40
+ makeSection({ blocks: [makeBlock({ slots: [] })] }),
41
+ ] as Sections;
42
+ expect(findFirstCmsImageUrl(sections)).toBeUndefined();
43
+ });
44
+
45
+ it("should find a section background image", () => {
46
+ const sections = [
47
+ makeSection({
48
+ backgroundMedia: {
49
+ url: "https://cdn.example.com/section-bg.jpg",
50
+ metaData: { width: 1920, height: 1080 },
51
+ },
52
+ }),
53
+ ] as Sections;
54
+ const result = findFirstCmsImageUrl(sections);
55
+ expect(result).toContain("cdn.example.com");
56
+ expect(result).toContain("section-bg.jpg");
57
+ });
58
+
59
+ it("should find a block background image", () => {
60
+ const sections = [
61
+ makeSection({
62
+ blocks: [
63
+ makeBlock({
64
+ backgroundMedia: {
65
+ url: "https://cdn.example.com/block-bg.jpg",
66
+ metaData: { width: 800, height: 600 },
67
+ },
68
+ slots: [],
69
+ }),
70
+ ],
71
+ }),
72
+ ] as Sections;
73
+ const result = findFirstCmsImageUrl(sections);
74
+ expect(result).toContain("cdn.example.com");
75
+ expect(result).toContain("block-bg.jpg");
76
+ });
77
+
78
+ it("should find an image element media URL", () => {
79
+ const sections = [
80
+ makeSection({
81
+ blocks: [
82
+ makeBlock({
83
+ slots: [
84
+ {
85
+ id: "slot1",
86
+ type: "image",
87
+ slot: "content",
88
+ data: {
89
+ media: { url: "https://cdn.example.com/element.jpg" },
90
+ },
91
+ },
92
+ ],
93
+ }),
94
+ ],
95
+ }),
96
+ ] as Sections;
97
+ const result = findFirstCmsImageUrl(sections);
98
+ expect(result).toBe("https://cdn.example.com/element.jpg");
99
+ });
100
+
101
+ it("should apply format option to element media URL", () => {
102
+ const sections = [
103
+ makeSection({
104
+ blocks: [
105
+ makeBlock({
106
+ slots: [
107
+ {
108
+ id: "slot1",
109
+ type: "image",
110
+ slot: "content",
111
+ data: {
112
+ media: { url: "https://cdn.example.com/element.jpg" },
113
+ },
114
+ },
115
+ ],
116
+ }),
117
+ ],
118
+ }),
119
+ ] as Sections;
120
+ const result = findFirstCmsImageUrl(sections, { format: "webp" });
121
+ expect(result).toBe("https://cdn.example.com/element.jpg?format=webp");
122
+ });
123
+
124
+ it("should apply format and quality options to element media URL", () => {
125
+ const sections = [
126
+ makeSection({
127
+ blocks: [
128
+ makeBlock({
129
+ slots: [
130
+ {
131
+ id: "slot1",
132
+ type: "image",
133
+ slot: "content",
134
+ data: {
135
+ media: { url: "https://cdn.example.com/element.jpg" },
136
+ },
137
+ },
138
+ ],
139
+ }),
140
+ ],
141
+ }),
142
+ ] as Sections;
143
+ const result = findFirstCmsImageUrl(sections, {
144
+ format: "webp",
145
+ quality: 85,
146
+ });
147
+ expect(result).toBe(
148
+ "https://cdn.example.com/element.jpg?format=webp&quality=85",
149
+ );
150
+ });
151
+
152
+ it("should prioritize section bg over block bg over element media", () => {
153
+ const sections = [
154
+ makeSection({
155
+ backgroundMedia: {
156
+ url: "https://cdn.example.com/section-bg.jpg",
157
+ metaData: { width: 1920, height: 1080 },
158
+ },
159
+ blocks: [
160
+ makeBlock({
161
+ backgroundMedia: {
162
+ url: "https://cdn.example.com/block-bg.jpg",
163
+ metaData: { width: 800, height: 600 },
164
+ },
165
+ slots: [
166
+ {
167
+ id: "slot1",
168
+ type: "image",
169
+ slot: "content",
170
+ data: {
171
+ media: { url: "https://cdn.example.com/element.jpg" },
172
+ },
173
+ },
174
+ ],
175
+ }),
176
+ ],
177
+ }),
178
+ ] as Sections;
179
+ const result = findFirstCmsImageUrl(sections);
180
+ expect(result).toContain("section-bg.jpg");
181
+ });
182
+
183
+ it("should skip sections without blocks and find next image", () => {
184
+ const sections = [
185
+ makeSection({}),
186
+ makeSection({
187
+ blocks: [
188
+ makeBlock({
189
+ slots: [
190
+ {
191
+ id: "slot1",
192
+ type: "image",
193
+ slot: "content",
194
+ data: {
195
+ media: { url: "https://cdn.example.com/found.jpg" },
196
+ },
197
+ },
198
+ ],
199
+ }),
200
+ ],
201
+ }),
202
+ ] as Sections;
203
+ const result = findFirstCmsImageUrl(sections);
204
+ expect(result).toBe("https://cdn.example.com/found.jpg");
205
+ });
206
+
207
+ it("should handle invalid element media URLs gracefully", () => {
208
+ const sections = [
209
+ makeSection({
210
+ blocks: [
211
+ makeBlock({
212
+ slots: [
213
+ {
214
+ id: "slot1",
215
+ type: "image",
216
+ slot: "content",
217
+ data: {
218
+ media: { url: "not a valid url" },
219
+ },
220
+ },
221
+ ],
222
+ }),
223
+ ],
224
+ }),
225
+ ] as Sections;
226
+ const result = findFirstCmsImageUrl(sections);
227
+ expect(result).toBe("not a valid url");
228
+ });
229
+ });
@@ -0,0 +1,39 @@
1
+ import { computed } from "vue";
2
+ import { useAppConfig, useHead } from "#imports";
3
+ import type { Schemas } from "#shopware";
4
+ import { findFirstCmsImageUrl } from "../helpers/cms/findFirstCmsImageUrl";
5
+
6
+ /**
7
+ * Preloads the first image found in CMS sections (background or element).
8
+ * This is typically the LCP (Largest Contentful Paint) candidate.
9
+ *
10
+ * Injects a `<link rel="preload" as="image">` in the `<head>` during SSR,
11
+ * allowing the browser to fetch the image before parsing CSS.
12
+ */
13
+ export function useLcpImagePreload(sections: Schemas["CmsSection"][]) {
14
+ const appConfig = useAppConfig();
15
+
16
+ const lcpImageHref = computed(() =>
17
+ findFirstCmsImageUrl(sections, {
18
+ format: appConfig.backgroundImage?.format,
19
+ quality: appConfig.backgroundImage?.quality,
20
+ }),
21
+ );
22
+
23
+ useHead(
24
+ computed(() =>
25
+ lcpImageHref.value
26
+ ? {
27
+ link: [
28
+ {
29
+ rel: "preload",
30
+ as: "image",
31
+ fetchpriority: "high",
32
+ href: lcpImageHref.value,
33
+ },
34
+ ],
35
+ }
36
+ : {},
37
+ ),
38
+ );
39
+ }
@@ -1,5 +1,10 @@
1
1
  import { defineComponent, onMounted, ref } from "vue";
2
2
 
3
+ /**
4
+ * @deprecated This component is deprecated and will be removed in the next major release.
5
+ * Use Nuxt's built-in `<ClientOnly>` component instead.
6
+ * @see https://nuxt.com/docs/api/components/client-only
7
+ */
3
8
  export const ClientOnly = defineComponent({
4
9
  setup(_, { slots }) {
5
10
  const init = ref(false);
@@ -0,0 +1,86 @@
1
+ import { getBackgroundImageUrl } from "@shopware/helpers";
2
+
3
+ interface MediaMeta {
4
+ width?: number;
5
+ height?: number;
6
+ }
7
+
8
+ interface BackgroundMediaHolder {
9
+ backgroundMedia?: {
10
+ url?: string;
11
+ metaData?: MediaMeta;
12
+ };
13
+ }
14
+
15
+ interface CmsSlot {
16
+ data?: { media?: { url?: string } } | unknown;
17
+ }
18
+
19
+ interface CmsBlock extends BackgroundMediaHolder {
20
+ slots?: CmsSlot[];
21
+ }
22
+
23
+ interface CmsSection extends BackgroundMediaHolder {
24
+ blocks?: CmsBlock[];
25
+ }
26
+
27
+ /**
28
+ * Finds the first visible image URL in CMS page sections by scanning:
29
+ * 1. Section background images
30
+ * 2. Block background images
31
+ * 3. Image element media (slot data)
32
+ *
33
+ * Returns the URL with optimized format/quality params applied,
34
+ * or undefined if no image is found.
35
+ */
36
+ export function findFirstCmsImageUrl(
37
+ sections: CmsSection[],
38
+ options?: { format?: string; quality?: number },
39
+ ): string | undefined {
40
+ for (const section of sections) {
41
+ // 1. Section background
42
+ if (section.backgroundMedia?.url) {
43
+ return getBackgroundImageUrl(
44
+ `url("${section.backgroundMedia.url}")`,
45
+ section,
46
+ options,
47
+ ).replace(/^url\("([^"]+)"\)$/, "$1");
48
+ }
49
+
50
+ if (!section.blocks) continue;
51
+
52
+ for (const block of section.blocks) {
53
+ // 2. Block background
54
+ if (block.backgroundMedia?.url) {
55
+ return getBackgroundImageUrl(
56
+ `url("${block.backgroundMedia.url}")`,
57
+ block,
58
+ options,
59
+ ).replace(/^url\("([^"]+)"\)$/, "$1");
60
+ }
61
+
62
+ if (!block.slots) continue;
63
+
64
+ for (const slot of block.slots) {
65
+ // 3. Image element media
66
+ const media = (slot.data as { media?: { url?: string } })?.media;
67
+ if (media?.url) {
68
+ try {
69
+ const url = new URL(media.url);
70
+ if (options?.format) {
71
+ url.searchParams.set("format", options.format);
72
+ }
73
+ if (typeof options?.quality === "number") {
74
+ url.searchParams.set("quality", String(options.quality));
75
+ }
76
+ return url.toString();
77
+ } catch {
78
+ return media.url;
79
+ }
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ return undefined;
86
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getImageSizes } from "./getImageSizes";
3
+
4
+ describe("getImageSizes", () => {
5
+ it("should return sizes for 1 slot", () => {
6
+ expect(getImageSizes(1)).toBe("(max-width: 768px) 100vw, 100vw");
7
+ });
8
+
9
+ it("should return sizes for 2 slots", () => {
10
+ expect(getImageSizes(2)).toBe("(max-width: 768px) 100vw, 50vw");
11
+ });
12
+
13
+ it("should return sizes for 3 slots", () => {
14
+ expect(getImageSizes(3)).toBe("(max-width: 768px) 100vw, 33vw");
15
+ });
16
+
17
+ it("should return default sizes for slot count > 3", () => {
18
+ expect(getImageSizes(4)).toBe("(max-width: 768px) 50vw, 25vw");
19
+ });
20
+
21
+ it("should return default sizes for slot count 0", () => {
22
+ expect(getImageSizes(0)).toBe("(max-width: 768px) 50vw, 25vw");
23
+ });
24
+
25
+ it("should use custom config overrides", () => {
26
+ const config = {
27
+ 1: "100vw",
28
+ 2: "50vw",
29
+ };
30
+ expect(getImageSizes(1, config)).toBe("100vw");
31
+ expect(getImageSizes(2, config)).toBe("50vw");
32
+ });
33
+
34
+ it("should fall back to custom default from config", () => {
35
+ const config = {
36
+ default: "80vw",
37
+ };
38
+ expect(getImageSizes(5, config)).toBe("80vw");
39
+ });
40
+
41
+ it("should fall back to 100vw when no default exists", () => {
42
+ const config = {
43
+ 1: "100vw",
44
+ };
45
+ // Override default to empty by spreading — slot 5 not found, default not in config
46
+ // but DEFAULT_IMAGE_SIZES has a default, so config must explicitly remove it
47
+ // Actually, the spread keeps DEFAULT_IMAGE_SIZES.default unless overridden
48
+ expect(getImageSizes(5, config)).toBe("(max-width: 768px) 50vw, 25vw");
49
+ });
50
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Default mapping of CMS block slot count to responsive `sizes` attribute values.
3
+ * Used by CmsGenericBlock to provide sizing hints to child image elements.
4
+ *
5
+ * Can be overridden via `app.config.ts`:
6
+ * ```ts
7
+ * export default defineAppConfig({
8
+ * imageSizes: {
9
+ * 1: "(max-width: 768px) 100vw, 1200px",
10
+ * 2: "(max-width: 768px) 100vw, 600px",
11
+ * },
12
+ * });
13
+ * ```
14
+ */
15
+ const DEFAULT_IMAGE_SIZES: Record<string, string> = {
16
+ 1: "(max-width: 768px) 100vw, 100vw",
17
+ 2: "(max-width: 768px) 100vw, 50vw",
18
+ 3: "(max-width: 768px) 100vw, 33vw",
19
+ default: "(max-width: 768px) 50vw, 25vw",
20
+ };
21
+
22
+ /**
23
+ * Returns the responsive `sizes` attribute value for an image
24
+ * based on the number of slots in its parent CMS block.
25
+ *
26
+ * @param slotCount - Number of slots in the block
27
+ * @param config - Optional override map from app.config.ts (imageSizes)
28
+ * @returns A valid HTML `sizes` attribute string
29
+ */
30
+ export function getImageSizes(
31
+ slotCount: number,
32
+ config?: Record<string, string>,
33
+ ): string {
34
+ const sizes = { ...DEFAULT_IMAGE_SIZES, ...config };
35
+ return sizes[slotCount] || sizes.default || "100vw";
36
+ }