@shopware/cms-base-layer 1.5.1 → 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 (183) hide show
  1. package/README.md +330 -12
  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/app/components/public/cms/element/CmsElementBuyBox.vue +145 -0
  55. package/app/components/public/cms/element/CmsElementCategoryNavigation.vue +53 -0
  56. package/{components → app/components}/public/cms/element/CmsElementCrossSelling.vue +3 -3
  57. package/{components → app/components}/public/cms/element/CmsElementImage.vue +52 -13
  58. package/app/components/public/cms/element/CmsElementImageGallery.vue +158 -0
  59. package/{components → app/components}/public/cms/element/CmsElementImageSlider.vue +2 -2
  60. package/{components → app/components}/public/cms/element/CmsElementProductBox.vue +2 -1
  61. package/app/components/public/cms/element/CmsElementProductDescriptionReviews.vue +217 -0
  62. package/{components → app/components}/public/cms/element/CmsElementProductListing.vue +23 -94
  63. package/app/components/public/cms/element/CmsElementProductName.vue +11 -0
  64. package/{components → app/components}/public/cms/element/CmsElementProductSlider.vue +4 -4
  65. package/{components → app/components}/public/cms/element/CmsElementText.vue +8 -2
  66. package/app/components/public/cms/element/SwProductListingPagination.vue +70 -0
  67. package/{components → app/components}/public/cms/section/CmsSectionDefault.vue +1 -1
  68. package/app/components/public/cms/section/CmsSectionSidebar.vue +36 -0
  69. package/app/components/public/cms/skeleton/ProductCardSkeleton.vue +28 -0
  70. package/app/components/ui/BaseButton.vue +99 -0
  71. package/app/components/ui/BaseIcon.vue +15 -0
  72. package/app/components/ui/Checkbox.vue +49 -0
  73. package/app/components/ui/CheckmarkIcon.vue +23 -0
  74. package/app/components/ui/ChevronIcon.vue +37 -0
  75. package/app/components/ui/ExclamationIcon.vue +11 -0
  76. package/app/components/ui/IconButton.vue +32 -0
  77. package/app/components/ui/RadioButton.vue +26 -0
  78. package/app/components/ui/StarIcon.vue +18 -0
  79. package/app/components/ui/SwitchButton.vue +100 -0
  80. package/app/components/ui/UserIcon.vue +11 -0
  81. package/app/components/ui/WishlistIcon.vue +20 -0
  82. package/app/composables/useImagePlaceholder.ts +27 -0
  83. package/{helpers → app/helpers}/clientOnly.ts +5 -0
  84. package/app/providers/shopware.test.ts +213 -0
  85. package/app/providers/shopware.ts +107 -0
  86. package/dist/index.d.mts +3 -3
  87. package/dist/index.d.ts +3 -3
  88. package/dist/index.mjs +2 -2
  89. package/index.d.ts +12 -0
  90. package/nuxt.config.ts +80 -6
  91. package/package.json +29 -21
  92. package/uno.config.ts +83 -0
  93. package/components/SwCategoryNavigation.vue +0 -44
  94. package/components/SwCategoryNavigationLink.vue +0 -57
  95. package/components/SwListingProductPrice.vue +0 -89
  96. package/components/SwProductCard.vue +0 -286
  97. package/components/SwProductListingFilter.vue +0 -42
  98. package/components/SwProductListingFilters.vue +0 -292
  99. package/components/listing-filters/SwFilterPrice.vue +0 -160
  100. package/components/listing-filters/SwFilterProperties.vue +0 -123
  101. package/components/listing-filters/SwFilterRating.vue +0 -101
  102. package/components/listing-filters/SwFilterShippingFree.vue +0 -104
  103. package/components/public/cms/block/CmsBlockImageFourColumn.vue +0 -29
  104. package/components/public/cms/block/CmsBlockImageHighlightRow.vue +0 -27
  105. package/components/public/cms/block/CmsBlockImageTextGallery.vue +0 -85
  106. package/components/public/cms/block/CmsBlockImageTextRow.vue +0 -43
  107. package/components/public/cms/block/CmsBlockImageThreeCover.vue +0 -27
  108. package/components/public/cms/block/CmsBlockImageTwoColumn.vue +0 -25
  109. package/components/public/cms/block/CmsBlockTextOnImage.vue +0 -20
  110. package/components/public/cms/element/CmsElementBuyBox.vue +0 -190
  111. package/components/public/cms/element/CmsElementCategoryNavigation.vue +0 -167
  112. package/components/public/cms/element/CmsElementImageGallery.vue +0 -249
  113. package/components/public/cms/element/CmsElementProductDescriptionReviews.vue +0 -123
  114. package/components/public/cms/element/CmsElementProductName.vue +0 -10
  115. package/components/public/cms/section/CmsSectionSidebar.vue +0 -41
  116. package/components/public/cms/skeleton/ProductCardSkeleton.vue +0 -44
  117. /package/{components → app/components}/SwMedia3D.vue +0 -0
  118. /package/{components → app/components}/SwProductGallery.vue +0 -0
  119. /package/{components → app/components}/SwProductPrice.vue +0 -0
  120. /package/{components → app/components}/SwProductUnits.vue +0 -0
  121. /package/{components → app/components}/SwSharedPrice.vue +0 -0
  122. /package/{components → app/components}/public/cms/CmsGenericBlock.md +0 -0
  123. /package/{components → app/components}/public/cms/CmsGenericBlock.vue +0 -0
  124. /package/{components → app/components}/public/cms/CmsGenericElement.md +0 -0
  125. /package/{components → app/components}/public/cms/CmsGenericElement.vue +0 -0
  126. /package/{components → app/components}/public/cms/CmsNoComponent.vue +0 -0
  127. /package/{components → app/components}/public/cms/CmsPage.md +0 -0
  128. /package/{components → app/components}/public/cms/block/CmsBlockCategoryNavigation.vue +0 -0
  129. /package/{components → app/components}/public/cms/block/CmsBlockCenterText.vue +0 -0
  130. /package/{components → app/components}/public/cms/block/CmsBlockCrossSelling.vue +0 -0
  131. /package/{components → app/components}/public/cms/block/CmsBlockCustomForm.vue +0 -0
  132. /package/{components → app/components}/public/cms/block/CmsBlockDefault.vue +0 -0
  133. /package/{components → app/components}/public/cms/block/CmsBlockForm.vue +0 -0
  134. /package/{components → app/components}/public/cms/block/CmsBlockHtml.vue +0 -0
  135. /package/{components → app/components}/public/cms/block/CmsBlockImage.vue +0 -0
  136. /package/{components → app/components}/public/cms/block/CmsBlockImageCover.vue +0 -0
  137. /package/{components → app/components}/public/cms/block/CmsBlockImageGallery.vue +0 -0
  138. /package/{components → app/components}/public/cms/block/CmsBlockImageSlider.vue +0 -0
  139. /package/{components → app/components}/public/cms/block/CmsBlockProductDescriptionReviews.vue +0 -0
  140. /package/{components → app/components}/public/cms/block/CmsBlockProductListing.vue +0 -0
  141. /package/{components → app/components}/public/cms/block/CmsBlockProductSlider.vue +0 -0
  142. /package/{components → app/components}/public/cms/block/CmsBlockText.vue +0 -0
  143. /package/{components → app/components}/public/cms/block/CmsBlockTextHero.vue +0 -0
  144. /package/{components → app/components}/public/cms/block/CmsBlockTextTeaser.vue +0 -0
  145. /package/{components → app/components}/public/cms/block/CmsBlockTextTeaserSection.vue +0 -0
  146. /package/{components → app/components}/public/cms/block/CmsBlockTextThreeColumn.vue +0 -0
  147. /package/{components → app/components}/public/cms/block/CmsBlockTextTwoColumn.vue +0 -0
  148. /package/{components → app/components}/public/cms/block/CmsBlockVimeoVideo.vue +0 -0
  149. /package/{components → app/components}/public/cms/block/CmsBlockYoutubeVideo.vue +0 -0
  150. /package/{components → app/components}/public/cms/element/CmsElementBuyBox.md +0 -0
  151. /package/{components → app/components}/public/cms/element/CmsElementCategoryNavigation.md +0 -0
  152. /package/{components → app/components}/public/cms/element/CmsElementCrossSelling.md +0 -0
  153. /package/{components → app/components}/public/cms/element/CmsElementCustomForm.md +0 -0
  154. /package/{components → app/components}/public/cms/element/CmsElementCustomForm.vue +0 -0
  155. /package/{components → app/components}/public/cms/element/CmsElementForm.md +0 -0
  156. /package/{components → app/components}/public/cms/element/CmsElementForm.vue +0 -0
  157. /package/{components → app/components}/public/cms/element/CmsElementHtml.vue +0 -0
  158. /package/{components → app/components}/public/cms/element/CmsElementImage.md +0 -0
  159. /package/{components → app/components}/public/cms/element/CmsElementImageGallery.md +0 -0
  160. /package/{components → app/components}/public/cms/element/CmsElementImageGallery3dPlaceholder.vue +0 -0
  161. /package/{components → app/components}/public/cms/element/CmsElementImageSlider.md +0 -0
  162. /package/{components → app/components}/public/cms/element/CmsElementManufacturerLogo.md +0 -0
  163. /package/{components → app/components}/public/cms/element/CmsElementManufacturerLogo.vue +0 -0
  164. /package/{components → app/components}/public/cms/element/CmsElementProductBox.md +0 -0
  165. /package/{components → app/components}/public/cms/element/CmsElementProductDescriptionReviews.md +0 -0
  166. /package/{components → app/components}/public/cms/element/CmsElementProductListing.md +0 -0
  167. /package/{components → app/components}/public/cms/element/CmsElementProductName.md +0 -0
  168. /package/{components → app/components}/public/cms/element/CmsElementProductSlider.md +0 -0
  169. /package/{components → app/components}/public/cms/element/CmsElementSidebarFilter.md +0 -0
  170. /package/{components → app/components}/public/cms/element/CmsElementSidebarFilter.vue +0 -0
  171. /package/{components → app/components}/public/cms/element/CmsElementText.md +0 -0
  172. /package/{components → app/components}/public/cms/element/CmsElementVimeoVideo.md +0 -0
  173. /package/{components → app/components}/public/cms/element/CmsElementVimeoVideo.vue +0 -0
  174. /package/{components → app/components}/public/cms/element/CmsElementYoutubeVideo.md +0 -0
  175. /package/{components → app/components}/public/cms/element/CmsElementYoutubeVideo.vue +0 -0
  176. /package/{components → app/components}/public/cms/section/CmsSectionDefault.md +0 -0
  177. /package/{components → app/components}/public/cms/section/CmsSectionSidebar.md +0 -0
  178. /package/{helpers → app/helpers}/html-to-vue/ast.ts +0 -0
  179. /package/{helpers → app/helpers}/html-to-vue/getOptionsFromNode.test.ts +0 -0
  180. /package/{helpers → app/helpers}/html-to-vue/getOptionsFromNode.ts +0 -0
  181. /package/{helpers → app/helpers}/html-to-vue/renderToHtml.ts +0 -0
  182. /package/{helpers → app/helpers}/html-to-vue/renderer.ts +0 -0
  183. /package/{helpers → app/helpers}/media/isSpatial.ts +0 -0
