@shopware/cms-base-layer 3.0.1 → 4.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.
Files changed (52) hide show
  1. package/README.md +78 -15
  2. package/app/app.config.ts +3 -3
  3. package/app/assets/css/rich-text.css +80 -0
  4. package/app/components/SwContactForm.vue +2 -1
  5. package/app/components/SwFilterChips.vue +29 -7
  6. package/app/components/SwNewsletterForm.vue +2 -1
  7. package/app/components/SwProductCard.vue +19 -3
  8. package/app/components/SwProductCardImage.vue +5 -1
  9. package/app/components/SwProductListingFilter.vue +5 -0
  10. package/app/components/SwProductListingFilters.vue +76 -74
  11. package/app/components/SwProductListingFiltersHorizontal.vue +73 -72
  12. package/app/components/SwStockInfo.vue +5 -2
  13. package/app/components/SwVariantConfigurator.vue +8 -3
  14. package/app/components/listing-filters/SwFilterCategories.vue +158 -0
  15. package/app/components/public/cms/CmsGenericBlock.vue +1 -1
  16. package/app/components/public/cms/CmsGenericElement.vue +1 -1
  17. package/app/components/public/cms/CmsNoComponent.vue +1 -1
  18. package/app/components/public/cms/FrontendAccountCustomerGroupRegistrationPage.vue +1 -1
  19. package/app/components/public/cms/block/CmsBlockCenterText.vue +2 -1
  20. package/app/components/public/cms/block/CmsBlockImageBubbleRow.vue +5 -1
  21. package/app/components/public/cms/block/CmsBlockImageFourColumn.vue +6 -2
  22. package/app/components/public/cms/block/CmsBlockImageGalleryBig.vue +9 -5
  23. package/app/components/public/cms/block/CmsBlockImageHighlightRow.vue +7 -2
  24. package/app/components/public/cms/block/CmsBlockImageTextRow.vue +9 -3
  25. package/app/components/public/cms/block/CmsBlockImageThreeCover.vue +7 -2
  26. package/app/components/public/cms/block/CmsBlockImageTwoColumn.vue +7 -3
  27. package/app/components/public/cms/block/CmsBlockProductHeading.vue +2 -1
  28. package/app/components/public/cms/element/CmsElementBuyBox.vue +4 -1
  29. package/app/components/public/cms/element/CmsElementCrossSelling.vue +2 -1
  30. package/app/components/public/cms/element/CmsElementHtml.vue +1 -1
  31. package/app/components/public/cms/element/CmsElementImage.vue +41 -2
  32. package/app/components/public/cms/element/CmsElementImageGallery.vue +2 -1
  33. package/app/components/public/cms/element/CmsElementProductDescriptionReviews.vue +18 -9
  34. package/app/components/public/cms/element/CmsElementText.md +77 -0
  35. package/app/components/public/cms/element/CmsElementText.vue +5 -32
  36. package/app/helpers/html-to-vue/renderToHtml.test.ts +55 -0
  37. package/app/helpers/html-to-vue/renderToHtml.ts +5 -1
  38. package/app/helpers/html-to-vue/renderer.ts +10 -1
  39. package/app/utils/listingFilters.test.ts +39 -0
  40. package/app/utils/listingFilters.ts +19 -0
  41. package/app/utils/routeQuery.test.ts +65 -0
  42. package/app/utils/routeQuery.ts +26 -0
  43. package/app/utils/useSelectedListingFilters.test.ts +92 -0
  44. package/app/utils/useSelectedListingFilters.ts +111 -0
  45. package/component-dirs.json +20 -0
  46. package/index.d.ts +2 -2
  47. package/nuxt.config.ts +35 -29
  48. package/package.json +27 -31
  49. package/dist/index.d.mts +0 -5
  50. package/dist/index.d.ts +0 -5
  51. package/dist/index.mjs +0 -31
  52. package/index.cjs +0 -7
