@shopware/cms-base-layer 3.1.0 → 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 (37) hide show
  1. package/README.md +64 -23
  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/SwProductCardImage.vue +5 -1
  8. package/app/components/SwProductListingFilter.vue +5 -0
  9. package/app/components/SwProductListingFilters.vue +40 -4
  10. package/app/components/SwProductListingFiltersHorizontal.vue +37 -2
  11. package/app/components/SwStockInfo.vue +5 -2
  12. package/app/components/SwVariantConfigurator.vue +8 -3
  13. package/app/components/listing-filters/SwFilterCategories.vue +158 -0
  14. package/app/components/public/cms/FrontendAccountCustomerGroupRegistrationPage.vue +1 -1
  15. package/app/components/public/cms/element/CmsElementBuyBox.vue +4 -1
  16. package/app/components/public/cms/element/CmsElementCrossSelling.vue +2 -1
  17. package/app/components/public/cms/element/CmsElementHtml.vue +1 -1
  18. package/app/components/public/cms/element/CmsElementImage.vue +41 -1
  19. package/app/components/public/cms/element/CmsElementImageGallery.vue +2 -1
  20. package/app/components/public/cms/element/CmsElementProductDescriptionReviews.vue +2 -2
  21. package/app/components/public/cms/element/CmsElementText.md +77 -0
  22. package/app/components/public/cms/element/CmsElementText.vue +5 -32
  23. package/app/helpers/html-to-vue/renderToHtml.test.ts +55 -0
  24. package/app/helpers/html-to-vue/renderToHtml.ts +5 -1
  25. package/app/helpers/html-to-vue/renderer.ts +10 -1
  26. package/app/utils/listingFilters.test.ts +39 -0
  27. package/app/utils/listingFilters.ts +19 -0
  28. package/app/utils/useSelectedListingFilters.test.ts +13 -3
  29. package/app/utils/useSelectedListingFilters.ts +7 -1
  30. package/component-dirs.json +20 -0
  31. package/index.d.ts +2 -2
  32. package/nuxt.config.ts +11 -28
  33. package/package.json +20 -23
  34. package/dist/index.d.mts +0 -5
  35. package/dist/index.d.ts +0 -5
  36. package/dist/index.mjs +0 -31
  37. package/index.cjs +0 -7
package/README.md CHANGED
@@ -192,10 +192,18 @@ Replace standard `<img>` tags with `<NuxtImg>` to enable automatic optimization:
192
192
  :src="product.cover.media.url"
193
193
  preset="productDetail"
194
194
  :width="800"
195
- :alt="product.cover.media.alt"
195
+ :alt="getTranslatedProperty(product.cover.media, 'alt')"
196
196
  />
197
197
  ```
198
198
 
199
+ The media `alt` text is a translatable field, so read it through `getTranslatedProperty` rather than
200
+ `media.alt` — the latter always holds the system language value, no matter which language the current
201
+ request asks for. The helper is not auto-imported, so add it to the component's script block:
202
+
203
+ ```ts
204
+ import { getTranslatedProperty } from "@shopware/helpers";
205
+ ```
206
+
199
207
  ### Supported Modifiers
200
208
 
201
209
  Shopware supports the following URL parameters for image transformation:
@@ -350,7 +358,7 @@ url("https://cdn.shopware.store/.../image.jpg?width=1000&fit=crop,smart&format=w
350
358
 
351
359
  ## LCP Image Preload
352
360
 
353
- This layer includes a `useLcpImagePreload` composable that automatically preloads the first image found in CMS page content. This targets the [Largest Contentful Paint (LCP)](https://web.dev/lcp/) element, which is often a hero background image or the first visible image element.
361
+ This layer includes a `useLcpImagePreload` composable that preloads the first image found in CMS page content when enabled via `lcpImagePreload: true` in your `app.config.ts` (disabled by default). This targets the [Largest Contentful Paint (LCP)](https://web.dev/lcp/) element, which is often a hero background image or the first visible image element.
354
362
 
355
363
  ### How it works
356
364
 
@@ -364,12 +372,10 @@ The first image found is injected as a `<link rel="preload" as="image" fetchprio
364
372
 
365
373
  ### Usage
366
374
 
367
- The composable is already called in `CmsPage.vue`. If you override `CmsPage`, you can use it in your custom component:
375
+ The composable is already called in `CmsPage.vue`. If you override `CmsPage`, you can use it in your custom component. It is auto-imported once your app extends this layer, so it needs no import statement:
368
376
 
