@shopware/cms-base-layer 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +58 -43
  2. package/app/components/SwCategoryNavigationLink.vue +4 -5
  3. package/app/components/SwMedia3D.vue +8 -3
  4. package/app/components/SwProductAddToCart.vue +27 -4
  5. package/app/components/SwProductCard.vue +26 -4
  6. package/app/components/SwProductCardDetails.vue +9 -2
  7. package/app/components/SwProductListingFilter.vue +1 -1
  8. package/app/components/SwProductListingFilters.vue +10 -222
  9. package/app/components/SwProductListingFiltersHorizontal.vue +15 -202
  10. package/app/components/public/cms/CmsBlockSpatialViewer.vue +7 -7
  11. package/app/components/public/cms/CmsGenericBlock.vue +6 -10
  12. package/app/components/public/cms/CmsGenericElement.md +11 -5
  13. package/app/components/public/cms/CmsGenericElement.vue +17 -11
  14. package/app/components/public/cms/block/CmsBlockAppRenderer.vue +48 -0
  15. package/app/components/public/cms/block/CmsBlockCategoryHeading.vue +19 -0
  16. package/app/components/public/cms/block/CmsBlockCategoryNavigation.vue +3 -2
  17. package/app/components/public/cms/block/CmsBlockCenterText.vue +5 -4
  18. package/app/components/public/cms/block/CmsBlockCrossSelling.vue +3 -2
  19. package/app/components/public/cms/block/CmsBlockCustomForm.vue +3 -2
  20. package/app/components/public/cms/block/CmsBlockForm.vue +3 -2
  21. package/app/components/public/cms/block/CmsBlockGalleryBuybox.vue +4 -3
  22. package/app/components/public/cms/block/CmsBlockImage.vue +3 -2
  23. package/app/components/public/cms/block/CmsBlockImageBubbleRow.vue +5 -4
  24. package/app/components/public/cms/block/CmsBlockImageCover.vue +3 -2
  25. package/app/components/public/cms/block/CmsBlockImageFourColumn.vue +6 -5
  26. package/app/components/public/cms/block/CmsBlockImageGallery.vue +3 -2
  27. package/app/components/public/cms/block/CmsBlockImageGalleryBig.vue +3 -2
  28. package/app/components/public/cms/block/CmsBlockImageHighlightRow.vue +5 -4
  29. package/app/components/public/cms/block/CmsBlockImageSimpleGrid.vue +5 -4
  30. package/app/components/public/cms/block/CmsBlockImageSlider.vue +3 -2
  31. package/app/components/public/cms/block/CmsBlockImageText.vue +4 -3
  32. package/app/components/public/cms/block/CmsBlockImageTextBubble.vue +8 -7
  33. package/app/components/public/cms/block/CmsBlockImageTextCover.vue +4 -3
  34. package/app/components/public/cms/block/CmsBlockImageTextGallery.vue +39 -16
  35. package/app/components/public/cms/block/CmsBlockImageTextRow.vue +8 -7
  36. package/app/components/public/cms/block/CmsBlockImageThreeColumn.vue +5 -4
  37. package/app/components/public/cms/block/CmsBlockImageThreeCover.vue +5 -4
  38. package/app/components/public/cms/block/CmsBlockImageTwoColumn.vue +4 -3
  39. package/app/components/public/cms/block/CmsBlockProductDescriptionReviews.vue +3 -2
  40. package/app/components/public/cms/block/CmsBlockProductHeading.vue +4 -3
  41. package/app/components/public/cms/block/CmsBlockProductListing.vue +3 -2
  42. package/app/components/public/cms/block/CmsBlockProductThreeColumn.vue +5 -4
  43. package/app/components/public/cms/block/CmsBlockSidebarFilter.vue +7 -5
  44. package/app/components/public/cms/block/CmsBlockText.vue +3 -2
  45. package/app/components/public/cms/block/CmsBlockTextHero.vue +3 -2
  46. package/app/components/public/cms/block/CmsBlockTextOnImage.vue +3 -2
  47. package/app/components/public/cms/block/CmsBlockTextThreeColumn.vue +5 -4
  48. package/app/components/public/cms/block/CmsBlockTextTwoColumn.vue +4 -3
  49. package/app/components/public/cms/block/CmsBlockVideo.vue +19 -0
  50. package/app/components/public/cms/block/CmsBlockVimeoVideo.vue +3 -2
  51. package/app/components/public/cms/block/CmsBlockYoutubeVideo.vue +3 -2
  52. package/app/components/public/cms/element/CmsElementCategoryName.md +8 -0
  53. package/app/components/public/cms/element/CmsElementCategoryName.vue +35 -0
  54. package/app/components/public/cms/element/CmsElementProductListing.vue +58 -25
  55. package/app/components/public/cms/element/CmsElementText.vue +11 -10
  56. package/app/components/public/cms/element/CmsElementVideo.md +27 -0
  57. package/app/components/public/cms/element/CmsElementVideo.vue +193 -0
  58. package/app/components/public/cms/section/CmsSectionSidebar.vue +4 -4
  59. package/app/composables/useListingFilters.ts +189 -0
  60. package/app/composables/useSelectedListingFilters.ts +46 -0
  61. package/app/helpers/cms/getAppRendererLayout.test.ts +93 -0
  62. package/app/helpers/cms/getAppRendererLayout.ts +36 -0
  63. package/app/helpers/cms/getCategoryNameContent.test.ts +57 -0
  64. package/app/helpers/cms/getCategoryNameContent.ts +15 -0
  65. package/app/helpers/cms/getTextElementContent.test.ts +79 -0
  66. package/app/helpers/cms/getTextElementContent.ts +37 -0
  67. package/app/helpers/cms/getVideoElementOptions.test.ts +384 -0
  68. package/app/helpers/cms/getVideoElementOptions.ts +142 -0
  69. package/app/helpers/html-to-vue/renderToHtml.test.ts +16 -0
  70. package/app/helpers/html-to-vue/renderer.ts +3 -3
  71. package/app/helpers/product/shouldShowBuyButton.test.ts +21 -0
  72. package/app/helpers/product/shouldShowBuyButton.ts +6 -0
  73. package/app/utils/{useSelectedListingFilters.test.ts → listingFilterState.test.ts} +1 -1
  74. package/app/utils/{useSelectedListingFilters.ts → listingFilterState.ts} +0 -35
  75. package/package.json +10 -10
