@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.
Files changed (67) hide show
  1. package/README.md +167 -125
  2. package/app/app.config.ts +12 -0
  3. package/app/components/SwCategoryNavigation.vue +25 -18
  4. package/app/components/SwFilterDropdown.vue +54 -0
  5. package/app/components/SwListingProductPrice.vue +2 -2
  6. package/app/components/SwMedia3D.vue +14 -5
  7. package/app/components/SwProductCard.vue +24 -21
  8. package/app/components/SwProductCardDetails.vue +29 -12
  9. package/app/components/SwProductCardImage.vue +30 -29
  10. package/app/components/SwProductGallery.vue +18 -14
  11. package/app/components/SwProductListingFilter.vue +20 -9
  12. package/app/components/SwProductListingFilters.vue +3 -7
  13. package/app/components/SwProductListingFiltersHorizontal.vue +306 -0
  14. package/app/components/SwProductPrice.vue +3 -3
  15. package/app/components/SwProductRating.vue +40 -0
  16. package/app/components/SwProductReviews.vue +6 -19
  17. package/app/components/SwProductUnits.vue +10 -15
  18. package/app/components/SwQuantitySelect.vue +4 -7
  19. package/app/components/SwSlider.vue +150 -51
  20. package/app/components/SwSortDropdown.vue +10 -6
  21. package/app/components/SwVariantConfigurator.vue +13 -13
  22. package/app/components/listing-filters/SwFilterPrice.vue +45 -40
  23. package/app/components/listing-filters/SwFilterProperties.vue +40 -33
  24. package/app/components/listing-filters/SwFilterRating.vue +36 -27
  25. package/app/components/listing-filters/SwFilterShippingFree.vue +39 -32
  26. package/app/components/public/cms/CmsBlockSpatialViewer.vue +94 -0
  27. package/app/components/public/cms/CmsGenericBlock.md +17 -2
  28. package/app/components/public/cms/CmsGenericBlock.vue +21 -2
  29. package/app/components/public/cms/CmsGenericElement.vue +7 -2
  30. package/app/components/public/cms/CmsNoComponent.vue +87 -8
  31. package/app/components/public/cms/CmsPage.md +19 -2
  32. package/app/components/public/cms/CmsPage.vue +7 -0
  33. package/app/components/public/cms/FrontendAccountCustomerGroupRegistrationPage.vue +52 -0
  34. package/app/components/public/cms/block/CmsBlockCenterText.vue +1 -1
  35. package/app/components/public/cms/block/CmsBlockImageText.vue +5 -5
  36. package/app/components/public/cms/block/CmsBlockTextOnImage.vue +5 -12
  37. package/app/components/public/cms/element/CmsElementBuyBox.vue +3 -3
  38. package/app/components/public/cms/element/CmsElementCrossSelling.vue +19 -3
  39. package/app/components/public/cms/element/CmsElementImage.vue +12 -35
  40. package/app/components/public/cms/element/CmsElementImageGallery.vue +117 -50
  41. package/app/components/public/cms/element/CmsElementProductBox.vue +7 -1
  42. package/app/components/public/cms/element/CmsElementProductListing.vue +15 -4
  43. package/app/components/public/cms/element/CmsElementProductName.vue +6 -1
  44. package/app/components/public/cms/element/CmsElementProductSlider.vue +56 -35
  45. package/app/components/public/cms/element/CmsElementSidebarFilter.vue +10 -2
  46. package/app/components/public/cms/element/CmsElementText.vue +10 -11
  47. package/app/components/public/cms/element/SwProductListingPagination.vue +2 -2
  48. package/app/components/public/cms/section/CmsSectionDefault.vue +2 -2
  49. package/app/components/public/cms/section/CmsSectionSidebar.vue +6 -3
  50. package/app/components/ui/BaseButton.vue +18 -15
  51. package/app/components/ui/ChevronIcon.vue +10 -13
  52. package/app/components/ui/WishlistIcon.vue +3 -8
  53. package/app/composables/useImagePlaceholder.ts +3 -3
  54. package/app/composables/useLcpImagePreload.test.ts +229 -0
  55. package/app/composables/useLcpImagePreload.ts +43 -0
  56. package/app/composables/useTypedAppConfig.ts +15 -0
  57. package/app/helpers/cms/findFirstCmsImageUrl.ts +86 -0
  58. package/app/helpers/cms/getImageSizes.test.ts +50 -0
  59. package/app/helpers/cms/getImageSizes.ts +36 -0
  60. package/app/helpers/html-to-vue/ast.ts +53 -19
  61. package/app/helpers/html-to-vue/getOptionsFromNode.ts +1 -1
  62. package/app/helpers/html-to-vue/renderToHtml.ts +7 -11
  63. package/app/helpers/html-to-vue/renderer.ts +86 -26
  64. package/index.d.ts +37 -5
  65. package/nuxt.config.ts +25 -0
  66. package/package.json +21 -21
  67. package/uno.config.ts +0 -83
