@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.
Files changed (107) hide show
  1. package/README.md +64 -58
  2. package/app/components/SwCategoryNavigation.vue +1 -0
  3. package/app/components/SwCategoryNavigationLink.vue +16 -8
  4. package/app/components/SwContactForm.vue +1 -0
  5. package/app/components/SwFilterChips.vue +1 -0
  6. package/app/components/SwListingProductPrice.vue +16 -4
  7. package/app/components/SwMedia3D.vue +1 -4
  8. package/app/components/SwNewsletterForm.vue +4 -3
  9. package/app/components/SwProductAddToCart.vue +24 -7
  10. package/app/components/SwProductCard.vue +20 -3
  11. package/app/components/SwProductCardDetails.vue +27 -9
  12. package/app/components/SwProductCardImage.vue +31 -12
  13. package/app/components/SwProductCardSkeleton.vue +37 -14
  14. package/app/components/SwProductGallery.vue +1 -0
  15. package/app/components/SwProductListingFilter.vue +5 -1
  16. package/app/components/SwProductListingFilters.vue +57 -76
  17. package/app/components/SwProductListingFiltersHorizontal.vue +40 -71
  18. package/app/components/SwProductPrice.vue +1 -0
  19. package/app/components/SwProductReviews.vue +14 -6
  20. package/app/components/SwProductReviewsForm.vue +15 -3
  21. package/app/components/SwProductUnits.vue +1 -0
  22. package/app/components/SwQuantitySelect.vue +10 -3
  23. package/app/components/SwSharedPrice.vue +1 -0
  24. package/app/components/SwSlider.vue +1 -0
  25. package/app/components/SwStockInfo.vue +12 -6
  26. package/app/components/SwVariantConfigurator.vue +4 -1
  27. package/app/components/listing-filters/SwFilterPrice.vue +79 -30
  28. package/app/components/listing-filters/SwFilterProperties.vue +40 -25
  29. package/app/components/listing-filters/SwFilterRating.vue +21 -9
  30. package/app/components/listing-filters/SwFilterShippingFree.vue +16 -8
  31. package/app/components/public/cms/CmsBlockSpatialViewer.vue +1 -0
  32. package/app/components/public/cms/CmsGenericBlock.vue +3 -1
  33. package/app/components/public/cms/CmsGenericElement.vue +2 -1
  34. package/app/components/public/cms/CmsNoComponent.vue +2 -1
  35. package/app/components/public/cms/CmsPage.md +1 -1
  36. package/app/components/public/cms/CmsPage.vue +2 -0
  37. package/app/components/public/cms/block/CmsBlockCategoryNavigation.vue +1 -0
  38. package/app/components/public/cms/block/CmsBlockCenterText.vue +3 -1
  39. package/app/components/public/cms/block/CmsBlockCrossSelling.vue +1 -0
  40. package/app/components/public/cms/block/CmsBlockCustomForm.vue +1 -0
  41. package/app/components/public/cms/block/CmsBlockForm.vue +1 -0
  42. package/app/components/public/cms/block/CmsBlockGalleryBuybox.vue +4 -1
  43. package/app/components/public/cms/block/CmsBlockImage.vue +1 -0
  44. package/app/components/public/cms/block/CmsBlockImageBubbleRow.vue +9 -2
  45. package/app/components/public/cms/block/CmsBlockImageCover.vue +1 -0
  46. package/app/components/public/cms/block/CmsBlockImageFourColumn.vue +10 -3
  47. package/app/components/public/cms/block/CmsBlockImageGallery.vue +1 -0
  48. package/app/components/public/cms/block/CmsBlockImageGalleryBig.vue +10 -5
  49. package/app/components/public/cms/block/CmsBlockImageHighlightRow.vue +11 -3
  50. package/app/components/public/cms/block/CmsBlockImageSimpleGrid.vue +1 -0
  51. package/app/components/public/cms/block/CmsBlockImageSlider.vue +1 -0
  52. package/app/components/public/cms/block/CmsBlockImageText.vue +4 -1
  53. package/app/components/public/cms/block/CmsBlockImageTextBubble.vue +4 -1
  54. package/app/components/public/cms/block/CmsBlockImageTextCover.vue +4 -1
  55. package/app/components/public/cms/block/CmsBlockImageTextGallery.vue +4 -12
  56. package/app/components/public/cms/block/CmsBlockImageTextRow.vue +13 -4
  57. package/app/components/public/cms/block/CmsBlockImageThreeColumn.vue +1 -0
  58. package/app/components/public/cms/block/CmsBlockImageThreeCover.vue +11 -3
  59. package/app/components/public/cms/block/CmsBlockImageTwoColumn.vue +11 -4
  60. package/app/components/public/cms/block/CmsBlockProductDescriptionReviews.vue +1 -0
  61. package/app/components/public/cms/block/CmsBlockProductHeading.vue +3 -1
  62. package/app/components/public/cms/block/CmsBlockProductListing.vue +1 -0
  63. package/app/components/public/cms/block/CmsBlockProductThreeColumn.vue +1 -0
  64. package/app/components/public/cms/block/CmsBlockSidebarFilter.vue +1 -0
  65. package/app/components/public/cms/block/CmsBlockText.vue +1 -0
  66. package/app/components/public/cms/block/CmsBlockTextHero.vue +1 -0
  67. package/app/components/public/cms/block/CmsBlockTextOnImage.vue +2 -4
  68. package/app/components/public/cms/block/CmsBlockTextThreeColumn.vue +1 -0
  69. package/app/components/public/cms/block/CmsBlockTextTwoColumn.vue +4 -1
  70. package/app/components/public/cms/block/CmsBlockVimeoVideo.vue +1 -0
  71. package/app/components/public/cms/block/CmsBlockYoutubeVideo.vue +1 -0
  72. package/app/components/public/cms/element/CmsElementBuyBox.vue +90 -72
  73. package/app/components/public/cms/element/CmsElementCategoryNavigation.vue +14 -3
  74. package/app/components/public/cms/element/CmsElementCrossSelling.vue +3 -1
  75. package/app/components/public/cms/element/CmsElementCustomForm.vue +1 -0
  76. package/app/components/public/cms/element/CmsElementForm.vue +1 -0
  77. package/app/components/public/cms/element/CmsElementHtml.vue +0 -1
  78. package/app/components/public/cms/element/CmsElementImage.vue +2 -1
  79. package/app/components/public/cms/element/CmsElementImageGallery.vue +6 -2
  80. package/app/components/public/cms/element/CmsElementManufacturerLogo.vue +1 -0
  81. package/app/components/public/cms/element/CmsElementProductBox.vue +1 -0
  82. package/app/components/public/cms/element/CmsElementProductDescriptionReviews.vue +93 -36
  83. package/app/components/public/cms/element/CmsElementProductListing.vue +16 -5
  84. package/app/components/public/cms/element/CmsElementProductSlider.vue +6 -4
  85. package/app/components/public/cms/element/CmsElementText.vue +2 -0
  86. package/app/components/public/cms/element/CmsElementVimeoVideo.vue +1 -0
  87. package/app/components/public/cms/element/CmsElementYoutubeVideo.vue +1 -0
  88. package/app/components/public/cms/element/SwProductListingPagination.vue +8 -2
  89. package/app/components/public/cms/section/CmsSectionSidebar.vue +13 -8
  90. package/app/components/public/cms/skeleton/ProductCardSkeleton.vue +16 -5
  91. package/app/components/ui/SwitchButton.vue +53 -14
  92. package/app/components/ui/WishlistIcon.vue +8 -2
  93. package/app/composables/useLcpImagePreload.test.ts +1 -0
  94. package/app/composables/useLcpImagePreload.ts +2 -0
  95. package/app/composables/useTypedAppConfig.ts +1 -0
  96. package/app/helpers/cms/getImageSizes.test.ts +1 -0
  97. package/app/helpers/html-to-vue/ast.ts +1 -0
  98. package/app/helpers/html-to-vue/getOptionsFromNode.test.ts +1 -0
  99. package/app/helpers/html-to-vue/renderToHtml.ts +1 -0
  100. package/app/helpers/html-to-vue/renderer.ts +1 -0
  101. package/app/providers/shopware.test.ts +1 -0
  102. package/app/utils/routeQuery.test.ts +65 -0
  103. package/app/utils/routeQuery.ts +26 -0
  104. package/app/utils/useSelectedListingFilters.test.ts +82 -0
  105. package/app/utils/useSelectedListingFilters.ts +105 -0
  106. package/nuxt.config.ts +24 -1
  107. package/package.json +22 -24
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import type { CmsElementYoutubeVideo } from "@shopware/composables";
3
3
  import { computed } from "vue";
