@shopware/cms-base-layer 3.0.1 → 3.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 +30 -8
- package/app/components/SwProductCard.vue +19 -3
- package/app/components/SwProductListingFilters.vue +36 -70
- package/app/components/SwProductListingFiltersHorizontal.vue +36 -70
- package/app/components/public/cms/CmsGenericBlock.vue +1 -1
- package/app/components/public/cms/CmsGenericElement.vue +1 -1
- package/app/components/public/cms/CmsNoComponent.vue +1 -1
- package/app/components/public/cms/block/CmsBlockCenterText.vue +2 -1
- package/app/components/public/cms/block/CmsBlockImageBubbleRow.vue +5 -1
- package/app/components/public/cms/block/CmsBlockImageFourColumn.vue +6 -2
- package/app/components/public/cms/block/CmsBlockImageGalleryBig.vue +9 -5
- package/app/components/public/cms/block/CmsBlockImageHighlightRow.vue +7 -2
- package/app/components/public/cms/block/CmsBlockImageTextRow.vue +9 -3
- package/app/components/public/cms/block/CmsBlockImageThreeCover.vue +7 -2
- package/app/components/public/cms/block/CmsBlockImageTwoColumn.vue +7 -3
- package/app/components/public/cms/block/CmsBlockProductHeading.vue +2 -1
- package/app/components/public/cms/element/CmsElementImage.vue +0 -1
- package/app/components/public/cms/element/CmsElementProductDescriptionReviews.vue +16 -7
- package/app/utils/routeQuery.test.ts +65 -0
- package/app/utils/routeQuery.ts +26 -0
- package/app/utils/useSelectedListingFilters.test.ts +82 -0
- package/app/utils/useSelectedListingFilters.ts +105 -0
- package/nuxt.config.ts +24 -1
- package/package.json +14 -15
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://github.com/shopware/frontends/issues?q=is%3Aopen+is%3Aissue+label%3Acms-base)
|
|
6
6
|
[](#)
|
|
7
7
|
|
|
8
|
-
Nuxt [layer](https://nuxt.com/docs/getting-started/layers) that provides an implementation of all CMS components in Shopware [based on utility-classes](https://
|
|
8
|
+
Nuxt [layer](https://nuxt.com/docs/getting-started/layers) that provides an implementation of all CMS components in Shopware [based on utility-classes](https://developer.shopware.com/frontends/concepts/styling.html).
|
|
9
9
|
|
|
10
10
|
It is useful for projects that want to use the CMS components while keeping CMS functionality separate from the styling system and design tokens.
|
|
11
11
|
|
|
@@ -90,7 +90,7 @@ Since all CMS components are registered in your Nuxt application, you can now st
|
|
|
90
90
|
|
|
91
91
|
> `@shopware/cms-base-layer` no longer owns the default UnoCSS theme. If you want the shared Shopware Frontends design tokens and UnoCSS defaults, extend `@shopware/unocss-design-tokens-layer` as shown above.
|
|
92
92
|
|
|
93
|
-
See a [short guide](https://
|
|
93
|
+
See a [short guide](https://developer.shopware.com/frontends/guides/cms/content-pages.html#use-the-cms-base-package) on how to use `cms-base-layer` in your Nuxt project.
|
|
94
94
|
|
|
95
95
|
## Styling and Design Tokens
|
|
96
96
|
|
|
@@ -442,13 +442,13 @@ export default defineAppConfig({
|
|
|
442
442
|
|
|
443
443
|
## 📘 Available components
|
|
444
444
|
|
|
445
|
-
The list of available blocks and elements is [here](https://
|
|
445
|
+
The list of available blocks and elements is [here](https://developer.shopware.com/frontends/packages/cms-base-layer.html#available-components).
|
|
446
446
|
|
|
447
447
|
## 🔄 Overwriting components
|
|
448
448
|
|
|
449
449
|
The procedure is:
|
|
450
450
|
|
|
451
|
-
- find a component in component's [list](https://
|
|
451
|
+
- find a component in component's [list](https://developer.shopware.com/frontends/packages/cms-base.html#available-components), using a [Vue devtools](https://devtools.vuejs.org/) or browsing the github [repository](https://github.com/shopware/frontends/tree/main/packages/cms-base-layer/app/components)
|
|
452
452
|
- take its name
|
|
453
453
|
- create a file with the same name and place it into `~/components` dir in your nuxt project (or wherever according your nuxt config)
|
|
454
454
|
|
|
@@ -475,7 +475,7 @@ No additional packages needed to be installed.
|
|
|
475
475
|
|
|
476
476
|
## Links
|
|
477
477
|
|
|
478
|
-
- [📘 Documentation](https://
|
|
478
|
+
- [📘 Documentation](https://developer.shopware.com/frontends)
|
|
479
479
|
|
|
480
480
|
- [👥 Community](https://discord.com/channels/1308047705309708348/1405501315160739951) (`#composable-frontend`)
|
|
481
481
|
|
|
@@ -485,9 +485,31 @@ No additional packages needed to be installed.
|
|
|
485
485
|
|
|
486
486
|
Full changelog for stable version is available [here](https://github.com/shopware/frontends/blob/main/packages/cms-base-layer/CHANGELOG.md)
|
|
487
487
|
|
|
488
|
-
### Latest changes: 3.0
|
|
488
|
+
### Latest changes: 3.1.0
|
|
489
|
+
|
|
490
|
+
### Minor Changes
|
|
491
|
+
|
|
492
|
+
- [#2503](https://github.com/shopware/frontends/pull/2503) [`1b0ccc9`](https://github.com/shopware/frontends/commit/1b0ccc95fcef89de42931155fb40119b9075d21b) Thanks [@mkucmus](https://github.com/mkucmus)! - `SwProductListingFilters` and `SwProductListingFiltersHorizontal`: honor the `listingType` prop. When set to `productSearchListing` the components drive `useProductSearchListing` instead of `useCategoryListing` and keep the active search term in the request criteria and resulting URL, so the shared filter sidebar can be reused on the product search page without resetting the query. Category listing behavior is unchanged.
|
|
489
493
|
|
|
490
494
|
### Patch Changes
|
|
491
495
|
|
|
492
|
-
-
|
|
493
|
-
|
|
496
|
+
- [#2593](https://github.com/shopware/frontends/pull/2593) [`760635c`](https://github.com/shopware/frontends/commit/760635cdbaf3c80ae99fbe0f42a31a8ebaa14675) Thanks [@patzick](https://github.com/patzick)! - Stop registering the `@unocss/nuxt` module from the CMS layer. UnoCSS is not a dependency of this package anymore (it lives in `@shopware/unocss-design-tokens-layer`), so registering the module made every app that doesn't install UnoCSS itself fail to build with `Could not load @unocss/nuxt. Is it installed?` - for example the blank template. Apps that want the shared UnoCSS setup keep getting it from `@shopware/unocss-design-tokens-layer` or their own `@unocss/nuxt` registration.
|
|
497
|
+
|
|
498
|
+
- [#2568](https://github.com/shopware/frontends/pull/2568) [`6315350`](https://github.com/shopware/frontends/commit/6315350add0464abef153343897d42f5808f2003) Thanks [@patzick](https://github.com/patzick)! - Add cacheable GET support for category details and product reviews while preserving the existing POST behavior when `cacheableReads` is disabled.
|
|
499
|
+
|
|
500
|
+
- `useCategorySearch.search` now calls `GET /category/{navigationId}` and sends the complete encoded Criteria in the `_criteria` query parameter when cacheable reads are enabled.
|
|
501
|
+
- `useProductReviews.loadProductReviews` now calls `GET /product/{productId}/reviews` and sends its encoded Criteria in `_criteria` when cacheable reads are enabled.
|
|
502
|
+
- The CMS product description reviews element follows the same flag and endpoint behavior when it needs to fetch reviews directly.
|
|
503
|
+
|
|
504
|
+
- [#2571](https://github.com/shopware/frontends/pull/2571) [`c64ae1c`](https://github.com/shopware/frontends/commit/c64ae1c6e2d11e33bd52df4b55177cc26b646433) Thanks [@patzick](https://github.com/patzick)! - Show the category a shopper came from in product breadcrumbs, while direct product links continue to use the default category.
|
|
505
|
+
|
|
506
|
+
- [#2596](https://github.com/shopware/frontends/pull/2596) [`6572aa8`](https://github.com/shopware/frontends/commit/6572aa84431e1f4a34d6cf04e549037692d638a6) Thanks [@patzick](https://github.com/patzick)! - Reduce Rolldown/Vite build noise: scope Nuxt global components, keep the three.js async chunk warning intentional, avoid shipping full carbon icon JSON via UnoCSS runtime, and bump @vueuse to 14.4.0.
|
|
507
|
+
|
|
508
|
+
- [#2592](https://github.com/shopware/frontends/pull/2592) [`fcc3d71`](https://github.com/shopware/frontends/commit/fcc3d71af05c2f2d409aa63ecb562714e6bc0e69) Thanks [@patzick](https://github.com/patzick)! - Replace CMS layer `@apply` styles with plain CSS declarations to avoid Nuxt build warnings.
|
|
509
|
+
|
|
510
|
+
- [#2568](https://github.com/shopware/frontends/pull/2568) [`6315350`](https://github.com/shopware/frontends/commit/6315350add0464abef153343897d42f5808f2003) Thanks [@patzick](https://github.com/patzick)! - Preserve CMS image aspect ratios when responsive dimensions are calculated after hydration, preventing visible image scaling as optimized sources load.
|
|
511
|
+
|
|
512
|
+
- Updated dependencies [[`6315350`](https://github.com/shopware/frontends/commit/6315350add0464abef153343897d42f5808f2003), [`b767721`](https://github.com/shopware/frontends/commit/b767721847bf3391f9067eca7a045089fb22fce0), [`f16c5a0`](https://github.com/shopware/frontends/commit/f16c5a0785d6187b73c3edcf37feab7c90bd7988), [`978b02c`](https://github.com/shopware/frontends/commit/978b02c969ca4b16f5fc1d7a953ec4cce3d98173), [`6572aa8`](https://github.com/shopware/frontends/commit/6572aa84431e1f4a34d6cf04e549037692d638a6), [`744833b`](https://github.com/shopware/frontends/commit/744833b9d7d2f8ea1f5dfe65be3fa554dbe4a09f), [`e03c91b`](https://github.com/shopware/frontends/commit/e03c91be172374894d90b7a0111855b76719fee1), [`33facb1`](https://github.com/shopware/frontends/commit/33facb178792c8cb26b47ab984ac48c08ab4b72b), [`9137475`](https://github.com/shopware/frontends/commit/91374753cedb2034385f642e6af11314f2971caa), [`474d3fe`](https://github.com/shopware/frontends/commit/474d3fed346816135b0c7c797990b215a8b691c0), [`38379a5`](https://github.com/shopware/frontends/commit/38379a5d52ab09008774533ef417ebe2cde3f7fd)]:
|
|
513
|
+
- @shopware/composables@1.12.1
|
|
514
|
+
- @shopware/api-client@1.5.1
|
|
515
|
+
- @shopware/helpers@1.7.2
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
useAddToCart,
|
|
18
18
|
useCartErrorParamsResolver,
|
|
19
19
|
useCartNotification,
|
|
20
|
+
useNavigationContext,
|
|
20
21
|
useNotifications,
|
|
21
22
|
useProductWishlist,
|
|
22
23
|
useUrlResolver,
|
|
@@ -144,10 +145,25 @@ const productManufacturer = computed(() =>
|
|
|
144
145
|
getProductManufacturerName(product.value),
|
|
145
146
|
);
|
|
146
147
|
|
|
147
|
-
const {
|
|
148
|
-
const
|
|
149
|
-
|
|
148
|
+
const { foreignKey: currentNavigationId, routeName } = useNavigationContext();
|
|
149
|
+
const referrerCategoryId = computed(() =>
|
|
150
|
+
isProductListing && routeName.value === "frontend.navigation.page"
|
|
151
|
+
? currentNavigationId.value
|
|
152
|
+
: undefined,
|
|
150
153
|
);
|
|
154
|
+
const { getUrlPrefix } = useUrlResolver();
|
|
155
|
+
const productLink = computed(() => {
|
|
156
|
+
const productRoute = getProductRoute(product.value);
|
|
157
|
+
const productRouteWithReferrer = {
|
|
158
|
+
...productRoute,
|
|
159
|
+
state: {
|
|
160
|
+
...productRoute.state,
|
|
161
|
+
referrerCategoryId: referrerCategoryId.value,
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
return buildUrlPrefix(productRouteWithReferrer, getUrlPrefix());
|
|
166
|
+
});
|
|
151
167
|
</script>
|
|
152
168
|
|
|
153
169
|
<template>
|
|
@@ -5,11 +5,19 @@ import type {
|
|
|
5
5
|
} from "@shopware/composables";
|
|
6
6
|
import { useCmsTranslations } from "@shopware/composables";
|
|
7
7
|
import { defu } from "defu";
|
|
8
|
-
import { computed
|
|
9
|
-
import type { ComputedRef
|
|
8
|
+
import { computed } from "vue";
|
|
9
|
+
import type { ComputedRef } from "vue";
|
|
10
10
|
import type { LocationQueryRaw } from "vue-router";
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
firstQueryValue,
|
|
14
|
+
toNumber,
|
|
15
|
+
useCategoryListing,
|
|
16
|
+
useProductSearchListing,
|
|
17
|
+
useRoute,
|
|
18
|
+
useRouter,
|
|
19
|
+
useSelectedListingFilters,
|
|
20
|
+
} from "#imports";
|
|
13
21
|
import type { Schemas, operations } from "#shopware";
|
|
14
22
|
|
|
15
23
|
const props = defineProps<{
|
|
@@ -17,6 +25,8 @@ const props = defineProps<{
|
|
|
17
25
|
listingType?: string;
|
|
18
26
|
}>();
|
|
19
27
|
|
|
28
|
+
const isProductSearch = props.listingType === "productSearchListing";
|
|
29
|
+
|
|
20
30
|
type Translations = {
|
|
21
31
|
listing: {
|
|
22
32
|
filters: string;
|
|
@@ -25,15 +35,6 @@ type Translations = {
|
|
|
25
35
|
};
|
|
26
36
|
};
|
|
27
37
|
|
|
28
|
-
type FilterState = {
|
|
29
|
-
manufacturer: Set<string>;
|
|
30
|
-
properties: Set<string>;
|
|
31
|
-
"min-price": number | undefined;
|
|
32
|
-
"max-price": number | undefined;
|
|
33
|
-
rating: number | undefined;
|
|
34
|
-
"shipping-free": boolean | undefined;
|
|
35
|
-
};
|
|
36
|
-
|
|
37
38
|
let translations: Translations = {
|
|
38
39
|
listing: {
|
|
39
40
|
filters: "Filters",
|
|
@@ -54,17 +55,9 @@ const {
|
|
|
54
55
|
getInitialFilters,
|
|
55
56
|
getSortingOrders,
|
|
56
57
|
search,
|
|
57
|
-
} = useCategoryListing();
|
|
58
|
+
} = isProductSearch ? useProductSearchListing() : useCategoryListing();
|
|
58
59
|
|
|
59
|
-
const sidebarSelectedFilters
|
|
60
|
-
reactive<FilterState>({
|
|
61
|
-
manufacturer: new Set(),
|
|
62
|
-
properties: new Set(),
|
|
63
|
-
"min-price": undefined,
|
|
64
|
-
"max-price": undefined,
|
|
65
|
-
rating: undefined,
|
|
66
|
-
"shipping-free": undefined,
|
|
67
|
-
});
|
|
60
|
+
const sidebarSelectedFilters = useSelectedListingFilters();
|
|
68
61
|
|
|
69
62
|
const showResetFiltersButton = computed<boolean>(() => {
|
|
70
63
|
if (
|
|
@@ -94,51 +87,10 @@ const searchCriteriaForRequest: ComputedRef<Schemas["ProductListingCriteria"]> =
|
|
|
94
87
|
order: getCurrentSortingOrder.value as string,
|
|
95
88
|
"shipping-free": sidebarSelectedFilters["shipping-free"] as boolean,
|
|
96
89
|
rating: sidebarSelectedFilters.rating as number,
|
|
97
|
-
search: "",
|
|
98
|
-
limit:
|
|
90
|
+
search: isProductSearch ? (firstQueryValue(route.query.search) ?? "") : "",
|
|
91
|
+
limit: toNumber(firstQueryValue(route.query.limit)) ?? 15,
|
|
99
92
|
}));
|
|
100
93
|
|
|
101
|
-
for (const param in route.query) {
|
|
102
|
-
if (param in sidebarSelectedFilters) {
|
|
103
|
-
const queryValue = route.query[param];
|
|
104
|
-
|
|
105
|
-
// Skip arrays
|
|
106
|
-
if (Array.isArray(queryValue)) continue;
|
|
107
|
-
|
|
108
|
-
if (["manufacturer", "properties"].includes(param)) {
|
|
109
|
-
if (typeof queryValue === "string") {
|
|
110
|
-
const elements = queryValue.split("|");
|
|
111
|
-
const targetSet = sidebarSelectedFilters[
|
|
112
|
-
param as keyof FilterState
|
|
113
|
-
] as Set<string>;
|
|
114
|
-
for (const element of elements) {
|
|
115
|
-
targetSet.add(element);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
} else if (queryValue && typeof queryValue === "string") {
|
|
119
|
-
// Fix: Use specific property assignments instead of generic keyof
|
|
120
|
-
if (param === "min-price") {
|
|
121
|
-
const numValue = Number(queryValue);
|
|
122
|
-
if (!Number.isNaN(numValue)) {
|
|
123
|
-
sidebarSelectedFilters["min-price"] = numValue;
|
|
124
|
-
}
|
|
125
|
-
} else if (param === "max-price") {
|
|
126
|
-
const numValue = Number(queryValue);
|
|
127
|
-
if (!Number.isNaN(numValue)) {
|
|
128
|
-
sidebarSelectedFilters["max-price"] = numValue;
|
|
129
|
-
}
|
|
130
|
-
} else if (param === "rating") {
|
|
131
|
-
const numValue = Number(queryValue);
|
|
132
|
-
if (!Number.isNaN(numValue)) {
|
|
133
|
-
sidebarSelectedFilters.rating = numValue;
|
|
134
|
-
}
|
|
135
|
-
} else if (param === "shipping-free") {
|
|
136
|
-
sidebarSelectedFilters["shipping-free"] = queryValue === "true";
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
94
|
const handleFilterChange = async (event: {
|
|
143
95
|
code: string;
|
|
144
96
|
value: string | number | boolean;
|
|
@@ -172,7 +124,12 @@ const handleFilterChange = async (event: {
|
|
|
172
124
|
|
|
173
125
|
const executeSearch = async () => {
|
|
174
126
|
try {
|
|
175
|
-
|
|
127
|
+
// Search context refetches from the URL (the page's useAsyncData watches
|
|
128
|
+
// route.query), so navigating below is enough; calling search() here too
|
|
129
|
+
// would double-fetch and flicker. Category listings have no URL watcher.
|
|
130
|
+
if (!isProductSearch) {
|
|
131
|
+
await search(searchCriteriaForRequest.value);
|
|
132
|
+
}
|
|
176
133
|
|
|
177
134
|
// Build query directly from searchCriteriaForRequest which already has pipe-separated strings
|
|
178
135
|
const criteria = searchCriteriaForRequest.value;
|
|
@@ -186,6 +143,12 @@ const executeSearch = async () => {
|
|
|
186
143
|
if (criteria["shipping-free"])
|
|
187
144
|
query["shipping-free"] = criteria["shipping-free"];
|
|
188
145
|
if (criteria.order) query.order = criteria.order;
|
|
146
|
+
// Keep the search term in the URL so filtering on the search page does not reset the query
|
|
147
|
+
if (isProductSearch && route.query.search)
|
|
148
|
+
query.search = route.query.search;
|
|
149
|
+
// Preserve the page-size selection; rebuilding the query from scratch would
|
|
150
|
+
// otherwise drop `limit` and revert to the default on refresh/navigation.
|
|
151
|
+
if (route.query.limit) query.limit = route.query.limit;
|
|
189
152
|
|
|
190
153
|
await router.push({
|
|
191
154
|
query: query as LocationQueryRaw,
|
|
@@ -215,10 +178,13 @@ const currentSortingOrder = computed({
|
|
|
215
178
|
},
|
|
216
179
|
});
|
|
217
180
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
181
|
+
// Search context refetches from the URL; category fetches directly.
|
|
182
|
+
if (!isProductSearch) {
|
|
183
|
+
await changeCurrentSortingOrder(order, {
|
|
184
|
+
...(route.query as unknown as operations["searchPage post /search"]["body"]),
|
|
185
|
+
limit: toNumber(firstQueryValue(route.query.limit)) ?? 15,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
222
188
|
} catch (error) {
|
|
223
189
|
console.error("Sorting order change failed:", error);
|
|
224
190
|
}
|
|
@@ -5,11 +5,19 @@ import type {
|
|
|
5
5
|
} from "@shopware/composables";
|
|
6
6
|
import { useCmsTranslations } from "@shopware/composables";
|
|
7
7
|
import { defu } from "defu";
|
|
8
|
-
import { computed
|
|
9
|
-
import type { ComputedRef
|
|
8
|
+
import { computed } from "vue";
|
|
9
|
+
import type { ComputedRef } from "vue";
|
|
10
10
|
import type { LocationQueryRaw } from "vue-router";
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
firstQueryValue,
|
|
14
|
+
toNumber,
|
|
15
|
+
useCategoryListing,
|
|
16
|
+
useProductSearchListing,
|
|
17
|
+
useRoute,
|
|
18
|
+
useRouter,
|
|
19
|
+
useSelectedListingFilters,
|
|
20
|
+
} from "#imports";
|
|
13
21
|
import type { Schemas, operations } from "#shopware";
|
|
14
22
|
|
|
15
23
|
const props = defineProps<{
|
|
@@ -17,6 +25,8 @@ const props = defineProps<{
|
|
|
17
25
|
listingType?: string;
|
|
18
26
|
}>();
|
|
19
27
|
|
|
28
|
+
const isProductSearch = props.listingType === "productSearchListing";
|
|
29
|
+
|
|
20
30
|
type Translations = {
|
|
21
31
|
listing: {
|
|
22
32
|
filters: string;
|
|
@@ -25,15 +35,6 @@ type Translations = {
|
|
|
25
35
|
};
|
|
26
36
|
};
|
|
27
37
|
|
|
28
|
-
type FilterState = {
|
|
29
|
-
manufacturer: Set<string>;
|
|
30
|
-
properties: Set<string>;
|
|
31
|
-
"min-price": number | undefined;
|
|
32
|
-
"max-price": number | undefined;
|
|
33
|
-
rating: number | undefined;
|
|
34
|
-
"shipping-free": boolean | undefined;
|
|
35
|
-
};
|
|
36
|
-
|
|
37
38
|
let translations: Translations = {
|
|
38
39
|
listing: {
|
|
39
40
|
filters: "Filters",
|
|
@@ -53,17 +54,9 @@ const {
|
|
|
53
54
|
getInitialFilters,
|
|
54
55
|
getSortingOrders,
|
|
55
56
|
search,
|
|
56
|
-
} = useCategoryListing();
|
|
57
|
+
} = isProductSearch ? useProductSearchListing() : useCategoryListing();
|
|
57
58
|
|
|
58
|
-
const sidebarSelectedFilters
|
|
59
|
-
reactive<FilterState>({
|
|
60
|
-
manufacturer: new Set(),
|
|
61
|
-
properties: new Set(),
|
|
62
|
-
"min-price": undefined,
|
|
63
|
-
"max-price": undefined,
|
|
64
|
-
rating: undefined,
|
|
65
|
-
"shipping-free": undefined,
|
|
66
|
-
});
|
|
59
|
+
const sidebarSelectedFilters = useSelectedListingFilters();
|
|
67
60
|
|
|
68
61
|
const showResetFiltersButton = computed<boolean>(() => {
|
|
69
62
|
if (
|
|
@@ -93,51 +86,10 @@ const searchCriteriaForRequest: ComputedRef<Schemas["ProductListingCriteria"]> =
|
|
|
93
86
|
order: getCurrentSortingOrder.value as string,
|
|
94
87
|
"shipping-free": sidebarSelectedFilters["shipping-free"] as boolean,
|
|
95
88
|
rating: sidebarSelectedFilters.rating as number,
|
|
96
|
-
search: "",
|
|
97
|
-
limit:
|
|
89
|
+
search: isProductSearch ? (firstQueryValue(route.query.search) ?? "") : "",
|
|
90
|
+
limit: toNumber(firstQueryValue(route.query.limit)) ?? 15,
|
|
98
91
|
}));
|
|
99
92
|
|
|
100
|
-
for (const param in route.query) {
|
|
101
|
-
if (param in sidebarSelectedFilters) {
|
|
102
|
-
const queryValue = route.query[param];
|
|
103
|
-
|
|
104
|
-
// Skip arrays
|
|
105
|
-
if (Array.isArray(queryValue)) continue;
|
|
106
|
-
|
|
107
|
-
if (["manufacturer", "properties"].includes(param)) {
|
|
108
|
-
if (typeof queryValue === "string") {
|
|
109
|
-
const elements = queryValue.split("|");
|
|
110
|
-
const targetSet = sidebarSelectedFilters[
|
|
111
|
-
param as keyof FilterState
|
|
112
|
-
] as Set<string>;
|
|
113
|
-
for (const element of elements) {
|
|
114
|
-
targetSet.add(element);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
} else if (queryValue && typeof queryValue === "string") {
|
|
118
|
-
// Fix: Use specific property assignments instead of generic keyof
|
|
119
|
-
if (param === "min-price") {
|
|
120
|
-
const numValue = Number(queryValue);
|
|
121
|
-
if (!Number.isNaN(numValue)) {
|
|
122
|
-
sidebarSelectedFilters["min-price"] = numValue;
|
|
123
|
-
}
|
|
124
|
-
} else if (param === "max-price") {
|
|
125
|
-
const numValue = Number(queryValue);
|
|
126
|
-
if (!Number.isNaN(numValue)) {
|
|
127
|
-
sidebarSelectedFilters["max-price"] = numValue;
|
|
128
|
-
}
|
|
129
|
-
} else if (param === "rating") {
|
|
130
|
-
const numValue = Number(queryValue);
|
|
131
|
-
if (!Number.isNaN(numValue)) {
|
|
132
|
-
sidebarSelectedFilters.rating = numValue;
|
|
133
|
-
}
|
|
134
|
-
} else if (param === "shipping-free") {
|
|
135
|
-
sidebarSelectedFilters["shipping-free"] = queryValue === "true";
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
93
|
const handleFilterChange = async (event: {
|
|
142
94
|
code: string;
|
|
143
95
|
value: string | number | boolean;
|
|
@@ -171,7 +123,12 @@ const handleFilterChange = async (event: {
|
|
|
171
123
|
|
|
172
124
|
const executeSearch = async () => {
|
|
173
125
|
try {
|
|
174
|
-
|
|
126
|
+
// Search context refetches from the URL (the page's useAsyncData watches
|
|
127
|
+
// route.query), so navigating below is enough; calling search() here too
|
|
128
|
+
// would double-fetch and flicker. Category listings have no URL watcher.
|
|
129
|
+
if (!isProductSearch) {
|
|
130
|
+
await search(searchCriteriaForRequest.value);
|
|
131
|
+
}
|
|
175
132
|
|
|
176
133
|
// Build query directly from searchCriteriaForRequest which already has pipe-separated strings
|
|
177
134
|
const criteria = searchCriteriaForRequest.value;
|
|
@@ -185,6 +142,12 @@ const executeSearch = async () => {
|
|
|
185
142
|
if (criteria["shipping-free"])
|
|
186
143
|
query["shipping-free"] = criteria["shipping-free"];
|
|
187
144
|
if (criteria.order) query.order = criteria.order;
|
|
145
|
+
// Keep the search term in the URL so filtering on the search page does not reset the query
|
|
146
|
+
if (isProductSearch && route.query.search)
|
|
147
|
+
query.search = route.query.search;
|
|
148
|
+
// Preserve the page-size selection; rebuilding the query from scratch would
|
|
149
|
+
// otherwise drop `limit` and revert to the default on refresh/navigation.
|
|
150
|
+
if (route.query.limit) query.limit = route.query.limit;
|
|
188
151
|
|
|
189
152
|
await router.push({
|
|
190
153
|
query: query as LocationQueryRaw,
|
|
@@ -214,10 +177,13 @@ const currentSortingOrder = computed({
|
|
|
214
177
|
},
|
|
215
178
|
});
|
|
216
179
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
180
|
+
// Search context refetches from the URL; category fetches directly.
|
|
181
|
+
if (!isProductSearch) {
|
|
182
|
+
await changeCurrentSortingOrder(order, {
|
|
183
|
+
...(route.query as unknown as operations["searchPage post /search"]["body"]),
|
|
184
|
+
limit: toNumber(firstQueryValue(route.query.limit)) ?? 15,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
221
187
|
} catch (error) {
|
|
222
188
|
console.error("Sorting order change failed:", error);
|
|
223
189
|
}
|
|
@@ -71,7 +71,7 @@ const DynamicRender = () => {
|
|
|
71
71
|
}
|
|
72
72
|
if (import.meta.dev) {
|
|
73
73
|
console.warn(
|
|
74
|
-
`[CMS] Block type "${componentName}" is not implemented.\n → Create a component named "${componentNameToResolve}.vue" to render it.\n 📖 Docs: https://
|
|
74
|
+
`[CMS] Block type "${componentName}" is not implemented.\n → Create a component named "${componentNameToResolve}.vue" to render it.\n 📖 Docs: https://developer.shopware.com/frontends/guides/cms/create-blocks`,
|
|
75
75
|
);
|
|
76
76
|
return h(resolveComponent("CmsNoComponent"), { content: props.content });
|
|
77
77
|
}
|
|
@@ -31,7 +31,7 @@ const DynamicRender = () => {
|
|
|
31
31
|
}
|
|
32
32
|
if (import.meta.dev) {
|
|
33
33
|
console.warn(
|
|
34
|
-
`[CMS] Element type "${componentName}" is not implemented.\n → Create a component named "${componentNameToResolve}.vue" to render it.\n 📖 Docs: https://
|
|
34
|
+
`[CMS] Element type "${componentName}" is not implemented.\n → Create a component named "${componentNameToResolve}.vue" to render it.\n 📖 Docs: https://developer.shopware.com/frontends/guides/cms/create-elements`,
|
|
35
35
|
);
|
|
36
36
|
return h(resolveComponent("CmsNoComponent"), { content: props.content });
|
|
37
37
|
}
|
|
@@ -28,7 +28,7 @@ const docsUrl = computed(() => {
|
|
|
28
28
|
component: expectedComponentName.value,
|
|
29
29
|
type: elementType.value.toLowerCase(),
|
|
30
30
|
});
|
|
31
|
-
return `https://
|
|
31
|
+
return `https://developer.shopware.com/frontends/guides/cms/missing-component?${params}`;
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
const aiPrompt = computed(() => {
|
|
@@ -30,6 +30,10 @@ const centerContent = getSlotContent("center");
|
|
|
30
30
|
</template>
|
|
31
31
|
<style scoped>
|
|
32
32
|
.cms-block-image-bubble-row :deep(.cms-element-image) {
|
|
33
|
-
|
|
33
|
+
aspect-ratio: 1 / 1;
|
|
34
|
+
max-width: 20rem;
|
|
35
|
+
overflow: hidden;
|
|
36
|
+
border-radius: 9999px;
|
|
37
|
+
object-fit: cover;
|
|
34
38
|
}
|
|
35
39
|
</style>
|
|
@@ -35,10 +35,14 @@ const centerRightContent = getSlotContent("center-right");
|
|
|
35
35
|
|
|
36
36
|
<style scoped>
|
|
37
37
|
.cms-block-image-four-column :deep(.cms-element-image) {
|
|
38
|
-
|
|
38
|
+
position: relative;
|
|
39
|
+
height: 100%;
|
|
40
|
+
width: 100%;
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
.cms-block-image-four-column :deep(.cms-element-image img) {
|
|
42
|
-
|
|
44
|
+
height: 100%;
|
|
45
|
+
width: 100%;
|
|
46
|
+
object-fit: cover;
|
|
43
47
|
}
|
|
44
48
|
</style>
|
|
@@ -20,24 +20,28 @@ const cmsContent = getSlotContent("imageGallery");
|
|
|
20
20
|
|
|
21
21
|
<style scoped>
|
|
22
22
|
.cms-block-image-gallery-big {
|
|
23
|
-
|
|
23
|
+
width: 100%;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
.cms-block-image-gallery-big :deep(.cms-element-image-gallery) {
|
|
27
|
-
|
|
27
|
+
max-width: 1280px;
|
|
28
|
+
margin-left: auto;
|
|
29
|
+
margin-right: auto;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
/* Enhanced styling for the big gallery version */
|
|
31
33
|
.cms-block-image-gallery-big :deep(.gallery-slider) {
|
|
32
|
-
|
|
34
|
+
max-height: 800px;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
.cms-block-image-gallery-big :deep(img) {
|
|
36
|
-
|
|
38
|
+
margin-left: auto;
|
|
39
|
+
margin-right: auto;
|
|
40
|
+
object-fit: contain;
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
/* Larger navigation controls */
|
|
40
44
|
.cms-block-image-gallery-big :deep(.gallery-slider-controls) {
|
|
41
|
-
|
|
45
|
+
transform: scale(1.25);
|
|
42
46
|
}
|
|
43
47
|
</style>
|
|
@@ -31,10 +31,15 @@ const centerContent = getSlotContent("center");
|
|
|
31
31
|
|
|
32
32
|
<style scoped>
|
|
33
33
|
.cms-block-image-highlight-row :deep(.cms-element-image) {
|
|
34
|
-
|
|
34
|
+
position: relative;
|
|
35
|
+
height: 100%;
|
|
36
|
+
width: 100%;
|
|
37
|
+
border: 12px solid #ffffff;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
.cms-block-image-highlight-row :deep(.cms-element-image img) {
|
|
38
|
-
|
|
41
|
+
height: 100%;
|
|
42
|
+
width: 100%;
|
|
43
|
+
object-fit: cover;
|
|
39
44
|
}
|
|
40
45
|
</style>
|
|
@@ -43,14 +43,20 @@ const rightTextContent = getSlotContent("right-text");
|
|
|
43
43
|
|
|
44
44
|
<style scoped>
|
|
45
45
|
.cms-block-image-text-row :deep(.cms-element-image) {
|
|
46
|
-
|
|
46
|
+
position: relative;
|
|
47
|
+
height: 100%;
|
|
48
|
+
width: 100%;
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
.cms-block-image-text-row :deep(.cms-element-image img) {
|
|
50
|
-
|
|
52
|
+
height: 100%;
|
|
53
|
+
width: 100%;
|
|
54
|
+
border-radius: 0.5rem;
|
|
55
|
+
object-fit: cover;
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
.cms-block-image-text-row :deep(.cms-element-text) {
|
|
54
|
-
|
|
59
|
+
align-self: stretch;
|
|
60
|
+
min-height: 3rem;
|
|
55
61
|
}
|
|
56
62
|
</style>
|
|
@@ -31,10 +31,15 @@ const centerContent = getSlotContent("center");
|
|
|
31
31
|
|
|
32
32
|
<style scoped>
|
|
33
33
|
.cms-block-image-three-cover :deep(.cms-element-image) {
|
|
34
|
-
|
|
34
|
+
position: relative;
|
|
35
|
+
aspect-ratio: 1 / 1;
|
|
36
|
+
height: 100%;
|
|
37
|
+
width: 100%;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
.cms-block-image-three-cover :deep(.cms-element-image img) {
|
|
38
|
-
|
|
41
|
+
height: 100%;
|
|
42
|
+
width: 100%;
|
|
43
|
+
object-fit: cover;
|
|
39
44
|
}
|
|
40
45
|
</style>
|
|
@@ -27,14 +27,18 @@ const rightContent = getSlotContent("right");
|
|
|
27
27
|
|
|
28
28
|
<style scoped>
|
|
29
29
|
.cms-block-image-two-column :deep(.cms-element-image) {
|
|
30
|
-
|
|
30
|
+
position: relative;
|
|
31
|
+
height: 100%;
|
|
32
|
+
width: 100%;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
.cms-block-image-two-column :deep(.cms-element-image img) {
|
|
34
|
-
|
|
36
|
+
height: 100%;
|
|
37
|
+
width: 100%;
|
|
38
|
+
object-fit: cover;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
.cms-block-image-two-column :deep(.cms-element-image-slider) {
|
|
38
|
-
|
|
42
|
+
width: 100%;
|
|
39
43
|
}
|
|
40
44
|
</style>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { encodeForQuery } from "@shopware/api-client/helpers";
|
|
2
3
|
import type { CmsElementProductDescriptionReviews } from "@shopware/composables";
|
|
3
4
|
import { useCmsTranslations } from "@shopware/composables";
|
|
4
5
|
import { getTranslatedProperty } from "@shopware/helpers";
|
|
@@ -56,18 +57,26 @@ const isSectionOpen = (sectionNumber: number) => {
|
|
|
56
57
|
};
|
|
57
58
|
|
|
58
59
|
const reviews: Ref<Schemas["ProductReview"][]> = ref([]);
|
|
59
|
-
const { apiClient } = useShopwareContext();
|
|
60
|
+
const { apiClient, cacheableReads } = useShopwareContext();
|
|
60
61
|
const { isLoggedIn } = useUser();
|
|
61
62
|
const reviewAdded = ref(false);
|
|
62
63
|
|
|
63
64
|
const fetchReviews = async () => {
|
|
64
65
|
try {
|
|
65
|
-
const reviewsResponse =
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
66
|
+
const reviewsResponse = cacheableReads
|
|
67
|
+
? await apiClient.invoke(
|
|
68
|
+
"readProductReviewsGet get /product/{productId}/reviews",
|
|
69
|
+
{
|
|
70
|
+
pathParams: { productId: product.value.id },
|
|
71
|
+
query: { _criteria: encodeForQuery({}) },
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
: await apiClient.invoke(
|
|
75
|
+
"readProductReviews post /product/{productId}/reviews",
|
|
76
|
+
{
|
|
77
|
+
pathParams: { productId: product.value.id },
|
|
78
|
+
},
|
|
79
|
+
);
|
|
71
80
|
reviews.value = reviewsResponse.data.elements || [];
|
|
72
81
|
} catch (error) {
|
|
73
82
|
console.error("Failed to fetch reviews:", error);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { firstQueryValue, toNumber } from "./routeQuery";
|
|
4
|
+
|
|
5
|
+
describe("firstQueryValue", () => {
|
|
6
|
+
it("returns a plain string value", () => {
|
|
7
|
+
expect(firstQueryValue("name-asc")).toBe("name-asc");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("returns the first element of a repeated (array) param", () => {
|
|
11
|
+
expect(firstQueryValue(["30", "45"])).toBe("30");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("returns undefined for undefined", () => {
|
|
15
|
+
expect(firstQueryValue(undefined)).toBeUndefined();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("returns undefined for null", () => {
|
|
19
|
+
expect(firstQueryValue(null)).toBeUndefined();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns undefined for an empty array", () => {
|
|
23
|
+
expect(firstQueryValue([])).toBeUndefined();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("returns undefined when the first array element is null", () => {
|
|
27
|
+
expect(firstQueryValue([null])).toBeUndefined();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("toNumber", () => {
|
|
32
|
+
it("parses an integer string", () => {
|
|
33
|
+
expect(toNumber("15")).toBe(15);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("parses a decimal string", () => {
|
|
37
|
+
expect(toNumber("19.99")).toBe(19.99);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("parses zero and negative values", () => {
|
|
41
|
+
expect(toNumber("0")).toBe(0);
|
|
42
|
+
expect(toNumber("-5")).toBe(-5);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns undefined for undefined", () => {
|
|
46
|
+
expect(toNumber(undefined)).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns undefined for an empty string", () => {
|
|
50
|
+
expect(toNumber("")).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns undefined for a non-numeric string", () => {
|
|
54
|
+
expect(toNumber("abc")).toBeUndefined();
|
|
55
|
+
expect(toNumber("12px")).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns undefined for non-finite values", () => {
|
|
59
|
+
expect(toNumber("Infinity")).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("composes with firstQueryValue for repeated numeric params", () => {
|
|
63
|
+
expect(toNumber(firstQueryValue(["30", "45"]))).toBe(30);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { LocationQuery } from "vue-router";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Collapses a route-query param to a single string.
|
|
5
|
+
*
|
|
6
|
+
* Router query values are `string | null`, an array when a param is repeated
|
|
7
|
+
* (e.g. `?p=1&p=2`), or `undefined` when absent. Returns the first usable
|
|
8
|
+
* string, or `undefined` for missing/empty/null values.
|
|
9
|
+
*/
|
|
10
|
+
export const firstQueryValue = (
|
|
11
|
+
value: LocationQuery[string] | undefined,
|
|
12
|
+
): string | undefined => {
|
|
13
|
+
const raw = Array.isArray(value) ? value[0] : value;
|
|
14
|
+
return raw ?? undefined;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parses a query string (typically from {@link firstQueryValue}) into a finite
|
|
19
|
+
* number, or `undefined` when missing/empty/non-numeric. Lets callers fall back
|
|
20
|
+
* with `??` instead of forwarding `NaN` into a request.
|
|
21
|
+
*/
|
|
22
|
+
export const toNumber = (value: string | undefined): number | undefined => {
|
|
23
|
+
if (value === undefined || value === "") return undefined;
|
|
24
|
+
const parsed = Number(value);
|
|
25
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
26
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
applyQueryToFilters,
|
|
5
|
+
createEmptyFilterState,
|
|
6
|
+
} from "./useSelectedListingFilters";
|
|
7
|
+
|
|
8
|
+
describe("applyQueryToFilters", () => {
|
|
9
|
+
it("populates sets from pipe-joined values", () => {
|
|
10
|
+
const state = createEmptyFilterState();
|
|
11
|
+
applyQueryToFilters(state, { manufacturer: "a|b", properties: "x" });
|
|
12
|
+
expect([...state.manufacturer]).toEqual(["a", "b"]);
|
|
13
|
+
expect([...state.properties]).toEqual(["x"]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("clears removed filters when the query no longer has them (Back/Forward)", () => {
|
|
17
|
+
const state = createEmptyFilterState();
|
|
18
|
+
applyQueryToFilters(state, { manufacturer: "a|b", search: "shirt" });
|
|
19
|
+
applyQueryToFilters(state, { search: "shirt" }); // manufacturer removed
|
|
20
|
+
expect(state.manufacturer.size).toBe(0);
|
|
21
|
+
expect(state.properties.size).toBe(0);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("clears removed scalar filters on resync", () => {
|
|
25
|
+
const state = createEmptyFilterState();
|
|
26
|
+
applyQueryToFilters(state, {
|
|
27
|
+
"min-price": "10",
|
|
28
|
+
"max-price": "20",
|
|
29
|
+
rating: "4",
|
|
30
|
+
"shipping-free": "true",
|
|
31
|
+
});
|
|
32
|
+
applyQueryToFilters(state, {});
|
|
33
|
+
expect(state["min-price"]).toBeUndefined();
|
|
34
|
+
expect(state["max-price"]).toBeUndefined();
|
|
35
|
+
expect(state.rating).toBeUndefined();
|
|
36
|
+
expect(state["shipping-free"]).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("keeps the Set identity stable across re-applies (reactive binding)", () => {
|
|
40
|
+
const state = createEmptyFilterState();
|
|
41
|
+
const manufacturerRef = state.manufacturer;
|
|
42
|
+
const propertiesRef = state.properties;
|
|
43
|
+
applyQueryToFilters(state, { manufacturer: "a" });
|
|
44
|
+
expect(state.manufacturer).toBe(manufacturerRef);
|
|
45
|
+
expect(state.properties).toBe(propertiesRef);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("drops empty members from pipe-joined values", () => {
|
|
49
|
+
const state = createEmptyFilterState();
|
|
50
|
+
applyQueryToFilters(state, { manufacturer: "a||b|" });
|
|
51
|
+
expect([...state.manufacturer]).toEqual(["a", "b"]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("parses scalars array/NaN-safe and ignores junk", () => {
|
|
55
|
+
const state = createEmptyFilterState();
|
|
56
|
+
applyQueryToFilters(state, {
|
|
57
|
+
"min-price": ["10", "20"],
|
|
58
|
+
"max-price": "abc",
|
|
59
|
+
rating: "4",
|
|
60
|
+
"shipping-free": "true",
|
|
61
|
+
});
|
|
62
|
+
expect(state["min-price"]).toBe(10); // first of repeated param
|
|
63
|
+
expect(state["max-price"]).toBeUndefined(); // NaN -> undefined
|
|
64
|
+
expect(state.rating).toBe(4);
|
|
65
|
+
expect(state["shipping-free"]).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("treats shipping-free other than 'true' as false", () => {
|
|
69
|
+
const state = createEmptyFilterState();
|
|
70
|
+
applyQueryToFilters(state, { "shipping-free": "false" });
|
|
71
|
+
expect(state["shipping-free"]).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("is idempotent (same query twice -> equal state)", () => {
|
|
75
|
+
const state = createEmptyFilterState();
|
|
76
|
+
const query = { manufacturer: "a|b", rating: "4" };
|
|
77
|
+
applyQueryToFilters(state, query);
|
|
78
|
+
applyQueryToFilters(state, query);
|
|
79
|
+
expect([...state.manufacturer]).toEqual(["a", "b"]);
|
|
80
|
+
expect(state.rating).toBe(4);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { reactive, watch } from "vue";
|
|
2
|
+
import type { UnwrapNestedRefs } from "vue";
|
|
3
|
+
import { useRoute } from "vue-router";
|
|
4
|
+
import type { LocationQuery } from "vue-router";
|
|
5
|
+
|
|
6
|
+
import { firstQueryValue, toNumber } from "./routeQuery";
|
|
7
|
+
|
|
8
|
+
export type FilterState = {
|
|
9
|
+
manufacturer: Set<string>;
|
|
10
|
+
properties: Set<string>;
|
|
11
|
+
"min-price": number | undefined;
|
|
12
|
+
"max-price": number | undefined;
|
|
13
|
+
rating: number | undefined;
|
|
14
|
+
"shipping-free": boolean | undefined;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Fresh, fully-empty filter state. The single source of "no selection". */
|
|
18
|
+
export const createEmptyFilterState = (): FilterState => ({
|
|
19
|
+
manufacturer: new Set<string>(),
|
|
20
|
+
properties: new Set<string>(),
|
|
21
|
+
"min-price": undefined,
|
|
22
|
+
"max-price": undefined,
|
|
23
|
+
rating: undefined,
|
|
24
|
+
"shipping-free": undefined,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resets `state` to empty, then repopulates it from `query`. Pure with respect
|
|
29
|
+
* to the router: it only mutates `state`, never navigates or fetches.
|
|
30
|
+
*
|
|
31
|
+
* Reset-first is what clears filters whose query param vanished (e.g. browser
|
|
32
|
+
* Back/Forward), which the old add-only loop left lingering. Idempotent:
|
|
33
|
+
* applying the same query twice yields identical contents, so it is safe to
|
|
34
|
+
* call from a route.query watcher that also fires on the user's own
|
|
35
|
+
* executeSearch() push.
|
|
36
|
+
*
|
|
37
|
+
* Array-valued params (e.g. ?manufacturer=a&manufacturer=b) collapse to the
|
|
38
|
+
* first value via firstQueryValue; numeric params are NaN-safe via toNumber -
|
|
39
|
+
* consistent with the rest of the listing parsing.
|
|
40
|
+
*/
|
|
41
|
+
export const applyQueryToFilters = (
|
|
42
|
+
state: FilterState,
|
|
43
|
+
query: LocationQuery,
|
|
44
|
+
): void => {
|
|
45
|
+
// 1) Reset every field. Clear the Sets in place so their identity is kept
|
|
46
|
+
// (props bound to them stay reactive); replace scalars with undefined.
|
|
47
|
+
state.manufacturer.clear();
|
|
48
|
+
state.properties.clear();
|
|
49
|
+
state["min-price"] = undefined;
|
|
50
|
+
state["max-price"] = undefined;
|
|
51
|
+
state.rating = undefined;
|
|
52
|
+
state["shipping-free"] = undefined;
|
|
53
|
+
|
|
54
|
+
// 2) Repopulate from the current query.
|
|
55
|
+
for (const code of ["manufacturer", "properties"] as const) {
|
|
56
|
+
const value = firstQueryValue(query[code]);
|
|
57
|
+
if (!value) continue;
|
|
58
|
+
for (const element of value.split("|")) {
|
|
59
|
+
if (element) state[code].add(element);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const minPrice = toNumber(firstQueryValue(query["min-price"]));
|
|
64
|
+
if (minPrice !== undefined) state["min-price"] = minPrice;
|
|
65
|
+
|
|
66
|
+
const maxPrice = toNumber(firstQueryValue(query["max-price"]));
|
|
67
|
+
if (maxPrice !== undefined) state["max-price"] = maxPrice;
|
|
68
|
+
|
|
69
|
+
const rating = toNumber(firstQueryValue(query.rating));
|
|
70
|
+
if (rating !== undefined) state.rating = rating;
|
|
71
|
+
|
|
72
|
+
const shippingFree = firstQueryValue(query["shipping-free"]);
|
|
73
|
+
if (shippingFree !== undefined) {
|
|
74
|
+
state["shipping-free"] = shippingFree === "true";
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Returns the reactive filter state, populated from the current route.query at
|
|
80
|
+
* setup (runs on server AND client -> identical first render, no hydration
|
|
81
|
+
* mismatch) and kept in sync on every subsequent query-only navigation
|
|
82
|
+
* (Back/Forward, manual URL edits, the component's own executeSearch push).
|
|
83
|
+
*
|
|
84
|
+
* The watcher is intentionally NOT immediate: the setup-time apply already
|
|
85
|
+
* covers the first render, and an immediate watcher would re-run during
|
|
86
|
+
* hydration. It only resyncs UI state - it never navigates or fetches - so it
|
|
87
|
+
* cannot loop with executeSearch() nor cause an extra listing request.
|
|
88
|
+
*/
|
|
89
|
+
export const useSelectedListingFilters = (): UnwrapNestedRefs<FilterState> => {
|
|
90
|
+
const route = useRoute();
|
|
91
|
+
const state = reactive<FilterState>(createEmptyFilterState());
|
|
92
|
+
|
|
93
|
+
// Initial parse (SSR + first client render).
|
|
94
|
+
applyQueryToFilters(state as FilterState, route.query);
|
|
95
|
+
|
|
96
|
+
// Resync on query-only route changes while the component stays mounted.
|
|
97
|
+
watch(
|
|
98
|
+
() => route.query,
|
|
99
|
+
(query) => {
|
|
100
|
+
applyQueryToFilters(state as FilterState, query);
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
return state;
|
|
105
|
+
};
|
package/nuxt.config.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createResolver } from "@nuxt/kit";
|
|
2
2
|
import type { NuxtConfig } from "@nuxt/schema";
|
|
3
3
|
import { defineNuxtConfig } from "nuxt/config";
|
|
4
|
+
import type { LoggingFunction, RollupLog } from "rollup";
|
|
4
5
|
|
|
5
6
|
const { resolve: resolveLayer } = createResolver(import.meta.url);
|
|
6
7
|
|
|
@@ -9,7 +10,11 @@ export default defineNuxtConfig({
|
|
|
9
10
|
// to prevent bundling heavy 3D libraries in the initial bundle.
|
|
10
11
|
// If you need 3D support, add "@tresjs/nuxt" to your app's nuxt.config.ts modules array
|
|
11
12
|
// and dynamically import SwMedia3D using defineAsyncComponent.
|
|
12
|
-
|
|
13
|
+
//
|
|
14
|
+
// @unocss/nuxt is not included here either. This layer only ships CMS components,
|
|
15
|
+
// the UnoCSS setup lives in @shopware/unocss-design-tokens-layer (or your own config),
|
|
16
|
+
// so apps that don't use UnoCSS can still extend this layer.
|
|
17
|
+
modules: ["@nuxt/image"],
|
|
13
18
|
|
|
14
19
|
hooks: {
|
|
15
20
|
"components:extend"(components) {
|
|
@@ -115,6 +120,24 @@ export default defineNuxtConfig({
|
|
|
115
120
|
optimizeDeps: {
|
|
116
121
|
include: ["xss"],
|
|
117
122
|
},
|
|
123
|
+
build: {
|
|
124
|
+
// Async SwMedia3D/three chunk is ~977 kB after minify; that split is intentional.
|
|
125
|
+
chunkSizeWarningLimit: 1000,
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
nitro: {
|
|
129
|
+
rollupConfig: {
|
|
130
|
+
// @vueuse/shared still embeds @__NO_SIDE_EFFECTS__ inside JSDoc (injectLocal).
|
|
131
|
+
// Nitro/Rollup treats that as a misplaced annotation. Track upstream vueuse.
|
|
132
|
+
onwarn(warning: RollupLog, warn: LoggingFunction) {
|
|
133
|
+
if (
|
|
134
|
+
warning.message?.includes("annotation that Rollup cannot interpret")
|
|
135
|
+
) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
warn(warning);
|
|
139
|
+
},
|
|
140
|
+
},
|
|
118
141
|
},
|
|
119
142
|
telemetry: {
|
|
120
143
|
enabled: false,
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shopware/cms-base-layer",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"description": "Vue CMS Nuxt Layer for Shopware",
|
|
5
5
|
"author": "Shopware",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/shopware/frontends.git"
|
|
9
9
|
},
|
|
10
|
-
"homepage": "https://
|
|
10
|
+
"homepage": "https://developer.shopware.com/frontends/concepts/shopping-experiences.html",
|
|
11
11
|
"keywords": [
|
|
12
12
|
"Shopware",
|
|
13
13
|
"Vue",
|
|
@@ -28,39 +28,38 @@
|
|
|
28
28
|
"index.cjs",
|
|
29
29
|
"index.d.ts",
|
|
30
30
|
"app.config.ts",
|
|
31
|
-
"nuxt.config.ts"
|
|
32
|
-
"uno.config.ts"
|
|
31
|
+
"nuxt.config.ts"
|
|
33
32
|
],
|
|
34
33
|
"dependencies": {
|
|
35
|
-
"@iconify-json/carbon": "1.2.
|
|
34
|
+
"@iconify-json/carbon": "1.2.24",
|
|
36
35
|
"@nuxt/image": "2.0.0",
|
|
37
36
|
"@nuxt/kit": "4.4.8",
|
|
38
37
|
"@tresjs/cientos": "5.7.2",
|
|
39
|
-
"@tresjs/nuxt": "^5.6.
|
|
38
|
+
"@tresjs/nuxt": "^5.6.3",
|
|
40
39
|
"@vuelidate/core": "2.0.3",
|
|
41
40
|
"@vuelidate/validators": "2.0.4",
|
|
42
|
-
"@vueuse/core": "14.
|
|
43
|
-
"entities": "
|
|
41
|
+
"@vueuse/core": "14.4.0",
|
|
42
|
+
"entities": "8.0.0",
|
|
44
43
|
"scule": "1.3.0",
|
|
45
44
|
"html-to-ast": "0.0.6",
|
|
46
45
|
"three": "0.183.2",
|
|
47
|
-
"vue": "3.5.
|
|
46
|
+
"vue": "3.5.39",
|
|
48
47
|
"xss": "1.0.15",
|
|
49
|
-
"@shopware/
|
|
50
|
-
"@shopware/composables": "1.12.
|
|
51
|
-
"@shopware/
|
|
48
|
+
"@shopware/api-client": "1.5.1",
|
|
49
|
+
"@shopware/composables": "1.12.1",
|
|
50
|
+
"@shopware/helpers": "1.7.2"
|
|
52
51
|
},
|
|
53
52
|
"devDependencies": {
|
|
54
53
|
"@nuxt/schema": "4.4.8",
|
|
55
54
|
"@types/three": "0.182.0",
|
|
56
55
|
"@typescript/native-preview": "7.0.0-dev.20260205.1",
|
|
57
|
-
"@vitest/coverage-v8": "4.1.
|
|
56
|
+
"@vitest/coverage-v8": "4.1.10",
|
|
58
57
|
"nuxt": "4.4.8",
|
|
59
58
|
"typescript": "5.9.3",
|
|
60
59
|
"unbuild": "3.6.1",
|
|
61
|
-
"vitest": "4.1.
|
|
60
|
+
"vitest": "4.1.10",
|
|
62
61
|
"vue-router": "5.1.0",
|
|
63
|
-
"vue-tsc": "3.3.
|
|
62
|
+
"vue-tsc": "3.3.7",
|
|
64
63
|
"tsconfig": "0.0.0"
|
|
65
64
|
},
|
|
66
65
|
"scripts": {
|