@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.
- package/README.md +58 -43
- package/app/components/SwCategoryNavigationLink.vue +4 -5
- package/app/components/SwMedia3D.vue +8 -3
- package/app/components/SwProductAddToCart.vue +27 -4
- package/app/components/SwProductCard.vue +26 -4
- package/app/components/SwProductCardDetails.vue +9 -2
- package/app/components/SwProductListingFilter.vue +1 -1
- package/app/components/SwProductListingFilters.vue +10 -222
- package/app/components/SwProductListingFiltersHorizontal.vue +15 -202
- package/app/components/public/cms/CmsBlockSpatialViewer.vue +7 -7
- package/app/components/public/cms/CmsGenericBlock.vue +6 -10
- package/app/components/public/cms/CmsGenericElement.md +11 -5
- package/app/components/public/cms/CmsGenericElement.vue +17 -11
- package/app/components/public/cms/block/CmsBlockAppRenderer.vue +48 -0
- package/app/components/public/cms/block/CmsBlockCategoryHeading.vue +19 -0
- package/app/components/public/cms/block/CmsBlockCategoryNavigation.vue +3 -2
- package/app/components/public/cms/block/CmsBlockCenterText.vue +5 -4
- package/app/components/public/cms/block/CmsBlockCrossSelling.vue +3 -2
- package/app/components/public/cms/block/CmsBlockCustomForm.vue +3 -2
- package/app/components/public/cms/block/CmsBlockForm.vue +3 -2
- package/app/components/public/cms/block/CmsBlockGalleryBuybox.vue +4 -3
- package/app/components/public/cms/block/CmsBlockImage.vue +3 -2
- package/app/components/public/cms/block/CmsBlockImageBubbleRow.vue +5 -4
- package/app/components/public/cms/block/CmsBlockImageCover.vue +3 -2
- package/app/components/public/cms/block/CmsBlockImageFourColumn.vue +6 -5
- package/app/components/public/cms/block/CmsBlockImageGallery.vue +3 -2
- package/app/components/public/cms/block/CmsBlockImageGalleryBig.vue +3 -2
- package/app/components/public/cms/block/CmsBlockImageHighlightRow.vue +5 -4
- package/app/components/public/cms/block/CmsBlockImageSimpleGrid.vue +5 -4
- package/app/components/public/cms/block/CmsBlockImageSlider.vue +3 -2
- package/app/components/public/cms/block/CmsBlockImageText.vue +4 -3
- package/app/components/public/cms/block/CmsBlockImageTextBubble.vue +8 -7
- package/app/components/public/cms/block/CmsBlockImageTextCover.vue +4 -3
- package/app/components/public/cms/block/CmsBlockImageTextGallery.vue +39 -16
- package/app/components/public/cms/block/CmsBlockImageTextRow.vue +8 -7
- package/app/components/public/cms/block/CmsBlockImageThreeColumn.vue +5 -4
- package/app/components/public/cms/block/CmsBlockImageThreeCover.vue +5 -4
- package/app/components/public/cms/block/CmsBlockImageTwoColumn.vue +4 -3
- package/app/components/public/cms/block/CmsBlockProductDescriptionReviews.vue +3 -2
- package/app/components/public/cms/block/CmsBlockProductHeading.vue +4 -3
- package/app/components/public/cms/block/CmsBlockProductListing.vue +3 -2
- package/app/components/public/cms/block/CmsBlockProductThreeColumn.vue +5 -4
- package/app/components/public/cms/block/CmsBlockSidebarFilter.vue +7 -5
- package/app/components/public/cms/block/CmsBlockText.vue +3 -2
- package/app/components/public/cms/block/CmsBlockTextHero.vue +3 -2
- package/app/components/public/cms/block/CmsBlockTextOnImage.vue +3 -2
- package/app/components/public/cms/block/CmsBlockTextThreeColumn.vue +5 -4
- package/app/components/public/cms/block/CmsBlockTextTwoColumn.vue +4 -3
- package/app/components/public/cms/block/CmsBlockVideo.vue +19 -0
- package/app/components/public/cms/block/CmsBlockVimeoVideo.vue +3 -2
- package/app/components/public/cms/block/CmsBlockYoutubeVideo.vue +3 -2
- package/app/components/public/cms/element/CmsElementCategoryName.md +8 -0
- package/app/components/public/cms/element/CmsElementCategoryName.vue +35 -0
- package/app/components/public/cms/element/CmsElementProductListing.vue +58 -25
- package/app/components/public/cms/element/CmsElementText.vue +11 -10
- package/app/components/public/cms/element/CmsElementVideo.md +27 -0
- package/app/components/public/cms/element/CmsElementVideo.vue +193 -0
- package/app/components/public/cms/section/CmsSectionSidebar.vue +4 -4
- package/app/composables/useListingFilters.ts +189 -0
- package/app/composables/useSelectedListingFilters.ts +46 -0
- package/app/helpers/cms/getAppRendererLayout.test.ts +93 -0
- package/app/helpers/cms/getAppRendererLayout.ts +36 -0
- package/app/helpers/cms/getCategoryNameContent.test.ts +57 -0
- package/app/helpers/cms/getCategoryNameContent.ts +15 -0
- package/app/helpers/cms/getTextElementContent.test.ts +79 -0
- package/app/helpers/cms/getTextElementContent.ts +37 -0
- package/app/helpers/cms/getVideoElementOptions.test.ts +384 -0
- package/app/helpers/cms/getVideoElementOptions.ts +142 -0
- package/app/helpers/html-to-vue/renderToHtml.test.ts +16 -0
- package/app/helpers/html-to-vue/renderer.ts +3 -3
- package/app/helpers/product/shouldShowBuyButton.test.ts +21 -0
- package/app/helpers/product/shouldShowBuyButton.ts +6 -0
- package/app/utils/{useSelectedListingFilters.test.ts → listingFilterState.test.ts} +1 -1
- package/app/utils/{useSelectedListingFilters.ts → listingFilterState.ts} +0 -35
- 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.
|
|
524
|
+
### Latest changes: 4.1.0
|
|
495
525
|
|
|
496
|
-
###
|
|
526
|
+
### Minor Changes
|
|
497
527
|
|
|
498
|
-
- [#
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
- [#
|
|
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
|
-
- [#
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
- [#
|
|
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
|
-
|
|
567
|
+
New translation key: `product.notAddedToCart`. Variant parents now use `product.details`. Add both to your locale files.
|
|
552
568
|
|
|
553
|
-
- Updated dependencies [[`
|
|
554
|
-
- @shopware/
|
|
555
|
-
- @shopware/composables@1.
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
|
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="
|
|
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 (
|
|
68
|
-
|
|
69
|
-
|
|
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 (
|
|
137
|
-
|
|
138
|
-
|
|
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="
|
|
82
|
+
v-if="showBuyButton"
|
|
76
83
|
size="medium"
|
|
77
84
|
:disabled="!product?.available"
|
|
78
85
|
block
|
|
@@ -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
|
-
|
|
55
|
-
getCurrentFilters,
|
|
37
|
+
currentSortingOrder,
|
|
56
38
|
getCurrentSortingOrder,
|
|
57
|
-
getInitialFilters,
|
|
58
39
|
getSortingOrders,
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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>
|