@shopware/cms-base-layer 3.1.0 → 4.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 +79 -23
- package/app/app.config.ts +3 -3
- package/app/assets/css/rich-text.css +80 -0
- package/app/components/SwCategoryNavigationLink.vue +4 -5
- package/app/components/SwContactForm.vue +2 -1
- package/app/components/SwFilterChips.vue +29 -7
- package/app/components/SwMedia3D.vue +8 -3
- package/app/components/SwNewsletterForm.vue +2 -1
- package/app/components/SwProductAddToCart.vue +27 -4
- package/app/components/SwProductCard.vue +26 -4
- package/app/components/SwProductCardDetails.vue +9 -2
- package/app/components/SwProductCardImage.vue +5 -1
- package/app/components/SwProductListingFilter.vue +6 -1
- package/app/components/SwProductListingFilters.vue +13 -189
- package/app/components/SwProductListingFiltersHorizontal.vue +20 -172
- 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/CmsBlockSpatialViewer.vue +7 -7
- package/app/components/public/cms/CmsGenericBlock.vue +6 -10
- package/app/components/public/cms/CmsGenericElement.md +11 -5
- package/app/components/public/cms/CmsGenericElement.vue +17 -11
- package/app/components/public/cms/FrontendAccountCustomerGroupRegistrationPage.vue +1 -1
- package/app/components/public/cms/block/CmsBlockAppRenderer.vue +48 -0
- package/app/components/public/cms/block/CmsBlockCategoryHeading.vue +19 -0
- package/app/components/public/cms/block/CmsBlockCategoryNavigation.vue +3 -2
- package/app/components/public/cms/block/CmsBlockCenterText.vue +5 -4
- package/app/components/public/cms/block/CmsBlockCrossSelling.vue +3 -2
- package/app/components/public/cms/block/CmsBlockCustomForm.vue +3 -2
- package/app/components/public/cms/block/CmsBlockForm.vue +3 -2
- package/app/components/public/cms/block/CmsBlockGalleryBuybox.vue +4 -3
- package/app/components/public/cms/block/CmsBlockImage.vue +3 -2
- package/app/components/public/cms/block/CmsBlockImageBubbleRow.vue +5 -4
- package/app/components/public/cms/block/CmsBlockImageCover.vue +3 -2
- package/app/components/public/cms/block/CmsBlockImageFourColumn.vue +6 -5
- package/app/components/public/cms/block/CmsBlockImageGallery.vue +3 -2
- package/app/components/public/cms/block/CmsBlockImageGalleryBig.vue +3 -2
- package/app/components/public/cms/block/CmsBlockImageHighlightRow.vue +5 -4
- package/app/components/public/cms/block/CmsBlockImageSimpleGrid.vue +5 -4
- package/app/components/public/cms/block/CmsBlockImageSlider.vue +3 -2
- package/app/components/public/cms/block/CmsBlockImageText.vue +4 -3
- package/app/components/public/cms/block/CmsBlockImageTextBubble.vue +8 -7
- package/app/components/public/cms/block/CmsBlockImageTextCover.vue +4 -3
- package/app/components/public/cms/block/CmsBlockImageTextGallery.vue +39 -16
- package/app/components/public/cms/block/CmsBlockImageTextRow.vue +8 -7
- package/app/components/public/cms/block/CmsBlockImageThreeColumn.vue +5 -4
- package/app/components/public/cms/block/CmsBlockImageThreeCover.vue +5 -4
- package/app/components/public/cms/block/CmsBlockImageTwoColumn.vue +4 -3
- package/app/components/public/cms/block/CmsBlockProductDescriptionReviews.vue +3 -2
- package/app/components/public/cms/block/CmsBlockProductHeading.vue +4 -3
- package/app/components/public/cms/block/CmsBlockProductListing.vue +3 -2
- package/app/components/public/cms/block/CmsBlockProductThreeColumn.vue +5 -4
- package/app/components/public/cms/block/CmsBlockSidebarFilter.vue +7 -5
- package/app/components/public/cms/block/CmsBlockText.vue +3 -2
- package/app/components/public/cms/block/CmsBlockTextHero.vue +3 -2
- package/app/components/public/cms/block/CmsBlockTextOnImage.vue +3 -2
- package/app/components/public/cms/block/CmsBlockTextThreeColumn.vue +5 -4
- package/app/components/public/cms/block/CmsBlockTextTwoColumn.vue +4 -3
- package/app/components/public/cms/block/CmsBlockVideo.vue +19 -0
- package/app/components/public/cms/block/CmsBlockVimeoVideo.vue +3 -2
- package/app/components/public/cms/block/CmsBlockYoutubeVideo.vue +3 -2
- package/app/components/public/cms/element/CmsElementBuyBox.vue +4 -1
- package/app/components/public/cms/element/CmsElementCategoryName.md +8 -0
- package/app/components/public/cms/element/CmsElementCategoryName.vue +35 -0
- 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 -1
- package/app/components/public/cms/element/CmsElementImageGallery.vue +2 -1
- package/app/components/public/cms/element/CmsElementProductDescriptionReviews.vue +2 -2
- package/app/components/public/cms/element/CmsElementProductListing.vue +58 -25
- package/app/components/public/cms/element/CmsElementText.md +77 -0
- package/app/components/public/cms/element/CmsElementText.vue +15 -41
- package/app/components/public/cms/element/CmsElementVideo.md +27 -0
- package/app/components/public/cms/element/CmsElementVideo.vue +193 -0
- package/app/components/public/cms/section/CmsSectionSidebar.vue +4 -4
- package/app/composables/useListingFilters.ts +189 -0
- package/app/composables/useSelectedListingFilters.ts +46 -0
- package/app/helpers/cms/getAppRendererLayout.test.ts +93 -0
- package/app/helpers/cms/getAppRendererLayout.ts +36 -0
- package/app/helpers/cms/getCategoryNameContent.test.ts +57 -0
- package/app/helpers/cms/getCategoryNameContent.ts +15 -0
- package/app/helpers/cms/getTextElementContent.test.ts +79 -0
- package/app/helpers/cms/getTextElementContent.ts +37 -0
- package/app/helpers/cms/getVideoElementOptions.test.ts +384 -0
- package/app/helpers/cms/getVideoElementOptions.ts +142 -0
- package/app/helpers/html-to-vue/renderToHtml.test.ts +71 -0
- package/app/helpers/html-to-vue/renderToHtml.ts +5 -1
- package/app/helpers/html-to-vue/renderer.ts +13 -4
- package/app/helpers/product/shouldShowBuyButton.test.ts +21 -0
- package/app/helpers/product/shouldShowBuyButton.ts +6 -0
- package/app/utils/{useSelectedListingFilters.test.ts → listingFilterState.test.ts} +14 -4
- package/app/utils/{useSelectedListingFilters.ts → listingFilterState.ts} +4 -33
- package/app/utils/listingFilters.test.ts +39 -0
- package/app/utils/listingFilters.ts +19 -0
- package/component-dirs.json +20 -0
- package/index.d.ts +2 -2
- package/nuxt.config.ts +11 -28
- package/package.json +23 -26
- 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
|
@@ -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"
|
|
@@ -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
|
|
@@ -133,7 +133,7 @@ onMounted(async () => {
|
|
|
133
133
|
class="self-stretch text-surface-on-surface text-base font-normal leading-normal"
|
|
134
134
|
>
|
|
135
135
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
|
136
|
-
<div v-html="description"></div>
|
|
136
|
+
<div class="cms-element-text" v-html="description"></div>
|
|
137
137
|
</div>
|
|
138
138
|
</div>
|
|
139
139
|
</Transition>
|
|
@@ -242,7 +242,7 @@ onMounted(async () => {
|
|
|
242
242
|
:key="category.id"
|
|
243
243
|
class="mb-2"
|
|
244
244
|
>
|
|
245
|
-
{{ category
|
|
245
|
+
{{ getTranslatedProperty(category, "name") }}
|
|
246
246
|
</div>
|
|
247
247
|
</div>
|
|
248
248
|
<div v-else>No categories available</div>
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { CmsElementProductListing } from "@shopware/composables";
|
|
3
3
|
import { useCmsTranslations } from "@shopware/composables";
|
|
4
|
+
import { until } from "@vueuse/core";
|
|
4
5
|
import { defu } from "defu";
|
|
5
6
|
import { computed, ref, useTemplateRef, watch } from "vue";
|
|
7
|
+
import type { LocationQuery } from "vue-router";
|
|
6
8
|
|
|
7
9
|
import {
|
|
10
|
+
firstQueryValue,
|
|
11
|
+
toNumber,
|
|
8
12
|
useCategoryListing,
|
|
9
13
|
useCmsElementConfig,
|
|
10
14
|
useRoute,
|
|
@@ -58,26 +62,63 @@ const limit = ref(
|
|
|
58
62
|
: defaultLimit,
|
|
59
63
|
);
|
|
60
64
|
|
|
61
|
-
const
|
|
65
|
+
const initialPath = route.path;
|
|
66
|
+
|
|
67
|
+
/** Query values are strings; the Store API expects numbers and booleans. */
|
|
68
|
+
function buildCriteria(query: LocationQuery) {
|
|
69
|
+
const criteria: Record<string, unknown> = {
|
|
70
|
+
limit: toNumber(firstQueryValue(query.limit)) ?? defaultLimit,
|
|
71
|
+
p: toNumber(firstQueryValue(query.p)) ?? defaultPage,
|
|
72
|
+
order: firstQueryValue(query.order) ?? defaultOrder,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const manufacturer = firstQueryValue(query.manufacturer);
|
|
76
|
+
if (manufacturer) criteria.manufacturer = manufacturer;
|
|
77
|
+
const properties = firstQueryValue(query.properties);
|
|
78
|
+
if (properties) criteria.properties = properties;
|
|
79
|
+
const minPrice = toNumber(firstQueryValue(query["min-price"]));
|
|
80
|
+
if (minPrice !== undefined) criteria["min-price"] = minPrice;
|
|
81
|
+
const maxPrice = toNumber(firstQueryValue(query["max-price"]));
|
|
82
|
+
if (maxPrice !== undefined) criteria["max-price"] = maxPrice;
|
|
83
|
+
const rating = toNumber(firstQueryValue(query.rating));
|
|
84
|
+
if (rating !== undefined) criteria.rating = rating;
|
|
85
|
+
if (query["shipping-free"])
|
|
86
|
+
criteria["shipping-free"] =
|
|
87
|
+
firstQueryValue(query["shipping-free"]) === "true";
|
|
88
|
+
|
|
89
|
+
return criteria as unknown as operations["searchPage post /search"]["body"];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The only place that fetches, so back and forward work.
|
|
62
93
|
watch(
|
|
63
|
-
() => route,
|
|
64
|
-
(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
94
|
+
() => route.query,
|
|
95
|
+
(query) => {
|
|
96
|
+
// A different path mounts its own component.
|
|
97
|
+
if (route.path !== initialPath) return;
|
|
98
|
+
|
|
99
|
+
// The select and the skeleton count read this, so it has to follow the URL
|
|
100
|
+
// too, not just the products.
|
|
101
|
+
limit.value = toNumber(firstQueryValue(query.limit)) ?? defaultLimit;
|
|
102
|
+
|
|
103
|
+
// Caught, not discarded: the Store API drops calls often enough that an
|
|
104
|
+
// unhandled rejection here would be a routine occurrence.
|
|
105
|
+
changeCurrentPage(
|
|
106
|
+
toNumber(firstQueryValue(query.p)) ?? defaultPage,
|
|
107
|
+
buildCriteria(query),
|
|
108
|
+
).catch((error) => {
|
|
109
|
+
console.error("Listing update failed:", error);
|
|
110
|
+
});
|
|
77
111
|
},
|
|
78
112
|
{ deep: true },
|
|
79
113
|
);
|
|
80
114
|
|
|
115
|
+
// `v-if="!loading"` unmounts the list while the watcher refetches, so scrolling
|
|
116
|
+
// straight after the push would target a node that is about to be detached.
|
|
117
|
+
async function scrollToListing() {
|
|
118
|
+
await until(loading).toBe(false);
|
|
119
|
+
productListElement.value?.scrollIntoView({ behavior: "smooth" });
|
|
120
|
+
}
|
|
121
|
+
|
|
81
122
|
const changePage = async (page: number) => {
|
|
82
123
|
await router.push({
|
|
83
124
|
query: {
|
|
@@ -86,11 +127,7 @@ const changePage = async (page: number) => {
|
|
|
86
127
|
limit: limit.value,
|
|
87
128
|
},
|
|
88
129
|
});
|
|
89
|
-
await
|
|
90
|
-
page,
|
|
91
|
-
route.query as unknown as operations["searchPage post /search"]["body"],
|
|
92
|
-
);
|
|
93
|
-
productListElement.value?.scrollIntoView({ behavior: "smooth" });
|
|
130
|
+
await scrollToListing();
|
|
94
131
|
};
|
|
95
132
|
|
|
96
133
|
const changeLimit = async (newLimit: number) => {
|
|
@@ -101,11 +138,7 @@ const changeLimit = async (newLimit: number) => {
|
|
|
101
138
|
p: defaultPage,
|
|
102
139
|
},
|
|
103
140
|
});
|
|
104
|
-
await
|
|
105
|
-
defaultPage,
|
|
106
|
-
route.query as unknown as operations["searchPage post /search"]["body"],
|
|
107
|
-
);
|
|
108
|
-
productListElement.value?.scrollIntoView({ behavior: "smooth" });
|
|
141
|
+
await scrollToListing();
|
|
109
142
|
};
|
|
110
143
|
|
|
111
144
|
const isProductListing = computed(
|
|
@@ -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.
|
|
@@ -6,6 +6,7 @@ import type { CSSProperties, VNode, VNodeArrayChildren } from "vue";
|
|
|
6
6
|
|
|
7
7
|
import { useCmsElementConfig, useUrlResolver } from "#imports";
|
|
8
8
|
|
|
9
|
+
import { getTextElementContent } from "../../../../helpers/cms/getTextElementContent";
|
|
9
10
|
import { getOptionsFromNode } from "../../../../helpers/html-to-vue/getOptionsFromNode";
|
|
10
11
|
import type { NodeObject } from "../../../../helpers/html-to-vue/getOptionsFromNode";
|
|
11
12
|
import { renderHtml } from "../../../../helpers/html-to-vue/renderToHtml";
|
|
@@ -15,11 +16,9 @@ const props = defineProps<{
|
|
|
15
16
|
content: CmsElementText;
|
|
16
17
|
}>();
|
|
17
18
|
const context = getCurrentInstance();
|
|
18
|
-
const { getConfigValue } = useCmsElementConfig(props.content);
|
|
19
|
+
const { getConfigValue } = useCmsElementConfig(() => props.content);
|
|
19
20
|
|
|
20
|
-
const mappedContent = computed
|
|
21
|
-
return props.content?.data?.content || getConfigValue("content");
|
|
22
|
-
});
|
|
21
|
+
const mappedContent = computed(() => getTextElementContent(props.content));
|
|
23
22
|
|
|
24
23
|
const style = computed<CSSProperties>(() => ({
|
|
25
24
|
alignContent: getConfigValue("verticalAlign"),
|
|
@@ -32,6 +31,10 @@ const CmsTextRender = defineComponent({
|
|
|
32
31
|
const { resolveUrl } = useUrlResolver();
|
|
33
32
|
|
|
34
33
|
const config = {
|
|
34
|
+
container: {
|
|
35
|
+
type: "div",
|
|
36
|
+
class: "cms-element-text",
|
|
37
|
+
},
|
|
35
38
|
textTransformer: (text: string) => decodeHTML(text),
|
|
36
39
|
extraComponentsMap: {
|
|
37
40
|
link: {
|
|
@@ -145,12 +148,14 @@ const CmsTextRender = defineComponent({
|
|
|
145
148
|
},
|
|
146
149
|
},
|
|
147
150
|
};
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
151
|
+
return () =>
|
|
152
|
+
renderHtml(
|
|
153
|
+
mappedContent.value || "<div class='missing-content-element'></div>",
|
|
154
|
+
config,
|
|
155
|
+
h,
|
|
156
|
+
context,
|
|
157
|
+
resolveUrl,
|
|
158
|
+
);
|
|
154
159
|
},
|
|
155
160
|
});
|
|
156
161
|
</script>
|
|
@@ -160,34 +165,3 @@ const CmsTextRender = defineComponent({
|
|
|
160
165
|
</div>
|
|
161
166
|
<CmsTextRender v-else />
|
|
162
167
|
</template>
|
|
163
|
-
<style scoped>
|
|
164
|
-
/** Global CSS styles for text elements */
|
|
165
|
-
h1,
|
|
166
|
-
h2,
|
|
167
|
-
h3,
|
|
168
|
-
h4,
|
|
169
|
-
h5 {
|
|
170
|
-
margin-bottom: 10px;
|
|
171
|
-
font-weight: 600;
|
|
172
|
-
}
|
|
173
|
-
h1 {
|
|
174
|
-
line-height: 2.5rem;
|
|
175
|
-
font-size: 2.25rem;
|
|
176
|
-
}
|
|
177
|
-
h2 {
|
|
178
|
-
line-height: 2rem;
|
|
179
|
-
font-size: 1.75rem;
|
|
180
|
-
}
|
|
181
|
-
h3 {
|
|
182
|
-
line-height: 1.5rem;
|
|
183
|
-
font-size: 1.25rem;
|
|
184
|
-
}
|
|
185
|
-
ol,
|
|
186
|
-
ul,
|
|
187
|
-
dl {
|
|
188
|
-
list-style-type: disc;
|
|
189
|
-
padding-left: 40px;
|
|
190
|
-
margin-top: 0;
|
|
191
|
-
margin-bottom: 1rem;
|
|
192
|
-
}
|
|
193
|
-
</style>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Play a video uploaded to the Shopware media library, in the `video` block. YouTube and Vimeo videos have their own elements.
|
|
2
|
+
|
|
3
|
+
Every option of the element in the Administration is supported:
|
|
4
|
+
|
|
5
|
+
| Option | Behaviour |
|
|
6
|
+
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
7
|
+
| Video | Static or mapped media. Nothing is rendered when it does not resolve. |
|
|
8
|
+
| Display mode | `standard` keeps the video's size, `stretch` makes it full width, `cover` fills the element and crops the video. |
|
|
9
|
+
| Minimum height | Applies to `cover` only. |
|
|
10
|
+
| Vertical / horizontal align | Positions a `standard` or `stretch` video. Ignored for `cover`. |
|
|
11
|
+
| Play automatically | Also mutes the video, because browsers only autoplay muted videos. |
|
|
12
|
+
| Play muted, Play in a loop | Set `muted` and `loop`. |
|
|
13
|
+
| Play inline on iOS devices | Sets `playsinline`. |
|
|
14
|
+
| Show controls | Shows the native controls. Without them the whole element is a play/pause button with a play icon, operable by keyboard too. |
|
|
15
|
+
| Load only after confirmation | Shows the cover image set for the video in the media module and loads nothing until playback starts. Turns autoplay off. Without a cover image, a `stretch` video keeps a 16:9 box until it loads. |
|
|
16
|
+
| Screen reader title | Names the video, falling back to the media alt text (and title for the tooltip). |
|
|
17
|
+
|
|
18
|
+
Without controls, the button's name always says what it does: play or pause. The video's name is announced as its description. A video that cannot be loaded, for example in a format the browser does not play or with a missing file, shows an error in place of the play icon and names the button after the error.
|
|
19
|
+
|
|
20
|
+
The texts can be translated through `cmsTranslations`:
|
|
21
|
+
|
|
22
|
+
| Key | Default |
|
|
23
|
+
| ------------------------ | ---------------------------------------------------- |
|
|
24
|
+
| `cms.video.playLabel` | `Play video` |
|
|
25
|
+
| `cms.video.pauseLabel` | `Pause video` |
|
|
26
|
+
| `cms.video.loadError` | `The video could not be loaded.` |
|
|
27
|
+
| `cms.video.notSupported` | `Your browser does not support the HTML5 video tag.` |
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { CmsElementVideo } from "@shopware/composables";
|
|
3
|
+
import { defu } from "defu";
|
|
4
|
+
import { computed, onMounted, ref, useId, useTemplateRef } from "vue";
|
|
5
|
+
|
|
6
|
+
import { useCmsTranslations } from "#imports";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
getVideoAttributes,
|
|
10
|
+
getVideoElementOptions,
|
|
11
|
+
getVideoToggleAttributes,
|
|
12
|
+
} from "../../../../helpers/cms/getVideoElementOptions";
|
|
13
|
+
|
|
14
|
+
const props = defineProps<{
|
|
15
|
+
content: CmsElementVideo;
|
|
16
|
+
}>();
|
|
17
|
+
|
|
18
|
+
type Translations = {
|
|
19
|
+
cms: {
|
|
20
|
+
video: {
|
|
21
|
+
playLabel: string;
|
|
22
|
+
pauseLabel: string;
|
|
23
|
+
loadError: string;
|
|
24
|
+
notSupported: string;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
let translations: Translations = {
|
|
30
|
+
cms: {
|
|
31
|
+
video: {
|
|
32
|
+
playLabel: "Play video",
|
|
33
|
+
pauseLabel: "Pause video",
|
|
34
|
+
loadError: "The video could not be loaded.",
|
|
35
|
+
notSupported: "Your browser does not support the HTML5 video tag.",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
translations = defu(useCmsTranslations(), translations) as Translations;
|
|
41
|
+
|
|
42
|
+
const JUSTIFY_CLASSES = {
|
|
43
|
+
"flex-start": "justify-start",
|
|
44
|
+
center: "justify-center",
|
|
45
|
+
"flex-end": "justify-end",
|
|
46
|
+
} as const;
|
|
47
|
+
|
|
48
|
+
const ALIGN_CLASSES = {
|
|
49
|
+
"flex-start": "self-start",
|
|
50
|
+
center: "self-center",
|
|
51
|
+
"flex-end": "self-end",
|
|
52
|
+
} as const;
|
|
53
|
+
|
|
54
|
+
const options = computed(() => getVideoElementOptions(props.content));
|
|
55
|
+
const videoAttributes = computed(() => getVideoAttributes(options.value));
|
|
56
|
+
|
|
57
|
+
const videoId = useId();
|
|
58
|
+
const videoElement = useTemplateRef<HTMLVideoElement>("videoElement");
|
|
59
|
+
const isPlaying = ref(false);
|
|
60
|
+
const hasFailed = ref(false);
|
|
61
|
+
|
|
62
|
+
function syncPlayingState() {
|
|
63
|
+
const video = videoElement.value;
|
|
64
|
+
isPlaying.value = !!video && !video.paused && !video.ended;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function markFailed() {
|
|
68
|
+
hasFailed.value = true;
|
|
69
|
+
videoElement.value?.pause();
|
|
70
|
+
isPlaying.value = false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function togglePlayback() {
|
|
74
|
+
const video = videoElement.value;
|
|
75
|
+
if (!video || hasFailed.value) return;
|
|
76
|
+
|
|
77
|
+
if (video.error || video.networkState === video.NETWORK_NO_SOURCE) {
|
|
78
|
+
markFailed();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (video.paused || video.ended) {
|
|
83
|
+
video.play()?.catch((error: unknown) => {
|
|
84
|
+
if ((error as DOMException | undefined)?.name === "NotSupportedError") {
|
|
85
|
+
markFailed();
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
video.pause();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function onKeydown(event: KeyboardEvent) {
|
|
95
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
96
|
+
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
if (event.repeat) return;
|
|
99
|
+
|
|
100
|
+
togglePlayback();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const toggleAttrs = computed(() =>
|
|
104
|
+
getVideoToggleAttributes(
|
|
105
|
+
options.value,
|
|
106
|
+
{ isPlaying: isPlaying.value, hasFailed: hasFailed.value, videoId },
|
|
107
|
+
translations.cms.video,
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const toggleListeners = computed(() =>
|
|
112
|
+
options.value.controls ? {} : { click: togglePlayback, keydown: onKeydown },
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
onMounted(syncPlayingState);
|
|
116
|
+
</script>
|
|
117
|
+
<template>
|
|
118
|
+
<div
|
|
119
|
+
v-if="options.src"
|
|
120
|
+
class="cms-element-video relative h-full"
|
|
121
|
+
:class="[
|
|
122
|
+
`is-${options.displayMode}`,
|
|
123
|
+
options.displayMode !== 'cover' && 'flex',
|
|
124
|
+
options.horizontalAlign && JUSTIFY_CLASSES[options.horizontalAlign],
|
|
125
|
+
]"
|
|
126
|
+
v-bind="toggleAttrs"
|
|
127
|
+
v-on="toggleListeners"
|
|
128
|
+
>
|
|
129
|
+
<div
|
|
130
|
+
class="cms-element-alignment min-w-0 max-w-full"
|
|
131
|
+
:class="[
|
|
132
|
+
options.verticalAlign && ALIGN_CLASSES[options.verticalAlign],
|
|
133
|
+
options.displayMode !== 'standard' && 'w-full',
|
|
134
|
+
options.displayMode === 'cover' && 'h-full',
|
|
135
|
+
]"
|
|
136
|
+
>
|
|
137
|
+
<div
|
|
138
|
+
class="cms-video-container relative w-full"
|
|
139
|
+
:class="{ 'h-full': options.displayMode === 'cover' }"
|
|
140
|
+
:style="
|
|
141
|
+
options.minHeight ? { minHeight: options.minHeight } : undefined
|
|
142
|
+
"
|
|
143
|
+
>
|
|
144
|
+
<span
|
|
145
|
+
v-if="!options.controls && !hasFailed"
|
|
146
|
+
class="cms-video-play-icon pointer-events-none absolute left-1/2 top-1/2 z-2 flex h-16 w-16 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-black/60 text-white transition-opacity duration-200"
|
|
147
|
+
:class="isPlaying ? 'opacity-0' : 'opacity-100'"
|
|
148
|
+
aria-hidden="true"
|
|
149
|
+
>
|
|
150
|
+
<span class="i-carbon-play-filled-alt h-8 w-8" />
|
|
151
|
+
</span>
|
|
152
|
+
<span
|
|
153
|
+
v-if="hasFailed"
|
|
154
|
+
class="cms-video-error pointer-events-none absolute inset-0 z-2 flex items-center justify-center p-4"
|
|
155
|
+
>
|
|
156
|
+
<span
|
|
157
|
+
class="rounded bg-black/60 px-3 py-2 text-center text-sm text-white"
|
|
158
|
+
>
|
|
159
|
+
{{ translations.cms.video.loadError }}
|
|
160
|
+
</span>
|
|
161
|
+
</span>
|
|
162
|
+
<video
|
|
163
|
+
:id="videoId"
|
|
164
|
+
ref="videoElement"
|
|
165
|
+
class="cms-video block max-w-full"
|
|
166
|
+
:class="{
|
|
167
|
+
'w-full': options.displayMode !== 'standard',
|
|
168
|
+
'absolute inset-0 h-full object-cover':
|
|
169
|
+
options.displayMode === 'cover',
|
|
170
|
+
'cursor-pointer': !options.controls && !hasFailed,
|
|
171
|
+
}"
|
|
172
|
+
:style="
|
|
173
|
+
options.placeholderAspectRatio
|
|
174
|
+
? { aspectRatio: options.placeholderAspectRatio }
|
|
175
|
+
: undefined
|
|
176
|
+
"
|
|
177
|
+
v-bind="videoAttributes"
|
|
178
|
+
@play="syncPlayingState"
|
|
179
|
+
@pause="syncPlayingState"
|
|
180
|
+
@ended="syncPlayingState"
|
|
181
|
+
@error="markFailed"
|
|
182
|
+
>
|
|
183
|
+
<source
|
|
184
|
+
:src="options.src"
|
|
185
|
+
:type="options.mimeType"
|
|
186
|
+
@error="markFailed"
|
|
187
|
+
/>
|
|
188
|
+
{{ translations.cms.video.notSupported }}
|
|
189
|
+
</video>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
</template>
|
|
@@ -6,12 +6,12 @@ import { computed, provide } from "vue";
|
|
|
6
6
|
const props = defineProps<{
|
|
7
7
|
content: CmsSectionSidebar;
|
|
8
8
|
}>();
|
|
9
|
-
const { getPositionContent
|
|
9
|
+
const { getPositionContent } = useCmsSection(() => props.content);
|
|
10
10
|
|
|
11
|
-
const sidebarBlocks = getPositionContent("sidebar");
|
|
12
|
-
const mainBlocks = getPositionContent("main");
|
|
11
|
+
const sidebarBlocks = computed(() => getPositionContent("sidebar"));
|
|
12
|
+
const mainBlocks = computed(() => getPositionContent("main"));
|
|
13
13
|
const mobileBehavior = computed(() => props.content.mobileBehavior);
|
|
14
|
-
const fullWidth = computed(() =>
|
|
14
|
+
const fullWidth = computed(() => props.content.sizingMode === "full_width");
|
|
15
15
|
|
|
16
16
|
// Provide layout context for child components
|
|
17
17
|
provide("cms-section-layout", "sidebar");
|