@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
@@ -2,7 +2,7 @@
2
2
  import { useCmsTranslations } from "@shopware/composables";
3
3
  import { getCmsTranslate } from "@shopware/helpers";
4
4
  import { defu } from "defu";
5
- import { toRefs } from "vue";
5
+ import { computed, toRefs } from "vue";
6
6
  import {
7
7
  useAddToCart,
8
8
  useCartErrorParamsResolver,
@@ -23,6 +23,7 @@ type Translations = {
23
23
  addedToCart: string;
24
24
  qty: string;
25
25
  addToCart: string;
26
+ productNumber: string;
26
27
  };
27
28
  errors: {
28
29
  [key: string]: string;
@@ -34,6 +35,7 @@ let translations: Translations = {
34
35
  addedToCart: "has been added to cart.",
35
36
  qty: "Qty",
36
37
  addToCart: "Add to cart",
38
+ productNumber: "Product number",
37
39
  },
38
40
  errors: {
39
41
  "product-stock-reached":
@@ -46,6 +48,12 @@ translations = defu(useCmsTranslations(), translations) as Translations;
46
48
  const { product } = toRefs(props);
47
49
  const { addToCart, quantity } = useAddToCart(product);
48
50
 
51
+ const availableStock = computed(() => product.value?.availableStock ?? 0);
52
+ const minPurchase = computed(() => product.value?.minPurchase ?? 0);
53
+ const deliveryTime = computed(() => product.value?.deliveryTime);
54
+ const restockTime = computed(() => product.value?.restockTime);
55
+ const productNumber = computed(() => product.value?.productNumber ?? "");
56
+
49
57
  const addToCartProxy = async () => {
50
58
  await addToCart();
51
59
  const errors = getErrorsCodes();
@@ -63,32 +71,19 @@ const addToCartProxy = async () => {
63
71
  </script>
64
72
 
65
73
  <template>
66
- <div class="flex flex-row mt-10">
67
- <div class="basis-1/4 relative -top-6">
68
- <label for="qty" class="text-sm">{{ translations.product.qty }}</label>
69
- <input
70
- id="qty"
71
- v-model="quantity"
72
- type="number"
73
- :min="product.minPurchase || 1"
74
- :max="product.calculatedMaxPurchase"
75
- :step="product.purchaseSteps || 1"
76
- class="border rounded-md py-2 px-4 border-solid border-1 border-cyan-600 w-full mt-4"
77
- data-testid="product-quantity"
78
- />
79
- </div>
80
- <div class="basis-3/4 ml-4">
81
- <button
82
- :disabled="!product.available"
83
- class="py-2 px-6 w-full mt-4 bg-gradient-to-r from-cyan-500 to-blue-500 transition ease-in-out hover:bg-gradient-to-l duration-300 cursor-pointer border border-transparent rounded-md flex items-center justify-center text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
84
- :class="{
85
- 'opacity-50 cursor-not-allowed': !product.available,
86
- }"
87
- data-testid="add-to-cart-button"
88
- @click="addToCartProxy"
89
- >
90
- 🛍 {{ translations.product.addToCart }}
91
- </button>
74
+ <div class="w-full inline-flex flex-col justify-start items-start gap-8">
75
+ <SwQuantitySelect v-model="quantity" :min="product.minPurchase" :max="product.maxPurchase"
76
+ :steps="product.purchaseSteps" />
77
+ <SwStockInfo :availableStock="availableStock" :minPurchase="minPurchase" :deliveryTime="deliveryTime"
78
+ :restockTime="restockTime" />
79
+ <div class="self-stretch flex flex-col justify-start items-start gap-1">
80
+ <SwBaseButton variant="primary" size="medium" :disabled="!product?.available" block data-testid="add-to-cart-button"
81
+ @click="addToCartProxy">
82
+ {{ translations.product.addToCart }}
83
+ </SwBaseButton>
84
+ <div class="self-stretch text-surface-on-surface text-xs font-normal leading-none">
85
+ {{ translations.product.productNumber }}: {{ productNumber }}
86
+ </div>
92
87
  </div>
93
88
  </div>
94
89
  </template>
@@ -0,0 +1,170 @@
1
+ <script setup lang="ts">
2
+ import { ApiClientError } from "@shopware/api-client";
3
+ import type { BoxLayout, DisplayMode } from "@shopware/composables";
4
+ import { useCmsTranslations } from "@shopware/composables";
5
+ import {
6
+ buildUrlPrefix,
7
+ getProductFromPrice,
8
+ getProductManufacturerName,
9
+ getProductName,
10
+ getProductRoute,
11
+ } from "@shopware/helpers";
12
+ import { getCmsTranslate } from "@shopware/helpers";
13
+ import { defu } from "defu";
14
+ import { computed, ref, toRefs } from "vue";
15
+ import {
16
+ useAddToCart,
17
+ useCartErrorParamsResolver,
18
+ useCartNotification,
19
+ useNotifications,
20
+ useProductWishlist,
21
+ useUrlResolver,
22
+ } from "#imports";
23
+ import type { Schemas } from "#shopware";
24
+
25
+ const { pushSuccess, pushError } = useNotifications();
26
+ const { getErrorsCodes } = useCartNotification();
27
+ const { resolveCartError } = useCartErrorParamsResolver();
28
+
29
+ const props = withDefaults(
30
+ defineProps<{
31
+ product: Schemas["Product"];
32
+ layoutType?: BoxLayout;
33
+ isProductListing?: boolean;
34
+ displayMode?: DisplayMode;
35
+ }>(),
36
+ {
37
+ layoutType: "standard",
38
+ displayMode: "standard",
39
+ isProductListing: false,
40
+ },
41
+ );
42
+
43
+ type Translations = {
44
+ product: {
45
+ addedToWishlist: string;
46
+ removedFromTheWishlist: string;
47
+ reason: string;
48
+ cannotAddToWishlist: string;
49
+ addedToCart: string;
50
+ addToCart: string;
51
+ details: string;
52
+ badges: {
53
+ topseller: string;
54
+ };
55
+ };
56
+ errors: {
57
+ [key: string]: string;
58
+ };
59
+ };
60
+
61
+ let translations: Translations = {
62
+ product: {
63
+ addedToWishlist: "has been added to wishlist.",
64
+ removedFromTheWishlist: "has been removed from wishlist.",
65
+ reason: "Reason",
66
+ cannotAddToWishlist: "cannot be added to wishlist.",
67
+ addedToCart: "has been added to cart.",
68
+ addToCart: "Add to cart",
69
+ details: "Details",
70
+ badges: {
71
+ topseller: "Tip",
72
+ },
73
+ },
74
+ errors: {
75
+ "product-stock-reached":
76
+ "The product {name} is only available {quantity} times",
77
+ },
78
+ };
79
+
80
+ translations = defu(useCmsTranslations(), translations) as Translations;
81
+
82
+ const { product } = toRefs(props);
83
+
84
+ const { addToCart } = useAddToCart(product);
85
+
86
+ const { addToWishlist, removeFromWishlist, isInWishlist } = useProductWishlist(
87
+ product.value.id,
88
+ );
89
+ const isLoading = ref(false);
90
+
91
+ const toggleWishlistProduct = async () => {
92
+ isLoading.value = true;
93
+
94
+ try {
95
+ if (!isInWishlist.value) {
96
+ await addToWishlist();
97
+ pushSuccess(
98
+ `${props.product?.translated.name} ${translations.product.addedToWishlist}`,
99
+ );
100
+ } else {
101
+ await removeFromWishlist();
102
+ pushSuccess(
103
+ `${props.product?.translated.name} ${translations.product.removedFromTheWishlist}`,
104
+ );
105
+ }
106
+ } catch (error) {
107
+ if (error instanceof ApiClientError) {
108
+ const reason = error.details.errors?.[0]?.detail
109
+ ? `${translations.product.reason}: ${error.details.errors?.[0]?.detail}`
110
+ : "";
111
+ return pushError(
112
+ `${props.product?.translated.name} ${translations.product.cannotAddToWishlist}\n${reason}`,
113
+ {
114
+ timeout: 5000,
115
+ },
116
+ );
117
+ }
118
+ } finally {
119
+ isLoading.value = false;
120
+ }
121
+ };
122
+
123
+ const addToCartProxy = async () => {
124
+ await addToCart();
125
+ const errors = getErrorsCodes();
126
+ for (const element of errors) {
127
+ const { messageKey, params } = resolveCartError(element);
128
+ if (translations.errors[messageKey])
129
+ pushError(getCmsTranslate(translations.errors[messageKey], params));
130
+ }
131
+
132
+ if (!errors.length)
133
+ pushSuccess(
134
+ `${props.product?.translated.name} ${translations.product.addedToCart}`,
135
+ );
136
+ };
137
+
138
+ const fromPrice = getProductFromPrice(props.product);
139
+ const productName = computed(() => getProductName({ product: product.value }));
140
+ const productManufacturer = computed(() =>
141
+ getProductManufacturerName(product.value),
142
+ );
143
+
144
+ const { getUrlPrefix } = useUrlResolver();
145
+ const productLink = computed(() =>
146
+ buildUrlPrefix(getProductRoute(product.value), getUrlPrefix()),
147
+ );
148
+ </script>
149
+
150
+ <template>
151
+ <div class="p-px flex flex-col justify-start items-start overflow-hidden">
152
+ <SwProductCardImage
153
+ :product="product"
154
+ :translations="translations"
155
+ :isInWishlist="isInWishlist"
156
+ :isLoading="isLoading"
157
+ :toggleWishlist="toggleWishlistProduct"
158
+ :productLink="productLink"
159
+ />
160
+ <SwProductCardDetails
161
+ :product="product"
162
+ :productName="productName"
163
+ :productManufacturer="productManufacturer"
164
+ :translations="translations"
165
+ :fromPrice="fromPrice"
166
+ :addToCartProxy="addToCartProxy"
167
+ :productLink="productLink"
168
+ />
169
+ </div>
170
+ </template>
@@ -0,0 +1,57 @@
1
+ <script setup lang="ts">
2
+ import type { UrlRouteOutput } from "@shopware/helpers";
3
+ import type { Schemas } from "#shopware";
4
+
5
+ type Translations = {
6
+ product: {
7
+ addToCart: string;
8
+ details: string;
9
+ };
10
+ errors: {
11
+ [key: string]: string;
12
+ };
13
+ };
14
+
15
+ defineProps<{
16
+ product: Schemas["Product"];
17
+ productName: string | null;
18
+ productManufacturer?: string | null;
19
+ translations: Translations;
20
+ fromPrice?: number;
21
+ addToCartProxy: () => Promise<void>;
22
+ productLink: UrlRouteOutput;
23
+ }>();
24
+ </script>
25
+ <template>
26
+ <div class="self-stretch p-2 flex flex-col justify-between items-start gap-4 flex-1">
27
+ <div class="self-stretch flex flex-col justify-start items-start gap-4">
28
+ <div class="self-stretch flex flex-col justify-start items-start gap-2">
29
+ <div class="self-stretch flex flex-col justify-start items-start gap-1">
30
+ <div v-if="productManufacturer"
31
+ class="self-stretch text-surface-on-surface text-sm font-bold leading-tight">
32
+ {{ productManufacturer }}
33
+ </div>
34
+
35
+ <RouterLink :to="productLink"
36
+ class="self-stretch text-surface-on-surface text-2xl font-normal font-serif leading-9 overflow-hidden line-clamp-2 break-words"
37
+ data-testid="product-box-product-name-link">
38
+ {{ productName }}
39
+ </RouterLink>
40
+ </div>
41
+ </div>
42
+
43
+ <SwListingProductPrice :product="product" data-testid="product-box-product-price" />
44
+ </div>
45
+
46
+ <SwBaseButton variant="primary" v-if="!fromPrice" size="medium" :disabled="!product?.available" block
47
+ data-testid="add-to-cart-button" @click="addToCartProxy">
48
+ {{ translations.product.addToCart }}
49
+ </SwBaseButton>
50
+
51
+ <RouterLink v-else :to="productLink" class="self-stretch">
52
+ <SwBaseButton block>
53
+ {{ translations.product.details }}
54
+ </SwBaseButton>
55
+ </RouterLink>
56
+ </div>
57
+ </template>
@@ -0,0 +1,87 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ type UrlRouteOutput,
4
+ getSmallestThumbnailUrl,
5
+ isProductOnSale,
6
+ isProductTopSeller,
7
+ } from "@shopware/helpers";
8
+ import { useElementSize } from "@vueuse/core";
9
+ import { computed, useTemplateRef } from "vue";
10
+ import { useImagePlaceholder } from "#imports";
11
+ import type { Schemas } from "#shopware";
12
+
13
+ type Translations = {
14
+ product: {
15
+ badges: {
16
+ topseller: string;
17
+ };
18
+ };
19
+ };
20
+
21
+ const props = defineProps<{
22
+ product: Schemas["Product"];
23
+ translations: Translations;
24
+ isInWishlist: boolean;
25
+ isLoading: boolean;
26
+ toggleWishlist: () => Promise<void>;
27
+ productLink: UrlRouteOutput;
28
+ }>();
29
+
30
+ const containerElement = useTemplateRef<HTMLDivElement>("containerElement");
31
+ const { width, height } = useElementSize(containerElement);
32
+
33
+ const DEFAULT_THUMBNAIL_SIZE = 10;
34
+ function roundUp(num: number) {
35
+ return num ? Math.ceil(num / 100) * 100 : DEFAULT_THUMBNAIL_SIZE;
36
+ }
37
+
38
+ const coverSrcPath = computed(() => {
39
+ return getSmallestThumbnailUrl(props.product?.cover?.media);
40
+ });
41
+
42
+ const imageModifiers = computed(() => {
43
+ // Use the larger dimension and apply 2x for high-DPI displays
44
+ // For square containers, width and height should be the same
45
+ const containerSize = Math.max(width.value || 0, height.value || 0);
46
+ const size = roundUp(containerSize * 2);
47
+ return {
48
+ width: size,
49
+ height: size,
50
+ };
51
+ });
52
+
53
+ const coverAlt = computed(() => {
54
+ return props.product?.cover?.media?.alt || props.product?.translated?.name;
55
+ });
56
+
57
+ const isOnSale = computed(() => isProductOnSale(props.product));
58
+ const isTopseller = computed(() => isProductTopSeller(props.product));
59
+
60
+ const placeholderSvg = useImagePlaceholder();
61
+ </script>
62
+
63
+ <template>
64
+ <div ref="containerElement" class="self-stretch min-h-[350px] relative flex flex-col justify-start items-start overflow-hidden aspect-square">
65
+ <RouterLink :to="productLink" class="self-stretch h-full relative overflow-hidden">
66
+ <NuxtImg preset="productCard"
67
+ class="w-full h-full absolute top-0 left-0 object-cover"
68
+ :placeholder="placeholderSvg"
69
+ :src="coverSrcPath" :alt="coverAlt" :modifiers="imageModifiers" data-testid="product-box-img" />
70
+ </RouterLink>
71
+
72
+ <div v-if="isTopseller || isOnSale"
73
+ class="px-1.5 py-1 left-2 bottom-2 absolute bg-other-sale rounded inline-flex justify-center items-center">
74
+ <div class="text-states-on-error text-xs font-bold leading-none">
75
+ {{ translations.product.badges.topseller }}
76
+ </div>
77
+ </div>
78
+
79
+ <client-only>
80
+ <SwIconButton type="secondary" aria-label="Toggle wishlist" :disabled="isLoading"
81
+ class="w-10 h-10 right-4 top-4 absolute bg-brand-secondary rounded-full flex items-center justify-center"
82
+ data-testid="product-box-toggle-wishlist-button" @click="toggleWishlist">
83
+ <SwWishlistIcon :filled="isInWishlist" />
84
+ </SwIconButton>
85
+ </client-only>
86
+ </div>
87
+ </template>
@@ -0,0 +1,33 @@
1
+ <template>
2
+ <div class="inline-flex flex-col items-start justify-start self-stretch overflow-hidden p-px w-full"
3
+ aria-hidden="true">
4
+ <!-- image skeleton -->
5
+ <div class="relative flex h-80 flex-col items-start justify-start self-stretch overflow-hidden">
6
+ <div class="relative h-80 w-full bg-gray-200 dark:bg-gray-700 animate-pulse"></div>
7
+
8
+ <div
9
+ class="absolute top-[281px] left-2 inline-flex items-center justify-center rounded bg-gray-300 dark:bg-gray-600 px-3 py-1 animate-pulse">
10
+ <span class="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded block"></span>
11
+ </div>
12
+
13
+ <div class="absolute top-4 right-4 h-10 w-10 rounded-full bg-gray-300 dark:bg-gray-600 animate-pulse"></div>
14
+ </div>
15
+
16
+ <!-- details skeleton -->
17
+ <div class="flex flex-col items-start justify-start gap-4 self-stretch p-2">
18
+ <div class="h-4 w-32 rounded bg-gray-200 dark:bg-gray-700 animate-pulse"></div>
19
+
20
+ <div class="w-full">
21
+ <div class="h-7 w-3/4 rounded bg-gray-200 dark:bg-gray-700 mb-2 animate-pulse"></div>
22
+ <div class="h-7 w-1/2 rounded bg-gray-200 dark:bg-gray-700 animate-pulse"></div>
23
+ </div>
24
+
25
+ <div class="h-6 w-24 rounded bg-gray-200 dark:bg-gray-700 animate-pulse"></div>
26
+
27
+ <div class="flex w-full gap-3">
28
+ <div class="flex-1 h-10 rounded bg-gray-200 dark:bg-gray-700 animate-pulse"></div>
29
+ <div class="w-24 h-10 rounded bg-gray-200 dark:bg-gray-700 animate-pulse"></div>
30
+ </div>
31
+ </div>
32
+ </div>
33
+ </template>
@@ -0,0 +1,64 @@
1
+ <script setup lang="ts" generic="ListingFilter extends { code: string }">
2
+ import { computed } from "vue";
3
+ import type { Component } from "vue";
4
+ import SwFilterPriceVue from "./listing-filters/SwFilterPrice.vue";
5
+ import SwFilterPropertiesVue from "./listing-filters/SwFilterProperties.vue";
6
+ import SwFilterRatingVue from "./listing-filters/SwFilterRating.vue";
7
+ import SwFilterShippingFreeVue from "./listing-filters/SwFilterShippingFree.vue";
8
+
9
+ const props = defineProps<{
10
+ filter: ListingFilter;
11
+ selectedManufacturer: Set<string>;
12
+ selectedProperties: Set<string>;
13
+ selectedMinPrice: number | undefined;
14
+ selectedMaxPrice: number | undefined;
15
+ selectedRating: number | undefined;
16
+ selectedShippingFree: boolean | undefined;
17
+ }>();
18
+
19
+ const emit = defineEmits<{
20
+ "filter-change": [{ code: string; value: string | number | boolean }];
21
+ }>();
22
+
23
+ const transformedFilters = computed(() => ({
24
+ price: {
25
+ min: props.selectedMinPrice,
26
+ max: props.selectedMaxPrice,
27
+ },
28
+ rating: props.selectedRating,
29
+ "shipping-free": props.selectedShippingFree,
30
+ manufacturer: [...props.selectedManufacturer],
31
+ properties: [...props.selectedProperties],
32
+ }));
33
+
34
+ const filterComponent = computed<Component | undefined>(() => {
35
+ const componentMap: Record<string, Component> = {
36
+ manufacturer: SwFilterPropertiesVue,
37
+ price: SwFilterPriceVue,
38
+ rating: SwFilterRatingVue,
39
+ "shipping-free": SwFilterShippingFreeVue,
40
+ };
41
+
42
+ return (
43
+ componentMap[props.filter.code] ||
44
+ ("options" in props.filter ? SwFilterPropertiesVue : undefined)
45
+ );
46
+ });
47
+
48
+ const handleSelectValue = ({
49
+ code,
50
+ value,
51
+ }: { code: string; value: string | number | boolean }) => {
52
+ emit("filter-change", { code, value });
53
+ };
54
+ </script>
55
+ <template>
56
+ <div>
57
+ <component
58
+ :is="filterComponent"
59
+ :filter="filter"
60
+ :selected-filters="transformedFilters"
61
+ @select-value="handleSelectValue"
62
+ />
63
+ </div>
64
+ </template>