@shopware/cms-base-layer 4.0.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 +58 -43
- package/app/components/SwCategoryNavigationLink.vue +4 -5
- package/app/components/SwMedia3D.vue +8 -3
- package/app/components/SwProductAddToCart.vue +27 -4
- package/app/components/SwProductCard.vue +26 -4
- package/app/components/SwProductCardDetails.vue +9 -2
- package/app/components/SwProductListingFilter.vue +1 -1
- package/app/components/SwProductListingFilters.vue +10 -222
- package/app/components/SwProductListingFiltersHorizontal.vue +15 -202
- 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/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/CmsElementCategoryName.md +8 -0
- package/app/components/public/cms/element/CmsElementCategoryName.vue +35 -0
- package/app/components/public/cms/element/CmsElementProductListing.vue +58 -25
- package/app/components/public/cms/element/CmsElementText.vue +11 -10
- 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 +16 -0
- package/app/helpers/html-to-vue/renderer.ts +3 -3
- 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} +1 -1
- package/app/utils/{useSelectedListingFilters.ts → listingFilterState.ts} +0 -35
- package/package.json +10 -10
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CmsElementVideo,
|
|
3
|
+
MediaDisplayMode,
|
|
4
|
+
VerticalAlign,
|
|
5
|
+
} from "@shopware/composables";
|
|
6
|
+
import { getTranslatedProperty } from "@shopware/helpers";
|
|
7
|
+
|
|
8
|
+
type Alignment = Exclude<VerticalAlign, "">;
|
|
9
|
+
|
|
10
|
+
export type VideoElementOptions = {
|
|
11
|
+
src?: string;
|
|
12
|
+
mimeType?: string;
|
|
13
|
+
poster?: string;
|
|
14
|
+
displayMode: MediaDisplayMode;
|
|
15
|
+
minHeight?: string;
|
|
16
|
+
placeholderAspectRatio?: string;
|
|
17
|
+
verticalAlign?: Alignment;
|
|
18
|
+
horizontalAlign?: Alignment;
|
|
19
|
+
autoplay: boolean;
|
|
20
|
+
muted: boolean;
|
|
21
|
+
loop: boolean;
|
|
22
|
+
playsInline: boolean;
|
|
23
|
+
controls: boolean;
|
|
24
|
+
preload: "auto" | "none";
|
|
25
|
+
ariaLabel?: string;
|
|
26
|
+
title?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type VideoToggleState = {
|
|
30
|
+
isPlaying: boolean;
|
|
31
|
+
hasFailed: boolean;
|
|
32
|
+
videoId: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type VideoToggleLabels = {
|
|
36
|
+
playLabel: string;
|
|
37
|
+
pauseLabel: string;
|
|
38
|
+
loadError: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const DISPLAY_MODES: MediaDisplayMode[] = ["standard", "stretch", "cover"];
|
|
42
|
+
const ALIGNMENTS: Alignment[] = ["flex-start", "center", "flex-end"];
|
|
43
|
+
|
|
44
|
+
function getConfigValue<KEY extends keyof CmsElementVideo["config"]>(
|
|
45
|
+
element: CmsElementVideo,
|
|
46
|
+
key: KEY,
|
|
47
|
+
): CmsElementVideo["config"][KEY]["value"] | undefined {
|
|
48
|
+
const config = element.config?.[key];
|
|
49
|
+
|
|
50
|
+
return config?.source === "mapped" ? undefined : config?.value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function toAlignment(value: unknown): Alignment | undefined {
|
|
54
|
+
return ALIGNMENTS.find((alignment) => alignment === value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getVideoElementOptions(
|
|
58
|
+
element: CmsElementVideo,
|
|
59
|
+
): VideoElementOptions {
|
|
60
|
+
const media = element.data?.media ?? undefined;
|
|
61
|
+
const displayMode =
|
|
62
|
+
DISPLAY_MODES.find(
|
|
63
|
+
(mode) => mode === getConfigValue(element, "displayMode"),
|
|
64
|
+
) ?? "standard";
|
|
65
|
+
const isCover = displayMode === "cover";
|
|
66
|
+
const showCover = !!getConfigValue(element, "showCover");
|
|
67
|
+
const autoplay = !showCover && !!getConfigValue(element, "autoPlay");
|
|
68
|
+
const poster =
|
|
69
|
+
(showCover && media?.extensions?.videoCoverMedia?.url) || undefined;
|
|
70
|
+
const label =
|
|
71
|
+
element.config?.ariaLabel?.source === "mapped"
|
|
72
|
+
? ""
|
|
73
|
+
: (element.data?.ariaLabel ?? "").trim();
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
src: media?.url || undefined,
|
|
77
|
+
mimeType: media?.mimeType || undefined,
|
|
78
|
+
poster,
|
|
79
|
+
displayMode,
|
|
80
|
+
minHeight: (isCover && getConfigValue(element, "minHeight")) || undefined,
|
|
81
|
+
placeholderAspectRatio:
|
|
82
|
+
showCover && !poster && displayMode === "stretch"
|
|
83
|
+
? "auto 16 / 9"
|
|
84
|
+
: undefined,
|
|
85
|
+
verticalAlign: isCover
|
|
86
|
+
? undefined
|
|
87
|
+
: toAlignment(getConfigValue(element, "verticalAlign")),
|
|
88
|
+
horizontalAlign: isCover
|
|
89
|
+
? undefined
|
|
90
|
+
: toAlignment(getConfigValue(element, "horizontalAlign")),
|
|
91
|
+
autoplay,
|
|
92
|
+
muted: autoplay || !!getConfigValue(element, "muted"),
|
|
93
|
+
loop: !!getConfigValue(element, "loop"),
|
|
94
|
+
playsInline: !!getConfigValue(element, "playsInline"),
|
|
95
|
+
controls: !!getConfigValue(element, "showControls"),
|
|
96
|
+
preload: showCover ? "none" : "auto",
|
|
97
|
+
ariaLabel: label || getTranslatedProperty(media, "alt").trim() || undefined,
|
|
98
|
+
title: label || getTranslatedProperty(media, "title").trim() || undefined,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function getVideoAttributes(options: VideoElementOptions) {
|
|
103
|
+
return {
|
|
104
|
+
preload: options.preload,
|
|
105
|
+
poster: options.poster,
|
|
106
|
+
autoplay: options.autoplay,
|
|
107
|
+
muted: options.muted,
|
|
108
|
+
loop: options.loop,
|
|
109
|
+
playsinline: options.playsInline || undefined,
|
|
110
|
+
controls: options.controls,
|
|
111
|
+
"aria-label": options.ariaLabel,
|
|
112
|
+
title: options.title,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function getToggleLabel(
|
|
117
|
+
state: VideoToggleState,
|
|
118
|
+
labels: VideoToggleLabels,
|
|
119
|
+
): string {
|
|
120
|
+
if (state.hasFailed) return labels.loadError;
|
|
121
|
+
|
|
122
|
+
return state.isPlaying ? labels.pauseLabel : labels.playLabel;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function getVideoToggleAttributes(
|
|
126
|
+
options: VideoElementOptions,
|
|
127
|
+
state: VideoToggleState,
|
|
128
|
+
labels: VideoToggleLabels,
|
|
129
|
+
) {
|
|
130
|
+
if (options.controls) return {};
|
|
131
|
+
|
|
132
|
+
const label = getToggleLabel(state, labels);
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
role: "button",
|
|
136
|
+
tabindex: 0,
|
|
137
|
+
"aria-label": label,
|
|
138
|
+
"aria-describedby": options.ariaLabel ? state.videoId : undefined,
|
|
139
|
+
"aria-disabled": state.hasFailed || undefined,
|
|
140
|
+
title: (!state.hasFailed && options.title) || label,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -44,6 +44,22 @@ describe("renderHtml", () => {
|
|
|
44
44
|
expect(vnode.props?.class).toBeUndefined();
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
+
it("passes the attributes of a tag as flat props", () => {
|
|
48
|
+
const vnode = render(
|
|
49
|
+
'<table><tr><td colspan="2" id="total" class="sum" style="color:red">1</td></tr></table>',
|
|
50
|
+
);
|
|
51
|
+
const table = (vnode.children as { children: unknown[] }[])[0];
|
|
52
|
+
const row = table?.children[0] as { children: unknown[] };
|
|
53
|
+
const cell = row.children[0] as { props: Record<string, unknown> };
|
|
54
|
+
|
|
55
|
+
expect(cell.props).toEqual({
|
|
56
|
+
colspan: "2",
|
|
57
|
+
id: "total",
|
|
58
|
+
class: "sum",
|
|
59
|
+
style: "color:red",
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
47
63
|
it("keeps rendering the parsed children next to the container class", () => {
|
|
48
64
|
const vnode = render("<h2>headline</h2>", {
|
|
49
65
|
container: { type: "div", class: "cms-element-text" },
|
|
@@ -92,7 +92,6 @@ export function renderer(
|
|
|
92
92
|
return config.textTransformer(node.content); // return text
|
|
93
93
|
}
|
|
94
94
|
if (node.type === "tag") {
|
|
95
|
-
const transformedNode = getOptionsFromNode(node, resolveUrl);
|
|
96
95
|
const children: RawChildren[] = [];
|
|
97
96
|
for (const child of node.children) {
|
|
98
97
|
const rendered = _render(h, child);
|
|
@@ -104,8 +103,9 @@ export function renderer(
|
|
|
104
103
|
if (componentConfig !== undefined) {
|
|
105
104
|
return componentConfig.renderer(node, children, h, context);
|
|
106
105
|
}
|
|
107
|
-
// else, create normal html element
|
|
108
|
-
|
|
106
|
+
// else, create normal html element; Vue 3 takes attributes as flat props
|
|
107
|
+
const { attrs, ...options } = getOptionsFromNode(node, resolveUrl);
|
|
108
|
+
return h(node.name, { ...attrs, ...options }, [...children]);
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
return undefined;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { shouldShowBuyButton } from "./shouldShowBuyButton";
|
|
4
|
+
|
|
5
|
+
describe("shouldShowBuyButton", () => {
|
|
6
|
+
it("shows the button for a product without variants", () => {
|
|
7
|
+
expect(shouldShowBuyButton({ childCount: 0 })).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("shows the button when childCount is missing", () => {
|
|
11
|
+
expect(shouldShowBuyButton({})).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("hides the button for a variant parent", () => {
|
|
15
|
+
expect(shouldShowBuyButton({ childCount: 2 })).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("hides the button when there is a from price", () => {
|
|
19
|
+
expect(shouldShowBuyButton({ childCount: 0 }, true)).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
|
|
|
3
3
|
import {
|
|
4
4
|
applyQueryToFilters,
|
|
5
5
|
createEmptyFilterState,
|
|
6
|
-
} from "./
|
|
6
|
+
} from "./listingFilterState";
|
|
7
7
|
|
|
8
8
|
describe("applyQueryToFilters", () => {
|
|
9
9
|
it("populates sets from pipe-joined values", () => {
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import { reactive, watch } from "vue";
|
|
2
|
-
import type { UnwrapNestedRefs } from "vue";
|
|
3
|
-
import { useRoute } from "vue-router";
|
|
4
1
|
import type { LocationQuery } from "vue-router";
|
|
5
2
|
|
|
6
3
|
import { firstQueryValue, toNumber } from "./routeQuery";
|
|
@@ -77,35 +74,3 @@ export const applyQueryToFilters = (
|
|
|
77
74
|
state["shipping-free"] = shippingFree === "true";
|
|
78
75
|
}
|
|
79
76
|
};
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Returns the reactive filter state, populated from the current route.query at
|
|
83
|
-
* setup (runs on server AND client -> identical first render, no hydration
|
|
84
|
-
* mismatch) and kept in sync on every subsequent query-only navigation
|
|
85
|
-
* (Back/Forward, manual URL edits, the component's own executeSearch push).
|
|
86
|
-
*
|
|
87
|
-
* The watcher is intentionally NOT immediate: the setup-time apply already
|
|
88
|
-
* covers the first render, and an immediate watcher would re-run during
|
|
89
|
-
* hydration. It only resyncs UI state - it never navigates or fetches - so it
|
|
90
|
-
* cannot loop with executeSearch() nor cause an extra listing request.
|
|
91
|
-
*/
|
|
92
|
-
export const useSelectedListingFilters = (): UnwrapNestedRefs<FilterState> => {
|
|
93
|
-
const route = useRoute();
|
|
94
|
-
// Fresh state per call, deliberately: the sidebar and the horizontal filter
|
|
95
|
-
// bar each hold their own selection object, so a mutation in one is invisible
|
|
96
|
-
// to the other until it reaches the route. The URL is what synchronises them.
|
|
97
|
-
const state = reactive<FilterState>(createEmptyFilterState());
|
|
98
|
-
|
|
99
|
-
// Initial parse (SSR + first client render).
|
|
100
|
-
applyQueryToFilters(state as FilterState, route.query);
|
|
101
|
-
|
|
102
|
-
// Resync on query-only route changes while the component stays mounted.
|
|
103
|
-
watch(
|
|
104
|
-
() => route.query,
|
|
105
|
-
(query) => {
|
|
106
|
-
applyQueryToFilters(state as FilterState, query);
|
|
107
|
-
},
|
|
108
|
-
);
|
|
109
|
-
|
|
110
|
-
return state;
|
|
111
|
-
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shopware/cms-base-layer",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"description": "Vue CMS Nuxt Layer for Shopware",
|
|
5
5
|
"author": "Shopware",
|
|
6
6
|
"repository": {
|
|
@@ -32,34 +32,34 @@
|
|
|
32
32
|
"nuxt.config.ts"
|
|
33
33
|
],
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@iconify-json/carbon": "1.2.
|
|
35
|
+
"@iconify-json/carbon": "1.2.27",
|
|
36
36
|
"@nuxt/image": "2.1.0",
|
|
37
37
|
"@nuxt/kit": "4.5.2",
|
|
38
38
|
"@tresjs/cientos": "5.8.1",
|
|
39
39
|
"@tresjs/nuxt": "^5.6.3",
|
|
40
40
|
"@vuelidate/core": "2.0.3",
|
|
41
41
|
"@vuelidate/validators": "2.0.4",
|
|
42
|
-
"@vueuse/core": "
|
|
42
|
+
"@vueuse/core": "15.0.0",
|
|
43
43
|
"entities": "8.0.0",
|
|
44
44
|
"scule": "1.3.0",
|
|
45
45
|
"html-to-ast": "0.0.6",
|
|
46
46
|
"three": "0.185.1",
|
|
47
|
-
"vue": "3.5.
|
|
47
|
+
"vue": "3.5.43",
|
|
48
48
|
"xss": "1.0.15",
|
|
49
|
-
"@shopware/
|
|
50
|
-
"@shopware/
|
|
49
|
+
"@shopware/api-client": "1.7.0",
|
|
50
|
+
"@shopware/composables": "1.14.0",
|
|
51
51
|
"@shopware/helpers": "1.8.0"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@nuxt/schema": "4.5.2",
|
|
55
55
|
"@types/three": "0.182.0",
|
|
56
56
|
"@typescript/native-preview": "7.0.0-dev.20260205.1",
|
|
57
|
-
"@vitest/coverage-v8": "
|
|
57
|
+
"@vitest/coverage-v8": "5.0.0",
|
|
58
58
|
"nuxt": "4.5.2",
|
|
59
59
|
"typescript": "5.9.3",
|
|
60
|
-
"vitest": "
|
|
61
|
-
"vue-router": "5.
|
|
62
|
-
"vue-tsc": "3.3.
|
|
60
|
+
"vitest": "5.0.0",
|
|
61
|
+
"vue-router": "5.3.1",
|
|
62
|
+
"vue-tsc": "3.3.11",
|
|
63
63
|
"tsconfig": "0.0.0"
|
|
64
64
|
},
|
|
65
65
|
"scripts": {
|