@shopware/cms-base-layer 3.0.1 → 4.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 +78 -15
- package/app/app.config.ts +3 -3
- package/app/assets/css/rich-text.css +80 -0
- package/app/components/SwContactForm.vue +2 -1
- package/app/components/SwFilterChips.vue +29 -7
- package/app/components/SwNewsletterForm.vue +2 -1
- package/app/components/SwProductCard.vue +19 -3
- package/app/components/SwProductCardImage.vue +5 -1
- package/app/components/SwProductListingFilter.vue +5 -0
- package/app/components/SwProductListingFilters.vue +76 -74
- package/app/components/SwProductListingFiltersHorizontal.vue +73 -72
- package/app/components/SwStockInfo.vue +5 -2
- package/app/components/SwVariantConfigurator.vue +8 -3
- package/app/components/listing-filters/SwFilterCategories.vue +158 -0
- package/app/components/public/cms/CmsGenericBlock.vue +1 -1
- package/app/components/public/cms/CmsGenericElement.vue +1 -1
- package/app/components/public/cms/CmsNoComponent.vue +1 -1
- package/app/components/public/cms/FrontendAccountCustomerGroupRegistrationPage.vue +1 -1
- package/app/components/public/cms/block/CmsBlockCenterText.vue +2 -1
- package/app/components/public/cms/block/CmsBlockImageBubbleRow.vue +5 -1
- package/app/components/public/cms/block/CmsBlockImageFourColumn.vue +6 -2
- package/app/components/public/cms/block/CmsBlockImageGalleryBig.vue +9 -5
- package/app/components/public/cms/block/CmsBlockImageHighlightRow.vue +7 -2
- package/app/components/public/cms/block/CmsBlockImageTextRow.vue +9 -3
- package/app/components/public/cms/block/CmsBlockImageThreeCover.vue +7 -2
- package/app/components/public/cms/block/CmsBlockImageTwoColumn.vue +7 -3
- package/app/components/public/cms/block/CmsBlockProductHeading.vue +2 -1
- package/app/components/public/cms/element/CmsElementBuyBox.vue +4 -1
- package/app/components/public/cms/element/CmsElementCrossSelling.vue +2 -1
- package/app/components/public/cms/element/CmsElementHtml.vue +1 -1
- package/app/components/public/cms/element/CmsElementImage.vue +41 -2
- package/app/components/public/cms/element/CmsElementImageGallery.vue +2 -1
- package/app/components/public/cms/element/CmsElementProductDescriptionReviews.vue +18 -9
- package/app/components/public/cms/element/CmsElementText.md +77 -0
- package/app/components/public/cms/element/CmsElementText.vue +5 -32
- package/app/helpers/html-to-vue/renderToHtml.test.ts +55 -0
- package/app/helpers/html-to-vue/renderToHtml.ts +5 -1
- package/app/helpers/html-to-vue/renderer.ts +10 -1
- package/app/utils/listingFilters.test.ts +39 -0
- package/app/utils/listingFilters.ts +19 -0
- package/app/utils/routeQuery.test.ts +65 -0
- package/app/utils/routeQuery.ts +26 -0
- package/app/utils/useSelectedListingFilters.test.ts +92 -0
- package/app/utils/useSelectedListingFilters.ts +111 -0
- package/component-dirs.json +20 -0
- package/index.d.ts +2 -2
- package/nuxt.config.ts +35 -29
- package/package.json +27 -31
- package/dist/index.d.mts +0 -5
- package/dist/index.d.ts +0 -5
- package/dist/index.mjs +0 -31
- package/index.cjs +0 -7
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
<script
|
|
2
|
+
setup
|
|
3
|
+
lang="ts"
|
|
4
|
+
generic="
|
|
5
|
+
ListingFilter extends {
|
|
6
|
+
code: string;
|
|
7
|
+
label: string;
|
|
8
|
+
name: string;
|
|
9
|
+
entities?: Array<{
|
|
10
|
+
id: string;
|
|
11
|
+
name?: string;
|
|
12
|
+
translated?: { name?: string };
|
|
13
|
+
}>;
|
|
14
|
+
}
|
|
15
|
+
"
|
|
16
|
+
>
|
|
17
|
+
import { useCmsTranslations } from "@shopware/composables";
|
|
18
|
+
import { excludeRootCategory, getTranslatedProperty } from "@shopware/helpers";
|
|
19
|
+
import { defu } from "defu";
|
|
20
|
+
import { computed, ref } from "vue";
|
|
21
|
+
|
|
22
|
+
import { useSessionContext } from "#imports";
|
|
23
|
+
|
|
24
|
+
const {
|
|
25
|
+
filter,
|
|
26
|
+
selectedFilters,
|
|
27
|
+
displayMode = "accordion",
|
|
28
|
+
} = defineProps<{
|
|
29
|
+
filter: ListingFilter;
|
|
30
|
+
selectedFilters: {
|
|
31
|
+
categories?: string[];
|
|
32
|
+
[key: string]: unknown;
|
|
33
|
+
};
|
|
34
|
+
displayMode?: "accordion" | "dropdown";
|
|
35
|
+
}>();
|
|
36
|
+
|
|
37
|
+
const emits =
|
|
38
|
+
defineEmits<
|
|
39
|
+
(e: "select-value", value: { code: string; value: unknown }) => void
|
|
40
|
+
>();
|
|
41
|
+
|
|
42
|
+
type Translations = {
|
|
43
|
+
listing: {
|
|
44
|
+
categories: string;
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
let translations: Translations = {
|
|
49
|
+
listing: {
|
|
50
|
+
categories: "Categories",
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
translations = defu(useCmsTranslations(), translations) as Translations;
|
|
55
|
+
|
|
56
|
+
const { sessionContext } = useSessionContext();
|
|
57
|
+
|
|
58
|
+
const categoryOptions = computed(() =>
|
|
59
|
+
excludeRootCategory(
|
|
60
|
+
filter.entities,
|
|
61
|
+
sessionContext.value?.salesChannel?.navigationCategoryId,
|
|
62
|
+
),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const isFilterVisible = ref<boolean>(false);
|
|
66
|
+
const toggle = () => {
|
|
67
|
+
isFilterVisible.value = !isFilterVisible.value;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const selectedIds = computed(() => selectedFilters?.categories || []);
|
|
71
|
+
|
|
72
|
+
const isChecked = (id: string) => selectedIds.value.includes(id);
|
|
73
|
+
|
|
74
|
+
const selectValue = (id: string) => {
|
|
75
|
+
emits("select-value", {
|
|
76
|
+
code: filter.code,
|
|
77
|
+
value: id,
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
</script>
|
|
81
|
+
|
|
82
|
+
<template>
|
|
83
|
+
<div
|
|
84
|
+
v-if="categoryOptions.length"
|
|
85
|
+
class="self-stretch flex flex-col justify-start items-start gap-4"
|
|
86
|
+
>
|
|
87
|
+
<!-- Accordion header (only in accordion mode) -->
|
|
88
|
+
<template v-if="displayMode === 'accordion'">
|
|
89
|
+
<div class="self-stretch flex flex-col justify-center items-center">
|
|
90
|
+
<div
|
|
91
|
+
class="self-stretch py-3 border-b border-outline-outline-variant inline-flex justify-between items-center gap-1 cursor-pointer"
|
|
92
|
+
@click="toggle"
|
|
93
|
+
role="button"
|
|
94
|
+
tabindex="0"
|
|
95
|
+
:aria-expanded="isFilterVisible"
|
|
96
|
+
:aria-controls="filter.code"
|
|
97
|
+
:aria-label="translations.listing.categories"
|
|
98
|
+
@keydown.enter="toggle"
|
|
99
|
+
@keydown.space.prevent="toggle"
|
|
100
|
+
>
|
|
101
|
+
<div class="flex-1 flex items-center gap-2.5">
|
|
102
|
+
<div
|
|
103
|
+
class="flex-1 text-surface-on-surface text-base font-bold leading-normal text-left"
|
|
104
|
+
>
|
|
105
|
+
{{ translations.listing.categories }}
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
<span class="flex items-center justify-center" aria-hidden="true">
|
|
109
|
+
<SwChevronIcon
|
|
110
|
+
:direction="isFilterVisible ? 'up' : 'down'"
|
|
111
|
+
:size="24"
|
|
112
|
+
/>
|
|
113
|
+
</span>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
</template>
|
|
117
|
+
|
|
118
|
+
<!-- Filter content -->
|
|
119
|
+
<transition name="filter-collapse">
|
|
120
|
+
<div
|
|
121
|
+
v-if="isFilterVisible || displayMode === 'dropdown'"
|
|
122
|
+
:id="filter.code"
|
|
123
|
+
class="self-stretch flex flex-col justify-start items-start gap-4"
|
|
124
|
+
>
|
|
125
|
+
<fieldset
|
|
126
|
+
class="self-stretch flex flex-col justify-start items-start gap-4"
|
|
127
|
+
>
|
|
128
|
+
<legend class="sr-only">{{ translations.listing.categories }}</legend>
|
|
129
|
+
<label
|
|
130
|
+
v-for="option in categoryOptions"
|
|
131
|
+
:key="option.id"
|
|
132
|
+
class="self-stretch inline-flex justify-start items-start gap-2 cursor-pointer"
|
|
133
|
+
>
|
|
134
|
+
<div
|
|
135
|
+
class="w-4 self-stretch pt-[3px] flex justify-start items-start gap-2.5"
|
|
136
|
+
>
|
|
137
|
+
<SwCheckbox
|
|
138
|
+
:model-value="isChecked(option.id)"
|
|
139
|
+
@update:model-value="() => selectValue(option.id)"
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
142
|
+
<div
|
|
143
|
+
class="flex-1 inline-flex flex-col justify-start items-start gap-0.5"
|
|
144
|
+
>
|
|
145
|
+
<div class="inline-flex justify-start items-center gap-1">
|
|
146
|
+
<div
|
|
147
|
+
class="flex-1 text-surface-on-surface text-base font-normal leading-normal"
|
|
148
|
+
>
|
|
149
|
+
{{ getTranslatedProperty(option, "name") }}
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
</label>
|
|
154
|
+
</fieldset>
|
|
155
|
+
</div>
|
|
156
|
+
</transition>
|
|
157
|
+
</div>
|
|
158
|
+
</template>
|
|
@@ -71,7 +71,7 @@ const DynamicRender = () => {
|
|
|
71
71
|
}
|
|
72
72
|
if (import.meta.dev) {
|
|
73
73
|
console.warn(
|
|
74
|
-
`[CMS] Block type "${componentName}" is not implemented.\n → Create a component named "${componentNameToResolve}.vue" to render it.\n 📖 Docs: https://
|
|
74
|
+
`[CMS] Block type "${componentName}" is not implemented.\n → Create a component named "${componentNameToResolve}.vue" to render it.\n 📖 Docs: https://developer.shopware.com/frontends/guides/cms/create-blocks`,
|
|
75
75
|
);
|
|
76
76
|
return h(resolveComponent("CmsNoComponent"), { content: props.content });
|
|
77
77
|
}
|
|
@@ -31,7 +31,7 @@ const DynamicRender = () => {
|
|
|
31
31
|
}
|
|
32
32
|
if (import.meta.dev) {
|
|
33
33
|
console.warn(
|
|
34
|
-
`[CMS] Element type "${componentName}" is not implemented.\n → Create a component named "${componentNameToResolve}.vue" to render it.\n 📖 Docs: https://
|
|
34
|
+
`[CMS] Element type "${componentName}" is not implemented.\n → Create a component named "${componentNameToResolve}.vue" to render it.\n 📖 Docs: https://developer.shopware.com/frontends/guides/cms/create-elements`,
|
|
35
35
|
);
|
|
36
36
|
return h(resolveComponent("CmsNoComponent"), { content: props.content });
|
|
37
37
|
}
|
|
@@ -28,7 +28,7 @@ const docsUrl = computed(() => {
|
|
|
28
28
|
component: expectedComponentName.value,
|
|
29
29
|
type: elementType.value.toLowerCase(),
|
|
30
30
|
});
|
|
31
|
-
return `https://
|
|
31
|
+
return `https://developer.shopware.com/frontends/guides/cms/missing-component?${params}`;
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
const aiPrompt = computed(() => {
|
|
@@ -38,7 +38,7 @@ useSeoMeta({
|
|
|
38
38
|
>
|
|
39
39
|
<div
|
|
40
40
|
v-if="registrationResponse?.translated.registrationIntroduction"
|
|
41
|
-
class="px-6 sm:px-4 mb-6"
|
|
41
|
+
class="cms-element-text px-6 sm:px-4 mb-6"
|
|
42
42
|
v-html="registrationResponse.translated.registrationIntroduction"
|
|
43
43
|
/>
|
|
44
44
|
<AccountRegistrationForm
|
|
@@ -30,6 +30,10 @@ const centerContent = getSlotContent("center");
|
|
|
30
30
|
</template>
|
|
31
31
|
<style scoped>
|
|
32
32
|
.cms-block-image-bubble-row :deep(.cms-element-image) {
|
|
33
|
-
|
|
33
|
+
aspect-ratio: 1 / 1;
|
|
34
|
+
max-width: 20rem;
|
|
35
|
+
overflow: hidden;
|
|
36
|
+
border-radius: 9999px;
|
|
37
|
+
object-fit: cover;
|
|
34
38
|
}
|
|
35
39
|
</style>
|
|
@@ -35,10 +35,14 @@ const centerRightContent = getSlotContent("center-right");
|
|
|
35
35
|
|
|
36
36
|
<style scoped>
|
|
37
37
|
.cms-block-image-four-column :deep(.cms-element-image) {
|
|
38
|
-
|
|
38
|
+
position: relative;
|
|
39
|
+
height: 100%;
|
|
40
|
+
width: 100%;
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
.cms-block-image-four-column :deep(.cms-element-image img) {
|
|
42
|
-
|
|
44
|
+
height: 100%;
|
|
45
|
+
width: 100%;
|
|
46
|
+
object-fit: cover;
|
|
43
47
|
}
|
|
44
48
|
</style>
|
|
@@ -20,24 +20,28 @@ const cmsContent = getSlotContent("imageGallery");
|
|
|
20
20
|
|
|
21
21
|
<style scoped>
|
|
22
22
|
.cms-block-image-gallery-big {
|
|
23
|
-
|
|
23
|
+
width: 100%;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
.cms-block-image-gallery-big :deep(.cms-element-image-gallery) {
|
|
27
|
-
|
|
27
|
+
max-width: 1280px;
|
|
28
|
+
margin-left: auto;
|
|
29
|
+
margin-right: auto;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
/* Enhanced styling for the big gallery version */
|
|
31
33
|
.cms-block-image-gallery-big :deep(.gallery-slider) {
|
|
32
|
-
|
|
34
|
+
max-height: 800px;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
.cms-block-image-gallery-big :deep(img) {
|
|
36
|
-
|
|
38
|
+
margin-left: auto;
|
|
39
|
+
margin-right: auto;
|
|
40
|
+
object-fit: contain;
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
/* Larger navigation controls */
|
|
40
44
|
.cms-block-image-gallery-big :deep(.gallery-slider-controls) {
|
|
41
|
-
|
|
45
|
+
transform: scale(1.25);
|
|
42
46
|
}
|
|
43
47
|
</style>
|
|
@@ -31,10 +31,15 @@ const centerContent = getSlotContent("center");
|
|
|
31
31
|
|
|
32
32
|
<style scoped>
|
|
33
33
|
.cms-block-image-highlight-row :deep(.cms-element-image) {
|
|
34
|
-
|
|
34
|
+
position: relative;
|
|
35
|
+
height: 100%;
|
|
36
|
+
width: 100%;
|
|
37
|
+
border: 12px solid #ffffff;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
.cms-block-image-highlight-row :deep(.cms-element-image img) {
|
|
38
|
-
|
|
41
|
+
height: 100%;
|
|
42
|
+
width: 100%;
|
|
43
|
+
object-fit: cover;
|
|
39
44
|
}
|
|
40
45
|
</style>
|
|
@@ -43,14 +43,20 @@ const rightTextContent = getSlotContent("right-text");
|
|
|
43
43
|
|
|
44
44
|
<style scoped>
|
|
45
45
|
.cms-block-image-text-row :deep(.cms-element-image) {
|
|
46
|
-
|
|
46
|
+
position: relative;
|
|
47
|
+
height: 100%;
|
|
48
|
+
width: 100%;
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
.cms-block-image-text-row :deep(.cms-element-image img) {
|
|
50
|
-
|
|
52
|
+
height: 100%;
|
|
53
|
+
width: 100%;
|
|
54
|
+
border-radius: 0.5rem;
|
|
55
|
+
object-fit: cover;
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
.cms-block-image-text-row :deep(.cms-element-text) {
|
|
54
|
-
|
|
59
|
+
align-self: stretch;
|
|
60
|
+
min-height: 3rem;
|
|
55
61
|
}
|
|
56
62
|
</style>
|
|
@@ -31,10 +31,15 @@ const centerContent = getSlotContent("center");
|
|
|
31
31
|
|
|
32
32
|
<style scoped>
|
|
33
33
|
.cms-block-image-three-cover :deep(.cms-element-image) {
|
|
34
|
-
|
|
34
|
+
position: relative;
|
|
35
|
+
aspect-ratio: 1 / 1;
|
|
36
|
+
height: 100%;
|
|
37
|
+
width: 100%;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
.cms-block-image-three-cover :deep(.cms-element-image img) {
|
|
38
|
-
|
|
41
|
+
height: 100%;
|
|
42
|
+
width: 100%;
|
|
43
|
+
object-fit: cover;
|
|
39
44
|
}
|
|
40
45
|
</style>
|
|
@@ -27,14 +27,18 @@ const rightContent = getSlotContent("right");
|
|
|
27
27
|
|
|
28
28
|
<style scoped>
|
|
29
29
|
.cms-block-image-two-column :deep(.cms-element-image) {
|
|
30
|
-
|
|
30
|
+
position: relative;
|
|
31
|
+
height: 100%;
|
|
32
|
+
width: 100%;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
.cms-block-image-two-column :deep(.cms-element-image img) {
|
|
34
|
-
|
|
36
|
+
height: 100%;
|
|
37
|
+
width: 100%;
|
|
38
|
+
object-fit: cover;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
.cms-block-image-two-column :deep(.cms-element-image-slider) {
|
|
38
|
-
|
|
42
|
+
width: 100%;
|
|
39
43
|
}
|
|
40
44
|
</style>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { CmsElementBuyBox } from "@shopware/composables";
|
|
3
3
|
import { useCmsTranslations } from "@shopware/composables";
|
|
4
|
+
import { getTranslatedProperty } from "@shopware/helpers";
|
|
4
5
|
import { defu } from "defu";
|
|
5
6
|
import { computed } from "vue";
|
|
6
7
|
|
|
@@ -64,7 +65,9 @@ const referencePrice = computed(
|
|
|
64
65
|
() => product.value?.calculatedPrice?.referencePrice,
|
|
65
66
|
);
|
|
66
67
|
const purchaseUnit = computed(() => product.value?.purchaseUnit);
|
|
67
|
-
const unitName = computed(() =>
|
|
68
|
+
const unitName = computed(() =>
|
|
69
|
+
getTranslatedProperty(product.value?.unit, "name"),
|
|
70
|
+
);
|
|
68
71
|
const productName = computed(() => product.value?.translated?.name || "");
|
|
69
72
|
</script>
|
|
70
73
|
<template>
|
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
CmsElementCrossSelling,
|
|
4
4
|
SliderElementConfig,
|
|
5
5
|
} from "@shopware/composables";
|
|
6
|
+
import { getTranslatedProperty } from "@shopware/helpers";
|
|
6
7
|
import { useElementSize } from "@vueuse/core";
|
|
7
8
|
import { computed, inject, ref, useTemplateRef } from "vue";
|
|
8
9
|
|
|
@@ -86,7 +87,7 @@ const toggleTab = (index: number) => {
|
|
|
86
87
|
}"
|
|
87
88
|
@click="toggleTab(index)"
|
|
88
89
|
>
|
|
89
|
-
{{ collection.crossSelling
|
|
90
|
+
{{ getTranslatedProperty(collection.crossSelling, "name") }}
|
|
90
91
|
</a>
|
|
91
92
|
</div>
|
|
92
93
|
<transition name="fade" mode="out-in">
|
|
@@ -11,7 +11,7 @@ const { cssClasses, layoutStyles } = getCmsLayoutConfiguration(props.content);
|
|
|
11
11
|
|
|
12
12
|
const HtmlComponent = () => {
|
|
13
13
|
return h("div", {
|
|
14
|
-
class: cssClasses,
|
|
14
|
+
class: ["cms-element-html", "cms-element-text", cssClasses],
|
|
15
15
|
style: layoutStyles,
|
|
16
16
|
innerHTML: props.content.data.content || "",
|
|
17
17
|
});
|
|
@@ -5,9 +5,14 @@ import type {
|
|
|
5
5
|
} from "@shopware/composables";
|
|
6
6
|
import { buildUrlPrefix } from "@shopware/helpers";
|
|
7
7
|
import { useElementSize } from "@vueuse/core";
|
|
8
|
+
import { defu } from "defu";
|
|
8
9
|
import { computed, defineAsyncComponent, useTemplateRef } from "vue";
|
|
9
10
|
|
|
10
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
useCmsElementImage,
|
|
13
|
+
useCmsTranslations,
|
|
14
|
+
useUrlResolver,
|
|
15
|
+
} from "#imports";
|
|
11
16
|
|
|
12
17
|
import { isSpatial } from "../../../../helpers/media/isSpatial";
|
|
13
18
|
|
|
@@ -16,17 +21,51 @@ const props = defineProps<{
|
|
|
16
21
|
imageGallery?: boolean;
|
|
17
22
|
}>();
|
|
18
23
|
|
|
24
|
+
type Translations = {
|
|
25
|
+
cms: {
|
|
26
|
+
image: {
|
|
27
|
+
linkWithoutLabel: string;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
let translations: Translations = {
|
|
33
|
+
cms: {
|
|
34
|
+
image: {
|
|
35
|
+
linkWithoutLabel: "Open linked page",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
translations = defu(useCmsTranslations(), translations) as Translations;
|
|
41
|
+
|
|
19
42
|
const { getUrlPrefix } = useUrlResolver();
|
|
20
43
|
const {
|
|
44
|
+
ariaLabel,
|
|
21
45
|
containerStyle,
|
|
22
46
|
displayMode,
|
|
23
47
|
imageContainerAttrs,
|
|
24
48
|
imageAttrs,
|
|
25
49
|
imageLink,
|
|
50
|
+
isDecorative,
|
|
26
51
|
isVideoElement,
|
|
27
52
|
mimeType,
|
|
28
53
|
} = useCmsElementImage(props.content);
|
|
29
54
|
|
|
55
|
+
// A link wrapping only an image is nameless without alt text, which axe
|
|
56
|
+
// reports as a `link-name` violation.
|
|
57
|
+
const linkAriaLabel = computed(() => {
|
|
58
|
+
if (!imageLink.value.url || imageAttrs.value.alt) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const mediaTitle = isDecorative.value ? "" : props.content.data?.media?.title;
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
ariaLabel.value || mediaTitle || translations.cms.image.linkWithoutLabel
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
30
69
|
const imageElement = useTemplateRef<HTMLImageElement>("imageElement");
|
|
31
70
|
const { width, height } = useElementSize(imageElement);
|
|
32
71
|
|
|
@@ -69,6 +108,7 @@ const SwMedia3D = computed(() => {
|
|
|
69
108
|
}"
|
|
70
109
|
:style="containerStyle"
|
|
71
110
|
v-bind="imageComputedContainerAttrs"
|
|
111
|
+
:aria-label="linkAriaLabel"
|
|
72
112
|
>
|
|
73
113
|
<video
|
|
74
114
|
v-if="isVideoElement"
|
|
@@ -92,7 +132,6 @@ const SwMedia3D = computed(() => {
|
|
|
92
132
|
preset="productDetail"
|
|
93
133
|
loading="lazy"
|
|
94
134
|
:width="imageSize"
|
|
95
|
-
:height="imageSize"
|
|
96
135
|
:class="{
|
|
97
136
|
'w-full': !imageGallery,
|
|
98
137
|
'h-full': !imageGallery && ['cover', 'stretch'].includes(displayMode),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { CmsElementImageGallery } from "@shopware/composables";
|
|
3
|
+
import { getTranslatedProperty } from "@shopware/helpers";
|
|
3
4
|
import { computed, defineAsyncComponent, ref } from "vue";
|
|
4
5
|
|
|
5
6
|
import { useCmsElementConfig, useImagePlaceholder } from "#imports";
|
|
@@ -129,7 +130,7 @@ function onTouchEnd() {
|
|
|
129
130
|
class="w-full h-full absolute inset-0 object-cover"
|
|
130
131
|
:placeholder="placeholderSvg"
|
|
131
132
|
:src="currentImage.url"
|
|
132
|
-
:alt="currentImage
|
|
133
|
+
:alt="getTranslatedProperty(currentImage, 'alt') || 'Product image'"
|
|
133
134
|
/>
|
|
134
135
|
<!-- Placeholder -->
|
|
135
136
|
<img
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { encodeForQuery } from "@shopware/api-client/helpers";
|
|
2
3
|
import type { CmsElementProductDescriptionReviews } from "@shopware/composables";
|
|
3
4
|
import { useCmsTranslations } from "@shopware/composables";
|
|
4
5
|
import { getTranslatedProperty } from "@shopware/helpers";
|
|
@@ -56,18 +57,26 @@ const isSectionOpen = (sectionNumber: number) => {
|
|
|
56
57
|
};
|
|
57
58
|
|
|
58
59
|
const reviews: Ref<Schemas["ProductReview"][]> = ref([]);
|
|
59
|
-
const { apiClient } = useShopwareContext();
|
|
60
|
+
const { apiClient, cacheableReads } = useShopwareContext();
|
|
60
61
|
const { isLoggedIn } = useUser();
|
|
61
62
|
const reviewAdded = ref(false);
|
|
62
63
|
|
|
63
64
|
const fetchReviews = async () => {
|
|
64
65
|
try {
|
|
65
|
-
const reviewsResponse =
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
66
|
+
const reviewsResponse = cacheableReads
|
|
67
|
+
? await apiClient.invoke(
|
|
68
|
+
"readProductReviewsGet get /product/{productId}/reviews",
|
|
69
|
+
{
|
|
70
|
+
pathParams: { productId: product.value.id },
|
|
71
|
+
query: { _criteria: encodeForQuery({}) },
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
: await apiClient.invoke(
|
|
75
|
+
"readProductReviews post /product/{productId}/reviews",
|
|
76
|
+
{
|
|
77
|
+
pathParams: { productId: product.value.id },
|
|
78
|
+
},
|
|
79
|
+
);
|
|
71
80
|
reviews.value = reviewsResponse.data.elements || [];
|
|
72
81
|
} catch (error) {
|
|
73
82
|
console.error("Failed to fetch reviews:", error);
|
|
@@ -124,7 +133,7 @@ onMounted(async () => {
|
|
|
124
133
|
class="self-stretch text-surface-on-surface text-base font-normal leading-normal"
|
|
125
134
|
>
|
|
126
135
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
|
127
|
-
<div v-html="description"></div>
|
|
136
|
+
<div class="cms-element-text" v-html="description"></div>
|
|
128
137
|
</div>
|
|
129
138
|
</div>
|
|
130
139
|
</Transition>
|
|
@@ -233,7 +242,7 @@ onMounted(async () => {
|
|
|
233
242
|
:key="category.id"
|
|
234
243
|
class="mb-2"
|
|
235
244
|
>
|
|
236
|
-
{{ category
|
|
245
|
+
{{ getTranslatedProperty(category, "name") }}
|
|
237
246
|
</div>
|
|
238
247
|
</div>
|
|
239
248
|
<div v-else>No categories available</div>
|
|
@@ -1 +1,78 @@
|
|
|
1
1
|
Display a text. Html to Vue mechanism is used to render buttons, links, images accordingly as Vue elements
|
|
2
|
+
|
|
3
|
+
#### Styling CMS-authored HTML
|
|
4
|
+
|
|
5
|
+
The rendered container always carries the `cms-element-text` class. Because the
|
|
6
|
+
content comes from the Shopware admin editor, its tags (`h1`–`h6`, `ul`, `table`,
|
|
7
|
+
`img`, …) arrive without any classes, so they can only be reached with element
|
|
8
|
+
selectors — and those must never be declared globally, or they would also hit
|
|
9
|
+
product names, buttons and other markup you do control.
|
|
10
|
+
|
|
11
|
+
This layer ships that typography for you in
|
|
12
|
+
[`app/assets/css/rich-text.css`](../../../../assets/css/rich-text.css),
|
|
13
|
+
registered through `css: []` in the layer's `nuxt.config.ts`. It is plain CSS, so
|
|
14
|
+
it also works for apps that don't use UnoCSS. Nothing to wire up in your app.
|
|
15
|
+
|
|
16
|
+
##### Retheming
|
|
17
|
+
|
|
18
|
+
Every value reads from a `--rte-*` custom property with a built-in fallback, so
|
|
19
|
+
you only set the properties you want to change — no need to redeclare the rules:
|
|
20
|
+
|
|
21
|
+
```css
|
|
22
|
+
:root {
|
|
23
|
+
--rte-h1-size: 3rem;
|
|
24
|
+
--rte-h1-line: 3.25rem;
|
|
25
|
+
--rte-border-color: var(--color-outline-variant);
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
| Property | Default |
|
|
30
|
+
| --------------------------------- | ------------------------------- |
|
|
31
|
+
| `--rte-h1-size` / `--rte-h1-line` | `2.25rem` / `2.5rem` |
|
|
32
|
+
| `--rte-h2-size` / `--rte-h2-line` | `1.75rem` / `2rem` |
|
|
33
|
+
| `--rte-h3-size` / `--rte-h3-line` | `1.25rem` / `1.5rem` |
|
|
34
|
+
| `--rte-h4-size` / `--rte-h4-line` | `1.125rem` / `1.5rem` (h4–h6) |
|
|
35
|
+
| `--rte-heading-weight` | `600` |
|
|
36
|
+
| `--rte-heading-mb` | `10px` |
|
|
37
|
+
| `--rte-flow` | `1rem` (spacing between blocks) |
|
|
38
|
+
| `--rte-list-indent` | `40px` |
|
|
39
|
+
| `--rte-cell-padding` | `0.5rem 0.75rem` |
|
|
40
|
+
| `--rte-border-color` | `#e5e7eb` (tables, `hr`) |
|
|
41
|
+
|
|
42
|
+
Because the properties are inherited, you can also scope them contextually
|
|
43
|
+
instead of overriding selectors:
|
|
44
|
+
|
|
45
|
+
```css
|
|
46
|
+
.cms-block-image-text-cover .cms-element-text {
|
|
47
|
+
--rte-h1-size: 3.5rem;
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
##### Scope
|
|
52
|
+
|
|
53
|
+
The class is set everywhere admin-authored HTML is injected, so one rule set
|
|
54
|
+
covers all of them:
|
|
55
|
+
|
|
56
|
+
| Component | Classes |
|
|
57
|
+
| ---------------------------------------------- | ------------------------------------- |
|
|
58
|
+
| `CmsElementText` | `cms-element-text` |
|
|
59
|
+
| `CmsElementHtml` | `cms-element-html cms-element-text` |
|
|
60
|
+
| `CmsElementProductDescriptionReviews` | `cms-element-text` (description body) |
|
|
61
|
+
| `FrontendAccountCustomerGroupRegistrationPage` | `cms-element-text` (intro text) |
|
|
62
|
+
|
|
63
|
+
Add `cms-element-text` to your own wrappers if you render admin HTML elsewhere,
|
|
64
|
+
for example custom fields using the HTML editor.
|
|
65
|
+
|
|
66
|
+
##### Notes
|
|
67
|
+
|
|
68
|
+
The stylesheet uses `:where()` throughout, which keeps the specificity at
|
|
69
|
+
`(0,1,0)`. Two consequences worth knowing:
|
|
70
|
+
|
|
71
|
+
- A utility class or an inline style set by the editor still wins, without
|
|
72
|
+
`!important`.
|
|
73
|
+
- The rules beat a framework reset (`ul { list-style: none }`) no matter which
|
|
74
|
+
stylesheet is injected first, so the CSS order does not matter.
|
|
75
|
+
|
|
76
|
+
A `<style scoped>` block inside this component would _not_ work: the markup is
|
|
77
|
+
created by a render function, and Vue only applies the scope id to the container
|
|
78
|
+
root, not to its descendants.
|