@shopware/cms-base-layer 1.5.1 → 2.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 (198) hide show
  1. package/README.md +398 -12
  2. package/app/app.config.ts +18 -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 +83 -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/SwFilterDropdown.vue +54 -0
  15. package/app/components/SwListingProductPrice.vue +89 -0
  16. package/{components → app/components}/SwMedia3D.vue +4 -2
  17. package/{components → app/components}/SwNewsletterForm.vue +45 -34
  18. package/{components → app/components}/SwPagination.vue +3 -5
  19. package/{components → app/components}/SwProductAddToCart.vue +22 -27
  20. package/app/components/SwProductCard.vue +169 -0
  21. package/app/components/SwProductCardDetails.vue +74 -0
  22. package/app/components/SwProductCardImage.vue +90 -0
  23. package/app/components/SwProductCardSkeleton.vue +33 -0
  24. package/app/components/SwProductGallery.vue +43 -0
  25. package/app/components/SwProductListingFilter.vue +75 -0
  26. package/app/components/SwProductListingFilters.vue +304 -0
  27. package/app/components/SwProductListingFiltersHorizontal.vue +306 -0
  28. package/{components → app/components}/SwProductPrice.vue +3 -3
  29. package/app/components/SwProductRating.vue +40 -0
  30. package/{components → app/components}/SwProductReviews.vue +25 -23
  31. package/app/components/SwProductReviewsForm.vue +292 -0
  32. package/{components → app/components}/SwProductUnits.vue +10 -15
  33. package/app/components/SwQuantitySelect.vue +103 -0
  34. package/{components → app/components}/SwSlider.vue +154 -55
  35. package/app/components/SwSortDropdown.vue +87 -0
  36. package/app/components/SwStockInfo.vue +44 -0
  37. package/{components → app/components}/SwVariantConfigurator.vue +13 -12
  38. package/app/components/listing-filters/SwFilterPrice.vue +219 -0
  39. package/app/components/listing-filters/SwFilterProperties.vue +120 -0
  40. package/app/components/listing-filters/SwFilterRating.vue +99 -0
  41. package/app/components/listing-filters/SwFilterShippingFree.vue +114 -0
  42. package/app/components/public/cms/CmsBlockSpatialViewer.vue +94 -0
  43. package/app/components/public/cms/CmsGenericBlock.md +42 -0
  44. package/{components → app/components}/public/cms/CmsGenericBlock.vue +15 -1
  45. package/{components → app/components}/public/cms/CmsPage.md +19 -2
  46. package/{components → app/components}/public/cms/CmsPage.vue +30 -5
  47. package/{components → app/components}/public/cms/block/CmsBlockCenterText.vue +1 -1
  48. package/{components → app/components}/public/cms/block/CmsBlockGalleryBuybox.vue +5 -5
  49. package/{components → app/components}/public/cms/block/CmsBlockImageBubbleRow.vue +5 -5
  50. package/app/components/public/cms/block/CmsBlockImageFourColumn.vue +41 -0
  51. package/app/components/public/cms/block/CmsBlockImageGalleryBig.vue +42 -0
  52. package/app/components/public/cms/block/CmsBlockImageHighlightRow.vue +37 -0
  53. package/{components → app/components}/public/cms/block/CmsBlockImageSimpleGrid.vue +11 -5
  54. package/{components → app/components}/public/cms/block/CmsBlockImageText.vue +7 -3
  55. package/{components → app/components}/public/cms/block/CmsBlockImageTextBubble.vue +13 -16
  56. package/{components → app/components}/public/cms/block/CmsBlockImageTextCover.vue +7 -9
  57. package/app/components/public/cms/block/CmsBlockImageTextGallery.vue +88 -0
  58. package/app/components/public/cms/block/CmsBlockImageTextRow.vue +53 -0
  59. package/{components → app/components}/public/cms/block/CmsBlockImageThreeColumn.vue +10 -4
  60. package/app/components/public/cms/block/CmsBlockImageThreeCover.vue +37 -0
  61. package/app/components/public/cms/block/CmsBlockImageTwoColumn.vue +37 -0
  62. package/{components → app/components}/public/cms/block/CmsBlockProductHeading.vue +1 -1
  63. package/{components → app/components}/public/cms/block/CmsBlockProductThreeColumn.vue +10 -4
  64. package/{components → app/components}/public/cms/block/CmsBlockSidebarFilter.vue +3 -1
  65. package/{components → app/components}/public/cms/block/CmsBlockTextOnImage.vue +8 -5
  66. package/app/components/public/cms/element/CmsElementBuyBox.vue +145 -0
  67. package/app/components/public/cms/element/CmsElementCategoryNavigation.vue +53 -0
  68. package/{components → app/components}/public/cms/element/CmsElementCrossSelling.vue +22 -6
  69. package/{components → app/components}/public/cms/element/CmsElementImage.vue +58 -21
  70. package/app/components/public/cms/element/CmsElementImageGallery.vue +225 -0
  71. package/{components → app/components}/public/cms/element/CmsElementImageSlider.vue +2 -2
  72. package/{components → app/components}/public/cms/element/CmsElementProductBox.vue +8 -1
  73. package/app/components/public/cms/element/CmsElementProductDescriptionReviews.vue +217 -0
  74. package/{components → app/components}/public/cms/element/CmsElementProductListing.vue +31 -95
  75. package/app/components/public/cms/element/CmsElementProductName.vue +16 -0
  76. package/app/components/public/cms/element/CmsElementProductSlider.vue +101 -0
  77. package/app/components/public/cms/element/CmsElementSidebarFilter.vue +20 -0
  78. package/{components → app/components}/public/cms/element/CmsElementText.vue +17 -12
  79. package/app/components/public/cms/element/SwProductListingPagination.vue +70 -0
  80. package/{components → app/components}/public/cms/section/CmsSectionDefault.vue +2 -2
  81. package/app/components/public/cms/section/CmsSectionSidebar.vue +39 -0
  82. package/app/components/public/cms/skeleton/ProductCardSkeleton.vue +28 -0
  83. package/app/components/ui/BaseButton.vue +102 -0
  84. package/app/components/ui/BaseIcon.vue +15 -0
  85. package/app/components/ui/Checkbox.vue +49 -0
  86. package/app/components/ui/CheckmarkIcon.vue +23 -0
  87. package/app/components/ui/ChevronIcon.vue +34 -0
  88. package/app/components/ui/ExclamationIcon.vue +11 -0
  89. package/app/components/ui/IconButton.vue +32 -0
  90. package/app/components/ui/RadioButton.vue +26 -0
  91. package/app/components/ui/StarIcon.vue +18 -0
  92. package/app/components/ui/SwitchButton.vue +100 -0
  93. package/app/components/ui/UserIcon.vue +11 -0
  94. package/app/components/ui/WishlistIcon.vue +15 -0
  95. package/app/composables/useImagePlaceholder.ts +27 -0
  96. package/app/composables/useLcpImagePreload.test.ts +229 -0
  97. package/app/composables/useLcpImagePreload.ts +39 -0
  98. package/{helpers → app/helpers}/clientOnly.ts +5 -0
  99. package/app/helpers/cms/findFirstCmsImageUrl.ts +86 -0
  100. package/app/helpers/cms/getImageSizes.test.ts +50 -0
  101. package/app/helpers/cms/getImageSizes.ts +36 -0
  102. package/app/helpers/html-to-vue/ast.ts +106 -0
  103. package/{helpers → app/helpers}/html-to-vue/getOptionsFromNode.ts +1 -1
  104. package/{helpers → app/helpers}/html-to-vue/renderToHtml.ts +7 -11
  105. package/app/helpers/html-to-vue/renderer.ts +116 -0
  106. package/app/plugins/unocss-runtime.client.ts +23 -0
  107. package/app/providers/shopware.test.ts +213 -0
  108. package/app/providers/shopware.ts +107 -0
  109. package/dist/index.d.mts +3 -3
  110. package/dist/index.d.ts +3 -3
  111. package/dist/index.mjs +2 -2
  112. package/index.d.ts +36 -0
  113. package/nuxt.config.ts +100 -6
  114. package/package.json +33 -23
  115. package/uno.config.ts +94 -0
  116. package/components/SwCategoryNavigation.vue +0 -44
  117. package/components/SwCategoryNavigationLink.vue +0 -57
  118. package/components/SwListingProductPrice.vue +0 -89
  119. package/components/SwProductCard.vue +0 -286
  120. package/components/SwProductGallery.vue +0 -39
  121. package/components/SwProductListingFilter.vue +0 -42
  122. package/components/SwProductListingFilters.vue +0 -292
  123. package/components/listing-filters/SwFilterPrice.vue +0 -160
  124. package/components/listing-filters/SwFilterProperties.vue +0 -123
  125. package/components/listing-filters/SwFilterRating.vue +0 -101
  126. package/components/listing-filters/SwFilterShippingFree.vue +0 -104
  127. package/components/public/cms/CmsGenericBlock.md +0 -27
  128. package/components/public/cms/block/CmsBlockImageFourColumn.vue +0 -29
  129. package/components/public/cms/block/CmsBlockImageHighlightRow.vue +0 -27
  130. package/components/public/cms/block/CmsBlockImageTextGallery.vue +0 -85
  131. package/components/public/cms/block/CmsBlockImageTextRow.vue +0 -43
  132. package/components/public/cms/block/CmsBlockImageThreeCover.vue +0 -27
  133. package/components/public/cms/block/CmsBlockImageTwoColumn.vue +0 -25
  134. package/components/public/cms/element/CmsElementBuyBox.vue +0 -190
  135. package/components/public/cms/element/CmsElementCategoryNavigation.vue +0 -167
  136. package/components/public/cms/element/CmsElementImageGallery.vue +0 -249
  137. package/components/public/cms/element/CmsElementProductDescriptionReviews.vue +0 -123
  138. package/components/public/cms/element/CmsElementProductName.vue +0 -10
  139. package/components/public/cms/element/CmsElementProductSlider.vue +0 -80
  140. package/components/public/cms/element/CmsElementSidebarFilter.vue +0 -12
  141. package/components/public/cms/section/CmsSectionSidebar.vue +0 -41
  142. package/components/public/cms/skeleton/ProductCardSkeleton.vue +0 -44
  143. package/helpers/html-to-vue/ast.ts +0 -72
  144. package/helpers/html-to-vue/renderer.ts +0 -56
  145. /package/{components → app/components}/SwSharedPrice.vue +0 -0
  146. /package/{components → app/components}/public/cms/CmsGenericElement.md +0 -0
  147. /package/{components → app/components}/public/cms/CmsGenericElement.vue +0 -0
  148. /package/{components → app/components}/public/cms/CmsNoComponent.vue +0 -0
  149. /package/{components → app/components}/public/cms/block/CmsBlockCategoryNavigation.vue +0 -0
  150. /package/{components → app/components}/public/cms/block/CmsBlockCrossSelling.vue +0 -0
  151. /package/{components → app/components}/public/cms/block/CmsBlockCustomForm.vue +0 -0
  152. /package/{components → app/components}/public/cms/block/CmsBlockDefault.vue +0 -0
  153. /package/{components → app/components}/public/cms/block/CmsBlockForm.vue +0 -0
  154. /package/{components → app/components}/public/cms/block/CmsBlockHtml.vue +0 -0
  155. /package/{components → app/components}/public/cms/block/CmsBlockImage.vue +0 -0
  156. /package/{components → app/components}/public/cms/block/CmsBlockImageCover.vue +0 -0
  157. /package/{components → app/components}/public/cms/block/CmsBlockImageGallery.vue +0 -0
  158. /package/{components → app/components}/public/cms/block/CmsBlockImageSlider.vue +0 -0
  159. /package/{components → app/components}/public/cms/block/CmsBlockProductDescriptionReviews.vue +0 -0
  160. /package/{components → app/components}/public/cms/block/CmsBlockProductListing.vue +0 -0
  161. /package/{components → app/components}/public/cms/block/CmsBlockProductSlider.vue +0 -0
  162. /package/{components → app/components}/public/cms/block/CmsBlockText.vue +0 -0
  163. /package/{components → app/components}/public/cms/block/CmsBlockTextHero.vue +0 -0
  164. /package/{components → app/components}/public/cms/block/CmsBlockTextTeaser.vue +0 -0
  165. /package/{components → app/components}/public/cms/block/CmsBlockTextTeaserSection.vue +0 -0
  166. /package/{components → app/components}/public/cms/block/CmsBlockTextThreeColumn.vue +0 -0
  167. /package/{components → app/components}/public/cms/block/CmsBlockTextTwoColumn.vue +0 -0
  168. /package/{components → app/components}/public/cms/block/CmsBlockVimeoVideo.vue +0 -0
  169. /package/{components → app/components}/public/cms/block/CmsBlockYoutubeVideo.vue +0 -0
  170. /package/{components → app/components}/public/cms/element/CmsElementBuyBox.md +0 -0
  171. /package/{components → app/components}/public/cms/element/CmsElementCategoryNavigation.md +0 -0
  172. /package/{components → app/components}/public/cms/element/CmsElementCrossSelling.md +0 -0
  173. /package/{components → app/components}/public/cms/element/CmsElementCustomForm.md +0 -0
  174. /package/{components → app/components}/public/cms/element/CmsElementCustomForm.vue +0 -0
  175. /package/{components → app/components}/public/cms/element/CmsElementForm.md +0 -0
  176. /package/{components → app/components}/public/cms/element/CmsElementForm.vue +0 -0
  177. /package/{components → app/components}/public/cms/element/CmsElementHtml.vue +0 -0
  178. /package/{components → app/components}/public/cms/element/CmsElementImage.md +0 -0
  179. /package/{components → app/components}/public/cms/element/CmsElementImageGallery.md +0 -0
  180. /package/{components → app/components}/public/cms/element/CmsElementImageGallery3dPlaceholder.vue +0 -0
  181. /package/{components → app/components}/public/cms/element/CmsElementImageSlider.md +0 -0
  182. /package/{components → app/components}/public/cms/element/CmsElementManufacturerLogo.md +0 -0
  183. /package/{components → app/components}/public/cms/element/CmsElementManufacturerLogo.vue +0 -0
  184. /package/{components → app/components}/public/cms/element/CmsElementProductBox.md +0 -0
  185. /package/{components → app/components}/public/cms/element/CmsElementProductDescriptionReviews.md +0 -0
  186. /package/{components → app/components}/public/cms/element/CmsElementProductListing.md +0 -0
  187. /package/{components → app/components}/public/cms/element/CmsElementProductName.md +0 -0
  188. /package/{components → app/components}/public/cms/element/CmsElementProductSlider.md +0 -0
  189. /package/{components → app/components}/public/cms/element/CmsElementSidebarFilter.md +0 -0
  190. /package/{components → app/components}/public/cms/element/CmsElementText.md +0 -0
  191. /package/{components → app/components}/public/cms/element/CmsElementVimeoVideo.md +0 -0
  192. /package/{components → app/components}/public/cms/element/CmsElementVimeoVideo.vue +0 -0
  193. /package/{components → app/components}/public/cms/element/CmsElementYoutubeVideo.md +0 -0
  194. /package/{components → app/components}/public/cms/element/CmsElementYoutubeVideo.vue +0 -0
  195. /package/{components → app/components}/public/cms/section/CmsSectionDefault.md +0 -0
  196. /package/{components → app/components}/public/cms/section/CmsSectionSidebar.md +0 -0
  197. /package/{helpers → app/helpers}/html-to-vue/getOptionsFromNode.test.ts +0 -0
  198. /package/{helpers → app/helpers}/media/isSpatial.ts +0 -0
