@shopware/cms-base-layer 1.5.0 → 2.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.
Files changed (184) hide show
  1. package/README.md +328 -13
  2. package/app/app.config.ts +7 -0
  3. package/app/assets/icons/check-circle.svg +3 -0
  4. package/app/assets/icons/checkmark.svg +3 -0
  5. package/app/assets/icons/chevron.svg +3 -0
  6. package/app/assets/icons/exclamation-circle.svg +3 -0
  7. package/app/assets/icons/star-empty.svg +3 -0
  8. package/app/assets/icons/star-filled.svg +3 -0
  9. package/app/assets/icons/user.svg +1 -0
  10. package/app/components/SwCategoryNavigation.vue +76 -0
  11. package/app/components/SwCategoryNavigationLink.vue +128 -0
  12. package/{components → app/components}/SwContactForm.vue +27 -27
  13. package/app/components/SwFilterChips.vue +144 -0
  14. package/app/components/SwListingProductPrice.vue +89 -0
  15. package/{components → app/components}/SwNewsletterForm.vue +45 -34
  16. package/{components → app/components}/SwPagination.vue +3 -5
  17. package/{components → app/components}/SwProductAddToCart.vue +22 -27
  18. package/app/components/SwProductCard.vue +170 -0
  19. package/app/components/SwProductCardDetails.vue +57 -0
  20. package/app/components/SwProductCardImage.vue +87 -0
  21. package/app/components/SwProductCardSkeleton.vue +33 -0
  22. package/app/components/SwProductListingFilter.vue +64 -0
  23. package/app/components/SwProductListingFilters.vue +308 -0
  24. package/{components → app/components}/SwProductReviews.vue +28 -13
  25. package/app/components/SwProductReviewsForm.vue +292 -0
  26. package/app/components/SwQuantitySelect.vue +106 -0
  27. package/{components → app/components}/SwSlider.vue +4 -4
  28. package/app/components/SwSortDropdown.vue +83 -0
  29. package/app/components/SwStockInfo.vue +44 -0
  30. package/{components → app/components}/SwVariantConfigurator.vue +1 -1
  31. package/app/components/listing-filters/SwFilterPrice.vue +214 -0
  32. package/app/components/listing-filters/SwFilterProperties.vue +113 -0
  33. package/app/components/listing-filters/SwFilterRating.vue +90 -0
  34. package/app/components/listing-filters/SwFilterShippingFree.vue +107 -0
  35. package/{components → app/components}/public/cms/CmsPage.vue +19 -4
  36. package/{components → app/components}/public/cms/block/CmsBlockGalleryBuybox.vue +5 -5
  37. package/{components → app/components}/public/cms/block/CmsBlockImageBubbleRow.vue +5 -5
  38. package/app/components/public/cms/block/CmsBlockImageFourColumn.vue +41 -0
  39. package/app/components/public/cms/block/CmsBlockImageGalleryBig.vue +42 -0
  40. package/app/components/public/cms/block/CmsBlockImageHighlightRow.vue +37 -0
  41. package/{components → app/components}/public/cms/block/CmsBlockImageSimpleGrid.vue +11 -5
  42. package/{components → app/components}/public/cms/block/CmsBlockImageText.vue +7 -3
  43. package/{components → app/components}/public/cms/block/CmsBlockImageTextBubble.vue +13 -16
  44. package/{components → app/components}/public/cms/block/CmsBlockImageTextCover.vue +7 -9
  45. package/app/components/public/cms/block/CmsBlockImageTextGallery.vue +88 -0
  46. package/app/components/public/cms/block/CmsBlockImageTextRow.vue +53 -0
  47. package/{components → app/components}/public/cms/block/CmsBlockImageThreeColumn.vue +10 -4
  48. package/app/components/public/cms/block/CmsBlockImageThreeCover.vue +37 -0
  49. package/app/components/public/cms/block/CmsBlockImageTwoColumn.vue +37 -0
  50. package/{components → app/components}/public/cms/block/CmsBlockProductHeading.vue +1 -1
  51. package/{components → app/components}/public/cms/block/CmsBlockProductThreeColumn.vue +10 -4
  52. package/{components → app/components}/public/cms/block/CmsBlockSidebarFilter.vue +3 -1
  53. package/app/components/public/cms/block/CmsBlockTextOnImage.vue +30 -0
  54. package/{components → app/components}/public/cms/block/CmsBlockTextTeaserSection.vue +4 -4
  55. package/{components → app/components}/public/cms/block/CmsBlockTextTwoColumn.vue +3 -5
  56. package/app/components/public/cms/element/CmsElementBuyBox.vue +145 -0
  57. package/app/components/public/cms/element/CmsElementCategoryNavigation.vue +53 -0
  58. package/{components → app/components}/public/cms/element/CmsElementCrossSelling.vue +3 -3
  59. package/{components → app/components}/public/cms/element/CmsElementImage.vue +52 -13
  60. package/app/components/public/cms/element/CmsElementImageGallery.vue +158 -0
  61. package/{components → app/components}/public/cms/element/CmsElementImageSlider.vue +2 -2
  62. package/{components → app/components}/public/cms/element/CmsElementProductBox.vue +2 -1
  63. package/app/components/public/cms/element/CmsElementProductDescriptionReviews.vue +217 -0
  64. package/{components → app/components}/public/cms/element/CmsElementProductListing.vue +23 -94
  65. package/app/components/public/cms/element/CmsElementProductName.vue +11 -0
  66. package/{components → app/components}/public/cms/element/CmsElementProductSlider.vue +4 -4
  67. package/{components → app/components}/public/cms/element/CmsElementText.vue +8 -2
  68. package/{components → app/components}/public/cms/element/CmsElementYoutubeVideo.vue +8 -2
  69. package/app/components/public/cms/element/SwProductListingPagination.vue +70 -0
  70. package/{components → app/components}/public/cms/section/CmsSectionDefault.vue +1 -1
  71. package/app/components/public/cms/section/CmsSectionSidebar.vue +36 -0
  72. package/app/components/public/cms/skeleton/ProductCardSkeleton.vue +28 -0
  73. package/app/components/ui/BaseButton.vue +99 -0
  74. package/app/components/ui/BaseIcon.vue +15 -0
  75. package/app/components/ui/Checkbox.vue +49 -0
  76. package/app/components/ui/CheckmarkIcon.vue +23 -0
  77. package/app/components/ui/ChevronIcon.vue +37 -0
  78. package/app/components/ui/ExclamationIcon.vue +11 -0
  79. package/app/components/ui/IconButton.vue +32 -0
  80. package/app/components/ui/RadioButton.vue +26 -0
  81. package/app/components/ui/StarIcon.vue +18 -0
  82. package/app/components/ui/SwitchButton.vue +100 -0
  83. package/app/components/ui/UserIcon.vue +11 -0
  84. package/app/components/ui/WishlistIcon.vue +20 -0
  85. package/app/composables/useImagePlaceholder.ts +27 -0
  86. package/{helpers → app/helpers}/clientOnly.ts +5 -0
  87. package/app/providers/shopware.test.ts +213 -0
  88. package/app/providers/shopware.ts +107 -0
  89. package/dist/index.d.mts +3 -3
  90. package/dist/index.d.ts +3 -3
  91. package/dist/index.mjs +2 -2
  92. package/index.d.ts +12 -0
  93. package/nuxt.config.ts +80 -6
  94. package/package.json +29 -21
  95. package/uno.config.ts +83 -0
  96. package/components/SwCategoryNavigation.vue +0 -44
  97. package/components/SwCategoryNavigationLink.vue +0 -57
  98. package/components/SwListingProductPrice.vue +0 -89
  99. package/components/SwProductCard.vue +0 -286
  100. package/components/SwProductListingFilter.vue +0 -42
  101. package/components/SwProductListingFilters.vue +0 -292
  102. package/components/listing-filters/SwFilterPrice.vue +0 -160
  103. package/components/listing-filters/SwFilterProperties.vue +0 -123
  104. package/components/listing-filters/SwFilterRating.vue +0 -101
  105. package/components/listing-filters/SwFilterShippingFree.vue +0 -104
  106. package/components/public/cms/block/CmsBlockImageFourColumn.vue +0 -29
  107. package/components/public/cms/block/CmsBlockImageHighlightRow.vue +0 -27
  108. package/components/public/cms/block/CmsBlockImageTextGallery.vue +0 -85
  109. package/components/public/cms/block/CmsBlockImageTextRow.vue +0 -43
  110. package/components/public/cms/block/CmsBlockImageThreeCover.vue +0 -27
  111. package/components/public/cms/block/CmsBlockImageTwoColumn.vue +0 -25
  112. package/components/public/cms/block/CmsBlockTextOnImage.vue +0 -20
  113. package/components/public/cms/element/CmsBlockHtml.md +0 -1
  114. package/components/public/cms/element/CmsElementBuyBox.vue +0 -190
  115. package/components/public/cms/element/CmsElementCategoryNavigation.vue +0 -167
  116. package/components/public/cms/element/CmsElementImageGallery.vue +0 -249
  117. package/components/public/cms/element/CmsElementProductDescriptionReviews.vue +0 -123
  118. package/components/public/cms/element/CmsElementProductName.vue +0 -10
  119. package/components/public/cms/section/CmsSectionSidebar.vue +0 -49
  120. package/components/public/cms/skeleton/ProductCardSkeleton.vue +0 -44
  121. /package/{components → app/components}/SwMedia3D.vue +0 -0
  122. /package/{components → app/components}/SwProductGallery.vue +0 -0
  123. /package/{components → app/components}/SwProductPrice.vue +0 -0
  124. /package/{components → app/components}/SwProductUnits.vue +0 -0
  125. /package/{components → app/components}/SwSharedPrice.vue +0 -0
  126. /package/{components → app/components}/public/cms/CmsGenericBlock.md +0 -0
  127. /package/{components → app/components}/public/cms/CmsGenericBlock.vue +0 -0
  128. /package/{components → app/components}/public/cms/CmsGenericElement.md +0 -0
  129. /package/{components → app/components}/public/cms/CmsGenericElement.vue +0 -0
  130. /package/{components → app/components}/public/cms/CmsNoComponent.vue +0 -0
  131. /package/{components → app/components}/public/cms/CmsPage.md +0 -0
  132. /package/{components → app/components}/public/cms/block/CmsBlockCategoryNavigation.vue +0 -0
  133. /package/{components → app/components}/public/cms/block/CmsBlockCenterText.vue +0 -0
  134. /package/{components → app/components}/public/cms/block/CmsBlockCrossSelling.vue +0 -0
  135. /package/{components → app/components}/public/cms/block/CmsBlockCustomForm.vue +0 -0
  136. /package/{components → app/components}/public/cms/block/CmsBlockDefault.vue +0 -0
  137. /package/{components → app/components}/public/cms/block/CmsBlockForm.vue +0 -0
  138. /package/{components/public/cms/element → app/components/public/cms/block}/CmsBlockHtml.vue +0 -0
  139. /package/{components → app/components}/public/cms/block/CmsBlockImage.vue +0 -0
  140. /package/{components → app/components}/public/cms/block/CmsBlockImageCover.vue +0 -0
  141. /package/{components → app/components}/public/cms/block/CmsBlockImageGallery.vue +0 -0
  142. /package/{components → app/components}/public/cms/block/CmsBlockImageSlider.vue +0 -0
  143. /package/{components → app/components}/public/cms/block/CmsBlockProductDescriptionReviews.vue +0 -0
  144. /package/{components → app/components}/public/cms/block/CmsBlockProductListing.vue +0 -0
  145. /package/{components → app/components}/public/cms/block/CmsBlockProductSlider.vue +0 -0
  146. /package/{components → app/components}/public/cms/block/CmsBlockText.vue +0 -0
  147. /package/{components → app/components}/public/cms/block/CmsBlockTextHero.vue +0 -0
  148. /package/{components → app/components}/public/cms/block/CmsBlockTextTeaser.vue +0 -0
  149. /package/{components → app/components}/public/cms/block/CmsBlockTextThreeColumn.vue +0 -0
  150. /package/{components → app/components}/public/cms/block/CmsBlockVimeoVideo.vue +0 -0
  151. /package/{components → app/components}/public/cms/block/CmsBlockYoutubeVideo.vue +0 -0
  152. /package/{components → app/components}/public/cms/element/CmsElementBuyBox.md +0 -0
  153. /package/{components → app/components}/public/cms/element/CmsElementCategoryNavigation.md +0 -0
  154. /package/{components → app/components}/public/cms/element/CmsElementCrossSelling.md +0 -0
  155. /package/{components → app/components}/public/cms/element/CmsElementCustomForm.md +0 -0
  156. /package/{components → app/components}/public/cms/element/CmsElementCustomForm.vue +0 -0
  157. /package/{components → app/components}/public/cms/element/CmsElementForm.md +0 -0
  158. /package/{components → app/components}/public/cms/element/CmsElementForm.vue +0 -0
  159. /package/{components → app/components}/public/cms/element/CmsElementHtml.vue +0 -0
  160. /package/{components → app/components}/public/cms/element/CmsElementImage.md +0 -0
  161. /package/{components → app/components}/public/cms/element/CmsElementImageGallery.md +0 -0
  162. /package/{components → app/components}/public/cms/element/CmsElementImageGallery3dPlaceholder.vue +0 -0
  163. /package/{components → app/components}/public/cms/element/CmsElementImageSlider.md +0 -0
  164. /package/{components → app/components}/public/cms/element/CmsElementManufacturerLogo.md +0 -0
  165. /package/{components → app/components}/public/cms/element/CmsElementManufacturerLogo.vue +0 -0
  166. /package/{components → app/components}/public/cms/element/CmsElementProductBox.md +0 -0
  167. /package/{components → app/components}/public/cms/element/CmsElementProductDescriptionReviews.md +0 -0
  168. /package/{components → app/components}/public/cms/element/CmsElementProductListing.md +0 -0
  169. /package/{components → app/components}/public/cms/element/CmsElementProductName.md +0 -0
  170. /package/{components → app/components}/public/cms/element/CmsElementProductSlider.md +0 -0
  171. /package/{components → app/components}/public/cms/element/CmsElementSidebarFilter.md +0 -0
  172. /package/{components → app/components}/public/cms/element/CmsElementSidebarFilter.vue +0 -0
  173. /package/{components → app/components}/public/cms/element/CmsElementText.md +0 -0
  174. /package/{components → app/components}/public/cms/element/CmsElementVimeoVideo.md +0 -0
  175. /package/{components → app/components}/public/cms/element/CmsElementVimeoVideo.vue +0 -0
  176. /package/{components → app/components}/public/cms/element/CmsElementYoutubeVideo.md +0 -0
  177. /package/{components → app/components}/public/cms/section/CmsSectionDefault.md +0 -0
  178. /package/{components → app/components}/public/cms/section/CmsSectionSidebar.md +0 -0
  179. /package/{helpers → app/helpers}/html-to-vue/ast.ts +0 -0
  180. /package/{helpers → app/helpers}/html-to-vue/getOptionsFromNode.test.ts +0 -0
  181. /package/{helpers → app/helpers}/html-to-vue/getOptionsFromNode.ts +0 -0
  182. /package/{helpers → app/helpers}/html-to-vue/renderToHtml.ts +0 -0
  183. /package/{helpers → app/helpers}/html-to-vue/renderer.ts +0 -0
  184. /package/{helpers → app/helpers}/media/isSpatial.ts +0 -0
