@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,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 initalRoute = defu(route);
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
- (newRoute) => {
65
- if (initalRoute.path !== newRoute.path) {
66
- return;
67
- }
68
- if (Object.keys(newRoute.query).length > 0) {
69
- return;
70
- }
71
- // this fires to reset the page when query are removed/empty on client side navigation for the same page (without hard reload)
72
- changeCurrentPage(defaultPage, {
73
- limit: defaultLimit,
74
- p: defaultPage,
75
- order: defaultOrder,
76
- } as unknown as operations["searchPage post /search"]["body"]);
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 changeCurrentPage(
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 changeCurrentPage(
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<string>(() => {
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
- const rawHtml =
153
- mappedContent.value?.length > 0
154
- ? mappedContent.value
155
- : "<div class='missing-content-element'></div>";
156
-
157
- return () => renderHtml(rawHtml, config, h, context, resolveUrl);
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, section } = useCmsSection(props.content);
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(() => section.sizingMode === "full_width");
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
+ };