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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user