@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.
- package/README.md +168 -100
- package/app/app.config.ts +11 -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 +4 -2
- package/app/components/SwProductCard.vue +20 -21
- package/app/components/SwProductCardDetails.vue +29 -12
- package/app/components/SwProductCardImage.vue +4 -1
- package/app/components/SwProductGallery.vue +18 -14
- package/app/components/SwProductListingFilter.vue +20 -9
- package/app/components/SwProductListingFilters.vue +1 -5
- 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 +12 -11
- 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 +15 -1
- package/app/components/public/cms/CmsPage.md +19 -2
- package/app/components/public/cms/CmsPage.vue +11 -1
- 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 +34 -36
- 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 +10 -3
- 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 +1 -1
- package/app/composables/useLcpImagePreload.test.ts +229 -0
- package/app/composables/useLcpImagePreload.ts +39 -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/app/plugins/unocss-runtime.client.ts +23 -0
- package/index.d.ts +24 -0
- package/nuxt.config.ts +20 -0
- package/package.json +23 -21
- package/uno.config.ts +11 -0
|
@@ -4,7 +4,7 @@ import type {
|
|
|
4
4
|
SliderElementConfig,
|
|
5
5
|
} from "@shopware/composables";
|
|
6
6
|
import { useElementSize } from "@vueuse/core";
|
|
7
|
-
import { computed, ref, useTemplateRef } from "vue";
|
|
7
|
+
import { computed, inject, ref, useTemplateRef } from "vue";
|
|
8
8
|
import { useCmsElementConfig } from "#imports";
|
|
9
9
|
|
|
10
10
|
const props = defineProps<{
|
|
@@ -46,9 +46,24 @@ const crossSellCollections = computed(() => {
|
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
const { width } = useElementSize(crossSellContainer);
|
|
49
|
+
const slotCount = inject<number>("cms-block-slot-count", 1);
|
|
50
|
+
const elMinWidth = computed(
|
|
51
|
+
() => +(config.value.minWidth?.value.replace(/\D+/g, "") || 300),
|
|
52
|
+
);
|
|
49
53
|
const slidesToShow = computed(() => {
|
|
50
|
-
|
|
51
|
-
|
|
54
|
+
// SSR: useElementSize returns 0, fallback to 1200px estimate divided by slot count
|
|
55
|
+
const containerWidth = width.value || 1200 / slotCount;
|
|
56
|
+
return Math.max(1, Math.floor(containerWidth / elMinWidth.value));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Responsive SSR breakpoints: scale by slotCount since container is ~1/slotCount of viewport
|
|
60
|
+
const ssrBreakpoints = computed(() => {
|
|
61
|
+
const max = slidesToShow.value;
|
|
62
|
+
const bp: Record<string, number> = {};
|
|
63
|
+
for (let n = 2; n <= max; n++) {
|
|
64
|
+
bp[`(min-width: ${elMinWidth.value * n * slotCount}px)`] = n;
|
|
65
|
+
}
|
|
66
|
+
return bp;
|
|
52
67
|
});
|
|
53
68
|
|
|
54
69
|
const toggleTab = (index: number) => {
|
|
@@ -80,6 +95,7 @@ const toggleTab = (index: number) => {
|
|
|
80
95
|
:slides-to-show="slidesToShow"
|
|
81
96
|
:slides-to-scroll="1"
|
|
82
97
|
:autoplay="false"
|
|
98
|
+
:ssr-breakpoints="ssrBreakpoints"
|
|
83
99
|
>
|
|
84
100
|
<SwProductCard
|
|
85
101
|
v-for="product of crossSellCollections[currentTabIndex]?.products"
|
|
@@ -3,10 +3,14 @@ import type {
|
|
|
3
3
|
CmsElementImage,
|
|
4
4
|
CmsElementManufacturerLogo,
|
|
5
5
|
} from "@shopware/composables";
|
|
6
|
-
import {
|
|
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";
|
|
12
|
+
import { computed, defineAsyncComponent, inject, useTemplateRef } from "vue";
|
|
13
|
+
import { useAppConfig, useCmsElementImage, useUrlResolver } from "#imports";
|
|
10
14
|
import { isSpatial } from "../../../../helpers/media/isSpatial";
|
|
11
15
|
|
|
12
16
|
const props = defineProps<{
|
|
@@ -25,44 +29,36 @@ const {
|
|
|
25
29
|
mimeType,
|
|
26
30
|
} = useCmsElementImage(props.content);
|
|
27
31
|
|
|
28
|
-
const
|
|
32
|
+
const imageSizes = inject<string>("cms-image-sizes", "100vw");
|
|
33
|
+
const appConfig = useAppConfig();
|
|
34
|
+
|
|
29
35
|
const imageElement = useTemplateRef<HTMLImageElement>("imageElement");
|
|
30
36
|
const { width, height } = useElementSize(imageElement);
|
|
31
37
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const srcPath = computed(() => {
|
|
37
|
-
if (!imageAttrs.value.src) return "";
|
|
38
|
-
|
|
39
|
-
try {
|
|
40
|
-
// Encode the URL first to handle special characters
|
|
41
|
-
const encodedUrl = encodeUrlPath(imageAttrs.value.src);
|
|
42
|
-
const url = new URL(encodedUrl);
|
|
38
|
+
const cdnOptions = computed(() => ({
|
|
39
|
+
format: appConfig.backgroundImage?.format,
|
|
40
|
+
quality: appConfig.backgroundImage?.quality,
|
|
41
|
+
}));
|
|
43
42
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
const srcSet = computed(
|
|
44
|
+
() =>
|
|
45
|
+
imageAttrs.value.srcset ||
|
|
46
|
+
generateCdnSrcSet(imageAttrs.value.src, undefined, cdnOptions.value),
|
|
47
|
+
);
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
url.searchParams.set("fit", "crop,smart");
|
|
59
|
-
|
|
60
|
-
return url.toString();
|
|
61
|
-
} catch {
|
|
62
|
-
// Fallback if URL parsing fails
|
|
63
|
-
return imageAttrs.value.src;
|
|
49
|
+
const srcPath = computed(() => {
|
|
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
|
+
);
|
|
64
58
|
}
|
|
59
|
+
return imageAttrs.value.src || "";
|
|
65
60
|
});
|
|
61
|
+
|
|
66
62
|
const imageComputedContainerAttrs = computed(() => {
|
|
67
63
|
const imageAttrsCopy = Object.assign({}, imageContainerAttrs.value);
|
|
68
64
|
if (imageAttrsCopy?.href) {
|
|
@@ -114,8 +110,10 @@ const SwMedia3D = computed(() => {
|
|
|
114
110
|
ref="imageElement"
|
|
115
111
|
preset="productDetail"
|
|
116
112
|
loading="lazy"
|
|
113
|
+
:sizes="imageSizes"
|
|
117
114
|
:class="{
|
|
118
|
-
'w-full
|
|
115
|
+
'w-full': !imageGallery,
|
|
116
|
+
'h-full': !imageGallery && ['cover', 'stretch'].includes(displayMode),
|
|
119
117
|
'w-4/5': imageGallery,
|
|
120
118
|
'absolute left-0 top-0': ['cover', 'stretch'].includes(displayMode),
|
|
121
119
|
'object-cover': displayMode === 'cover',
|
|
@@ -123,7 +121,7 @@ const SwMedia3D = computed(() => {
|
|
|
123
121
|
}"
|
|
124
122
|
:alt="imageAttrs.alt"
|
|
125
123
|
:src="srcPath"
|
|
126
|
-
:srcset="
|
|
124
|
+
:srcset="srcSet"
|
|
127
125
|
/>
|
|
128
126
|
</component>
|
|
129
127
|
</template>
|
|
@@ -1,25 +1,36 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { CmsElementImageGallery } from "@shopware/composables";
|
|
3
|
-
import { computed, ref } from "vue";
|
|
4
|
-
import { useCmsElementConfig } from "#imports";
|
|
3
|
+
import { computed, defineAsyncComponent, ref } from "vue";
|
|
4
|
+
import { useCmsElementConfig, useImagePlaceholder } from "#imports";
|
|
5
5
|
import { isSpatial } from "../../../../helpers/media/isSpatial";
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
slidesToShow?: number;
|
|
11
|
-
slidesToScroll?: number;
|
|
12
|
-
}>(),
|
|
13
|
-
{
|
|
14
|
-
slidesToShow: 5,
|
|
15
|
-
slidesToScroll: 4,
|
|
16
|
-
},
|
|
7
|
+
// Load SwMedia3D only on client-side to avoid SSR issues with three.js packages
|
|
8
|
+
const SwMedia3DAsync = defineAsyncComponent(
|
|
9
|
+
() => import("../../../SwMedia3D.vue"),
|
|
17
10
|
);
|
|
18
11
|
|
|
12
|
+
const props = defineProps<{
|
|
13
|
+
content: CmsElementImageGallery;
|
|
14
|
+
}>();
|
|
15
|
+
|
|
19
16
|
const { getConfigValue } = useCmsElementConfig(props.content);
|
|
20
17
|
|
|
18
|
+
const DEFAULT_MIN_HEIGHT = "500px";
|
|
19
|
+
const DEFAULT_NAVIGATION = "inside";
|
|
20
|
+
|
|
21
|
+
const minHeight = computed(
|
|
22
|
+
() => getConfigValue("minHeight") || DEFAULT_MIN_HEIGHT,
|
|
23
|
+
);
|
|
24
|
+
const navigationArrows = computed(
|
|
25
|
+
() => getConfigValue("navigationArrows") || DEFAULT_NAVIGATION,
|
|
26
|
+
);
|
|
27
|
+
const navigationDots = computed(
|
|
28
|
+
() => getConfigValue("navigationDots") || DEFAULT_NAVIGATION,
|
|
29
|
+
);
|
|
30
|
+
|
|
21
31
|
const currentIndex = ref(0);
|
|
22
32
|
const mediaGallery = computed(() => props.content.data?.sliderItems ?? []);
|
|
33
|
+
const placeholderSvg = useImagePlaceholder();
|
|
23
34
|
|
|
24
35
|
function goToSlide(index: number) {
|
|
25
36
|
if (index >= 0 && index < mediaGallery.value.length) {
|
|
@@ -43,7 +54,7 @@ const currentImage = computed(() => {
|
|
|
43
54
|
return mediaGallery.value[currentIndex.value]?.media;
|
|
44
55
|
});
|
|
45
56
|
|
|
46
|
-
// Touch event handling for mobile swipe gestures
|
|
57
|
+
// Touch event handling for mobile swipe gestures
|
|
47
58
|
const touchStartX = ref(0);
|
|
48
59
|
const touchEndX = ref(0);
|
|
49
60
|
|
|
@@ -57,77 +68,133 @@ function onTouchMove(event: TouchEvent) {
|
|
|
57
68
|
|
|
58
69
|
function onTouchEnd() {
|
|
59
70
|
const deltaX = touchEndX.value - touchStartX.value;
|
|
60
|
-
|
|
61
|
-
// Define a threshold for swipe detection
|
|
62
71
|
const threshold = 50; // pixels
|
|
63
72
|
|
|
64
73
|
if (Math.abs(deltaX) > threshold) {
|
|
65
74
|
if (deltaX < 0) {
|
|
66
|
-
// Swipe Left
|
|
67
75
|
next();
|
|
68
76
|
} else {
|
|
69
|
-
// Swipe Right
|
|
70
77
|
previous();
|
|
71
78
|
}
|
|
72
79
|
}
|
|
73
80
|
|
|
74
|
-
// Reset values
|
|
75
81
|
touchStartX.value = 0;
|
|
76
82
|
touchEndX.value = 0;
|
|
77
83
|
}
|
|
78
84
|
</script>
|
|
79
85
|
|
|
80
86
|
<template>
|
|
81
|
-
<div
|
|
87
|
+
<div
|
|
88
|
+
class="w-full max-w-full relative inline-flex flex-col justify-center items-center gap-2 mx-auto"
|
|
89
|
+
>
|
|
82
90
|
<div class="w-full">
|
|
83
91
|
<!-- Main Image Display -->
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
92
|
+
<div
|
|
93
|
+
class="w-full relative overflow-hidden"
|
|
94
|
+
:style="{ minHeight }"
|
|
95
|
+
@touchstart="onTouchStart"
|
|
96
|
+
@touchmove="onTouchMove"
|
|
97
|
+
@touchend="onTouchEnd"
|
|
98
|
+
>
|
|
87
99
|
<Transition name="gallery-fade" mode="out-in">
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
100
|
+
<!-- 3D media -->
|
|
101
|
+
<div
|
|
102
|
+
v-if="currentImage && isSpatial(currentImage)"
|
|
103
|
+
:key="currentImage.url + '-3d'"
|
|
104
|
+
class="w-full h-full relative"
|
|
105
|
+
:style="{ minHeight }"
|
|
106
|
+
>
|
|
107
|
+
<client-only>
|
|
108
|
+
<SwMedia3DAsync :src="currentImage.url" />
|
|
109
|
+
<template #fallback>
|
|
110
|
+
<CmsElementImageGallery3dPlaceholder
|
|
111
|
+
class="w-full h-full absolute inset-0 object-cover"
|
|
112
|
+
/>
|
|
113
|
+
<span
|
|
114
|
+
class="absolute bottom-4 right-4 text-sm bg-gray-800 rounded px-2 py-1 text-white"
|
|
115
|
+
>
|
|
116
|
+
3D
|
|
117
|
+
</span>
|
|
118
|
+
</template>
|
|
119
|
+
</client-only>
|
|
93
120
|
</div>
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
121
|
+
<!-- Regular image -->
|
|
122
|
+
<NuxtImg
|
|
123
|
+
v-else-if="currentImage"
|
|
124
|
+
:key="currentImage.url"
|
|
125
|
+
preset="hero"
|
|
126
|
+
loading="lazy"
|
|
127
|
+
class="w-full h-full absolute inset-0 object-cover"
|
|
128
|
+
:placeholder="placeholderSvg"
|
|
129
|
+
:src="currentImage.url"
|
|
130
|
+
:alt="currentImage.alt || 'Product image'"
|
|
131
|
+
/>
|
|
132
|
+
<!-- Placeholder -->
|
|
133
|
+
<img
|
|
134
|
+
v-else
|
|
135
|
+
class="w-full h-full absolute inset-0 object-cover"
|
|
136
|
+
:src="placeholderSvg"
|
|
137
|
+
alt="Placeholder image"
|
|
138
|
+
/>
|
|
99
139
|
</Transition>
|
|
100
|
-
|
|
101
140
|
</div>
|
|
141
|
+
|
|
102
142
|
<!-- Navigation Arrows -->
|
|
103
|
-
<div
|
|
104
|
-
|
|
143
|
+
<div
|
|
144
|
+
v-if="mediaGallery.length > 1 && navigationArrows !== 'none'"
|
|
145
|
+
class="absolute inset-0 flex items-center justify-between px-2 sm:px-4 pointer-events-none"
|
|
146
|
+
>
|
|
105
147
|
<!-- Previous Button -->
|
|
106
148
|
<button
|
|
107
|
-
class="
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
149
|
+
:class="[
|
|
150
|
+
'w-10 h-10 rounded-full transition disabled:opacity-50 pointer-events-auto shadow-lg flex items-center justify-center',
|
|
151
|
+
navigationArrows === 'outside'
|
|
152
|
+
? 'bg-brand-tertiary text-surface-on-surface'
|
|
153
|
+
: 'bg-surface-surface/20 hover:bg-surface-surface/50',
|
|
154
|
+
]"
|
|
155
|
+
:disabled="currentIndex === 0"
|
|
156
|
+
aria-label="Previous image"
|
|
157
|
+
@click="previous"
|
|
158
|
+
>
|
|
159
|
+
<SwChevronIcon direction="left" />
|
|
112
160
|
</button>
|
|
113
161
|
|
|
114
162
|
<!-- Next Button -->
|
|
115
163
|
<button
|
|
116
|
-
class="
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
164
|
+
:class="[
|
|
165
|
+
'w-10 h-10 rounded-full transition disabled:opacity-50 pointer-events-auto shadow-lg flex items-center justify-center',
|
|
166
|
+
navigationArrows === 'outside'
|
|
167
|
+
? 'bg-brand-tertiary text-surface-on-surface'
|
|
168
|
+
: 'bg-surface-surface/20 hover:bg-surface-surface/50',
|
|
169
|
+
]"
|
|
170
|
+
:disabled="currentIndex === mediaGallery.length - 1"
|
|
171
|
+
aria-label="Next image"
|
|
172
|
+
@click="next"
|
|
173
|
+
>
|
|
174
|
+
<SwChevronIcon direction="right" />
|
|
121
175
|
</button>
|
|
122
176
|
</div>
|
|
123
177
|
|
|
124
178
|
<!-- Dot Indicators -->
|
|
125
|
-
<div
|
|
126
|
-
|
|
127
|
-
|
|
179
|
+
<div
|
|
180
|
+
v-if="mediaGallery.length > 1 && navigationDots !== 'none'"
|
|
181
|
+
:class="[
|
|
182
|
+
'flex justify-center items-center gap-2',
|
|
183
|
+
navigationDots === 'outside' ? 'mt-4' : 'absolute bottom-4 left-1/2 transform -translate-x-1/2',
|
|
184
|
+
]"
|
|
185
|
+
>
|
|
186
|
+
<button
|
|
187
|
+
v-for="(image, index) in mediaGallery"
|
|
188
|
+
:key="image.media?.url"
|
|
189
|
+
class="relative rounded-full transition-all duration-200 hover:scale-110"
|
|
190
|
+
:class="{
|
|
128
191
|
'w-6 h-2 bg-surface-on-surface-variant': index === currentIndex,
|
|
129
|
-
'w-2 h-2 bg-surface-surface-container-highest':
|
|
130
|
-
|
|
192
|
+
'w-2 h-2 bg-surface-surface-container-highest':
|
|
193
|
+
index !== currentIndex,
|
|
194
|
+
}"
|
|
195
|
+
:aria-label="`Go to image ${index + 1}`"
|
|
196
|
+
@click="goToSlide(index)"
|
|
197
|
+
/>
|
|
131
198
|
</div>
|
|
132
199
|
</div>
|
|
133
200
|
</div>
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { CmsElementProductBox } from "@shopware/composables";
|
|
3
3
|
import { computed } from "vue";
|
|
4
|
+
import { useCmsElementConfig } from "#imports";
|
|
4
5
|
|
|
5
6
|
const props = defineProps<{
|
|
6
7
|
content: CmsElementProductBox;
|
|
7
8
|
}>();
|
|
8
9
|
|
|
10
|
+
const { getConfigValue } = useCmsElementConfig(props.content);
|
|
9
11
|
const product = computed(() => props.content.data?.product || {});
|
|
10
12
|
</script>
|
|
11
13
|
|
|
12
14
|
<template>
|
|
13
|
-
<SwProductCard
|
|
15
|
+
<SwProductCard
|
|
16
|
+
v-if="product?.id"
|
|
17
|
+
:product="product"
|
|
18
|
+
:layout-type="getConfigValue('boxLayout')"
|
|
19
|
+
/>
|
|
14
20
|
<SwProductCardSkeleton v-else />
|
|
15
21
|
</template>
|
|
@@ -4,13 +4,14 @@ import { useCmsTranslations } from "@shopware/composables";
|
|
|
4
4
|
import { defu } from "defu";
|
|
5
5
|
import { computed, ref, useTemplateRef, watch } from "vue";
|
|
6
6
|
import { useRoute, useRouter } from "vue-router";
|
|
7
|
-
import { useCategoryListing } from "#imports";
|
|
7
|
+
import { useCategoryListing, useCmsElementConfig } from "#imports";
|
|
8
8
|
import type { Schemas, operations } from "#shopware";
|
|
9
9
|
|
|
10
10
|
const props = defineProps<{
|
|
11
11
|
content: CmsElementProductListing;
|
|
12
12
|
}>();
|
|
13
13
|
|
|
14
|
+
const { getConfigValue } = useCmsElementConfig(props.content);
|
|
14
15
|
const defaultLimit = 15;
|
|
15
16
|
const defaultPage = 1;
|
|
16
17
|
const defaultOrder = "name-asc";
|
|
@@ -154,8 +155,14 @@ compareRouteQueryWithInitialListing();
|
|
|
154
155
|
{{ translations.listing.noProducts }}
|
|
155
156
|
</div>
|
|
156
157
|
<div v-if="!loading" ref="productListElement" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 auto-rows-fr gap-x-4 sm:gap-x-6 lg:gap-x-8 gap-y-8 sm:gap-y-12 lg:gap-y-16">
|
|
157
|
-
<SwProductCard
|
|
158
|
-
|
|
158
|
+
<SwProductCard
|
|
159
|
+
v-for="product in getElements"
|
|
160
|
+
:key="product.id"
|
|
161
|
+
:product="product"
|
|
162
|
+
:is-product-listing="isProductListing"
|
|
163
|
+
:layout-type="getConfigValue('boxLayout')"
|
|
164
|
+
class="w-full"
|
|
165
|
+
/>
|
|
159
166
|
</div>
|
|
160
167
|
<div v-if="loading" data-testid="loading" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 auto-rows-fr gap-x-4 sm:gap-x-6 lg:gap-x-8 gap-y-8 sm:gap-y-12 lg:gap-y-16">
|
|
161
168
|
<ProductCardSkeleton v-for="index in limit" :key="index"
|
|
@@ -7,5 +7,10 @@ defineProps<{
|
|
|
7
7
|
</script>
|
|
8
8
|
<template>
|
|
9
9
|
<!-- there is no css config coming from API for this element so we don't need to merge -->
|
|
10
|
-
<
|
|
10
|
+
<div role="heading" aria-level="1">
|
|
11
|
+
<CmsElementText
|
|
12
|
+
:content="content as any"
|
|
13
|
+
class="self-stretch text-surface-on-surface text-4xl font-normal font-serif leading-[60px]"
|
|
14
|
+
/>
|
|
15
|
+
</div>
|
|
11
16
|
</template>
|
|
@@ -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>
|