4
+
4
5
  import { useCmsElementConfig } from "#imports";
5
6
 
6
7
  const props = defineProps<{
@@ -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 :total="total" :current="current" @change-page="handlePageChange" />
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 class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
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 class="self-stretch flex flex-col lg:flex-row items-stretch gap-16" :class="{
22
- 'px-6': fullWidth,
23
- }">
24
- <aside :class="{
25
- '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':
26
- mobileBehavior !== 'hidden',
27
- 'hidden lg:block': mobileBehavior === 'hidden',
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 role="status" class="p-px flex flex-col justify-start items-start overflow-hidden">
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 class="self-stretch min-h-[350px] relative flex items-center justify-center overflow-hidden aspect-square animate-pulse">
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 class="h-4 bg-gray-200 rounded-full dark:bg-gray-700 w-3/4 mb-3"></div>
22
- <div class="h-3 bg-gray-200 rounded-full dark:bg-gray-700 w-1/2 mb-4"></div>
23
- <div class="h-5 bg-gray-200 rounded-full dark:bg-gray-700 w-20 mb-3"></div>
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 :for="inputId"
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 class="inline-block cursor-pointer" :class="{ 'cursor-not-allowed': disabled }">
61
- <input ref="inputRef" :id="inputId" type="checkbox" :name="inputName" class="sr-only" :checked="localChecked"
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" :aria-label="ariaLabel || undefined" v-bind="$attrs" />
64
- <span role="switch" :aria-checked="localChecked" :tabindex="disabled ? -1 : 0"
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="localChecked ? 'bg-brand-secondary switch-track--on' : 'bg-surface-surface-container-highest'"
67
- @keydown.space.prevent="activateByKeyboard" @click.prevent="activateByClick">
68
- <span class="w-4 h-4 rounded-full absolute switch-knob"
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="localChecked ? 'bg-brand-on-secondary' : 'bg-surface-on-surface-variant'"></span>
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 v-if="description || $slots.description" class="self-stretch inline-flex justify-start items-center gap-2.5">
76
- <div class="flex-1 justify-start text-surface-on-surface-variant text-sm font-normal leading-tight">
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: background-color 180ms ease-in-out, box-shadow 180ms ease-in-out;
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: left 180ms cubic-bezier(.2, .9, .2, 1), top 180ms cubic-bezier(.2, .9, .2, 1), background-color 120ms linear;
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 class="i-carbon-favorite w-6 h-5 hover:cursor-pointer" v-if="!filled"></div>
13
- <div class="i-carbon-favorite-filled w-6 h-5 hover:cursor-pointer" v-else></div>
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>
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
+
2
3
  import { findFirstCmsImageUrl } from "../helpers/cms/findFirstCmsImageUrl";