@@ -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>
@@ -14,7 +14,7 @@ const props = defineProps<{
14
14
  const defaultLimit = 15;
15
15
  const defaultPage = 1;
16
16
  const defaultOrder = "name-asc";
17
- const productListElement = useTemplateRef("productListElement");
17
+ const productListElement = useTemplateRef<HTMLDivElement>("productListElement");
18
18
 
19
19
  type Translations = {
20
20
  listing: {
@@ -87,13 +87,11 @@ const changePage = async (page: number) => {
87
87
  productListElement.value?.scrollIntoView({ behavior: "smooth" });
88
88
  };
89
89
 
90
- const changeLimit = async (limit: Event) => {
91
- const select = limit.target as HTMLSelectElement;
92
-
90
+ const changeLimit = async (newLimit: number) => {
93
91
  await router.push({
94
92
  query: {
95
93
  ...route.query,
96
- limit: select.value,
94
+ limit: newLimit,
97
95
  p: defaultPage,
98
96
  },
99
97
  });
@@ -151,95 +149,26 @@ compareRouteQueryWithInitialListing();
151
149
  </script>
152
150
 
153
151
  <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> -->
152
+ <div class="max-w-2xl mx-auto lg:max-w-full">
153
+ <div v-if="!loading && getElements.length < 1" class="text-center text-xl py-16 text-surface-on-surface-variant">
154
+ {{ translations.listing.noProducts }}
155
+ </div>
156
+ <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">
157
+ <SwProductCard v-for="product in getElements" :key="product.id" :product="product"
158
+ :is-product-listing="isProductListing" class="w-full" />
159
+ </div>
160
+ <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">
161
+ <ProductCardSkeleton v-for="index in limit" :key="index"
162
+ class="w-full" />
243
163
  </div>
164
+ <SwProductListingPagination
165
+ v-if="!loading"
166
+ v-model:limit="limit"
167
+ :total="getTotalPagesCount"
168
+ :current="Number(getCurrentPage)"
169
+ :translations="translations"
170
+ @change-page="changePage"
171
+ @change-limit="changeLimit"
172
+ />
244
173
  </div>
245
174
  </template>
@@ -0,0 +1,11 @@
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
+ <CmsElementText :content="content as any" class="self-stretch text-surface-on-surface text-4xl font-normal font-serif leading-[60px]" />
11
+ </template>
@@ -12,12 +12,12 @@ const props = defineProps<{
12
12
  }>();
13
13
  const { getConfigValue } = useCmsElementConfig(props.content);
14
14
 
15
- const productSlider = useTemplateRef("productSlider");
15
+ const productSlider = useTemplateRef<HTMLDivElement>("productSlider");
16
16
  const slidesToShow = ref<number>();
17
17
  const products = computed(() => props.content?.data?.products ?? []);
18
18
  const config: ComputedRef<SliderElementConfig> = computed(() => ({
19
19
  minHeight: {
20
- value: "300px",
20
+ value: "450px",
21
21
  source: "static",
22
22
  },
23
23
  verticalAlign: {
@@ -55,10 +55,10 @@ const border = computed(() => getConfigValue("border"));
55
55
  </script>
56
56
  <template>
57
57
  <div ref="productSlider" class="cms-element-product-slider">
58
- <h3 v-if="title" class="mb-5 text-lg font-bold text-secondary-700">
58
+ <h3 v-if="title" class="pl-6 pb-6 text-center md:text-left text-surface-on-surface">
59
59
  {{ title }}
60
60
  </h3>
61
- <div :class="{ 'py-5 border border-secondary-300': border }">
61
+ <div :class="{ 'py-5 border border-outline-outline-variant': border }">
62
62
  <SwSlider
63
63
  :config="config"
64
64
  gap="1.25rem"
@@ -75,8 +75,14 @@ const CmsTextRender = defineComponent({
75
75
  "rounded-md inline-block my-2 py-2 px-4 border border-transparent text-sm font-medium focus:outline-none disabled:opacity-75";
76
76
 
77
77
  _class = node.attrs.class
78
- .replace("btn-secondary", `${btnClass} bg-dark text-white`)
79
- .replace("btn-primary", `${btnClass} bg-primary text-white`);
78
+ .replace(
79
+ "btn-secondary",
80
+ `${btnClass} bg-brand-secondary text-brand-on-secondary hover:bg-brand-secondary-hover`,
81
+ )
82
+ .replace(
83
+ "btn-primary",
84
+ `${btnClass} bg-brand-primary text-brand-on-primary hover:bg-brand-primary-hover`,
85
+ );
80
86
  }
81
87
 
82
88
  return createElement(
@@ -0,0 +1,70 @@
1
+ <script setup lang="ts">
2
+ type Translations = {
3
+ listing: {
4
+ perPage: string;
5
+ product: string;
6
+ products: string;
7
+ };
8
+ };
9
+
10
+ defineProps<{
11
+ total: number;
12
+ current: number;
13
+ limit: number;
14
+ translations: Translations;
15
+ }>();
16
+
17
+ const emit = defineEmits<{
18
+ changePage: [page: number];
19
+ changeLimit: [limit: number];
20
+ }>();
21
+
22
+ const limitModel = defineModel<number>("limit", { required: true });
23
+
24
+ const handlePageChange = (page: number) => {
25
+ emit("changePage", page);
26
+ };
27
+
28
+ const handleLimitChange = (event: Event) => {
29
+ const select = event.target as HTMLSelectElement;
30
+ emit("changeLimit", Number(select.value));
31
+ };
32
+ </script>
33
+
34
+ <template>
35
+ <div v-if="total > 0" class="flex flex-col gap-6 sm:gap-8 mt-6 sm:mt-8">
36
+ <!-- Pagination Controls -->
37
+ <div class="flex justify-center w-full">
38
+ <SwPagination :total="total" :current="current" @change-page="handlePageChange" />
39
+ </div>
40
+
41
+ <!-- Items per page selector -->
42
+ <div class="flex justify-center items-center gap-3 sm:gap-4">
43
+ <label
44
+ for="limit"
45
+ class="text-sm sm:text-base text-surface-on-surface"
46
+ data-testid="listing-pagination-limit-label"
47
+ >
48
+ {{ translations.listing.perPage }}
49
+ </label>
50
+ <div class="relative">
51
+ <select
52
+ id="limit"
53
+ v-model="limitModel"
54
+ name="limitchoices"
55
+ class="appearance-none bg-surface-surface border border-outline-outline hover:border-outline-outline-primary focus:border-outline-outline-primary focus:ring-2 focus:ring-outline-outline-primary focus:ring-opacity-20 px-4 py-2 pr-10 rounded-md text-sm sm:text-base text-surface-on-surface cursor-pointer transition-colors"
56
+ data-testid="listing-pagination-limit-select"
57
+ @change="handleLimitChange"
58
+ >
59
+ <option :value="1">1 {{ translations.listing.product }}</option>
60
+ <option :value="15">15 {{ translations.listing.products }}</option>
61
+ <option :value="30">30 {{ translations.listing.products }}</option>
62
+ <option :value="45">45 {{ translations.listing.products }}</option>
63
+ </select>
64
+ <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
65
+ <SwChevronIcon direction="down" :size="16" />
66
+ </div>
67
+ </div>
68
+ </div>
69
+ </div>
70
+ </template>
@@ -10,7 +10,7 @@ const { cssClasses, layoutStyles } = getCmsLayoutConfiguration(props.content);
10
10
  </script>
11
11
 
12
12
  <template>
13
- <div class="cms-section-default" :class="cssClasses" :styles="layoutStyles">
13
+ <div class="my-4" :class="cssClasses" :style="layoutStyles as any">
14
14
  <CmsGenericBlock
15
15
  v-for="cmsBlock in content.blocks"
16
16
  :key="cmsBlock.id"
@@ -0,0 +1,36 @@
1
+ <script setup lang="ts">
2
+ import { useCmsSection } from "@shopware/composables";
3
+ import type { CmsSectionSidebar } from "@shopware/composables";
4
+ import { computed } from "vue";
5
+
6
+ const props = defineProps<{
7
+ content: CmsSectionSidebar;
8
+ }>();
9
+ const { getPositionContent, section } = useCmsSection(props.content);
10
+
11
+ const sidebarBlocks = getPositionContent("sidebar");
12
+ const mainBlocks = getPositionContent("main");
13
+ const mobileBehavior = computed(() => props.content.mobileBehavior);
14
+ const fullWidth = computed(() => section.sizingMode === "full_width");
15
+ </script>
16
+
17
+ <template>
18
+ <div class="self-stretch flex flex-col lg:flex-row items-stretch gap-16" :class="{
19
+ 'px-6': fullWidth,
20
+ }">
21
+ <aside :class="{
22
+ 'w-full lg:w-72 xl:w-80 flex-shrink-0 bg-surface-surface flex flex-col justify-start items-stretch gap-4 lg:sticky lg:top-20 px-4 lg:px-0':
23
+ mobileBehavior !== 'hidden',
24
+ 'hidden lg:block': mobileBehavior === 'hidden',
25
+ }">
26
+ <div v-for="cmsBlock in sidebarBlocks" :key="cmsBlock.id" class="w-full">
27
+ <CmsGenericBlock :content="cmsBlock" />
28
+ </div>
29
+ </aside>
30
+ <main class="flex-1 flex flex-col justify-start items-stretch gap-20">
31
+ <div v-for="cmsBlock in mainBlocks" :key="cmsBlock.id" class="w-full">
32
+ <CmsGenericBlock :content="cmsBlock" />
33
+ </div>
34
+ </main>
35
+ </div>
36
+ </template>
@@ -0,0 +1,28 @@
1
+ <script setup lang="ts">
2
+ import { useImagePlaceholder } from "#imports";
3
+
4
+ const placeholderSvg = useImagePlaceholder();
5
+ </script>
6
+
7
+ <template>
8
+ <div role="status" class="p-px flex flex-col justify-start items-start overflow-hidden">
9
+ <!-- Image skeleton -->
10
+ <div class="self-stretch min-h-[350px] relative flex items-center justify-center overflow-hidden aspect-square animate-pulse">
11
+ <img
12
+ :src="placeholderSvg"
13
+ alt=""
14
+ aria-hidden="true"
15
+ class="w-full h-full object-cover"
16
+ />
17
+ </div>
18
+
19
+ <!-- Details skeleton -->
20
+ <div class="w-full pt-4 animate-pulse">
21
+ <div class="h-4 bg-gray-200 rounded-full dark:bg-gray-700 w-3/4 mb-3"></div>
22
+ <div class="h-3 bg-gray-200 rounded-full dark:bg-gray-700 w-1/2 mb-4"></div>
23
+ <div class="h-5 bg-gray-200 rounded-full dark:bg-gray-700 w-20 mb-3"></div>
24
+ <div class="h-10 bg-gray-200 rounded dark:bg-gray-700 w-full"></div>
25
+ </div>
26
+ <span class="sr-only">Loading...</span>
27
+ </div>
28
+ </template>
@@ -0,0 +1,99 @@
1
+ <script setup lang="ts">
2
+ import { computed } from "vue";
3
+
4
+ export interface SwBaseButtonProps {
5
+ variant?:
6
+ | "primary"
7
+ | "secondary"
8
+ | "success"
9
+ | "warning"
10
+ | "outline"
11
+ | "ghost";
12
+ size?: "small" | "medium" | "large";
13
+ disabled?: boolean;
14
+ loading?: boolean;
15
+ type?: "button" | "submit" | "reset";
16
+ block?: boolean;
17
+ }
18
+
19
+ defineOptions({
20
+ inheritAttrs: false,
21
+ });
22
+
23
+ const props = withDefaults(defineProps<SwBaseButtonProps>(), {
24
+ variant: "primary",
25
+ size: "medium",
26
+ disabled: false,
27
+ loading: false,
28
+ type: "button",
29
+ block: false,
30
+ });
31
+
32
+ const emit = defineEmits<{
33
+ click: [event: MouseEvent];
34
+ }>();
35
+
36
+ const buttonClasses = computed(() => {
37
+ const classes = [
38
+ "inline-flex justify-center items-center gap-2 rounded font-bold transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2",
39
+ ];
40
+
41
+ const sizeClasses = {
42
+ small: "px-3 py-2 text-sm",
43
+ medium: "px-4 py-3 text-base",
44
+ large: "px-6 py-4 text-lg",
45
+ };
46
+ classes.push(sizeClasses[props.size]);
47
+
48
+ const variantClasses = {
49
+ primary:
50
+ "bg-brand-primary hover:bg-brand-primary-hover text-brand-on-primary focus:ring-brand-primary",
51
+ secondary:
52
+ "bg-brand-secondary hover:bg-brand-secondary-hover text-brand-on-secondary focus:ring-brand-secondary",
53
+ success:
54
+ "bg-states-success hover:opacity-90 text-white focus:ring-states-success transition-opacity",
55
+ warning:
56
+ "bg-states-warning hover:opacity-90 text-white focus:ring-states-warning transition-opacity",
57
+ outline:
58
+ "border-2 border-brand-primary text-brand-primary hover:bg-brand-primary hover:text-brand-on-primary focus:ring-brand-primary",
59
+ ghost:
60
+ "bg-transparent text-surface-on-surface-variant hover:text-surface-on-surface focus:ring-surface-on-surface",
61
+ };
62
+
63
+ if (props.disabled || props.loading) {
64
+ classes.push(
65
+ "bg-surface-surface-disabled text-surface-on-surface cursor-not-allowed opacity-50",
66
+ );
67
+ } else {
68
+ classes.push(variantClasses[props.variant]);
69
+ }
70
+
71
+ if (props.block) {
72
+ classes.push("w-full");
73
+ }
74
+
75
+ return classes.join(" ");
76
+ });
77
+
78
+ const handleClick = (event: MouseEvent) => {
79
+ if (!props.disabled && !props.loading) {
80
+ emit("click", event);
81
+ }
82
+ };
83
+ </script>
84
+
85
+ <template>
86
+ <button
87
+ :type="type"
88
+ :class="buttonClasses"
89
+ :disabled="disabled || loading"
90
+ @click="handleClick"
91
+ v-bind="$attrs"
92
+ >
93
+ <div v-if="loading" class="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin"></div>
94
+
95
+ <span :class="{ 'opacity-0': loading }">
96
+ <slot />
97
+ </span>
98
+ </button>
99
+ </template>
@@ -0,0 +1,15 @@
1
+ <script setup lang="ts">
2
+ const {
3
+ src,
4
+ size = 24,
5
+ alt = "",
6
+ } = defineProps<{
7
+ src: string;
8
+ size?: number;
9
+ alt?: string;
10
+ }>();
11
+ </script>
12
+
13
+ <template>
14
+ <NuxtImg :src="src" :alt="alt" :width="size" :height="size" />
15
+ </template>