@@ -11,7 +11,7 @@ import {
11
11
  } from "@shopware/helpers";
12
12
  import { getCmsTranslate } from "@shopware/helpers";
13
13
  import { defu } from "defu";
14
- import { computed, ref, toRefs } from "vue";
14
+ import { computed, ref, toRef } from "vue";
15
15
  import {
16
16
  useAddToCart,
17
17
  useCartErrorParamsResolver,
@@ -26,19 +26,17 @@ const { pushSuccess, pushError } = useNotifications();
26
26
  const { getErrorsCodes } = useCartNotification();
27
27
  const { resolveCartError } = useCartErrorParamsResolver();
28
28
 
29
- const props = withDefaults(
30
- defineProps<{
31
- product: Schemas["Product"];
32
- layoutType?: BoxLayout;
33
- isProductListing?: boolean;
34
- displayMode?: DisplayMode;
35
- }>(),
36
- {
37
- layoutType: "standard",
38
- displayMode: "standard",
39
- isProductListing: false,
40
- },
41
- );
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
+ }>();
42
40
 
43
41
  type Translations = {
44
42
  product: {
@@ -52,6 +50,8 @@ type Translations = {
52
50
  badges: {
53
51
  topseller: string;
54
52
  };
53
+ addToWishlist: string;
54
+ removeFromWishlist: string;
55
55
  };
56
56
  errors: {
57
57
  [key: string]: string;
@@ -70,6 +70,8 @@ let translations: Translations = {
70
70
  badges: {
71
71
  topseller: "Tip",
72
72
  },
73
+ addToWishlist: "Add to wishlist",
74
+ removeFromWishlist: "Remove from wishlist",
73
75
  },
74
76
  errors: {
75
77
  "product-stock-reached":
@@ -79,7 +81,7 @@ let translations: Translations = {
79
81
 
80
82
  translations = defu(useCmsTranslations(), translations) as Translations;
81
83
 
82
- const { product } = toRefs(props);
84
+ const product = toRef(() => productProp);
83
85
 
84
86
  const { addToCart } = useAddToCart(product);
85
87
 
@@ -95,12 +97,12 @@ const toggleWishlistProduct = async () => {
95
97
  if (!isInWishlist.value) {
96
98
  await addToWishlist();
97
99
  pushSuccess(
98
- `${props.product?.translated.name} ${translations.product.addedToWishlist}`,
100
+ `${product?.value.translated.name} ${translations.product.addedToWishlist}`,
99
101
  );
100
102
  } else {
101
103
  await removeFromWishlist();
102
104
  pushSuccess(
103
- `${props.product?.translated.name} ${translations.product.removedFromTheWishlist}`,
105
+ `${product?.value.translated.name} ${translations.product.removedFromTheWishlist}`,
104
106
  );
105
107
  }
106
108
  } catch (error) {
@@ -109,7 +111,7 @@ const toggleWishlistProduct = async () => {
109
111
  ? `${translations.product.reason}: ${error.details.errors?.[0]?.detail}`
110
112
  : "";
111
113
  return pushError(
112
- `${props.product?.translated.name} ${translations.product.cannotAddToWishlist}\n${reason}`,
114
+ `${product?.value.translated.name} ${translations.product.cannotAddToWishlist}\n${reason}`,
113
115
  {
114
116
  timeout: 5000,
115
117
  },
@@ -131,11 +133,11 @@ const addToCartProxy = async () => {
131
133
 
132
134
  if (!errors.length)
133
135
  pushSuccess(
134
- `${props.product?.translated.name} ${translations.product.addedToCart}`,
136
+ `${product?.value.translated.name} ${translations.product.addedToCart}`,
135
137
  );
136
138
  };
137
139
 
138
- const fromPrice = getProductFromPrice(props.product);
140
+ const fromPrice = getProductFromPrice(product.value);
139
141
  const productName = computed(() => getProductName({ product: product.value }));
140
142
  const productManufacturer = computed(() =>
141
143
  getProductManufacturerName(product.value),
@@ -165,6 +167,7 @@ const productLink = computed(() =>
165
167
  :fromPrice="fromPrice"
166
168
  :addToCartProxy="addToCartProxy"
167
169
  :productLink="productLink"
170
+ :layoutType="layoutType"
168
171
  />
169
172
  </div>
170
- </template>
173
+ </template>
@@ -1,5 +1,7 @@
1
1
  <script setup lang="ts">
2
+ import type { BoxLayout } from "@shopware/composables";
2
3
  import type { UrlRouteOutput } from "@shopware/helpers";
4
+ import { computed } from "vue";
3
5
  import type { Schemas } from "#shopware";
4
6
 
5
7
  type Translations = {
@@ -12,7 +14,7 @@ type Translations = {
12
14
  };
13
15
  };
14
16
 
15
- defineProps<{
17
+ const props = defineProps<{
16
18
  product: Schemas["Product"];
17
19
  productName: string | null;
18
20
  productManufacturer?: string | null;
@@ -20,7 +22,10 @@ defineProps<{
20
22
  fromPrice?: number;
21
23
  addToCartProxy: () => Promise<void>;
22
24
  productLink: UrlRouteOutput;
25
+ layoutType?: BoxLayout;
23
26
  }>();
27
+
28
+ const isMinimalLayout = computed(() => props.layoutType === "minimal");
24
29
  </script>
25
30
  <template>
26
31
  <div class="self-stretch p-2 flex flex-col justify-between items-start gap-4 flex-1">
@@ -33,25 +38,37 @@ defineProps<{
33
38
  </div>
34
39
 
35
40
  <RouterLink :to="productLink"
36
- class="self-stretch text-surface-on-surface text-2xl font-normal font-serif leading-9 overflow-hidden line-clamp-2 break-words"
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]"
37
42
  data-testid="product-box-product-name-link">
38
43
  {{ productName }}
39
44
  </RouterLink>
40
45
  </div>
41
46
  </div>
42
47
 
43
- <SwListingProductPrice :product="product" data-testid="product-box-product-price" />
44
- </div>
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
+ />
45
55
 
46
- <SwBaseButton variant="primary" v-if="!fromPrice" size="medium" :disabled="!product?.available" block
47
- data-testid="add-to-cart-button" @click="addToCartProxy">
48
- {{ translations.product.addToCart }}
49
- </SwBaseButton>
56
+ <!-- Price for standard layout -->
57
+ <SwListingProductPrice v-else :product="product" data-testid="product-box-product-price" />
58
+ </div>
50
59
 
51
- <RouterLink v-else :to="productLink" class="self-stretch">
52
- <SwBaseButton block>
53
- {{ translations.product.details }}
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 }}
54
65
  </SwBaseButton>
55
- </RouterLink>
66
+
67
+ <RouterLink v-else :to="productLink" class="self-stretch">
68
+ <SwBaseButton block>
69
+ {{ translations.product.details }}
70
+ </SwBaseButton>
71
+ </RouterLink>
72
+ </template>
56
73
  </div>
57
74
  </template>
@@ -5,9 +5,8 @@ import {
5
5
  isProductOnSale,
6
6
  isProductTopSeller,
7
7
  } from "@shopware/helpers";
8
- import { useElementSize } from "@vueuse/core";
9
- import { computed, useTemplateRef } from "vue";
10
- import { useImagePlaceholder } from "#imports";
8
+ import { computed, inject } from "vue";
9
+ import { useUser } from "#imports";
11
10
  import type { Schemas } from "#shopware";
12
11
 
13
12
  type Translations = {
@@ -15,6 +14,8 @@ type Translations = {
15
14
  badges: {
16
15
  topseller: string;
17
16
  };
17
+ addToWishlist: string;
18
+ removeFromWishlist: string;
18
19
  };
19
20
  };
20
21
 
@@ -27,27 +28,11 @@ const props = defineProps<{
27
28
  productLink: UrlRouteOutput;
28
29
  }>();
29
30
 
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
31
  const coverSrcPath = computed(() => {
39
- return getSmallestThumbnailUrl(props.product?.cover?.media);
40
- });
41
-
42
- const imageModifiers = computed(() => {
43
- // Use the larger dimension and apply 2x for high-DPI displays
44
- // For square containers, width and height should be the same
45
- const containerSize = Math.max(width.value || 0, height.value || 0);
46
- const size = roundUp(containerSize * 2);
47
- return {
48
- width: size,
49
- height: size,
50
- };
32
+ return (
33
+ props.product?.cover?.media?.url ||
34
+ getSmallestThumbnailUrl(props.product?.cover?.media)
35
+ );
51
36
  });
52
37
 
53
38
  const coverAlt = computed(() => {
@@ -57,16 +42,30 @@ const coverAlt = computed(() => {
57
42
  const isOnSale = computed(() => isProductOnSale(props.product));
58
43
  const isTopseller = computed(() => isProductTopSeller(props.product));
59
44
 
60
- const placeholderSvg = useImagePlaceholder();
45
+ const { isLoggedIn } = useUser();
46
+ const loginModal = inject<{
47
+ open: (options?: { onSuccess?: () => void | Promise<void> }) => void;
48
+ } | null>("loginModal", null);
49
+
50
+ function handleWishlistClick() {
51
+ if (isLoggedIn.value || !loginModal) {
52
+ props.toggleWishlist();
53
+ return;
54
+ }
55
+ loginModal.open({ onSuccess: props.toggleWishlist });
56
+ }
61
57
  </script>
62
58
 
63
59
  <template>
64
- <div ref="containerElement" class="self-stretch min-h-[350px] relative flex flex-col justify-start items-start overflow-hidden aspect-square">
60
+ <div class="self-stretch min-h-[350px] relative flex flex-col justify-start items-start overflow-hidden aspect-square">
65
61
  <RouterLink :to="productLink" class="self-stretch h-full relative overflow-hidden">
66
62
  <NuxtImg preset="productCard"
67
63
  class="w-full h-full absolute top-0 left-0 object-cover"
68
- :placeholder="placeholderSvg"
69
- :src="coverSrcPath" :alt="coverAlt" :modifiers="imageModifiers" data-testid="product-box-img" />
64
+ :src="coverSrcPath" :alt="coverAlt"
65
+ width="400" height="400"
66
+ densities="1x"
67
+ loading="lazy"
68
+ data-testid="product-box-img" />
70
69
  </RouterLink>
71
70
 
72
71
  <div v-if="isTopseller || isOnSale"
@@ -77,9 +76,11 @@ const placeholderSvg = useImagePlaceholder();
77
76
  </div>
78
77
 
79
78
  <client-only>
80
- <SwIconButton type="secondary" aria-label="Toggle wishlist" :disabled="isLoading"
79
+ <SwIconButton type="secondary"
80
+ :aria-label="isInWishlist ? translations.product.removeFromWishlist : translations.product.addToWishlist"
81
+ :disabled="isLoading"
81
82
  class="w-10 h-10 right-4 top-4 absolute bg-brand-secondary rounded-full flex items-center justify-center"
82
- data-testid="product-box-toggle-wishlist-button" @click="toggleWishlist">
83
+ data-testid="product-box-toggle-wishlist-button" @click="handleWishlistClick">
83
84
  <SwWishlistIcon :filled="isInWishlist" />
84
85
  </SwIconButton>
85
86
  </client-only>
@@ -1,30 +1,34 @@
1
1
  <script setup lang="ts">
2
- import type { CmsElementImageGallery } from "@shopware/composables";
2
+ import type {
3
+ CmsElementImageGallery,
4
+ SliderElementConfig,
5
+ } from "@shopware/composables";
3
6
  import { ref, watch } from "vue";
4
7
  import type { Schemas } from "#shopware";
5
8
 
6
- const props = defineProps<{
9
+ const { product, config = {} } = defineProps<{
7
10
  product: Schemas["Product"];
11
+ config?: Partial<SliderElementConfig>;
8
12
  }>();
13
+
14
+ const defaultConfig: SliderElementConfig = {
15
+ minHeight: { value: "300px", source: "static" },
16
+ navigationArrows: { value: "inside", source: "static" },
17
+ navigationDots: { value: "inside", source: "static" },
18
+ };
19
+
9
20
  const content = ref<CmsElementImageGallery>();
10
21
 
11
22
  watch(
12
- () => props.product,
13
- (value) => {
14
- const media = value.media;
23
+ [() => product, () => config],
24
+ ([currentProduct, currentConfig]) => {
15
25
  content.value = {
16
26
  config: {
17
- minHeight: {
18
- value: "300px",
19
- source: "static",
20
- },
21
- navigationArrows: {
22
- value: "inside",
23
- source: "static",
24
- },
27
+ ...defaultConfig,
28
+ ...currentConfig,
25
29
  },
26
30
  data: {
27
- sliderItems: media,
31
+ sliderItems: currentProduct.media,
28
32
  },
29
33
  } as CmsElementImageGallery;
30
34
  },
@@ -6,7 +6,16 @@ import SwFilterPropertiesVue from "./listing-filters/SwFilterProperties.vue";
6
6
  import SwFilterRatingVue from "./listing-filters/SwFilterRating.vue";
7
7
  import SwFilterShippingFreeVue from "./listing-filters/SwFilterShippingFree.vue";
8
8
 
9
- const props = defineProps<{
9
+ const {
10
+ filter,
11
+ selectedManufacturer,
12
+ selectedProperties,
13
+ selectedMinPrice,
14
+ selectedMaxPrice,
15
+ selectedRating,
16
+ selectedShippingFree,
17
+ displayMode = "accordion",
18
+ } = defineProps<{
10
19
  filter: ListingFilter;
11
20
  selectedManufacturer: Set<string>;
12
21
  selectedProperties: Set<string>;
@@ -14,6 +23,7 @@ const props = defineProps<{
14
23
  selectedMaxPrice: number | undefined;
15
24
  selectedRating: number | undefined;
16
25
  selectedShippingFree: boolean | undefined;
26
+ displayMode?: "accordion" | "dropdown";
17
27
  }>();
18
28
 
19
29
  const emit = defineEmits<{
@@ -22,13 +32,13 @@ const emit = defineEmits<{
22
32
 
23
33
  const transformedFilters = computed(() => ({
24
34
  price: {
25
- min: props.selectedMinPrice,
26
- max: props.selectedMaxPrice,
35
+ min: selectedMinPrice,
36
+ max: selectedMaxPrice,
27
37
  },
28
- rating: props.selectedRating,
29
- "shipping-free": props.selectedShippingFree,
30
- manufacturer: [...props.selectedManufacturer],
31
- properties: [...props.selectedProperties],
38
+ rating: selectedRating,
39
+ "shipping-free": selectedShippingFree,
40
+ manufacturer: [...selectedManufacturer],
41
+ properties: [...selectedProperties],
32
42
  }));
33
43
 
34
44
  const filterComponent = computed<Component | undefined>(() => {
@@ -40,8 +50,8 @@ const filterComponent = computed<Component | undefined>(() => {
40
50
  };
41
51
 
42
52
  return (
43
- componentMap[props.filter.code] ||
44
- ("options" in props.filter ? SwFilterPropertiesVue : undefined)
53
+ componentMap[filter.code] ||
54
+ ("options" in filter ? SwFilterPropertiesVue : undefined)
45
55
  );
46
56
  });
47
57
 
@@ -58,6 +68,7 @@ const handleSelectValue = ({
58
68
  :is="filterComponent"
59
69
  :filter="filter"
60
70
  :selected-filters="transformedFilters"
71
+ :display-mode="displayMode"
61
72
  @select-value="handleSelectValue"
62
73
  />
63
74
  </div>
@@ -7,12 +7,12 @@ import { useCmsTranslations } from "@shopware/composables";
7
7
  import { defu } from "defu";
8
8
  import { computed, reactive } from "vue";
9
9
  import type { ComputedRef, UnwrapNestedRefs } from "vue";
10
- import { type LocationQueryRaw, useRoute, useRouter } from "vue-router";
11
- import { useCategoryListing } from "#imports";
10
+ import type { LocationQueryRaw } from "vue-router";
11
+ import { useCategoryListing, useRoute, useRouter } from "#imports";
12
12
  import type { Schemas, operations } from "#shopware";
13
13
 
14
14
  const props = defineProps<{
15
- content: CmsElementProductListing | CmsElementSidebarFilter;
15
+ content?: CmsElementProductListing | CmsElementSidebarFilter;
16
16
  listingType?: string;
17
17
  }>();
18
18
 
@@ -233,10 +233,6 @@ async function invokeCleanFilters() {
233
233
  }
234
234
  }
235
235
 
236
- const isDefaultSidebarFilter =
237
- props.content.type === "sidebar-filter" &&
238
- props.content.config?.boxLayout?.value === "standard";
239
-
240
236
  const handleSortChange = (sortKey: string) => {
241
237
  currentSortingOrder.value = sortKey;
242
238
  };