package/README.md CHANGED
@@ -460,6 +460,36 @@ The procedure is:
460
460
 
461
461
  ✅ Thanks to this, nuxt will take the component registered in your app instead of the one registered by this nuxt layer.
462
462
 
463
+ ### App blocks (`app-renderer`)
464
+
465
+ Every block an app registers through the Meteor Admin SDK (`cms.registerCmsBlock`) reaches the Store API with the type `app-renderer`, so they all render through `CmsBlockAppRenderer`. By default it places the block's slots in the CSS grid the app declared, like the Storefront's fallback, in the order the app declared them.
466
+
467
+ To give one app block its own markup, add a global component named after the block, `CmsBlockAppRenderer` followed by the PascalCase `appBlockName` — for a block registered as `swag-two-columns`, that is `CmsBlockAppRendererSwagTwoColumns.vue`. It receives the block as its `content` prop, and every other app block keeps the fallback:
468
+
469
+ ```vue
470
+ <script setup lang="ts">
471
+ import type { CmsBlockAppRenderer } from "@shopware/composables";
472
+
473
+ const props = defineProps<{ content: CmsBlockAppRenderer }>();
474
+
475
+ const { getSlotContent } = useCmsBlock(() => props.content);
476
+
477
+ const text = computed(() => getSlotContent("text-0"));
478
+ const image = computed(() => getSlotContent("image-1"));
479
+ </script>
480
+
481
+ <template>
482
+ <div class="grid gap-6 md:grid-cols-2">
483
+ <CmsGenericElement :content="text" />
484
+ <CmsGenericElement :content="image" />
485
+ </div>
486
+ </template>
487
+ ```
488
+
489
+ The slots are named `{element}-{index}` in the order the app declared them, so look them up by name rather than by position: the Store API sorts them by name, so `image-1` comes before `text-0` and `text-10` before `text-2`. The fallback restores the declared order from the index, as the Administration preview shows it. The Storefront's fallback keeps the Store API order, so a block that mixes element types can place its slots differently there.
490
+
491
+ The lookup of these components lives in `CmsBlockAppRenderer` itself. If you override `CmsBlockAppRenderer`, for example to change the fallback markup, your override replaces that lookup too, and your `CmsBlockAppRenderer{AppBlockName}` components are no longer used unless it renders them. A component whose name does not match the `appBlockName` renders the fallback without a warning, as a misnamed override of any other CMS component does.
492
+
463
493
  ### Internal components
464
494
 
465
495
  ❗**Internal components are not a part of public API. Once overwritten you need to track the changes on your own.**
@@ -491,66 +521,51 @@ No additional packages needed to be installed.
491
521
 