@@ -0,0 +1,158 @@
1
+ <script
2
+ setup
3
+ lang="ts"
4
+ generic="
5
+ ListingFilter extends {
6
+ code: string;
7
+ label: string;
8
+ name: string;
9
+ entities?: Array<{
10
+ id: string;
11
+ name?: string;
12
+ translated?: { name?: string };
13
+ }>;
14
+ }
15
+ "
16
+ >
17
+ import { useCmsTranslations } from "@shopware/composables";
18
+ import { excludeRootCategory, getTranslatedProperty } from "@shopware/helpers";
19
+ import { defu } from "defu";
20
+ import { computed, ref } from "vue";
21
+
22
+ import { useSessionContext } from "#imports";
23
+
24
+ const {
25
+ filter,
26
+ selectedFilters,
27
+ displayMode = "accordion",
28
+ } = defineProps<{
29
+ filter: ListingFilter;
30
+ selectedFilters: {
31
+ categories?: string[];
32
+ [key: string]: unknown;
33
+ };
34
+ displayMode?: "accordion" | "dropdown";
35
+ }>();
36
+
37
+ const emits =
38
+ defineEmits<
39
+ (e: "select-value", value: { code: string; value: unknown }) => void
40
+ >();
41
+
42
+ type Translations = {
43
+ listing: {
44
+ categories: string;
45
+ };
46
+ };
47
+
48
+ let translations: Translations = {
49
+ listing: {
50
+ categories: "Categories",
51
+ },
52
+ };
53
+
54
+ translations = defu(useCmsTranslations(), translations) as Translations;
55
+
56
+ const { sessionContext } = useSessionContext();
57
+
58
+ const categoryOptions = computed(() =>
59
+ excludeRootCategory(
60
+ filter.entities,
61
+ sessionContext.value?.salesChannel?.navigationCategoryId,
62
+ ),
63
+ );
64
+
65
+ const isFilterVisible = ref<boolean>(false);
66
+ const toggle = () => {
67
+ isFilterVisible.value = !isFilterVisible.value;
68
+ };
69
+
70
+ const selectedIds = computed(() => selectedFilters?.categories || []);
71
+
72
+ const isChecked = (id: string) => selectedIds.value.includes(id);
73
+
74
+ const selectValue = (id: string) => {
75
+ emits("select-value", {
76
+ code: filter.code,
77
+ value: id,
78
+ });
79
+ };
80
+ </script>
81
+
82
+ <template>
83
+ <div
84
+ v-if="categoryOptions.length"
85
+ class="self-stretch flex flex-col justify-start items-start gap-4"
86
+ >
87
+ <!-- Accordion header (only in accordion mode) -->
88
+ <template v-if="displayMode === 'accordion'">
89
+ <div class="self-stretch flex flex-col justify-center items-center">
90
+ <div
91
+ class="self-stretch py-3 border-b border-outline-outline-variant inline-flex justify-between items-center gap-1 cursor-pointer"
92
+ @click="toggle"
93
+ role="button"
94
+ tabindex="0"
95
+ :aria-expanded="isFilterVisible"
96
+ :aria-controls="filter.code"
97
+ :aria-label="translations.listing.categories"
98
+ @keydown.enter="toggle"
99
+ @keydown.space.prevent="toggle"
100
+ >
101
+ <div class="flex-1 flex items-center gap-2.5">
102
+ <div
103
+ class="flex-1 text-surface-on-surface text-base font-bold leading-normal text-left"
104
+ >
105
+ {{ translations.listing.categories }}
106
+ </div>
107
+ </div>
108
+ <span class="flex items-center justify-center" aria-hidden="true">
109
+ <SwChevronIcon
110
+ :direction="isFilterVisible ? 'up' : 'down'"
111
+ :size="24"
112
+ />
113
+ </span>
114
+ </div>
115
+ </div>
116
+ </template>
117
+
118
+ <!-- Filter content -->
119
+ <transition name="filter-collapse">
120
+ <div
121
+ v-if="isFilterVisible || displayMode === 'dropdown'"
122
+ :id="filter.code"
123
+ class="self-stretch flex flex-col justify-start items-start gap-4"
124
+ >
125
+ <fieldset
126
+ class="self-stretch flex flex-col justify-start items-start gap-4"
127
+ >
128
+ <legend class="sr-only">{{ translations.listing.categories }}</legend>
129
+ <label
130
+ v-for="option in categoryOptions"
131
+ :key="option.id"
132
+ class="self-stretch inline-flex justify-start items-start gap-2 cursor-pointer"
133
+ >
134
+ <div
135
+ class="w-4 self-stretch pt-[3px] flex justify-start items-start gap-2.5"
136
+ >
137
+ <SwCheckbox
138
+ :model-value="isChecked(option.id)"
139
+ @update:model-value="() => selectValue(option.id)"
140
+ />
141
+ </div>
142
+ <div
143
+ class="flex-1 inline-flex flex-col justify-start items-start gap-0.5"
144
+ >
145
+ <div class="inline-flex justify-start items-center gap-1">
146
+ <div
147
+ class="flex-1 text-surface-on-surface text-base font-normal leading-normal"
148
+ >
149
+ {{ getTranslatedProperty(option, "name") }}
150
+ </div>
151
+ </div>
152
+ </div>
153
+ </label>
154
+ </fieldset>
155
+ </div>
156
+ </transition>
157
+ </div>
158
+ </template>
@@ -71,7 +71,7 @@ const DynamicRender = () => {
71
71
  }
