@shopware/cms-base-layer 1.5.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +328 -13
- package/app/app.config.ts +7 -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 +76 -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/SwListingProductPrice.vue +89 -0
- 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 +170 -0
- package/app/components/SwProductCardDetails.vue +57 -0
- package/app/components/SwProductCardImage.vue +87 -0
- package/app/components/SwProductCardSkeleton.vue +33 -0
- package/app/components/SwProductListingFilter.vue +64 -0
- package/app/components/SwProductListingFilters.vue +308 -0
- package/{components → app/components}/SwProductReviews.vue +28 -13
- package/app/components/SwProductReviewsForm.vue +292 -0
- package/app/components/SwQuantitySelect.vue +106 -0
- package/{components → app/components}/SwSlider.vue +4 -4
- package/app/components/SwSortDropdown.vue +83 -0
- package/app/components/SwStockInfo.vue +44 -0
- package/{components → app/components}/SwVariantConfigurator.vue +1 -1
- package/app/components/listing-filters/SwFilterPrice.vue +214 -0
- package/app/components/listing-filters/SwFilterProperties.vue +113 -0
- package/app/components/listing-filters/SwFilterRating.vue +90 -0
- package/app/components/listing-filters/SwFilterShippingFree.vue +107 -0
- package/{components → app/components}/public/cms/CmsPage.vue +19 -4
- 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/app/components/public/cms/block/CmsBlockTextOnImage.vue +30 -0
- package/{components → app/components}/public/cms/block/CmsBlockTextTeaserSection.vue +4 -4
- package/{components → app/components}/public/cms/block/CmsBlockTextTwoColumn.vue +3 -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 +3 -3
- package/{components → app/components}/public/cms/element/CmsElementImage.vue +52 -13
- package/app/components/public/cms/element/CmsElementImageGallery.vue +158 -0
- package/{components → app/components}/public/cms/element/CmsElementImageSlider.vue +2 -2
- package/{components → app/components}/public/cms/element/CmsElementProductBox.vue +2 -1
- package/app/components/public/cms/element/CmsElementProductDescriptionReviews.vue +217 -0
- package/{components → app/components}/public/cms/element/CmsElementProductListing.vue +23 -94
- package/app/components/public/cms/element/CmsElementProductName.vue +11 -0
- package/{components → app/components}/public/cms/element/CmsElementProductSlider.vue +4 -4
- package/{components → app/components}/public/cms/element/CmsElementText.vue +8 -2
- package/{components → app/components}/public/cms/element/CmsElementYoutubeVideo.vue +8 -2
- package/app/components/public/cms/element/SwProductListingPagination.vue +70 -0
- package/{components → app/components}/public/cms/section/CmsSectionDefault.vue +1 -1
- package/app/components/public/cms/section/CmsSectionSidebar.vue +36 -0
- package/app/components/public/cms/skeleton/ProductCardSkeleton.vue +28 -0
- package/app/components/ui/BaseButton.vue +99 -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 +37 -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 +20 -0
- package/app/composables/useImagePlaceholder.ts +27 -0
- package/{helpers → app/helpers}/clientOnly.ts +5 -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 +12 -0
- package/nuxt.config.ts +80 -6
- package/package.json +29 -21
- package/uno.config.ts +83 -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/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/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/block/CmsBlockTextOnImage.vue +0 -20
- package/components/public/cms/element/CmsBlockHtml.md +0 -1
- 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/section/CmsSectionSidebar.vue +0 -49
- package/components/public/cms/skeleton/ProductCardSkeleton.vue +0 -44
- /package/{components → app/components}/SwMedia3D.vue +0 -0
- /package/{components → app/components}/SwProductGallery.vue +0 -0
- /package/{components → app/components}/SwProductPrice.vue +0 -0
- /package/{components → app/components}/SwProductUnits.vue +0 -0
- /package/{components → app/components}/SwSharedPrice.vue +0 -0
- /package/{components → app/components}/public/cms/CmsGenericBlock.md +0 -0
- /package/{components → app/components}/public/cms/CmsGenericBlock.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/CmsPage.md +0 -0
- /package/{components → app/components}/public/cms/block/CmsBlockCategoryNavigation.vue +0 -0
- /package/{components → app/components}/public/cms/block/CmsBlockCenterText.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/public/cms/element → 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/CmsBlockTextThreeColumn.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/CmsElementSidebarFilter.vue +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/section/CmsSectionDefault.md +0 -0
- /package/{components → app/components}/public/cms/section/CmsSectionSidebar.md +0 -0
- /package/{helpers → app/helpers}/html-to-vue/ast.ts +0 -0
- /package/{helpers → app/helpers}/html-to-vue/getOptionsFromNode.test.ts +0 -0
- /package/{helpers → app/helpers}/html-to-vue/getOptionsFromNode.ts +0 -0
- /package/{helpers → app/helpers}/html-to-vue/renderToHtml.ts +0 -0
- /package/{helpers → app/helpers}/html-to-vue/renderer.ts +0 -0
- /package/{helpers → app/helpers}/media/isSpatial.ts +0 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type {
|
|
3
|
+
CmsElementProductListing,
|
|
4
|
+
CmsElementSidebarFilter,
|
|
5
|
+
} from "@shopware/composables";
|
|
6
|
+
import { useCmsTranslations } from "@shopware/composables";
|
|
7
|
+
import { defu } from "defu";
|
|
8
|
+
import { computed, reactive } from "vue";
|
|
9
|
+
import type { ComputedRef, UnwrapNestedRefs } from "vue";
|
|
10
|
+
import { type LocationQueryRaw, useRoute, useRouter } from "vue-router";
|
|
11
|
+
import { useCategoryListing } from "#imports";
|
|
12
|
+
import type { Schemas, operations } from "#shopware";
|
|
13
|
+
|
|
14
|
+
const props = defineProps<{
|
|
15
|
+
content: CmsElementProductListing | CmsElementSidebarFilter;
|
|
16
|
+
listingType?: string;
|
|
17
|
+
}>();
|
|
18
|
+
|
|
19
|
+
type Translations = {
|
|
20
|
+
listing: {
|
|
21
|
+
filters: string;
|
|
22
|
+
sort: string;
|
|
23
|
+
resetFilters: string;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type FilterState = {
|
|
28
|
+
manufacturer: Set<string>;
|
|
29
|
+
properties: Set<string>;
|
|
30
|
+
"min-price": number | undefined;
|
|
31
|
+
"max-price": number | undefined;
|
|
32
|
+
rating: number | undefined;
|
|
33
|
+
"shipping-free": boolean | undefined;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let translations: Translations = {
|
|
37
|
+
listing: {
|
|
38
|
+
filters: "Filters",
|
|
39
|
+
sort: "Sort",
|
|
40
|
+
resetFilters: "Reset filters",
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
translations = defu(useCmsTranslations(), translations) as Translations;
|
|
45
|
+
|
|
46
|
+
const route = useRoute();
|
|
47
|
+
const router = useRouter();
|
|
48
|
+
|
|
49
|
+
const {
|
|
50
|
+
changeCurrentSortingOrder,
|
|
51
|
+
getCurrentFilters,
|
|
52
|
+
getCurrentSortingOrder,
|
|
53
|
+
getInitialFilters,
|
|
54
|
+
getSortingOrders,
|
|
55
|
+
search,
|
|
56
|
+
} = useCategoryListing();
|
|
57
|
+
|
|
58
|
+
const sidebarSelectedFilters: UnwrapNestedRefs<FilterState> =
|
|
59
|
+
reactive<FilterState>({
|
|
60
|
+
manufacturer: new Set(),
|
|
61
|
+
properties: new Set(),
|
|
62
|
+
"min-price": undefined,
|
|
63
|
+
"max-price": undefined,
|
|
64
|
+
rating: undefined,
|
|
65
|
+
"shipping-free": undefined,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const showResetFiltersButton = computed<boolean>(() => {
|
|
69
|
+
if (
|
|
70
|
+
sidebarSelectedFilters.manufacturer.size !== 0 ||
|
|
71
|
+
sidebarSelectedFilters.properties.size !== 0 ||
|
|
72
|
+
sidebarSelectedFilters["max-price"] ||
|
|
73
|
+
sidebarSelectedFilters["min-price"] ||
|
|
74
|
+
sidebarSelectedFilters.rating ||
|
|
75
|
+
sidebarSelectedFilters["shipping-free"]
|
|
76
|
+
) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return false;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const searchCriteriaForRequest: ComputedRef<Schemas["ProductListingCriteria"]> =
|
|
84
|
+
computed(() => ({
|
|
85
|
+
manufacturer: [
|
|
86
|
+
...(sidebarSelectedFilters.manufacturer as Set<string>),
|
|
87
|
+
]?.join("|"),
|
|
88
|
+
properties: [...(sidebarSelectedFilters.properties as Set<string>)]?.join(
|
|
89
|
+
"|",
|
|
90
|
+
),
|
|
91
|
+
"min-price": sidebarSelectedFilters["min-price"] as number,
|
|
92
|
+
"max-price": sidebarSelectedFilters["max-price"] as number,
|
|
93
|
+
order: getCurrentSortingOrder.value as string,
|
|
94
|
+
"shipping-free": sidebarSelectedFilters["shipping-free"] as boolean,
|
|
95
|
+
rating: sidebarSelectedFilters.rating as number,
|
|
96
|
+
search: "",
|
|
97
|
+
limit: route.query.limit ? Number(route.query.limit) : 15,
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
for (const param in route.query) {
|
|
101
|
+
if (param in sidebarSelectedFilters) {
|
|
102
|
+
const queryValue = route.query[param];
|
|
103
|
+
|
|
104
|
+
// Skip arrays
|
|
105
|
+
if (Array.isArray(queryValue)) continue;
|
|
106
|
+
|
|
107
|
+
if (["manufacturer", "properties"].includes(param)) {
|
|
108
|
+
if (typeof queryValue === "string") {
|
|
109
|
+
const elements = queryValue.split("|");
|
|
110
|
+
const targetSet = sidebarSelectedFilters[
|
|
111
|
+
param as keyof FilterState
|
|
112
|
+
] as Set<string>;
|
|
113
|
+
for (const element of elements) {
|
|
114
|
+
targetSet.add(element);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} else if (queryValue && typeof queryValue === "string") {
|
|
118
|
+
// Fix: Use specific property assignments instead of generic keyof
|
|
119
|
+
if (param === "min-price") {
|
|
120
|
+
const numValue = Number(queryValue);
|
|
121
|
+
if (!Number.isNaN(numValue)) {
|
|
122
|
+
sidebarSelectedFilters["min-price"] = numValue;
|
|
123
|
+
}
|
|
124
|
+
} else if (param === "max-price") {
|
|
125
|
+
const numValue = Number(queryValue);
|
|
126
|
+
if (!Number.isNaN(numValue)) {
|
|
127
|
+
sidebarSelectedFilters["max-price"] = numValue;
|
|
128
|
+
}
|
|
129
|
+
} else if (param === "rating") {
|
|
130
|
+
const numValue = Number(queryValue);
|
|
131
|
+
if (!Number.isNaN(numValue)) {
|
|
132
|
+
sidebarSelectedFilters.rating = numValue;
|
|
133
|
+
}
|
|
134
|
+
} else if (param === "shipping-free") {
|
|
135
|
+
sidebarSelectedFilters["shipping-free"] = queryValue === "true";
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const handleFilterChange = async (event: {
|
|
142
|
+
code: string;
|
|
143
|
+
value: string | number | boolean;
|
|
144
|
+
}) => {
|
|
145
|
+
try {
|
|
146
|
+
const { code, value } = event;
|
|
147
|
+
|
|
148
|
+
if (code === "manufacturer" || code === "properties") {
|
|
149
|
+
const filterSet = sidebarSelectedFilters[code];
|
|
150
|
+
const stringValue = String(value);
|
|
151
|
+
|
|
152
|
+
if (filterSet.has(stringValue)) {
|
|
153
|
+
filterSet.delete(stringValue);
|
|
154
|
+
} else {
|
|
155
|
+
filterSet.add(stringValue);
|
|
156
|
+
}
|
|
157
|
+
} else if (code === "min-price" || code === "max-price") {
|
|
158
|
+
sidebarSelectedFilters[code] =
|
|
159
|
+
typeof value === "number" ? value : Number(value);
|
|
160
|
+
} else if (code === "rating") {
|
|
161
|
+
sidebarSelectedFilters.rating = Number(value);
|
|
162
|
+
} else if (code === "shipping-free") {
|
|
163
|
+
sidebarSelectedFilters["shipping-free"] = Boolean(value);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
await executeSearch();
|
|
167
|
+
} catch (error) {
|
|
168
|
+
console.error("Filter update failed:", error);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const executeSearch = async () => {
|
|
173
|
+
try {
|
|
174
|
+
await search(searchCriteriaForRequest.value);
|
|
175
|
+
|
|
176
|
+
// Build query directly from searchCriteriaForRequest which already has pipe-separated strings
|
|
177
|
+
const criteria = searchCriteriaForRequest.value;
|
|
178
|
+
const query: Record<string, unknown> = {};
|
|
179
|
+
|
|
180
|
+
if (criteria.manufacturer) query.manufacturer = criteria.manufacturer;
|
|
181
|
+
if (criteria.properties) query.properties = criteria.properties;
|
|
182
|
+
if (criteria["min-price"]) query["min-price"] = criteria["min-price"];
|
|
183
|
+
if (criteria["max-price"]) query["max-price"] = criteria["max-price"];
|
|
184
|
+
if (criteria.rating) query.rating = criteria.rating;
|
|
185
|
+
if (criteria["shipping-free"])
|
|
186
|
+
query["shipping-free"] = criteria["shipping-free"];
|
|
187
|
+
if (criteria.order) query.order = criteria.order;
|
|
188
|
+
|
|
189
|
+
await router.push({
|
|
190
|
+
query: query as LocationQueryRaw,
|
|
191
|
+
});
|
|
192
|
+
} catch (error) {
|
|
193
|
+
console.error("Search execution failed:", error);
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const clearFilters = () => {
|
|
198
|
+
(sidebarSelectedFilters.manufacturer as Set<string>).clear();
|
|
199
|
+
(sidebarSelectedFilters.properties as Set<string>).clear();
|
|
200
|
+
sidebarSelectedFilters["min-price"] = undefined;
|
|
201
|
+
sidebarSelectedFilters["max-price"] = undefined;
|
|
202
|
+
sidebarSelectedFilters.rating = undefined;
|
|
203
|
+
sidebarSelectedFilters["shipping-free"] = undefined;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const currentSortingOrder = computed({
|
|
207
|
+
get: (): string => getCurrentSortingOrder.value || "",
|
|
208
|
+
set: async (order: string): Promise<void> => {
|
|
209
|
+
try {
|
|
210
|
+
await router.push({
|
|
211
|
+
query: {
|
|
212
|
+
...route.query,
|
|
213
|
+
order,
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
await changeCurrentSortingOrder(order, {
|
|
218
|
+
...(route.query as unknown as operations["searchPage post /search"]["body"]),
|
|
219
|
+
limit: route.query.limit ? Number(route.query.limit) : 15,
|
|
220
|
+
});
|
|
221
|
+
} catch (error) {
|
|
222
|
+
console.error("Sorting order change failed:", error);
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
async function invokeCleanFilters() {
|
|
228
|
+
try {
|
|
229
|
+
clearFilters();
|
|
230
|
+
await executeSearch();
|
|
231
|
+
} catch (error) {
|
|
232
|
+
console.error("Clear filters failed:", error);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const isDefaultSidebarFilter =
|
|
237
|
+
props.content.type === "sidebar-filter" &&
|
|
238
|
+
props.content.config?.boxLayout?.value === "standard";
|
|
239
|
+
|
|
240
|
+
const handleSortChange = (sortKey: string) => {
|
|
241
|
+
currentSortingOrder.value = sortKey;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const handleRemoveFilterChip = async (chip: {
|
|
245
|
+
code: string;
|
|
246
|
+
value: string | number;
|
|
247
|
+
}) => {
|
|
248
|
+
if (chip.code === "properties" || chip.code === "manufacturer") {
|
|
249
|
+
const filterSet = sidebarSelectedFilters[chip.code] as Set<string>;
|
|
250
|
+
filterSet.delete(String(chip.value));
|
|
251
|
+
} else if (chip.code === "price") {
|
|
252
|
+
sidebarSelectedFilters["min-price"] = undefined;
|
|
253
|
+
sidebarSelectedFilters["max-price"] = undefined;
|
|
254
|
+
} else if (chip.code === "rating") {
|
|
255
|
+
sidebarSelectedFilters.rating = undefined;
|
|
256
|
+
} else if (chip.code === "shipping-free") {
|
|
257
|
+
sidebarSelectedFilters["shipping-free"] = undefined;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
await executeSearch();
|
|
261
|
+
};
|
|
262
|
+
</script>
|
|
263
|
+
<template>
|
|
264
|
+
<div>
|
|
265
|
+
<!-- Active Filter Chips -->
|
|
266
|
+
<SwFilterChips
|
|
267
|
+
:filters="sidebarSelectedFilters"
|
|
268
|
+
:available-filters="getInitialFilters"
|
|
269
|
+
@remove="handleRemoveFilterChip"
|
|
270
|
+
/>
|
|
271
|
+
|
|
272
|
+
<!-- Filters Header -->
|
|
273
|
+
<div class="self-stretch flex flex-col justify-start items-start gap-4">
|
|
274
|
+
<div
|
|
275
|
+
class="flex flex-row items-center justify-between w-full mb-4 py-3 border-b border-outline-outline-variant">
|
|
276
|
+
<div class="flex-1 text-surface-on-surface text-base font-bold leading-normal">
|
|
277
|
+
{{ translations.listing.filters }}
|
|
278
|
+
</div>
|
|
279
|
+
<SwSortDropdown
|
|
280
|
+
:sort-options="getSortingOrders ?? []"
|
|
281
|
+
:current-sort="getCurrentSortingOrder ?? ''"
|
|
282
|
+
:label="translations.listing.sort"
|
|
283
|
+
@sort-change="handleSortChange"
|
|
284
|
+
/>
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
|
|
288
|
+
<!-- Filters List -->
|
|
289
|
+
<div class="self-stretch flex flex-col justify-start items-start gap-4">
|
|
290
|
+
<SwProductListingFilter v-for="filter in getInitialFilters" :key="filter.id"
|
|
291
|
+
:filter="filter"
|
|
292
|
+
:selected-manufacturer="sidebarSelectedFilters.manufacturer"
|
|
293
|
+
:selected-properties="sidebarSelectedFilters.properties"
|
|
294
|
+
:selected-min-price="sidebarSelectedFilters['min-price']"
|
|
295
|
+
:selected-max-price="sidebarSelectedFilters['max-price']"
|
|
296
|
+
:selected-rating="sidebarSelectedFilters.rating"
|
|
297
|
+
:selected-shipping-free="sidebarSelectedFilters['shipping-free']"
|
|
298
|
+
@filter-change="handleFilterChange"
|
|
299
|
+
class="w-full" />
|
|
300
|
+
<div v-if="showResetFiltersButton" class="w-full">
|
|
301
|
+
<SwBaseButton variant="primary" size="medium" block @click="invokeCleanFilters" type="button">
|
|
302
|
+
{{ translations.listing.resetFilters }}
|
|
303
|
+
<span class="w-6 h-6 i-carbon-close-filled inline-block align-middle ml-2"></span>
|
|
304
|
+
</SwBaseButton>
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
</template>
|
|
@@ -17,6 +17,7 @@ type Translations = {
|
|
|
17
17
|
product: {
|
|
18
18
|
noReviews: string;
|
|
19
19
|
reviewNotAccepted: string;
|
|
20
|
+
reviewFeedback: string;
|
|
20
21
|
};
|
|
21
22
|
};
|
|
22
23
|
|
|
@@ -24,6 +25,7 @@ let translations: Translations = {
|
|
|
24
25
|
product: {
|
|
25
26
|
noReviews: "No reviews yet.",
|
|
26
27
|
reviewNotAccepted: "Your review has not been approved yet",
|
|
28
|
+
reviewFeedback: "Shop feedback",
|
|
27
29
|
},
|
|
28
30
|
};
|
|
29
31
|
|
|
@@ -64,40 +66,50 @@ const formatDate = (date: string) => {
|
|
|
64
66
|
<template>
|
|
65
67
|
<div
|
|
66
68
|
v-if="loadingReviews"
|
|
67
|
-
class="absolute inset-0 flex items-center justify-center z-10 bg-
|
|
69
|
+
class="absolute inset-0 flex items-center justify-center z-10 bg-surface-surface/75"
|
|
68
70
|
>
|
|
69
71
|
<div
|
|
70
|
-
class="h-15 w-15 i-carbon-progress-bar-round animate-spin
|
|
72
|
+
class="h-15 w-15 i-carbon-progress-bar-round animate-spin text-outline-outline"
|
|
71
73
|
/>
|
|
72
74
|
</div>
|
|
73
|
-
<div v-else-if="reviewsList.length">
|
|
74
|
-
<div
|
|
75
|
+
<div v-else-if="reviewsList.length" class="flex flex-col gap-6">
|
|
76
|
+
<div
|
|
77
|
+
v-for="(review, index) in reviewsList"
|
|
78
|
+
:key="review.id"
|
|
79
|
+
class="pb-6"
|
|
80
|
+
:class="{ 'border-b border-surface-surface-container-highest': index < reviewsList.length - 1 }"
|
|
81
|
+
>
|
|
75
82
|
<div
|
|
76
83
|
v-if="review.createdAt"
|
|
77
|
-
class="cms-block-product-description-reviews__reviews-time
|
|
84
|
+
class="cms-block-product-description-reviews__reviews-time text-surface-on-surface-variant text-sm"
|
|
78
85
|
>
|
|
86
|
+
<span v-if="review.externalUser">{{ review.externalUser }} - </span>
|
|
79
87
|
<span>{{ formatDate(review.createdAt) }}</span>
|
|
80
88
|
</div>
|
|
81
89
|
<div
|
|
82
90
|
v-if="!review.status"
|
|
83
|
-
class="mt-2 text-3 p-2 bg-
|
|
91
|
+
class="mt-2 text-3 p-2 bg-states-info-container text-states-on-info-container flex gap-2 items-center"
|
|
84
92
|
>
|
|
85
93
|
<div class="w-6 h-6 i-carbon-warning" />
|
|
86
94
|
{{ translations.product.reviewNotAccepted }}
|
|
87
95
|
</div>
|
|
88
96
|
<div
|
|
89
97
|
class="cms-block-product-description-reviews__reviews-rating inline-flex items-center mt-2"
|
|
98
|
+
role="img"
|
|
99
|
+
:aria-label="`${review.points} out of 5 stars`"
|
|
90
100
|
>
|
|
91
|
-
<
|
|
101
|
+
<SwStarIcon
|
|
92
102
|
v-for="_ in review.points"
|
|
93
103
|
:key="`filled-star-${_}`"
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
104
|
+
:filled="true"
|
|
105
|
+
:size="20"
|
|
106
|
+
/>
|
|
107
|
+
<SwStarIcon
|
|
97
108
|
v-for="_ in 5 - (review.points || 0)"
|
|
98
109
|
:key="`empty-star-${_}`"
|
|
99
|
-
|
|
100
|
-
|
|
110
|
+
:filled="false"
|
|
111
|
+
:size="20"
|
|
112
|
+
/>
|
|
101
113
|
<div
|
|
102
114
|
class="cms-block-product-description-reviews__reviews-title font-semibold ml-2"
|
|
103
115
|
>
|
|
@@ -105,7 +117,10 @@ const formatDate = (date: string) => {
|
|
|
105
117
|
</div>
|
|
106
118
|
</div>
|
|
107
119
|
<div class="cms-block-product-description-reviews__reviews-content mt-2">
|
|
108
|
-
<
|
|
120
|
+
<p class="break-words">{{ review.content }}</p>
|
|
121
|
+
<p v-if="review.comment" class="text-surface-on-surface-variant mt-2">
|
|
122
|
+
- {{ translations.product.reviewFeedback }}: {{ review.comment }}
|
|
123
|
+
</p>
|
|
109
124
|
</div>
|
|
110
125
|
</div>
|
|
111
126
|
</div>
|
|
@@ -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>
|