Add price-range toggle filters to the shop overview

Scoped down from the original "category checkboxes + price slider"
sidebar proposal — Products have no category taxonomy today (only
Posts do), so a category filter isn't buildable against real data
yet. Price buckets are computed from the active catalog's actual
min/max price (not fixed round-number thresholds), so they stay
sensible regardless of what's actually being sold. Same URL-search-
param, plain-Link toggle-chip pattern as the blog/order-list filters.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-30 22:37:25 +00:00
parent 72c7b0454a
commit 8c5e284b6c
2 changed files with 69 additions and 5 deletions
+62 -3
View File
@@ -13,7 +13,29 @@ import { WishlistButton } from "../../components/WishlistButton";
// there's no reason to pay for a client fetch when a server one already
// gives faster first paint and no loading flash.
export async function ProductGrid() {
// 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 } }) {
const [allProducts, shipping, defaultTaxRate, kleinunternehmer, wishlistEnabled] = await Promise.all([
getProducts(),
getShippingSettings(),
@@ -21,9 +43,9 @@ export async function ProductGrid() {
getKleinunternehmer(),
getWishlistEnabled(),
]);
const products = allProducts.filter((p) => p.active);
const allActiveProducts = allProducts.filter((p) => p.active);
if (products.length === 0) {
if (allActiveProducts.length === 0) {
return (
<section className="w-full bg-bg-base flex flex-col items-center pb-16 md:pb-20 px-[var(--layout-padding-x)]">
<p className="text-body text-text-muted">Aktuell keine Produkte verfügbar.</p>
@@ -31,8 +53,45 @@ export async function ProductGrid() {
);
}
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;
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>
)}
{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. 4-up now only kicks in at lg
+7 -2
View File
@@ -19,12 +19,17 @@ export const metadata: Metadata = {
},
};
export default function ShopPage() {
export default async function ShopPage({
searchParams,
}: {
searchParams: Promise<{ priceBucket?: string }>;
}) {
const resolvedSearchParams = await searchParams;
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<ShopHeader />
<ProductGrid />
<ProductGrid searchParams={resolvedSearchParams} />
<TrustRow />
</main>
<Footer />