Replace shop price toggle-badges with a real min/max range filter

Preset price buckets as toggle chips were reverted per feedback
("keine toggle badges") — PriceRangeFilter.tsx is a plain min/max
number-input pair instead, still a URL search param underneath
(?minPrice=&maxPrice=), same shareable/bookmarkable approach.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-30 22:53:51 +00:00
parent bc40e22910
commit bce5a9f82c
3 changed files with 93 additions and 54 deletions
+80
View File
@@ -0,0 +1,80 @@
"use client";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
// 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.
export function PriceRangeFilter({ catalogMin, catalogMax }: { catalogMin: number; catalogMax: number }) {
const router = useRouter();
const searchParams = useSearchParams();
const [minPrice, setMinPrice] = useState(searchParams.get("minPrice") ?? "");
const [maxPrice, setMaxPrice] = useState(searchParams.get("maxPrice") ?? "");
const hasFilter = Boolean(searchParams.get("minPrice") || searchParams.get("maxPrice"));
function apply(e: React.FormEvent) {
e.preventDefault();
const params = new URLSearchParams();
if (minPrice) params.set("minPrice", minPrice);
if (maxPrice) params.set("maxPrice", maxPrice);
const qs = params.toString();
router.push(qs ? `/shop?${qs}` : "/shop");
}
function reset() {
setMinPrice("");
setMaxPrice("");
router.push("/shop");
}
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 && (
<button
type="button"
onClick={reset}
className="text-body-sm font-semibold text-text-muted underline hover:text-brand transition-colors"
>
Zurücksetzen
</button>
)}
</form>
);
}
+12 -53
View File
@@ -6,6 +6,7 @@ import { formatPrice, discountPercent } from "../../lib/format";
import { RevealGroup, RevealItem } from "../../components/Reveal";
import { AddToCartInlineButton } from "../../components/AddToCartInlineButton";
import { WishlistButton } from "../../components/WishlistButton";
import { PriceRangeFilter } from "./PriceRangeFilter";
// Server Component — fetches straight from Payload (getProducts(), ISR
// cached 60s) rather than going through the client-side useProducts()
@@ -16,26 +17,10 @@ import { WishlistButton } from "../../components/WishlistButton";
// 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. 3 buckets split evenly across the active catalog's actual
// min/max price (not fixed round-number thresholds like "unter 20 €") so
// the ranges stay sensible regardless of what's actually being sold —
// re-computed on every request from whatever's active, never stale.
function buildPriceBuckets(products: { price: number }[]): { label: string; min: number; max: number }[] {
if (products.length === 0) return [];
const prices = products.map((p) => p.price);
const min = Math.min(...prices);
const max = Math.max(...prices);
if (min === max) return [];
const step = (max - min) / 3;
const bounds = [min, min + step, min + step * 2, max];
return [0, 1, 2].map((i) => ({
label: i === 0 ? `bis ${formatPrice(bounds[i + 1])}` : i === 2 ? `ab ${formatPrice(bounds[i])}` : `${formatPrice(bounds[i])}${formatPrice(bounds[i + 1])}`,
min: bounds[i],
max: bounds[i + 1],
}));
}
export async function ProductGrid({ searchParams }: { searchParams?: { priceBucket?: string } }) {
// 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 } }) {
const [allProducts, shipping, defaultTaxRate, kleinunternehmer, wishlistEnabled] = await Promise.all([
getProducts(),
getShippingSettings(),
@@ -53,42 +38,16 @@ export async function ProductGrid({ searchParams }: { searchParams?: { priceBuck
);
}
const priceBuckets = buildPriceBuckets(allActiveProducts);
const activeBucketIndex = searchParams?.priceBucket ? Number(searchParams.priceBucket) : null;
const activeBucket = activeBucketIndex !== null ? priceBuckets[activeBucketIndex] : undefined;
const products = activeBucket
? allActiveProducts.filter((p) => p.price >= activeBucket.min && p.price <= activeBucket.max)
: allActiveProducts;
const catalogPrices = allActiveProducts.map((p) => p.price);
const catalogMin = Math.min(...catalogPrices);
const catalogMax = Math.max(...catalogPrices);
const minPrice = searchParams?.minPrice ? Number(searchParams.minPrice) : null;
const maxPrice = searchParams?.maxPrice ? Number(searchParams.maxPrice) : null;
const products = allActiveProducts.filter((p) => (minPrice === null || p.price >= minPrice) && (maxPrice === null || p.price <= maxPrice));
return (
<section className="w-full bg-bg-base flex flex-col pb-16 md:pb-20 px-[var(--layout-padding-x)]">
{priceBuckets.length > 0 && (
<div className="flex flex-wrap gap-2 w-full pb-6">
<Link
href="/shop"
className={`inline-flex items-center px-3 py-1.5 rounded-full text-body-sm font-semibold whitespace-nowrap border transition-colors ${
activeBucketIndex === null
? "bg-brand border-brand text-text-primary"
: "bg-bg-base border-border text-text-muted hover:border-brand hover:text-brand"
}`}
>
Alle Preise
</Link>
{priceBuckets.map((bucket, i) => (
<Link
key={i}
href={`/shop?priceBucket=${i}`}
className={`inline-flex items-center px-3 py-1.5 rounded-full text-body-sm font-semibold whitespace-nowrap border transition-colors ${
activeBucketIndex === i
? "bg-brand border-brand text-text-primary"
: "bg-bg-base border-border text-text-muted hover:border-brand hover:text-brand"
}`}
>
{bucket.label}
</Link>
))}
</div>
)}
{catalogMin !== catalogMax && <PriceRangeFilter catalogMin={catalogMin} catalogMax={catalogMax} />}
{products.length === 0 && <p className="text-body text-text-muted pb-6">Keine Produkte in dieser Preisspanne gefunden.</p>}
+1 -1
View File
@@ -22,7 +22,7 @@ export const metadata: Metadata = {
export default async function ShopPage({
searchParams,
}: {
searchParams: Promise<{ priceBucket?: string }>;
searchParams: Promise<{ minPrice?: string; maxPrice?: string }>;
}) {
const resolvedSearchParams = await searchParams;
return (