@shopware/cms-base-layer 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +168 -100
  2. package/app/app.config.ts +11 -0
  3. package/app/components/SwCategoryNavigation.vue +25 -18
  4. package/app/components/SwFilterDropdown.vue +54 -0
  5. package/app/components/SwListingProductPrice.vue +2 -2
  6. package/app/components/SwMedia3D.vue +4 -2
  7. package/app/components/SwProductCard.vue +20 -21
  8. package/app/components/SwProductCardDetails.vue +29 -12
  9. package/app/components/SwProductCardImage.vue +4 -1
  10. package/app/components/SwProductGallery.vue +18 -14
  11. package/app/components/SwProductListingFilter.vue +20 -9
  12. package/app/components/SwProductListingFilters.vue +1 -5
  13. package/app/components/SwProductListingFiltersHorizontal.vue +306 -0
  14. package/app/components/SwProductPrice.vue +3 -3
  15. package/app/components/SwProductRating.vue +40 -0
  16. package/app/components/SwProductReviews.vue +6 -19
  17. package/app/components/SwProductUnits.vue +10 -15
  18. package/app/components/SwQuantitySelect.vue +4 -7
  19. package/app/components/SwSlider.vue +150 -51
  20. package/app/components/SwSortDropdown.vue +10 -6
  21. package/app/components/SwVariantConfigurator.vue +12 -11
  22. package/app/components/listing-filters/SwFilterPrice.vue +45 -40
  23. package/app/components/listing-filters/SwFilterProperties.vue +40 -33
  24. package/app/components/listing-filters/SwFilterRating.vue +36 -27
  25. package/app/components/listing-filters/SwFilterShippingFree.vue +39 -32
  26. package/app/components/public/cms/CmsBlockSpatialViewer.vue +94 -0
  27. package/app/components/public/cms/CmsGenericBlock.md +17 -2
  28. package/app/components/public/cms/CmsGenericBlock.vue +15 -1
  29. package/app/components/public/cms/CmsPage.md +19 -2
  30. package/app/components/public/cms/CmsPage.vue +11 -1
  31. package/app/components/public/cms/block/CmsBlockCenterText.vue +1 -1
  32. package/app/components/public/cms/block/CmsBlockImageText.vue +5 -5
  33. package/app/components/public/cms/block/CmsBlockTextOnImage.vue +5 -12
  34. package/app/components/public/cms/element/CmsElementBuyBox.vue +3 -3
  35. package/app/components/public/cms/element/CmsElementCrossSelling.vue +19 -3
  36. package/app/components/public/cms/element/CmsElementImage.vue +34 -36
  37. package/app/components/public/cms/element/CmsElementImageGallery.vue +117 -50
  38. package/app/components/public/cms/element/CmsElementProductBox.vue +7 -1
  39. package/app/components/public/cms/element/CmsElementProductListing.vue +10 -3
  40. package/app/components/public/cms/element/CmsElementProductName.vue +6 -1
  41. package/app/components/public/cms/element/CmsElementProductSlider.vue +56 -35
  42. package/app/components/public/cms/element/CmsElementSidebarFilter.vue +10 -2
  43. package/app/components/public/cms/element/CmsElementText.vue +10 -11
  44. package/app/components/public/cms/element/SwProductListingPagination.vue +2 -2
  45. package/app/components/public/cms/section/CmsSectionDefault.vue +2 -2
  46. package/app/components/public/cms/section/CmsSectionSidebar.vue +6 -3
  47. package/app/components/ui/BaseButton.vue +18 -15
  48. package/app/components/ui/ChevronIcon.vue +10 -13
  49. package/app/components/ui/WishlistIcon.vue +3 -8
  50. package/app/composables/useImagePlaceholder.ts +1 -1
  51. package/app/composables/useLcpImagePreload.test.ts +229 -0
  52. package/app/composables/useLcpImagePreload.ts +39 -0
  53. package/app/helpers/cms/findFirstCmsImageUrl.ts +86 -0
  54. package/app/helpers/cms/getImageSizes.test.ts +50 -0
  55. package/app/helpers/cms/getImageSizes.ts +36 -0
  56. package/app/helpers/html-to-vue/ast.ts +53 -19
  57. package/app/helpers/html-to-vue/getOptionsFromNode.ts +1 -1
  58. package/app/helpers/html-to-vue/renderToHtml.ts +7 -11
  59. package/app/helpers/html-to-vue/renderer.ts +86 -26
  60. package/app/plugins/unocss-runtime.client.ts +23 -0
  61. package/index.d.ts +24 -0
  62. package/nuxt.config.ts +20 -0
  63. package/package.json +23 -21
  64. package/uno.config.ts +11 -0
