@shopware/cms-base-layer 2.0.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +167 -125
- package/app/app.config.ts +12 -0
- package/app/components/SwCategoryNavigation.vue +25 -18
- package/app/components/SwFilterDropdown.vue +54 -0
- package/app/components/SwListingProductPrice.vue +2 -2
- package/app/components/SwMedia3D.vue +14 -5
- package/app/components/SwProductCard.vue +24 -21
- package/app/components/SwProductCardDetails.vue +29 -12
- package/app/components/SwProductCardImage.vue +30 -29
- package/app/components/SwProductGallery.vue +18 -14
- package/app/components/SwProductListingFilter.vue +20 -9
- package/app/components/SwProductListingFilters.vue +3 -7
- package/app/components/SwProductListingFiltersHorizontal.vue +306 -0
- package/app/components/SwProductPrice.vue +3 -3
- package/app/components/SwProductRating.vue +40 -0
- package/app/components/SwProductReviews.vue +6 -19
- package/app/components/SwProductUnits.vue +10 -15
- package/app/components/SwQuantitySelect.vue +4 -7
- package/app/components/SwSlider.vue +150 -51
- package/app/components/SwSortDropdown.vue +10 -6
- package/app/components/SwVariantConfigurator.vue +13 -13
- package/app/components/listing-filters/SwFilterPrice.vue +45 -40
- package/app/components/listing-filters/SwFilterProperties.vue +40 -33
- package/app/components/listing-filters/SwFilterRating.vue +36 -27
- package/app/components/listing-filters/SwFilterShippingFree.vue +39 -32
- package/app/components/public/cms/CmsBlockSpatialViewer.vue +94 -0
- package/app/components/public/cms/CmsGenericBlock.md +17 -2
- package/app/components/public/cms/CmsGenericBlock.vue +21 -2
- package/app/components/public/cms/CmsGenericElement.vue +7 -2
- package/app/components/public/cms/CmsNoComponent.vue +87 -8
- package/app/components/public/cms/CmsPage.md +19 -2
- package/app/components/public/cms/CmsPage.vue +7 -0
- package/app/components/public/cms/FrontendAccountCustomerGroupRegistrationPage.vue +52 -0
- package/app/components/public/cms/block/CmsBlockCenterText.vue +1 -1
- package/app/components/public/cms/block/CmsBlockImageText.vue +5 -5
- package/app/components/public/cms/block/CmsBlockTextOnImage.vue +5 -12
- package/app/components/public/cms/element/CmsElementBuyBox.vue +3 -3
- package/app/components/public/cms/element/CmsElementCrossSelling.vue +19 -3
- package/app/components/public/cms/element/CmsElementImage.vue +12 -35
- package/app/components/public/cms/element/CmsElementImageGallery.vue +117 -50
- package/app/components/public/cms/element/CmsElementProductBox.vue +7 -1
- package/app/components/public/cms/element/CmsElementProductListing.vue +15 -4
- package/app/components/public/cms/element/CmsElementProductName.vue +6 -1
- package/app/components/public/cms/element/CmsElementProductSlider.vue +56 -35
- package/app/components/public/cms/element/CmsElementSidebarFilter.vue +10 -2
- package/app/components/public/cms/element/CmsElementText.vue +10 -11
- package/app/components/public/cms/element/SwProductListingPagination.vue +2 -2
- package/app/components/public/cms/section/CmsSectionDefault.vue +2 -2
- package/app/components/public/cms/section/CmsSectionSidebar.vue +6 -3
- package/app/components/ui/BaseButton.vue +18 -15
- package/app/components/ui/ChevronIcon.vue +10 -13
- package/app/components/ui/WishlistIcon.vue +3 -8
- package/app/composables/useImagePlaceholder.ts +3 -3
- package/app/composables/useLcpImagePreload.test.ts +229 -0
- package/app/composables/useLcpImagePreload.ts +43 -0
- package/app/composables/useTypedAppConfig.ts +15 -0
- package/app/helpers/cms/findFirstCmsImageUrl.ts +86 -0
- package/app/helpers/cms/getImageSizes.test.ts +50 -0
- package/app/helpers/cms/getImageSizes.ts +36 -0
- package/app/helpers/html-to-vue/ast.ts +53 -19
- package/app/helpers/html-to-vue/getOptionsFromNode.ts +1 -1
- package/app/helpers/html-to-vue/renderToHtml.ts +7 -11
- package/app/helpers/html-to-vue/renderer.ts +86 -26
- package/index.d.ts +37 -5
- package/nuxt.config.ts +25 -0
- package/package.json +21 -21
- package/uno.config.ts +0 -83
|
@@ -3,8 +3,9 @@ import type {
|
|
|
3
3
|
CmsElementProductSlider,
|
|
4
4
|
SliderElementConfig,
|
|
5
5
|
} from "@shopware/composables";
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
6
|
+
import { useElementSize } from "@vueuse/core";
|
|
7
|
+
import { computed, inject, useTemplateRef } from "vue";
|
|
8
|
+
import type { CSSProperties, ComputedRef } from "vue";
|
|
8
9
|
import { useCmsElementConfig } from "#imports";
|
|
9
10
|
|
|
10
11
|
const props = defineProps<{
|
|
@@ -13,7 +14,16 @@ const props = defineProps<{
|
|
|
13
14
|
const { getConfigValue } = useCmsElementConfig(props.content);
|
|
14
15
|
|
|
15
16
|
const productSlider = useTemplateRef<HTMLDivElement>("productSlider");
|
|
16
|
-
const
|
|
17
|
+
const slotCount = inject<number>("cms-block-slot-count", 1);
|
|
18
|
+
const elMinWidth = computed(
|
|
19
|
+
() => +getConfigValue("elMinWidth").replace(/\D+/g, "") || 300,
|
|
20
|
+
);
|
|
21
|
+
const { width } = useElementSize(productSlider);
|
|
22
|
+
const slidesToShow = computed(() => {
|
|
23
|
+
// SSR: useElementSize returns 0, fallback to 1200px estimate divided by slot count
|
|
24
|
+
const containerWidth = width.value || 1200 / slotCount;
|
|
25
|
+
return Math.max(1, Math.floor(containerWidth / elMinWidth.value));
|
|
26
|
+
});
|
|
17
27
|
const products = computed(() => props.content?.data?.products ?? []);
|
|
18
28
|
const config: ComputedRef<SliderElementConfig> = computed(() => ({
|
|
19
29
|
minHeight: {
|
|
@@ -29,52 +39,63 @@ const config: ComputedRef<SliderElementConfig> = computed(() => ({
|
|
|
29
39
|
source: "static",
|
|
30
40
|
},
|
|
31
41
|
navigationDots: {
|
|
32
|
-
value: "",
|
|
42
|
+
value: getConfigValue("navigation") === true ? "outside" : "",
|
|
33
43
|
source: "static",
|
|
34
44
|
},
|
|
35
45
|
navigationArrows: {
|
|
36
|
-
value: getConfigValue("navigation") ? "outside" : "",
|
|
46
|
+
value: getConfigValue("navigation") === true ? "outside" : "",
|
|
37
47
|
source: "static",
|
|
38
48
|
},
|
|
39
49
|
}));
|
|
40
50
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}, 100);
|
|
51
|
+
// Responsive SSR breakpoints: scale by slotCount since container is ~1/slotCount of viewport
|
|
52
|
+
const ssrBreakpoints = computed(() => {
|
|
53
|
+
const max = slidesToShow.value;
|
|
54
|
+
const bp: Record<string, number> = {};
|
|
55
|
+
for (let n = 2; n <= max; n++) {
|
|
56
|
+
bp[`(min-width: ${elMinWidth.value * n * slotCount}px)`] = n;
|
|
57
|
+
}
|
|
58
|
+
return bp;
|
|
50
59
|
});
|
|
51
60
|
|
|
52
61
|
const autoplay = computed(() => getConfigValue("rotate"));
|
|
53
62
|
const title = computed(() => getConfigValue("title"));
|
|
54
63
|
const border = computed(() => getConfigValue("border"));
|
|
64
|
+
|
|
65
|
+
const verticalAlignStyle = computed<CSSProperties>(() => ({
|
|
66
|
+
alignContent: getConfigValue("verticalAlign"),
|
|
67
|
+
}));
|
|
68
|
+
const hasVerticalAlignment = computed(
|
|
69
|
+
() => !!verticalAlignStyle.value.alignContent,
|
|
70
|
+
);
|
|
55
71
|
</script>
|
|
56
72
|
<template>
|
|
57
|
-
<div
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
:
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
73
|
+
<div
|
|
74
|
+
:style="hasVerticalAlignment ? verticalAlignStyle : undefined"
|
|
75
|
+
>
|
|
76
|
+
<div ref="productSlider" class="cms-element-product-slider">
|
|
77
|
+
<h3 v-if="title" class="pl-6 pb-6 text-center md:text-left text-surface-on-surface">
|
|
78
|
+
{{ title }}
|
|
79
|
+
</h3>
|
|
80
|
+
<div :class="{ 'py-5 border border-outline-outline-variant': border }">
|
|
81
|
+
<SwSlider
|
|
82
|
+
:config="config"
|
|
83
|
+
gap="1.25rem"
|
|
84
|
+
:slides-to-show="slidesToShow"
|
|
85
|
+
:slides-to-scroll="1"
|
|
86
|
+
:autoplay="autoplay"
|
|
87
|
+
:ssr-breakpoints="ssrBreakpoints"
|
|
88
|
+
>
|
|
89
|
+
<SwProductCard
|
|
90
|
+
v-for="product of products"
|
|
91
|
+
:key="product.id"
|
|
92
|
+
class="h-full"
|
|
93
|
+
:product="product"
|
|
94
|
+
:layout-type="getConfigValue('boxLayout')"
|
|
95
|
+
:display-mode="getConfigValue('displayMode')"
|
|
96
|
+
/>
|
|
97
|
+
</SwSlider>
|
|
98
|
+
</div>
|
|
78
99
|
</div>
|
|
79
100
|
</div>
|
|
80
101
|
</template>
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { CmsElementSidebarFilter } from "@shopware/composables";
|
|
3
|
+
import { inject } from "vue";
|
|
3
4
|
|
|
4
5
|
defineProps<{
|
|
5
6
|
content: CmsElementSidebarFilter;
|
|
6
7
|
}>();
|
|
8
|
+
|
|
9
|
+
// Inject layout context from parent section
|
|
10
|
+
// If in sidebar section -> use vertical accordion filters
|
|
11
|
+
// Otherwise -> use horizontal dropdown filters
|
|
12
|
+
const sectionLayout = inject<string>("cms-section-layout", "default");
|
|
13
|
+
const isInSidebar = sectionLayout === "sidebar";
|
|
7
14
|
</script>
|
|
8
15
|
<template>
|
|
9
|
-
<div
|
|
10
|
-
<SwProductListingFilters :content="content" />
|
|
16
|
+
<div>
|
|
17
|
+
<SwProductListingFilters v-if="isInSidebar" :content="content" />
|
|
18
|
+
<SwProductListingFiltersHorizontal v-else :content="content" />
|
|
11
19
|
</div>
|
|
12
20
|
</template>
|
|
@@ -20,10 +20,10 @@ const mappedContent = computed<string>(() => {
|
|
|
20
20
|
});
|
|
21
21
|
|
|
22
22
|
const style = computed<CSSProperties>(() => ({
|
|
23
|
-
|
|
23
|
+
alignContent: getConfigValue("verticalAlign"),
|
|
24
24
|
}));
|
|
25
25
|
|
|
26
|
-
const hasVerticalAlignment = computed(() => !!style.value.
|
|
26
|
+
const hasVerticalAlignment = computed(() => !!style.value.alignContent);
|
|
27
27
|
|
|
28
28
|
const CmsTextRender = defineComponent({
|
|
29
29
|
setup() {
|
|
@@ -37,7 +37,7 @@ const CmsTextRender = defineComponent({
|
|
|
37
37
|
return (
|
|
38
38
|
node.type === "tag" &&
|
|
39
39
|
node.name === "a" &&
|
|
40
|
-
!node.attrs?.class?.
|
|
40
|
+
!node.attrs?.class?.includes("btn")
|
|
41
41
|
);
|
|
42
42
|
},
|
|
43
43
|
renderer(
|
|
@@ -61,7 +61,7 @@ const CmsTextRender = defineComponent({
|
|
|
61
61
|
return (
|
|
62
62
|
node.type === "tag" &&
|
|
63
63
|
node.name === "a" &&
|
|
64
|
-
node.attrs?.class?.
|
|
64
|
+
!!node.attrs?.class?.includes("btn")
|
|
65
65
|
);
|
|
66
66
|
},
|
|
67
67
|
renderer(
|
|
@@ -75,6 +75,7 @@ const CmsTextRender = defineComponent({
|
|
|
75
75
|
"rounded-md inline-block my-2 py-2 px-4 border border-transparent text-sm font-medium focus:outline-none disabled:opacity-75";
|
|
76
76
|
|
|
77
77
|
_class = node.attrs.class
|
|
78
|
+
.replace(/\bbtn\s+/, "")
|
|
78
79
|
.replace(
|
|
79
80
|
"btn-secondary",
|
|
80
81
|
`${btnClass} bg-brand-secondary text-brand-on-secondary hover:bg-brand-secondary-hover`,
|
|
@@ -82,7 +83,8 @@ const CmsTextRender = defineComponent({
|
|
|
82
83
|
.replace(
|
|
83
84
|
"btn-primary",
|
|
84
85
|
`${btnClass} bg-brand-primary text-brand-on-primary hover:bg-brand-primary-hover`,
|
|
85
|
-
)
|
|
86
|
+
)
|
|
87
|
+
.trim();
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
return createElement(
|
|
@@ -146,18 +148,15 @@ const CmsTextRender = defineComponent({
|
|
|
146
148
|
? mappedContent.value
|
|
147
149
|
: "<div class='cms-element-text missing-content-element'></div>";
|
|
148
150
|
|
|
149
|
-
return () =>
|
|
150
|
-
h("div", {}, renderHtml(rawHtml, config, h, context, resolveUrl));
|
|
151
|
+
return () => renderHtml(rawHtml, config, h, context, resolveUrl);
|
|
151
152
|
},
|
|
152
153
|
});
|
|
153
154
|
</script>
|
|
154
155
|
<template>
|
|
155
|
-
<div
|
|
156
|
-
:class="{ flex: hasVerticalAlignment, 'flex-row': hasVerticalAlignment }"
|
|
157
|
-
:style="style"
|
|
158
|
-
>
|
|
156
|
+
<div v-if="hasVerticalAlignment" class="grid h-full" :style="style">
|
|
159
157
|
<CmsTextRender />
|
|
160
158
|
</div>
|
|
159
|
+
<CmsTextRender v-else />
|
|
161
160
|
</template>
|
|
162
161
|
<style scoped>
|
|
163
162
|
/** Global CSS styles for text elements */
|
|
@@ -7,14 +7,14 @@ const props = defineProps<{
|
|
|
7
7
|
}>();
|
|
8
8
|
|
|
9
9
|
const { cssClasses, layoutStyles } = getCmsLayoutConfiguration(props.content);
|
|
10
|
+
const { sizingMode: _, ...sectionStyles } = layoutStyles;
|
|
10
11
|
</script>
|
|
11
12
|
|
|
12
13
|
<template>
|
|
13
|
-
<div class="my-4" :class="cssClasses" :style="
|
|
14
|
+
<div class="my-4" :class="cssClasses" :style="sectionStyles as any">
|
|
14
15
|
<CmsGenericBlock
|
|
15
16
|
v-for="cmsBlock in content.blocks"
|
|
16
17
|
:key="cmsBlock.id"
|
|
17
|
-
class="overflow-auto"
|
|
18
18
|
:content="cmsBlock"
|
|
19
19
|
/>
|
|
20
20
|
</div>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { useCmsSection } from "@shopware/composables";
|
|
3
3
|
import type { CmsSectionSidebar } from "@shopware/composables";
|
|
4
|
-
import { computed } from "vue";
|
|
4
|
+
import { computed, provide } from "vue";
|
|
5
5
|
|
|
6
6
|
const props = defineProps<{
|
|
7
7
|
content: CmsSectionSidebar;
|
|
@@ -12,6 +12,9 @@ const sidebarBlocks = getPositionContent("sidebar");
|
|
|
12
12
|
const mainBlocks = getPositionContent("main");
|
|
13
13
|
const mobileBehavior = computed(() => props.content.mobileBehavior);
|
|
14
14
|
const fullWidth = computed(() => section.sizingMode === "full_width");
|
|
15
|
+
|
|
16
|
+
// Provide layout context for child components
|
|
17
|
+
provide("cms-section-layout", "sidebar");
|
|
15
18
|
</script>
|
|
16
19
|
|
|
17
20
|
<template>
|
|
@@ -27,10 +30,10 @@ const fullWidth = computed(() => section.sizingMode === "full_width");
|
|
|
27
30
|
<CmsGenericBlock :content="cmsBlock" />
|
|
28
31
|
</div>
|
|
29
32
|
</aside>
|
|
30
|
-
<
|
|
33
|
+
<div class="flex-1 flex flex-col justify-start items-stretch gap-20">
|
|
31
34
|
<div v-for="cmsBlock in mainBlocks" :key="cmsBlock.id" class="w-full">
|
|
32
35
|
<CmsGenericBlock :content="cmsBlock" />
|
|
33
36
|
</div>
|
|
34
|
-
</
|
|
37
|
+
</div>
|
|
35
38
|
</div>
|
|
36
39
|
</template>
|
|
@@ -20,14 +20,14 @@ defineOptions({
|
|
|
20
20
|
inheritAttrs: false,
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
const
|
|
24
|
-
variant
|
|
25
|
-
size
|
|
26
|
-
disabled
|
|
27
|
-
loading
|
|
28
|
-
type
|
|
29
|
-
block
|
|
30
|
-
});
|
|
23
|
+
const {
|
|
24
|
+
variant = "primary",
|
|
25
|
+
size = "medium",
|
|
26
|
+
disabled = false,
|
|
27
|
+
loading = false,
|
|
28
|
+
type = "button",
|
|
29
|
+
block = false,
|
|
30
|
+
} = defineProps<SwBaseButtonProps>();
|
|
31
31
|
|
|
32
32
|
const emit = defineEmits<{
|
|
33
33
|
click: [event: MouseEvent];
|
|
@@ -43,7 +43,7 @@ const buttonClasses = computed(() => {
|
|
|
43
43
|
medium: "px-4 py-3 text-base",
|
|
44
44
|
large: "px-6 py-4 text-lg",
|
|
45
45
|
};
|
|
46
|
-
classes.push(sizeClasses[
|
|
46
|
+
classes.push(sizeClasses[size]);
|
|
47
47
|
|
|
48
48
|
const variantClasses = {
|
|
49
49
|
primary:
|
|
@@ -60,15 +60,15 @@ const buttonClasses = computed(() => {
|
|
|
60
60
|
"bg-transparent text-surface-on-surface-variant hover:text-surface-on-surface focus:ring-surface-on-surface",
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
-
if (
|
|
63
|
+
if (disabled || loading) {
|
|
64
64
|
classes.push(
|
|
65
65
|
"bg-surface-surface-disabled text-surface-on-surface cursor-not-allowed opacity-50",
|
|
66
66
|
);
|
|
67
67
|
} else {
|
|
68
|
-
classes.push(variantClasses[
|
|
68
|
+
classes.push(variantClasses[variant]);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
if (
|
|
71
|
+
if (block) {
|
|
72
72
|
classes.push("w-full");
|
|
73
73
|
}
|
|
74
74
|
|
|
@@ -76,7 +76,7 @@ const buttonClasses = computed(() => {
|
|
|
76
76
|
});
|
|
77
77
|
|
|
78
78
|
const handleClick = (event: MouseEvent) => {
|
|
79
|
-
if (!
|
|
79
|
+
if (!disabled && !loading) {
|
|
80
80
|
emit("click", event);
|
|
81
81
|
}
|
|
82
82
|
};
|
|
@@ -90,10 +90,13 @@ const handleClick = (event: MouseEvent) => {
|
|
|
90
90
|
@click="handleClick"
|
|
91
91
|
v-bind="$attrs"
|
|
92
92
|
>
|
|
93
|
-
<div
|
|
93
|
+
<div
|
|
94
|
+
v-if="loading"
|
|
95
|
+
class="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin"
|
|
96
|
+
></div>
|
|
94
97
|
|
|
95
98
|
<span :class="{ 'opacity-0': loading }">
|
|
96
99
|
<slot />
|
|
97
100
|
</span>
|
|
98
101
|
</button>
|
|
99
|
-
</template>
|
|
102
|
+
</template>
|
|
@@ -2,18 +2,15 @@
|
|
|
2
2
|
import ChevronSvg from "@cms-assets/icons/chevron.svg";
|
|
3
3
|
import { computed } from "vue";
|
|
4
4
|
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
alt: "",
|
|
15
|
-
},
|
|
16
|
-
);
|
|
5
|
+
const {
|
|
6
|
+
direction = "down",
|
|
7
|
+
size = 24,
|
|
8
|
+
alt = "",
|
|
9
|
+
} = defineProps<{
|
|
10
|
+
direction?: "up" | "down" | "left" | "right";
|
|
11
|
+
size?: number;
|
|
12
|
+
alt?: string;
|
|
13
|
+
}>();
|
|
17
14
|
|
|
18
15
|
const rotationClass = computed(() => {
|
|
19
16
|
const rotations = {
|
|
@@ -22,7 +19,7 @@ const rotationClass = computed(() => {
|
|
|
22
19
|
left: "rotate-90",
|
|
23
20
|
right: "-rotate-90",
|
|
24
21
|
};
|
|
25
|
-
return rotations[
|
|
22
|
+
return rotations[direction];
|
|
26
23
|
});
|
|
27
24
|
</script>
|
|
28
25
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useTypedAppConfig } from "./useTypedAppConfig";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Composable that provides an SVG placeholder image as a data URI
|
|
@@ -8,14 +8,14 @@ import { useAppConfig } from "nuxt/app";
|
|
|
8
8
|
* @returns Base64-encoded SVG data URI
|
|
9
9
|
*/
|
|
10
10
|
export function useImagePlaceholder(color?: string) {
|
|
11
|
-
const appConfig =
|
|
11
|
+
const appConfig = useTypedAppConfig();
|
|
12
12
|
const placeholderColor =
|
|
13
13
|
color || appConfig.imagePlaceholder?.color || "#543B95";
|
|
14
14
|
|
|
15
15
|
const placeholderSvg = `data:image/svg+xml;base64,${btoa(
|
|
16
16
|
`
|
|
17
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"
|
|
18
|
+
<rect width="96" height="96" fill="${placeholderColor}" opacity="0.08"/>
|
|
19
19
|
<g transform="translate(36, 36)">
|
|
20
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
21
|
</g>
|
|
@@ -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
|
+
});
|