Files
gutscheintracker/app/page.tsx
T

128 lines
4.5 KiB
TypeScript

import Link from "next/link";
import { Suspense } from "react";
import Shell from "./components/shell";
import { getDb } from "@/lib/db";
import { getVoucherStatus, type VoucherStatus } from "@/lib/voucher-status";
import { formatEuro, isExpiringSoon } from "@/lib/vouchers";
const FILTERS = [
{ key: "", label: "Alle" },
{ key: "aktiv", label: "Aktiv" },
{ key: "bald", label: "Bald ablaufend" },
{ key: "abgelaufen", label: "Abgelaufen" },
{ key: "eingeloest", label: "Eingelöst" },
] as const;
const STATUS_STYLE: Record<VoucherStatus, string> = {
aktiv: "bg-brand-50 text-brand-800",
abgelaufen: "bg-danger/10 text-danger",
eingeloest: "bg-wash text-muted",
};
function statusLabel(s: VoucherStatus) {
if (s === "aktiv") return "Aktiv";
if (s === "abgelaufen") return "Abgelaufen";
return "Eingelöst";
}
function Skeleton() {
return (
<ul aria-label="lädt" className="grid gap-3">
{[0, 1, 2].map((i) => (
<li key={i} className="animate-pulse rounded-card border border-line bg-white p-5">
<div className="h-4 w-1/3 rounded bg-line" />
<div className="mt-2 h-3 w-1/4 rounded bg-wash" />
</li>
))}
</ul>
);
}
async function VoucherList({ filter }: { filter: string }) {
const db = getDb();
const all = await db.voucher.findMany({ orderBy: { createdAt: "desc" } });
const filtered = all.filter((v) => {
const s = getVoucherStatus(v);
if (filter === "eingeloest") return s === "eingeloest";
if (filter === "abgelaufen") return s === "abgelaufen";
if (filter === "bald") return s === "aktiv" && isExpiringSoon(v.ablaufdatum);
if (filter === "aktiv") return s === "aktiv";
return true;
});
if (filtered.length === 0) {
return (
<div className="rounded-card border border-line bg-white p-10 text-center">
<p className="text-sm text-muted">Keine Gutscheine gefunden.</p>
<Link
href="/vouchers/new"
className="mt-4 inline-block rounded-field bg-brand-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-brand-700"
>
Gutschein anlegen
</Link>
</div>
);
}
return (
<ul className="grid gap-3">
{filtered.map((v) => {
const s = getVoucherStatus(v);
return (
<li key={v.id} className="rounded-card border border-line bg-white p-5 transition-shadow hover:shadow-[0_8px_24px_rgba(32,55,39,0.08)]">
<div className="flex items-start justify-between gap-3">
<Link href={`/vouchers/${v.id}`} className="text-[15px] font-semibold tracking-tight hover:text-brand-700">
{v.name}
</Link>
<span className={`shrink-0 rounded-full px-2.5 py-1 text-xs font-semibold ${STATUS_STYLE[s]}`}>
{statusLabel(s)}
</span>
</div>
<p className="mt-1.5 text-sm text-muted">
{v.anbieter} · {formatEuro(v.restCents)} von {formatEuro(v.wertCents)}
{v.ablaufdatum ? ` · bis ${v.ablaufdatum.toLocaleDateString("de-DE")}` : ""}
</p>
</li>
);
})}
</ul>
);
}
export default async function Home({ searchParams }: { searchParams: Promise<{ filter?: string }> }) {
const { filter = "" } = await searchParams;
return (
<Shell>
<div className="mb-5 flex items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Gutscheine</h1>
<p className="mt-1 text-sm text-muted">Alle Gutscheine eures Haushalts im Blick.</p>
</div>
<Link
href="/vouchers/new"
className="shrink-0 rounded-field bg-brand-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-brand-700"
>
Neu
</Link>
</div>
<nav className="mb-5 flex flex-wrap gap-2" aria-label="Filter">
{FILTERS.map((f) => (
<Link
key={f.key}
href={f.key ? `/?filter=${f.key}` : "/"}
aria-current={filter === f.key ? "page" : undefined}
className={`rounded-full border px-3.5 py-1.5 text-[13px] font-medium transition-colors ${
filter === f.key
? "border-brand-700 bg-brand-600 text-white"
: "border-line bg-white text-ink hover:border-brand-600 hover:text-brand-700"
}`}
>
{f.label}
</Link>
))}
</nav>
<Suspense fallback={<Skeleton />}>
<VoucherList filter={filter} />
</Suspense>
</Shell>
);
}