72
72
  if (import.meta.dev) {
73
73
  console.warn(
74
- `[CMS] Block type "${componentName}" is not implemented.\n → Create a component named "${componentNameToResolve}.vue" to render it.\n 📖 Docs: https://frontends.shopware.com/getting-started/cms/create-blocks`,
74
+ `[CMS] Block type "${componentName}" is not implemented.\n → Create a component named "${componentNameToResolve}.vue" to render it.\n 📖 Docs: https://developer.shopware.com/frontends/guides/cms/create-blocks`,
75
75
  );
76
76
  return h(resolveComponent("CmsNoComponent"), { content: props.content });
77
77
  }
@@ -31,7 +31,7 @@ const DynamicRender = () => {
31
31
  }
32
32
  if (import.meta.dev) {
33
33
  console.warn(
34
- `[CMS] Element type "${componentName}" is not implemented.\n → Create a component named "${componentNameToResolve}.vue" to render it.\n 📖 Docs: https://frontends.shopware.com/getting-started/cms/create-elements`,
34
+ `[CMS] Element type "${componentName}" is not implemented.\n → Create a component named "${componentNameToResolve}.vue" to render it.\n 📖 Docs: https://developer.shopware.com/frontends/guides/cms/create-elements`,
35
35
  );
36
36
  return h(resolveComponent("CmsNoComponent"), { content: props.content });
37
37
  }
@@ -28,7 +28,7 @@ const docsUrl = computed(() => {
28
28
  component: expectedComponentName.value,
29
29
  type: elementType.value.toLowerCase(),
30
30
  });
31
- return `https://frontends.shopware.com/getting-started/cms/missing-component?${params}`;
31
+ return `https://developer.shopware.com/frontends/guides/cms/missing-component?${params}`;
32
32
  });
33
33
 