3
4
 
4
5
  type Sections = Parameters<typeof findFirstCmsImageUrl>[0];
@@ -1,6 +1,8 @@
1
1
  import { computed } from "vue";
2
+
2
3
  import { useHead } from "#imports";
3
4
  import type { Schemas } from "#shopware";
5
+
4
6
  import { findFirstCmsImageUrl } from "../helpers/cms/findFirstCmsImageUrl";
5
7
  import { useTypedAppConfig } from "./useTypedAppConfig";
6
8
 
@@ -1,4 +1,5 @@
1
1
  import type { BackgroundImageOptions } from "@shopware/helpers";
2
+
2
3
  import { useAppConfig } from "#imports";
3
4
 
4
5
  type CmsBaseLayerAppConfig = ReturnType<typeof useAppConfig> & {
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
+
2
3
  import { getImageSizes } from "./getImageSizes";
3
4
 
4
5
  describe("getImageSizes", () => {
@@ -3,6 +3,7 @@
3
3
  */
4
4
  // @ts-expect-error - html-to-ast has type definition issues with package.json exports
5
5
  import { parse } from "html-to-ast";
6
+
6
7
  import type { NodeObject } from "./getOptionsFromNode";
7
8
 
8
9
  type ASTNode = NodeObject | NodeObject[];
@@ -1,4 +1,5 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
2
3
  import { getOptionsFromNode } from "./getOptionsFromNode";
3
4
  import type { NodeObject } from "./getOptionsFromNode";
4
5
 
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { ComponentInternalInstance, h } from "vue";
6
+
6
7
  import { type RectifyConfig, generateAST, rectifyAST } from "./ast";
7
8
  import { type RendererConfig, renderer } from "./renderer";
8
9
 
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { ComponentInternalInstance, VNode } from "vue";
6
+
6
7
  import { isNode } from "./ast";
7
8
  import type { NodeObject } from "./getOptionsFromNode";
8
9
  import { getOptionsFromNode } from "./getOptionsFromNode";
@@ -1,5 +1,6 @@
1
1
  import type { ImageCTX } from "@nuxt/image";
2
2
  import { describe, expect, it } from "vitest";
3
+
3
4
  import shopwareProvider from "./shopware";
4
5
 
5
6
  describe("Shopware Image Provider", () => {
@@ -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
- modules: ["@unocss/nuxt", "@nuxt/image"],
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,