@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,292 @@
1
+ <script setup lang="ts">
2
+ import { ApiClientError } from "@shopware/api-client";
3
+ import type { ApiError } from "@shopware/api-client";
4
+ import { useCmsTranslations } from "@shopware/composables";
5
+ import { useVuelidate } from "@vuelidate/core";
6
+ import type { ValidationRuleWithoutParams } from "@vuelidate/core";
7
+ import { minLength, required } from "@vuelidate/validators";
8
+ import { defu } from "defu";
9
+ import { computed, reactive, ref } from "vue";
10
+ import { useShopwareContext } from "#imports";
11
+
12
+ const props = defineProps<{
13
+ productId: string;
14
+ }>();
15
+
16
+ const emit = defineEmits<{
17
+ success: [];
18
+ }>();
19
+
20
+ type Translations = {
21
+ product: {
22
+ addReview: string;
23
+ reviewsForm: {
24
+ title: string;
25
+ titlePlaceholder: string;
26
+ review: string;
27
+ reviewPlaceholder: string;
28
+ submit: string;
29
+ rating: string;
30
+ };
31
+ errors: {
32
+ reviewAlreadyExists: string;
33
+ };
34
+ };
35
+ errors?: {
36
+ [key: string]: string;
37
+ };
38
+ };
39
+
40
+ let translations: Translations = {
41
+ product: {
42
+ addReview: "Add review",
43
+ reviewsForm: {
44
+ title: "Title",
45
+ titlePlaceholder: "Enter a title for your review",
46
+ review: "Your review",
47
+ reviewPlaceholder:
48
+ "Share your experience with this product (minimum 40 characters)",
49
+ submit: "Submit",
50
+ rating: "Your rating",
51
+ },
52
+ errors: {
53
+ reviewAlreadyExists:
54
+ "You have already submitted a review for this product",
55
+ },
56
+ },
57
+ };
58
+
59
+ translations = defu(useCmsTranslations(), translations) as Translations;
60
+
61
+ type State = {
62
+ rating: number | null;
63
+ title: string;
64
+ review: string;
65
+ };
66
+
67
+ const state = reactive<State>({
68
+ rating: null,
69
+ title: "",
70
+ review: "",
71
+ });
72
+
73
+ const isLoading = ref(false);
74
+ const errorMessages = ref<ApiError[]>([]);
75
+
76
+ const rules = computed(() => ({
77
+ rating: {
78
+ required: required as ValidationRuleWithoutParams,
79
+ },
80
+ title: {
81
+ required: required as ValidationRuleWithoutParams,
82
+ minLength: minLength(5),
83
+ },
84
+ review: {
85
+ required: required as ValidationRuleWithoutParams,
86
+ minLength: minLength(40),
87
+ },
88
+ }));
89
+
90
+ const { apiClient } = useShopwareContext();
91
+
92
+ const $v = useVuelidate(rules, state);
93
+
94
+ const invokeSend = async () => {
95
+ $v.value.$touch();
96
+ const valid = await $v.value.$validate();
97
+
98
+ if (!valid) {
99
+ return;
100
+ }
101
+
102
+ try {
103
+ isLoading.value = true;
104
+ await apiClient.invoke(
105
+ "saveProductReview post /product/{productId}/review",
106
+ {
107
+ pathParams: {
108
+ productId: props.productId,
109
+ },
110
+ body: {
111
+ title: state.title,
112
+ content: state.review,
113
+ points: state.rating || 0,
114
+ },
115
+ },
116
+ );
117
+
118
+ // Reset form state after successful submission
119
+ state.rating = null;
120
+ state.title = "";
121
+ state.review = "";
122
+ $v.value.$reset();
123
+ errorMessages.value = [];
124
+
125
+ emit("success");
126
+ } catch (error) {
127
+ if (error instanceof ApiClientError) {
128
+ // Resolve error messages using i18n translations when available
129
+ errorMessages.value = error.details.errors.map((err: ApiError) => {
130
+ // Try to get translation from i18n errors namespace (if template provides it)
131
+ const translatedMessage =
132
+ (err.code && translations.errors?.[err.code]) ??
133
+ // Fall back to product-specific error translations
134
+ (err.code === "VIOLATION::ENTITY_EXISTS"
135
+ ? translations.product.errors.reviewAlreadyExists
136
+ : undefined) ??
137
+ // Fall back to API error detail
138
+ err.detail;
139
+
140
+ return {
141
+ ...err,
142
+ detail: translatedMessage,
143
+ };
144
+ });
145
+ }
146
+ } finally {
147
+ isLoading.value = false;
148
+ }
149
+ };
150
+
151
+ const invokeRating = (value: number) => {
152
+ state.rating = value;
153
+ };
154
+ </script>
155
+
156
+ <template>
157
+ <form class="flex flex-col gap-4 md:gap-5 relative" @submit.prevent="invokeSend">
158
+ <div
159
+ v-if="isLoading"
160
+ class="absolute inset-0 flex items-center justify-center z-10 bg-surface-surface/80 rounded-md"
161
+ >
162
+ <div
163
+ class="h-12 w-12 i-carbon-progress-bar-round animate-spin text-brand-primary"
164
+ />
165
+ </div>
166
+ <div>
167
+ <div class="flex flex-col gap-2">
168
+ <h4 class="text-lg md:text-xl font-bold text-surface-on-surface mt-3">
169
+ {{ translations.product.addReview }}
170
+ </h4>
171
+ <span class="text-sm text-surface-on-surface-variant">{{
172
+ translations.product.reviewsForm.rating
173
+ }}</span>
174
+ <div class="flex flex-row gap-2" role="group" :aria-label="translations.product.reviewsForm.rating">
175
+ <SwStarIcon
176
+ v-for="index in state.rating || 0"
177
+ :key="`filled-${index}`"
178
+ :filled="true"
179
+ :size="24"
180
+ role="button"
181
+ :aria-label="`Rate ${index} out of 5 stars`"
182
+ tabindex="0"
183
+ class="cursor-pointer hover:opacity-80 transition-opacity active:scale-95 focus:outline-none focus:ring-2 focus:ring-brand-primary rounded"
184
+ data-testid="review-filled-star"
185
+ @click="invokeRating(index)"
186
+ @keydown.enter.prevent="invokeRating(index)"
187
+ @keydown.space.prevent="invokeRating(index)"
188
+ />
189
+ <SwStarIcon
190
+ v-for="index in 5 - (state.rating || 0)"
191
+ :key="`empty-${index}`"
192
+ :filled="false"
193
+ :size="24"
194
+ role="button"
195
+ :aria-label="`Rate ${(state.rating || 0) + index} out of 5 stars`"
196
+ tabindex="0"
197
+ class="cursor-pointer hover:opacity-80 transition-opacity active:scale-95 focus:outline-none focus:ring-2 focus:ring-brand-primary rounded"
198
+ data-testid="review-empty-star"
199
+ @click="invokeRating((state.rating || 0) + index)"
200
+ @keydown.enter.prevent="invokeRating((state.rating || 0) + index)"
201
+ @keydown.space.prevent="invokeRating((state.rating || 0) + index)"
202
+ />
203
+ </div>
204
+ <span
205
+ v-if="$v.rating.$error && $v.rating.$errors[0]?.$message"
206
+ class="pt-1 text-sm text-states-error"
207
+ >
208
+ {{ $v.rating.$errors[0].$message }}
209
+ </span>
210
+ </div>
211
+ </div>
212
+ <div
213
+ v-if="errorMessages.length"
214
+ class="p-3 mb-4 bg-surface-surface-container border border-states-error rounded-md flex gap-2 md:gap-3 items-start"
215
+ >
216
+ <div class="w-5 h-5 text-states-error flex-shrink-0 mt-0.5">
217
+ <SwExclamationIcon :size="20" />
218
+ </div>
219
+ <div class="flex-1">
220
+ <p v-for="(error, index) in errorMessages" :key="index" class="text-sm text-states-error">
221
+ {{ error.detail }}
222
+ </p>
223
+ </div>
224
+ </div>
225
+ <div>
226
+ <label
227
+ for="title"
228
+ class="block mb-2 text-sm font-medium text-surface-on-surface"
229
+ >{{ translations.product.reviewsForm.title }}</label
230
+ >
231
+ <input
232
+ id="title"
233
+ v-model="state.title"
234
+ class="block w-full px-3 py-2.5 md:py-2 border rounded-md text-base md:text-sm text-surface-on-surface bg-surface-surface placeholder-surface-on-surface-variant focus:outline-none focus:ring-2 focus:ring-outline-outline"
235
+ :class="[
236
+ $v.title.$error
237
+ ? 'border-states-error focus:ring-states-error'
238
+ : 'border-outline-outline',
239
+ ]"
240
+ type="text"
241
+ :placeholder="translations.product.reviewsForm.titlePlaceholder"
242
+ :disabled="isLoading"
243
+ data-testid="review-title-input"
244
+ @blur="$v.title.$touch()"
245
+ />
246
+ <span
247
+ v-if="$v.title.$error && $v.title.$errors[0]?.$message"
248
+ class="pt-1 text-sm text-states-error"
249
+ >
250
+ {{ $v.title.$errors[0].$message }}
251
+ </span>
252
+ </div>
253
+ <div>
254
+ <label
255
+ for="review"
256
+ class="block mb-2 text-sm font-medium text-surface-on-surface"
257
+ >{{ translations.product.reviewsForm.review }}</label
258
+ >
259
+ <textarea
260
+ id="review"
261
+ v-model="state.review"
262
+ class="block w-full px-3 py-2.5 md:py-2 border rounded-md text-base md:text-sm text-surface-on-surface bg-surface-surface placeholder-surface-on-surface-variant focus:outline-none focus:ring-2 focus:ring-outline-outline min-h-32 md:min-h-40"
263
+ :class="[
264
+ $v.review.$error
265
+ ? 'border-states-error focus:ring-states-error'
266
+ : 'border-outline-outline',
267
+ ]"
268
+ :placeholder="translations.product.reviewsForm.reviewPlaceholder"
269
+ :disabled="isLoading"
270
+ data-testid="review-text-input"
271
+ @blur="$v.review.$touch()"
272
+ />
273
+ <span
274
+ v-if="$v.review.$error && $v.review.$errors[0]?.$message"
275
+ class="pt-1 text-sm text-states-error"
276
+ >
277
+ {{ $v.review.$errors[0].$message }}
278
+ </span>
279
+ </div>
280
+ <SwBaseButton
281
+ type="submit"
282
+ variant="primary"
283
+ size="medium"
284
+ :disabled="isLoading"
285
+ :loading="isLoading"
286
+ class="mt-4 w-full md:w-auto md:self-start"
287
+ data-testid="review-submit-button"
288
+ >
289
+ {{ translations.product.reviewsForm.submit }}
290
+ </SwBaseButton>
291
+ </form>
292
+ </template>
@@ -4,15 +4,10 @@ import { defu } from "defu";
4
4
  import { computed } from "vue";
