@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
@@ -78,8 +78,18 @@ const { getConfigValue } = useCmsElementConfig(props.content);
78
78
  const { newsletterSubscribe, newsletterUnsubscribe } = useNewsletter();
79
79
 
80
80
  const getFormTitle = computed(() => getConfigValue("title"));
81
- const state = reactive({
82
- option: subscriptionOptions[0]?.value ?? "",
81
+
82
+ type NewsletterFormState = {
83
+ option: "subscribe" | "unsubscribe";
84
+ salutationId: string;
85
+ firstName: string;
86
+ lastName: string;
87
+ email: string;
88
+ checkbox: boolean;
89
+ };
90
+
91
+ const state = reactive<NewsletterFormState>({
92
+ option: subscriptionOptions[0]?.value ?? "subscribe",
83
93
  salutationId: "",
84
94
  firstName: "",
85
95
  lastName: "",
@@ -87,7 +97,7 @@ const state = reactive({
87
97
  checkbox: false,
88
98
  });
89
99
 
90
- type Rules = {
100
+ type RequiredRules = {
91
101
  email: {
92
102
  required: ValidationRuleWithoutParams;
93
103
  email: ValidationRuleWithoutParams;
@@ -96,6 +106,9 @@ type Rules = {
96
106
  required: ValidationRuleWithoutParams;
97
107
  isTrue: (value: boolean) => boolean;
98
108
  };
109
+ };
110
+
111
+ type OptionalRules = {
99
112
  firstName: {
100
113
  required: ValidationRuleWithoutParams;
101
114
  minLength: number;
@@ -105,8 +118,9 @@ type Rules = {
105
118
  minLength: number;
106
119
  };
107
120
  };
108
- const rules = computed(() => {
109
- let temp: Partial<Rules> = {
121
+
122
+ const rules = computed((): RequiredRules & Partial<OptionalRules> => {
123
+ const temp: RequiredRules & Partial<OptionalRules> = {
110
124
  email: {
111
125
  required,
112
126
  email,
@@ -117,16 +131,13 @@ const rules = computed(() => {
117
131
  },
118
132
  };
119
133
  if (state.option === "subscribe") {
120
- temp = {
121
- ...temp,
122
- firstName: {
123
- required,
124
- minLength: 3,
125
- },
126
- lastName: {
127
- required,
128
- minLength: 3,
129
- },
134
+ temp.firstName = {
135
+ required,
136
+ minLength: 3,
137
+ };
138
+ temp.lastName = {
139
+ required,
140
+ minLength: 3,
130
141
  };
131
142
  }
132
143
  return temp;
@@ -184,7 +195,7 @@ const invokeSubmit = async () => {
184
195
  id="option"
185
196
  v-model="state.option"
186
197
  name="option"
187
- class="appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 focus:z-10 sm:text-sm"
198
+ class="appearance-none relative block w-full px-3 py-2 border border-outline-outline-variant placeholder-surface-on-surface-variant text-surface-on-surface rounded-md focus:border-brand-primary focus:outline-none focus:ring-brand-primary focus:z-10 sm:text-sm"
188
199
  >
189
200
  <option
190
201
  v-for="subscription in subscriptionOptions"
@@ -204,19 +215,19 @@ const invokeSubmit = async () => {
204
215
  type="email"
205
216
  autocomplete="email"
206
217
  :class="[
207
- $v.email?.$error
218
+ $v.email.$error
208
219
  ? 'border-red-600 focus:border-red-600'
209
- : 'border-gray-300 focus:border-indigo-500',
220
+ : 'border-outline-outline-variant focus:border-brand-primary',
210
221
  ]"
211
- class="appearance-none relative block w-full px-3 py-2 border placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:z-10 sm:text-sm"
222
+ class="appearance-none relative block w-full px-3 py-2 border placeholder-surface-on-surface-variant text-surface-on-surface rounded-md focus:outline-none focus:ring-brand-primary focus:z-10 sm:text-sm"
212
223
  :placeholder="translations.form.emailPlaceholder"
213
- @blur="$v.email?.$touch()"
224
+ @blur="$v.email.$touch()"
214
225
  />
215
226
  <span
216
- v-if="$v.email?.$error"
227
+ v-if="$v.email.$error && $v.email.$errors[0]?.$message"
217
228
  class="pt-1 text-sm text-red-600 focus:ring-brand-primary border-gray-300"
218
229
  >
219
- {{ $v.email?.$errors[0]?.$message || '' }}
230
+ {{ $v.email.$errors[0].$message }}
220
231
  </span>
221
232
  </div>
222
233
  <div v-if="state.option === 'subscribe'" class="col-span-4">
@@ -225,7 +236,7 @@ const invokeSubmit = async () => {
225
236
  id="salutation"
226
237
  v-model="state.salutationId"
227
238
  name="salutation"
228
- class=" border-gray-300 focus:border-indigo-500appearance-none relative block w-full px-3 py-2 border placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:z-10 sm:text-sm"
239
+ class=" border-outline-outline-variant focus:border-brand-primaryappearance-none relative block w-full px-3 py-2 border placeholder-surface-on-surface-variant text-surface-on-surface rounded-md focus:outline-none focus:ring-brand-primary focus:z-10 sm:text-sm"
229
240
  >
230
241
  <option disabled selected value="">
231
242
  {{ translations.form.salutationPlaceholder }}
@@ -247,20 +258,20 @@ const invokeSubmit = async () => {
247
258
  name="first-name"
248
259
  type="text"
249
260
  autocomplete="given-name"
250
- class="appearance-none relative block w-full px-3 py-2 border placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:z-10 sm:text-sm"
261
+ class="appearance-none relative block w-full px-3 py-2 border placeholder-surface-on-surface-variant text-surface-on-surface rounded-md focus:outline-none focus:ring-brand-primary focus:z-10 sm:text-sm"
251
262
  :class="[
252
263
  $v.firstName?.$error
253
- ? 'border-red-600 focus:border-red-600'
254
- : 'border-gray-300 focus:border-indigo-500',
264
+ ? 'border-red-600 focus:border-red-600'
265
+ : 'border-outline-outline-variant focus:border-brand-primary',
255
266
  ]"
256
267
  :placeholder="translations.form.firstNamePlaceholder"
257
268
  @blur="$v.firstName?.$touch()"
258
269
  />
259
270
  <span
260
- v-if="$v.firstName?.$error"
271
+ v-if="$v.firstName?.$error && $v.firstName?.$errors[0]?.$message"
261
272
  class="pt-1 text-sm text-red-600 focus:ring-brand-primary border-gray-300"
262
273
  >
263
- {{ $v.firstName?.$errors[0]?.$message || '' }}
274
+ {{ $v.firstName?.$errors[0].$message }}
264
275
  </span>
265
276
  </div>
266
277
  <div v-if="state.option === 'subscribe'" class="col-span-4">
@@ -271,20 +282,20 @@ const invokeSubmit = async () => {
271
282
  name="last-name"
272
283
  type="text"
273
284
  autocomplete="family-name"
274
- class="appearance-none relative block w-full px-3 py-2 border placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:z-10 sm:text-sm"
285
+ class="appearance-none relative block w-full px-3 py-2 border placeholder-surface-on-surface-variant text-surface-on-surface rounded-md focus:outline-none focus:ring-brand-primary focus:z-10 sm:text-sm"
275
286
  :class="[
276
287
  $v.lastName?.$error
277
288
  ? 'border-red-600 focus:border-red-600'
278
- : 'border-gray-300 focus:border-indigo-500',
289
+ : 'border-outline-outline-variant focus:border-brand-primary',
279
290
  ]"
280
291
  :placeholder="translations.form.lastNamePlaceholder"
281
292
  @blur="$v.lastName?.$touch()"
282
293
  />
283
294
  <span
284
- v-if="$v.lastName?.$error"
295
+ v-if="$v.lastName?.$error && $v.lastName?.$errors[0]?.$message"
285
296
  class="pt-1 text-sm text-red-600 focus:ring-brand-primary border-gray-300"
286
297
  >
287
- {{ $v.lastName?.$errors[0]?.$message || '' }}
298
+ {{ $v.lastName?.$errors[0].$message }}
288
299
  </span>
289
300
  </div>
290
301
  <div class="col-span-12">
@@ -295,7 +306,7 @@ const invokeSubmit = async () => {
295
306
  v-model="state.checkbox"
296
307
  name="privacy"
297
308
  type="checkbox"
298
- class="mt-1 focus:ring-indigo-500 h-4 w-4 border text-indigo-600 rounded"
309
+ class="mt-1 focus:ring-brand-primary h-4 w-4 border text-brand-primary rounded"
299
310
  :class="[
300
311
  $v.checkbox?.$error ? 'border-red-600' : 'border-gray-300',
301
312
  ]"
@@ -313,7 +324,7 @@ const invokeSubmit = async () => {
313
324
  </div>
314
325
  <div class="flex justify-end mt-10">
315
326
  <button
316
- class="group relative flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-75"
327
+ class="group relative flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-brand-primary hover:bg-brand-primary-hover focus:outline-none focus:ring-2 focus:ring-brand-primary disabled:opacity-75"
317
328
  type="submit"
318
329
  >
319
330
  {{ translations.form.submit }}
@@ -36,8 +36,7 @@ defineEmits<(e: "changePage", page: number) => void>();
36
36
  @click="$emit('changePage', current - 1)"
37
37
  >
38
38
  <span class="sr-only">{{ translations.listing.previous }}</span>
39
- <!-- Heroicon name: solid/chevron-left -->
40
- <div class="w-5 h-5 i-carbon-chevron-left" />
39
+ <SwChevronIcon direction="left" :size="20" />
41
40
  </button>
42
41
  <button
43
42
  v-if="current > 2"
@@ -62,7 +61,7 @@ defineEmits<(e: "changePage", page: number) => void>();
62
61
  </button>
63
62
  <button
64
63
  aria-current="page"
65
- class="bg-indigo-50 border-indigo-500 text-indigo-600 relative inline-flex items-center px-4 py-2 border text-sm font-medium"
64
+ class="bg-surface-surface-primary border-brand-primary text-brand-primary relative inline-flex items-center px-4 py-2 border text-sm font-medium"
66
65
  :class="[
67
66
  current - 1 >= 1 ? '' : 'rounded-l-md border border-secondary-300',
68
67
  total == current ? 'rounded-r-md border border-secondary-300' : '',
@@ -99,8 +98,7 @@ defineEmits<(e: "changePage", page: number) => void>();
99
98
  @click="$emit('changePage', current + 1)"
100
99
  >
101
100
  <span class="sr-only">{{ translations.listing.next }}</span>
102
- <!-- Heroicon name: solid/chevron-right -->
103
- <div class="w-5 h-5 i-carbon-chevron-right" />
101
+ <SwChevronIcon direction="right" :size="20" />
104
102
  </button>
105
103
  </nav>
106
104
  </template>
@@ -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,169 @@
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, toRef } 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 {
30
+ product: productProp,
31
+ layoutType = "standard",
32
+ displayMode = "standard",
33
+ isProductListing = false,
34
+ } = defineProps<{
35
+ product: Schemas["Product"];
36
+ layoutType?: BoxLayout;
37
+ displayMode?: DisplayMode;
38
+ isProductListing?: boolean;
39
+ }>();
40
+
41
+ type Translations = {
42
+ product: {
43
+ addedToWishlist: string;
44
+ removedFromTheWishlist: string;
45
+ reason: string;
46
+ cannotAddToWishlist: string;
47
+ addedToCart: string;
48
+ addToCart: string;
49
+ details: string;
50
+ badges: {
51
+ topseller: string;
52
+ };
53
+ };
54
+ errors: {
55
+ [key: string]: string;
56
+ };
57
+ };
58
+
59
+ let translations: Translations = {
60
+ product: {
61
+ addedToWishlist: "has been added to wishlist.",
62
+ removedFromTheWishlist: "has been removed from wishlist.",
63
+ reason: "Reason",
64
+ cannotAddToWishlist: "cannot be added to wishlist.",
65
+ addedToCart: "has been added to cart.",
66
+ addToCart: "Add to cart",
67
+ details: "Details",
68
+ badges: {
69
+ topseller: "Tip",
70
+ },
71
+ },
72
+ errors: {
73
+ "product-stock-reached":
74
+ "The product {name} is only available {quantity} times",
75
+ },
76
+ };
77
+
78
+ translations = defu(useCmsTranslations(), translations) as Translations;
79
+
80
+ const product = toRef(() => productProp);
81
+
82
+ const { addToCart } = useAddToCart(product);
83
+
84
+ const { addToWishlist, removeFromWishlist, isInWishlist } = useProductWishlist(
85
+ product.value.id,
86
+ );
87
+ const isLoading = ref(false);
88
+
89
+ const toggleWishlistProduct = async () => {
90
+ isLoading.value = true;
91
+
92
+ try {
93
+ if (!isInWishlist.value) {
94
+ await addToWishlist();
95
+ pushSuccess(
96
+ `${product?.value.translated.name} ${translations.product.addedToWishlist}`,
97
+ );
98
+ } else {
99
+ await removeFromWishlist();
100
+ pushSuccess(
101
+ `${product?.value.translated.name} ${translations.product.removedFromTheWishlist}`,
102
+ );
103
+ }
104
+ } catch (error) {
105
+ if (error instanceof ApiClientError) {
106
+ const reason = error.details.errors?.[0]?.detail
107
+ ? `${translations.product.reason}: ${error.details.errors?.[0]?.detail}`
108
+ : "";
109
+ return pushError(
110
+ `${product?.value.translated.name} ${translations.product.cannotAddToWishlist}\n${reason}`,
111
+ {
112
+ timeout: 5000,
113
+ },
114
+ );
115
+ }
116
+ } finally {
117
+ isLoading.value = false;
118
+ }
119
+ };
120
+
121
+ const addToCartProxy = async () => {
122
+ await addToCart();
123
+ const errors = getErrorsCodes();
124
+ for (const element of errors) {
125
+ const { messageKey, params } = resolveCartError(element);
126
+ if (translations.errors[messageKey])
127
+ pushError(getCmsTranslate(translations.errors[messageKey], params));
128
+ }
129
+
130
+ if (!errors.length)
131
+ pushSuccess(
132
+ `${product?.value.translated.name} ${translations.product.addedToCart}`,
133
+ );
134
+ };
135
+
136
+ const fromPrice = getProductFromPrice(product.value);
137
+ const productName = computed(() => getProductName({ product: product.value }));
138
+ const productManufacturer = computed(() =>
139
+ getProductManufacturerName(product.value),
140
+ );
141
+
142
+ const { getUrlPrefix } = useUrlResolver();
143
+ const productLink = computed(() =>
144
+ buildUrlPrefix(getProductRoute(product.value), getUrlPrefix()),
145
+ );
146
+ </script>
147
+
148
+ <template>
149
+ <div class="p-px flex flex-col justify-start items-start overflow-hidden">
150
+ <SwProductCardImage
151
+ :product="product"
152
+ :translations="translations"
153
+ :isInWishlist="isInWishlist"
154
+ :isLoading="isLoading"
155
+ :toggleWishlist="toggleWishlistProduct"
156
+ :productLink="productLink"
157
+ />
158
+ <SwProductCardDetails
159
+ :product="product"
160
+ :productName="productName"
161
+ :productManufacturer="productManufacturer"
162
+ :translations="translations"
163
+ :fromPrice="fromPrice"
164
+ :addToCartProxy="addToCartProxy"
165
+ :productLink="productLink"
166
+ :layoutType="layoutType"
167
+ />
168
+ </div>
169
+ </template>
@@ -0,0 +1,74 @@
1
+ <script setup lang="ts">
2
+ import type { BoxLayout } from "@shopware/composables";
3
+ import type { UrlRouteOutput } from "@shopware/helpers";
4
+ import { computed } from "vue";
5
+ import type { Schemas } from "#shopware";
6
+
7
+ type Translations = {
8
+ product: {
9
+ addToCart: string;
10
+ details: string;
11
+ };
12
+ errors: {
13
+ [key: string]: string;
14
+ };
15
+ };
16
+
17
+ const props = defineProps<{
18
+ product: Schemas["Product"];
19
+ productName: string | null;
20
+ productManufacturer?: string | null;
21
+ translations: Translations;
22
+ fromPrice?: number;
23
+ addToCartProxy: () => Promise<void>;
24
+ productLink: UrlRouteOutput;
25
+ layoutType?: BoxLayout;
26
+ }>();
27
+
28
+ const isMinimalLayout = computed(() => props.layoutType === "minimal");
29
+ </script>
30
+ <template>
31
+ <div class="self-stretch p-2 flex flex-col justify-between items-start gap-4 flex-1">
32
+ <div class="self-stretch flex flex-col justify-start items-start gap-4">
33
+ <div class="self-stretch flex flex-col justify-start items-start gap-2">
34
+ <div class="self-stretch flex flex-col justify-start items-start gap-1">
35
+ <div v-if="productManufacturer"
36
+ class="self-stretch text-surface-on-surface text-sm font-bold leading-tight">
37
+ {{ productManufacturer }}
38
+ </div>
39
+
40
+ <RouterLink :to="productLink"
41
+ class="self-stretch text-surface-on-surface text-2xl font-normal font-serif leading-9 overflow-hidden line-clamp-2 break-words min-h-[4.5rem]"
42
+ data-testid="product-box-product-name-link">
43
+ {{ productName }}
44
+ </RouterLink>
45
+ </div>
46
+ </div>
47
+
48
+ <!-- Star rating for minimal layout -->
49
+ <SwProductRating
50
+ v-if="isMinimalLayout"
51
+ :rating="product?.ratingAverage ?? 0"
52
+ :review-count="product?.productReviews?.length ?? 0"
53
+ class="mt-4"
54
+ />
55
+
56
+ <!-- Price for standard layout -->
57
+ <SwListingProductPrice v-else :product="product" data-testid="product-box-product-price" />
58
+ </div>
59
+
60
+ <!-- CTA buttons only for non-minimal layout -->
61
+ <template v-if="!isMinimalLayout">
62
+ <SwBaseButton variant="primary" v-if="!fromPrice" size="medium" :disabled="!product?.available" block
63
+ data-testid="add-to-cart-button" @click="addToCartProxy">
64
+ {{ translations.product.addToCart }}
65
+ </SwBaseButton>
66
+
67
+ <RouterLink v-else :to="productLink" class="self-stretch">
68
+ <SwBaseButton block>
69
+ {{ translations.product.details }}
70
+ </SwBaseButton>
71
+ </RouterLink>
72
+ </template>
73
+ </div>
74
+ </template>
@@ -0,0 +1,90 @@
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 (
40
+ getSmallestThumbnailUrl(props.product?.cover?.media) ||
41
+ props.product?.cover?.media?.url
42
+ );
43
+ });
44
+
45
+ const imageModifiers = computed(() => {
46
+ // Use the larger dimension and apply 2x for high-DPI displays
47
+ // For square containers, width and height should be the same
48
+ const containerSize = Math.max(width.value || 0, height.value || 0);
49
+ const size = roundUp(containerSize * 2);
50
+ return {
51
+ width: size,
52
+ height: size,
53
+ };
54
+ });
55
+
56
+ const coverAlt = computed(() => {
57
+ return props.product?.cover?.media?.alt || props.product?.translated?.name;
58
+ });
59
+
60
+ const isOnSale = computed(() => isProductOnSale(props.product));
61
+ const isTopseller = computed(() => isProductTopSeller(props.product));
62
+
63
+ const placeholderSvg = useImagePlaceholder();
64
+ </script>
65
+
66
+ <template>
67
+ <div ref="containerElement" class="self-stretch min-h-[350px] relative flex flex-col justify-start items-start overflow-hidden aspect-square">
68
+ <RouterLink :to="productLink" class="self-stretch h-full relative overflow-hidden">
69
+ <NuxtImg preset="productCard"
70
+ class="w-full h-full absolute top-0 left-0 object-cover"
71
+ :placeholder="placeholderSvg"
72
+ :src="coverSrcPath" :alt="coverAlt" :modifiers="imageModifiers" data-testid="product-box-img" />
73
+ </RouterLink>
74
+
75
+ <div v-if="isTopseller || isOnSale"
76
+ class="px-1.5 py-1 left-2 bottom-2 absolute bg-other-sale rounded inline-flex justify-center items-center">
77
+ <div class="text-states-on-error text-xs font-bold leading-none">
78
+ {{ translations.product.badges.topseller }}
79
+ </div>
80
+ </div>
81
+
82
+ <client-only>
83
+ <SwIconButton type="secondary" aria-label="Toggle wishlist" :disabled="isLoading"
84
+ class="w-10 h-10 right-4 top-4 absolute bg-brand-secondary rounded-full flex items-center justify-center"
85
+ data-testid="product-box-toggle-wishlist-button" @click="toggleWishlist">
86
+ <SwWishlistIcon :filled="isInWishlist" />
87
+ </SwIconButton>
88
+ </client-only>
89
+ </div>
90
+ </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>