@@ -0,0 +1,106 @@
1
+ <script setup lang="ts">
2
+ import { defu } from "defu";
3
+ import { computed, getCurrentInstance } from "vue";
4
+ import { useCmsTranslations } from "#imports";
5
+
6
+ type Translations = {
7
+ form: {
8
+ quantitySelect: {
9
+ label: string;
10
+ increaseButton: string;
11
+ decreaseButton: string;
12
+ };
13
+ };
14
+ };
15
+
16
+ let translations = {
17
+ form: {
18
+ quantitySelect: {
19
+ label: "Quantity",
20
+ increaseButton: "Increase quantity",
21
+ decreaseButton: "Decrease quantity",
22
+ },
23
+ },
24
+ };
25
+
26
+ translations = defu(useCmsTranslations(), translations) as Translations;
27
+
28
+ const quantity = defineModel<number>({
29
+ required: true,
30
+ });
31
+
32
+ const {
33
+ size = "large",
34
+ steps,
35
+ max,
36
+ min,
37
+ id: propId,
38
+ } = defineProps<{
39
+ size?: "small" | "large";
40
+ steps?: number;
41
+ min?: number;
42
+ max?: number;
43
+ id?: string;
44
+ }>();
45
+
46
+ // generate an id that prefers a provided prop and otherwise uses the component uid
47
+ const inputId = computed(() => {
48
+ if (propId) return propId;
49
+ const uid = Math.random().toString(36).substr(2, 9);
50
+ return `sw-quantity-${uid}`;
51
+ });
52
+
53
+ function increaseQty() {
54
+ quantity.value++;
55
+ }
56
+
57
+ function decreaseQty() {
58
+ if (quantity.value > 1) {
59
+ quantity.value--;
60
+ }
61
+ }
62
+
63
+ const sizeClasses = {
64
+ small: "w-8 h-8",
65
+ large: "w-10 h-10",
66
+ };
67
+ </script>
68
+ <template>
69
+ <div class="rounded outline outline-1 outline-offset-[-1px] outline-outline-outline inline-flex">
70
+ <button
71
+ type="button"
72
+ :class="sizeClasses[size]"
73
+ class="bg-surface-surface border-0 border-r-1 cursor-pointer hover:bg-brand-tertiary-hover font-semibold"
74
+ @click="decreaseQty"
75
+ :aria-label="translations.form.quantitySelect.decreaseButton"
76
+ >
77
+ -
78
+ </button>
79
+ <div class="bg-white border-l border-r border-outline-outline inline-flex flex-col justify-center items-center">
80
+ <!-- visually hidden label for screen readers -->
81
+ <label :for="inputId" class="sr-only">{{ translations.form.quantitySelect.label }}</label>
82
+
83
+ <input
84
+ :id="inputId"
85
+ v-model="quantity"
86
+ type="number"
87
+ :min="min"
88
+ :max="max"
89
+ :step="steps"
90
+ data-testid="product-quantity"
91
+ :class="sizeClasses[size]"
92
+ class="self-stretch text-center justify-start text-surface-on-surface text-xs font-bold leading-[18px] appearance-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
93
+ :aria-label="translations.form.quantitySelect.label"
94
+ />
95
+ </div>
96
+ <button
97
+ type="button"
98
+ :class="sizeClasses[size]"
99
+ class="w-10 bg-surface-surface border-0 border-l-1 cursor-pointer hover:bg-brand-tertiary-hover font-semibold"
100
+ @click="increaseQty"
101
+ :aria-label="translations.form.quantitySelect.increaseButton"
102
+ >
103
+ +
104
+ </button>
105
+ </div>
106
+ </template>
@@ -34,7 +34,7 @@ const props = withDefaults(
34
34
 
35
35
  const { getConfigValue } = useCmsElementConfig({
36
36
  config: props.config,
37
- } as Schemas["CmsSlot"] & {
37
+ } as Omit<Schemas["CmsSlot"], "config"> & {
38
38
  config: SliderElementConfig;
39
39
  });
40
40
 
@@ -63,12 +63,12 @@ const children = computed<string[]>(() => {
63
63
  ] as string[];
64
64
  });
