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 (
- {/*