From 557f6a1abca865825ca8afad345b63822c959c69 Mon Sep 17 00:00:00 2001 From: Marco Date: Sat, 1 Aug 2026 08:15:58 +0000 Subject: [PATCH] Add product categories + shop sidebar filters, fix CTA button alignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/cart/components/RelatedProducts.tsx | 14 +- .../merkliste/components/MerklisteGrid.tsx | 5 + app/lib/__tests__/cartTotals.test.ts | 1 + app/lib/payload.ts | 9 ++ app/shop/components/CategoryFilter.tsx | 72 +++++++++ app/shop/components/PriceRangeFilter.tsx | 152 ++++++++++-------- app/shop/components/ProductGrid.tsx | 62 +++++-- app/shop/page.tsx | 2 +- 8 files changed, 228 insertions(+), 89 deletions(-) create mode 100644 app/shop/components/CategoryFilter.tsx diff --git a/app/cart/components/RelatedProducts.tsx b/app/cart/components/RelatedProducts.tsx index cd8bac0..517fbaf 100644 --- a/app/cart/components/RelatedProducts.tsx +++ b/app/cart/components/RelatedProducts.tsx @@ -134,7 +134,7 @@ export function RelatedProducts({ defaultTaxRate, kleinunternehmer }: { defaultT
-
+

{/* 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. */}

{anyLowStock ? "Nur noch wenige verfügbar" : null}

+ + {/* 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). */} +
+
diff --git a/app/konto/merkliste/components/MerklisteGrid.tsx b/app/konto/merkliste/components/MerklisteGrid.tsx index 57c2d68..2df9a14 100644 --- a/app/konto/merkliste/components/MerklisteGrid.tsx +++ b/app/konto/merkliste/components/MerklisteGrid.tsx @@ -88,6 +88,11 @@ export function MerklisteGrid({ {!kleinunternehmer && inkl. {taxRate}% MwSt.}

+ {/* 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). */} +
+ {/* No className override — AddToCartInlineButton's `className` prop REPLACES its whole default styling (`?? defaultClass`, not a merge), so passing just "w-full" here previously threw diff --git a/app/lib/__tests__/cartTotals.test.ts b/app/lib/__tests__/cartTotals.test.ts index dfdec5b..0575eeb 100644 --- a/app/lib/__tests__/cartTotals.test.ts +++ b/app/lib/__tests__/cartTotals.test.ts @@ -25,6 +25,7 @@ const product = (overrides: Partial = {}): Product => ({ maxQty: null, taxRatePercent: null, noShippingCost: false, + categories: [], ...overrides, }); diff --git a/app/lib/payload.ts b/app/lib/payload.ts index d06b94d..2cf1af2 100644 --- a/app/lib/payload.ts +++ b/app/lib/payload.ts @@ -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)), }; } diff --git a/app/shop/components/CategoryFilter.tsx b/app/shop/components/CategoryFilter.tsx new file mode 100644 index 0000000..ec5ac2f --- /dev/null +++ b/app/shop/components/CategoryFilter.tsx @@ -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 ( +
+ {categories.length > 1 && ( +
+

Kategorie

+ {categories.map((category) => ( + + ))} +
+ )} + +
+ ); +} diff --git a/app/shop/components/PriceRangeFilter.tsx b/app/shop/components/PriceRangeFilter.tsx index 2281eef..e7cd737 100644 --- a/app/shop/components/PriceRangeFilter.tsx +++ b/app/shop/components/PriceRangeFilter.tsx @@ -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 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 - * 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 ( -
+
{sidebar &&

Preis

} -
- - - {!sidebar && } +
+ {formatPrice(minPrice)} + {formatPrice(maxPrice)}
-
+
+
+
+ onMinInput(Number(e.currentTarget.value))} + onChange={() => commit(minPrice, maxPrice)} + className={thumbClasses} + /> + onMaxInput(Number(e.currentTarget.value))} + onChange={() => commit(minPrice, maxPrice)} + className={thumbClasses} + /> +
+ {hasFilter && ( - {hasFilter && ( - - )} -
- + )} +
); } diff --git a/app/shop/components/ProductGrid.tsx b/app/shop/components/ProductGrid.tsx index 5ddb1a9..4844ce0 100644 --- a/app/shop/components/ProductGrid.tsx +++ b/app/shop/components/ProductGrid.tsx @@ -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 (
- {/*