@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.
Files changed (75) hide show
  1. package/README.md +58 -43
  2. package/app/components/SwCategoryNavigationLink.vue +4 -5
  3. package/app/components/SwMedia3D.vue +8 -3
  4. package/app/components/SwProductAddToCart.vue +27 -4
  5. package/app/components/SwProductCard.vue +26 -4
  6. package/app/components/SwProductCardDetails.vue +9 -2
  7. package/app/components/SwProductListingFilter.vue +1 -1
  8. package/app/components/SwProductListingFilters.vue +10 -222
  9. package/app/components/SwProductListingFiltersHorizontal.vue +15 -202
  10. package/app/components/public/cms/CmsBlockSpatialViewer.vue +7 -7
  11. package/app/components/public/cms/CmsGenericBlock.vue +6 -10
  12. package/app/components/public/cms/CmsGenericElement.md +11 -5
  13. package/app/components/public/cms/CmsGenericElement.vue +17 -11
  14. package/app/components/public/cms/block/CmsBlockAppRenderer.vue +48 -0
  15. package/app/components/public/cms/block/CmsBlockCategoryHeading.vue +19 -0
  16. package/app/components/public/cms/block/CmsBlockCategoryNavigation.vue +3 -2
  17. package/app/components/public/cms/block/CmsBlockCenterText.vue +5 -4
  18. package/app/components/public/cms/block/CmsBlockCrossSelling.vue +3 -2
  19. package/app/components/public/cms/block/CmsBlockCustomForm.vue +3 -2
  20. package/app/components/public/cms/block/CmsBlockForm.vue +3 -2
  21. package/app/components/public/cms/block/CmsBlockGalleryBuybox.vue +4 -3
  22. package/app/components/public/cms/block/CmsBlockImage.vue +3 -2
  23. package/app/components/public/cms/block/CmsBlockImageBubbleRow.vue +5 -4
  24. package/app/components/public/cms/block/CmsBlockImageCover.vue +3 -2
  25. package/app/components/public/cms/block/CmsBlockImageFourColumn.vue +6 -5
  26. package/app/components/public/cms/block/CmsBlockImageGallery.vue +3 -2
  27. package/app/components/public/cms/block/CmsBlockImageGalleryBig.vue +3 -2
  28. package/app/components/public/cms/block/CmsBlockImageHighlightRow.vue +5 -4
  29. package/app/components/public/cms/block/CmsBlockImageSimpleGrid.vue +5 -4
  30. package/app/components/public/cms/block/CmsBlockImageSlider.vue +3 -2
  31. package/app/components/public/cms/block/CmsBlockImageText.vue +4 -3
  32. package/app/components/public/cms/block/CmsBlockImageTextBubble.vue +8 -7
  33. package/app/components/public/cms/block/CmsBlockImageTextCover.vue +4 -3
  34. package/app/components/public/cms/block/CmsBlockImageTextGallery.vue +39 -16
  35. package/app/components/public/cms/block/CmsBlockImageTextRow.vue +8 -7
  36. package/app/components/public/cms/block/CmsBlockImageThreeColumn.vue +5 -4
  37. package/app/components/public/cms/block/CmsBlockImageThreeCover.vue +5 -4
  38. package/app/components/public/cms/block/CmsBlockImageTwoColumn.vue +4 -3
  39. package/app/components/public/cms/block/CmsBlockProductDescriptionReviews.vue +3 -2
  40. package/app/components/public/cms/block/CmsBlockProductHeading.vue +4 -3
  41. package/app/components/public/cms/block/CmsBlockProductListing.vue +3 -2
  42. package/app/components/public/cms/block/CmsBlockProductThreeColumn.vue +5 -4
  43. package/app/components/public/cms/block/CmsBlockSidebarFilter.vue +7 -5
  44. package/app/components/public/cms/block/CmsBlockText.vue +3 -2
  45. package/app/components/public/cms/block/CmsBlockTextHero.vue +3 -2
  46. package/app/components/public/cms/block/CmsBlockTextOnImage.vue +3 -2
  47. package/app/components/public/cms/block/CmsBlockTextThreeColumn.vue +5 -4
  48. package/app/components/public/cms/block/CmsBlockTextTwoColumn.vue +4 -3
  49. package/app/components/public/cms/block/CmsBlockVideo.vue +19 -0
  50. package/app/components/public/cms/block/CmsBlockVimeoVideo.vue +3 -2
  51. package/app/components/public/cms/block/CmsBlockYoutubeVideo.vue +3 -2
  52. package/app/components/public/cms/element/CmsElementCategoryName.md +8 -0
  53. package/app/components/public/cms/element/CmsElementCategoryName.vue +35 -0
  54. package/app/components/public/cms/element/CmsElementProductListing.vue +58 -25
  55. package/app/components/public/cms/element/CmsElementText.vue +11 -10
  56. package/app/components/public/cms/element/CmsElementVideo.md +27 -0
  57. package/app/components/public/cms/element/CmsElementVideo.vue +193 -0
  58. package/app/components/public/cms/section/CmsSectionSidebar.vue +4 -4
  59. package/app/composables/useListingFilters.ts +189 -0
  60. package/app/composables/useSelectedListingFilters.ts +46 -0
  61. package/app/helpers/cms/getAppRendererLayout.test.ts +93 -0
  62. package/app/helpers/cms/getAppRendererLayout.ts +36 -0
  63. package/app/helpers/cms/getCategoryNameContent.test.ts +57 -0
  64. package/app/helpers/cms/getCategoryNameContent.ts +15 -0
  65. package/app/helpers/cms/getTextElementContent.test.ts +79 -0
  66. package/app/helpers/cms/getTextElementContent.ts +37 -0
  67. package/app/helpers/cms/getVideoElementOptions.test.ts +384 -0
  68. package/app/helpers/cms/getVideoElementOptions.ts +142 -0
  69. package/app/helpers/html-to-vue/renderToHtml.test.ts +16 -0
  70. package/app/helpers/html-to-vue/renderer.ts +3 -3
  71. package/app/helpers/product/shouldShowBuyButton.test.ts +21 -0
  72. package/app/helpers/product/shouldShowBuyButton.ts +6 -0
  73. package/app/utils/{useSelectedListingFilters.test.ts → listingFilterState.test.ts} +1 -1
  74. package/app/utils/{useSelectedListingFilters.ts → listingFilterState.ts} +0 -35
  75. 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
- return h(node.name, transformedNode, [...children]);
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
+ });
@@ -0,0 +1,6 @@
1
+ export function shouldShowBuyButton(
2
+ product: { childCount?: number } | undefined,
3
+ displayFrom = false,
4
+ ): boolean {
5
+ return !displayFrom && (product?.childCount ?? 0) <= 0;
6
+ }
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
3
3
  import {
4
4
  applyQueryToFilters,
5
5
  createEmptyFilterState,
6
- } from "./useSelectedListingFilters";
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.0.0",
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.26",
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": "14.4.0",
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.42",
47
+ "vue": "3.5.43",
48
48
  "xss": "1.0.15",
49
- "@shopware/composables": "1.13.0",
50
- "@shopware/api-client": "1.6.0",
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": "4.1.10",
57
+ "@vitest/coverage-v8": "5.0.0",
58
58
  "nuxt": "4.5.2",
59
59
  "typescript": "5.9.3",
60
- "vitest": "4.1.10",
61
- "vue-router": "5.2.0",
62
- "vue-tsc": "3.3.9",
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": {