Replace order-filter chip wall with 3 compact selects; subgrid-align trust badges
OrderFilters.tsx: filter chips (7 status + 5 payment-status + N year options as pills) read as cluttered and ate a lot of vertical space. Replaced with 3 native <select>s in a row (a small Client Component just for the onChange→router.push navigation) — same URL-search-param filtering underneath, just a much more compact control. TrustRow.tsx: replaced the hardcoded min-h-[3.2rem] title reservation (a guess, and it visibly over-reserved space for single-line titles) with a real CSS Grid + `grid-template-rows: subgrid` — every badge's icon+title box shares the same row-track height (auto-sized to whichever title actually needs 2 lines), so descriptions align without any hardcoded value. The two structurally different layouts (icon-above-title below lg vs. icon-beside-a-title/description-stack at lg) now render as two separate blocks instead of fighting one shared shape. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+44
-32
@@ -11,41 +11,53 @@ export async function TrustRow() {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
// Below lg (1024px): badges sit side by side as one row (never wrap,
|
||||
// never stack to one-per-row) with icon ABOVE text, centered — icon
|
||||
// beside text (the lg: layout below) is what made a full row of 3
|
||||
// badges too wide below ~1024px in the first place (see git history:
|
||||
// originally lg:-gated for exactly this, then briefly tried flex-wrap,
|
||||
// which put an odd 3rd item alone on its own wrapped line and read as
|
||||
// disorganized). Putting the icon above the text instead shrinks each
|
||||
// badge down to just its text column's width, and — combined with
|
||||
// dropping whitespace-nowrap on the title/description below lg: so
|
||||
// long copy wraps within its own narrow column instead of forcing
|
||||
// overflow — a real row of 3 now fits without wrapping at all, so
|
||||
// there's no odd-item-out case to worry about. lg:+ reverts to the
|
||||
// original icon-beside-text row-with-dividers layout unchanged.
|
||||
<div className="w-full bg-bg-base flex justify-center py-8 px-[var(--layout-padding-x)]">
|
||||
<div className="flex flex-row justify-center gap-6 lg:gap-12 items-start lg:items-center w-full lg:w-auto">
|
||||
{items.map((item, i) => (
|
||||
<div key={item.id} className="flex flex-1 lg:flex-none items-center gap-6 lg:gap-12 min-w-0">
|
||||
{i > 0 && <div className="hidden lg:block h-10 w-px bg-border" />}
|
||||
<div className="flex flex-col items-center text-center gap-2 w-full min-w-0 lg:flex-row lg:items-center lg:text-left lg:gap-4 lg:w-auto">
|
||||
<div className="w-full bg-bg-base py-8 px-[var(--layout-padding-x)]">
|
||||
{/* Below lg (1024px): a CSS Grid with `grid-template-rows: subgrid` —
|
||||
not a fixed min-height guess — so every badge's "icon+title" box
|
||||
shares the SAME row track height (auto-sized to whichever
|
||||
badge's title actually needs 2 lines), and every description
|
||||
starts exactly at that row's bottom edge regardless of how many
|
||||
lines its own title happens to wrap to. Icon beside text (the
|
||||
lg: layout below) is what made a full row of 3 badges too wide
|
||||
below ~1024px in the first place (see git history: originally
|
||||
lg:-gated for exactly this, then briefly tried flex-wrap, which
|
||||
put an odd 3rd item alone on its own wrapped line and read as
|
||||
disorganized) — icon above text instead shrinks each badge down
|
||||
to just its text column's width, letting a real row of 3 fit
|
||||
without wrapping at all. Two structurally different layouts
|
||||
(icon-above-title here vs. icon-beside-a-title/description-
|
||||
stack at lg:) don't share one flexible markup shape cleanly, so
|
||||
this renders as two separate blocks (lg:hidden / hidden lg:flex)
|
||||
rather than fighting one shape across both breakpoints. */}
|
||||
<div
|
||||
className="lg:hidden grid justify-center gap-x-6 gap-y-1"
|
||||
style={{ gridTemplateColumns: `repeat(${items.length}, auto)`, gridTemplateRows: "repeat(2, auto)" }}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="grid row-span-2 justify-items-center" style={{ gridTemplateRows: "subgrid" }}>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="relative size-8 shrink-0">
|
||||
<Image alt="" src={item.icon} fill sizes="32px" className="object-contain" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 items-center min-w-0 lg:items-start">
|
||||
{/* min-h reserves 2 lines worth of height (text-body's
|
||||
line-height is 1.6rem) below lg: — titles are short
|
||||
enough that some wrap to 2 lines in their narrow
|
||||
stacked column and some don't, and without a reserved
|
||||
height the description below started at a different
|
||||
y-position from one badge to the next depending on
|
||||
whether its own title happened to wrap. Reset to
|
||||
min-h-0 at lg: (whitespace-nowrap there forces a
|
||||
single line anyway, so the reservation would just add
|
||||
unwanted empty space). */}
|
||||
<p className="font-semibold text-body text-text-primary min-h-[3.2rem] lg:min-h-0 lg:whitespace-nowrap">{item.title}</p>
|
||||
<p className="text-body-sm text-text-muted lg:whitespace-nowrap">{item.description}</p>
|
||||
<p className="font-semibold text-body text-text-primary text-center">{item.title}</p>
|
||||
</div>
|
||||
<p className="text-body-sm text-text-muted text-center">{item.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* lg+: original icon-beside-text row with dividers, unchanged. */}
|
||||
<div className="hidden lg:flex justify-center gap-12 items-center">
|
||||
{items.map((item, i) => (
|
||||
<div key={item.id} className="flex items-center gap-12">
|
||||
{i > 0 && <div className="h-10 w-px bg-border" />}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative size-8 shrink-0">
|
||||
<Image alt="" src={item.icon} fill sizes="32px" className="object-contain" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 items-start">
|
||||
<p className="font-semibold text-body text-text-primary whitespace-nowrap">{item.title}</p>
|
||||
<p className="text-body-sm text-text-muted whitespace-nowrap">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
// Three native <select>s in a row instead of a wall of filter chips (tried
|
||||
// first, reverted 2026-07-30 — with 7 status options + 5 payment-status
|
||||
// options + N years, a chip per option read as cluttered and ate a lot of
|
||||
// vertical space). Client Component only for the onChange→navigate wiring;
|
||||
// the actual filtering still happens server-side in page.tsx via the same
|
||||
// URL search params, so this stays a plain GET-style filter (shareable/
|
||||
// bookmarkable/back-button-safe), not client-side state.
|
||||
export function OrderFilters({
|
||||
statusOptions,
|
||||
paymentStatusOptions,
|
||||
years,
|
||||
}: {
|
||||
statusOptions: { value: string; label: string }[];
|
||||
paymentStatusOptions: { value: string; label: string }[];
|
||||
years: string[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const status = searchParams.get("status") ?? "";
|
||||
const paymentStatus = searchParams.get("paymentStatus") ?? "";
|
||||
const year = searchParams.get("year") ?? "";
|
||||
const hasAnyFilter = Boolean(status || paymentStatus || year);
|
||||
|
||||
function setParam(key: string, value: string) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) params.set(key, value);
|
||||
else params.delete(key);
|
||||
const qs = params.toString();
|
||||
router.push(qs ? `/konto/bestellungen?${qs}` : "/konto/bestellungen");
|
||||
}
|
||||
|
||||
const selectClass =
|
||||
"w-full sm:w-auto min-w-0 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";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 w-full">
|
||||
<select aria-label="Nach Fulfillment-Status filtern" className={selectClass} value={status} onChange={(e) => setParam("status", e.target.value)}>
|
||||
<option value="">Alle Status</option>
|
||||
{statusOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label="Nach Zahlungsstatus filtern"
|
||||
className={selectClass}
|
||||
value={paymentStatus}
|
||||
onChange={(e) => setParam("paymentStatus", e.target.value)}
|
||||
>
|
||||
<option value="">Alle Zahlungsstatus</option>
|
||||
{paymentStatusOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select aria-label="Nach Jahr filtern" className={selectClass} value={year} onChange={(e) => setParam("year", e.target.value)}>
|
||||
<option value="">Alle Jahre</option>
|
||||
{years.map((y) => (
|
||||
<option key={y} value={y}>
|
||||
{y}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{hasAnyFilter && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/konto/bestellungen")}
|
||||
className="text-body-sm font-semibold text-text-muted underline hover:text-brand transition-colors self-start sm:self-auto"
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import { OrderStatusBadge } from "../components/OrderStatusBadge";
|
||||
import { PaymentStatusBadge } from "../components/PaymentStatusBadge";
|
||||
import { LogoutButton } from "../components/LogoutButton";
|
||||
import { OrderFilters } from "./components/OrderFilters";
|
||||
|
||||
// robots: noindex — account area, same reasoning as /checkout.
|
||||
export const metadata: Metadata = {
|
||||
@@ -34,45 +36,8 @@ const PAYMENT_STATUS_FILTER_LABEL: Record<string, string> = {
|
||||
partially_refunded: "Teilweise erstattet",
|
||||
};
|
||||
|
||||
function FilterChip({
|
||||
href,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
active: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={`inline-flex items-center px-3 py-1.5 rounded-full text-body-sm font-semibold whitespace-nowrap border transition-colors ${
|
||||
active
|
||||
? "bg-brand border-brand text-text-primary"
|
||||
: "bg-bg-base border-border text-text-muted hover:border-brand hover:text-brand"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Every filter is expressed as plain URL search params (?status=…&paymentStatus=…&year=…)
|
||||
// rather than client-side state — this page stays a Server Component, each
|
||||
// chip is just a <Link> to the same route with one param changed/removed,
|
||||
// and the filtered result is shareable/bookmarkable/back-button-safe for
|
||||
// free. buildFilterHref() only ever changes ONE param at a time, keeping
|
||||
// the other active filters intact (a status filter + a year filter can
|
||||
// both be active together).
|
||||
function buildFilterHref(current: Record<string, string | undefined>, key: string, value: string | undefined): string {
|
||||
const params = new URLSearchParams();
|
||||
const next = { ...current, [key]: value };
|
||||
for (const [k, v] of Object.entries(next)) {
|
||||
if (v) params.set(k, v);
|
||||
}
|
||||
const qs = params.toString();
|
||||
return qs ? `/konto/bestellungen?${qs}` : "/konto/bestellungen";
|
||||
}
|
||||
const STATUS_OPTIONS = Object.entries(ORDER_STATUS_LABEL).map(([value, label]) => ({ value, label }));
|
||||
const PAYMENT_STATUS_OPTIONS = Object.entries(PAYMENT_STATUS_FILTER_LABEL).map(([value, label]) => ({ value, label }));
|
||||
|
||||
export default async function KontoBestellungenPage({
|
||||
searchParams,
|
||||
@@ -83,13 +48,11 @@ export default async function KontoBestellungenPage({
|
||||
if (!session) redirect("/konto/login");
|
||||
|
||||
const { status, paymentStatus, year } = await searchParams;
|
||||
const activeFilters = { status, paymentStatus, year };
|
||||
|
||||
const [orders, availableYears] = await Promise.all([
|
||||
getCustomerOrders(session.token, session.customer.id, true, { status, paymentStatus, year }),
|
||||
getCustomerOrderYears(session.token, session.customer.id),
|
||||
]);
|
||||
const hasAnyFilter = Boolean(status || paymentStatus || year);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -103,42 +66,9 @@ export default async function KontoBestellungenPage({
|
||||
</p>
|
||||
|
||||
{availableYears.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 w-full">
|
||||
<FilterChip href={buildFilterHref(activeFilters, "status", undefined)} active={!status}>
|
||||
Alle Status
|
||||
</FilterChip>
|
||||
{Object.entries(ORDER_STATUS_LABEL).map(([value, label]) => (
|
||||
<FilterChip key={value} href={buildFilterHref(activeFilters, "status", value)} active={status === value}>
|
||||
{label}
|
||||
</FilterChip>
|
||||
))}
|
||||
<div className="hidden sm:block w-px h-6 self-center bg-border" />
|
||||
<FilterChip href={buildFilterHref(activeFilters, "paymentStatus", undefined)} active={!paymentStatus}>
|
||||
Alle Zahlungsstatus
|
||||
</FilterChip>
|
||||
{Object.entries(PAYMENT_STATUS_FILTER_LABEL).map(([value, label]) => (
|
||||
<FilterChip key={value} href={buildFilterHref(activeFilters, "paymentStatus", value)} active={paymentStatus === value}>
|
||||
{label}
|
||||
</FilterChip>
|
||||
))}
|
||||
<div className="hidden sm:block w-px h-6 self-center bg-border" />
|
||||
<FilterChip href={buildFilterHref(activeFilters, "year", undefined)} active={!year}>
|
||||
Alle Jahre
|
||||
</FilterChip>
|
||||
{availableYears.map((y) => (
|
||||
<FilterChip key={y} href={buildFilterHref(activeFilters, "year", y)} active={year === y}>
|
||||
{y}
|
||||
</FilterChip>
|
||||
))}
|
||||
{hasAnyFilter && (
|
||||
<Link
|
||||
href="/konto/bestellungen"
|
||||
className="inline-flex items-center px-3 py-1.5 text-body-sm font-semibold text-text-muted underline hover:text-brand transition-colors"
|
||||
>
|
||||
Filter zurücksetzen
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<Suspense fallback={null}>
|
||||
<OrderFilters statusOptions={STATUS_OPTIONS} paymentStatusOptions={PAYMENT_STATUS_OPTIONS} years={availableYears} />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{availableYears.length === 0 ? (
|
||||
|
||||
Reference in New Issue
Block a user