@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
|
@@ -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
|
|
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 (
|
|
39
|
-
return
|
|
43
|
+
if (filter.code === "manufacturer") {
|
|
44
|
+
return selectedFilters?.manufacturer || [];
|
|
40
45
|
}
|
|
41
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
</
|
|
90
|
+
</template>
|
|
91
|
+
|
|
92
|
+
<!-- Filter content -->
|
|
84
93
|
<transition name="filter-collapse">
|
|
85
|
-
<div v-if="isFilterVisible" :id="
|
|
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">{{
|
|
96
|
+
<legend class="sr-only">{{ filter.name }}</legend>
|
|
88
97
|
<label
|
|
89
|
-
v-for="option in
|
|
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
|
|
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 :
|
|
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
|
-
|
|
40
|
+
selectedFilters?.rating !== hoveredIndex.value
|
|
36
41
|
? hoveredIndex.value
|
|
37
42
|
: undefined;
|
|
38
|
-
emits("select-value", { code:
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
</
|
|
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
|
|
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:
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
</
|
|
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="
|
|
97
|
-
:aria-label="
|
|
98
|
-
:label="
|
|
99
|
-
:description="
|
|
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
|
-
|
|
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, resolveComponent } from "vue";
|
|
8
8
|
import type { Schemas } from "#shopware";
|
|
9
|
+
import { useTypedAppConfig } from "../../../composables/useTypedAppConfig";
|
|
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 = useTypedAppConfig();
|
|
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
|
{
|
|
@@ -53,7 +67,12 @@ const DynamicRender = () => {
|
|
|
53
67
|
}),
|
|
54
68
|
);
|
|
55
69
|
}
|
|
56
|
-
|
|
70
|
+
if (import.meta.dev) {
|
|
71
|
+
console.warn(
|
|
72
|
+
`[CMS] Block type "${componentName}" is not implemented.\n → Create a component named "${componentNameToResolve}.vue" to render it.\n 📖 Docs: https://frontends.shopware.com/getting-started/cms/create-blocks`,
|
|
73
|
+
);
|
|
74
|
+
return h(resolveComponent("CmsNoComponent"), { content: props.content });
|
|
75
|
+
}
|
|
57
76
|
return h("div", {}, "");
|
|
58
77
|
};
|
|
59
78
|
</script>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { resolveCmsComponent } from "@shopware/composables";
|
|
3
3
|
import { getCmsLayoutConfiguration } from "@shopware/helpers";
|
|
4
|
-
import { h } from "vue";
|
|
4
|
+
import { h, resolveComponent } from "vue";
|
|
5
5
|
import type { Schemas } from "#shopware";
|
|
6
6
|
|
|
7
7
|
const props = defineProps<{
|
|
@@ -28,7 +28,12 @@ const DynamicRender = () => {
|
|
|
28
28
|
class: cssClasses,
|
|
29
29
|
});
|
|
30
30
|
}
|
|
31
|
-
|
|
31
|
+
if (import.meta.dev) {
|
|
32
|
+
console.warn(
|
|
33
|
+
`[CMS] Element type "${componentName}" is not implemented.\n → Create a component named "${componentNameToResolve}.vue" to render it.\n 📖 Docs: https://frontends.shopware.com/getting-started/cms/create-elements`,
|
|
34
|
+
);
|
|
35
|
+
return h(resolveComponent("CmsNoComponent"), { content: props.content });
|
|
36
|
+
}
|
|
32
37
|
return h("div", {}, "");
|
|
33
38
|
};
|
|
34
39
|
</script>
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { refAutoReset } from "@vueuse/core";
|
|
3
|
+
import { pascalCase } from "scule";
|
|
2
4
|
import { computed } from "vue";
|
|
3
5
|
import type { Schemas } from "#shopware";
|
|
4
6
|
|
|
5
7
|
const props = defineProps<{
|
|
6
|
-
content: Schemas["CmsBlock"];
|
|
8
|
+
content: Schemas["CmsSection"] | Schemas["CmsBlock"] | Schemas["CmsSlot"];
|
|
7
9
|
}>();
|
|
8
10
|
|
|
9
11
|
const elementType = computed(() =>
|
|
@@ -15,13 +17,90 @@ const elementType = computed(() =>
|
|
|
15
17
|
);
|
|
16
18
|
|
|
17
19
|
const componentName = computed(() => props.content.type);
|
|
20
|
+
|
|
21
|
+
const expectedComponentName = computed(() =>
|
|
22
|
+
pascalCase(`Cms-${elementType.value}-${componentName.value ?? ""}`),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const docsUrl = computed(() => {
|
|
26
|
+
const params = new URLSearchParams({
|
|
27
|
+
component: expectedComponentName.value,
|
|
28
|
+
type: elementType.value.toLowerCase(),
|
|
29
|
+
});
|
|
30
|
+
return `https://frontends.shopware.com/getting-started/cms/missing-component?${params}`;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const aiPrompt = computed(() => {
|
|
34
|
+
const schemaType =
|
|
35
|
+
props.content.apiAlias === "cms_block"
|
|
36
|
+
? 'Schemas["CmsBlock"]'
|
|
37
|
+
: props.content.apiAlias === "cms_section"
|
|
38
|
+
? 'Schemas["CmsSection"]'
|
|
39
|
+
: 'Schemas["CmsSlot"]';
|
|
40
|
+
|
|
41
|
+
const contentJson = JSON.stringify(props.content, null, 2);
|
|
42
|
+
const type = elementType.value.toLowerCase();
|
|
43
|
+
const name = componentName.value;
|
|
44
|
+
const compName = expectedComponentName.value;
|
|
45
|
+
|
|
46
|
+
return [
|
|
47
|
+
`Create a Vue 3 component \`${compName}.vue\` for a Shopware Frontends headless storefront.`,
|
|
48
|
+
"",
|
|
49
|
+
`This component renders the CMS ${type} type: "${name}" (apiAlias: "${props.content.apiAlias}").`,
|
|
50
|
+
"",
|
|
51
|
+
"Requirements:",
|
|
52
|
+
`- Accept a \`content: ${schemaType}\` prop`,
|
|
53
|
+
`- Render the data for CMS ${type} type "${name}"`,
|
|
54
|
+
`- Follow patterns from existing Cms${elementType.value} components in packages/cms-base-layer/app/components/public/cms/${type}/`,
|
|
55
|
+
"- Use composables from @shopware/composables where applicable",
|
|
56
|
+
"",
|
|
57
|
+
`The full content prop for this ${type} is:`,
|
|
58
|
+
contentJson,
|
|
59
|
+
"",
|
|
60
|
+
`Reference: ${docsUrl.value}`,
|
|
61
|
+
].join("\n");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const copied = refAutoReset(false, 2000);
|
|
65
|
+
|
|
66
|
+
async function copyPrompt() {
|
|
67
|
+
try {
|
|
68
|
+
await navigator.clipboard.writeText(aiPrompt.value);
|
|
69
|
+
copied.value = true;
|
|
70
|
+
} catch {
|
|
71
|
+
// Fallback: clipboard API unavailable (non-secure origin, unfocused document)
|
|
72
|
+
console.warn("[CMS] Could not copy to clipboard. Prompt logged below:");
|
|
73
|
+
console.info(aiPrompt.value);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
18
76
|
</script>
|
|
19
|
-
|
|
77
|
+
|
|
20
78
|
<template>
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
79
|
+
<div
|
|
80
|
+
class="sw-cms-no-component box-border min-h-[40px] rounded border-2 border-dashed border-brand-primary bg-brand-secondary font-mono text-[11px] text-brand-on-secondary"
|
|
81
|
+
>
|
|
82
|
+
<div class="flex flex-wrap items-center gap-1.5 px-2 py-1.5">
|
|
83
|
+
<span class="text-brand-primary">⚠ missing implementation:</span>
|
|
84
|
+
<span class="font-semibold">{{ expectedComponentName }}</span>
|
|
85
|
+
|
|
86
|
+
<a
|
|
87
|
+
:href="docsUrl"
|
|
88
|
+
target="_blank"
|
|
89
|
+
rel="noopener noreferrer"
|
|
90
|
+
title="View CMS documentation"
|
|
91
|
+
class="whitespace-nowrap rounded border border-outline-outline-primary bg-surface-surface px-[5px] py-px text-[10px] leading-none text-brand-primary no-underline hover:bg-brand-secondary-hover"
|
|
92
|
+
>
|
|
93
|
+
docs ↗
|
|
94
|
+
</a>
|
|
95
|
+
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
title="Copy AI prompt to clipboard"
|
|
99
|
+
class="cursor-pointer whitespace-nowrap rounded border border-outline-outline-primary bg-surface-surface px-[5px] py-px font-mono text-[10px] leading-none text-brand-primary hover:bg-brand-secondary-hover"
|
|
100
|
+
@click.stop="copyPrompt"
|
|
101
|
+
>
|
|
102
|
+
{{ copied ? "copied ✓" : "copy AI prompt" }}
|
|
103
|
+
</button>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
27
106
|
</template>
|
|
@@ -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
|
-
|
|
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">
|