Add product categories + shop sidebar filters, fix CTA button alignment

Products now carry an optional categories relationship (backend:
new product-categories collection mirroring the blog's categories
pattern, migration applied and deployed). The shop page gains a left
sidebar (styled like AccountNav) with a dual-handle price slider,
category checkboxes, and an availability toggle — all instant-apply
via searchParams, same union-filter semantics as the blog's category
chips. Uncategorized products always match every category filter
rather than disappearing.

Also fixes CTA buttons sitting at different heights across sibling
cards when one product's title wraps to two lines — MerklisteGrid.tsx
and RelatedProducts.tsx get the same h-full/flex-1 spacer pattern
ProductGrid.tsx already used.
This commit is contained in:
Marco
2026-08-01 08:15:58 +00:00
parent f95feafb1a
commit 557f6a1abc
8 changed files with 228 additions and 89 deletions
+9 -5
View File
@@ -134,7 +134,7 @@ export function RelatedProducts({ defaultTaxRate, kleinunternehmer }: { defaultT
<div
key={product.id}
className={
"group sm:col-span-4 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1 " +
"group sm:col-span-4 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col h-full gap-4 transition-transform duration-300 hover:-translate-y-1 " +
// Center the row when there are fewer than 3 cards to show
// (e.g. only 1 active product left once the others are
// already in the cart) — only the first card needs an
@@ -176,7 +176,7 @@ export function RelatedProducts({ defaultTaxRate, kleinunternehmer }: { defaultT
)
)}
</div>
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-2 w-full">
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-2 w-full flex-1">
<p
className="font-semibold text-h4 text-text-primary w-full"
style={{ fontFamily: "var(--font-lora)" }}
@@ -192,12 +192,16 @@ export function RelatedProducts({ defaultTaxRate, kleinunternehmer }: { defaultT
</p>
{/* Always rendered, text conditional — min-h reserves this
line's height in both states so cards in the same row
stay equal height regardless of low-stock status; this
component has no h-full/flex-1 spacer trick like
ProductGrid.tsx to absorb a variable-height line instead. */}
stay equal height regardless of low-stock status. */}
<p className="min-h-[1.05rem] text-label font-bold text-warning">
{anyLowStock ? "Nur noch wenige verfügbar" : null}
</p>
{/* flex-1 spacer — pins every card's button to the same Y
regardless of whether `product.name` wraps to one or two
lines (see ProductGrid.tsx's identical spacer). */}
<div className="flex-1" />
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
</div>
</div>
@@ -88,6 +88,11 @@ export function MerklisteGrid({
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
</p>
</div>
{/* flex-1 spacer — pins every card's button to the same Y
regardless of whether `product.name` wraps to one or two
lines (see ProductGrid.tsx's identical spacer). */}
<div className="flex-1" />
{/* No className override — AddToCartInlineButton's `className`
prop REPLACES its whole default styling (`?? defaultClass`,
not a merge), so passing just "w-full" here previously threw
+1
View File
@@ -25,6 +25,7 @@ const product = (overrides: Partial<Product> = {}): Product => ({
maxQty: null,
taxRatePercent: null,
noShippingCost: false,
categories: [],
...overrides,
});
+9
View File
@@ -235,6 +235,11 @@ export type Product = {
// exempts the individual product, not the whole cart.
noShippingCost: boolean;
variants: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean; maxQty: number | null }[];
// Empty for a product that predates this field or was never tagged —
// treated as "matches every category filter" by the shop grid rather
// than "matches none", since an uncategorized product shouldn't just
// disappear the moment a category filter is applied.
categories: string[];
};
type PayloadProduct = {
@@ -270,6 +275,7 @@ type PayloadProduct = {
lowStockThreshold: number | null;
}[]
| null;
categories: ({ name: string } | number)[] | null;
};
// A product/variant is only actually unbuyable when it opted into
@@ -330,6 +336,9 @@ export function mapPayloadProduct(product: PayloadProduct): Product {
lowStock: isLowStock(v.trackInventory, v.stock, v.lowStockThreshold),
maxQty: maxPurchasableQty(v.trackInventory, v.stock, v.allowBackorder),
})),
categories: (product.categories ?? [])
.map((c) => (typeof c === "object" && c ? c.name : null))
.filter((name): name is string => Boolean(name)),
};
}
+72
View File
@@ -0,0 +1,72 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
// Category checkboxes (union — any checked category matches) plus a
// separate "Nur verfügbare Produkte" availability toggle, sharing one
// component since both are simple instant-apply checkboxes writing to the
// same /shop searchParams (no submit button, same as the price slider —
// see PriceRangeFilter.tsx). Categories are derived from the active
// product set by the caller (ProductGrid.tsx), same "distinct values from
// what's actually in use" pattern as the blog's category chips — an
// uncategorized product still matches every category filter (see
// Product.categories' own comment in lib/payload.ts), so this list never
// needs an "Alle" fallback entry.
export function CategoryFilter({ categories }: { categories: string[] }) {
const router = useRouter();
const searchParams = useSearchParams();
const activeCategories = (searchParams.get("categories") ?? "").split(",").filter(Boolean);
const inStockOnly = searchParams.get("inStock") === "1";
function push(params: URLSearchParams) {
const qs = params.toString();
router.push(qs ? `/shop?${qs}` : "/shop");
}
function toggleCategory(category: string) {
const next = activeCategories.includes(category)
? activeCategories.filter((c) => c !== category)
: [...activeCategories, category];
const params = new URLSearchParams(searchParams.toString());
if (next.length > 0) params.set("categories", next.join(","));
else params.delete("categories");
push(params);
}
function toggleInStock() {
const params = new URLSearchParams(searchParams.toString());
if (inStockOnly) params.delete("inStock");
else params.set("inStock", "1");
push(params);
}
return (
<div className="flex flex-col gap-4 w-full">
{categories.length > 1 && (
<div className="flex flex-col gap-2 w-full">
<p className="font-bold text-body-sm text-text-primary">Kategorie</p>
{categories.map((category) => (
<label key={category} className="flex items-center gap-2 text-body-sm text-text-primary cursor-pointer">
<input
type="checkbox"
checked={activeCategories.includes(category)}
onChange={() => toggleCategory(category)}
className="h-4 w-4 rounded-sm border-border accent-brand cursor-pointer"
/>
{category}
</label>
))}
</div>
)}
<label className="flex items-center gap-2 text-body-sm text-text-primary cursor-pointer">
<input
type="checkbox"
checked={inStockOnly}
onChange={toggleInStock}
className="h-4 w-4 rounded-sm border-border accent-brand cursor-pointer"
/>
Nur verfügbare Produkte
</label>
</div>
);
}
+85 -67
View File
@@ -2,12 +2,15 @@
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { formatPrice } from "../../lib/format";
// Real min/max range (not preset toggle buckets, tried first and
// reverted 2026-07-30 — "keine toggle badges") — two plain number inputs,
// 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.
// Two overlapping native <input type="range"> thumbs (a plain CSS trick —
// track transparent/pointer-events-none, only the thumb itself clickable
// via the ::-webkit-slider-thumb/::-moz-range-thumb pseudo-elements) —
// replaced the earlier two-number-input version (2026-08-01) for a more
// direct "drag to filter" feel. `onInput` updates the visual position on
// every drag frame; the URL/navigation only commits on `onChange` (fires
// once, on mouse-up/key-up), so dragging doesn't spam router.push.
export function PriceRangeFilter({
catalogMin,
catalogMax,
@@ -15,87 +18,102 @@ export function PriceRangeFilter({
}: {
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. */
/** "bar" (default): horizontal row — used above the grid at <lg (see
* ProductGrid.tsx's mobile/tablet filter bar). "sidebar": stacked to
* fit the lg:+ left sidebar column instead. */
layout?: "bar" | "sidebar";
}) {
const router = useRouter();
const searchParams = useSearchParams();
const [minPrice, setMinPrice] = useState(searchParams.get("minPrice") ?? "");
const [maxPrice, setMaxPrice] = useState(searchParams.get("maxPrice") ?? "");
const paramMin = searchParams.get("minPrice");
const paramMax = searchParams.get("maxPrice");
const [minPrice, setMinPrice] = useState(paramMin ? Number(paramMin) : catalogMin);
const [maxPrice, setMaxPrice] = useState(paramMax ? Number(paramMax) : catalogMax);
const hasFilter = Boolean(searchParams.get("minPrice") || searchParams.get("maxPrice"));
const hasFilter = Boolean(paramMin || paramMax);
const sidebar = layout === "sidebar";
function apply(e: React.FormEvent) {
e.preventDefault();
const params = new URLSearchParams();
if (minPrice) params.set("minPrice", minPrice);
if (maxPrice) params.set("maxPrice", maxPrice);
function commit(nextMin: number, nextMax: number) {
const params = new URLSearchParams(searchParams.toString());
if (nextMin > catalogMin) params.set("minPrice", String(nextMin));
else params.delete("minPrice");
if (nextMax < catalogMax) params.set("maxPrice", String(nextMax));
else params.delete("maxPrice");
const qs = params.toString();
router.push(qs ? `/shop?${qs}` : "/shop");
}
function reset() {
setMinPrice("");
setMaxPrice("");
router.push("/shop");
setMinPrice(catalogMin);
setMaxPrice(catalogMax);
const params = new URLSearchParams(searchParams.toString());
params.delete("minPrice");
params.delete("maxPrice");
const qs = params.toString();
router.push(qs ? `/shop?${qs}` : "/shop");
}
const sidebar = layout === "sidebar";
// Clamped against each other while dragging — a thumb can't be pushed
// past its sibling, so the range never visually inverts.
function onMinInput(value: number) {
setMinPrice(Math.min(value, maxPrice));
}
function onMaxInput(value: number) {
setMaxPrice(Math.max(value, minPrice));
}
const range = catalogMax - catalogMin || 1;
const minPct = ((minPrice - catalogMin) / range) * 100;
const maxPct = ((maxPrice - catalogMin) / range) * 100;
const thumbClasses =
"absolute w-full m-0 appearance-none bg-transparent pointer-events-none " +
"[&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-brand [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:shadow-sm " +
"[&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-brand [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:cursor-pointer " +
"[&::-webkit-slider-runnable-track]:bg-transparent [&::-moz-range-track]:bg-transparent";
return (
<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"}
>
<div className={sidebar ? "flex flex-col gap-3 items-stretch w-full" : "flex flex-col gap-2 w-full pb-6 sm:max-w-xs"}>
{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 className="flex items-center justify-between text-label text-text-muted">
<span>{formatPrice(minPrice)}</span>
<span>{formatPrice(maxPrice)}</span>
</div>
<div className={sidebar ? "flex flex-col gap-2 items-stretch w-full" : "flex items-center gap-3"}>
<div className="relative h-4 flex items-center">
<div className="absolute inset-x-0 h-1 rounded-full bg-bg-muted" />
<div className="absolute h-1 rounded-full bg-brand" style={{ left: `${minPct}%`, right: `${100 - maxPct}%` }} />
<input
type="range"
aria-label="Mindestpreis"
min={catalogMin}
max={catalogMax}
step="0.1"
value={minPrice}
onInput={(e) => onMinInput(Number(e.currentTarget.value))}
onChange={() => commit(minPrice, maxPrice)}
className={thumbClasses}
/>
<input
type="range"
aria-label="Höchstpreis"
min={catalogMin}
max={catalogMax}
step="0.1"
value={maxPrice}
onInput={(e) => onMaxInput(Number(e.currentTarget.value))}
onChange={() => commit(minPrice, maxPrice)}
className={thumbClasses}
/>
</div>
{hasFilter && (
<button
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`}
type="button"
onClick={reset}
className="self-start text-body-sm font-semibold text-text-muted underline hover:text-brand transition-colors"
>
Anwenden
Zurücksetzen
</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>
)}
</div>
);
}
+46 -16
View File
@@ -8,6 +8,7 @@ import { AddToCartInlineButton } from "../../components/AddToCartInlineButton";
import { WishlistButton } from "../../components/WishlistButton";
import { ArrowRightIcon } from "../../components/ArrowRightIcon";
import { PriceRangeFilter } from "./PriceRangeFilter";
import { CategoryFilter } from "./CategoryFilter";
// Server Component — fetches straight from Payload (getProducts(), ISR
// cached 60s) rather than going through the client-side useProducts()
@@ -15,13 +16,20 @@ import { PriceRangeFilter } from "./PriceRangeFilter";
// there's no reason to pay for a client fetch when a server one already
// gives faster first paint and no loading flash.
// Products have no category taxonomy today (only Posts do) — a "N
// checkboxes" category sidebar isn't buildable against real data yet, so
// this only covers price, the one dimension that already exists on every
// product. A real min/max range (PriceRangeFilter.tsx), not preset toggle
// buckets — tried buckets-as-toggle-chips first, reverted 2026-07-30
// ("keine toggle badges").
export async function ProductGrid({ searchParams }: { searchParams?: { minPrice?: string; maxPrice?: string } }) {
// Three filter dimensions: price (PriceRangeFilter.tsx, a real min/max
// slider — preset toggle buckets were tried first and reverted 2026-07-30,
// "keine toggle badges"), category (CategoryFilter.tsx, since
// `products.categories` now exists — same distinct-values-in-use pattern
// as the blog's category chips), and availability (in-stock only).
function isProductFullyOutOfStock(product: { outOfStock: boolean; variants: { outOfStock: boolean }[] }): boolean {
return product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock;
}
export async function ProductGrid({
searchParams,
}: {
searchParams?: { minPrice?: string; maxPrice?: string; categories?: string; inStock?: string };
}) {
const [allProducts, shipping, defaultTaxRate, kleinunternehmer, wishlistEnabled, shopFilterEnabled] = await Promise.all([
getProducts(),
getShippingSettings(),
@@ -45,18 +53,36 @@ export async function ProductGrid({ searchParams }: { searchParams?: { minPrice?
const catalogMax = Math.max(...catalogPrices);
const minPrice = shopFilterEnabled && searchParams?.minPrice ? Number(searchParams.minPrice) : null;
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;
const allCategories = Array.from(new Set(allActiveProducts.flatMap((p) => p.categories))).sort((a, b) => a.localeCompare(b, "de"));
const activeCategories =
shopFilterEnabled && searchParams?.categories ? searchParams.categories.split(",").filter(Boolean) : [];
const inStockOnly = shopFilterEnabled && searchParams?.inStock === "1";
const anyOutOfStock = allActiveProducts.some(isProductFullyOutOfStock);
const products = allActiveProducts.filter((p) => {
if (minPrice !== null && p.price < minPrice) return false;
if (maxPrice !== null && p.price > maxPrice) return false;
// Uncategorized products (categories.length === 0) always match — see
// Product.categories' own comment in lib/payload.ts.
if (activeCategories.length > 0 && p.categories.length > 0 && !p.categories.some((c) => activeCategories.includes(c))) {
return false;
}
if (inStockOnly && isProductFullyOutOfStock(p)) return false;
return true;
});
const hasSidebarFilter = shopFilterEnabled && (catalogMin !== catalogMax || allCategories.length > 1 || anyOutOfStock);
return (
<section className="w-full bg-bg-base flex flex-col pb-16 md:pb-20 px-[var(--layout-padding-x)]">
{/* <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. */}
{/* <lg: filters (if any) sit as their own bar above the grid — same
as before. lg+: they move into a left sidebar instead (see aside
below), so this is hidden there to avoid rendering twice. */}
{hasSidebarFilter && (
<div className="lg:hidden">
<div className="lg:hidden flex flex-col gap-4 pb-2">
<PriceRangeFilter catalogMin={catalogMin} catalogMax={catalogMax} />
<CategoryFilter categories={allCategories} />
</div>
)}
@@ -65,13 +91,17 @@ export async function ProductGrid({ searchParams }: { searchParams?: { minPrice?
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">
<aside className="hidden lg:flex lg:flex-col lg:w-56 shrink-0 gap-6">
{/* Same label treatment as AccountNav.tsx's "Mein Konto"
heading — this sidebar is visually modeled on that one. */}
<p className="text-label font-bold text-text-muted uppercase tracking-wide px-3 pb-2">Filter</p>
<PriceRangeFilter catalogMin={catalogMin} catalogMax={catalogMax} layout="sidebar" />
<CategoryFilter categories={allCategories} />
</aside>
)}
<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>}
{products.length === 0 && <p className="text-body text-text-muted pb-6">Keine Produkte für diese Filter 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
@@ -90,7 +120,7 @@ export async function ProductGrid({ searchParams }: { searchParams?: { minPrice?
// shows as such in the picker itself (AddToCartInlineButton),
// not as a blanket badge that would misleadingly suggest the
// whole product is unavailable while other variants still are.
const fullyOutOfStock = product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock;
const fullyOutOfStock = isProductFullyOutOfStock(product);
// Mirrors fullyOutOfStock's "any vs. every" split — a varianted
// product reads as low-stock as soon as one variant is, since a
// shopper landing on the grid hasn't picked a variant yet.
+1 -1
View File
@@ -22,7 +22,7 @@ export const metadata: Metadata = {
export default async function ShopPage({
searchParams,
}: {
searchParams: Promise<{ minPrice?: string; maxPrice?: string }>;
searchParams: Promise<{ minPrice?: string; maxPrice?: string; categories?: string; inStock?: string }>;
}) {
const resolvedSearchParams = await searchParams;
return (