@@ -0,0 +1,225 @@
1
+ <script setup lang="ts">
2
+ import type { CmsElementImageGallery } from "@shopware/composables";
3
+ import { computed, defineAsyncComponent, ref } from "vue";
4
+ import { useCmsElementConfig, useImagePlaceholder } from "#imports";
5
+ import { isSpatial } from "../../../../helpers/media/isSpatial";
6
+
7
+ // Load SwMedia3D only on client-side to avoid SSR issues with three.js packages
8
+ const SwMedia3DAsync = defineAsyncComponent(
9
+ () => import("../../../SwMedia3D.vue"),
10
+ );
11
+
12
+ const props = defineProps<{
13
+ content: CmsElementImageGallery;
14
+ }>();
15
+
16
+ const { getConfigValue } = useCmsElementConfig(props.content);
17
+
18
+ const DEFAULT_MIN_HEIGHT = "500px";
19
+ const DEFAULT_NAVIGATION = "inside";
20
+
21
+ const minHeight = computed(
22
+ () => getConfigValue("minHeight") || DEFAULT_MIN_HEIGHT,
23
+ );
24
+ const navigationArrows = computed(
25
+ () => getConfigValue("navigationArrows") || DEFAULT_NAVIGATION,
26
+ );
27
+ const navigationDots = computed(
28
+ () => getConfigValue("navigationDots") || DEFAULT_NAVIGATION,
29
+ );
30
+
31
+ const currentIndex = ref(0);
32
+ const mediaGallery = computed(() => props.content.data?.sliderItems ?? []);
33
+ const placeholderSvg = useImagePlaceholder();
34
+
35
+ function goToSlide(index: number) {
36
+ if (index >= 0 && index < mediaGallery.value.length) {
37
+ currentIndex.value = index;
38
+ }
39
+ }
40
+
41
+ function previous() {
42
+ if (currentIndex.value > 0) {
43
+ currentIndex.value--;
44
+ }
45
+ }
46
+
47
+ function next() {
48
+ if (currentIndex.value < mediaGallery.value.length - 1) {
49
+ currentIndex.value++;
50
+ }
51
+ }
52
+
53
+ const currentImage = computed(() => {
54
+ return mediaGallery.value[currentIndex.value]?.media;
55
+ });
56
+
57
+ // Touch event handling for mobile swipe gestures
58
+ const touchStartX = ref(0);
59
+ const touchEndX = ref(0);
60
+
61
+ function onTouchStart(event: TouchEvent) {
62
+ touchStartX.value = event.touches?.[0]?.clientX || 0;
63
+ }
64
+
65
+ function onTouchMove(event: TouchEvent) {
66
+ touchEndX.value = event?.touches?.[0]?.clientX || 0;
67
+ }
68
+
69
+ function onTouchEnd() {
70
+ const deltaX = touchEndX.value - touchStartX.value;
71
+ const threshold = 50; // pixels
72
+
73
+ if (Math.abs(deltaX) > threshold) {
74
+ if (deltaX < 0) {
75
+ next();
76
+ } else {
77
+ previous();
78
+ }
79
+ }
80
+
81
+ touchStartX.value = 0;
82
+ touchEndX.value = 0;
83
+ }
84
+ </script>
85
+
86
+ <template>
87
+ <div
88
+ class="w-full max-w-full relative inline-flex flex-col justify-center items-center gap-2 mx-auto"
89
+ >
90
+ <div class="w-full">
91
+ <!-- Main Image Display -->
92
+ <div
93
+ class="w-full relative overflow-hidden"
94
+ :style="{ minHeight }"
95
+ @touchstart="onTouchStart"
96
+ @touchmove="onTouchMove"
97
+ @touchend="onTouchEnd"
98
+ >
99
+ <Transition name="gallery-fade" mode="out-in">
100
+ <!-- 3D media -->
101
+ <div
102
+ v-if="currentImage && isSpatial(currentImage)"
103
+ :key="currentImage.url + '-3d'"
104
+ class="w-full h-full relative"
105
+ :style="{ minHeight }"
106
+ >
107
+ <client-only>
108
+ <SwMedia3DAsync :src="currentImage.url" />
109
+ <template #fallback>
110
+ <CmsElementImageGallery3dPlaceholder
111
+ class="w-full h-full absolute inset-0 object-cover"
112
+ />
113
+ <span
114
+ class="absolute bottom-4 right-4 text-sm bg-gray-800 rounded px-2 py-1 text-white"
115
+ >
116
+ 3D
117
+ </span>
118
+ </template>
119
+ </client-only>
120
+ </div>
121
+ <!-- Regular image -->
122
+ <NuxtImg
123
+ v-else-if="currentImage"
124
+ :key="currentImage.url"
125
+ preset="hero"
126
+ loading="lazy"
127
+ class="w-full h-full absolute inset-0 object-cover"
128
+ :placeholder="placeholderSvg"
129
+ :src="currentImage.url"
130
+ :alt="currentImage.alt || 'Product image'"
131
+ />
132
+ <!-- Placeholder -->
133
+ <img
134
+ v-else
135
+ class="w-full h-full absolute inset-0 object-cover"
136
+ :src="placeholderSvg"
137
+ alt="Placeholder image"
138
+ />
139
+ </Transition>
140
+ </div>
141
+
142
+ <!-- Navigation Arrows -->
143
+ <div
144
+ v-if="mediaGallery.length > 1 && navigationArrows !== 'none'"
145
+ class="absolute inset-0 flex items-center justify-between px-2 sm:px-4 pointer-events-none"
146
+ >
147
+ <!-- Previous Button -->
148
+ <button
149
+ :class="[
150
+ 'w-10 h-10 rounded-full transition disabled:opacity-50 pointer-events-auto shadow-lg flex items-center justify-center',
151
+ navigationArrows === 'outside'
152
+ ? 'bg-brand-tertiary text-surface-on-surface'
153
+ : 'bg-surface-surface/20 hover:bg-surface-surface/50',
154
+ ]"
155
+ :disabled="currentIndex === 0"
156
+ aria-label="Previous image"
157
+ @click="previous"
158
+ >
159
+ <SwChevronIcon direction="left" />
160
+ </button>
161
+
162
+ <!-- Next Button -->
163
+ <button
164
+ :class="[
165
+ 'w-10 h-10 rounded-full transition disabled:opacity-50 pointer-events-auto shadow-lg flex items-center justify-center',
166
+ navigationArrows === 'outside'
167
+ ? 'bg-brand-tertiary text-surface-on-surface'
168
+ : 'bg-surface-surface/20 hover:bg-surface-surface/50',
169
+ ]"
170
+ :disabled="currentIndex === mediaGallery.length - 1"
171
+ aria-label="Next image"
172
+ @click="next"
173
+ >
174
+ <SwChevronIcon direction="right" />
175
+ </button>
176
+ </div>
177
+
178
+ <!-- Dot Indicators -->
179
+ <div
180
+ v-if="mediaGallery.length > 1 && navigationDots !== 'none'"
181
+ :class="[
182
+ 'flex justify-center items-center gap-2',
183
+ navigationDots === 'outside' ? 'mt-4' : 'absolute bottom-4 left-1/2 transform -translate-x-1/2',
184
+ ]"
185
+ >
186
+ <button
187
+ v-for="(image, index) in mediaGallery"
188
+ :key="image.media?.url"
189
+ class="relative rounded-full transition-all duration-200 hover:scale-110"
190
+ :class="{
191
+ 'w-6 h-2 bg-surface-on-surface-variant': index === currentIndex,
192
+ 'w-2 h-2 bg-surface-surface-container-highest':
193
+ index !== currentIndex,
194
+ }"
195
+ :aria-label="`Go to image ${index + 1}`"
196
+ @click="goToSlide(index)"
197
+ />
198
+ </div>
199
+ </div>
200
+ </div>
201
+ </template>
202
+
203
+ <style scoped>
204
+ /* Gallery fade transition */
205
+ .gallery-fade-enter-active,
206
+ .gallery-fade-leave-active {
207
+ transition: all 0.3s ease-in-out;
208
+ }
209
+
210
+ .gallery-fade-enter-from {
211
+ opacity: 0;
212
+ transform: scale(1.05) translateY(10px);
213
+ }
214
+
215
+ .gallery-fade-leave-to {
216
+ opacity: 0;
217
+ transform: scale(0.95) translateY(-10px);
218
+ }
219
+
220
+ .gallery-fade-enter-to,
221
+ .gallery-fade-leave-from {
222
+ opacity: 1;
223
+ transform: scale(1) translateY(0);
224
+ }
225
+ </style>
@@ -11,8 +11,8 @@ const props = defineProps<{
11
11
  const items = computed(() => props.content.data.sliderItems);
12
12
  </script>
13
13
  <template>
14
- <!-- need some with here for small views that the slider can calculate the correct items with -->
15
- <div class="cms-element-image-slider w-[92vw] sm:w-[94vw] md:w-auto">
14
+ <!-- need some width here for small views that the slider can calculate the correct items with -->
15
+ <div class="cms-element-image-slider w-[92vw] sm:w-[94vw] md:w-full">
16
16
  <SwSlider :config="props.content.config">
17
17
  <CmsElementImage
18
18
  v-for="image of items"
@@ -1,14 +1,21 @@
1
1
  <script setup lang="ts">
2
2
  import type { CmsElementProductBox } from "@shopware/composables";
3
3
  import { computed } from "vue";
4
+ import { useCmsElementConfig } from "#imports";
4
5
 
5
6
  const props = defineProps<{
6
7
  content: CmsElementProductBox;
7
8
  }>();
8
9
 
10
+ const { getConfigValue } = useCmsElementConfig(props.content);
9
11
  const product = computed(() => props.content.data?.product || {});
10
12
  </script>
11
13
 
12
14
  <template>
13
- <SwProductCard :product="product" />
15
+ <SwProductCard
16
+ v-if="product?.id"
17
+ :product="product"
18
+ :layout-type="getConfigValue('boxLayout')"
19
+ />
20
+ <SwProductCardSkeleton v-else />
14
21
  </template>
@@ -0,0 +1,217 @@
1
+ <script setup lang="ts">
2
+ import type { CmsElementProductDescriptionReviews } from "@shopware/composables";
3
+ import { useCmsTranslations } from "@shopware/composables";
4
+ import { getTranslatedProperty } from "@shopware/helpers";
5
+ import { defu } from "defu";
6
+ import { type Ref, computed, onMounted, ref } from "vue";
7
+ import xss from "xss";
8
+ import { useProduct, useShopwareContext, useUser } from "#imports";
9
+ import type { Schemas } from "#shopware";
10
+
11
+ const props = defineProps<{
12
+ content: CmsElementProductDescriptionReviews;
13
+ }>();
14
+
15
+ type Translations = {
16
+ product: {
17
+ description: string;
18
+ reviews: string;
19
+ messages: {
20
+ reviewAdded: string;
21
+ loginToReview: string;
22
+ };
23
+ };
24
+ };
25
+
26
+ let translations: Translations = {
27
+ product: {
28
+ description: "Description",
29
+ reviews: "Reviews",
30
+ messages: {
31
+ reviewAdded: "Thank you for submitting your review",
32
+ loginToReview: "Please log in to write a review",
33
+ },
34
+ },
35
+ };
36
+ translations = defu(useCmsTranslations(), translations) as Translations;
37
+
38
+ const openSections = ref<Set<number>>(new Set([1]));
39
+ const { product } = useProduct(props.content.data?.product);
40
+
41
+ const description = computed(() =>
42
+ xss(getTranslatedProperty(product.value, "description")),
43
+ );
44
+
45
+ const toggleSection = (sectionNumber: number) => {
46
+ if (openSections.value.has(sectionNumber)) {
47
+ openSections.value.delete(sectionNumber);
48
+ } else {
49
+ openSections.value.add(sectionNumber);
50
+ }
51
+ };
52
+
53
+ const isSectionOpen = (sectionNumber: number) => {
54
+ return openSections.value.has(sectionNumber);
55
+ };
56
+
57
+ const reviews: Ref<Schemas["ProductReview"][]> = ref([]);
58
+ const { apiClient } = useShopwareContext();
59
+ const { isLoggedIn } = useUser();
60
+ const reviewAdded = ref(false);
61
+
62
+ const fetchReviews = async () => {
63
+ try {
64
+ const reviewsResponse = await apiClient.invoke(
65
+ "readProductReviews post /product/{productId}/reviews",
66
+ {
67
+ pathParams: { productId: product.value.id },
68
+ },
69
+ );
70
+ reviews.value = reviewsResponse.data.elements || [];
71
+ } catch (error) {
72
+ console.error("Failed to fetch reviews:", error);
73
+ // Keep existing reviews if fetch fails
74
+ }
75
+ };
76
+
77
+ const handleReviewAdded = () => {
78
+ reviewAdded.value = true;
79
+ fetchReviews();
80
+ };
81
+
82
+ onMounted(async () => {
83
+ if (props.content.data?.reviews?.elements?.length) {
84
+ reviews.value = props.content.data.reviews.elements;
85
+ } else {
86
+ await fetchReviews();
87
+ }
88
+ });
89
+ </script>
90
+
91
+ <template>
92
+ <div class="w-full self-stretch inline-flex flex-col justify-start items-start gap-4">
93
+ <div class="self-stretch flex flex-col justify-center items-center">
94
+ <div
95
+ class="self-stretch py-3 border-b border-outline-outline-variant inline-flex justify-start items-center gap-1 cursor-pointer hover:bg-surface-surface-variant transition-colors"
96
+ @click="toggleSection(1)">
97
+ <div class="flex-1 flex items-center gap-2.5">
98
+ <div class="flex-1 text-surface-on-surface text-base font-bold leading-normal">
99
+ {{ translations.product.description }}
100
+ </div>
101
+ </div>
102
+ <div class="w-6 h-6 relative">
103
+ <div class="w-2.5 h-1.5 left-[7px] top-[9.50px] absolute">
104
+ <div class="i-carbon-chevron-down transition-transform duration-200"
105
+ :class="{ 'rotate-180': isSectionOpen(1) }"></div>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ </div>
110
+ <Transition name="accordion">
111
+ <div v-if="isSectionOpen(1)" class="self-stretch flex flex-col justify-center items-center gap-2.5">
112
+ <div
113
+ class="self-stretch text-surface-on-surface text-base font-normal leading-normal">
114
+ <!-- eslint-disable-next-line vue/no-v-html -->
115
+ <div v-html="description"></div>
116
+ </div>
117
+ </div>
118
+ </Transition>
119
+ <div class="self-stretch flex flex-col justify-center items-center">
120
+ <div
121
+ class="self-stretch py-3 border-b border-outline-outline-variant inline-flex justify-start items-center gap-1 cursor-pointer hover:bg-surface-surface-variant transition-colors"
122
+ @click="toggleSection(2)">
123
+ <div class="flex-1 flex items-center gap-2.5">
124
+ <div class="flex-1 text-surface-on-surface text-base font-bold leading-normal">
125
+ {{ translations.product.reviews }} ({{ reviews.length }})
126
+ </div>
127
+ </div>
128
+ <div class="w-6 h-6 relative">
129
+ <div class="w-2.5 h-1.5 left-[7px] top-[9.50px] absolute">
130
+ <div class="i-carbon-chevron-down transition-transform duration-200"
131
+ :class="{ 'rotate-180': isSectionOpen(2) }"></div>
132
+ </div>
133
+ </div>
134
+ </div>
135
+ </div>
136
+ <Transition name="accordion">
137
+ <div v-if="isSectionOpen(2)" class="self-stretch flex flex-col justify-center items-center gap-2.5">
138
+ <div
139
+ class="self-stretch text-surface-on-surface text-base font-normal leading-normal">
140
+ <SwProductReviews v-if="product" :product="product" :reviews="reviews" />
141
+ <ClientOnly>
142
+ <SwProductReviewsForm
143
+ v-if="isLoggedIn && !reviewAdded && product"
144
+ :product-id="product.id"
145
+ @success="handleReviewAdded"
146
+ />
147
+ <div v-else-if="!isLoggedIn && product" class="mt-4 p-3 bg-surface-surface-container border border-surface-on-surface-variant rounded-md flex gap-2 md:gap-3 items-center">
148
+ <div class="w-5 h-5 text-surface-on-surface-variant flex-shrink-0">
149
+ <SwUserIcon :size="20" />
150
+ </div>
151
+ <span class="text-sm text-surface-on-surface-variant">{{ translations.product.messages.loginToReview }}</span>
152
+ </div>
153
+ </ClientOnly>
154
+ <div v-if="reviewAdded" class="mt-4 p-3 bg-surface-surface-container border border-states-success rounded-md flex gap-2 md:gap-3 items-center">
155
+ <div class="w-5 h-5 text-states-success flex-shrink-0">
156
+ <SwCheckmarkIcon :size="20" :filled="true" alt="Success" />
157
+ </div>
158
+ <span class="text-sm text-states-success">{{ translations.product.messages.reviewAdded }}</span>
159
+ </div>
160
+ </div>
161
+ </div>
162
+ </Transition>
163
+ <div class="self-stretch flex flex-col justify-center items-center">
164
+ <div
165
+ class="self-stretch py-3 border-b border-outline-outline-variant inline-flex justify-start items-center gap-1 cursor-pointer hover:bg-surface-surface-variant transition-colors"
166
+ @click="toggleSection(3)">
167
+ <div class="flex-1 flex items-center gap-2.5">
168
+ <div class="flex-1 text-surface-on-surface text-base font-bold leading-normal">
169
+ Category
170
+ </div>
171
+ </div>
172
+ <div class="w-6 h-6 relative">
173
+ <div class="w-2.5 h-1.5 left-[7px] top-[9.50px] absolute">
174
+ <div class="i-carbon-chevron-down transition-transform duration-200"
175
+ :class="{ 'rotate-180': isSectionOpen(3) }"></div>
176
+ </div>
177
+ </div>
178
+ </div>
179
+ </div>
180
+ <Transition name="accordion">
181
+ <div v-if="isSectionOpen(3)" class="self-stretch flex flex-col justify-center items-center gap-2.5">
182
+ <div
183
+ class="self-stretch text-surface-on-surface text-base font-normal leading-normal">
184
+ <div v-if="product?.categories">
185
+ <div v-for="category in product.categories" :key="category.id" class="mb-2">
186
+ {{ category.name }}
187
+ </div>
188
+ </div>
189
+ <div v-else>
190
+ No categories available
191
+ </div>
192
+ </div>
193
+ </div>
194
+ </Transition>
195
+ </div>
196
+ </template>
197
+ <style scoped>
198
+ .accordion-enter-active,
199
+ .accordion-leave-active {
200
+ transition: all 0.3s ease;
201
+ overflow: hidden;
202
+ }
203
+
204
+ .accordion-enter-from,
205
+ .accordion-leave-to {
206
+ max-height: 0;
207
+ opacity: 0;
208
+ transform: translateY(-10px);
209
+ }
210
+
211
+ .accordion-enter-to,
212
+ .accordion-leave-from {
213
+ max-height: 500px;
214
+ opacity: 1;
215
+ transform: translateY(0);
216
+ }
217
+ </style>
@@ -4,17 +4,18 @@ import { useCmsTranslations } from "@shopware/composables";
4
4
  import { defu } from "defu";
5
5
  import { computed, ref, useTemplateRef, watch } from "vue";
6
6
  import { useRoute, useRouter } from "vue-router";
7
- import { useCategoryListing } from "#imports";
7
+ import { useCategoryListing, useCmsElementConfig } from "#imports";
8
8
  import type { Schemas, operations } from "#shopware";
9
9
 
10
10
  const props = defineProps<{
11
11
  content: CmsElementProductListing;
12
12
  }>();
13
13
 
14
+ const { getConfigValue } = useCmsElementConfig(props.content);
14
15
  const defaultLimit = 15;
15
16
  const defaultPage = 1;
16
17
  const defaultOrder = "name-asc";
17
- const productListElement = useTemplateRef("productListElement");
18
+ const productListElement = useTemplateRef<HTMLDivElement>("productListElement");
18
19
 
19
20
  type Translations = {
20
21
  listing: {
@@ -87,13 +88,11 @@ const changePage = async (page: number) => {
87
88
  productListElement.value?.scrollIntoView({ behavior: "smooth" });
88
89
  };
89
90
 
90
- const changeLimit = async (limit: Event) => {
91
- const select = limit.target as HTMLSelectElement;
92
-
91
+ const changeLimit = async (newLimit: number) => {
93
92
  await router.push({
94
93
  query: {
95
94
  ...route.query,
96
- limit: select.value,
95
+ limit: newLimit,
97
96
  p: defaultPage,
98
97
  },
99
98
  });
@@ -151,95 +150,32 @@ compareRouteQueryWithInitialListing();
151
150
  </script>
152
151
 
153
152
  <template>
154
- <div class="bg-white">
155
- <div class="max-w-2xl mx-auto lg:max-w-full">
156
- <div class="mt-6">
157
- <div
158
- v-if="!loading"
159
- ref="productListElement"
160
- class="flex justify-center flex-wrap p-4 md:p-6 lg:p-8 productListElement"
161
- >
162
- <SwProductCard
163
- v-for="product in getElements"
164
- :key="product.id"
165
- :product="product"
166
- :is-product-listing="isProductListing"
167
- class="p-4 border rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 ease-in-out w-full lg:w-3/7 2xl:w-7/24 mr-0 sm:mr-8 mb-8"
168
- />
169
- </div>
170
- <div
171
- v-if="loading"
172
- data-testid="loading"
173
- class="flex justify-center flex-wrap p-4 md:p-6 lg:p-8"
174
- >
175
- <ProductCardSkeleton
176
- v-for="index in limit"
177
- :key="index"
178
- class="w-full mb-8 sm:w-3/7 lg:w-2/7 2xl:w-7/24 mr-0 sm:mr-8 mb-8"
179
- />
180
- </div>
181
- <div
182
- class="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6 lg:gap-8 p-4 md:p-6 lg:p-8"
183
- >
184
- <div class="text-center place-self-center">
185
- <SwPagination
186
- :total="getTotalPagesCount"
187
- :current="Number(getCurrentPage)"
188
- @change-page="changePage"
189
- />
190
- </div>
191
- <div class="text-center place-self-center mt-2 lg:mt-0">
192
- <div
193
- class="inline-block align-top text-center md:text-left"
194
- data-testid="listing-pagination-limit-box"
195
- >
196
- <label
197
- for="limit"
198
- class="inline mr-4"
199
- data-testid="listing-pagination-limit-label"
200
- >{{ translations.listing.perPage }}</label
201
- >
202
- <select
203
- id="limit"
204
- v-model="limit"
205
- name="limitchoices"
206
- class="inline appearance-none bg-white border border-gray-400 hover:border-gray-500 px-4 py-2 pr-8 rounded shadow leading-tight focus:outline-none focus:shadow-outline"
207
- data-testid="listing-pagination-limit-select"
208
- @change="changeLimit"
209
- >
210
- <option :value="1">1 {{ translations.listing.product }}</option>
211
- <option :value="15">
212
- 15 {{ translations.listing.products }}
213
- </option>
214
- <option :value="30">
215
- 30 {{ translations.listing.products }}
216
- </option>
217
- <option :value="45">
218
- 45 {{ translations.listing.products }}
219
- </option>
220
- </select>
221
- <div
222
- class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-700"
223
- >
224
- <svg
225
- class="fill-current h-4 w-4"
226
- xmlns="http://www.w3.org/2000/svg"
227
- viewBox="0 0 20 20"
228
- >
229
- <path
230
- d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z"
231
- />
232
- </svg>
233
- </div>
234
- </div>
235
- </div>
236
- </div>
237
- </div>
238
- <!-- <div v-else>
239
- <h2 class="mx-auto text-center">
240
- {{ translations.listing.noProducts }}
241
- </h2>
242
- </div> -->
153
+ <div class="max-w-2xl mx-auto lg:max-w-full">
154
+ <div v-if="!loading && getElements.length < 1" class="text-center text-xl py-16 text-surface-on-surface-variant">
155
+ {{ translations.listing.noProducts }}
156
+ </div>
157
+ <div v-if="!loading" ref="productListElement" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 auto-rows-fr gap-x-4 sm:gap-x-6 lg:gap-x-8 gap-y-8 sm:gap-y-12 lg:gap-y-16">
158
+ <SwProductCard
159
+ v-for="product in getElements"
160
+ :key="product.id"
161
+ :product="product"
162
+ :is-product-listing="isProductListing"
163
+ :layout-type="getConfigValue('boxLayout')"
164
+ class="w-full"
165
+ />
166
+ </div>
167
+ <div v-if="loading" data-testid="loading" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 auto-rows-fr gap-x-4 sm:gap-x-6 lg:gap-x-8 gap-y-8 sm:gap-y-12 lg:gap-y-16">
168
+ <ProductCardSkeleton v-for="index in limit" :key="index"
169
+ class="w-full" />
243
170
  </div>
171
+ <SwProductListingPagination
172
+ v-if="!loading"
173
+ v-model:limit="limit"
174
+ :total="getTotalPagesCount"
175
+ :current="Number(getCurrentPage)"
176
+ :translations="translations"
177
+ @change-page="changePage"
178
+ @change-limit="changeLimit"
179
+ />
244
180
  </div>
245
181
  </template>
@@ -0,0 +1,16 @@
1
+ <script setup lang="ts">
2
+ import type { CmsElementProductName } from "@shopware/composables";
3
+
4
+ defineProps<{
5
+ content: CmsElementProductName;
6
+ }>();
7
+ </script>
8
+ <template>
9
+ <!-- there is no css config coming from API for this element so we don't need to merge -->
10
+ <div role="heading" aria-level="1">
11
+ <CmsElementText
12
+ :content="content as any"
13
+ class="self-stretch text-surface-on-surface text-4xl font-normal font-serif leading-[60px]"
14
+ />
15
+ </div>
16
+ </template>