@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.
- package/README.md +398 -12
- package/app/app.config.ts +18 -0
- package/app/assets/icons/check-circle.svg +3 -0
- package/app/assets/icons/checkmark.svg +3 -0
- package/app/assets/icons/chevron.svg +3 -0
- package/app/assets/icons/exclamation-circle.svg +3 -0
- package/app/assets/icons/star-empty.svg +3 -0
- package/app/assets/icons/star-filled.svg +3 -0
- package/app/assets/icons/user.svg +1 -0
- package/app/components/SwCategoryNavigation.vue +83 -0
- package/app/components/SwCategoryNavigationLink.vue +128 -0
- package/{components → app/components}/SwContactForm.vue +27 -27
- package/app/components/SwFilterChips.vue +144 -0
- package/app/components/SwFilterDropdown.vue +54 -0
- package/app/components/SwListingProductPrice.vue +89 -0
- package/{components → app/components}/SwMedia3D.vue +4 -2
- package/{components → app/components}/SwNewsletterForm.vue +45 -34
- package/{components → app/components}/SwPagination.vue +3 -5
- package/{components → app/components}/SwProductAddToCart.vue +22 -27
- package/app/components/SwProductCard.vue +169 -0
- package/app/components/SwProductCardDetails.vue +74 -0
- package/app/components/SwProductCardImage.vue +90 -0
- package/app/components/SwProductCardSkeleton.vue +33 -0
- package/app/components/SwProductGallery.vue +43 -0
- package/app/components/SwProductListingFilter.vue +75 -0
- package/app/components/SwProductListingFilters.vue +304 -0
- package/app/components/SwProductListingFiltersHorizontal.vue +306 -0
- package/{components → app/components}/SwProductPrice.vue +3 -3
- package/app/components/SwProductRating.vue +40 -0
- package/{components → app/components}/SwProductReviews.vue +25 -23
- package/app/components/SwProductReviewsForm.vue +292 -0
- package/{components → app/components}/SwProductUnits.vue +10 -15
- package/app/components/SwQuantitySelect.vue +103 -0
- package/{components → app/components}/SwSlider.vue +154 -55
- package/app/components/SwSortDropdown.vue +87 -0
- package/app/components/SwStockInfo.vue +44 -0
- package/{components → app/components}/SwVariantConfigurator.vue +13 -12
- package/app/components/listing-filters/SwFilterPrice.vue +219 -0
- package/app/components/listing-filters/SwFilterProperties.vue +120 -0
- package/app/components/listing-filters/SwFilterRating.vue +99 -0
- package/app/components/listing-filters/SwFilterShippingFree.vue +114 -0
- package/app/components/public/cms/CmsBlockSpatialViewer.vue +94 -0
- package/app/components/public/cms/CmsGenericBlock.md +42 -0
- package/{components → app/components}/public/cms/CmsGenericBlock.vue +15 -1
- package/{components → app/components}/public/cms/CmsPage.md +19 -2
- package/{components → app/components}/public/cms/CmsPage.vue +30 -5
- package/{components → app/components}/public/cms/block/CmsBlockCenterText.vue +1 -1
- package/{components → app/components}/public/cms/block/CmsBlockGalleryBuybox.vue +5 -5
- package/{components → app/components}/public/cms/block/CmsBlockImageBubbleRow.vue +5 -5
- package/app/components/public/cms/block/CmsBlockImageFourColumn.vue +41 -0
- package/app/components/public/cms/block/CmsBlockImageGalleryBig.vue +42 -0
- package/app/components/public/cms/block/CmsBlockImageHighlightRow.vue +37 -0
- package/{components → app/components}/public/cms/block/CmsBlockImageSimpleGrid.vue +11 -5
- package/{components → app/components}/public/cms/block/CmsBlockImageText.vue +7 -3
- package/{components → app/components}/public/cms/block/CmsBlockImageTextBubble.vue +13 -16
- package/{components → app/components}/public/cms/block/CmsBlockImageTextCover.vue +7 -9
- package/app/components/public/cms/block/CmsBlockImageTextGallery.vue +88 -0
- package/app/components/public/cms/block/CmsBlockImageTextRow.vue +53 -0
- package/{components → app/components}/public/cms/block/CmsBlockImageThreeColumn.vue +10 -4
- package/app/components/public/cms/block/CmsBlockImageThreeCover.vue +37 -0
- package/app/components/public/cms/block/CmsBlockImageTwoColumn.vue +37 -0
- package/{components → app/components}/public/cms/block/CmsBlockProductHeading.vue +1 -1
- package/{components → app/components}/public/cms/block/CmsBlockProductThreeColumn.vue +10 -4
- package/{components → app/components}/public/cms/block/CmsBlockSidebarFilter.vue +3 -1
- package/{components → app/components}/public/cms/block/CmsBlockTextOnImage.vue +8 -5
- package/app/components/public/cms/element/CmsElementBuyBox.vue +145 -0
- package/app/components/public/cms/element/CmsElementCategoryNavigation.vue +53 -0
- package/{components → app/components}/public/cms/element/CmsElementCrossSelling.vue +22 -6
- package/{components → app/components}/public/cms/element/CmsElementImage.vue +58 -21
- package/app/components/public/cms/element/CmsElementImageGallery.vue +225 -0
- package/{components → app/components}/public/cms/element/CmsElementImageSlider.vue +2 -2
- package/{components → app/components}/public/cms/element/CmsElementProductBox.vue +8 -1
- package/app/components/public/cms/element/CmsElementProductDescriptionReviews.vue +217 -0
- package/{components → app/components}/public/cms/element/CmsElementProductListing.vue +31 -95
- package/app/components/public/cms/element/CmsElementProductName.vue +16 -0
- package/app/components/public/cms/element/CmsElementProductSlider.vue +101 -0
- package/app/components/public/cms/element/CmsElementSidebarFilter.vue +20 -0
- package/{components → app/components}/public/cms/element/CmsElementText.vue +17 -12
- package/app/components/public/cms/element/SwProductListingPagination.vue +70 -0
- package/{components → app/components}/public/cms/section/CmsSectionDefault.vue +2 -2
- package/app/components/public/cms/section/CmsSectionSidebar.vue +39 -0
- package/app/components/public/cms/skeleton/ProductCardSkeleton.vue +28 -0
- package/app/components/ui/BaseButton.vue +102 -0
- package/app/components/ui/BaseIcon.vue +15 -0
- package/app/components/ui/Checkbox.vue +49 -0
- package/app/components/ui/CheckmarkIcon.vue +23 -0
- package/app/components/ui/ChevronIcon.vue +34 -0
- package/app/components/ui/ExclamationIcon.vue +11 -0
- package/app/components/ui/IconButton.vue +32 -0
- package/app/components/ui/RadioButton.vue +26 -0
- package/app/components/ui/StarIcon.vue +18 -0
- package/app/components/ui/SwitchButton.vue +100 -0
- package/app/components/ui/UserIcon.vue +11 -0
- package/app/components/ui/WishlistIcon.vue +15 -0
- package/app/composables/useImagePlaceholder.ts +27 -0
- package/app/composables/useLcpImagePreload.test.ts +229 -0
- package/app/composables/useLcpImagePreload.ts +39 -0
- package/{helpers → app/helpers}/clientOnly.ts +5 -0
- package/app/helpers/cms/findFirstCmsImageUrl.ts +86 -0
- package/app/helpers/cms/getImageSizes.test.ts +50 -0
- package/app/helpers/cms/getImageSizes.ts +36 -0
- package/app/helpers/html-to-vue/ast.ts +106 -0
- package/{helpers → app/helpers}/html-to-vue/getOptionsFromNode.ts +1 -1
- package/{helpers → app/helpers}/html-to-vue/renderToHtml.ts +7 -11
- package/app/helpers/html-to-vue/renderer.ts +116 -0
- package/app/plugins/unocss-runtime.client.ts +23 -0
- package/app/providers/shopware.test.ts +213 -0
- package/app/providers/shopware.ts +107 -0
- package/dist/index.d.mts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.mjs +2 -2
- package/index.d.ts +36 -0
- package/nuxt.config.ts +100 -6
- package/package.json +33 -23
- package/uno.config.ts +94 -0
- package/components/SwCategoryNavigation.vue +0 -44
- package/components/SwCategoryNavigationLink.vue +0 -57
- package/components/SwListingProductPrice.vue +0 -89
- package/components/SwProductCard.vue +0 -286
- package/components/SwProductGallery.vue +0 -39
- package/components/SwProductListingFilter.vue +0 -42
- package/components/SwProductListingFilters.vue +0 -292
- package/components/listing-filters/SwFilterPrice.vue +0 -160
- package/components/listing-filters/SwFilterProperties.vue +0 -123
- package/components/listing-filters/SwFilterRating.vue +0 -101
- package/components/listing-filters/SwFilterShippingFree.vue +0 -104
- package/components/public/cms/CmsGenericBlock.md +0 -27
- package/components/public/cms/block/CmsBlockImageFourColumn.vue +0 -29
- package/components/public/cms/block/CmsBlockImageHighlightRow.vue +0 -27
- package/components/public/cms/block/CmsBlockImageTextGallery.vue +0 -85
- package/components/public/cms/block/CmsBlockImageTextRow.vue +0 -43
- package/components/public/cms/block/CmsBlockImageThreeCover.vue +0 -27
- package/components/public/cms/block/CmsBlockImageTwoColumn.vue +0 -25
- package/components/public/cms/element/CmsElementBuyBox.vue +0 -190
- package/components/public/cms/element/CmsElementCategoryNavigation.vue +0 -167
- package/components/public/cms/element/CmsElementImageGallery.vue +0 -249
- package/components/public/cms/element/CmsElementProductDescriptionReviews.vue +0 -123
- package/components/public/cms/element/CmsElementProductName.vue +0 -10
- package/components/public/cms/element/CmsElementProductSlider.vue +0 -80
- package/components/public/cms/element/CmsElementSidebarFilter.vue +0 -12
- package/components/public/cms/section/CmsSectionSidebar.vue +0 -41
- package/components/public/cms/skeleton/ProductCardSkeleton.vue +0 -44
- package/helpers/html-to-vue/ast.ts +0 -72
- package/helpers/html-to-vue/renderer.ts +0 -56
- /package/{components → app/components}/SwSharedPrice.vue +0 -0
- /package/{components → app/components}/public/cms/CmsGenericElement.md +0 -0
- /package/{components → app/components}/public/cms/CmsGenericElement.vue +0 -0
- /package/{components → app/components}/public/cms/CmsNoComponent.vue +0 -0
- /package/{components → app/components}/public/cms/block/CmsBlockCategoryNavigation.vue +0 -0
- /package/{components → app/components}/public/cms/block/CmsBlockCrossSelling.vue +0 -0
- /package/{components → app/components}/public/cms/block/CmsBlockCustomForm.vue +0 -0
- /package/{components → app/components}/public/cms/block/CmsBlockDefault.vue +0 -0
- /package/{components → app/components}/public/cms/block/CmsBlockForm.vue +0 -0
- /package/{components → app/components}/public/cms/block/CmsBlockHtml.vue +0 -0
- /package/{components → app/components}/public/cms/block/CmsBlockImage.vue +0 -0
- /package/{components → app/components}/public/cms/block/CmsBlockImageCover.vue +0 -0
- /package/{components → app/components}/public/cms/block/CmsBlockImageGallery.vue +0 -0
- /package/{components → app/components}/public/cms/block/CmsBlockImageSlider.vue +0 -0
- /package/{components → app/components}/public/cms/block/CmsBlockProductDescriptionReviews.vue +0 -0
- /package/{components → app/components}/public/cms/block/CmsBlockProductListing.vue +0 -0
- /package/{components → app/components}/public/cms/block/CmsBlockProductSlider.vue +0 -0
- /package/{components → app/components}/public/cms/block/CmsBlockText.vue +0 -0
- /package/{components → app/components}/public/cms/block/CmsBlockTextHero.vue +0 -0
- /package/{components → app/components}/public/cms/block/CmsBlockTextTeaser.vue +0 -0
- /package/{components → app/components}/public/cms/block/CmsBlockTextTeaserSection.vue +0 -0
- /package/{components → app/components}/public/cms/block/CmsBlockTextThreeColumn.vue +0 -0
- /package/{components → app/components}/public/cms/block/CmsBlockTextTwoColumn.vue +0 -0
- /package/{components → app/components}/public/cms/block/CmsBlockVimeoVideo.vue +0 -0
- /package/{components → app/components}/public/cms/block/CmsBlockYoutubeVideo.vue +0 -0
- /package/{components → app/components}/public/cms/element/CmsElementBuyBox.md +0 -0
- /package/{components → app/components}/public/cms/element/CmsElementCategoryNavigation.md +0 -0
- /package/{components → app/components}/public/cms/element/CmsElementCrossSelling.md +0 -0
- /package/{components → app/components}/public/cms/element/CmsElementCustomForm.md +0 -0
- /package/{components → app/components}/public/cms/element/CmsElementCustomForm.vue +0 -0
- /package/{components → app/components}/public/cms/element/CmsElementForm.md +0 -0
- /package/{components → app/components}/public/cms/element/CmsElementForm.vue +0 -0
- /package/{components → app/components}/public/cms/element/CmsElementHtml.vue +0 -0
- /package/{components → app/components}/public/cms/element/CmsElementImage.md +0 -0
- /package/{components → app/components}/public/cms/element/CmsElementImageGallery.md +0 -0
- /package/{components → app/components}/public/cms/element/CmsElementImageGallery3dPlaceholder.vue +0 -0
- /package/{components → app/components}/public/cms/element/CmsElementImageSlider.md +0 -0
- /package/{components → app/components}/public/cms/element/CmsElementManufacturerLogo.md +0 -0
- /package/{components → app/components}/public/cms/element/CmsElementManufacturerLogo.vue +0 -0
- /package/{components → app/components}/public/cms/element/CmsElementProductBox.md +0 -0
- /package/{components → app/components}/public/cms/element/CmsElementProductDescriptionReviews.md +0 -0
- /package/{components → app/components}/public/cms/element/CmsElementProductListing.md +0 -0
- /package/{components → app/components}/public/cms/element/CmsElementProductName.md +0 -0
- /package/{components → app/components}/public/cms/element/CmsElementProductSlider.md +0 -0
- /package/{components → app/components}/public/cms/element/CmsElementSidebarFilter.md +0 -0
- /package/{components → app/components}/public/cms/element/CmsElementText.md +0 -0
- /package/{components → app/components}/public/cms/element/CmsElementVimeoVideo.md +0 -0
- /package/{components → app/components}/public/cms/element/CmsElementVimeoVideo.vue +0 -0
- /package/{components → app/components}/public/cms/element/CmsElementYoutubeVideo.md +0 -0
- /package/{components → app/components}/public/cms/element/CmsElementYoutubeVideo.vue +0 -0
- /package/{components → app/components}/public/cms/section/CmsSectionDefault.md +0 -0
- /package/{components → app/components}/public/cms/section/CmsSectionSidebar.md +0 -0
- /package/{helpers → app/helpers}/html-to-vue/getOptionsFromNode.test.ts +0 -0
- /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
|
-
|
|
82
|
-
|
|
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
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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-
|
|
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
|
|
218
|
+
$v.email.$error
|
|
208
219
|
? 'border-red-600 focus:border-red-600'
|
|
209
|
-
: 'border-
|
|
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-
|
|
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
|
|
224
|
+
@blur="$v.email.$touch()"
|
|
214
225
|
/>
|
|
215
226
|
<span
|
|
216
|
-
v-if="$v.email?.$
|
|
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
|
|
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-
|
|
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-
|
|
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
|
-
|
|
254
|
-
|
|
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]
|
|
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-
|
|
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-
|
|
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]
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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-
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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>
|