From 51e4860fe36e71965d6f3fcc03394a1687d9bea8 Mon Sep 17 00:00:00 2001 From: Marco Date: Fri, 31 Jul 2026 13:00:55 +0000 Subject: [PATCH] Add spotlight wishlist toggle and shop filter/grid polish Per-product spotlightShowWishlist opt-in (independent of the global wishlistEnabled toggle), a reveal-on-hover WishlistButton variant for multi-card grids (avoids a heart on every card reading as visual noise), and related PriceRangeFilter/ProductGrid/CustomSelect adjustments. Co-Authored-By: Claude Sonnet 5 --- app/components/CustomSelect.tsx | 12 ++- app/components/ProductSpotlight.tsx | 9 +- app/components/WishlistButton.tsx | 20 ++++- app/lib/__tests__/cartTotals.test.ts | 1 + app/shop/components/PriceRangeFilter.tsx | 103 ++++++++++++++--------- app/shop/components/ProductGrid.tsx | 52 +++++++++--- 6 files changed, 139 insertions(+), 58 deletions(-) diff --git a/app/components/CustomSelect.tsx b/app/components/CustomSelect.tsx index 22ec45f..482d67c 100644 --- a/app/components/CustomSelect.tsx +++ b/app/components/CustomSelect.tsx @@ -67,6 +67,16 @@ export function CustomSelect({ listRef.current?.querySelector(`[data-index="${highlighted}"]`)?.scrollIntoView({ block: "nearest" }); }, [open, highlighted]); + // Keyboard/focus-driven close: Tabbing (or programmatically moving + // focus) away from the trigger+list entirely used to leave the panel + // open forever — the mousedown-outside listener above only ever reacts + // to a mouse click, not focus leaving via Tab. `relatedTarget` is where + // focus is headed; null on some browsers when it lands outside the + // document/on a non-focusable element, which should also close. + function onBlur(e: React.FocusEvent) { + if (!rootRef.current?.contains(e.relatedTarget as Node)) setOpen(false); + } + function select(index: number) { onChange(allOptions[index].value); setOpen(false); @@ -96,7 +106,7 @@ export function CustomSelect({ } return ( -
+
diff --git a/app/components/WishlistButton.tsx b/app/components/WishlistButton.tsx index 8aa4d9f..66eca0d 100644 --- a/app/components/WishlistButton.tsx +++ b/app/components/WishlistButton.tsx @@ -14,10 +14,24 @@ export function WishlistButton({ productId, variant = "", className = "", + revealOnHover = false, }: { productId: number; variant?: string; className?: string; + /** false (default): always visible — right for single-product contexts + * (ProductSpotlight, /konto/merkliste, the product detail page) where + * there's no "wall of hearts" to thin out. true: invisible until the + * card is hovered/focused, unless the product is already wishlisted (a + * filled heart stays as a permanent status indicator) — right for a + * multi-card grid (ProductGrid.tsx), where a heart on every single card + * reads as visual noise (Marco: "sieht man überall Herzen", 2026-07-31). + * Relies on the parent card already carrying `group`/`focus-within` + * (see ProductGrid.tsx) — Tailwind's plain `:hover`, so tapping a card + * on touch devices reveals it the same way the existing + * `group-hover:-translate-y-1` card-lift already does, no separate + * touch handling needed. */ + revealOnHover?: boolean; }) { const { isWishlisted, toggle } = useWishlist(); const [pending, setPending] = useState(false); @@ -47,7 +61,11 @@ export function WishlistButton({ aria-label={wishlisted ? "Von der Merkliste entfernen" : "Zur Merkliste hinzufügen"} aria-pressed={wishlisted} disabled={pending} - className={`flex h-9 w-9 items-center justify-center rounded-full bg-bg-base/90 backdrop-blur-sm transition-transform active:scale-90 disabled:opacity-60 ${className}`} + className={`flex h-9 w-9 items-center justify-center rounded-full bg-bg-base/90 backdrop-blur-sm transition-all active:scale-90 disabled:opacity-60 ${ + revealOnHover && !wishlisted + ? "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100" + : "" + } ${className}`} > = {}): Product => ({ spotlightHeadline: null, spotlightText: null, spotlightImage: null, + spotlightShowWishlist: false, variants: [], outOfStock: false, lowStock: false, diff --git a/app/shop/components/PriceRangeFilter.tsx b/app/shop/components/PriceRangeFilter.tsx index 42167fe..2281eef 100644 --- a/app/shop/components/PriceRangeFilter.tsx +++ b/app/shop/components/PriceRangeFilter.tsx @@ -8,7 +8,18 @@ import { useRouter, useSearchParams } from "next/navigation"; // submitted via a small Client Component's router.push. Still a plain // URL search param underneath (?minPrice=&maxPrice=), so the result stays // shareable/bookmarkable like every other filter on the site. -export function PriceRangeFilter({ catalogMin, catalogMax }: { catalogMin: number; catalogMax: number }) { +export function PriceRangeFilter({ + catalogMin, + catalogMax, + layout = "bar", +}: { + catalogMin: number; + catalogMax: number; + /** "bar" (default): horizontal row, wraps — used above the grid at + * - Von - setMinPrice(e.target.value)} - className="w-24 border border-border rounded-sm px-3 py-2 text-body-sm text-text-primary bg-bg-base outline-none focus:border-brand transition-colors" - /> - - - - - {hasFilter && ( +
+ {sidebar &&

Preis

} +
+ + + {!sidebar && } +
+
- )} + {hasFilter && ( + + )} +
); } diff --git a/app/shop/components/ProductGrid.tsx b/app/shop/components/ProductGrid.tsx index e39084c..f98f890 100644 --- a/app/shop/components/ProductGrid.tsx +++ b/app/shop/components/ProductGrid.tsx @@ -46,18 +46,42 @@ export async function ProductGrid({ searchParams }: { searchParams?: { minPrice? const maxPrice = shopFilterEnabled && searchParams?.maxPrice ? Number(searchParams.maxPrice) : null; const products = allActiveProducts.filter((p) => (minPrice === null || p.price >= minPrice) && (maxPrice === null || p.price <= maxPrice)); + const hasSidebarFilter = shopFilterEnabled && catalogMin !== catalogMax; + return (
- {shopFilterEnabled && catalogMin !== catalogMax && } + {/*
+ )} - {products.length === 0 &&

Keine Produkte in dieser Preisspanne gefunden.

} + {/* lg:flex — a real left sidebar only once there's an actual filter + to put in it (hasSidebarFilter); with no filter, the grid alone + fills the row exactly as before, no empty reserved column. */} +
+ {hasSidebarFilter && ( + + )} - {/* 2-up from the mobile breakpoint (sm, 640px) through 1023px — was - sm:grid-cols-12 with each card sm:col-span-3 (4-up), too narrow a - card through that tablet range. 4-up now only kicks in at lg - (1024px), true mobile (below sm) unchanged. */} - - {products.map((product) => { +
+ {products.length === 0 &&

Keine Produkte in dieser Preisspanne gefunden.

} + + {/* 2-up from the mobile breakpoint (sm, 640px) through 1023px — was + sm:grid-cols-12 with each card sm:col-span-3 (4-up), too narrow a + card through that tablet range. lg+ is now 3-up (col-span-4 of + 12) rather than 4-up — narrowed to leave room for the sidebar + filter alongside it (see hasSidebarFilter above); with no + filter active the grid still renders at this same 3-up density, + simplest to keep one fixed lg: density rather than branching + the whole grid on hasSidebarFilter too. */} + + {products.map((product) => { const discount = discountPercent(product.price, product.compareAtPrice); const taxRate = effectiveTaxRate(product, defaultTaxRate); // A varianted product only reads as "ausverkauft" overall once @@ -73,14 +97,14 @@ export async function ProductGrid({ searchParams }: { searchParams?: { minPrice? return (
{product.name} {fullyOutOfStock ? ( @@ -95,7 +119,7 @@ export async function ProductGrid({ searchParams }: { searchParams?: { minPrice? ) )} {wishlistEnabled && ( - + )}
@@ -147,8 +171,10 @@ export async function ProductGrid({ searchParams }: { searchParams?: { minPrice?
); - })} -
+ })} + +
+
); }