65
65
  const emit = defineEmits<(e: "changeSlide", index: number) => void>();
66
- const slider = useTemplateRef("slider");
67
- const imageSlider = useTemplateRef("imageSlider");
66
+ const slider = useTemplateRef<HTMLDivElement>("slider");
67
+ const imageSlider = useTemplateRef<HTMLDivElement>("imageSlider");
68
68
  const imageSliderTrackStyle = ref<CSSProperties>();
69
69
  const activeSlideIndex = ref<number>(0);
70
70
  const speed = ref<number>(300);
71
- const imageSliderTrack = useTemplateRef("imageSliderTrack");
71
+ const imageSliderTrack = useTemplateRef<HTMLDivElement>("imageSliderTrack");
72
72
  const autoPlayInterval = ref();
73
73
  const isReady = ref<boolean>();
74
74
  const isSliding = ref<boolean>();
@@ -0,0 +1,83 @@
1
+ <script setup lang="ts">
2
+ import { onClickOutside } from "@vueuse/core";
3
+ import { ref, useTemplateRef } from "vue";
4
+
5
+ type SortOption = {
6
+ key: string;
7
+ label: string;
8
+ };
9
+
10
+ defineProps<{
11
+ sortOptions: SortOption[];
12
+ currentSort: string;
13
+ label: string;
14
+ }>();
15
+
16
+ const emit = defineEmits<{
17
+ "sort-change": [string];
18
+ }>();
19
+
20
+ const isSortMenuOpen = ref(false);
21
+ const dropdownElement = useTemplateRef<HTMLDivElement>("dropdownElement");
22
+
23
+ onClickOutside(dropdownElement, () => {
24
+ isSortMenuOpen.value = false;
25
+ });
26
+
27
+ const handleSortingClick = (key: string) => {
28
+ emit("sort-change", key);
29
+ isSortMenuOpen.value = false;
30
+ };
31
+ </script>
32
+
33
+ <template>
34
+ <div ref="dropdownElement" class="flex items-center">
35
+ <div class="relative inline-block text-left">
36
+ <SwBaseButton
37
+ variant="ghost"
38
+ size="medium"
39
+ type="button"
40
+ @click="isSortMenuOpen = !isSortMenuOpen"
41
+ id="menu-button"
42
+ aria-expanded="false"
43
+ aria-haspopup="true"
44
+ class="group pr-0"
45
+ >
46
+ <span class="inline-flex items-center gap-1">
47
+ {{ label }}
48
+ <SwChevronIcon
49
+ :direction="isSortMenuOpen ? 'up' : 'down'"
50
+ :size="24"
51
+ :aria-label="isSortMenuOpen ? 'Close sort menu' : 'Open sort menu'"
52
+ />
53
+ </span>
54
+ </SwBaseButton>
55
+ <div
56
+ :class="[isSortMenuOpen ? 'absolute' : 'hidden']"
57
+ class="origin-top-right right-0 mt-2 w-40 rounded-md shadow-2xl bg-surface-surface ring-1 ring-opacity-dark-low focus:outline-none z-1000"
58
+ role="menu"
59
+ aria-orientation="vertical"
60
+ aria-labelledby="menu-button"
61
+ tabindex="-1"
62
+ >
63
+ <div class="py-1" role="none">
64
+ <button
65
+ v-for="sorting in sortOptions"
66
+ :key="sorting.key"
67
+ @click="handleSortingClick(sorting.key)"
68
+ :class="[
69
+ sorting.key === currentSort
70
+ ? 'font-medium text-surface-on-surface'
71
+ : 'text-surface-on-surface-variant',
72
+ ]"
73
+ class="block px-4 py-2 text-sm bg-transparent hover:bg-surface-surface-container"
74
+ role="menuitem"
75
+ tabindex="-1"
76
+ >
77
+ {{ sorting.label }}
78
+ </button>
79
+ </div>
80
+ </div>
81
+ </div>
82
+ </div>
83
+ </template>
@@ -0,0 +1,44 @@
1
+ <script setup lang="ts">
2
+ import { defu } from "defu";
3
+
4
+ import { useCmsTranslations } from "#imports";
5
+ import type { Schemas } from "#shopware";
6
+
7
+ defineProps<{
8
+ availableStock: number;
9
+ minPurchase: number;
10
+ deliveryTime?: Schemas["DeliveryTime"];
11
+ restockTime?: number;
12
+ }>();
13
+
14
+ type Translations = {
15
+ product: {
16
+ deliveryTime: string;
17
+ days: string;
18
+ noAvailable: string;
19
+ };
20
+ };
21
+
22
+ let translations: Translations = {
23
+ product: {
24
+ deliveryTime: "Available, delivery time",
25
+ days: "days",
26
+ noAvailable: "No longer available",
27
+ },
28
+ };
29
+
30
+ translations = defu(useCmsTranslations(), translations) as Translations;
31
+ </script>
32
+ <template>
33
+ <div class="inline-flex justify-start items-center gap-2">
34
+ <div class="w-2 h-2 bg-states-success rounded-full" v-if="availableStock > 0"></div>
35
+ <div class="w-2 h-2 bg-states-error rounded-full" v-else></div>
36
+ <span v-if="availableStock >= minPurchase && deliveryTime">{{ translations.product.deliveryTime }} {{
37
+ deliveryTime?.name }}
38
+ </span>
39
+ <span v-else-if="availableStock < minPurchase && deliveryTime && restockTime">
40
+ {{ translations.product.deliveryTime }} {{ restockTime }}
41
+ {{ translations.product.days }} {{ deliveryTime?.name }}</span>
42
+ <span v-else>{{ translations.product.noAvailable }}</span>
43
+ </div>
44
+ </template>
@@ -102,7 +102,7 @@ const onHandleChange = async () => {
102
102
  data-testid="product-variant"
103
103
  class="group relative border rounded-md py-3 px-4 flex items-center justify-center text-sm font-medium uppercase hover:bg-gray-50 focus:outline-none sm:flex-1 bg-white shadow-sm text-gray-900 cursor-pointer"
104
104
  :class="{
105
- 'border-3 border-indigo-600': isOptionSelected(option.id),
105
+ 'border-3 border-brand-primary': isOptionSelected(option.id),
106
106
  }"
