@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
|
@@ -8,12 +8,15 @@ import { pascalCase } from "scule";
|
|
|
8
8
|
import { computed, h, resolveComponent, watchEffect } from "vue";
|
|
9
9
|
import { createCategoryListingContext, useNavigationContext } from "#imports";
|
|
10
10
|
import type { Schemas } from "#shopware";
|
|
11
|
+
import { useLcpImagePreload } from "../../../composables/useLcpImagePreload";
|
|
12
|
+
import { useTypedAppConfig } from "../../../composables/useTypedAppConfig";
|
|
11
13
|
|
|
12
14
|
const props = defineProps<{
|
|
13
15
|
content: Schemas["CmsPage"];
|
|
14
16
|
}>();
|
|
15
17
|
|
|
16
18
|
const { routeName } = useNavigationContext();
|
|
19
|
+
const appConfig = useTypedAppConfig();
|
|
17
20
|
|
|
18
21
|
// Function to initialize or update listing context
|
|
19
22
|
function updateListingContext(content: Schemas["CmsPage"]) {
|
|
@@ -36,6 +39,8 @@ const cmsSections = computed<Schemas["CmsSection"][]>(() => {
|
|
|
36
39
|
return props.content?.sections || [];
|
|
37
40
|
});
|
|
38
41
|
|
|
42
|
+
useLcpImagePreload(props.content?.sections || []);
|
|
43
|
+
|
|
39
44
|
const DynamicRender = () => {
|
|
40
45
|
const componentsMap = cmsSections.value.map((section) => {
|
|
41
46
|
return {
|
|
@@ -56,6 +61,7 @@ const DynamicRender = () => {
|
|
|
56
61
|
layoutStyles.backgroundImage = getBackgroundImageUrl(
|
|
57
62
|
layoutStyles.backgroundImage,
|
|
58
63
|
componentObject.section,
|
|
64
|
+
appConfig.backgroundImage,
|
|
59
65
|
);
|
|
60
66
|
}
|
|
61
67
|
|
|
@@ -64,6 +70,7 @@ const DynamicRender = () => {
|
|
|
64
70
|
class: {
|
|
65
71
|
...cssClasses,
|
|
66
72
|
"max-w-screen-2xl w-full mx-auto": layoutStyles?.sizingMode === "boxed",
|
|
73
|
+
"w-full": layoutStyles?.sizingMode === "full_width",
|
|
67
74
|
},
|
|
68
75
|
style: {
|
|
69
76
|
backgroundColor: layoutStyles?.backgroundColor,
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const props = defineProps<{
|
|
3
|
+
navigationId: string;
|
|
4
|
+
}>();
|
|
5
|
+
|
|
6
|
+
const { apiClient } = useShopwareContext();
|
|
7
|
+
|
|
8
|
+
const { data: registrationResponse } = await useAsyncData(
|
|
9
|
+
`cmsNavigation${props.navigationId}`,
|
|
10
|
+
async () => {
|
|
11
|
+
const response = await apiClient.invoke(
|
|
12
|
+
"getCustomerGroupRegistrationInfo get /customer-group-registration/config/{customerGroupId}",
|
|
13
|
+
{
|
|
14
|
+
pathParams: { customerGroupId: props.navigationId },
|
|
15
|
+
},
|
|
16
|
+
);
|
|
17
|
+
return response.data || {};
|
|
18
|
+
},
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
useSeoMeta({
|
|
22
|
+
description: () =>
|
|
23
|
+
registrationResponse.value?.translated?.registrationSeoMetaDescription ||
|
|
24
|
+
undefined,
|
|
25
|
+
});
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<template>
|
|
29
|
+
<div class="container mx-auto bg-surface-surface flex flex-col mt-10">
|
|
30
|
+
<h1
|
|
31
|
+
class="mb-4 text-2xl font-extrabold leading-none tracking-tight text-surface-on-surface md:text-3xl lg:text-5xl text-center"
|
|
32
|
+
>
|
|
33
|
+
{{ registrationResponse?.translated.registrationTitle }}
|
|
34
|
+
</h1>
|
|
35
|
+
<div
|
|
36
|
+
v-if="registrationResponse?.registrationActive"
|
|
37
|
+
class="text-lg font-normal text-surface-on-surface-variant lg:text-xl"
|
|
38
|
+
>
|
|
39
|
+
<div
|
|
40
|
+
v-if="registrationResponse?.translated.registrationIntroduction"
|
|
41
|
+
class="px-6 sm:px-4 mb-6"
|
|
42
|
+
v-html="registrationResponse.translated.registrationIntroduction"
|
|
43
|
+
/>
|
|
44
|
+
<AccountRegistrationForm
|
|
45
|
+
:customer-group-id="registrationResponse?.id"
|
|
46
|
+
:company-only="
|
|
47
|
+
registrationResponse?.translated?.registrationOnlyCompanyRegistration
|
|
48
|
+
"
|
|
49
|
+
/>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
</template>
|
|
@@ -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-
|
|
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]
|
|
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
|
-
<
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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,
|
|
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="
|
|
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':
|
|
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 }}
|
|
@@ -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,7 +3,7 @@ import type {
|
|
|
3
3
|
CmsElementImage,
|
|
4
4
|
CmsElementManufacturerLogo,
|
|
5
5
|
} from "@shopware/composables";
|
|
6
|
-
import { buildUrlPrefix
|
|
6
|
+
import { buildUrlPrefix } from "@shopware/helpers";
|
|
7
7
|
import { useElementSize } from "@vueuse/core";
|
|
8
8
|
import { computed, defineAsyncComponent, useTemplateRef } from "vue";
|
|
9
9
|
import { useCmsElementImage, useUrlResolver } from "#imports";
|
|
@@ -25,44 +25,19 @@ const {
|
|
|
25
25
|
mimeType,
|
|
26
26
|
} = useCmsElementImage(props.content);
|
|
27
27
|
|
|
28
|
-
const DEFAULT_THUMBNAIL_SIZE = 10;
|
|
29
28
|
const imageElement = useTemplateRef<HTMLImageElement>("imageElement");
|
|
30
29
|
const { width, height } = useElementSize(imageElement);
|
|
31
30
|
|
|
32
31
|
function roundUp(num: number) {
|
|
33
|
-
return
|
|
32
|
+
return Math.ceil(num / 100) * 100;
|
|
34
33
|
}
|
|
35
34
|
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
// Encode the URL first to handle special characters
|
|
41
|
-
const encodedUrl = encodeUrlPath(imageAttrs.value.src);
|
|
42
|
-
const url = new URL(encodedUrl);
|
|
43
|
-
|
|
44
|
-
// Only add size parameters if dimensions are available (after mount)
|
|
45
|
-
// This prevents hydration mismatch
|
|
46
|
-
const w = roundUp(width.value);
|
|
47
|
-
const h = roundUp(height.value);
|
|
48
|
-
|
|
49
|
-
if (w > DEFAULT_THUMBNAIL_SIZE || h > DEFAULT_THUMBNAIL_SIZE) {
|
|
50
|
-
if (width.value > height.value) {
|
|
51
|
-
url.searchParams.set("width", String(w));
|
|
52
|
-
} else {
|
|
53
|
-
url.searchParams.set("height", String(h));
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Add fit parameter
|
|
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;
|
|
64
|
-
}
|
|
35
|
+
const imageSize = computed(() => {
|
|
36
|
+
const containerSize = Math.max(width.value || 0, height.value || 0);
|
|
37
|
+
if (!containerSize) return undefined;
|
|
38
|
+
return roundUp(containerSize * 2);
|
|
65
39
|
});
|
|
40
|
+
|
|
66
41
|
const imageComputedContainerAttrs = computed(() => {
|
|
67
42
|
const imageAttrsCopy = Object.assign({}, imageContainerAttrs.value);
|
|
68
43
|
if (imageAttrsCopy?.href) {
|
|
@@ -114,16 +89,18 @@ const SwMedia3D = computed(() => {
|
|
|
114
89
|
ref="imageElement"
|
|
115
90
|
preset="productDetail"
|
|
116
91
|
loading="lazy"
|
|
92
|
+
:width="imageSize"
|
|
93
|
+
:height="imageSize"
|
|
117
94
|
:class="{
|
|
118
|
-
'w-full
|
|
95
|
+
'w-full': !imageGallery,
|
|
96
|
+
'h-full': !imageGallery && ['cover', 'stretch'].includes(displayMode),
|
|
119
97
|
'w-4/5': imageGallery,
|
|
120
98
|
'absolute left-0 top-0': ['cover', 'stretch'].includes(displayMode),
|
|
121
99
|
'object-cover': displayMode === 'cover',
|
|
122
100
|
'object-contain': imageGallery || displayMode !== 'cover',
|
|
123
101
|
}"
|
|
124
102
|
:alt="imageAttrs.alt"
|
|
125
|
-
:src="
|
|
126
|
-
:srcset="imageAttrs.srcset"
|
|
103
|
+
:src="imageAttrs.src"
|
|
127
104
|
/>
|
|
128
105
|
</component>
|
|
129
106
|
</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>
|
|
@@ -3,14 +3,19 @@ import type { CmsElementProductListing } from "@shopware/composables";
|
|
|
3
3
|
import { useCmsTranslations } from "@shopware/composables";
|
|
4
4
|
import { defu } from "defu";
|
|
5
5
|
import { computed, ref, useTemplateRef, watch } from "vue";
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
useCategoryListing,
|
|
8
|
+
useCmsElementConfig,
|
|
9
|
+
useRoute,
|
|
10
|
+
useRouter,
|
|
11
|
+
} from "#imports";
|
|
8
12
|
import type { Schemas, operations } from "#shopware";
|
|
9
13
|
|
|
10
14
|
const props = defineProps<{
|
|
11
15
|
content: CmsElementProductListing;
|
|
12
16
|
}>();
|
|
13
17
|
|
|
18
|
+
const { getConfigValue } = useCmsElementConfig(props.content);
|
|
14
19
|
const defaultLimit = 15;
|
|
15
20
|
const defaultPage = 1;
|
|
16
21
|
const defaultOrder = "name-asc";
|
|
@@ -154,8 +159,14 @@ compareRouteQueryWithInitialListing();
|
|
|
154
159
|
{{ translations.listing.noProducts }}
|
|
155
160
|
</div>
|
|
156
161
|
<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
|
-
|
|
162
|
+
<SwProductCard
|
|
163
|
+
v-for="product in getElements"
|
|
164
|
+
:key="product.id"
|
|
165
|
+
:product="product"
|
|
166
|
+
:is-product-listing="isProductListing"
|
|
167
|
+
:layout-type="getConfigValue('boxLayout')"
|
|
168
|
+
class="w-full"
|
|
169
|
+
/>
|
|
159
170
|
</div>
|
|
160
171
|
<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
172
|
<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>
|