492
522
  Full changelog for stable version is available [here](https://github.com/shopware/frontends/blob/main/packages/cms-base-layer/CHANGELOG.md)
493
523
 
494
- ### Latest changes: 4.0.0
524
+ ### Latest changes: 4.1.0
495
525
 
496
- ### Major Changes
526
+ ### Minor Changes
497
527
 
498
- - [#2609](https://github.com/shopware/frontends/pull/2609) [`2dc86da`](https://github.com/shopware/frontends/commit/2dc86da2532e986ebdadd06a922e68403414a0ff) Thanks [@mkucmus](https://github.com/mkucmus)! - Make the package a plain Nuxt layer.
528
+ - [#2785](https://github.com/shopware/frontends/pull/2785) [`74a477b`](https://github.com/shopware/frontends/commit/74a477bfa88e3c518d3a0336e4d301c823122ba8) Thanks [@mdanilowicz](https://github.com/mdanilowicz)! - Render the CMS blocks and elements Shopware added since 6.6, which rendered nothing until now:
499
529
 
500
- **Breaking:** the package now declares an `exports` map, so only the layer entry point
501
- is importable. Deep imports such as
502
- `@shopware/cms-base-layer/app/components/SwProductCard.vue` no longer resolve. Use the
503
- auto-registered global components instead, which is what extending the layer gives you.
530
+ - `category-heading` block and `category-name` element (6.7.12), used by the default listing layouts for the category headline.
531
+ - `video` block and element (6.7.8) for media library videos, with every option of the Administration. New translation keys: `cms.video.playLabel`, `cms.video.pauseLabel`, `cms.video.loadError` and `cms.video.notSupported`.
532
+ - `app-renderer` block (6.6.1) for blocks registered through the Meteor Admin SDK. A global `CmsBlockAppRenderer{AppBlockName}` component overrides a single app block, see "App blocks" in the README.
504
533
 
505
- **Breaking:** the leftover Nuxt module is gone, so `index.cjs` and the `dist` build are
506
- no longer published. The module registered a component folder that moved to `app/` a
507
- while ago, so it could not work any more. Consume this package with `extends`, which
508
- resolves `nuxt.config.ts`:
534
+ - [#2774](https://github.com/shopware/frontends/pull/2774) [`5961f55`](https://github.com/shopware/frontends/commit/5961f55b9f7cebad626cc073ae0bf857aa26f91b) Thanks [@mdanilowicz](https://github.com/mdanilowicz)! - Render blocks reactively and survive a missing slot
509
535
 
510
- ```ts
511
- export default defineNuxtConfig({
512
- extends: ["@shopware/cms-base-layer"],
513
- });
514
- ```
536
+ `CmsGenericElement` now takes `content` as an optional prop and renders nothing when it is missing, instead of handing `undefined` to `resolveCmsComponent` and throwing. A block does not have to carry every slot its layout allows, so that is no longer an error path.
515
537
 
516
- Other changes:
538
+ Every block component now passes its `content` to `useCmsBlock` as a getter and reads slot lookups through a `computed`, and `CmsSectionSidebar` does the same with `useCmsSection`. A block or section that receives new content re-resolves which slot goes where, instead of rendering the tree it was mounted with.
517
539
 
518
- - Dropped the `build` and `dev` scripts and the `unbuild` dev dependency. There is
519
- nothing left to bundle.
520
- - Fixed `files`. It listed `helpers` and `app.config.ts`, which do not exist at the
521
- package root, and shipped the dead `dist` and `index.cjs`.
522
- - Deleted `components.d.ts`. It typed two components at paths that moved to
523
- `app/components/public/cms/`, and no tsconfig referenced the file.
524
- - Removed two vitest aliases that pointed at files which do not exist.
525
- - Removed the `check-colors` script. It read `uno.config.ts` from this package, which
526
- moved to `@shopware/unocss-design-tokens-layer`, so it always found zero colors. It
527
- also relied on `tsx` without declaring it.
540
+ That stops at the element boundary. Element components still call `useCmsElementConfig(props.content)` and `useCmsElementImage(props.content)`, which capture the slot object at setup, so an element reused for a different slot of the same type keeps its old config- and media-derived values — an image its old source, a text its old configured content. Only values read straight from the prop (`props.content.data`) follow. Making those composables accept a getter is a separate change.
528
541
 
529
- ### Minor Changes
542
+ Both generic components also stop emitting an empty `<div>` where they used to render a placeholder: a missing slot and — in production — a block or element type with no component now render nothing. Dev mode is unchanged: it still warns and renders `CmsNoComponent`.
530
543
 
531
- - [#2574](https://github.com/shopware/frontends/pull/2574) [`2ddf156`](https://github.com/shopware/frontends/commit/2ddf156805b2941fe2069e78453fb3c4eb6d44ac) Thanks [@mkucmus](https://github.com/mkucmus)! - `SwProductListingFilters` and `SwProductListingFiltersHorizontal` render a new `SwFilterCategories` checkbox filter when the listing response contains the category aggregations from `getCategoryFilterAggregations()`. The selection is kept in the `categories` URL param and applied as a criteria `post-filter`. Both are search listings only (`listing-type="productSearchListing"`); on any other listing the category filter is not rendered, because the selection would neither reach the URL nor the criteria. Listings without these aggregations are unaffected.
544
+ `CmsGenericBlock` and `CmsGenericElement` dropped their `Problem resolving component: …` branch. It sat behind `if (resolvedComponent)` and tested `isResolved`, which was always `true` there, so it never rendered; an unresolved component still logs a dev warning and renders `CmsNoComponent`.
532
545
 
533
546
  ### Patch Changes
534
547
 
535
- - [#2645](https://github.com/shopware/frontends/pull/2645) [`7a7ce3e`](https://github.com/shopware/frontends/commit/7a7ce3e5649e7ab84f7e547ce1ebcd9413117dde) Thanks [@mkucmus](https://github.com/mkucmus)! - Ship `component-dirs.json` in the published package. `nuxt.config.ts` imports it at load time, but it was missing from `files`, so the layer failed to load when installed from the registry.
548
+ - [#2778](https://github.com/shopware/frontends/pull/2778) [`0d4151c`](https://github.com/shopware/frontends/commit/0d4151ce1fb231606c33e1fd88f525baff60416d) Thanks [@grenzenlos-digital](https://github.com/grenzenlos-digital)! - Use Three.js vectors for the 3D camera and light positions.
549
+
550
+ - [#2731](https://github.com/shopware/frontends/pull/2731) [`46d6daa`](https://github.com/shopware/frontends/commit/46d6daade81e3d9e443f3badfeba2ca0e2751477) Thanks [@patzick](https://github.com/patzick)! - Add an optional notification action (label + link) so add-to-cart toasts can offer a "View cart" shortcut, and keep those toasts visible a little longer.
536
551
 
537
- - [#2660](https://github.com/shopware/frontends/pull/2660) [`8913956`](https://github.com/shopware/frontends/commit/89139563924163e57cafdd9770fe603f2dbd8cba) Thanks [@mdanilowicz](https://github.com/mdanilowicz)! - Read translatable entity fields through their `translated` object instead of the untranslated entity root, so they follow the language of the current request instead of rendering the system language. Affected components: `SwContactForm` and `SwNewsletterForm` (salutation `displayName`), `SwStockInfo` (delivery time `name`), `SwVariantConfigurator` (property group `name` — the option values below it already used `translated`), `CmsElementBuyBox` (unit `name`), `CmsElementCrossSelling` (cross selling `name`), `CmsElementProductDescriptionReviews` (category `name`), `SwProductCardImage` and `CmsElementImageGallery` (media `alt`).
552
+ - [#2813](https://github.com/shopware/frontends/pull/2813) [`c6abd30`](https://github.com/shopware/frontends/commit/c6abd3000a9c3ad8ec4512704aa99a66ab0e671c) Thanks [@patzick](https://github.com/patzick)! - Fix a 500 on every page with a listing filter (`Cannot read properties of undefined (reading 'query')`) in projects that install the layer outside this monorepo. `useSelectedListingFilters` imported `useRoute` straight from `vue-router`, which the layer does not depend on, so it could resolve to a different `vue-router` copy than the one Nuxt's router uses and get no route back. It now uses Nuxt's `useRoute`, and `SwCategoryNavigationLink` renders `NuxtLink` instead of importing `RouterLink` from `vue-router` for the same reason. The pure filter-state helpers moved from `app/utils/useSelectedListingFilters.ts` to `app/utils/listingFilterState.ts`; their auto-imported names are unchanged.
538
553
 
539
- All of them now go through `getTranslatedProperty()` from `@shopware/helpers`, which falls back to the root property when `translated` is missing — so the rendered value only ever changes on storefronts whose language differs from the system language.
554
+ - [#2785](https://github.com/shopware/frontends/pull/2785) [`74a477b`](https://github.com/shopware/frontends/commit/74a477bfa88e3c518d3a0336e4d301c823122ba8) Thanks [@mdanilowicz](https://github.com/mdanilowicz)! - Fix how `CmsElementText` renders CMS text. `CmsElementProductName` and `CmsElementCategoryName` render through it and get the same fixes.
540
555
 
541
- - [#2606](https://github.com/shopware/frontends/pull/2606) [`1c9a604`](https://github.com/shopware/frontends/commit/1c9a604e05c0fa1b27708a5338cd5690a0ff5c67) Thanks [@mkucmus](https://github.com/mkucmus)! - Stop leaking internal types into the layer's public type surface.
556
+ - It renders only the content the Store API resolved. When that content was empty, it fell back to the raw `config.content` value, which the backend sanitizer never saw. Static content that the sanitizer removed completely, such as a lone `<script>`, came back unsanitized, and a `{{ … }}` placeholder that resolved to nothing was shown as it was typed. Both now render nothing, as in the Storefront. An element built by hand without `data` still renders its static config.
557
+ - It no longer shows a mapping path. The backend answers a mapping to a value that is not a string with the path itself, for example `category.customFields`. That is now treated as unresolved.
558
+ - It follows a new `content`. It read its content and config once, when it was set up, so a component that received a new `content` kept the old text.
559
+ - HTML attributes other than `class`, `style` and `align` now reach the page, for example `id`, `title` and `colspan`. They were passed in the Vue 2 shape, so server renders dropped them and client renders added `attrs="[object Object]"`. That also made the server and client markup differ on hydration.
542
560
 
543
- - `index.d.ts` now only augments the app config. It no longer re-exports `@shopware/composables` and `./.nuxt/imports`.
544
- - The `#imports` shim moved to a private `types/imports.d.ts`, which is not published.
545
- - `app.config` exports a plain object with `satisfies AppConfigInput` instead of calling `defineAppConfig`. No runtime change.
561
+ - [#2677](https://github.com/shopware/frontends/pull/2677) [`62c8d4c`](https://github.com/shopware/frontends/commit/62c8d4c86130c076b8391271593372eac83380e3) Thanks [@mkucmus](https://github.com/mkucmus)! - Drive the product listing from the URL. Browser back and forward now update the products, and sorting no longer fires a duplicate request.
546
562
 
547
- This fixes `Cannot find name 'ref'` and similar errors in the shared and node contexts when a project uses the Nuxt 4 project-references `tsconfig.json`.
563
+ - [#2677](https://github.com/shopware/frontends/pull/2677) [`62c8d4c`](https://github.com/shopware/frontends/commit/62c8d4c86130c076b8391271593372eac83380e3) Thanks [@mkucmus](https://github.com/mkucmus)! - Write listing filters to the URL before fetching, so a slow or failed request no longer drops the selection. Expose the product id on the add-to-cart button.
548
564
 
549
- - [#2601](https://github.com/shopware/frontends/pull/2601) [`e64d2f9`](https://github.com/shopware/frontends/commit/e64d2f9c13a99054d7e56e4b7ec2f1f23f9768b7) Thanks [@mdanilowicz](https://github.com/mdanilowicz)! - Typography for CMS-authored HTML is now scoped to a `cms-element-text` class and ships from the layer as `app/assets/css/rich-text.css`, with overflow guards for long strings, media, `pre` and tables. Headings and lists in your own markup that had no explicit size or `list-*` class were relying on the removed global rules and will change.
565
+ - [#2812](https://github.com/shopware/frontends/pull/2812) [`09d8b0f`](https://github.com/shopware/frontends/commit/09d8b0fea5489e973ef4f1f49d09e72372ab9ecf) Thanks [@mkucmus](https://github.com/mkucmus)! - Show "Details" instead of "Add to cart" for variant parents, and show success only when the product is in the cart. A product with a single price tier now shows "Add to cart", like in the Twig storefront.
550
566
 
551
- - [#2663](https://github.com/shopware/frontends/pull/2663) [`7020545`](https://github.com/shopware/frontends/commit/70205458cb9357a068029d0aaef41898ab94b354) Thanks [@mkucmus](https://github.com/mkucmus)! - A linked CMS image always has an accessible name now. `CmsElementImage` names the link with the element's `ariaLabel`, then the media title, then a generic fallback translatable through `cms.image.linkWithoutLabel`. A decorative image skips the media title.
567
+ New translation key: `product.notAddedToCart`. Variant parents now use `product.details`. Add both to your locale files.
552
568
 
553
- - Updated dependencies [[`2ddf156`](https://github.com/shopware/frontends/commit/2ddf156805b2941fe2069e78453fb3c4eb6d44ac), [`204c8f4`](https://github.com/shopware/frontends/commit/204c8f45f737e724db6d00b80c5faef8ddb77cb4), [`7020545`](https://github.com/shopware/frontends/commit/70205458cb9357a068029d0aaef41898ab94b354), [`183c183`](https://github.com/shopware/frontends/commit/183c183f905486c27fa770fd0f4cd9993e86c20e), [`458494e`](https://github.com/shopware/frontends/commit/458494e8bd2be88d4fbf161636a109c8f4efc443), [`183c183`](https://github.com/shopware/frontends/commit/183c183f905486c27fa770fd0f4cd9993e86c20e), [`183c183`](https://github.com/shopware/frontends/commit/183c183f905486c27fa770fd0f4cd9993e86c20e), [`8913956`](https://github.com/shopware/frontends/commit/89139563924163e57cafdd9770fe603f2dbd8cba), [`458494e`](https://github.com/shopware/frontends/commit/458494e8bd2be88d4fbf161636a109c8f4efc443)]:
554
- - @shopware/helpers@1.8.0
555
- - @shopware/composables@1.13.0
556
- - @shopware/api-client@1.6.0
569
+ - Updated dependencies [[`44ece9d`](https://github.com/shopware/frontends/commit/44ece9dac2e4d0248c2270f7eff496c258632f5b), [`46d6daa`](https://github.com/shopware/frontends/commit/46d6daade81e3d9e443f3badfeba2ca0e2751477), [`7dbca8b`](https://github.com/shopware/frontends/commit/7dbca8bf8bca1225b365bc03e4dbc07d3764c2e1), [`74a477b`](https://github.com/shopware/frontends/commit/74a477bfa88e3c518d3a0336e4d301c823122ba8), [`74a477b`](https://github.com/shopware/frontends/commit/74a477bfa88e3c518d3a0336e4d301c823122ba8), [`5961f55`](https://github.com/shopware/frontends/commit/5961f55b9f7cebad626cc073ae0bf857aa26f91b), [`c78188a`](https://github.com/shopware/frontends/commit/c78188ac6c95ae19b070f5ea3361a60f21dff281), [`0df4c17`](https://github.com/shopware/frontends/commit/0df4c17b18ec38fc4d0c33f1cb6396f8f532a65d), [`4b43e64`](https://github.com/shopware/frontends/commit/4b43e64a8d78be6eca1f8d9c24140e046193af41)]:
570
+ - @shopware/api-client@1.7.0
571
+ - @shopware/composables@1.14.0
@@ -6,7 +6,6 @@ import {
6
6
  urlIsAbsolute,
7
7
  } from "@shopware/helpers";
8
8
  import { computed } from "vue";
9
- import { RouterLink } from "vue-router";
10
9
 
11
10
  import { useUrlResolver } from "#imports";
12
11
  import type { Schemas } from "#shopware";
@@ -48,7 +47,7 @@ const hasChildren = computed(() => {
48
47
  class="self-stretch py-3 border-b border-outline-outline-variant inline-flex justify-start items-center gap-1"
49
48
  >
50
49
  <div class="flex-1 flex justify-start items-center gap-2.5">
51
- <RouterLink
50
+ <NuxtLink
52
51
  v-if="!urlIsAbsolute(url.path)"
53
52
  :to="url"
54
53
  :class="[
@@ -56,7 +55,7 @@ const hasChildren = computed(() => {
56
55
  ]"
57
56
  >
58
57
  {{ getTranslatedProperty(navigationElement, "name") }}
59
- </RouterLink>
58
+ </NuxtLink>
60
59
  <a
61
60
  v-else
62
61
  :href="url.path"
@@ -93,7 +92,7 @@ const hasChildren = computed(() => {
93
92
  class="self-stretch pl-4 py-1.5 inline-flex justify-start items-center gap-2"
94
93
  >
95
94
  <div class="py-0.5 flex-1 flex justify-start items-center gap-2.5">
96
- <RouterLink
95
+ <NuxtLink
97
96
  v-if="!urlIsAbsolute(url.path)"
98
97
  :to="url"
99
98
  :class="[
@@ -102,7 +101,7 @@ const hasChildren = computed(() => {
102
101
  ]"
103
102
  >
104
103
  {{ getTranslatedProperty(navigationElement, "name") }}
105
- </RouterLink>
104
+ </NuxtLink>
106
105
  <a
107
106
  v-else
108
107
  :href="url.path"
@@ -1,7 +1,7 @@
1
1
  <script lang="ts" setup>
2
2
  import { Bounds, OrbitControls, useGLTF } from "@tresjs/cientos";
3
3
  import { TresCanvas } from "@tresjs/core";
4
- import { BasicShadowMap, NoToneMapping, SRGBColorSpace } from "three";
4
+ import { BasicShadowMap, NoToneMapping, SRGBColorSpace, Vector3 } from "three";
5
5
  import { computed, shallowRef } from "vue";
6
6
 
7
7
  const props = defineProps<{
@@ -20,6 +20,8 @@ const gl = {
20
20
 
21
21
  const { state } = await useGLTF(props.src);
22
22
  const model = computed(() => state.value?.scene);
23
+ const cameraPosition = new Vector3(0, 0, 500);
24
+ const lightPosition = new Vector3(3, 3, 3);
23
25
 
24
26
  const boundsRef = shallowRef();
25
27
 
@@ -29,12 +31,15 @@ function focusObject() {
29
31
  </script>
30
32
  <template>
31
33
  <TresCanvas v-bind="gl">
32
- <TresPerspectiveCamera :args="[75, 1, 0.1, 2000]" :position="[0, 0, 500]" />
34
+ <TresPerspectiveCamera
35
+ :args="[75, 1, 0.1, 2000]"
36
+ :position="cameraPosition"
37
+ />
33
38
  <OrbitControls make-default />
34
39
  <Bounds ref="boundsRef" clip use-mounted>
35
40
  <primitive v-if="model" :object="model" @click="focusObject" />
36
41
  </Bounds>
37
- <TresDirectionalLight :position="[3, 3, 3]" :intensity="1" />
42
+ <TresDirectionalLight :position="lightPosition" :intensity="1" />
38
43
  <TresAmbientLight :intensity="2" />
39
44
  </TresCanvas>
40
45
  </template>
@@ -22,6 +22,8 @@ const props = defineProps<{
22
22
  type Translations = {
23
23
  product: {
24
24
  addedToCart: string;
25
+ notAddedToCart: string;
26
+ viewCart: string;
25
27
  qty: string;
26
28
  addToCart: string;
27
29
  productNumber: string;
@@ -34,6 +36,8 @@ type Translations = {
34
36
  let translations: Translations = {
35
37
  product: {
36
38
  addedToCart: "has been added to cart.",
39
+ notAddedToCart: "could not be added to cart.",
40
+ viewCart: "View cart",
37
41
  qty: "Qty",
38
42
  addToCart: "Add to cart",
39
43
  productNumber: "Product number",
@@ -56,7 +60,7 @@ const restockTime = computed(() => product.value?.restockTime);
56
60
  const productNumber = computed(() => product.value?.productNumber ?? "");
57
61
 
58
62
  const addToCartProxy = async () => {
59
- await addToCart();
63
+ const cart = await addToCart();
60
64
  const errors = getErrorsCodes();
61
65
  for (const element of errors) {
62
66
  const { messageKey, params } = resolveCartError(element);
@@ -64,10 +68,28 @@ const addToCartProxy = async () => {
64
68
  pushError(getCmsTranslate(translations.errors[messageKey], params));
65
69
  }
66
70
 
67
- if (!errors.length)
68
- pushSuccess(
69
- `${props.product?.translated.name} ${translations.product.addedToCart}`,
71
+ if (errors.length) return;
72
+
73
+ // Shopware drops some items without a cart error, e.g. a variant parent.
74
+ const added =
75
+ cart?.lineItems?.some((item) => item.referencedId === product.value.id) ??
76
+ true;
77
+ if (!added) {
78
+ pushError(
79
+ `${props.product?.translated.name} ${translations.product.notAddedToCart}`,
70
80
  );
81
+ return;
82
+ }
83
+
84
+ pushSuccess(
85
+ `${props.product?.translated.name} ${translations.product.addedToCart}`,
86
+ {
87
+ action: {
88
+ label: translations.product.viewCart,
89
+ to: "/checkout/cart",
90
+ },
91
+ },
92
+ );
71
93
  };
72
94
  </script>
73
95
 
@@ -92,6 +114,7 @@ const addToCartProxy = async () => {
92
114
  :disabled="!product?.available"
93
115
  block
94
116
  data-testid="add-to-cart-button"
117
+ :data-product-id="product?.id"
95
118
  @click="addToCartProxy"
96
119
  >
97
120
  {{ translations.product.addToCart }}
@@ -47,6 +47,8 @@ type Translations = {
47
47
  reason: string;
48
48
  cannotAddToWishlist: string;
49
49
  addedToCart: string;
50
+ notAddedToCart: string;
51
+ viewCart: string;
50
52
  addToCart: string;
51
53
  details: string;
52
54
  badges: {
@@ -67,6 +69,8 @@ let translations: Translations = {
67
69
  reason: "Reason",
68
70
  cannotAddToWishlist: "cannot be added to wishlist.",
69
71
  addedToCart: "has been added to cart.",
72
+ notAddedToCart: "could not be added to cart.",
73
+ viewCart: "View cart",
70
74
  addToCart: "Add to cart",
71
75
  details: "Details",
72
76
  badges: {
@@ -125,7 +129,7 @@ const toggleWishlistProduct = async () => {
125
129
  };
126
130
 
127
131
  const addToCartProxy = async () => {
128
- await addToCart();
132
+ const cart = await addToCart();
129
133
  const errors = getErrorsCodes();
130
134
  for (const element of errors) {
131
135
  const { messageKey, params } = resolveCartError(element);
@@ -133,10 +137,28 @@ const addToCartProxy = async () => {
133
137
  pushError(getCmsTranslate(translations.errors[messageKey], params));
134
138
  }
135
139
 
136
- if (!errors.length)
137
- pushSuccess(
138
- `${product?.value.translated.name} ${translations.product.addedToCart}`,
140
+ if (errors.length) return;
141
+
142
+ // Shopware drops some items without a cart error, e.g. a variant parent.
143
+ const added =
144
+ cart?.lineItems?.some((item) => item.referencedId === product.value.id) ??
145
+ true;
146
+ if (!added) {
147
+ pushError(
148
+ `${product?.value.translated.name} ${translations.product.notAddedToCart}`,
139
149
  );
150
+ return;
151
+ }
152
+
153
+ pushSuccess(
154
+ `${product?.value.translated.name} ${translations.product.addedToCart}`,
155
+ {
156
+ action: {
157
+ label: translations.product.viewCart,
158
+ to: "/checkout/cart",
159
+ },
160
+ },
161
+ );
140
162
  };
141
163
 
142
164
  const fromPrice = getProductFromPrice(product.value);
@@ -1,10 +1,13 @@
1
1
  <script setup lang="ts">
2
2
  import type { BoxLayout } from "@shopware/composables";
3
+ import { useProductPrice } from "@shopware/composables";
3
4
  import type { UrlRouteOutput } from "@shopware/helpers";
4
- import { computed } from "vue";
5
+ import { computed, toRef } from "vue";
5
6
 
6
7
  import type { Schemas } from "#shopware";
7
8
 
9
+ import { shouldShowBuyButton } from "../helpers/product/shouldShowBuyButton";
10
+
8
11
  type Translations = {
9
12
  product: {
10
13
  addToCart: string;
@@ -27,6 +30,10 @@ const props = defineProps<{
27
30
  }>();
28
31
 
29
32
  const isMinimalLayout = computed(() => props.layoutType === "minimal");
33
+ const { displayFrom } = useProductPrice(toRef(props, "product"));
34
+ const showBuyButton = computed(() =>
35
+ shouldShowBuyButton(props.product, displayFrom.value),
36
+ );
30
37
  </script>
31
38
  <template>
32
39
  <div
@@ -72,7 +79,7 @@ const isMinimalLayout = computed(() => props.layoutType === "minimal");
72
79
  <template v-if="!isMinimalLayout">
73
80
  <SwBaseButton
74
81
  variant="primary"
75
- v-if="!fromPrice"
82
+ v-if="showBuyButton"
76
83
  size="medium"
77
84
  :disabled="!product?.available"
78
85
  block
@@ -72,7 +72,7 @@ const handleSelectValue = ({
72
72
  };
73
73
  </script>
74
74
  <template>
75
- <div>
75
+ <div :data-testid="`listing-filter-${filter.code}`">
76
76
  <component
77
77
  :is="filterComponent"
78
78
  :filter="filter"
@@ -4,23 +4,9 @@ import type {
4
4
  CmsElementSidebarFilter,
5
5
  } from "@shopware/composables";
6
6
  import { useCmsTranslations } from "@shopware/composables";
7
- import { getCategoryFilterPostFilter } from "@shopware/helpers";
8
7
  import { defu } from "defu";
9
- import { computed } from "vue";
10
- import type { ComputedRef } from "vue";
11
- import type { LocationQueryRaw } from "vue-router";
12
8
 
13
- import {
14
- firstQueryValue,
15
- getVisibleListingFilters,
16
- toNumber,
17
- useCategoryListing,
18
- useProductSearchListing,
19
- useRoute,
20
- useRouter,
21
- useSelectedListingFilters,
22
- } from "#imports";
23
- import type { Schemas, operations } from "#shopware";
9
+ import { useListingFilters } from "#imports";
24
10
 
25
11
  const props = defineProps<{
26
12
  content?: CmsElementProductListing | CmsElementSidebarFilter;
@@ -47,216 +33,18 @@ let translations: Translations = {
47
33
 
48
34
  translations = defu(useCmsTranslations(), translations) as Translations;
49
35
 
50
- const route = useRoute();
51
- const router = useRouter();
52
-
53
36
  const {
54
- changeCurrentSortingOrder,
55
- getCurrentFilters,
37
+ currentSortingOrder,
56
38
  getCurrentSortingOrder,
57
- getInitialFilters,
58
39
  getSortingOrders,
59
- search,
60
- } = isProductSearch ? useProductSearchListing() : useCategoryListing();
61
-
62
- const sidebarSelectedFilters = useSelectedListingFilters();
63
-
64
- // Only the filters this listing can actually apply - the category filter is
65
- // search-only, matching the post-filter and URL gates below.
66
- const visibleFilters = computed(() =>
67
- getVisibleListingFilters(getInitialFilters.value, { isProductSearch }),
68
- );
69
-
70
- const showResetFiltersButton = computed<boolean>(() => {
71
- if (
72
- sidebarSelectedFilters.manufacturer.size !== 0 ||
73
- sidebarSelectedFilters.properties.size !== 0 ||
74
- (isProductSearch && sidebarSelectedFilters.categories.size !== 0) ||
75
- sidebarSelectedFilters["max-price"] ||
76
- sidebarSelectedFilters["min-price"] ||
77
- sidebarSelectedFilters.rating ||
78
- sidebarSelectedFilters["shipping-free"]
79
- ) {
80
- return true;
81
- }
82
-
83
- return false;
84
- });
85
-
86
- const searchCriteriaForRequest: ComputedRef<Schemas["ProductListingCriteria"]> =
87
- computed(() => ({
88
- manufacturer: [
89
- ...(sidebarSelectedFilters.manufacturer as Set<string>),
90
- ]?.join("|"),
91
- properties: [...(sidebarSelectedFilters.properties as Set<string>)]?.join(
92
- "|",
93
- ),
94
- // Category selection travels as a post-filter so the category
95
- // aggregation itself is not reduced (faceted behavior). Search pages
96
- // only: category pages never offer this filter, so a stale
97
- // ?categories= param must not silently narrow them.
98
- ...(isProductSearch && sidebarSelectedFilters.categories.size > 0
99
- ? {
100
- "post-filter": [
101
- getCategoryFilterPostFilter([
102
- ...(sidebarSelectedFilters.categories as Set<string>),
103
- ]),
104
- ],
105
- }
106
- : {}),
107
- "min-price": sidebarSelectedFilters["min-price"] as number,
108
- "max-price": sidebarSelectedFilters["max-price"] as number,
109
- order: getCurrentSortingOrder.value as string,
110
- "shipping-free": sidebarSelectedFilters["shipping-free"] as boolean,
111
- rating: sidebarSelectedFilters.rating as number,
112
- search: isProductSearch ? (firstQueryValue(route.query.search) ?? "") : "",
113
- limit: toNumber(firstQueryValue(route.query.limit)) ?? 15,
114
- }));
115
-
116
- const handleFilterChange = async (event: {
117
- code: string;
118
- value: string | number | boolean;
119
- }) => {
120
- try {
121
- const { code, value } = event;
122
-
123
- if (
124
- code === "manufacturer" ||
125
- code === "properties" ||
126
- code === "categories"
127
- ) {
128
- const filterSet = sidebarSelectedFilters[code];
129
- const stringValue = String(value);
130
-
131
- if (filterSet.has(stringValue)) {
132
- filterSet.delete(stringValue);
133
- } else {
134
- filterSet.add(stringValue);
135
- }
136
- } else if (code === "min-price" || code === "max-price") {
137
- sidebarSelectedFilters[code] =
138
- typeof value === "number" ? value : Number(value);
139
- } else if (code === "rating") {
140
- sidebarSelectedFilters.rating = Number(value);
141
- } else if (code === "shipping-free") {
142
- sidebarSelectedFilters["shipping-free"] = Boolean(value);
143
- }
144
-
145
- await executeSearch();
146
- } catch (error) {
147
- console.error("Filter update failed:", error);
148
- }
149
- };
150
-
151
- const executeSearch = async () => {
152
- try {
153
- // Search context refetches from the URL (the page's useAsyncData watches
154
- // route.query), so navigating below is enough; calling search() here too
155
- // would double-fetch and flicker. Category listings have no URL watcher.
156
- if (!isProductSearch) {
157
- await search(searchCriteriaForRequest.value);
158
- }
159
-
160
- // Build query directly from searchCriteriaForRequest which already has pipe-separated strings
161
- const criteria = searchCriteriaForRequest.value;
162
- const query: Record<string, unknown> = {};
163
-
164
- if (criteria.manufacturer) query.manufacturer = criteria.manufacturer;
165
- if (criteria.properties) query.properties = criteria.properties;
166
- if (isProductSearch && sidebarSelectedFilters.categories.size > 0)
167
- query.categories = [
168
- ...(sidebarSelectedFilters.categories as Set<string>),
169
- ].join("|");
170
- if (criteria["min-price"]) query["min-price"] = criteria["min-price"];
171
- if (criteria["max-price"]) query["max-price"] = criteria["max-price"];
172
- if (criteria.rating) query.rating = criteria.rating;
173
- if (criteria["shipping-free"])
174
- query["shipping-free"] = criteria["shipping-free"];
175
- if (criteria.order) query.order = criteria.order;
176
- // Keep the search term in the URL so filtering on the search page does not reset the query
177
- if (isProductSearch && route.query.search)
178
- query.search = route.query.search;
179
- // Preserve the page-size selection; rebuilding the query from scratch would
180
- // otherwise drop `limit` and revert to the default on refresh/navigation.
181
- if (route.query.limit) query.limit = route.query.limit;
182
-
183
- await router.push({
184
- query: query as LocationQueryRaw,
185
- });
186
- } catch (error) {
187
- console.error("Search execution failed:", error);
188
- }
189
- };
190
-
191
- const clearFilters = () => {
192
- (sidebarSelectedFilters.manufacturer as Set<string>).clear();
193
- (sidebarSelectedFilters.properties as Set<string>).clear();
194
- (sidebarSelectedFilters.categories as Set<string>).clear();
195
- sidebarSelectedFilters["min-price"] = undefined;
196
- sidebarSelectedFilters["max-price"] = undefined;
197
- sidebarSelectedFilters.rating = undefined;
198
- sidebarSelectedFilters["shipping-free"] = undefined;
199
- };
200
-
201
- const currentSortingOrder = computed({
202
- get: (): string => getCurrentSortingOrder.value || "",
203
- set: async (order: string): Promise<void> => {
204
- try {
205
- await router.push({
206
- query: {
207
- ...route.query,
208
- order,
209
- },
210
- });
211
-
212
- // Search context refetches from the URL; category fetches directly.
213
- if (!isProductSearch) {
214
- await changeCurrentSortingOrder(order, {
215
- ...(route.query as unknown as operations["searchPage post /search"]["body"]),
216
- limit: toNumber(firstQueryValue(route.query.limit)) ?? 15,
217
- });
218
- }
219
- } catch (error) {
220
- console.error("Sorting order change failed:", error);
221
- }
222
- },
223
- });
224
-
225
- async function invokeCleanFilters() {
226
- try {
227
- clearFilters();
228
- await executeSearch();
229
- } catch (error) {
230
- console.error("Clear filters failed:", error);
231
- }
232
- }
233
-
234
- const handleSortChange = (sortKey: string) => {
235
- currentSortingOrder.value = sortKey;
236
- };
237
-
238
- const handleRemoveFilterChip = async (chip: {
239
- code: string;
240
- value: string | number;
241
- }) => {
242
- if (
243
- chip.code === "properties" ||
244
- chip.code === "manufacturer" ||
245
- chip.code === "categories"
246
- ) {
247
- const filterSet = sidebarSelectedFilters[chip.code] as Set<string>;
248
- filterSet.delete(String(chip.value));
249
- } else if (chip.code === "price") {
250
- sidebarSelectedFilters["min-price"] = undefined;
251
- sidebarSelectedFilters["max-price"] = undefined;
252
- } else if (chip.code === "rating") {
253
- sidebarSelectedFilters.rating = undefined;
254
- } else if (chip.code === "shipping-free") {
255
- sidebarSelectedFilters["shipping-free"] = undefined;
256
- }
257
-
258
- await executeSearch();
259
- };
40
+ handleFilterChange,
41
+ handleRemoveFilterChip,
42
+ handleSortChange,
43
+ invokeCleanFilters,
44
+ selectedFilters: sidebarSelectedFilters,
45
+ showResetFiltersButton,
46
+ visibleFilters,
47
+ } = useListingFilters(isProductSearch);
260
48
  </script>
261
49
  <template>
262
50
  <div>