369
377
  ```vue
370
378
  <script setup>
371
- import { useLcpImagePreload } from "@shopware/cms-base-layer/composables/useLcpImagePreload";
372
-
373
379
  const props = defineProps<{ content: Schemas["CmsPage"] }>();
374
380
 
375
381
  useLcpImagePreload(props.content?.sections || []);
@@ -384,7 +390,7 @@ Images are optimized to prevent the browser from downloading images larger than
384
390
 
385
391
  ### Product Card Images (`SwProductCardImage`)
386
392
 
387
- The `productCard` preset only defines URL modifiers (format/quality/fit). `width`/`height`/`densities`/`loading` stay on the component — NuxtImg presets don't propagate these reliably:
393
+ The `productCard` preset only defines URL modifiers (format/quality/fit). `width`/`height` and `loading` stay on the component: in `@nuxt/image` 2.1.0 a preset carries `width`/`height` only as `modifiers`, which shape the URL rather than the rendered attributes, and `loading` is not a preset field at all. `densities` is kept alongside them for consistency, though a preset would propagate it:
388
394
 
389
395
  ```ts
390
396
  // nuxt.config.ts
@@ -448,9 +454,9 @@ The list of available blocks and elements is [here](https://developer.shopware.c
448
454
 
449
455
  The procedure is:
450
456
 
451
- - find a component in component's [list](https://developer.shopware.com/frontends/packages/cms-base.html#available-components), using a [Vue devtools](https://devtools.vuejs.org/) or browsing the github [repository](https://github.com/shopware/frontends/tree/main/packages/cms-base-layer/app/components)
457
+ - find a component in component's [list](https://developer.shopware.com/frontends/packages/cms-base-layer.html#available-components), using a [Vue devtools](https://devtools.vuejs.org/) or browsing the github [repository](https://github.com/shopware/frontends/tree/main/packages/cms-base-layer/app/components)
452
458
  - take its name
453
- - create a file with the same name and place it into `~/components` dir in your nuxt project (or wherever according your nuxt config)
459
+ - create a file with the same name and place it under a `components` directory that your `nuxt.config.ts` registers with `global: true` — CMS components are looked up with `resolveComponent`, so an override outside a global path is never found and this layer's version keeps rendering with no error. In the starter template that directory is `app/components/cms/`; because it is registered with `pathPrefix: false`, the name comes from the filename alone and subdirectory depth under it does not matter.
454
460
 
455
461
  ✅ Thanks to this, nuxt will take the component registered in your app instead of the one registered by this nuxt layer.
456
462
 
@@ -485,31 +491,66 @@ No additional packages needed to be installed.
485
491
 
486
492
  Full changelog for stable version is available [here](https://github.com/shopware/frontends/blob/main/packages/cms-base-layer/CHANGELOG.md)
487
493
 
488
- ### Latest changes: 3.1.0
494
+ ### Latest changes: 4.0.0
495
+
496
+ ### Major Changes
497
+
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.
499
+
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.
504
+
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`:
509
+
510
+ ```ts
511
+ export default defineNuxtConfig({
512
+ extends: ["@shopware/cms-base-layer"],
513
+ });
514
+ ```
515
+
516
+ Other changes:
517
+
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.
489
528
 
490
529
  ### Minor Changes
491
530
 
492
- - [#2503](https://github.com/shopware/frontends/pull/2503) [`1b0ccc9`](https://github.com/shopware/frontends/commit/1b0ccc95fcef89de42931155fb40119b9075d21b) Thanks [@mkucmus](https://github.com/mkucmus)! - `SwProductListingFilters` and `SwProductListingFiltersHorizontal`: honor the `listingType` prop. When set to `productSearchListing` the components drive `useProductSearchListing` instead of `useCategoryListing` and keep the active search term in the request criteria and resulting URL, so the shared filter sidebar can be reused on the product search page without resetting the query. Category listing behavior is unchanged.
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.
493
532
 
494
533
  ### Patch Changes
495
534
 
496
- - [#2593](https://github.com/shopware/frontends/pull/2593) [`760635c`](https://github.com/shopware/frontends/commit/760635cdbaf3c80ae99fbe0f42a31a8ebaa14675) Thanks [@patzick](https://github.com/patzick)! - Stop registering the `@unocss/nuxt` module from the CMS layer. UnoCSS is not a dependency of this package anymore (it lives in `@shopware/unocss-design-tokens-layer`), so registering the module made every app that doesn't install UnoCSS itself fail to build with `Could not load @unocss/nuxt. Is it installed?` - for example the blank template. Apps that want the shared UnoCSS setup keep getting it from `@shopware/unocss-design-tokens-layer` or their own `@unocss/nuxt` registration.
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.
536
+
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`).
497
538
 
498
- - [#2568](https://github.com/shopware/frontends/pull/2568) [`6315350`](https://github.com/shopware/frontends/commit/6315350add0464abef153343897d42f5808f2003) Thanks [@patzick](https://github.com/patzick)! - Add cacheable GET support for category details and product reviews while preserving the existing POST behavior when `cacheableReads` is disabled.
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.
499
540
 
500
- - `useCategorySearch.search` now calls `GET /category/{navigationId}` and sends the complete encoded Criteria in the `_criteria` query parameter when cacheable reads are enabled.
501
- - `useProductReviews.loadProductReviews` now calls `GET /product/{productId}/reviews` and sends its encoded Criteria in `_criteria` when cacheable reads are enabled.
502
- - The CMS product description reviews element follows the same flag and endpoint behavior when it needs to fetch reviews directly.
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.
503
542
 
504
- - [#2571](https://github.com/shopware/frontends/pull/2571) [`c64ae1c`](https://github.com/shopware/frontends/commit/c64ae1c6e2d11e33bd52df4b55177cc26b646433) Thanks [@patzick](https://github.com/patzick)! - Show the category a shopper came from in product breadcrumbs, while direct product links continue to use the default category.
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.
505
546
 
506
- - [#2596](https://github.com/shopware/frontends/pull/2596) [`6572aa8`](https://github.com/shopware/frontends/commit/6572aa84431e1f4a34d6cf04e549037692d638a6) Thanks [@patzick](https://github.com/patzick)! - Reduce Rolldown/Vite build noise: scope Nuxt global components, keep the three.js async chunk warning intentional, avoid shipping full carbon icon JSON via UnoCSS runtime, and bump @vueuse to 14.4.0.
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`.
507
548
 
508
- - [#2592](https://github.com/shopware/frontends/pull/2592) [`fcc3d71`](https://github.com/shopware/frontends/commit/fcc3d71af05c2f2d409aa63ecb562714e6bc0e69) Thanks [@patzick](https://github.com/patzick)! - Replace CMS layer `@apply` styles with plain CSS declarations to avoid Nuxt build warnings.
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.
509
550
 
510
- - [#2568](https://github.com/shopware/frontends/pull/2568) [`6315350`](https://github.com/shopware/frontends/commit/6315350add0464abef153343897d42f5808f2003) Thanks [@patzick](https://github.com/patzick)! - Preserve CMS image aspect ratios when responsive dimensions are calculated after hydration, preventing visible image scaling as optimized sources load.
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.
511
552
 
512
- - Updated dependencies [[`6315350`](https://github.com/shopware/frontends/commit/6315350add0464abef153343897d42f5808f2003), [`b767721`](https://github.com/shopware/frontends/commit/b767721847bf3391f9067eca7a045089fb22fce0), [`f16c5a0`](https://github.com/shopware/frontends/commit/f16c5a0785d6187b73c3edcf37feab7c90bd7988), [`978b02c`](https://github.com/shopware/frontends/commit/978b02c969ca4b16f5fc1d7a953ec4cce3d98173), [`6572aa8`](https://github.com/shopware/frontends/commit/6572aa84431e1f4a34d6cf04e549037692d638a6), [`744833b`](https://github.com/shopware/frontends/commit/744833b9d7d2f8ea1f5dfe65be3fa554dbe4a09f), [`e03c91b`](https://github.com/shopware/frontends/commit/e03c91be172374894d90b7a0111855b76719fee1), [`33facb1`](https://github.com/shopware/frontends/commit/33facb178792c8cb26b47ab984ac48c08ab4b72b), [`9137475`](https://github.com/shopware/frontends/commit/91374753cedb2034385f642e6af11314f2971caa), [`474d3fe`](https://github.com/shopware/frontends/commit/474d3fed346816135b0c7c797990b215a8b691c0), [`38379a5`](https://github.com/shopware/frontends/commit/38379a5d52ab09008774533ef417ebe2cde3f7fd)]:
513
- - @shopware/composables@1.12.1
514
- - @shopware/api-client@1.5.1
515
- - @shopware/helpers@1.7.2
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
package/app/app.config.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { defineAppConfig } from "#imports";
1
+ import type { AppConfigInput } from "nuxt/schema";
2
2
 
3
- export default defineAppConfig({
3
+ export default {
4
4
  imagePlaceholder: {
5
5
  color: "#543B95",
6
6
  },
@@ -16,4 +16,4 @@ export default defineAppConfig({
16
16
  default: "(max-width: 768px) 50vw, 25vw",
17
17
  },
18
18
  unocssRuntime: true,
19
- });
19
+ } satisfies AppConfigInput;
@@ -0,0 +1,80 @@
1
+ .cms-element-text {
2
+ word-break: break-word;
3
+
4
+ :where(h1, h2, h3, h4, h5, h6) {
5
+ margin-bottom: var(--rte-heading-mb, 10px);
6
+ font-weight: var(--rte-heading-weight, 600);
7
+ }
8
+
9
+ :where(h1) {
10
+ font-size: var(--rte-h1-size, 2.25rem);
11
+ line-height: var(--rte-h1-line, 2.5rem);
12
+ }
13
+
14
+ :where(h2) {
15
+ font-size: var(--rte-h2-size, 1.75rem);
16
+ line-height: var(--rte-h2-line, 2rem);
17
+ }
18
+
19
+ :where(h3) {
20
+ font-size: var(--rte-h3-size, 1.25rem);
21
+ line-height: var(--rte-h3-line, 1.5rem);
22
+ }
23
+
24
+ :where(h4, h5, h6) {
25
+ font-size: var(--rte-h4-size, 1.125rem);
26
+ line-height: var(--rte-h4-line, 1.5rem);
27
+ }
28
+
29
+ :where(p, blockquote, pre) {
30
+ margin-bottom: var(--rte-flow, 1rem);
31
+ }
32
+
33
+ :where(ol, ul, dl) {
34
+ list-style-type: disc;
35
+ padding-left: var(--rte-list-indent, 40px);
36
+ margin-top: 0;
37
+ margin-bottom: var(--rte-flow, 1rem);
38
+ }
39
+
40
+ :where(ol) {
41
+ list-style-type: decimal;
42
+ }
43
+
44
+ :where(img, iframe, video) {
45
+ max-width: 100%;
46
+ }
47
+
48
+ :where(img) {
49
+ height: auto;
50
+ }
51
+
52
+ :where(table) {
53
+ width: 100%;
54
+ margin-bottom: var(--rte-flow, 1rem);
55
+ border-collapse: collapse;
56
+ }
57
+
58
+ :where(th, td) {
59
+ padding: var(--rte-cell-padding, 0.5rem 0.75rem);
60
+ border: 1px solid var(--rte-border-color, #e5e7eb);
61
+ }
62
+
63
+ :where(th, thead td) {
64
+ text-align: left;
65
+ font-weight: var(--rte-heading-weight, 600);
66
+ }
67
+
68
+ :where(hr) {
69
+ margin: var(--rte-flow, 1rem) 0;
70
+ border-top: 1px solid var(--rte-border-color, #e5e7eb);
71
+ }
72
+
73
+ > :last-child {
74
+ margin-bottom: 0;
75
+ }
76
+
77
+ :where(.sw-text-editor-table__col-selector) {
78
+ display: none;
79
+ }
80
+ }
@@ -3,6 +3,7 @@ import { ApiClientError } from "@shopware/api-client";
3
3
  import type { ApiError } from "@shopware/api-client";
4
4
  import type { CmsElementForm } from "@shopware/composables";
5
5
  import { useCmsTranslations } from "@shopware/composables";
6
+ import { getTranslatedProperty } from "@shopware/helpers";
6
7
  import { useVuelidate } from "@vuelidate/core";
7
8
  import { email, minLength, required } from "@vuelidate/validators";
8
9
  import { defu } from "defu";
@@ -189,7 +190,7 @@ const invokeSubmit = async () => {
189
190
  :key="salutation.id"
190
191
  :value="salutation.id"
191
192
  >
192
- {{ salutation.displayName }}
193
+ {{ getTranslatedProperty(salutation, "displayName") }}
193
194
  </option>
194
195
  </select>
195
196
  </div>
@@ -6,6 +6,9 @@ import type { Schemas } from "#shopware";
6
6
  type FilterState = {
7
7
  manufacturer: Set<string>;
8
8
  properties: Set<string>;
9
+ // Optional: consumers building their own filter-state object predate this
10
+ // field, so a missing one must render no category chips, not throw.
11
+ categories?: Set<string>;
9
12
  "min-price": number | undefined;
10
13
  "max-price": number | undefined;
11
14
  rating: number | undefined;
@@ -70,13 +73,14 @@ const activeChips = computed(() => {
70
73
  }
71
74
 
72
75
  // Add manufacturer filters
73
- const manufacturers = Array.from(props.filters.manufacturer);
74
- for (const manufacturerId of manufacturers) {
75
- const filter = props.availableFilters.find(
76
- (f) => f.code === "manufacturer",
77
- );
78
- if (filter && "entities" in filter && filter.entities) {
79
- const entity = filter.entities.find((e) => e.id === manufacturerId);
76
+ const manufacturerFilter = props.availableFilters.find(
77
+ (f) => f.code === "manufacturer",
78
+ );
79
+ if (manufacturerFilter?.entities) {
80
+ for (const manufacturerId of props.filters.manufacturer) {
81
+ const entity = manufacturerFilter.entities.find(
82
+ (e) => e.id === manufacturerId,
83
+ );
80
84
  const name = getTranslatedName(entity);
81
85
  if (name) {
82
86
  chips.push({
@@ -88,6 +92,24 @@ const activeChips = computed(() => {
88
92
  }
89
93
  }
90
94
 
95
+ // Add category filters
96
+ const categoryFilter = props.availableFilters.find(
97
+ (f) => f.code === "categories",
98
+ );
99
+ if (categoryFilter?.entities) {
100
+ for (const categoryId of props.filters.categories ?? []) {
101
+ const entity = categoryFilter.entities.find((e) => e.id === categoryId);
102
+ const name = getTranslatedName(entity);
103
+ if (name) {
104
+ chips.push({
105
+ label: name,
106
+ code: "categories",
107
+ value: categoryId,
108
+ });
109
+ }
110
+ }
111
+ }
112
+
91
113
  // Add price filters
92
114
  if (props.filters["min-price"] || props.filters["max-price"]) {
93
115
  const min = props.filters["min-price"] || 0;
@@ -3,6 +3,7 @@ import { ApiClientError } from "@shopware/api-client";
3
3
  import type { ApiError } from "@shopware/api-client";
4
4
  import type { CmsElementForm } from "@shopware/composables";
5
5
  import { useCmsTranslations } from "@shopware/composables";
6
+ import { getTranslatedProperty } from "@shopware/helpers";
6
7
  import { useVuelidate } from "@vuelidate/core";
7
8
  import type { ValidationRuleWithoutParams } from "@vuelidate/core";
8
9
  import { email, required } from "@vuelidate/validators";
@@ -247,7 +248,7 @@ const invokeSubmit = async () => {
247
248
  :key="salutation.id"
248
249
  :value="salutation.id"
249
250
  >
250
- {{ salutation.displayName }}
251
+ {{ getTranslatedProperty(salutation, "displayName") }}
251
252
  </option>
252
253
  </select>
253
254
  </div>
@@ -2,6 +2,7 @@
2
2
  import {
3
3
  type UrlRouteOutput,
4
4
  getSmallestThumbnailUrl,
5
+ getTranslatedProperty,
5
6
  isProductOnSale,
6
7
  isProductTopSeller,
7
8
  } from "@shopware/helpers";
@@ -37,7 +38,10 @@ const coverSrcPath = computed(() => {
37
38
  });
38
39
 
39
40
  const coverAlt = computed(() => {
40
- return props.product?.cover?.media?.alt || props.product?.translated?.name;
41
+ return (
42
+ getTranslatedProperty(props.product?.cover?.media, "alt") ||
43
+ props.product?.translated?.name
44
+ );
41
45
  });
42
46
 
43
47
  const isOnSale = computed(() => isProductOnSale(props.product));
@@ -2,6 +2,7 @@
2
2
  import { computed } from "vue";
3
3
  import type { Component } from "vue";
4
4
 
5
+ import SwFilterCategoriesVue from "./listing-filters/SwFilterCategories.vue";
5
6
  import SwFilterPriceVue from "./listing-filters/SwFilterPrice.vue";
6
7
  import SwFilterPropertiesVue from "./listing-filters/SwFilterProperties.vue";
7
8
  import SwFilterRatingVue from "./listing-filters/SwFilterRating.vue";
@@ -11,6 +12,7 @@ const {
11
12
  filter,
12
13
  selectedManufacturer,
13
14
  selectedProperties,
15
+ selectedCategories = new Set<string>(),
14
16
  selectedMinPrice,
15
17
  selectedMaxPrice,
16
18
  selectedRating,
@@ -20,6 +22,7 @@ const {
20
22
  filter: ListingFilter;
21
23
  selectedManufacturer: Set<string>;
22
24
  selectedProperties: Set<string>;
25
+ selectedCategories?: Set<string>;
23
26
  selectedMinPrice: number | undefined;
24
27
  selectedMaxPrice: number | undefined;
25
28
  selectedRating: number | undefined;
@@ -40,11 +43,13 @@ const transformedFilters = computed(() => ({
40
43
  "shipping-free": selectedShippingFree,
41
44
  manufacturer: [...selectedManufacturer],
42
45
  properties: [...selectedProperties],
46
+ categories: [...selectedCategories],
43
47
  }));
44
48
 
45
49
  const filterComponent = computed<Component | undefined>(() => {
46
50
  const componentMap: Record<string, Component> = {
47
51
  manufacturer: SwFilterPropertiesVue,
52
+ categories: SwFilterCategoriesVue,
48
53
  price: SwFilterPriceVue,
49
54
  rating: SwFilterRatingVue,
50
55
  "shipping-free": SwFilterShippingFreeVue,
@@ -4,6 +4,7 @@ import type {
4
4
  CmsElementSidebarFilter,
5
5
  } from "@shopware/composables";
6
6
  import { useCmsTranslations } from "@shopware/composables";
7
+ import { getCategoryFilterPostFilter } from "@shopware/helpers";
7
8
  import { defu } from "defu";
8
9
  import { computed } from "vue";
9
10
  import type { ComputedRef } from "vue";
@@ -11,6 +12,7 @@ import type { LocationQueryRaw } from "vue-router";
11
12
 
12
13
  import {
13
14
  firstQueryValue,
15
+ getVisibleListingFilters,
14
16
  toNumber,
15
17
  useCategoryListing,
16
18
  useProductSearchListing,
@@ -59,10 +61,17 @@ const {
59
61
 
60
62
  const sidebarSelectedFilters = useSelectedListingFilters();
61
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
+
62
70
  const showResetFiltersButton = computed<boolean>(() => {
63
71
  if (
64
72
  sidebarSelectedFilters.manufacturer.size !== 0 ||
65
73
  sidebarSelectedFilters.properties.size !== 0 ||
74
+ (isProductSearch && sidebarSelectedFilters.categories.size !== 0) ||
66
75
  sidebarSelectedFilters["max-price"] ||
67
76
  sidebarSelectedFilters["min-price"] ||
68
77
  sidebarSelectedFilters.rating ||
@@ -82,6 +91,19 @@ const searchCriteriaForRequest: ComputedRef<Schemas["ProductListingCriteria"]> =
82
91
  properties: [...(sidebarSelectedFilters.properties as Set<string>)]?.join(
83
92
  "|",
84
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
+ : {}),
85
107
  "min-price": sidebarSelectedFilters["min-price"] as number,
86
108
  "max-price": sidebarSelectedFilters["max-price"] as number,
87
109
  order: getCurrentSortingOrder.value as string,
@@ -98,7 +120,11 @@ const handleFilterChange = async (event: {
98
120
  try {
99
121
  const { code, value } = event;
100
122
 
101
- if (code === "manufacturer" || code === "properties") {
123
+ if (
124
+ code === "manufacturer" ||
125
+ code === "properties" ||
126
+ code === "categories"
127
+ ) {
102
128
  const filterSet = sidebarSelectedFilters[code];
103
129
  const stringValue = String(value);
104
130
 
@@ -137,6 +163,10 @@ const executeSearch = async () => {
137
163
 
138
164
  if (criteria.manufacturer) query.manufacturer = criteria.manufacturer;
139
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("|");
140
170
  if (criteria["min-price"]) query["min-price"] = criteria["min-price"];
141
171
  if (criteria["max-price"]) query["max-price"] = criteria["max-price"];
142
172
  if (criteria.rating) query.rating = criteria.rating;
@@ -161,6 +191,7 @@ const executeSearch = async () => {
161
191
  const clearFilters = () => {
162
192
  (sidebarSelectedFilters.manufacturer as Set<string>).clear();
163
193
  (sidebarSelectedFilters.properties as Set<string>).clear();
194
+ (sidebarSelectedFilters.categories as Set<string>).clear();
164
195
  sidebarSelectedFilters["min-price"] = undefined;
165
196
  sidebarSelectedFilters["max-price"] = undefined;
166
197
  sidebarSelectedFilters.rating = undefined;
@@ -208,7 +239,11 @@ const handleRemoveFilterChip = async (chip: {
208
239
  code: string;
209
240
  value: string | number;
210
241
  }) => {
211
- if (chip.code === "properties" || chip.code === "manufacturer") {
242
+ if (
243
+ chip.code === "properties" ||
244
+ chip.code === "manufacturer" ||
245
+ chip.code === "categories"
246
+ ) {
212
247
  const filterSet = sidebarSelectedFilters[chip.code] as Set<string>;
213
248
  filterSet.delete(String(chip.value));
214
249
  } else if (chip.code === "price") {
@@ -228,7 +263,7 @@ const handleRemoveFilterChip = async (chip: {
228
263
  <!-- Active Filter Chips -->
229
264
  <SwFilterChips
230
265
  :filters="sidebarSelectedFilters"
231
- :available-filters="getInitialFilters"
266
+ :available-filters="visibleFilters"
232
267
  @remove="handleRemoveFilterChip"
233
268
  />
234
269
 
@@ -254,11 +289,12 @@ const handleRemoveFilterChip = async (chip: {
254
289
  <!-- Filters List -->
255
290
  <div class="self-stretch flex flex-col justify-start items-start gap-4">
256
291
  <SwProductListingFilter
257
- v-for="filter in getInitialFilters"
292
+ v-for="filter in visibleFilters"
258
293
  :key="filter.id"
259
294
  :filter="filter"
260
295
  :selected-manufacturer="sidebarSelectedFilters.manufacturer"
261
296
  :selected-properties="sidebarSelectedFilters.properties"
297
+ :selected-categories="sidebarSelectedFilters.categories"
262
298
  :selected-min-price="sidebarSelectedFilters['min-price']"
263
299
  :selected-max-price="sidebarSelectedFilters['max-price']"
264
300
  :selected-rating="sidebarSelectedFilters.rating"
@@ -4,6 +4,7 @@ import type {
4
4
  CmsElementSidebarFilter,
5
5
  } from "@shopware/composables";
6
6
  import { useCmsTranslations } from "@shopware/composables";
7
+ import { getCategoryFilterPostFilter } from "@shopware/helpers";
7
8
  import { defu } from "defu";
8
9
  import { computed } from "vue";
9
10
  import type { ComputedRef } from "vue";
@@ -11,6 +12,7 @@ import type { LocationQueryRaw } from "vue-router";
11
12
 
12
13
  import {
13
14
  firstQueryValue,
15
+ getVisibleListingFilters,
14
16
  toNumber,
15
17
  useCategoryListing,
16
18
  useProductSearchListing,
@@ -58,10 +60,17 @@ const {
58
60
 
59
61
  const sidebarSelectedFilters = useSelectedListingFilters();
60
62
 
63
+ // Only the filters this listing can actually apply - the category filter is
64
+ // search-only, matching the post-filter and URL gates below.
65
+ const visibleFilters = computed(() =>
66
+ getVisibleListingFilters(getInitialFilters.value, { isProductSearch }),
67
+ );
68
+
61
69
  const showResetFiltersButton = computed<boolean>(() => {
62
70
  if (
63
71
  sidebarSelectedFilters.manufacturer.size !== 0 ||
64
72
  sidebarSelectedFilters.properties.size !== 0 ||
73
+ (isProductSearch && sidebarSelectedFilters.categories.size !== 0) ||
65
74
  sidebarSelectedFilters["max-price"] ||
66
75
  sidebarSelectedFilters["min-price"] ||
67
76
  sidebarSelectedFilters.rating ||
@@ -81,6 +90,19 @@ const searchCriteriaForRequest: ComputedRef<Schemas["ProductListingCriteria"]> =
81
90
  properties: [...(sidebarSelectedFilters.properties as Set<string>)]?.join(
82
91
  "|",
83
92
  ),
93
+ // Category selection travels as a post-filter so the category
94
+ // aggregation itself is not reduced (faceted behavior). Search pages
95
+ // only: category pages never offer this filter, so a stale
96
+ // ?categories= param must not silently narrow them.
97
+ ...(isProductSearch && sidebarSelectedFilters.categories.size > 0
98
+ ? {
99
+ "post-filter": [
100
+ getCategoryFilterPostFilter([
101
+ ...(sidebarSelectedFilters.categories as Set<string>),
102
+ ]),
103
+ ],
104
+ }
105
+ : {}),
84
106
  "min-price": sidebarSelectedFilters["min-price"] as number,
85
107
  "max-price": sidebarSelectedFilters["max-price"] as number,
86
108
  order: getCurrentSortingOrder.value as string,
@@ -97,7 +119,11 @@ const handleFilterChange = async (event: {
97
119
  try {
98
120
  const { code, value } = event;
99
121
 
100
- if (code === "manufacturer" || code === "properties") {
122
+ if (
123
+ code === "manufacturer" ||
124
+ code === "properties" ||
125
+ code === "categories"
126
+ ) {
101
127
  const filterSet = sidebarSelectedFilters[code];
102
128
  const stringValue = String(value);
103
129
 
@@ -136,6 +162,10 @@ const executeSearch = async () => {
136
162
 
137
163
  if (criteria.manufacturer) query.manufacturer = criteria.manufacturer;
138
164
  if (criteria.properties) query.properties = criteria.properties;
165
+ if (isProductSearch && sidebarSelectedFilters.categories.size > 0)
166
+ query.categories = [
167
+ ...(sidebarSelectedFilters.categories as Set<string>),
168
+ ].join("|");
139
169
  if (criteria["min-price"]) query["min-price"] = criteria["min-price"];
140
170
  if (criteria["max-price"]) query["max-price"] = criteria["max-price"];
141
171
  if (criteria.rating) query.rating = criteria.rating;
@@ -160,6 +190,7 @@ const executeSearch = async () => {
160
190
  const clearFilters = () => {
161
191
  (sidebarSelectedFilters.manufacturer as Set<string>).clear();
162
192
  (sidebarSelectedFilters.properties as Set<string>).clear();
193
+ (sidebarSelectedFilters.categories as Set<string>).clear();
163
194
  sidebarSelectedFilters["min-price"] = undefined;
164
195
  sidebarSelectedFilters["max-price"] = undefined;
165
196
  sidebarSelectedFilters.rating = undefined;
@@ -208,6 +239,9 @@ const hasActiveFilter = (filter: { code: string }) => {
208
239
  if (filter.code === "manufacturer") {
209
240
  return sidebarSelectedFilters.manufacturer.size > 0;
210
241
  }
242
+ if (filter.code === "categories") {
243
+ return sidebarSelectedFilters.categories.size > 0;
244
+ }
211
245
  if (filter.code === "price") {
212
246
  return (
213
247
  sidebarSelectedFilters["min-price"] !== undefined ||
@@ -231,7 +265,7 @@ const hasActiveFilter = (filter: { code: string }) => {
231
265
  <div class="flex flex-wrap items-center justify-start gap-4 z-10">
232
266
  <!-- Filter dropdowns -->
233
267
  <SwFilterDropdown
234
- v-for="filter in getInitialFilters"
268
+ v-for="filter in visibleFilters"
235
269
  :key="filter.id"
236
270
  :label="filter.label"
237
271
  :is-active="hasActiveFilter(filter)"
@@ -241,6 +275,7 @@ const hasActiveFilter = (filter: { code: string }) => {
241
275
  display-mode="dropdown"
242
276
  :selected-manufacturer="sidebarSelectedFilters.manufacturer"
243
277
  :selected-properties="sidebarSelectedFilters.properties"
278
+ :selected-categories="sidebarSelectedFilters.categories"
244
279
  :selected-min-price="sidebarSelectedFilters['min-price']"
245
280
  :selected-max-price="sidebarSelectedFilters['max-price']"
246
281
  :selected-rating="sidebarSelectedFilters.rating"