@shopware/cms-base-layer 2.0.0 → 3.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 +167 -125
- package/app/app.config.ts +12 -0
- package/app/components/SwCategoryNavigation.vue +25 -18
- package/app/components/SwFilterDropdown.vue +54 -0
- package/app/components/SwListingProductPrice.vue +2 -2
- package/app/components/SwMedia3D.vue +14 -5
- package/app/components/SwProductCard.vue +24 -21
- package/app/components/SwProductCardDetails.vue +29 -12
- package/app/components/SwProductCardImage.vue +30 -29
- package/app/components/SwProductGallery.vue +18 -14
- package/app/components/SwProductListingFilter.vue +20 -9
- package/app/components/SwProductListingFilters.vue +3 -7
- package/app/components/SwProductListingFiltersHorizontal.vue +306 -0
- package/app/components/SwProductPrice.vue +3 -3
- package/app/components/SwProductRating.vue +40 -0
- package/app/components/SwProductReviews.vue +6 -19
- package/app/components/SwProductUnits.vue +10 -15
- package/app/components/SwQuantitySelect.vue +4 -7
- package/app/components/SwSlider.vue +150 -51
- package/app/components/SwSortDropdown.vue +10 -6
- package/app/components/SwVariantConfigurator.vue +13 -13
- package/app/components/listing-filters/SwFilterPrice.vue +45 -40
- package/app/components/listing-filters/SwFilterProperties.vue +40 -33
- package/app/components/listing-filters/SwFilterRating.vue +36 -27
- package/app/components/listing-filters/SwFilterShippingFree.vue +39 -32
- package/app/components/public/cms/CmsBlockSpatialViewer.vue +94 -0
- package/app/components/public/cms/CmsGenericBlock.md +17 -2
- package/app/components/public/cms/CmsGenericBlock.vue +21 -2
- package/app/components/public/cms/CmsGenericElement.vue +7 -2
- package/app/components/public/cms/CmsNoComponent.vue +87 -8
- package/app/components/public/cms/CmsPage.md +19 -2
- package/app/components/public/cms/CmsPage.vue +7 -0
- package/app/components/public/cms/FrontendAccountCustomerGroupRegistrationPage.vue +52 -0
- package/app/components/public/cms/block/CmsBlockCenterText.vue +1 -1
- package/app/components/public/cms/block/CmsBlockImageText.vue +5 -5
- package/app/components/public/cms/block/CmsBlockTextOnImage.vue +5 -12
- package/app/components/public/cms/element/CmsElementBuyBox.vue +3 -3
- package/app/components/public/cms/element/CmsElementCrossSelling.vue +19 -3
- package/app/components/public/cms/element/CmsElementImage.vue +12 -35
- package/app/components/public/cms/element/CmsElementImageGallery.vue +117 -50
- package/app/components/public/cms/element/CmsElementProductBox.vue +7 -1
- package/app/components/public/cms/element/CmsElementProductListing.vue +15 -4
- package/app/components/public/cms/element/CmsElementProductName.vue +6 -1
- package/app/components/public/cms/element/CmsElementProductSlider.vue +56 -35
- package/app/components/public/cms/element/CmsElementSidebarFilter.vue +10 -2
- package/app/components/public/cms/element/CmsElementText.vue +10 -11
- package/app/components/public/cms/element/SwProductListingPagination.vue +2 -2
- package/app/components/public/cms/section/CmsSectionDefault.vue +2 -2
- package/app/components/public/cms/section/CmsSectionSidebar.vue +6 -3
- package/app/components/ui/BaseButton.vue +18 -15
- package/app/components/ui/ChevronIcon.vue +10 -13
- package/app/components/ui/WishlistIcon.vue +3 -8
- package/app/composables/useImagePlaceholder.ts +3 -3
- package/app/composables/useLcpImagePreload.test.ts +229 -0
- package/app/composables/useLcpImagePreload.ts +43 -0
- package/app/composables/useTypedAppConfig.ts +15 -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 +53 -19
- package/app/helpers/html-to-vue/getOptionsFromNode.ts +1 -1
- package/app/helpers/html-to-vue/renderToHtml.ts +7 -11
- package/app/helpers/html-to-vue/renderer.ts +86 -26
- package/index.d.ts +37 -5
- package/nuxt.config.ts +25 -0
- package/package.json +21 -21
- package/uno.config.ts +0 -83
|
@@ -0,0 +1,306 @@
|
|
|
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 } from "vue-router";
|
|
11
|
+
import { useCategoryListing, useRoute, useRouter } 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
|
+
getCurrentSortingOrder,
|
|
52
|
+
getInitialFilters,
|
|
53
|
+
getSortingOrders,
|
|
54
|
+
search,
|
|
55
|
+
} = useCategoryListing();
|
|
56
|
+
|
|
57
|
+
const sidebarSelectedFilters: UnwrapNestedRefs<FilterState> =
|
|
58
|
+
reactive<FilterState>({
|
|
59
|
+
manufacturer: new Set(),
|
|
60
|
+
properties: new Set(),
|
|
61
|
+
"min-price": undefined,
|
|
62
|
+
"max-price": undefined,
|
|
63
|
+
rating: undefined,
|
|
64
|
+
"shipping-free": undefined,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const showResetFiltersButton = computed<boolean>(() => {
|
|
68
|
+
if (
|
|
69
|
+
sidebarSelectedFilters.manufacturer.size !== 0 ||
|
|
70
|
+
sidebarSelectedFilters.properties.size !== 0 ||
|
|
71
|
+
sidebarSelectedFilters["max-price"] ||
|
|
72
|
+
sidebarSelectedFilters["min-price"] ||
|
|
73
|
+
sidebarSelectedFilters.rating ||
|
|
74
|
+
sidebarSelectedFilters["shipping-free"]
|
|
75
|
+
) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return false;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const searchCriteriaForRequest: ComputedRef<Schemas["ProductListingCriteria"]> =
|
|
83
|
+
computed(() => ({
|
|
84
|
+
manufacturer: [
|
|
85
|
+
...(sidebarSelectedFilters.manufacturer as Set<string>),
|
|
86
|
+
]?.join("|"),
|
|
87
|
+
properties: [...(sidebarSelectedFilters.properties as Set<string>)]?.join(
|
|
88
|
+
"|",
|
|
89
|
+
),
|
|
90
|
+
"min-price": sidebarSelectedFilters["min-price"] as number,
|
|
91
|
+
"max-price": sidebarSelectedFilters["max-price"] as number,
|
|
92
|
+
order: getCurrentSortingOrder.value as string,
|
|
93
|
+
"shipping-free": sidebarSelectedFilters["shipping-free"] as boolean,
|
|
94
|
+
rating: sidebarSelectedFilters.rating as number,
|
|
95
|
+
search: "",
|
|
96
|
+
limit: route.query.limit ? Number(route.query.limit) : 15,
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
for (const param in route.query) {
|
|
100
|
+
if (param in sidebarSelectedFilters) {
|
|
101
|
+
const queryValue = route.query[param];
|
|
102
|
+
|
|
103
|
+
// Skip arrays
|
|
104
|
+
if (Array.isArray(queryValue)) continue;
|
|
105
|
+
|
|
106
|
+
if (["manufacturer", "properties"].includes(param)) {
|
|
107
|
+
if (typeof queryValue === "string") {
|
|
108
|
+
const elements = queryValue.split("|");
|
|
109
|
+
const targetSet = sidebarSelectedFilters[
|
|
110
|
+
param as keyof FilterState
|
|
111
|
+
] as Set<string>;
|
|
112
|
+
for (const element of elements) {
|
|
113
|
+
targetSet.add(element);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} else if (queryValue && typeof queryValue === "string") {
|
|
117
|
+
// Fix: Use specific property assignments instead of generic keyof
|
|
118
|
+
if (param === "min-price") {
|
|
119
|
+
const numValue = Number(queryValue);
|
|
120
|
+
if (!Number.isNaN(numValue)) {
|
|
121
|
+
sidebarSelectedFilters["min-price"] = numValue;
|
|
122
|
+
}
|
|
123
|
+
} else if (param === "max-price") {
|
|
124
|
+
const numValue = Number(queryValue);
|
|
125
|
+
if (!Number.isNaN(numValue)) {
|
|
126
|
+
sidebarSelectedFilters["max-price"] = numValue;
|
|
127
|
+
}
|
|
128
|
+
} else if (param === "rating") {
|
|
129
|
+
const numValue = Number(queryValue);
|
|
130
|
+
if (!Number.isNaN(numValue)) {
|
|
131
|
+
sidebarSelectedFilters.rating = numValue;
|
|
132
|
+
}
|
|
133
|
+
} else if (param === "shipping-free") {
|
|
134
|
+
sidebarSelectedFilters["shipping-free"] = queryValue === "true";
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const handleFilterChange = async (event: {
|
|
141
|
+
code: string;
|
|
142
|
+
value: string | number | boolean;
|
|
143
|
+
}) => {
|
|
144
|
+
try {
|
|
145
|
+
const { code, value } = event;
|
|
146
|
+
|
|
147
|
+
if (code === "manufacturer" || code === "properties") {
|
|
148
|
+
const filterSet = sidebarSelectedFilters[code];
|
|
149
|
+
const stringValue = String(value);
|
|
150
|
+
|
|
151
|
+
if (filterSet.has(stringValue)) {
|
|
152
|
+
filterSet.delete(stringValue);
|
|
153
|
+
} else {
|
|
154
|
+
filterSet.add(stringValue);
|
|
155
|
+
}
|
|
156
|
+
} else if (code === "min-price" || code === "max-price") {
|
|
157
|
+
sidebarSelectedFilters[code] =
|
|
158
|
+
typeof value === "number" ? value : Number(value);
|
|
159
|
+
} else if (code === "rating") {
|
|
160
|
+
sidebarSelectedFilters.rating = Number(value);
|
|
161
|
+
} else if (code === "shipping-free") {
|
|
162
|
+
sidebarSelectedFilters["shipping-free"] = Boolean(value);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await executeSearch();
|
|
166
|
+
} catch (error) {
|
|
167
|
+
console.error("Filter update failed:", error);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const executeSearch = async () => {
|
|
172
|
+
try {
|
|
173
|
+
await search(searchCriteriaForRequest.value);
|
|
174
|
+
|
|
175
|
+
// Build query directly from searchCriteriaForRequest which already has pipe-separated strings
|
|
176
|
+
const criteria = searchCriteriaForRequest.value;
|
|
177
|
+
const query: Record<string, unknown> = {};
|
|
178
|
+
|
|
179
|
+
if (criteria.manufacturer) query.manufacturer = criteria.manufacturer;
|
|
180
|
+
if (criteria.properties) query.properties = criteria.properties;
|
|
181
|
+
if (criteria["min-price"]) query["min-price"] = criteria["min-price"];
|
|
182
|
+
if (criteria["max-price"]) query["max-price"] = criteria["max-price"];
|
|
183
|
+
if (criteria.rating) query.rating = criteria.rating;
|
|
184
|
+
if (criteria["shipping-free"])
|
|
185
|
+
query["shipping-free"] = criteria["shipping-free"];
|
|
186
|
+
if (criteria.order) query.order = criteria.order;
|
|
187
|
+
|
|
188
|
+
await router.push({
|
|
189
|
+
query: query as LocationQueryRaw,
|
|
190
|
+
});
|
|
191
|
+
} catch (error) {
|
|
192
|
+
console.error("Search execution failed:", error);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const clearFilters = () => {
|
|
197
|
+
(sidebarSelectedFilters.manufacturer as Set<string>).clear();
|
|
198
|
+
(sidebarSelectedFilters.properties as Set<string>).clear();
|
|
199
|
+
sidebarSelectedFilters["min-price"] = undefined;
|
|
200
|
+
sidebarSelectedFilters["max-price"] = undefined;
|
|
201
|
+
sidebarSelectedFilters.rating = undefined;
|
|
202
|
+
sidebarSelectedFilters["shipping-free"] = undefined;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const currentSortingOrder = computed({
|
|
206
|
+
get: (): string => getCurrentSortingOrder.value || "",
|
|
207
|
+
set: async (order: string): Promise<void> => {
|
|
208
|
+
try {
|
|
209
|
+
await router.push({
|
|
210
|
+
query: {
|
|
211
|
+
...route.query,
|
|
212
|
+
order,
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
await changeCurrentSortingOrder(order, {
|
|
217
|
+
...(route.query as unknown as operations["searchPage post /search"]["body"]),
|
|
218
|
+
limit: route.query.limit ? Number(route.query.limit) : 15,
|
|
219
|
+
});
|
|
220
|
+
} catch (error) {
|
|
221
|
+
console.error("Sorting order change failed:", error);
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
async function invokeCleanFilters() {
|
|
227
|
+
try {
|
|
228
|
+
clearFilters();
|
|
229
|
+
await executeSearch();
|
|
230
|
+
} catch (error) {
|
|
231
|
+
console.error("Clear filters failed:", error);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const handleSortChange = (sortKey: string) => {
|
|
236
|
+
currentSortingOrder.value = sortKey;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// Helper to check if a filter has active selections
|
|
240
|
+
const hasActiveFilter = (filter: { code: string }) => {
|
|
241
|
+
if (filter.code === "manufacturer") {
|
|
242
|
+
return sidebarSelectedFilters.manufacturer.size > 0;
|
|
243
|
+
}
|
|
244
|
+
if (filter.code === "price") {
|
|
245
|
+
return (
|
|
246
|
+
sidebarSelectedFilters["min-price"] !== undefined ||
|
|
247
|
+
sidebarSelectedFilters["max-price"] !== undefined
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
if (filter.code === "rating") {
|
|
251
|
+
return sidebarSelectedFilters.rating !== undefined;
|
|
252
|
+
}
|
|
253
|
+
if (filter.code === "shipping-free") {
|
|
254
|
+
return sidebarSelectedFilters["shipping-free"] === true;
|
|
255
|
+
}
|
|
256
|
+
// Properties filter - check if any property from this filter group is selected
|
|
257
|
+
return sidebarSelectedFilters.properties.size > 0;
|
|
258
|
+
};
|
|
259
|
+
</script>
|
|
260
|
+
|
|
261
|
+
<template>
|
|
262
|
+
<div>
|
|
263
|
+
<!-- Horizontal Filters Row -->
|
|
264
|
+
<div class="flex flex-wrap items-center justify-start gap-4 z-10">
|
|
265
|
+
<!-- Filter dropdowns -->
|
|
266
|
+
<SwFilterDropdown
|
|
267
|
+
v-for="filter in getInitialFilters"
|
|
268
|
+
:key="filter.id"
|
|
269
|
+
:label="filter.label"
|
|
270
|
+
:is-active="hasActiveFilter(filter)"
|
|
271
|
+
>
|
|
272
|
+
<SwProductListingFilter
|
|
273
|
+
:filter="filter"
|
|
274
|
+
display-mode="dropdown"
|
|
275
|
+
:selected-manufacturer="sidebarSelectedFilters.manufacturer"
|
|
276
|
+
:selected-properties="sidebarSelectedFilters.properties"
|
|
277
|
+
:selected-min-price="sidebarSelectedFilters['min-price']"
|
|
278
|
+
:selected-max-price="sidebarSelectedFilters['max-price']"
|
|
279
|
+
:selected-rating="sidebarSelectedFilters.rating"
|
|
280
|
+
:selected-shipping-free="sidebarSelectedFilters['shipping-free']"
|
|
281
|
+
@filter-change="handleFilterChange"
|
|
282
|
+
/>
|
|
283
|
+
</SwFilterDropdown>
|
|
284
|
+
|
|
285
|
+
<!-- Sort dropdown -->
|
|
286
|
+
<SwSortDropdown
|
|
287
|
+
:sort-options="getSortingOrders ?? []"
|
|
288
|
+
:current-sort="getCurrentSortingOrder ?? ''"
|
|
289
|
+
:label="translations.listing.sort"
|
|
290
|
+
@sort-change="handleSortChange"
|
|
291
|
+
/>
|
|
292
|
+
|
|
293
|
+
<!-- Reset filters button -->
|
|
294
|
+
<SwBaseButton
|
|
295
|
+
v-if="showResetFiltersButton"
|
|
296
|
+
variant="ghost"
|
|
297
|
+
size="medium"
|
|
298
|
+
@click="invokeCleanFilters"
|
|
299
|
+
type="button"
|
|
300
|
+
>
|
|
301
|
+
{{ translations.listing.resetFilters }}
|
|
302
|
+
<span class="w-5 h-5 i-carbon-close inline-block align-middle ml-1"></span>
|
|
303
|
+
</SwBaseButton>
|
|
304
|
+
</div>
|
|
305
|
+
</div>
|
|
306
|
+
</template>
|
|
@@ -37,7 +37,7 @@ translations = defu(useCmsTranslations(), translations) as Translations;
|
|
|
37
37
|
|
|
38
38
|
const { product } = toRefs(props);
|
|
39
39
|
|
|
40
|
-
const { unitPrice, price, tierPrices,
|
|
40
|
+
const { unitPrice, price, tierPrices, hasListPrice } = useProductPrice(product);
|
|
41
41
|
const { getFormattedPrice } = usePrice();
|
|
42
42
|
</script>
|
|
43
43
|
|
|
@@ -45,7 +45,7 @@ const { getFormattedPrice } = usePrice();
|
|
|
45
45
|
<div>
|
|
46
46
|
<div v-if="!tierPrices.length">
|
|
47
47
|
<SwSharedPrice
|
|
48
|
-
v-if="
|
|
48
|
+
v-if="hasListPrice"
|
|
49
49
|
class="text-1xl text-gray-900 basis-2/6 justify-end line-through"
|
|
50
50
|
:value="price?.listPrice?.price"
|
|
51
51
|
/>
|
|
@@ -53,7 +53,7 @@ const { getFormattedPrice } = usePrice();
|
|
|
53
53
|
v-if="unitPrice"
|
|
54
54
|
class="text-3xl text-gray-900 basis-2/6 justify-end"
|
|
55
55
|
:class="{
|
|
56
|
-
'text-red':
|
|
56
|
+
'text-red': hasListPrice,
|
|
57
57
|
}"
|
|
58
58
|
:value="unitPrice"
|
|
59
59
|
/>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from "vue";
|
|
3
|
+
|
|
4
|
+
const {
|
|
5
|
+
rating = 0,
|
|
6
|
+
reviewCount = 0,
|
|
7
|
+
starSize = 16,
|
|
8
|
+
showCount = true,
|
|
9
|
+
} = defineProps<{
|
|
10
|
+
rating: number;
|
|
11
|
+
reviewCount?: number;
|
|
12
|
+
starSize?: number;
|
|
13
|
+
showCount?: boolean;
|
|
14
|
+
}>();
|
|
15
|
+
|
|
16
|
+
const filledStars = computed(() => Math.round(rating));
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<div class="flex items-center">
|
|
21
|
+
<div
|
|
22
|
+
class="flex items-center gap-1.5"
|
|
23
|
+
role="img"
|
|
24
|
+
:aria-label="`${rating} out of 5 stars`"
|
|
25
|
+
>
|
|
26
|
+
<SwStarIcon
|
|
27
|
+
v-for="i in 5"
|
|
28
|
+
:key="`star-${i}`"
|
|
29
|
+
:filled="i <= filledStars"
|
|
30
|
+
:size="starSize"
|
|
31
|
+
/>
|
|
32
|
+
</div>
|
|
33
|
+
<span
|
|
34
|
+
v-if="showCount && reviewCount > 0"
|
|
35
|
+
class="ml-1 text-surface-on-surface-variant text-base leading-normal"
|
|
36
|
+
>
|
|
37
|
+
({{ reviewCount }})
|
|
38
|
+
</span>
|
|
39
|
+
</div>
|
|
40
|
+
</template>
|
|
@@ -93,26 +93,13 @@ const formatDate = (date: string) => {
|
|
|
93
93
|
<div class="w-6 h-6 i-carbon-warning" />
|
|
94
94
|
{{ translations.product.reviewNotAccepted }}
|
|
95
95
|
</div>
|
|
96
|
-
<div
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
<SwStarIcon
|
|
102
|
-
v-for="_ in review.points"
|
|
103
|
-
:key="`filled-star-${_}`"
|
|
104
|
-
:filled="true"
|
|
105
|
-
:size="20"
|
|
96
|
+
<div class="cms-block-product-description-reviews__reviews-rating inline-flex items-center mt-2">
|
|
97
|
+
<SwProductRating
|
|
98
|
+
:rating="review.points ?? 0"
|
|
99
|
+
:star-size="20"
|
|
100
|
+
:show-count="false"
|
|
106
101
|
/>
|
|
107
|
-
<
|
|
108
|
-
v-for="_ in 5 - (review.points || 0)"
|
|
109
|
-
:key="`empty-star-${_}`"
|
|
110
|
-
:filled="false"
|
|
111
|
-
:size="20"
|
|
112
|
-
/>
|
|
113
|
-
<div
|
|
114
|
-
class="cms-block-product-description-reviews__reviews-title font-semibold ml-2"
|
|
115
|
-
>
|
|
102
|
+
<div class="cms-block-product-description-reviews__reviews-title font-semibold ml-2">
|
|
116
103
|
<p>{{ review.title }}</p>
|
|
117
104
|
</div>
|
|
118
105
|
</div>
|
|
@@ -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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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(() =>
|
|
32
|
-
const unitName = computed(() =>
|
|
26
|
+
const purchaseUnit = computed(() => product?.purchaseUnit);
|
|
27
|
+
const unitName = computed(() => product?.unit?.translated.name);
|
|
33
28
|
const referencePrice = computed(
|
|
34
|
-
() =>
|
|
29
|
+
() => product?.calculatedPrice?.referencePrice?.price,
|
|
35
30
|
);
|
|
36
31
|
const referenceUnit = computed(
|
|
37
|
-
() =>
|
|
32
|
+
() => product?.calculatedPrice?.referencePrice?.referenceUnit,
|
|
38
33
|
);
|
|
39
34
|
const referenceUnitName = computed(
|
|
40
|
-
() =>
|
|
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="
|
|
41
|
+
<template v-if="showContent">
|
|
47
42
|
{{ translations.product.content }}: {{ purchaseUnit }} {{ unitName }}
|
|
48
43
|
</template>
|
|
49
44
|
<template v-if="referencePrice">
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { defu } from "defu";
|
|
3
|
-
import { computed,
|
|
3
|
+
import { computed, useId } from "vue";
|
|
4
4
|
import { useCmsTranslations } from "#imports";
|
|
5
5
|
|
|
6
6
|
type Translations = {
|
|
@@ -43,12 +43,9 @@ const {
|
|
|
43
43
|
id?: string;
|
|
44
44
|
}>();
|
|
45
45
|
|
|
46
|
-
// generate an id that prefers a provided prop and otherwise uses
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
const uid = Math.random().toString(36).substr(2, 9);
|
|
50
|
-
return `sw-quantity-${uid}`;
|
|
51
|
-
});
|
|
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);
|
|
52
49
|
|
|
53
50
|
function increaseQty() {
|
|
54
51
|
quantity.value++;
|