@shopware/cms-base-layer 3.0.1 → 4.0.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 +78 -15
- package/app/app.config.ts +3 -3
- package/app/assets/css/rich-text.css +80 -0
- package/app/components/SwContactForm.vue +2 -1
- package/app/components/SwFilterChips.vue +29 -7
- package/app/components/SwNewsletterForm.vue +2 -1
- package/app/components/SwProductCard.vue +19 -3
- package/app/components/SwProductCardImage.vue +5 -1
- package/app/components/SwProductListingFilter.vue +5 -0
- package/app/components/SwProductListingFilters.vue +76 -74
- package/app/components/SwProductListingFiltersHorizontal.vue +73 -72
- package/app/components/SwStockInfo.vue +5 -2
- package/app/components/SwVariantConfigurator.vue +8 -3
- package/app/components/listing-filters/SwFilterCategories.vue +158 -0
- 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/FrontendAccountCustomerGroupRegistrationPage.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/CmsElementBuyBox.vue +4 -1
- package/app/components/public/cms/element/CmsElementCrossSelling.vue +2 -1
- package/app/components/public/cms/element/CmsElementHtml.vue +1 -1
- package/app/components/public/cms/element/CmsElementImage.vue +41 -2
- package/app/components/public/cms/element/CmsElementImageGallery.vue +2 -1
- package/app/components/public/cms/element/CmsElementProductDescriptionReviews.vue +18 -9
- package/app/components/public/cms/element/CmsElementText.md +77 -0
- package/app/components/public/cms/element/CmsElementText.vue +5 -32
- package/app/helpers/html-to-vue/renderToHtml.test.ts +55 -0
- package/app/helpers/html-to-vue/renderToHtml.ts +5 -1
- package/app/helpers/html-to-vue/renderer.ts +10 -1
- package/app/utils/listingFilters.test.ts +39 -0
- package/app/utils/listingFilters.ts +19 -0
- package/app/utils/routeQuery.test.ts +65 -0
- package/app/utils/routeQuery.ts +26 -0
- package/app/utils/useSelectedListingFilters.test.ts +92 -0
- package/app/utils/useSelectedListingFilters.ts +111 -0
- package/component-dirs.json +20 -0
- package/index.d.ts +2 -2
- package/nuxt.config.ts +35 -29
- package/package.json +27 -31
- package/dist/index.d.mts +0 -5
- package/dist/index.d.ts +0 -5
- package/dist/index.mjs +0 -31
- package/index.cjs +0 -7
|
@@ -32,6 +32,10 @@ const CmsTextRender = defineComponent({
|
|
|
32
32
|
const { resolveUrl } = useUrlResolver();
|
|
33
33
|
|
|
34
34
|
const config = {
|
|
35
|
+
container: {
|
|
36
|
+
type: "div",
|
|
37
|
+
class: "cms-element-text",
|
|
38
|
+
},
|
|
35
39
|
textTransformer: (text: string) => decodeHTML(text),
|
|
36
40
|
extraComponentsMap: {
|
|
37
41
|
link: {
|
|
@@ -148,7 +152,7 @@ const CmsTextRender = defineComponent({
|
|
|
148
152
|
const rawHtml =
|
|
149
153
|
mappedContent.value?.length > 0
|
|
150
154
|
? mappedContent.value
|
|
151
|
-
: "<div class='
|
|
155
|
+
: "<div class='missing-content-element'></div>";
|
|
152
156
|
|
|
153
157
|
return () => renderHtml(rawHtml, config, h, context, resolveUrl);
|
|
154
158
|
},
|
|
@@ -160,34 +164,3 @@ const CmsTextRender = defineComponent({
|
|
|
160
164
|
</div>
|
|
161
165
|
<CmsTextRender v-else />
|
|
162
166
|
</template>
|
|
163
|
-
<style scoped>
|
|
164
|
-
/** Global CSS styles for text elements */
|
|
165
|
-
h1,
|
|
166
|
-
h2,
|
|
167
|
-
h3,
|
|
168
|
-
h4,
|
|
169
|
-
h5 {
|
|
170
|
-
margin-bottom: 10px;
|
|
171
|
-
font-weight: 600;
|
|
172
|
-
}
|
|
173
|
-
h1 {
|
|
174
|
-
line-height: 2.5rem;
|
|
175
|
-
font-size: 2.25rem;
|
|
176
|
-
}
|
|
177
|
-
h2 {
|
|
178
|
-
line-height: 2rem;
|
|
179
|
-
font-size: 1.75rem;
|
|
180
|
-
}
|
|
181
|
-
h3 {
|
|
182
|
-
line-height: 1.5rem;
|
|
183
|
-
font-size: 1.25rem;
|
|
184
|
-
}
|
|
185
|
-
ol,
|
|
186
|
-
ul,
|
|
187
|
-
dl {
|
|
188
|
-
list-style-type: disc;
|
|
189
|
-
padding-left: 40px;
|
|
190
|
-
margin-top: 0;
|
|
191
|
-
margin-bottom: 1rem;
|
|
192
|
-
}
|
|
193
|
-
</style>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { h } from "vue";
|
|
3
|
+
|
|
4
|
+
import { renderHtml } from "./renderToHtml";
|
|
5
|
+
|
|
6
|
+
describe("renderHtml", () => {
|
|
7
|
+
const resolveUrl = (url: string) => url;
|
|
8
|
+
|
|
9
|
+
const render = (
|
|
10
|
+
html: string,
|
|
11
|
+
config: Parameters<typeof renderHtml>[1] = {},
|
|
12
|
+
) => renderHtml(html, config, h, null, resolveUrl);
|
|
13
|
+
|
|
14
|
+
it("wraps the content in a plain div by default", () => {
|
|
15
|
+
const vnode = render("<p>content</p>");
|
|
16
|
+
|
|
17
|
+
expect(vnode.type).toBe("div");
|
|
18
|
+
expect(vnode.props?.class).toBeUndefined();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("applies container.class to the wrapper element", () => {
|
|
22
|
+
const vnode = render("<h1>headline</h1>", {
|
|
23
|
+
container: { type: "div", class: "cms-element-text" },
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
expect(vnode.props?.class).toBe("cms-element-text");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("honours a custom container type", () => {
|
|
30
|
+
const vnode = render("<p>content</p>", {
|
|
31
|
+
container: { type: "section", class: "cms-element-text" },
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
expect(vnode.type).toBe("section");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("does not leak container.class into later renders", () => {
|
|
38
|
+
render("<p>scoped</p>", {
|
|
39
|
+
container: { type: "div", class: "cms-element-text" },
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const vnode = render("<p>unscoped</p>");
|
|
43
|
+
|
|
44
|
+
expect(vnode.props?.class).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("keeps rendering the parsed children next to the container class", () => {
|
|
48
|
+
const vnode = render("<h2>headline</h2>", {
|
|
49
|
+
container: { type: "div", class: "cms-element-text" },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(Array.isArray(vnode.children)).toBe(true);
|
|
53
|
+
expect((vnode.children as { type: string }[])[0]?.type).toBe("h2");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -25,7 +25,11 @@ export function renderHtml(
|
|
|
25
25
|
context: ComponentInternalInstance | null,
|
|
26
26
|
resolveUrl: (url: string) => string,
|
|
27
27
|
) {
|
|
28
|
-
const mergedConfig =
|
|
28
|
+
const mergedConfig: DefaultConfig = {
|
|
29
|
+
...defaultConfig,
|
|
30
|
+
...config,
|
|
31
|
+
container: { ...defaultConfig.container, ...config.container },
|
|
32
|
+
};
|
|
29
33
|
const _ast = generateAST(html);
|
|
30
34
|
const rectifyConfig: RectifyConfig = {
|
|
31
35
|
extraComponentsMap: config.extraComponentsMap,
|
|
@@ -27,6 +27,7 @@ export type ExtraComponentConfig = {
|
|
|
27
27
|
export type RendererConfig = {
|
|
28
28
|
container: {
|
|
29
29
|
type: string;
|
|
30
|
+
class?: string;
|
|
30
31
|
};
|
|
31
32
|
extraComponentsMap: Record<string, ExtraComponentConfig>;
|
|
32
33
|
renderAnyway: boolean;
|
|
@@ -113,5 +114,13 @@ export function renderer(
|
|
|
113
114
|
const rendered = _render(createElement, ast);
|
|
114
115
|
const children = flattenChildren(rendered);
|
|
115
116
|
|
|
116
|
-
|
|
117
|
+
const containerProps: Record<string, unknown> = { ...(context?.data || {}) };
|
|
118
|
+
|
|
119
|
+
if (config.container.class) {
|
|
120
|
+
containerProps.class = containerProps.class
|
|
121
|
+
? [containerProps.class, config.container.class]
|
|
122
|
+
: config.container.class;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return createElement(config.container.type, containerProps, children);
|
|
117
126
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { getVisibleListingFilters } from "./listingFilters";
|
|
4
|
+
|
|
5
|
+
describe("getVisibleListingFilters", () => {
|
|
6
|
+
const filters = [
|
|
7
|
+
{ code: "manufacturer" },
|
|
8
|
+
{ code: "categories" },
|
|
9
|
+
{ code: "price" },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
it("should keep the categories filter on a search listing", () => {
|
|
13
|
+
expect(
|
|
14
|
+
getVisibleListingFilters(filters, { isProductSearch: true }),
|
|
15
|
+
).toStrictEqual(filters);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("should drop the categories filter on a non-search listing", () => {
|
|
19
|
+
expect(
|
|
20
|
+
getVisibleListingFilters(filters, { isProductSearch: false }),
|
|
21
|
+
).toStrictEqual([{ code: "manufacturer" }, { code: "price" }]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("should leave listings without a categories filter untouched", () => {
|
|
25
|
+
const withoutCategories = [{ code: "manufacturer" }, { code: "rating" }];
|
|
26
|
+
expect(
|
|
27
|
+
getVisibleListingFilters(withoutCategories, { isProductSearch: false }),
|
|
28
|
+
).toStrictEqual(withoutCategories);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("should return an empty array for missing filters", () => {
|
|
32
|
+
expect(
|
|
33
|
+
getVisibleListingFilters(undefined, { isProductSearch: true }),
|
|
34
|
+
).toStrictEqual([]);
|
|
35
|
+
expect(
|
|
36
|
+
getVisibleListingFilters(null, { isProductSearch: false }),
|
|
37
|
+
).toStrictEqual([]);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { CATEGORY_AGGREGATION_NAME } from "@shopware/helpers";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Narrows the listing filters to the ones the current listing can act on.
|
|
5
|
+
*
|
|
6
|
+
* The category selection is only written to the `categories` URL param and to
|
|
7
|
+
* the criteria `post-filter` on search listings. A category listing that
|
|
8
|
+
* happens to request the category aggregations would otherwise render a
|
|
9
|
+
* checkbox that the next query sync resets and that never changes the result
|
|
10
|
+
* set, so the filter is dropped there.
|
|
11
|
+
*/
|
|
12
|
+
export const getVisibleListingFilters = <T extends { code: string }>(
|
|
13
|
+
filters: T[] | undefined | null,
|
|
14
|
+
{ isProductSearch }: { isProductSearch: boolean },
|
|
15
|
+
): T[] => {
|
|
16
|
+
if (!filters) return [];
|
|
17
|
+
if (isProductSearch) return filters;
|
|
18
|
+
return filters.filter((filter) => filter.code !== CATEGORY_AGGREGATION_NAME);
|
|
19
|
+
};
|
|
@@ -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,92 @@
|
|
|
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, {
|
|
12
|
+
manufacturer: "a|b",
|
|
13
|
+
properties: "x",
|
|
14
|
+
categories: "c1|c2",
|
|
15
|
+
});
|
|
16
|
+
expect([...state.manufacturer]).toEqual(["a", "b"]);
|
|
17
|
+
expect([...state.properties]).toEqual(["x"]);
|
|
18
|
+
expect([...state.categories]).toEqual(["c1", "c2"]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("clears removed filters when the query no longer has them (Back/Forward)", () => {
|
|
22
|
+
const state = createEmptyFilterState();
|
|
23
|
+
applyQueryToFilters(state, {
|
|
24
|
+
manufacturer: "a|b",
|
|
25
|
+
categories: "c1",
|
|
26
|
+
search: "shirt",
|
|
27
|
+
});
|
|
28
|
+
applyQueryToFilters(state, { search: "shirt" }); // manufacturer + categories removed
|
|
29
|
+
expect(state.manufacturer.size).toBe(0);
|
|
30
|
+
expect(state.properties.size).toBe(0);
|
|
31
|
+
expect(state.categories.size).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("clears removed scalar filters on resync", () => {
|
|
35
|
+
const state = createEmptyFilterState();
|
|
36
|
+
applyQueryToFilters(state, {
|
|
37
|
+
"min-price": "10",
|
|
38
|
+
"max-price": "20",
|
|
39
|
+
rating: "4",
|
|
40
|
+
"shipping-free": "true",
|
|
41
|
+
});
|
|
42
|
+
applyQueryToFilters(state, {});
|
|
43
|
+
expect(state["min-price"]).toBeUndefined();
|
|
44
|
+
expect(state["max-price"]).toBeUndefined();
|
|
45
|
+
expect(state.rating).toBeUndefined();
|
|
46
|
+
expect(state["shipping-free"]).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("keeps the Set identity stable across re-applies (reactive binding)", () => {
|
|
50
|
+
const state = createEmptyFilterState();
|
|
51
|
+
const manufacturerRef = state.manufacturer;
|
|
52
|
+
const propertiesRef = state.properties;
|
|
53
|
+
applyQueryToFilters(state, { manufacturer: "a" });
|
|
54
|
+
expect(state.manufacturer).toBe(manufacturerRef);
|
|
55
|
+
expect(state.properties).toBe(propertiesRef);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("drops empty members from pipe-joined values", () => {
|
|
59
|
+
const state = createEmptyFilterState();
|
|
60
|
+
applyQueryToFilters(state, { manufacturer: "a||b|" });
|
|
61
|
+
expect([...state.manufacturer]).toEqual(["a", "b"]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("parses scalars array/NaN-safe and ignores junk", () => {
|
|
65
|
+
const state = createEmptyFilterState();
|
|
66
|
+
applyQueryToFilters(state, {
|
|
67
|
+
"min-price": ["10", "20"],
|
|
68
|
+
"max-price": "abc",
|
|
69
|
+
rating: "4",
|
|
70
|
+
"shipping-free": "true",
|
|
71
|
+
});
|
|
72
|
+
expect(state["min-price"]).toBe(10); // first of repeated param
|
|
73
|
+
expect(state["max-price"]).toBeUndefined(); // NaN -> undefined
|
|
74
|
+
expect(state.rating).toBe(4);
|
|
75
|
+
expect(state["shipping-free"]).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("treats shipping-free other than 'true' as false", () => {
|
|
79
|
+
const state = createEmptyFilterState();
|
|
80
|
+
applyQueryToFilters(state, { "shipping-free": "false" });
|
|
81
|
+
expect(state["shipping-free"]).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("is idempotent (same query twice -> equal state)", () => {
|
|
85
|
+
const state = createEmptyFilterState();
|
|
86
|
+
const query = { manufacturer: "a|b", rating: "4" };
|
|
87
|
+
applyQueryToFilters(state, query);
|
|
88
|
+
applyQueryToFilters(state, query);
|
|
89
|
+
expect([...state.manufacturer]).toEqual(["a", "b"]);
|
|
90
|
+
expect(state.rating).toBe(4);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
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
|
+
categories: Set<string>;
|
|
12
|
+
"min-price": number | undefined;
|
|
13
|
+
"max-price": number | undefined;
|
|
14
|
+
rating: number | undefined;
|
|
15
|
+
"shipping-free": boolean | undefined;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Fresh, fully-empty filter state. The single source of "no selection". */
|
|
19
|
+
export const createEmptyFilterState = (): FilterState => ({
|
|
20
|
+
manufacturer: new Set<string>(),
|
|
21
|
+
properties: new Set<string>(),
|
|
22
|
+
categories: new Set<string>(),
|
|
23
|
+
"min-price": undefined,
|
|
24
|
+
"max-price": undefined,
|
|
25
|
+
rating: undefined,
|
|
26
|
+
"shipping-free": undefined,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resets `state` to empty, then repopulates it from `query`. Pure with respect
|
|
31
|
+
* to the router: it only mutates `state`, never navigates or fetches.
|
|
32
|
+
*
|
|
33
|
+
* Reset-first is what clears filters whose query param vanished (e.g. browser
|
|
34
|
+
* Back/Forward), which the old add-only loop left lingering. Idempotent:
|
|
35
|
+
* applying the same query twice yields identical contents, so it is safe to
|
|
36
|
+
* call from a route.query watcher that also fires on the user's own
|
|
37
|
+
* executeSearch() push.
|
|
38
|
+
*
|
|
39
|
+
* Array-valued params (e.g. ?manufacturer=a&manufacturer=b) collapse to the
|
|
40
|
+
* first value via firstQueryValue; numeric params are NaN-safe via toNumber -
|
|
41
|
+
* consistent with the rest of the listing parsing.
|
|
42
|
+
*/
|
|
43
|
+
export const applyQueryToFilters = (
|
|
44
|
+
state: FilterState,
|
|
45
|
+
query: LocationQuery,
|
|
46
|
+
): void => {
|
|
47
|
+
// 1) Reset every field. Clear the Sets in place so their identity is kept
|
|
48
|
+
// (props bound to them stay reactive); replace scalars with undefined.
|
|
49
|
+
state.manufacturer.clear();
|
|
50
|
+
state.properties.clear();
|
|
51
|
+
state.categories.clear();
|
|
52
|
+
state["min-price"] = undefined;
|
|
53
|
+
state["max-price"] = undefined;
|
|
54
|
+
state.rating = undefined;
|
|
55
|
+
state["shipping-free"] = undefined;
|
|
56
|
+
|
|
57
|
+
// 2) Repopulate from the current query.
|
|
58
|
+
for (const code of ["manufacturer", "properties", "categories"] as const) {
|
|
59
|
+
const value = firstQueryValue(query[code]);
|
|
60
|
+
if (!value) continue;
|
|
61
|
+
for (const element of value.split("|")) {
|
|
62
|
+
if (element) state[code].add(element);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const minPrice = toNumber(firstQueryValue(query["min-price"]));
|
|
67
|
+
if (minPrice !== undefined) state["min-price"] = minPrice;
|
|
68
|
+
|
|
69
|
+
const maxPrice = toNumber(firstQueryValue(query["max-price"]));
|
|
70
|
+
if (maxPrice !== undefined) state["max-price"] = maxPrice;
|
|
71
|
+
|
|
72
|
+
const rating = toNumber(firstQueryValue(query.rating));
|
|
73
|
+
if (rating !== undefined) state.rating = rating;
|
|
74
|
+
|
|
75
|
+
const shippingFree = firstQueryValue(query["shipping-free"]);
|
|
76
|
+
if (shippingFree !== undefined) {
|
|
77
|
+
state["shipping-free"] = shippingFree === "true";
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Returns the reactive filter state, populated from the current route.query at
|
|
83
|
+
* setup (runs on server AND client -> identical first render, no hydration
|
|
84
|
+
* mismatch) and kept in sync on every subsequent query-only navigation
|
|
85
|
+
* (Back/Forward, manual URL edits, the component's own executeSearch push).
|
|
86
|
+
*
|
|
87
|
+
* The watcher is intentionally NOT immediate: the setup-time apply already
|
|
88
|
+
* covers the first render, and an immediate watcher would re-run during
|
|
89
|
+
* hydration. It only resyncs UI state - it never navigates or fetches - so it
|
|
90
|
+
* cannot loop with executeSearch() nor cause an extra listing request.
|
|
91
|
+
*/
|
|
92
|
+
export const useSelectedListingFilters = (): UnwrapNestedRefs<FilterState> => {
|
|
93
|
+
const route = useRoute();
|
|
94
|
+
// Fresh state per call, deliberately: the sidebar and the horizontal filter
|
|
95
|
+
// bar each hold their own selection object, so a mutation in one is invisible
|
|
96
|
+
// to the other until it reaches the route. The URL is what synchronises them.
|
|
97
|
+
const state = reactive<FilterState>(createEmptyFilterState());
|
|
98
|
+
|
|
99
|
+
// Initial parse (SSR + first client render).
|
|
100
|
+
applyQueryToFilters(state as FilterState, route.query);
|
|
101
|
+
|
|
102
|
+
// Resync on query-only route changes while the component stays mounted.
|
|
103
|
+
watch(
|
|
104
|
+
() => route.query,
|
|
105
|
+
(query) => {
|
|
106
|
+
applyQueryToFilters(state as FilterState, query);
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
return state;
|
|
111
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"dirs": [
|
|
3
|
+
{
|
|
4
|
+
"path": "./app/components",
|
|
5
|
+
"pattern": "Sw*",
|
|
6
|
+
"global": true
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"path": "./app/components/public",
|
|
10
|
+
"pathPrefix": false,
|
|
11
|
+
"global": true
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"path": "./app/components/ui",
|
|
15
|
+
"prefix": "Sw",
|
|
16
|
+
"global": true
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"excluded": ["SwMedia3D"]
|
|
20
|
+
}
|
package/index.d.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
/// <reference types="@nuxt/schema" />
|
|
2
2
|
|
|
3
|
-
export * from "@shopware/composables";
|
|
4
|
-
export * from "./.nuxt/imports";
|
|
5
3
|
|
|
6
4
|
type CmsBaseLayerAppConfig = {
|
|
7
5
|
/** Placeholder shown while CMS images are loading */
|
|
@@ -42,3 +40,5 @@ declare module "@nuxt/schema" {
|
|
|
42
40
|
interface AppConfig extends CmsBaseLayerAppConfig {}
|
|
43
41
|
interface AppConfigInput extends CmsBaseLayerAppConfig {}
|
|
44
42
|
}
|
|
43
|
+
|
|
44
|
+
export {};
|
package/nuxt.config.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
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";
|
|
5
|
+
|
|
6
|
+
import componentDirs from "./component-dirs.json";
|
|
4
7
|
|
|
5
8
|
const { resolve: resolveLayer } = createResolver(import.meta.url);
|
|
6
9
|
|
|
@@ -9,20 +12,20 @@ export default defineNuxtConfig({
|
|
|
9
12
|
// to prevent bundling heavy 3D libraries in the initial bundle.
|
|
10
13
|
// If you need 3D support, add "@tresjs/nuxt" to your app's nuxt.config.ts modules array
|
|
11
14
|
// and dynamically import SwMedia3D using defineAsyncComponent.
|
|
12
|
-
|
|
15
|
+
//
|
|
16
|
+
// @unocss/nuxt is not included here either. This layer only ships CMS components,
|
|
17
|
+
// the UnoCSS setup lives in @shopware/unocss-design-tokens-layer (or your own config),
|
|
18
|
+
// so apps that don't use UnoCSS can still extend this layer.
|
|
19
|
+
modules: ["@nuxt/image"],
|
|
20
|
+
css: [resolveLayer("./app/assets/css/rich-text.css")],
|
|
13
21
|
|
|
14
22
|
hooks: {
|
|
15
23
|
"components:extend"(components) {
|
|
16
24
|
// Exclude SwMedia3D from auto-import to prevent bundling heavy 3D libraries
|
|
17
25
|
// It should be dynamically imported when needed using defineAsyncComponent.
|
|
18
|
-
const
|
|
19
|
-
(c) =>
|
|
20
|
-
|
|
21
|
-
c.kebabName === "sw-media3-d" ||
|
|
22
|
-
c.filePath?.includes("SwMedia3D.vue"),
|
|
23
|
-
);
|
|
24
|
-
if (index > -1) {
|
|
25
|
-
components.splice(index, 1);
|
|
26
|
+
for (const name of componentDirs.excluded) {
|
|
27
|
+
const index = components.findIndex((c) => c.pascalName === name);
|
|
28
|
+
if (index > -1) components.splice(index, 1);
|
|
26
29
|
}
|
|
27
30
|
},
|
|
28
31
|
},
|
|
@@ -85,26 +88,11 @@ export default defineNuxtConfig({
|
|
|
85
88
|
},
|
|
86
89
|
},
|
|
87
90
|
|
|
88
|
-
components:
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
global: true,
|
|
94
|
-
},
|
|
95
|
-
{
|
|
96
|
-
path: resolveLayer("./app/components/ui"),
|
|
97
|
-
extensions: [".vue"],
|
|
98
|
-
prefix: "Sw",
|
|
99
|
-
global: true,
|
|
100
|
-
},
|
|
101
|
-
{
|
|
102
|
-
path: resolveLayer("./app/components/public"),
|
|
103
|
-
pathPrefix: false,
|
|
104
|
-
global: true,
|
|
105
|
-
extensions: [".vue"],
|
|
106
|
-
},
|
|
107
|
-
],
|
|
91
|
+
components: componentDirs.dirs.map(({ path, ...dir }) => ({
|
|
92
|
+
...dir,
|
|
93
|
+
path: resolveLayer(path),
|
|
94
|
+
extensions: [".vue"],
|
|
95
|
+
})),
|
|
108
96
|
alias: {
|
|
109
97
|
"@cms-assets": resolveLayer("./app/assets"),
|
|
110
98
|
},
|
|
@@ -115,6 +103,24 @@ export default defineNuxtConfig({
|
|
|
115
103
|
optimizeDeps: {
|
|
116
104
|
include: ["xss"],
|
|
117
105
|
},
|
|
106
|
+
build: {
|
|
107
|
+
// Async SwMedia3D/three chunk is ~977 kB after minify; that split is intentional.
|
|
108
|
+
chunkSizeWarningLimit: 1000,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
nitro: {
|
|
112
|
+
rollupConfig: {
|
|
113
|
+
// @vueuse/shared still embeds @__NO_SIDE_EFFECTS__ inside JSDoc (injectLocal).
|
|
114
|
+
// Nitro/Rollup treats that as a misplaced annotation. Track upstream vueuse.
|
|
115
|
+
onwarn(warning: RollupLog, warn: LoggingFunction) {
|
|
116
|
+
if (
|
|
117
|
+
warning.message?.includes("annotation that Rollup cannot interpret")
|
|
118
|
+
) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
warn(warning);
|
|
122
|
+
},
|
|
123
|
+
},
|
|
118
124
|
},
|
|
119
125
|
telemetry: {
|
|
120
126
|
enabled: false,
|