@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,35 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type {
|
|
3
|
+
CmsElementCategoryName,
|
|
4
|
+
CmsElementText,
|
|
5
|
+
} from "@shopware/composables";
|
|
6
|
+
import { computed } from "vue";
|
|
7
|
+
|
|
8
|
+
import { getCategoryNameContent } from "../../../../helpers/cms/getCategoryNameContent";
|
|
9
|
+
|
|
10
|
+
const props = defineProps<{
|
|
11
|
+
content: CmsElementCategoryName;
|
|
12
|
+
}>();
|
|
13
|
+
|
|
14
|
+
const textContent = computed(
|
|
15
|
+
() =>
|
|
16
|
+
({
|
|
17
|
+
...props.content,
|
|
18
|
+
type: "text",
|
|
19
|
+
config: {
|
|
20
|
+
...props.content.config,
|
|
21
|
+
verticalAlign: {
|
|
22
|
+
source: "static",
|
|
23
|
+
value: props.content.config?.verticalAlign?.value ?? "",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
data: {
|
|
27
|
+
apiAlias: "cms_text",
|
|
28
|
+
content: getCategoryNameContent(props.content),
|
|
29
|
+
},
|
|
30
|
+
}) as CmsElementText,
|
|
31
|
+
);
|
|
32
|
+
</script>
|
|
33
|
+
<template>
|
|
34
|
+
<CmsElementText :content="textContent" class="cms-element-category-name" />
|
|
35
|
+
</template>
|
|
@@ -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(
|
|
@@ -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"),
|
|
@@ -149,12 +148,14 @@ const CmsTextRender = defineComponent({
|
|
|
149
148
|
},
|
|
150
149
|
},
|
|
151
150
|
};
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
151
|
+
return () =>
|
|
152
|
+
renderHtml(
|
|
153
|
+
mappedContent.value || "<div class='missing-content-element'></div>",
|
|
154
|
+
config,
|
|
155
|
+
h,
|
|
156
|
+
context,
|
|
157
|
+
resolveUrl,
|
|
158
|
+
);
|
|
158
159
|
},
|
|
159
160
|
});
|
|
160
161
|
</script>
|
|
@@ -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");
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { computed } from "vue";
|
|
2
|
+
import type { ComputedRef } from "vue";
|
|
3
|
+
import type { LocationQueryRaw } from "vue-router";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
firstQueryValue,
|
|
7
|
+
getVisibleListingFilters,
|
|
8
|
+
toNumber,
|
|
9
|
+
useCategoryListing,
|
|
10
|
+
useProductSearchListing,
|
|
11
|
+
useRoute,
|
|
12
|
+
useRouter,
|
|
13
|
+
useSelectedListingFilters,
|
|
14
|
+
} from "#imports";
|
|
15
|
+
import type { Schemas } from "#shopware";
|
|
16
|
+
|
|
17
|
+
/** Shared by the sidebar and horizontal filter components. */
|
|
18
|
+
export function useListingFilters(isProductSearch: boolean) {
|
|
19
|
+
const route = useRoute();
|
|
20
|
+
const router = useRouter();
|
|
21
|
+
|
|
22
|
+
const { getCurrentSortingOrder, getInitialFilters, getSortingOrders } =
|
|
23
|
+
isProductSearch ? useProductSearchListing() : useCategoryListing();
|
|
24
|
+
|
|
25
|
+
const selectedFilters = useSelectedListingFilters();
|
|
26
|
+
|
|
27
|
+
// Only the filters this listing can apply; the category filter is search-only.
|
|
28
|
+
const visibleFilters = computed(() =>
|
|
29
|
+
getVisibleListingFilters(getInitialFilters.value, { isProductSearch }),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const showResetFiltersButton = computed<boolean>(
|
|
33
|
+
() =>
|
|
34
|
+
selectedFilters.manufacturer.size !== 0 ||
|
|
35
|
+
selectedFilters.properties.size !== 0 ||
|
|
36
|
+
(isProductSearch && selectedFilters.categories.size !== 0) ||
|
|
37
|
+
!!selectedFilters["max-price"] ||
|
|
38
|
+
!!selectedFilters["min-price"] ||
|
|
39
|
+
!!selectedFilters.rating ||
|
|
40
|
+
!!selectedFilters["shipping-free"],
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const searchCriteriaForRequest: ComputedRef<
|
|
44
|
+
Schemas["ProductListingCriteria"]
|
|
45
|
+
> = computed(() => ({
|
|
46
|
+
manufacturer: [...(selectedFilters.manufacturer as Set<string>)]?.join("|"),
|
|
47
|
+
properties: [...(selectedFilters.properties as Set<string>)]?.join("|"),
|
|
48
|
+
"min-price": selectedFilters["min-price"] as number,
|
|
49
|
+
"max-price": selectedFilters["max-price"] as number,
|
|
50
|
+
order: getCurrentSortingOrder.value as string,
|
|
51
|
+
"shipping-free": selectedFilters["shipping-free"] as boolean,
|
|
52
|
+
rating: selectedFilters.rating as number,
|
|
53
|
+
search: isProductSearch ? (firstQueryValue(route.query.search) ?? "") : "",
|
|
54
|
+
limit: toNumber(firstQueryValue(route.query.limit)) ?? 15,
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
const executeSearch = async () => {
|
|
58
|
+
try {
|
|
59
|
+
const criteria = searchCriteriaForRequest.value;
|
|
60
|
+
const query: Record<string, unknown> = {};
|
|
61
|
+
|
|
62
|
+
if (criteria.manufacturer) query.manufacturer = criteria.manufacturer;
|
|
63
|
+
if (criteria.properties) query.properties = criteria.properties;
|
|
64
|
+
if (isProductSearch && selectedFilters.categories.size > 0)
|
|
65
|
+
query.categories = [
|
|
66
|
+
...(selectedFilters.categories as Set<string>),
|
|
67
|
+
].join("|");
|
|
68
|
+
if (criteria["min-price"]) query["min-price"] = criteria["min-price"];
|
|
69
|
+
if (criteria["max-price"]) query["max-price"] = criteria["max-price"];
|
|
70
|
+
if (criteria.rating) query.rating = criteria.rating;
|
|
71
|
+
if (criteria["shipping-free"])
|
|
72
|
+
query["shipping-free"] = criteria["shipping-free"];
|
|
73
|
+
if (criteria.order) query.order = criteria.order;
|
|
74
|
+
// Keep the search term so filtering does not reset the query.
|
|
75
|
+
if (isProductSearch && route.query.search)
|
|
76
|
+
query.search = route.query.search;
|
|
77
|
+
// Preserve the page size, which rebuilding the query would drop.
|
|
78
|
+
if (route.query.limit) query.limit = route.query.limit;
|
|
79
|
+
|
|
80
|
+
// URL first: gating it on the request lost the filter when the call
|
|
81
|
+
// failed. The listing refetches from the URL, so this is the whole job.
|
|
82
|
+
await router.push({ query: query as LocationQueryRaw });
|
|
83
|
+
} catch (error) {
|
|
84
|
+
console.error("Search execution failed:", error);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const handleFilterChange = async (event: {
|
|
89
|
+
code: string;
|
|
90
|
+
value: string | number | boolean;
|
|
91
|
+
}) => {
|
|
92
|
+
try {
|
|
93
|
+
const { code, value } = event;
|
|
94
|
+
|
|
95
|
+
if (
|
|
96
|
+
code === "manufacturer" ||
|
|
97
|
+
code === "properties" ||
|
|
98
|
+
code === "categories"
|
|
99
|
+
) {
|
|
100
|
+
const filterSet = selectedFilters[code];
|
|
101
|
+
const stringValue = String(value);
|
|
102
|
+
if (filterSet.has(stringValue)) filterSet.delete(stringValue);
|
|
103
|
+
else filterSet.add(stringValue);
|
|
104
|
+
} else if (code === "min-price" || code === "max-price") {
|
|
105
|
+
selectedFilters[code] =
|
|
106
|
+
typeof value === "number" ? value : Number(value);
|
|
107
|
+
} else if (code === "rating") {
|
|
108
|
+
selectedFilters.rating = Number(value);
|
|
109
|
+
} else if (code === "shipping-free") {
|
|
110
|
+
selectedFilters["shipping-free"] = Boolean(value);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await executeSearch();
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.error("Filter update failed:", error);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const clearFilters = () => {
|
|
120
|
+
(selectedFilters.manufacturer as Set<string>).clear();
|
|
121
|
+
(selectedFilters.properties as Set<string>).clear();
|
|
122
|
+
(selectedFilters.categories as Set<string>).clear();
|
|
123
|
+
selectedFilters["min-price"] = undefined;
|
|
124
|
+
selectedFilters["max-price"] = undefined;
|
|
125
|
+
selectedFilters.rating = undefined;
|
|
126
|
+
selectedFilters["shipping-free"] = undefined;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
async function invokeCleanFilters() {
|
|
130
|
+
try {
|
|
131
|
+
clearFilters();
|
|
132
|
+
await executeSearch();
|
|
133
|
+
} catch (error) {
|
|
134
|
+
console.error("Clear filters failed:", error);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const currentSortingOrder = computed({
|
|
139
|
+
get: (): string => getCurrentSortingOrder.value || "",
|
|
140
|
+
set: async (order: string): Promise<void> => {
|
|
141
|
+
try {
|
|
142
|
+
// Pushing is the whole job; the listing refetches from the URL.
|
|
143
|
+
await router.push({ query: { ...route.query, order } });
|
|
144
|
+
} catch (error) {
|
|
145
|
+
console.error("Sorting order change failed:", error);
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const handleSortChange = (sortKey: string) => {
|
|
151
|
+
currentSortingOrder.value = sortKey;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const handleRemoveFilterChip = async (chip: {
|
|
155
|
+
code: string;
|
|
156
|
+
value: string | number;
|
|
157
|
+
}) => {
|
|
158
|
+
if (
|
|
159
|
+
chip.code === "properties" ||
|
|
160
|
+
chip.code === "manufacturer" ||
|
|
161
|
+
chip.code === "categories"
|
|
162
|
+
) {
|
|
163
|
+
(selectedFilters[chip.code] as Set<string>).delete(String(chip.value));
|
|
164
|
+
} else if (chip.code === "price") {
|
|
165
|
+
selectedFilters["min-price"] = undefined;
|
|
166
|
+
selectedFilters["max-price"] = undefined;
|
|
167
|
+
} else if (chip.code === "rating") {
|
|
168
|
+
selectedFilters.rating = undefined;
|
|
169
|
+
} else if (chip.code === "shipping-free") {
|
|
170
|
+
selectedFilters["shipping-free"] = undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
await executeSearch();
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
currentSortingOrder,
|
|
178
|
+
getCurrentSortingOrder,
|
|
179
|
+
executeSearch,
|
|
180
|
+
getSortingOrders,
|
|
181
|
+
handleFilterChange,
|
|
182
|
+
handleRemoveFilterChip,
|
|
183
|
+
handleSortChange,
|
|
184
|
+
invokeCleanFilters,
|
|
185
|
+
selectedFilters,
|
|
186
|
+
showResetFiltersButton,
|
|
187
|
+
visibleFilters,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { reactive, watch } from "vue";
|
|
2
|
+
import type { UnwrapNestedRefs } from "vue";
|
|
3
|
+
|
|
4
|
+
// Nuxt's useRoute, never vue-router's: this layer does not depend on
|
|
5
|
+
// vue-router, so a bare import resolves to whichever copy the consumer's
|
|
6
|
+
// package manager hoisted. When that is not the copy Nuxt's router uses, its
|
|
7
|
+
// inject() returns undefined and every page with a listing filter 500s in SSR.
|
|
8
|
+
import {
|
|
9
|
+
applyQueryToFilters,
|
|
10
|
+
createEmptyFilterState,
|
|
11
|
+
useRoute,
|
|
12
|
+
} from "#imports";
|
|
13
|
+
|
|
14
|
+
import type { FilterState } from "../utils/listingFilterState";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Returns the reactive filter state, populated from the current route.query at
|
|
18
|
+
* setup (runs on server AND client -> identical first render, no hydration
|
|
19
|
+
* mismatch) and kept in sync on every subsequent query-only navigation
|
|
20
|
+
* (Back/Forward, manual URL edits, the component's own executeSearch push).
|
|
21
|
+
*
|
|
22
|
+
* The watcher is intentionally NOT immediate: the setup-time apply already
|
|
23
|
+
* covers the first render, and an immediate watcher would re-run during
|
|
24
|
+
* hydration. It only resyncs UI state - it never navigates or fetches - so it
|
|
25
|
+
* cannot loop with executeSearch() nor cause an extra listing request.
|
|
26
|
+
*/
|
|
27
|
+
export const useSelectedListingFilters = (): UnwrapNestedRefs<FilterState> => {
|
|
28
|
+
const route = useRoute();
|
|
29
|
+
// Fresh state per call, deliberately: the sidebar and the horizontal filter
|
|
30
|
+
// bar each hold their own selection object, so a mutation in one is invisible
|
|
31
|
+
// to the other until it reaches the route. The URL is what synchronises them.
|
|
32
|
+
const state = reactive<FilterState>(createEmptyFilterState());
|
|
33
|
+
|
|
34
|
+
// Initial parse (SSR + first client render).
|
|
35
|
+
applyQueryToFilters(state as FilterState, route.query);
|
|
36
|
+
|
|
37
|
+
// Resync on query-only route changes while the component stays mounted.
|
|
38
|
+
watch(
|
|
39
|
+
() => route.query,
|
|
40
|
+
(query) => {
|
|
41
|
+
applyQueryToFilters(state as FilterState, query);
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
return state;
|
|
46
|
+
};
|