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:
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,6 +18,7 @@ const product = (overrides: Partial<Product> = {}): Product => ({
|
||||
spotlightHeadline: null,
|
||||
spotlightText: null,
|
||||
spotlightImage: null,
|
||||
spotlightShowWishlist: false,
|
||||
variants: [],
|
||||
outOfStock: false,
|
||||
lowStock: false,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user