@shopware/cms-base-layer 3.0.0 → 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 +64 -58
- package/app/components/SwCategoryNavigation.vue +1 -0
- package/app/components/SwCategoryNavigationLink.vue +16 -8
- package/app/components/SwContactForm.vue +1 -0
- package/app/components/SwFilterChips.vue +1 -0
- package/app/components/SwListingProductPrice.vue +16 -4
- package/app/components/SwMedia3D.vue +1 -4
- package/app/components/SwNewsletterForm.vue +4 -3
- package/app/components/SwProductAddToCart.vue +24 -7
- package/app/components/SwProductCard.vue +20 -3
- package/app/components/SwProductCardDetails.vue +27 -9
- package/app/components/SwProductCardImage.vue +31 -12
- package/app/components/SwProductCardSkeleton.vue +37 -14
- package/app/components/SwProductGallery.vue +1 -0
- package/app/components/SwProductListingFilter.vue +5 -1
- package/app/components/SwProductListingFilters.vue +57 -76
- package/app/components/SwProductListingFiltersHorizontal.vue +40 -71
- package/app/components/SwProductPrice.vue +1 -0
- package/app/components/SwProductReviews.vue +14 -6
- package/app/components/SwProductReviewsForm.vue +15 -3
- package/app/components/SwProductUnits.vue +1 -0
- package/app/components/SwQuantitySelect.vue +10 -3
- package/app/components/SwSharedPrice.vue +1 -0
- package/app/components/SwSlider.vue +1 -0
- package/app/components/SwStockInfo.vue +12 -6
- package/app/components/SwVariantConfigurator.vue +4 -1
- package/app/components/listing-filters/SwFilterPrice.vue +79 -30
- package/app/components/listing-filters/SwFilterProperties.vue +40 -25
- package/app/components/listing-filters/SwFilterRating.vue +21 -9
- package/app/components/listing-filters/SwFilterShippingFree.vue +16 -8
- package/app/components/public/cms/CmsBlockSpatialViewer.vue +1 -0
- package/app/components/public/cms/CmsGenericBlock.vue +3 -1
- package/app/components/public/cms/CmsGenericElement.vue +2 -1
- package/app/components/public/cms/CmsNoComponent.vue +2 -1
- package/app/components/public/cms/CmsPage.md +1 -1
- package/app/components/public/cms/CmsPage.vue +2 -0
- package/app/components/public/cms/block/CmsBlockCategoryNavigation.vue +1 -0
- package/app/components/public/cms/block/CmsBlockCenterText.vue +3 -1
- package/app/components/public/cms/block/CmsBlockCrossSelling.vue +1 -0
- package/app/components/public/cms/block/CmsBlockCustomForm.vue +1 -0
- package/app/components/public/cms/block/CmsBlockForm.vue +1 -0
- package/app/components/public/cms/block/CmsBlockGalleryBuybox.vue +4 -1
- package/app/components/public/cms/block/CmsBlockImage.vue +1 -0
- package/app/components/public/cms/block/CmsBlockImageBubbleRow.vue +9 -2
- package/app/components/public/cms/block/CmsBlockImageCover.vue +1 -0
- package/app/components/public/cms/block/CmsBlockImageFourColumn.vue +10 -3
- package/app/components/public/cms/block/CmsBlockImageGallery.vue +1 -0
- package/app/components/public/cms/block/CmsBlockImageGalleryBig.vue +10 -5
- package/app/components/public/cms/block/CmsBlockImageHighlightRow.vue +11 -3
- package/app/components/public/cms/block/CmsBlockImageSimpleGrid.vue +1 -0
- package/app/components/public/cms/block/CmsBlockImageSlider.vue +1 -0
- package/app/components/public/cms/block/CmsBlockImageText.vue +4 -1
- package/app/components/public/cms/block/CmsBlockImageTextBubble.vue +4 -1
- package/app/components/public/cms/block/CmsBlockImageTextCover.vue +4 -1
- package/app/components/public/cms/block/CmsBlockImageTextGallery.vue +4 -12
- package/app/components/public/cms/block/CmsBlockImageTextRow.vue +13 -4
- package/app/components/public/cms/block/CmsBlockImageThreeColumn.vue +1 -0
- package/app/components/public/cms/block/CmsBlockImageThreeCover.vue +11 -3
- package/app/components/public/cms/block/CmsBlockImageTwoColumn.vue +11 -4
- package/app/components/public/cms/block/CmsBlockProductDescriptionReviews.vue +1 -0
- package/app/components/public/cms/block/CmsBlockProductHeading.vue +3 -1
- package/app/components/public/cms/block/CmsBlockProductListing.vue +1 -0
- package/app/components/public/cms/block/CmsBlockProductThreeColumn.vue +1 -0
- package/app/components/public/cms/block/CmsBlockSidebarFilter.vue +1 -0
- package/app/components/public/cms/block/CmsBlockText.vue +1 -0
- package/app/components/public/cms/block/CmsBlockTextHero.vue +1 -0
- package/app/components/public/cms/block/CmsBlockTextOnImage.vue +2 -4
- package/app/components/public/cms/block/CmsBlockTextThreeColumn.vue +1 -0
- package/app/components/public/cms/block/CmsBlockTextTwoColumn.vue +4 -1
- package/app/components/public/cms/block/CmsBlockVimeoVideo.vue +1 -0
- package/app/components/public/cms/block/CmsBlockYoutubeVideo.vue +1 -0
- package/app/components/public/cms/element/CmsElementBuyBox.vue +90 -72
- package/app/components/public/cms/element/CmsElementCategoryNavigation.vue +14 -3
- package/app/components/public/cms/element/CmsElementCrossSelling.vue +3 -1
- package/app/components/public/cms/element/CmsElementCustomForm.vue +1 -0
- package/app/components/public/cms/element/CmsElementForm.vue +1 -0
- package/app/components/public/cms/element/CmsElementHtml.vue +0 -1
- package/app/components/public/cms/element/CmsElementImage.vue +2 -1
- package/app/components/public/cms/element/CmsElementImageGallery.vue +6 -2
- package/app/components/public/cms/element/CmsElementManufacturerLogo.vue +1 -0
- package/app/components/public/cms/element/CmsElementProductBox.vue +1 -0
- package/app/components/public/cms/element/CmsElementProductDescriptionReviews.vue +93 -36
- package/app/components/public/cms/element/CmsElementProductListing.vue +16 -5
- package/app/components/public/cms/element/CmsElementProductSlider.vue +6 -4
- package/app/components/public/cms/element/CmsElementText.vue +2 -0
- package/app/components/public/cms/element/CmsElementVimeoVideo.vue +1 -0
- package/app/components/public/cms/element/CmsElementYoutubeVideo.vue +1 -0
- package/app/components/public/cms/element/SwProductListingPagination.vue +8 -2
- package/app/components/public/cms/section/CmsSectionSidebar.vue +13 -8
- package/app/components/public/cms/skeleton/ProductCardSkeleton.vue +16 -5
- package/app/components/ui/SwitchButton.vue +53 -14
- package/app/components/ui/WishlistIcon.vue +8 -2
- package/app/composables/useLcpImagePreload.test.ts +1 -0
- package/app/composables/useLcpImagePreload.ts +2 -0
- package/app/composables/useTypedAppConfig.ts +1 -0
- package/app/helpers/cms/getImageSizes.test.ts +1 -0
- package/app/helpers/html-to-vue/ast.ts +1 -0
- package/app/helpers/html-to-vue/getOptionsFromNode.test.ts +1 -0
- package/app/helpers/html-to-vue/renderToHtml.ts +1 -0
- package/app/helpers/html-to-vue/renderer.ts +1 -0
- package/app/providers/shopware.test.ts +1 -0
- 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 +22 -24
|
@@ -35,7 +35,11 @@ const handleLimitChange = (event: Event) => {
|
|
|
35
35
|
<div v-if="total > 0" class="flex flex-col gap-6 sm:gap-8 mt-6 sm:mt-8">
|
|
36
36
|
<!-- Pagination Controls -->
|
|
37
37
|
<div class="flex justify-center w-full">
|
|
38
|
-
<SwPagination
|
|
38
|
+
<SwPagination
|
|
39
|
+
:total="total"
|
|
40
|
+
:current="current"
|
|
41
|
+
@change-page="handlePageChange"
|
|
42
|
+
/>
|
|
39
43
|
</div>
|
|
40
44
|
|
|
41
45
|
<!-- Items per page selector -->
|
|
@@ -61,7 +65,9 @@ const handleLimitChange = (event: Event) => {
|
|
|
61
65
|
<option :value="30">30 {{ translations.listing.products }}</option>
|
|
62
66
|
<option :value="45">45 {{ translations.listing.products }}</option>
|
|
63
67
|
</select>
|
|
64
|
-
<div
|
|
68
|
+
<div
|
|
69
|
+
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"
|
|
70
|
+
>
|
|
65
71
|
<SwChevronIcon direction="down" :size="16" />
|
|
66
72
|
</div>
|
|
67
73
|
</div>
|
|
@@ -18,14 +18,19 @@ provide("cms-section-layout", "sidebar");
|
|
|
18
18
|
</script>
|
|
19
19
|
|
|
20
20
|
<template>
|
|
21
|
-
<div
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
21
|
+
<div
|
|
22
|
+
class="self-stretch flex flex-col lg:flex-row items-stretch gap-16"
|
|
23
|
+
:class="{
|
|
24
|
+
'px-6': fullWidth,
|
|
25
|
+
}"
|
|
26
|
+
>
|
|
27
|
+
<aside
|
|
28
|
+
:class="{
|
|
29
|
+
'w-full lg:w-72 xl:w-80 flex-shrink-0 bg-surface-surface flex flex-col justify-start items-stretch gap-4 lg:sticky lg:top-20 px-4 lg:px-0':
|
|
30
|
+
mobileBehavior !== 'hidden',
|
|
31
|
+
'hidden lg:block': mobileBehavior === 'hidden',
|
|
32
|
+
}"
|
|
33
|
+
>
|
|
29
34
|
<div v-for="cmsBlock in sidebarBlocks" :key="cmsBlock.id" class="w-full">
|
|
30
35
|
<CmsGenericBlock :content="cmsBlock" />
|
|
31
36
|
</div>
|
|
@@ -5,9 +5,14 @@ const placeholderSvg = useImagePlaceholder();
|
|
|
5
5
|
</script>
|
|
6
6
|
|
|
7
7
|
<template>
|
|
8
|
-
<div
|
|
8
|
+
<div
|
|
9
|
+
role="status"
|
|
10
|
+
class="p-px flex flex-col justify-start items-start overflow-hidden"
|
|
11
|
+
>
|
|
9
12
|
<!-- Image skeleton -->
|
|
10
|
-
<div
|
|
13
|
+
<div
|
|
14
|
+
class="self-stretch min-h-[350px] relative flex items-center justify-center overflow-hidden aspect-square animate-pulse"
|
|
15
|
+
>
|
|
11
16
|
<img
|
|
12
17
|
:src="placeholderSvg"
|
|
13
18
|
alt=""
|
|
@@ -18,9 +23,15 @@ const placeholderSvg = useImagePlaceholder();
|
|
|
18
23
|
|
|
19
24
|
<!-- Details skeleton -->
|
|
20
25
|
<div class="w-full pt-4 animate-pulse">
|
|
21
|
-
<div
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
<div
|
|
27
|
+
class="h-4 bg-gray-200 rounded-full dark:bg-gray-700 w-3/4 mb-3"
|
|
28
|
+
></div>
|
|
29
|
+
<div
|
|
30
|
+
class="h-3 bg-gray-200 rounded-full dark:bg-gray-700 w-1/2 mb-4"
|
|
31
|
+
></div>
|
|
32
|
+
<div
|
|
33
|
+
class="h-5 bg-gray-200 rounded-full dark:bg-gray-700 w-20 mb-3"
|
|
34
|
+
></div>
|
|
24
35
|
<div class="h-10 bg-gray-200 rounded dark:bg-gray-700 w-full"></div>
|
|
25
36
|
</div>
|
|
26
37
|
<span class="sr-only">Loading...</span>
|
|
@@ -47,9 +47,11 @@ const activateByClick = (ev?: Event) => {
|
|
|
47
47
|
<div class="w-full inline-flex flex-col justify-start items-start gap-2">
|
|
48
48
|
<div class="self-stretch inline-flex justify-start items-center gap-3">
|
|
49
49
|
<!-- left label that toggles the input via for="#inputId" -->
|
|
50
|
-
<label
|
|
50
|
+
<label
|
|
51
|
+
:for="inputId"
|
|
51
52
|
class="flex-1 flex justify-start items-center gap-1 text-surface-on-surface text-base font-normal leading-normal cursor-pointer"
|
|
52
|
-
:class="{ 'cursor-not-allowed': disabled }"
|
|
53
|
+
:class="{ 'cursor-not-allowed': disabled }"
|
|
54
|
+
>
|
|
53
55
|
<span v-if="$slots.default">
|
|
54
56
|
<slot />
|
|
55
57
|
</span>
|
|
@@ -57,23 +59,55 @@ const activateByClick = (ev?: Event) => {
|
|
|
57
59
|
</label>
|
|
58
60
|
|
|
59
61
|
<div class="w-10 h-6 relative">
|
|
60
|
-
<label
|
|
61
|
-
|
|
62
|
+
<label
|
|
63
|
+
class="inline-block cursor-pointer"
|
|
64
|
+
:class="{ 'cursor-not-allowed': disabled }"
|
|
65
|
+
>
|
|
66
|
+
<input
|
|
67
|
+
ref="inputRef"
|
|
68
|
+
:id="inputId"
|
|
69
|
+
type="checkbox"
|
|
70
|
+
:name="inputName"
|
|
71
|
+
class="sr-only"
|
|
72
|
+
:checked="localChecked"
|
|
62
73
|
@change="toggleState()"
|
|
63
|
-
:disabled="disabled"
|
|
64
|
-
|
|
74
|
+
:disabled="disabled"
|
|
75
|
+
:aria-label="ariaLabel || undefined"
|
|
76
|
+
v-bind="$attrs"
|
|
77
|
+
/>
|
|
78
|
+
<span
|
|
79
|
+
role="switch"
|
|
80
|
+
:aria-checked="localChecked"
|
|
81
|
+
:tabindex="disabled ? -1 : 0"
|
|
65
82
|
class="w-10 h-6 relative rounded-full flex-shrink-0 inline-block switch-track cursor-pointer"
|
|
66
|
-
:class="
|
|
67
|
-
|
|
68
|
-
|
|
83
|
+
:class="
|
|
84
|
+
localChecked
|
|
85
|
+
? 'bg-brand-secondary switch-track--on'
|
|
86
|
+
: 'bg-surface-surface-container-highest'
|
|
87
|
+
"
|
|
88
|
+
@keydown.space.prevent="activateByKeyboard"
|
|
89
|
+
@click.prevent="activateByClick"
|
|
90
|
+
>
|
|
91
|
+
<span
|
|
92
|
+
class="w-4 h-4 rounded-full absolute switch-knob"
|
|
69
93
|
:style="{ left: localChecked ? '19px' : '4px', top: '4px' }"
|
|
70
|
-
:class="
|
|
94
|
+
:class="
|
|
95
|
+
localChecked
|
|
96
|
+
? 'bg-brand-on-secondary'
|
|
97
|
+
: 'bg-surface-on-surface-variant'
|
|
98
|
+
"
|
|
99
|
+
></span>
|
|
71
100
|
</span>
|
|
72
101
|
</label>
|
|
73
102
|
</div>
|
|
74
103
|
</div>
|
|
75
|
-
<div
|
|
76
|
-
|
|
104
|
+
<div
|
|
105
|
+
v-if="description || $slots.description"
|
|
106
|
+
class="self-stretch inline-flex justify-start items-center gap-2.5"
|
|
107
|
+
>
|
|
108
|
+
<div
|
|
109
|
+
class="flex-1 justify-start text-surface-on-surface-variant text-sm font-normal leading-tight"
|
|
110
|
+
>
|
|
77
111
|
<slot name="description">{{ description }}</slot>
|
|
78
112
|
</div>
|
|
79
113
|
</div>
|
|
@@ -82,7 +116,9 @@ const activateByClick = (ev?: Event) => {
|
|
|
82
116
|
|
|
83
117
|
<style scoped>
|
|
84
118
|
.switch-track {
|
|
85
|
-
transition:
|
|
119
|
+
transition:
|
|
120
|
+
background-color 180ms ease-in-out,
|
|
121
|
+
box-shadow 180ms ease-in-out;
|
|
86
122
|
}
|
|
87
123
|
|
|
88
124
|
.switch-track--on {
|
|
@@ -91,7 +127,10 @@ const activateByClick = (ev?: Event) => {
|
|
|
91
127
|
}
|
|
92
128
|
|
|
93
129
|
.switch-knob {
|
|
94
|
-
transition:
|
|
130
|
+
transition:
|
|
131
|
+
left 180ms cubic-bezier(0.2, 0.9, 0.2, 1),
|
|
132
|
+
top 180ms cubic-bezier(0.2, 0.9, 0.2, 1),
|
|
133
|
+
background-color 120ms linear;
|
|
95
134
|
}
|
|
96
135
|
|
|
97
136
|
.switch-track:focus {
|
|
@@ -9,7 +9,13 @@ const { filled = false } = defineProps<{
|
|
|
9
9
|
<!-- <Icon size="1rem" name="shopware:heart" v-if="type !== 'filled'" class="w-6 h-5 block hover:cursor-pointer" :class="[
|
|
10
10
|
styles[type]
|
|
11
11
|
]" /> -->
|
|
12
|
-
<div
|
|
13
|
-
|
|
12
|
+
<div
|
|
13
|
+
class="i-carbon-favorite w-6 h-5 hover:cursor-pointer"
|
|
14
|
+
v-if="!filled"
|
|
15
|
+
></div>
|
|
16
|
+
<div
|
|
17
|
+
class="i-carbon-favorite-filled w-6 h-5 hover:cursor-pointer"
|
|
18
|
+
v-else
|
|
19
|
+
></div>
|
|
14
20
|
</div>
|
|
15
21
|
</template>
|
|
@@ -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,
|