5
5
  import type { Schemas } from "#shopware";
6
6
 
7
- const props = withDefaults(
8
- defineProps<{
9
- product: Schemas["Product"];
10
- showContent?: boolean;
11
- }>(),
12
- {
13
- showContent: true,
14
- },
15
- );
7
+ const { product, showContent = true } = defineProps<{
8
+ product: Schemas["Product"];
9
+ showContent?: boolean;
10
+ }>();
16
11
 
17
12
  type Translations = {
18
13
  product: {
@@ -28,22 +23,22 @@ let translations: Translations = {
28
23
 
29
24
  translations = defu(useCmsTranslations(), translations) as Translations;
30
25
 
31
- const purchaseUnit = computed(() => props.product?.purchaseUnit);
32
- const unitName = computed(() => props.product?.unit?.translated.name);
26
+ const purchaseUnit = computed(() => product?.purchaseUnit);
27
+ const unitName = computed(() => product?.unit?.translated.name);
33
28
  const referencePrice = computed(
34
- () => props.product?.calculatedPrice?.referencePrice?.price,
29
+ () => product?.calculatedPrice?.referencePrice?.price,
35
30
  );
36
31
  const referenceUnit = computed(
37
- () => props.product?.calculatedPrice?.referencePrice?.referenceUnit,
32
+ () => product?.calculatedPrice?.referencePrice?.referenceUnit,
38
33
  );
39
34
  const referenceUnitName = computed(
40
- () => props.product?.calculatedPrice?.referencePrice?.unitName,
35
+ () => product?.calculatedPrice?.referencePrice?.unitName,
41
36
  );
42
37
  </script>
43
38
 
44
39
  <template>
45
40
  <div v-if="purchaseUnit" class="flex text-gray-500 justify-end gap-1">
46
- <template v-if="props.showContent">
41
+ <template v-if="showContent">
47
42
  {{ translations.product.content }}: {{ purchaseUnit }} {{ unitName }}
48
43
  </template>
49
44
  <template v-if="referencePrice">
@@ -0,0 +1,103 @@
1
+ <script setup lang="ts">
2
+ import { defu } from "defu";
3
+ import { computed, useId } from "vue";
4
+ import { useCmsTranslations } from "#imports";
5
+
6
+ type Translations = {
7
+ form: {
8
+ quantitySelect: {
9
+ label: string;
10
+ increaseButton: string;
11
+ decreaseButton: string;
12
+ };
13
+ };
14
+ };
15
+
16
+ let translations = {
17
+ form: {
18
+ quantitySelect: {
19
+ label: "Quantity",
20
+ increaseButton: "Increase quantity",
21
+ decreaseButton: "Decrease quantity",
22
+ },
23
+ },
24
+ };
25
+
26
+ translations = defu(useCmsTranslations(), translations) as Translations;
27
+
28
+ const quantity = defineModel<number>({
29
+ required: true,
30
+ });
31
+
32
+ const {
33
+ size = "large",
34
+ steps,
35
+ max,
36
+ min,
37
+ id: propId,
38
+ } = defineProps<{
39
+ size?: "small" | "large";
40
+ steps?: number;
41
+ min?: number;
42
+ max?: number;
43
+ id?: string;
44
+ }>();
45
+
46
+ // generate an id that prefers a provided prop and otherwise uses Vue's useId for SSR/CSR consistency
47
+ const generatedId = useId();
48
+ const inputId = computed(() => propId || generatedId);
49
+
50
+ function increaseQty() {
51
+ quantity.value++;
52
+ }
53
+
54
+ function decreaseQty() {
55
+ if (quantity.value > 1) {
56
+ quantity.value--;
57
+ }
58
+ }
59
+
60
+ const sizeClasses = {
61
+ small: "w-8 h-8",
62
+ large: "w-10 h-10",
63
+ };
64
+ </script>
65
+ <template>
66
+ <div class="rounded outline outline-1 outline-offset-[-1px] outline-outline-outline inline-flex">
67
+ <button
68
+ type="button"
69
+ :class="sizeClasses[size]"
70
+ class="bg-surface-surface border-0 border-r-1 cursor-pointer hover:bg-brand-tertiary-hover font-semibold"
71
+ @click="decreaseQty"
72
+ :aria-label="translations.form.quantitySelect.decreaseButton"
73
+ >
74
+ -
75
+ </button>
76
+ <div class="bg-white border-l border-r border-outline-outline inline-flex flex-col justify-center items-center">
77
+ <!-- visually hidden label for screen readers -->
78
+ <label :for="inputId" class="sr-only">{{ translations.form.quantitySelect.label }}</label>
79
+
80
+ <input
81
+ :id="inputId"
82
+ v-model="quantity"
83
+ type="number"
84
+ :min="min"
85
+ :max="max"
86
+ :step="steps"
87
+ data-testid="product-quantity"
88
+ :class="sizeClasses[size]"
89
+ class="self-stretch text-center justify-start text-surface-on-surface text-xs font-bold leading-[18px] appearance-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
90
+ :aria-label="translations.form.quantitySelect.label"
91
+ />
92
+ </div>
93
+ <button
94
+ type="button"
95
+ :class="sizeClasses[size]"
96
+ class="w-10 bg-surface-surface border-0 border-l-1 cursor-pointer hover:bg-brand-tertiary-hover font-semibold"
97
+ @click="increaseQty"
98
+ :aria-label="translations.form.quantitySelect.increaseButton"
99
+ >
100
+ +
101
+ </button>
102
+ </div>
103
+ </template>