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 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-31 13:00:55 +00:00
parent 8c2220ae45
commit 51e4860fe3
6 changed files with 139 additions and 58 deletions
+11 -1
View File
@@ -67,6 +67,16 @@ export function CustomSelect({
listRef.current?.querySelector<HTMLElement>(`[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 (
<div ref={rootRef} className={`relative w-full ${fullWidth ? "" : "sm:w-auto"}`}>
<div ref={rootRef} onBlur={onBlur} className={`relative w-full ${fullWidth ? "" : "sm:w-auto"}`}>
<button
type="button"
aria-haspopup="listbox"
+7 -2
View File
@@ -2,7 +2,8 @@ import Image from "next/image";
import Link from "next/link";
import { AddToCartButton } from "./AddToCartButton";
import { Reveal } from "./Reveal";
import { getSpotlightProduct, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../lib/payload";
import { WishlistButton } from "./WishlistButton";
import { getSpotlightProduct, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer, getWishlistEnabled } from "../lib/payload";
import { formatPrice, discountPercent } from "../lib/format";
import { effectiveTaxRate } from "../lib/cartTotals";
@@ -23,11 +24,12 @@ import { effectiveTaxRate } from "../lib/cartTotals";
* see Products.ts), not duplicated here as hardcoded literals.
*/
export async function ProductSpotlight() {
const [product, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
const [product, shipping, defaultTaxRate, kleinunternehmer, wishlistEnabled] = await Promise.all([
getSpotlightProduct(),
getShippingSettings(),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
getWishlistEnabled(),
]);
if (!product) return null;
@@ -71,6 +73,9 @@ export async function ProductSpotlight() {
</span>
)
)}
{wishlistEnabled && product.spotlightShowWishlist && (
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" />
)}
</div>
<div className="flex flex-col gap-4 items-start flex-1 min-w-0 w-full">
+19 -1
View File
@@ -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}`}
>
<svg width="20" height="18" viewBox="0 0 20 18" fill={wishlisted ? "currentColor" : "none"} className={wishlisted ? "text-brand" : "text-text-primary"}>
<path
+1
View File
@@ -18,6 +18,7 @@ const product = (overrides: Partial<Product> = {}): Product => ({
spotlightHeadline: null,
spotlightText: null,
spotlightImage: null,
spotlightShowWishlist: false,
variants: [],
outOfStock: false,
lowStock: false,
+62 -41
View File
@@ -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
* <lg (see ProductGrid.tsx's mobile/tablet filter bar). "sidebar":
* stacked vertically to fit the lg:+ left sidebar column instead. */
layout?: "bar" | "sidebar";
}) {
const router = useRouter();
const searchParams = useSearchParams();
const [minPrice, setMinPrice] = useState(searchParams.get("minPrice") ?? "");
@@ -31,50 +42,60 @@ export function PriceRangeFilter({ catalogMin, catalogMax }: { catalogMin: numbe
router.push("/shop");
}
const sidebar = layout === "sidebar";
return (
<form onSubmit={apply} className="flex flex-wrap items-end gap-3 w-full pb-6">
<label className="flex flex-col gap-1">
<span className="text-label text-text-muted">Von</span>
<input
type="number"
inputMode="decimal"
min={0}
step="0.01"
placeholder={`${catalogMin}`}
value={minPrice}
onChange={(e) => 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"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-label text-text-muted">Bis</span>
<input
type="number"
inputMode="decimal"
min={0}
step="0.01"
placeholder={`${catalogMax}`}
value={maxPrice}
onChange={(e) => setMaxPrice(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"
/>
</label>
<span className="text-body-sm text-text-muted"></span>
<button
type="submit"
className="px-4 py-2 rounded-sm bg-brand text-body-sm font-bold text-text-primary hover:brightness-95 active:scale-[0.97] transition-all"
>
Anwenden
</button>
{hasFilter && (
<form
onSubmit={apply}
className={sidebar ? "flex flex-col gap-3 items-stretch w-full" : "flex flex-wrap items-end gap-3 w-full pb-6"}
>
{sidebar && <p className="font-bold text-body-sm text-text-primary">Preis</p>}
<div className={sidebar ? "flex items-end gap-3 w-full" : "flex items-end gap-3"}>
<label className={sidebar ? "flex flex-col gap-1 flex-1 min-w-0" : "flex flex-col gap-1"}>
<span className="text-label text-text-muted">Von</span>
<input
type="number"
inputMode="decimal"
min={0}
step="0.01"
placeholder={`${catalogMin}`}
value={minPrice}
onChange={(e) => setMinPrice(e.target.value)}
className={`${sidebar ? "w-full" : "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`}
/>
</label>
<label className={sidebar ? "flex flex-col gap-1 flex-1 min-w-0" : "flex flex-col gap-1"}>
<span className="text-label text-text-muted">Bis</span>
<input
type="number"
inputMode="decimal"
min={0}
step="0.01"
placeholder={`${catalogMax}`}
value={maxPrice}
onChange={(e) => setMaxPrice(e.target.value)}
className={`${sidebar ? "w-full" : "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`}
/>
</label>
{!sidebar && <span className="text-body-sm text-text-muted"></span>}
</div>
<div className={sidebar ? "flex flex-col gap-2 items-stretch w-full" : "flex items-center gap-3"}>
<button
type="button"
onClick={reset}
className="text-body-sm font-semibold text-text-muted underline hover:text-brand transition-colors"
type="submit"
className={`${sidebar ? "w-full" : ""} px-4 py-2 rounded-sm bg-brand text-body-sm font-bold text-text-primary hover:brightness-95 active:scale-[0.97] transition-all`}
>
Zurücksetzen
Anwenden
</button>
)}
{hasFilter && (
<button
type="button"
onClick={reset}
className={`${sidebar ? "text-center" : ""} text-body-sm font-semibold text-text-muted underline hover:text-brand transition-colors`}
>
Zurücksetzen
</button>
)}
</div>
</form>
);
}
+39 -13
View File
@@ -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 (
<section className="w-full bg-bg-base flex flex-col pb-16 md:pb-20 px-[var(--layout-padding-x)]">
{shopFilterEnabled && catalogMin !== catalogMax && <PriceRangeFilter catalogMin={catalogMin} catalogMax={catalogMax} />}
{/* <lg: filter (if any) sits as its own bar above the grid — same as
before. lg+: it moves into a left sidebar instead (see aside
below), so it's hidden here to avoid rendering twice. */}
{hasSidebarFilter && (
<div className="lg:hidden">
<PriceRangeFilter catalogMin={catalogMin} catalogMax={catalogMax} />
</div>
)}
{products.length === 0 && <p className="text-body text-text-muted pb-6">Keine Produkte in dieser Preisspanne gefunden.</p>}
{/* 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. */}
<div className={hasSidebarFilter ? "lg:flex lg:gap-10 w-full" : "w-full"}>
{hasSidebarFilter && (
<aside className="hidden lg:block lg:w-56 shrink-0">
<PriceRangeFilter catalogMin={catalogMin} catalogMax={catalogMax} layout="sidebar" />
</aside>
)}
{/* 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. */}
<RevealGroup className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] w-full">
{products.map((product) => {
<div className="flex-1 min-w-0 flex flex-col">
{products.length === 0 && <p className="text-body text-text-muted pb-6">Keine Produkte in dieser Preisspanne gefunden.</p>}
{/* 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. */}
<RevealGroup className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-12 gap-6 sm:gap-[var(--layout-grid-gap)] w-full">
{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 (
<RevealItem
key={product.id}
className="group lg:col-span-3 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col h-full transition-transform duration-300 hover:-translate-y-1"
className="group lg:col-span-4 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col h-full transition-transform duration-300 hover:-translate-y-1"
>
<div className="relative w-full aspect-[276/210] overflow-hidden">
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 1024px) 25vw, (min-width: 640px) 50vw, 100vw"
sizes="(min-width: 1024px) 30vw, (min-width: 640px) 50vw, 100vw"
className={`object-cover transition-transform duration-500 group-hover:scale-105 ${fullyOutOfStock ? "opacity-60" : ""}`}
/>
{fullyOutOfStock ? (
@@ -95,7 +119,7 @@ export async function ProductGrid({ searchParams }: { searchParams?: { minPrice?
)
)}
{wishlistEnabled && (
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" />
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" revealOnHover />
)}
</div>
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-4 w-full flex-1">
@@ -147,8 +171,10 @@ export async function ProductGrid({ searchParams }: { searchParams?: { minPrice?
</div>
</RevealItem>
);
})}
</RevealGroup>
})}
</RevealGroup>
</div>
</div>
</section>
);
}