34
34
  const aiPrompt = computed(() => {
@@ -38,7 +38,7 @@ useSeoMeta({
38
38
  >
39
39
  <div
40
40
  v-if="registrationResponse?.translated.registrationIntroduction"
41
- class="px-6 sm:px-4 mb-6"
41
+ class="cms-element-text px-6 sm:px-4 mb-6"
42
42
  v-html="registrationResponse.translated.registrationIntroduction"
43
43
  />
44
44
  <AccountRegistrationForm
@@ -22,6 +22,7 @@ const slotCenterContent = getSlotContent("center");
22
22
 
23
23
  <style scoped>
24
24
  .cms-block-center-text .cms-element-image {
25
- @apply self-stretch min-h-12;
25
+ align-self: stretch;
26
+ min-height: 3rem;
26
27
  }
27
28
  </style>
@@ -30,6 +30,10 @@ const centerContent = getSlotContent("center");
30
30
  </template>
31
31
  <style scoped>
32
32
  .cms-block-image-bubble-row :deep(.cms-element-image) {
33
- @apply object-cover max-w-xs overflow-hidden rounded-full aspect-square;
33
+ aspect-ratio: 1 / 1;
34
+ max-width: 20rem;
35
+ overflow: hidden;
36
+ border-radius: 9999px;
37
+ object-fit: cover;
34
38
  }
35
39
  </style>
@@ -35,10 +35,14 @@ const centerRightContent = getSlotContent("center-right");
35
35
 
36
36
  <style scoped>
37
37
  .cms-block-image-four-column :deep(.cms-element-image) {
38
- @apply relative h-full w-full;
38
+ position: relative;
39
+ height: 100%;
40
+ width: 100%;
39
41
  }
40
42
 
41
43
  .cms-block-image-four-column :deep(.cms-element-image img) {
42
- @apply w-full h-full object-cover;
44
+ height: 100%;
45
+ width: 100%;
46
+ object-fit: cover;
43
47
  }
44
48
  </style>
@@ -20,24 +20,28 @@ const cmsContent = getSlotContent("imageGallery");
20
20
 
21
21
  <style scoped>
22
22
  .cms-block-image-gallery-big {
23
- @apply w-full;
23
+ width: 100%;
24
24
  }
25
25
 
26
26
  .cms-block-image-gallery-big :deep(.cms-element-image-gallery) {
27
- @apply max-w-screen-xl mx-auto;
27
+ max-width: 1280px;
28
+ margin-left: auto;
29
+ margin-right: auto;
28
30
  }
29
31
 
30
32
  /* Enhanced styling for the big gallery version */
31
33
  .cms-block-image-gallery-big :deep(.gallery-slider) {
32
- @apply max-h-[800px];
34
+ max-height: 800px;
33
35
  }
34
36
 
35
37
  .cms-block-image-gallery-big :deep(img) {
36
- @apply object-contain mx-auto;
38
+ margin-left: auto;
39
+ margin-right: auto;
40
+ object-fit: contain;
37
41
  }
38
42
 
39
43
  /* Larger navigation controls */
40
44
  .cms-block-image-gallery-big :deep(.gallery-slider-controls) {
41
- @apply scale-125;
45
+ transform: scale(1.25);
42
46
  }
43
47
  </style>
@@ -31,10 +31,15 @@ const centerContent = getSlotContent("center");
31
31
 
32
32
  <style scoped>
33
33
  .cms-block-image-highlight-row :deep(.cms-element-image) {
34
- @apply relative h-full w-full border-[12px] border-surface-surface;
34
+ position: relative;
35
+ height: 100%;
36
+ width: 100%;
37
+ border: 12px solid #ffffff;
35
38
  }
36
39
 
37
40
  .cms-block-image-highlight-row :deep(.cms-element-image img) {
38
- @apply w-full h-full object-cover;
41
+ height: 100%;
42
+ width: 100%;
43
+ object-fit: cover;
39
44
  }
40
45
  </style>
@@ -43,14 +43,20 @@ const rightTextContent = getSlotContent("right-text");
43
43
 
44
44
  <style scoped>
45
45
  .cms-block-image-text-row :deep(.cms-element-image) {
46
- @apply relative h-full w-full;
46
+ position: relative;
47
+ height: 100%;
48
+ width: 100%;
47
49
  }
48
50
 
49
51
  .cms-block-image-text-row :deep(.cms-element-image img) {
50
- @apply w-full h-full object-cover rounded-lg;
52
+ height: 100%;
53
+ width: 100%;
54
+ border-radius: 0.5rem;
55
+ object-fit: cover;
51
56
  }
52
57
 
53
58
  .cms-block-image-text-row :deep(.cms-element-text) {
54
- @apply self-stretch min-h-12;
59
+ align-self: stretch;
60
+ min-height: 3rem;
55
61
  }
56
62
  </style>
@@ -31,10 +31,15 @@ const centerContent = getSlotContent("center");
31
31
 
32
32
  <style scoped>
33
33
  .cms-block-image-three-cover :deep(.cms-element-image) {
34
- @apply aspect-square relative h-full w-full;
34
+ position: relative;
35
+ aspect-ratio: 1 / 1;
36
+ height: 100%;
37
+ width: 100%;
35
38
  }
36
39
 
37
40
  .cms-block-image-three-cover :deep(.cms-element-image img) {
38
- @apply w-full h-full object-cover;
41
+ height: 100%;
42
+ width: 100%;
43
+ object-fit: cover;
39
44
  }
40
45
  </style>
@@ -27,14 +27,18 @@ const rightContent = getSlotContent("right");
27
27
 
28
28
  <style scoped>
29
29
  .cms-block-image-two-column :deep(.cms-element-image) {
30
- @apply relative h-full w-full;
30
+ position: relative;
31
+ height: 100%;
32
+ width: 100%;
31
33
  }
32
34
 
33
35
  .cms-block-image-two-column :deep(.cms-element-image img) {
34
- @apply w-full h-full object-cover;
36
+ height: 100%;
37
+ width: 100%;
38
+ object-fit: cover;
35
39
  }
36
40
 
37
41
  .cms-block-image-two-column :deep(.cms-element-image-slider) {
38
- @apply w-full;
42
+ width: 100%;
39
43
  }
40
44
  </style>
@@ -22,6 +22,7 @@ const rightContent = getSlotContent("right");
22
22
 
23
23
  <style scoped>
24
24
  .cms-block-product-heading .cms-element-image {
25
- @apply max-w-[7.5rem] max-h-[7.5rem];
25
+ max-height: 7.5rem;
26
+ max-width: 7.5rem;
26
27
  }
27
28
  </style>
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import type { CmsElementBuyBox } from "@shopware/composables";
3
3
  import { useCmsTranslations } from "@shopware/composables";
4
+ import { getTranslatedProperty } from "@shopware/helpers";
4
5
  import { defu } from "defu";
5
6
  import { computed } from "vue";
6
7
 
@@ -64,7 +65,9 @@ const referencePrice = computed(
64
65
  () => product.value?.calculatedPrice?.referencePrice,
65
66
  );
66
67
  const purchaseUnit = computed(() => product.value?.purchaseUnit);
67
- const unitName = computed(() => product.value?.unit?.name);
68
+ const unitName = computed(() =>
69
+ getTranslatedProperty(product.value?.unit, "name"),
70
+ );
68
71
  const productName = computed(() => product.value?.translated?.name || "");
69
72
  </script>
70
73
  <template>
@@ -3,6 +3,7 @@ import type {
3
3
  CmsElementCrossSelling,
4
4
  SliderElementConfig,
5
5
  } from "@shopware/composables";
6
+ import { getTranslatedProperty } from "@shopware/helpers";
6
7
  import { useElementSize } from "@vueuse/core";
7
8
  import { computed, inject, ref, useTemplateRef } from "vue";
8
9
 
@@ -86,7 +87,7 @@ const toggleTab = (index: number) => {
86
87
  }"
87
88
  @click="toggleTab(index)"
88
89
  >
89
- {{ collection.crossSelling.name }}
90
+ {{ getTranslatedProperty(collection.crossSelling, "name") }}
90
91
  </a>
91
92
  </div>
92
93
  <transition name="fade" mode="out-in">
@@ -11,7 +11,7 @@ const { cssClasses, layoutStyles } = getCmsLayoutConfiguration(props.content);
11
11
 
12
12
  const HtmlComponent = () => {
13
13
  return h("div", {
14
- class: cssClasses,
14
+ class: ["cms-element-html", "cms-element-text", cssClasses],
15
15
  style: layoutStyles,
16
16
  innerHTML: props.content.data.content || "",
17
17
  });
@@ -5,9 +5,14 @@ import type {
5
5
  } from "@shopware/composables";
6
6
  import { buildUrlPrefix } from "@shopware/helpers";
7
7
  import { useElementSize } from "@vueuse/core";
8
+ import { defu } from "defu";
8
9
  import { computed, defineAsyncComponent, useTemplateRef } from "vue";
9
10
 
10
- import { useCmsElementImage, useUrlResolver } from "#imports";
11
+ import {
12
+ useCmsElementImage,
13
+ useCmsTranslations,
14
+ useUrlResolver,
15
+ } from "#imports";
11
16
 
12
17
  import { isSpatial } from "../../../../helpers/media/isSpatial";
13
18
 
@@ -16,17 +21,51 @@ const props = defineProps<{
16
21
  imageGallery?: boolean;
17
22
  }>();
18
23
 
24
+ type Translations = {
25
+ cms: {
26
+ image: {
27
+ linkWithoutLabel: string;
28
+ };
29
+ };
30
+ };
31
+
32
+ let translations: Translations = {
33
+ cms: {
34
+ image: {
35
+ linkWithoutLabel: "Open linked page",
36
+ },
37
+ },
38
+ };
39
+
40
+ translations = defu(useCmsTranslations(), translations) as Translations;
41
+
19
42
  const { getUrlPrefix } = useUrlResolver();
20
43
  const {
44
+ ariaLabel,
21
45
  containerStyle,
22
46
  displayMode,
23
47
  imageContainerAttrs,
24
48
  imageAttrs,
25
49
  imageLink,
50
+ isDecorative,
26
51
  isVideoElement,
27
52
  mimeType,
28
53
  } = useCmsElementImage(props.content);
29
54
 
55
+ // A link wrapping only an image is nameless without alt text, which axe
56
+ // reports as a `link-name` violation.
57
+ const linkAriaLabel = computed(() => {
58
+ if (!imageLink.value.url || imageAttrs.value.alt) {
59
+ return undefined;
60
+ }
61
+
62
+ const mediaTitle = isDecorative.value ? "" : props.content.data?.media?.title;
63
+
64
+ return (
65
+ ariaLabel.value || mediaTitle || translations.cms.image.linkWithoutLabel
66
+ );
67
+ });
68
+
30
69
  const imageElement = useTemplateRef<HTMLImageElement>("imageElement");
31
70
  const { width, height } = useElementSize(imageElement);
32
71
 
@@ -69,6 +108,7 @@ const SwMedia3D = computed(() => {
69
108
  }"
70
109
  :style="containerStyle"
71
110
  v-bind="imageComputedContainerAttrs"
111
+ :aria-label="linkAriaLabel"
72
112
  >
73
113
  <video
74
114
  v-if="isVideoElement"
@@ -92,7 +132,6 @@ const SwMedia3D = computed(() => {
92
132
  preset="productDetail"
93
133
  loading="lazy"
94
134
  :width="imageSize"
95
- :height="imageSize"
96
135
  :class="{
97
136
  'w-full': !imageGallery,
98
137
  'h-full': !imageGallery && ['cover', 'stretch'].includes(displayMode),
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { CmsElementImageGallery } from "@shopware/composables";
3
+ import { getTranslatedProperty } from "@shopware/helpers";
3
4
  import { computed, defineAsyncComponent, ref } from "vue";
4
5
 
5
6
  import { useCmsElementConfig, useImagePlaceholder } from "#imports";
@@ -129,7 +130,7 @@ function onTouchEnd() {
129
130
  class="w-full h-full absolute inset-0 object-cover"
130
131
  :placeholder="placeholderSvg"
131
132
  :src="currentImage.url"
132
- :alt="currentImage.alt || 'Product image'"
133
+ :alt="getTranslatedProperty(currentImage, 'alt') || 'Product image'"
133
134
  />
134
135
  <!-- Placeholder -->
135
136
  <img
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { encodeForQuery } from "@shopware/api-client/helpers";
2
3
  import type { CmsElementProductDescriptionReviews } from "@shopware/composables";
3
4
  import { useCmsTranslations } from "@shopware/composables";
4
5
  import { getTranslatedProperty } from "@shopware/helpers";
@@ -56,18 +57,26 @@ const isSectionOpen = (sectionNumber: number) => {
56
57
  };
57
58
 
58
59
  const reviews: Ref<Schemas["ProductReview"][]> = ref([]);
59
- const { apiClient } = useShopwareContext();
60
+ const { apiClient, cacheableReads } = useShopwareContext();
60
61
  const { isLoggedIn } = useUser();
61
62
  const reviewAdded = ref(false);
62
63
 
63
64
  const fetchReviews = async () => {
64
65
  try {
65
- const reviewsResponse = await apiClient.invoke(
66
- "readProductReviews post /product/{productId}/reviews",
67
- {
68
- pathParams: { productId: product.value.id },
69
- },
70
- );
66
+ const reviewsResponse = cacheableReads
67
+ ? await apiClient.invoke(
68
+ "readProductReviewsGet get /product/{productId}/reviews",
69
+ {
70
+ pathParams: { productId: product.value.id },
71
+ query: { _criteria: encodeForQuery({}) },
72
+ },
73
+ )
74
+ : await apiClient.invoke(
75
+ "readProductReviews post /product/{productId}/reviews",
76
+ {
77
+ pathParams: { productId: product.value.id },
78
+ },
79
+ );
71
80
  reviews.value = reviewsResponse.data.elements || [];
72
81
  } catch (error) {
73
82
  console.error("Failed to fetch reviews:", error);
@@ -124,7 +133,7 @@ onMounted(async () => {
124
133
  class="self-stretch text-surface-on-surface text-base font-normal leading-normal"
125
134
  >
126
135
  <!-- eslint-disable-next-line vue/no-v-html -->
127
- <div v-html="description"></div>
136
+ <div class="cms-element-text" v-html="description"></div>
128
137
  </div>
129
138
  </div>
130
139
  </Transition>
@@ -233,7 +242,7 @@ onMounted(async () => {
233
242
  :key="category.id"
234
243
  class="mb-2"
235
244
  >
236
- {{ category.name }}
245
+ {{ getTranslatedProperty(category, "name") }}
237
246
  </div>
238
247
  </div>
239
248
  <div v-else>No categories available</div>
@@ -1 +1,78 @@
1
1
  Display a text. Html to Vue mechanism is used to render buttons, links, images accordingly as Vue elements
2
+
3
+ #### Styling CMS-authored HTML
4
+
5
+ The rendered container always carries the `cms-element-text` class. Because the
6
+ content comes from the Shopware admin editor, its tags (`h1`–`h6`, `ul`, `table`,
7
+ `img`, …) arrive without any classes, so they can only be reached with element
8
+ selectors — and those must never be declared globally, or they would also hit
9
+ product names, buttons and other markup you do control.
10
+
11
+ This layer ships that typography for you in
12
+ [`app/assets/css/rich-text.css`](../../../../assets/css/rich-text.css),
13
+ registered through `css: []` in the layer's `nuxt.config.ts`. It is plain CSS, so
14
+ it also works for apps that don't use UnoCSS. Nothing to wire up in your app.
15
+
16
+ ##### Retheming
17
+
18
+ Every value reads from a `--rte-*` custom property with a built-in fallback, so
19
+ you only set the properties you want to change — no need to redeclare the rules:
20
+
21
+ ```css
22
+ :root {
23
+ --rte-h1-size: 3rem;
24
+ --rte-h1-line: 3.25rem;
25
+ --rte-border-color: var(--color-outline-variant);
26
+ }
27
+ ```
28
+
29
+ | Property | Default |
30
+ | --------------------------------- | ------------------------------- |
31
+ | `--rte-h1-size` / `--rte-h1-line` | `2.25rem` / `2.5rem` |
32
+ | `--rte-h2-size` / `--rte-h2-line` | `1.75rem` / `2rem` |
33
+ | `--rte-h3-size` / `--rte-h3-line` | `1.25rem` / `1.5rem` |
34
+ | `--rte-h4-size` / `--rte-h4-line` | `1.125rem` / `1.5rem` (h4–h6) |
35
+ | `--rte-heading-weight` | `600` |
36
+ | `--rte-heading-mb` | `10px` |
37
+ | `--rte-flow` | `1rem` (spacing between blocks) |
38
+ | `--rte-list-indent` | `40px` |
39
+ | `--rte-cell-padding` | `0.5rem 0.75rem` |
40
+ | `--rte-border-color` | `#e5e7eb` (tables, `hr`) |
41
+
42
+ Because the properties are inherited, you can also scope them contextually
43
+ instead of overriding selectors:
44
+
45
+ ```css
46
+ .cms-block-image-text-cover .cms-element-text {
47
+ --rte-h1-size: 3.5rem;
48
+ }
49
+ ```
50
+
51
+ ##### Scope
52
+
53
+ The class is set everywhere admin-authored HTML is injected, so one rule set
54
+ covers all of them:
55
+
56
+ | Component | Classes |
57
+ | ---------------------------------------------- | ------------------------------------- |
58
+ | `CmsElementText` | `cms-element-text` |
59
+ | `CmsElementHtml` | `cms-element-html cms-element-text` |
60
+ | `CmsElementProductDescriptionReviews` | `cms-element-text` (description body) |
61
+ | `FrontendAccountCustomerGroupRegistrationPage` | `cms-element-text` (intro text) |
62
+
63
+ Add `cms-element-text` to your own wrappers if you render admin HTML elsewhere,
64
+ for example custom fields using the HTML editor.
65
+
66
+ ##### Notes
67
+
68
+ The stylesheet uses `:where()` throughout, which keeps the specificity at
69
+ `(0,1,0)`. Two consequences worth knowing:
70
+
71
+ - A utility class or an inline style set by the editor still wins, without
72
+ `!important`.
73
+ - The rules beat a framework reset (`ul { list-style: none }`) no matter which
74
+ stylesheet is injected first, so the CSS order does not matter.
75
+
76
+ A `<style scoped>` block inside this component would _not_ work: the markup is
77
+ created by a render function, and Vue only applies the scope id to the container
78
+ root, not to its descendants.