107
107
  @click="handleChange(optionGroup.translated.name, option.id, onHandleChange)"
108
108
  >
@@ -0,0 +1,214 @@
1
+ <script setup lang="ts" generic="
2
+ ListingFilter extends {
3
+ code: string;
4
+ min?: number;
5
+ max?: number;
6
+ label: string;
7
+ }
8
+ ">
9
+ import { useCmsTranslations } from "@shopware/composables";
10
+ import { onClickOutside, useDebounceFn, useEventListener } from "@vueuse/core";
11
+ import { defu } from "defu";
12
+ import { onMounted, reactive, ref, watch } from "vue";
13
+ import type { Schemas } from "#shopware";
14
+
15
+ const emits =
16
+ defineEmits<
17
+ (e: "select-value", value: { code: string; value: unknown }) => void
18
+ >();
19
+
20
+ const props = defineProps<{
21
+ filter: ListingFilter;
22
+ selectedFilters: Schemas["ProductListingResult"]["currentFilters"];
23
+ }>();
24
+
25
+ type Translations = {
26
+ listing: {
27
+ min: string;
28
+ max: string;
29
+ };
30
+ };
31
+ let translations: Translations = {
32
+ listing: {
33
+ min: "Min",
34
+ max: "Max",
35
+ },
36
+ };
37
+ translations = defu(useCmsTranslations(), translations) as Translations;
38
+
39
+ const prices = reactive<{ min: number; max: number }>({
40
+ min: 0,
41
+ max: 0,
42
+ });
43
+
44
+ onMounted(() => {
45
+ prices.min = Math.floor(
46
+ props.selectedFilters.price?.min ?? props.filter.min ?? 0,
47
+ );
48
+ prices.max = Math.floor(
49
+ props.selectedFilters.price?.max ?? props.filter.max ?? 0,
50
+ );
51
+ });
52
+
53
+ const isFilterVisible = ref<boolean>(false);
54
+ const toggle = () => {
55
+ isFilterVisible.value = !isFilterVisible.value;
56
+ };
57
+
58
+ const dropdownElement = ref(null);
59
+ onClickOutside(dropdownElement, () => {
60
+ isFilterVisible.value = false;
61
+ });
62
+
63
+ function onMinPriceChange(newPrice: number, oldPrice: number) {
64
+ if (newPrice === oldPrice || oldPrice === 0) return;
65
+ emits("select-value", {
66
+ code: "min-price",
67
+ value: newPrice,
68
+ });
69
+ }
70
+ const debounceMinPriceUpdate = useDebounceFn(onMinPriceChange, 500);
71
+ watch(() => prices.min, debounceMinPriceUpdate);
72
+
73
+ function onMaxPriceChange(newPrice: number, oldPrice: number) {
74
+ if (newPrice === oldPrice || oldPrice === 0) return;
75
+ emits("select-value", {
76
+ code: "max-price",
77
+ value: newPrice,
78
+ });
79
+ }
80
+ const debounceMaxPriceUpdate = useDebounceFn(onMaxPriceChange, 500);
81
+ watch(() => prices.max, debounceMaxPriceUpdate);
82
+
83
+ // Slider drag logic
84
+ type DragType = "min" | "max" | null;
85
+ const dragging = ref<DragType>(null);
86
+ const sliderRect = ref<DOMRect | null>(null);
87
+
88
+ const getClientX = (event: MouseEvent | TouchEvent): number =>
89
+ event instanceof MouseEvent ? event.clientX : event.touches[0]?.clientX || 0;
90
+
91
+ const updateSliderValue = (clientX: number) => {
92
+ if (!dragging.value || !sliderRect.value) return;
93
+
94
+ const min = props.filter.min ?? 0;
95
+ const max = props.filter.max ?? 100;
96
+ const percent = Math.min(
97
+ Math.max((clientX - sliderRect.value.left) / sliderRect.value.width, 0),
98
+ 1,
99
+ );
100
+ const value = Math.round(min + percent * (max - min));
101
+
102
+ if (dragging.value === "min") {
103
+ if (value >= min && value <= prices.max) prices.min = value;
104
+ } else {
105
+ if (value <= max && value >= prices.min) prices.max = value;
106
+ }
107
+ };
108
+
109
+ const onDrag = (event: MouseEvent | TouchEvent) => {
110
+ if (!dragging.value) return;
111
+ event.preventDefault();
112
+ updateSliderValue(getClientX(event));
113
+ };
114
+
115
+ const stopDrag = () => {
116
+ dragging.value = null;
117
+ sliderRect.value = null;
118
+ };
119
+
120
+ useEventListener(window, "mousemove", onDrag);
121
+ useEventListener(window, "mouseup", stopDrag);
122
+ useEventListener(window, "touchmove", onDrag, { passive: false });
123
+ useEventListener(window, "touchend", stopDrag);
124
+
125
+ const startDrag = (type: "min" | "max", event: MouseEvent | TouchEvent) => {
126
+ event.preventDefault();
127
+ dragging.value = type;
128
+ const slider = (event.target as HTMLElement).closest(".relative.w-64.h-10");
129
+ if (slider) {
130
+ sliderRect.value = slider.getBoundingClientRect();
131
+ }
132
+ };
133
+ </script>
134
+
135
+ <template>
136
+ <div class="self-stretch flex flex-col justify-start items-start gap-4">
137
+ <div class="self-stretch flex flex-col justify-center items-center">
138
+ <div
139
+ class="self-stretch py-3 border-b border-outline-outline-variant inline-flex justify-between items-center gap-1 cursor-pointer"
140
+ @click="toggle"
141
+ role="button"
142
+ tabindex="0"
143
+ :aria-expanded="isFilterVisible"
144
+ :aria-controls="`filter-${props.filter.code}`"
145
+ @keydown.enter="toggle"
146
+ @keydown.space.prevent="toggle"
147
+ >
148
+ <div class="flex-1 flex items-center gap-2.5">
149
+ <div class="flex-1 text-surface-on-surface text-base font-bold leading-normal text-left">
150
+ {{ props.filter.label }}
151
+ </div>
152
+ </div>
153
+ <SwIconButton
154
+ type="ghost"
155
+ :aria-label="isFilterVisible ? 'Collapse filter' : 'Expand filter'"
156
+ tabindex="-1"
157
+ >
158
+ <SwChevronIcon :direction="isFilterVisible ? 'up' : 'down'" :size="24" />
159
+ </SwIconButton>
160
+ </div>
161
+ </div>
162
+ <transition name="filter-collapse">
163
+ <div v-if="isFilterVisible" :id="props.filter.code"
164
+ class="self-stretch flex flex-col justify-start items-start gap-2.5">
165
+ <div class="self-stretch flex flex-col justify-start items-start gap-1">
166
+ <div class="self-stretch inline-flex justify-between items-center gap-2">
167
+ <div
168
+ class="w-16 h-10 px-2 py-1 rounded-lg outline outline-1 outline-offset-[-1px] outline-outline-outline-variant inline-flex flex-col justify-center items-start gap-2.5">
169
+ <input type="number" :placeholder="translations.listing.min" v-model.number="prices.min"
170
+ class="w-full bg-transparent border-none outline-none text-surface-on-surface text-sm font-normal leading-tight"
171
+ @change="emits('select-value', { code: props.filter.code, value: { min: prices.min, max: prices.max } })"
172
+ :min="props.filter.min" :max="prices.max" />
173
+ </div>
174
+ <div
175
+ class="w-16 h-10 px-2 py-1 rounded-lg outline outline-1 outline-offset-[-1px] outline-outline-outline-variant inline-flex flex-col justify-center items-start gap-2.5">
176
+ <input type="number" :placeholder="translations.listing.max" v-model.number="prices.max"
177
+ class="w-full bg-transparent border-none outline-none text-surface-on-surface text-sm font-normal leading-tight"
178
+ @change="emits('select-value', { code: props.filter.code, value: { min: prices.min, max: prices.max } })"
179
+ :min="prices.min" :max="props.filter.max" />
180
+ </div>
181
+ </div>
182
+ <!-- Custom slider UI -->
183
+ <div class="relative w-64 h-10 mt-2 mx-auto flex items-center select-none">
184
+ <!-- Track -->
185
+ <div
186
+ class="absolute left-0 top-1/2 -translate-y-1/2 w-full h-2 bg-surface-surface-container-highest rounded-full">
187
+ </div>
188
+ <!-- Active range -->
189
+ <div class="absolute top-1/2 -translate-y-1/2 h-2 bg-surface-surface-primary rounded-full" :style="{
190
+ left: ((prices.min - (props.filter.min ?? 0)) / ((props.filter.max ?? 100) - (props.filter.min ?? 0))) * 100 + '%',
191
+ width: ((prices.max - prices.min) / ((props.filter.max ?? 100) - (props.filter.min ?? 0))) * 100 + '%',
192
+ }"></div>
193
+ <!-- Min thumb -->
194
+ <div
195
+ class="absolute top-1/2 -translate-y-1/2 w-5 h-5 bg-brand-primary rounded-full shadow-[2px_2px_10px_0px_rgba(0,0,0,0.15)] cursor-pointer touch-none"
196
+ :style="{
197
+ left: `calc(${((prices.min - (props.filter.min ?? 0)) / ((props.filter.max ?? 100) - (props.filter.min ?? 0))) * 100}% - 10px)`
198
+ }"
199
+ @mousedown.prevent="startDrag('min', $event)"
200
+ @touchstart.prevent="startDrag('min', $event)"></div>
201
+ <!-- Max thumb -->
202
+ <div
203
+ class="absolute top-1/2 -translate-y-1/2 w-5 h-5 bg-brand-primary rounded-full shadow-[2px_2px_10px_0px_rgba(0,0,0,0.15)] cursor-pointer touch-none"
204
+ :style="{
205
+ left: `calc(${((prices.max - (props.filter.min ?? 0)) / ((props.filter.max ?? 100) - (props.filter.min ?? 0))) * 100}% - 10px)`
206
+ }"
207
+ @mousedown.prevent="startDrag('max', $event)"
208
+ @touchstart.prevent="startDrag('max', $event)"></div>
209
+ </div>
210
+ </div>
211
+ </div>
212
+ </transition>
213
+ </div>
214
+ </template>
@@ -0,0 +1,113 @@
1
+ <script
2
+ setup
3
+ lang="ts"
4
+ generic="
5
+ ListingFilter extends {
6
+ code: string;
7
+ label: string;
8
+ name: string;
9
+ options: Array<Schemas['PropertyGroupOption']>;
10
+ entities: Array<Schemas['ProductManufacturer']>;
11
+ }
12
+ "
13
+ >
14
+ import { getTranslatedProperty } from "@shopware/helpers";
15
+ import { computed, ref } from "vue";
16
+ import type { Schemas } from "#shopware";
17
+
18
+ const props = defineProps<{
19
+ filter: ListingFilter;
20
+ selectedFilters: {
21
+ manufacturer?: string[];
22
+ properties?: string[];
23
+ [key: string]: unknown;
24
+ };
25
+ }>();
26
+
27
+ const emits =
28
+ defineEmits<
29
+ (e: "select-value", value: { code: string; value: unknown }) => void
30
+ >();
31
+
32
+ const isFilterVisible = ref<boolean>(false);
33
+ const toggle = () => {
34
+ isFilterVisible.value = !isFilterVisible.value;
35
+ };
36
+
37
+ const selectedIds = computed(() => {
38
+ if (props.filter.code === "manufacturer") {
39
+ return props.selectedFilters?.manufacturer || [];
40
+ }
41
+ return props.selectedFilters?.properties || [];
42
+ });
43
+
44
+ const isChecked = (id: string) => selectedIds.value.includes(id);
45
+
46
+ const selectValue = (id: string) => {
47
+ const emitCode =
48
+ props.filter.code === "manufacturer" ? "manufacturer" : "properties";
49
+ emits("select-value", {
50
+ code: emitCode,
51
+ value: id,
52
+ });
53
+ };
54
+ </script>
55
+
56
+ <template>
57
+ <div class="self-stretch flex flex-col justify-start items-start gap-4">
58
+ <div class="self-stretch flex flex-col justify-center items-center">
59
+ <div
60
+ class="self-stretch py-3 border-b border-outline-outline-variant inline-flex justify-between items-center gap-1 cursor-pointer"
61
+ @click="toggle"
62
+ role="button"
63
+ tabindex="0"
64
+ :aria-expanded="isFilterVisible"
65
+ :aria-controls="props.filter.code"
66
+ :aria-label="props.filter.label"
67
+ @keydown.enter="toggle"
68
+ @keydown.space.prevent="toggle"
69
+ >
70
+ <div class="flex-1 flex items-center gap-2.5">
71
+ <div class="flex-1 text-surface-on-surface text-base font-bold leading-normal text-left">
72
+ {{ props.filter.label }}
73
+ </div>
74
+ </div>
75
+ <SwIconButton
76
+ type="ghost"
77
+ :aria-label="isFilterVisible ? 'Collapse filter' : 'Expand filter'"
78
+ tabindex="-1"
79
+ >
80
+ <SwChevronIcon :direction="isFilterVisible ? 'up' : 'down'" :size="24" />
81
+ </SwIconButton>
82
+ </div>
83
+ </div>
84
+ <transition name="filter-collapse">
85
+ <div v-if="isFilterVisible" :id="props.filter.code" class="self-stretch flex flex-col justify-start items-start gap-4">
86
+ <fieldset class="self-stretch flex flex-col justify-start items-start gap-4">
87
+ <legend class="sr-only">{{ props.filter.name }}</legend>
88
+ <label
89
+ v-for="option in props.filter.options || props.filter.entities"
90
+ :key="`${option.id}-${isChecked(option.id)}`"
91
+ class="self-stretch inline-flex justify-start items-start gap-2 cursor-pointer"
92
+ @click="selectValue(option.id)"
93
+ >
94
+ <div class="w-4 self-stretch pt-[3px] flex justify-start items-start gap-2.5">
95
+ <SwCheckbox
96
+ :model-value="isChecked(option.id)"
97
+ @update:model-value="() => selectValue(option.id)"
98
+ @click.stop
99
+ />
100
+ </div>
101
+ <div class="flex-1 inline-flex flex-col justify-start items-start gap-0.5">
102
+ <div class="inline-flex justify-start items-center gap-1">
103
+ <div class="flex-1 text-surface-on-surface text-base font-normal leading-normal">
104
+ {{ getTranslatedProperty(option, 'name') }}
105
+ </div>
106
+ </div>
107
+ </div>
108
+ </label>
109
+ </fieldset>
110
+ </div>
111
+ </transition>
112
+ </div>
113
+ </template>
@@ -0,0 +1,90 @@
1
+ <script
2
+ setup
3
+ lang="ts"
4
+ generic="
5
+ ListingFilter extends {
6
+ code: string;
7
+ label: string;
8
+ }
9
+ "
10
+ >
11
+ import { computed, ref } from "vue";
12
+ import type { Schemas } from "#shopware";
13
+
14
+ const emits =
15
+ defineEmits<
16
+ (e: "select-value", value: { code: string; value: unknown }) => void
17
+ >();
18
+
19
+ const props = defineProps<{
20
+ filter: ListingFilter;
21
+ selectedFilters: Schemas["ProductListingResult"]["currentFilters"];
22
+ }>();
23
+ const isHoverActive = ref(false);
24
+ const hoveredIndex = ref(0);
25
+ const displayedScore = computed(() =>
26
+ isHoverActive.value ? hoveredIndex.value : props.selectedFilters?.rating || 0,
27
+ );
28
+
29
+ const hoverRating = (key: number) => {
30
+ hoveredIndex.value = key;
31
+ isHoverActive.value = true;
32
+ };
33
+ const onChangeRating = () => {
34
+ const newValue =
35
+ props.selectedFilters?.rating !== hoveredIndex.value
36
+ ? hoveredIndex.value
37
+ : undefined;
38
+ emits("select-value", { code: props.filter?.code, value: newValue });
39
+ };
40
+
41
+ const isFilterVisible = ref<boolean>(false);
42
+ const toggle = () => {
43
+ isFilterVisible.value = !isFilterVisible.value;
44
+ };
45
+ </script>
46
+
47
+ <template>
48
+ <div class="self-stretch flex flex-col justify-start items-start gap-4">
49
+ <div class="self-stretch flex flex-col justify-center items-center">
50
+ <div
51
+ class="self-stretch py-3 border-b border-outline-outline-variant inline-flex justify-between items-center gap-1 cursor-pointer"
52
+ @click="toggle"
53
+ role="button"
54
+ tabindex="0"
55
+ :aria-expanded="isFilterVisible"
56
+ :aria-controls="`filter-rating`"
57
+ @keydown.enter="toggle"
58
+ @keydown.space.prevent="toggle"
59
+ >
60
+ <div class="flex-1 flex items-center gap-2.5">
61
+ <div class="flex-1 text-surface-on-surface text-base font-bold leading-normal text-left">
62
+ {{ props.filter.label }}
63
+ </div>
64
+ </div>
65
+ <SwIconButton
66
+ type="ghost"
67
+ :aria-label="isFilterVisible ? 'Collapse filter' : 'Expand filter'"
68
+ tabindex="-1"
69
+ >
70
+ <SwChevronIcon :direction="isFilterVisible ? 'up' : 'down'" :size="24" />
71
+ </SwIconButton>
72
+ </div>
73
+ </div>
74
+ <transition name="filter-collapse">
75
+ <div v-if="isFilterVisible" class="self-stretch flex flex-col justify-start items-start gap-4">
76
+ <div class="flex flex-row items-center gap-2 mt-2">
77
+ <div
78
+ v-for="i in 5"
79
+ :key="i"
80
+ :class="['h-6 w-6 cursor-pointer', displayedScore >= i ? 'i-carbon-star-filled' : 'i-carbon-star']"
81
+ @mouseleave="isHoverActive = false"
82
+ @click="hoverRating(i); onChangeRating()"
83
+ @mouseover="hoverRating(i)"
84
+ :aria-label="`${i} star${i !== 1 ? 's' : ''}`"
85
+ />
86
+ </div>
87
+ </div>
88
+ </transition>
89
+ </div>
90
+ </template>