@@ -15,13 +15,18 @@ import { getTranslatedProperty } from "@shopware/helpers";
15
15
  import { computed, ref } from "vue";
16
16
  import type { Schemas } from "#shopware";
17
17
 
18
- const props = defineProps<{
18
+ const {
19
+ filter,
20
+ selectedFilters,
21
+ displayMode = "accordion",
22
+ } = defineProps<{
19
23
  filter: ListingFilter;
20
24
  selectedFilters: {
21
25
  manufacturer?: string[];
22
26
  properties?: string[];
23
27
  [key: string]: unknown;
24
28
  };
29
+ displayMode?: "accordion" | "dropdown";
25
30
  }>();
26
31
 
27
32
  const emits =
@@ -35,17 +40,17 @@ const toggle = () => {
35
40
  };
36
41
 
37
42
  const selectedIds = computed(() => {
38
- if (props.filter.code === "manufacturer") {
39
- return props.selectedFilters?.manufacturer || [];
43
+ if (filter.code === "manufacturer") {
44
+ return selectedFilters?.manufacturer || [];
40
45
  }
41
- return props.selectedFilters?.properties || [];
46
+ return selectedFilters?.properties || [];
42
47
  });
43
48
 
44
49
  const isChecked = (id: string) => selectedIds.value.includes(id);
45
50
 
46
51
  const selectValue = (id: string) => {
47
52
  const emitCode =
48
- props.filter.code === "manufacturer" ? "manufacturer" : "properties";
53
+ filter.code === "manufacturer" ? "manufacturer" : "properties";
49
54
  emits("select-value", {
50
55
  code: emitCode,
51
56
  value: id,
@@ -55,47 +60,49 @@ const selectValue = (id: string) => {
55
60
 
56
61
  <template>
57
62
  <div class="self-stretch flex flex-col justify-start items-start gap-4">
58
- <div class="self-stretch flex flex-col justify-center items-center">
59
- <div
60
- class="self-stretch py-3 border-b border-outline-outline-variant inline-flex justify-between items-center gap-1 cursor-pointer"
61
- @click="toggle"
62
- role="button"
63
- tabindex="0"
64
- :aria-expanded="isFilterVisible"
65
- :aria-controls="props.filter.code"
66
- :aria-label="props.filter.label"
67
- @keydown.enter="toggle"
68
- @keydown.space.prevent="toggle"
69
- >
70
- <div class="flex-1 flex items-center gap-2.5">
71
- <div class="flex-1 text-surface-on-surface text-base font-bold leading-normal text-left">
72
- {{ props.filter.label }}
63
+ <!-- Accordion header (only in accordion mode) -->
64
+ <template v-if="displayMode === 'accordion'">
65
+ <div class="self-stretch flex flex-col justify-center items-center">
66
+ <div
67
+ class="self-stretch py-3 border-b border-outline-outline-variant inline-flex justify-between items-center gap-1 cursor-pointer"
68
+ @click="toggle"
69
+ role="button"
70
+ tabindex="0"
71
+ :aria-expanded="isFilterVisible"
72
+ :aria-controls="filter.code"
73
+ :aria-label="filter.label"
74
+ @keydown.enter="toggle"
75
+ @keydown.space.prevent="toggle"
76
+ >
77
+ <div class="flex-1 flex items-center gap-2.5">
78
+ <div class="flex-1 text-surface-on-surface text-base font-bold leading-normal text-left">
79
+ {{ filter.label }}
80
+ </div>
73
81
  </div>
82
+ <span
83
+ class="flex items-center justify-center"
84
+ aria-hidden="true"
85
+ >
86
+ <SwChevronIcon :direction="isFilterVisible ? 'up' : 'down'" :size="24" />
87
+ </span>
74
88
  </div>
75
- <SwIconButton
76
- type="ghost"
77
- :aria-label="isFilterVisible ? 'Collapse filter' : 'Expand filter'"
78
- tabindex="-1"
79
- >
80
- <SwChevronIcon :direction="isFilterVisible ? 'up' : 'down'" :size="24" />
81
- </SwIconButton>
82
89
  </div>
83
- </div>
90
+ </template>
91
+
92
+ <!-- Filter content -->
84
93
  <transition name="filter-collapse">
85
- <div v-if="isFilterVisible" :id="props.filter.code" class="self-stretch flex flex-col justify-start items-start gap-4">
94
+ <div v-if="isFilterVisible || displayMode === 'dropdown'" :id="filter.code" class="self-stretch flex flex-col justify-start items-start gap-4">
86
95
  <fieldset class="self-stretch flex flex-col justify-start items-start gap-4">
87
- <legend class="sr-only">{{ props.filter.name }}</legend>
96
+ <legend class="sr-only">{{ filter.name }}</legend>
88
97
  <label
89
- v-for="option in props.filter.options || props.filter.entities"
98
+ v-for="option in filter.options || filter.entities"
90
99
  :key="`${option.id}-${isChecked(option.id)}`"
91
100
  class="self-stretch inline-flex justify-start items-start gap-2 cursor-pointer"
92
- @click="selectValue(option.id)"
93
101
  >
94
102
  <div class="w-4 self-stretch pt-[3px] flex justify-start items-start gap-2.5">
95
103
  <SwCheckbox
96
104
  :model-value="isChecked(option.id)"
97
105
  @update:model-value="() => selectValue(option.id)"
98
- @click.stop
99
106
  />
100
107
  </div>
101
108
  <div class="flex-1 inline-flex flex-col justify-start items-start gap-0.5">
@@ -16,14 +16,19 @@ const emits =
16
16
  (e: "select-value", value: { code: string; value: unknown }) => void
17
17
  >();
18
18
 
19
- const props = defineProps<{
19
+ const {
20
+ filter,
21
+ selectedFilters,
22
+ displayMode = "accordion",
23
+ } = defineProps<{
20
24
  filter: ListingFilter;
21
25
  selectedFilters: Schemas["ProductListingResult"]["currentFilters"];
26
+ displayMode?: "accordion" | "dropdown";
22
27
  }>();
23
28
  const isHoverActive = ref(false);
24
29
  const hoveredIndex = ref(0);
25
30
  const displayedScore = computed(() =>
26
- isHoverActive.value ? hoveredIndex.value : props.selectedFilters?.rating || 0,
31
+ isHoverActive.value ? hoveredIndex.value : selectedFilters?.rating || 0,
27
32
  );
28
33
 
29
34
  const hoverRating = (key: number) => {
@@ -32,10 +37,10 @@ const hoverRating = (key: number) => {
32
37
  };
33
38
  const onChangeRating = () => {
34
39
  const newValue =
35
- props.selectedFilters?.rating !== hoveredIndex.value
40
+ selectedFilters?.rating !== hoveredIndex.value
36
41
  ? hoveredIndex.value
37
42
  : undefined;
38
- emits("select-value", { code: props.filter?.code, value: newValue });
43
+ emits("select-value", { code: filter?.code, value: newValue });
39
44
  };
40
45
 
41
46
  const isFilterVisible = ref<boolean>(false);
@@ -46,33 +51,37 @@ const toggle = () => {
46
51
 
47
52
  <template>
48
53
  <div class="self-stretch flex flex-col justify-start items-start gap-4">
49
- <div class="self-stretch flex flex-col justify-center items-center">
50
- <div
51
- class="self-stretch py-3 border-b border-outline-outline-variant inline-flex justify-between items-center gap-1 cursor-pointer"
52
- @click="toggle"
53
- role="button"
54
- tabindex="0"
55
- :aria-expanded="isFilterVisible"
56
- :aria-controls="`filter-rating`"
57
- @keydown.enter="toggle"
58
- @keydown.space.prevent="toggle"
59
- >
60
- <div class="flex-1 flex items-center gap-2.5">
61
- <div class="flex-1 text-surface-on-surface text-base font-bold leading-normal text-left">
62
- {{ props.filter.label }}
54
+ <!-- Accordion header (only in accordion mode) -->
55
+ <template v-if="displayMode === 'accordion'">
56
+ <div class="self-stretch flex flex-col justify-center items-center">
57
+ <div
58
+ class="self-stretch py-3 border-b border-outline-outline-variant inline-flex justify-between items-center gap-1 cursor-pointer"
59
+ @click="toggle"
60
+ role="button"
61
+ tabindex="0"
62
+ :aria-expanded="isFilterVisible"
63
+ :aria-controls="`filter-rating`"
64
+ @keydown.enter="toggle"
65
+ @keydown.space.prevent="toggle"
66
+ >
67
+ <div class="flex-1 flex items-center gap-2.5">
68
+ <div class="flex-1 text-surface-on-surface text-base font-bold leading-normal text-left">
69
+ {{ filter.label }}
70
+ </div>
63
71
  </div>
72
+ <span
73
+ class="flex items-center justify-center"
74
+ aria-hidden="true"
75
+ >
76
+ <SwChevronIcon :direction="isFilterVisible ? 'up' : 'down'" :size="24" />
77
+ </span>
64
78
  </div>
65
- <SwIconButton
66
- type="ghost"
67
- :aria-label="isFilterVisible ? 'Collapse filter' : 'Expand filter'"
68
- tabindex="-1"
69
- >
70
- <SwChevronIcon :direction="isFilterVisible ? 'up' : 'down'" :size="24" />
71
- </SwIconButton>
72
79
  </div>
73
- </div>
80
+ </template>
81
+
82
+ <!-- Filter content -->
74
83
  <transition name="filter-collapse">
75
- <div v-if="isFilterVisible" class="self-stretch flex flex-col justify-start items-start gap-4">
84
+ <div v-if="isFilterVisible || displayMode === 'dropdown'" class="self-stretch flex flex-col justify-start items-start gap-4">
76
85
  <div class="flex flex-row items-center gap-2 mt-2">
77
86
  <div
78
87
  v-for="i in 5"
@@ -16,10 +16,16 @@ import { defu } from "defu";
16
16
  import { computed, ref } from "vue";
17
17
  import type { Schemas } from "#shopware";
18
18
 
19
- const props = defineProps<{
19
+ const {
20
+ filter,
21
+ selectedFilters,
22
+ description,
23
+ displayMode = "accordion",
24
+ } = defineProps<{
20
25
  filter: ListingFilter;
21
26
  selectedFilters: Schemas["ProductListingResult"]["currentFilters"];
22
27
  description?: string; // Optional description for i18n
28
+ displayMode?: "accordion" | "dropdown";
23
29
  }>();
24
30
 
25
31
  type Translations = {
@@ -38,9 +44,7 @@ const emits =
38
44
  defineEmits<
39
45
  (e: "select-value", value: { code: string; value: unknown }) => void
40
46
  >();
41
- const currentFilterData = computed(
42
- () => !!props.selectedFilters[props.filter?.code],
43
- );
47
+ const currentFilterData = computed(() => !!selectedFilters[filter?.code]);
44
48
 
45
49
  const isFilterVisible = ref<boolean>(false);
46
50
  const toggle = () => {
@@ -53,50 +57,53 @@ onClickOutside(dropdownElement, () => {
53
57
  });
54
58
 
55
59
  const handleRadioUpdate = (val: string | null | boolean | undefined) => {
56
- emits("select-value", { code: props.filter.code, value: !!val });
60
+ emits("select-value", { code: filter.code, value: !!val });
57
61
  };
58
62
  </script>
59
63
 
60
64
  <template>
61
65
  <div class="self-stretch flex flex-col justify-start items-start gap-4">
62
- <div class="self-stretch flex flex-col justify-center items-center">
63
- <div
64
- class="self-stretch py-3 border-b border-outline-outline-variant inline-flex justify-between items-center gap-1 cursor-pointer"
65
- @click="toggle"
66
- role="button"
67
- tabindex="0"
68
- :aria-expanded="isFilterVisible"
69
- :aria-controls="`filter-${props.filter.code}`"
70
- @keydown.enter="toggle"
71
- @keydown.space.prevent="toggle"
72
- >
73
- <div class="flex-1 flex items-center gap-2.5">
74
- <div class="flex-1 text-surface-on-surface text-base font-bold leading-normal text-left">
75
- {{ props.filter.label }}
66
+ <!-- Accordion header (only in accordion mode) -->
67
+ <template v-if="displayMode === 'accordion'">
68
+ <div class="self-stretch flex flex-col justify-center items-center">
69
+ <div
70
+ class="self-stretch py-3 border-b border-outline-outline-variant inline-flex justify-between items-center gap-1 cursor-pointer"
71
+ @click="toggle"
72
+ role="button"
73
+ tabindex="0"
74
+ :aria-expanded="isFilterVisible"
75
+ :aria-controls="`filter-${filter.code}`"
76
+ @keydown.enter="toggle"
77
+ @keydown.space.prevent="toggle"
78
+ >
79
+ <div class="flex-1 flex items-center gap-2.5">
80
+ <div class="flex-1 text-surface-on-surface text-base font-bold leading-normal text-left">
81
+ {{ filter.label }}
82
+ </div>
76
83
  </div>
84
+ <span
85
+ class="flex items-center justify-center"
86
+ aria-hidden="true"
87
+ >
88
+ <SwChevronIcon :direction="isFilterVisible ? 'up' : 'down'" :size="24" />
89
+ </span>
77
90
  </div>
78
- <SwIconButton
79
- type="ghost"
80
- :aria-label="isFilterVisible ? 'Collapse filter' : 'Expand filter'"
81
- tabindex="-1"
82
- >
83
- <SwChevronIcon :direction="isFilterVisible ? 'up' : 'down'" :size="24" />
84
- </SwIconButton>
85
91
  </div>
86
- </div>
92
+ </template>
87
93
 
94
+ <!-- Filter content -->
88
95
  <transition name="filter-collapse">
89
- <div v-if="isFilterVisible" class="self-stretch">
96
+ <div v-if="isFilterVisible || displayMode === 'dropdown'" class="self-stretch">
90
97
  <div class="pt-6 space-y-4">
91
98
  <div class="self-stretch inline-flex justify-start items-start gap-2 w-full">
92
99
  <div class="flex-1 pt-[3px]">
93
100
  <SwSwitchButton
94
101
  :model-value="currentFilterData"
95
102
  @update:model-value="handleRadioUpdate"
96
- :name="props.filter.code"
97
- :aria-label="props.filter.label"
98
- :label="props.filter.label"
99
- :description="props.description || translations.listing.freeShipping"
103
+ :name="filter.code"
104
+ :aria-label="filter.label"
105
+ :label="filter.label"
106
+ :description="description || translations.listing.freeShipping"
100
107
  />
101
108
  </div>
102
109
  </div>
@@ -0,0 +1,94 @@
1
+ <script lang="ts" setup>
2
+ import { computed, defineAsyncComponent } from "vue";
3
+ import { useCmsBlock } from "#imports";
4
+ import type { Schemas } from "#shopware";
5
+
6
+ const SwMedia3DAsync = defineAsyncComponent(
7
+ () => import("../../SwMedia3D.vue"),
8
+ );
9
+
10
+ const props = defineProps<{
11
+ content: Schemas["CmsBlock"];
12
+ }>();
13
+
14
+ const { getSlotContent } = useCmsBlock(props.content);
15
+ const slotContent = getSlotContent("default");
16
+
17
+ function getConfigValue(key: string): unknown {
18
+ if (!slotContent?.config) return null;
19
+ const configEntry =
20
+ slotContent.config[key as keyof typeof slotContent.config];
21
+ if (
22
+ configEntry &&
23
+ typeof configEntry === "object" &&
24
+ "value" in configEntry &&
25
+ configEntry !== null
26
+ ) {
27
+ return (configEntry as { value: unknown }).value;
28
+ }
29
+ return null;
30
+ }
31
+
32
+ const modelUrl = computed(() => {
33
+ if (slotContent?.data) {
34
+ const data = slotContent.data as unknown as Schemas["Media"];
35
+ if (data?.url && typeof data.url === "string") {
36
+ return data.url;
37
+ }
38
+ }
39
+
40
+ const configUrl = getConfigValue("url");
41
+ if (typeof configUrl === "string" && configUrl) {
42
+ return configUrl;
43
+ }
44
+ return null;
45
+ });
46
+
47
+ const maxHeight = computed(() => {
48
+ const height = getConfigValue("maxHeight");
49
+ return typeof height === "string" ? height : "600px";
50
+ });
51
+
52
+ const formFactor = computed(() => {
53
+ const factor = getConfigValue("formFactor");
54
+ return typeof factor === "string" ? factor : "square";
55
+ });
56
+
57
+ const aspectRatio = computed(() => {
58
+ switch (formFactor.value) {
59
+ case "square":
60
+ return 1;
61
+ case "landscape":
62
+ return 16 / 9;
63
+ case "portrait":
64
+ return 9 / 16;
65
+ default:
66
+ return 1;
67
+ }
68
+ });
69
+
70
+ const containerStyle = computed(() => ({
71
+ width: "100%",
72
+ height: "100%",
73
+ minHeight: "400px",
74
+ maxHeight: maxHeight.value,
75
+ aspectRatio: `${aspectRatio.value}`,
76
+ position: "relative" as const,
77
+ }));
78
+ </script>
79
+
80
+ <template>
81
+ <div class="cms-block-spatial-viewer" :style="containerStyle">
82
+ <client-only>
83
+ <div v-if="modelUrl" class="w-full h-full">
84
+ <SwMedia3DAsync :src="modelUrl" />
85
+ </div>
86
+
87
+ <template #fallback>
88
+ <div class="w-full h-full flex items-center justify-center bg-gray-100">
89
+ <span class="text-gray-500">3D Viewer</span>
90
+ </div>
91
+ </template>
92
+ </client-only>
93
+ </div>
94
+ </template>
@@ -1,6 +1,21 @@
1
- Renders a Block type structure
1
+ Renders a Block type structure.
2
2
 
3
- Example usage:
3
+ Resolves the correct CMS block component dynamically and applies layout configuration (CSS classes, background color, background image). When a block has a `backgroundMedia` set, the component automatically optimizes the background image URL using the `getBackgroundImageUrl` helper from `@shopware/helpers`, appending `format` and `quality` parameters from the `backgroundImage` app config.
4
+
5
+ ### Background Image Optimization
6
+
7
+ Background image settings are read from `app.config.ts`:
8
+
9
+ ```ts
10
+ export default defineAppConfig({
11
+ backgroundImage: {
12
+ format: "webp",
13
+ quality: 85,
14
+ },
15
+ });
16
+ ```
17
+
18
+ ### Example usage
4
19
 
5
20
  ```vue{14-19}
6
21
  <script setup lang="ts">
@@ -4,13 +4,21 @@ import {
4
4
  getBackgroundImageUrl,
5
5
  getCmsLayoutConfiguration,
6
6
  } from "@shopware/helpers";
7
- import { h } from "vue";
7
+ import { h, provide } from "vue";
8
+ import { useAppConfig } from "#imports";
8
9
  import type { Schemas } from "#shopware";
10
+ import { getImageSizes } from "../../../helpers/cms/getImageSizes";
9
11
 
10
12
  const props = defineProps<{
11
13
  content: Schemas["CmsBlock"];
12
14
  }>();
13
15
 
16
+ const appConfig = useAppConfig();
17
+
18
+ const slotCount = props.content.slots?.length || 1;
19
+ provide("cms-block-slot-count", slotCount);
20
+ provide("cms-image-sizes", getImageSizes(slotCount, appConfig.imageSizes));
21
+
14
22
  const DynamicRender = () => {
15
23
  const {
16
24
  resolvedComponent,
@@ -31,16 +39,22 @@ const DynamicRender = () => {
31
39
  layoutStyles.backgroundImage = getBackgroundImageUrl(
32
40
  layoutStyles.backgroundImage,
33
41
  props.content,
42
+ {
43
+ format: appConfig.backgroundImage?.format,
44
+ quality: appConfig.backgroundImage?.quality,
45
+ },
34
46
  );
35
47
  }
36
48
 
37
49
  const containerStyles = {
38
50
  backgroundColor: layoutStyles.backgroundColor,
39
51
  backgroundImage: layoutStyles.backgroundImage,
52
+ backgroundSize: layoutStyles.backgroundSize,
40
53
  };
41
54
 
42
55
  layoutStyles.backgroundColor = null;
43
56
  layoutStyles.backgroundImage = null;
57
+ layoutStyles.backgroundSize = null;
44
58
  return h(
45
59
  "div",
46
60
  {
@@ -1,6 +1,23 @@
1
- An entrypoint to render the whole CMS object
1
+ An entrypoint to render the whole CMS object.
2
2
 
3
- Example usage:
3
+ Resolves all CMS sections dynamically and applies their layout configuration. When a section has a `backgroundMedia` set, the component automatically optimizes the background image URL using the `getBackgroundImageUrl` helper from `@shopware/helpers`, appending `format` and `quality` parameters from the `backgroundImage` app config.
4
+
5
+ ### Background Image Optimization
6
+
7
+ Background image settings are read from `app.config.ts`:
8
+
9
+ ```ts
10
+ export default defineAppConfig({
11
+ backgroundImage: {
12
+ format: "webp", // output format
13
+ quality: 85, // image quality (0-100)
14
+ },
15
+ });
16
+ ```
17
+
18
+ See the [cms-base-layer README](../../../../../../README.md#%EF%B8%8F-background-image-optimization) for full details.
19
+
20
+ ### Example usage
4
21
 
5
22
  ```vue{29}
6
23
  <script setup lang="ts">
@@ -6,14 +6,20 @@ import {
6
6
  } from "@shopware/helpers";
7
7
  import { pascalCase } from "scule";
8
8
  import { computed, h, resolveComponent, watchEffect } from "vue";
9
- import { createCategoryListingContext, useNavigationContext } from "#imports";
9
+ import {
10
+ createCategoryListingContext,
11
+ useAppConfig,
12
+ useNavigationContext,
13
+ } from "#imports";
10
14
  import type { Schemas } from "#shopware";
15
+ import { useLcpImagePreload } from "../../../composables/useLcpImagePreload";
11
16
 
12
17
  const props = defineProps<{
13
18
  content: Schemas["CmsPage"];
14
19
  }>();
15
20
 
16
21
  const { routeName } = useNavigationContext();
22
+ const appConfig = useAppConfig();
17
23
 
18
24
  // Function to initialize or update listing context
19
25
  function updateListingContext(content: Schemas["CmsPage"]) {
@@ -36,6 +42,8 @@ const cmsSections = computed<Schemas["CmsSection"][]>(() => {
36
42
  return props.content?.sections || [];
37
43
  });
38
44
 
45
+ useLcpImagePreload(props.content?.sections || []);
46
+
39
47
  const DynamicRender = () => {
40
48
  const componentsMap = cmsSections.value.map((section) => {
41
49
  return {
@@ -56,6 +64,7 @@ const DynamicRender = () => {
56
64
  layoutStyles.backgroundImage = getBackgroundImageUrl(
57
65
  layoutStyles.backgroundImage,
58
66
  componentObject.section,
67
+ appConfig.backgroundImage,
59
68
  );
60
69
  }
61
70
 
@@ -64,6 +73,7 @@ const DynamicRender = () => {
64
73
  class: {
65
74
  ...cssClasses,
66
75
  "max-w-screen-2xl w-full mx-auto": layoutStyles?.sizingMode === "boxed",
76
+ "w-full": layoutStyles?.sizingMode === "full_width",
67
77
  },
68
78
  style: {
69
79
  backgroundColor: layoutStyles?.backgroundColor,
@@ -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 }}