feat: gemeinsames App-Design mit Seitenleiste, globalem Theme und neuem Login

This commit is contained in:
opencode committed 2026-10-18 11:11:30 +02:00
1 parent be6aac86ce
commit fa1e8c2c33
15 files changed
+1248 -189

No files matched your search

+110
View File
@@ -0,0 +1,110 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import type { ReactNode } from "react";
const NAV = [
{ href: "/", label: "Gutscheine" },
{ href: "/vouchers/new", label: "Neu" },
{ href: "/contacts", label: "Kontakte" },
{ href: "/settings", label: "Einstellungen" },
] as const;
function isActive(pathname: string, href: string) {
if (href === "/") return pathname === "/";
return pathname === href || pathname.startsWith(`${href}/`);
}
export default function Shell({ children }: { children: ReactNode }) {
const pathname = usePathname();
const router = useRouter();
async function logout() {
await fetch("/api/auth/logout", { method: "POST" }).catch(() => {});
router.push("/login");
router.refresh();
}
return (
<div className="min-h-dvh md:flex">
<aside className="hidden w-60 shrink-0 flex-col border-r border-line bg-white px-4 py-6 md:flex">
<p className="flex items-center gap-2.5 px-2 text-[15px] font-semibold tracking-tight">
<span
aria-hidden="true"
className="grid h-8 w-8 place-items-center rounded-[10px] bg-brand-100 text-[15px] font-bold text-brand-800"
>
G
</span>
Gutscheintracker
</p>
<nav aria-label="Hauptmenü" className="mt-7 flex flex-col gap-1">
{NAV.map((item) => (
<Link
key={item.href}
href={item.href}
aria-current={isActive(pathname, item.href) ? "page" : undefined}
className={`rounded-[10px] px-3 py-2 text-sm font-medium transition-colors ${
isActive(pathname, item.href)
? "bg-brand-50 text-brand-800"
: "text-ink hover:bg-wash"
}`}
>
{item.label}
</Link>
))}
</nav>
<button
type="button"
onClick={logout}
className="mt-auto rounded-[10px] px-3 py-2 text-left text-sm font-medium text-muted transition-colors hover:bg-wash hover:text-ink"
>
Abmelden
</button>
</aside>
<div className="min-w-0 flex-1">
<header className="sticky top-0 flex items-center justify-between border-b border-line bg-white/95 px-4 py-3 backdrop-blur md:hidden">
<p className="flex items-center gap-2 text-sm font-semibold tracking-tight">
<span
aria-hidden="true"
className="grid h-7 w-7 place-items-center rounded-lg bg-brand-100 text-[13px] font-bold text-brand-800"
>
G
</span>
Gutscheintracker
</p>
<button
type="button"
onClick={logout}
className="rounded-lg px-3 py-1.5 text-sm font-medium text-muted hover:text-ink"
>
Abmelden
</button>
</header>
<div className="mx-auto w-full max-w-3xl px-4 pb-28 pt-6 md:px-6 md:pb-12">
{children}
</div>
<nav
aria-label="Hauptmenü"
className="fixed inset-x-0 bottom-0 grid grid-cols-4 border-t border-line bg-white/95 backdrop-blur md:hidden"
>
{NAV.map((item) => (
<Link
key={item.href}
href={item.href}
aria-current={isActive(pathname, item.href) ? "page" : undefined}
className={`px-1 py-2.5 text-center text-xs font-medium ${
isActive(pathname, item.href) ? "text-brand-700" : "text-muted"
}`}
>
{item.label}
</Link>
))}
</nav>
</div>
</div>
);
}
+75 -27
View File
@@ -1,10 +1,14 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import Shell from "../components/shell";
import { validateContact } from "@/lib/contacts";
type Contact = { id: number; name: string; email: string | null; telegramChatId: string | null };
const inputClass =
"mt-1.5 h-11 w-full rounded-field border border-line bg-white px-3.5 text-sm text-ink placeholder:text-muted/60";
export default function ContactsPage() {
const [contacts, setContacts] = useState<Contact[]>([]);
const [name, setName] = useState("");
@@ -39,34 +43,78 @@ export default function ContactsPage() {
}
return (
<main className="mx-auto max-w-xl space-y-4 p-6">
<Link href="/" className="text-sm underline">← Zurück</Link>
<h1 className="text-xl font-bold">Kontakte</h1>
<form onSubmit={onSubmit} className="space-y-3" noValidate>
<div>
<label htmlFor="name" className="block text-sm font-medium">Name</label>
<input id="name" value={name} onChange={(e) => setName(e.target.value)} className="mt-1 w-full rounded border px-3 py-2" />
<Shell>
<Link href="/" className="text-sm font-medium text-muted hover:text-ink">
← Zurück
</Link>
<h1 className="mt-2 text-2xl font-semibold tracking-tight">Kontakte</h1>
<p className="mt-1 text-sm text-muted">Mindestens E-Mail oder Telegram pro Kontakt.</p>
<form onSubmit={onSubmit} className="mt-5 rounded-card border border-line bg-white p-5 sm:p-6" noValidate>
<div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label htmlFor="name" className="block text-[13px] font-semibold">Name</label>
<input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="off"
placeholder="z. B. Mama"
aria-invalid={errors.includes("name")}
className={`${inputClass} ${errors.includes("name") ? "border-danger" : ""}`}
/>
</div>
<div>
<label htmlFor="email" className="block text-[13px] font-semibold">E-Mail</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
placeholder="name@beispiel.de"
aria-invalid={errors.includes("email")}
className={`${inputClass} ${errors.includes("email") ? "border-danger" : ""}`}
/>
</div>
<div>
<label htmlFor="telegramChatId" className="block text-[13px] font-semibold">Telegram-Chat-ID</label>
<input
id="telegramChatId"
value={telegramChatId}
onChange={(e) => setTelegramChatId(e.target.value)}
autoComplete="off"
placeholder="z. B. 123456789"
className={inputClass}
/>
</div>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium">E-Mail</label>
<input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="mt-1 w-full rounded border px-3 py-2" />
</div>
<div>
<label htmlFor="telegramChatId" className="block text-sm font-medium">Telegram-Chat-ID</label>
<input id="telegramChatId" value={telegramChatId} onChange={(e) => setTelegramChatId(e.target.value)} className="mt-1 w-full rounded border px-3 py-2" />
</div>
{errors.length > 0 && <p className="text-sm text-red-600">Ungültig: {errors.join(", ")}</p>}
<button type="submit" className="rounded bg-black px-4 py-2 text-white">Hinzufügen</button>
{errors.length > 0 && (
<p role="alert" className="mt-4 rounded-field bg-danger/10 px-3.5 py-2.5 text-sm font-medium text-danger">
Bitte prüfe die Eingaben: {errors.join(", ")}
</p>
)}
<button
type="submit"
className="mt-5 h-12 w-full rounded-field bg-brand-600 text-sm font-semibold text-white transition-colors hover:bg-brand-700 active:scale-[0.99] sm:w-auto sm:px-8"
>
Hinzufügen
</button>
</form>
<ul className="divide-y">
{contacts.map((c) => (
<li key={c.id} className="py-2 text-sm">
<span className="font-medium">{c.name}</span>
{c.email && <span> · {c.email}</span>}
{c.telegramChatId && <span> · Telegram {c.telegramChatId}</span>}
</li>
))}
</ul>
</main>
<section aria-label="Gespeicherte Kontakte" className="mt-5 rounded-card border border-line bg-white px-5 py-2">
{contacts.length === 0 ? (
<p className="py-3 text-sm text-muted">Noch keine Kontakte angelegt.</p>
) : (
<ul className="divide-y divide-line">
{contacts.map((c) => (
<li key={c.id} className="py-3 text-sm">
<span className="font-semibold">{c.name}</span>
{c.email && <span className="text-muted"> · {c.email}</span>}
{c.telegramChatId && <span className="text-muted"> · Telegram {c.telegramChatId}</span>}
</li>
))}
</ul>
)}
</section>
</Shell>
);
}
+41
View File
@@ -0,0 +1,41 @@
@import "tailwindcss";
@theme {
--font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--color-brand-50: #eef5f0;
--color-brand-100: #dcebe1;
--color-brand-600: #397b58;
--color-brand-700: #306e4e;
--color-brand-800: #285a41;
--color-ink: #202823;
--color-muted: #5f6d63;
--color-line: #dde5de;
--color-wash: #f2f5f2;
--color-danger: #9c3838;
--radius-card: 16px;
--radius-field: 10px;
}
body {
background-color: var(--color-wash);
color: var(--color-ink);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
:where(a, button, input, select, textarea):focus-visible {
outline: 3px solid rgb(57 123 88 / 0.35);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition: none !important;
animation: none !important;
}
}
+9 -2
View File
@@ -1,10 +1,17 @@
import type { ReactNode } from "react";
import { Geist } from "next/font/google";
import "./globals.css";
export const metadata = { title: "Gutscheintracker" };
const geist = Geist({ subsets: ["latin"], display: "swap" });
export const metadata = {
title: "Gutscheintracker",
description: "Gutscheine verwalten und rechtzeitig an das Ablaufdatum erinnern.",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="de">
<html lang="de" className={geist.className}>
<body>{children}</body>
</html>
);
+67 -21
View File
@@ -33,27 +33,73 @@ export default function LoginPage() {
}
return (
<main style={{ maxWidth: 360, margin: "4rem auto", padding: "0 1rem" }}>
<h1>Anmelden</h1>
<form onSubmit={onSubmit}>
<label>
Benutzer
<input value={user} onChange={(e) => setUser(e.target.value)} autoComplete="username" />
</label>
<label>
Passwort
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</label>
{error && <p role="alert">{error}</p>}
<button type="submit" disabled={loading}>
{loading ? "…" : "Anmelden"}
</button>
</form>
<main className="grid min-h-dvh place-items-center bg-wash px-5 py-9">
<div className="w-full max-w-[420px]">
<p className="mb-6 flex items-center gap-2.5 text-base font-semibold tracking-tight">
<span
aria-hidden="true"
className="grid h-9 w-9 place-items-center rounded-[11px] bg-brand-100 text-[17px] font-bold text-brand-800"
>
G
</span>
Gutscheintracker
</p>
<section
aria-labelledby="login-title"
className="rounded-card border border-line bg-white p-8 shadow-[0_16px_38px_rgba(32,55,39,0.07)]"
>
<h1 id="login-title" className="text-[27px] font-semibold leading-snug tracking-tight">
Willkommen zurück
</h1>
<p className="mb-7 mt-2 text-sm leading-relaxed text-muted">
Melde dich mit eurem gemeinsamen Haushaltszugang an.
</p>
<form onSubmit={onSubmit} className="grid gap-[18px]" noValidate>
<div className="grid gap-[7px]">
<label htmlFor="user" className="text-[13px] font-semibold">
Benutzername
</label>
<input
id="user"
value={user}
onChange={(e) => setUser(e.target.value)}
autoComplete="username"
placeholder="haushalt"
className="h-[46px] rounded-field border border-line bg-white px-3.5 text-sm text-ink placeholder:text-muted/70"
/>
</div>
<div className="grid gap-[7px]">
<label htmlFor="password" className="text-[13px] font-semibold">
Passwort
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
placeholder="Dein Passwort"
className="h-[46px] rounded-field border border-line bg-white px-3.5 text-sm text-ink placeholder:text-muted/70"
/>
</div>
{error && (
<p role="alert" className="rounded-field bg-danger/10 px-3.5 py-2.5 text-sm font-medium text-danger">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="h-[47px] rounded-field bg-brand-600 text-sm font-semibold text-white transition-colors hover:bg-brand-700 active:scale-[0.99] disabled:opacity-60"
>
{loading ? "Wird geprüft …" : "Anmelden"}
</button>
</form>
<p className="mt-[19px] text-center text-xs text-muted">
Privater Zugang für euren Haushalt
</p>
</section>
</div>
</main>
);
}
+61 -26
View File
@@ -1,7 +1,8 @@
import Link from "next/link";
import { Suspense } from "react";
import Shell from "./components/shell";
import { getDb } from "@/lib/db";
import { getVoucherStatus } from "@/lib/voucher-status";
import { getVoucherStatus, type VoucherStatus } from "@/lib/voucher-status";
import { formatEuro, isExpiringSoon } from "@/lib/vouchers";
const FILTERS = [
@@ -12,13 +13,25 @@ const FILTERS = [
{ 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">
<ul aria-label="lädt" className="grid gap-3">
{[0, 1, 2].map((i) => (
<li key={i} className="animate-pulse rounded border p-4">
<div className="h-4 w-1/3 bg-gray-200" />
<div className="mt-2 h-3 w-1/4 bg-gray-100" />
<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>
@@ -38,26 +51,38 @@ async function VoucherList({ filter }: { filter: string }) {
});
if (filtered.length === 0) {
return (
<div className="rounded border p-8 text-center">
<p>Keine Gutscheine gefunden.</p>
<Link href="/vouchers/new" className="mt-4 inline-block rounded bg-black px-4 py-2 text-white">
<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="space-y-2">
{filtered.map((v) => (
<li key={v.id} className="rounded border p-4">
<Link href={`/vouchers/${v.id}`} className="font-medium underline">
{v.name}
</Link>
<p className="text-sm text-gray-600">
{v.anbieter} · {formatEuro(v.restCents)} / {formatEuro(v.wertCents)} · {getVoucherStatus(v)}
</p>
</li>
))}
<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>
);
}
@@ -65,20 +90,30 @@ async function VoucherList({ filter }: { filter: string }) {
export default async function Home({ searchParams }: { searchParams: Promise<{ filter?: string }> }) {
const { filter = "" } = await searchParams;
return (
<main className="mx-auto max-w-2xl space-y-4 p-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold">Gutscheine</h1>
<Link href="/vouchers/new" className="rounded bg-black px-4 py-2 text-white">
<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="flex gap-2" aria-label="Filter">
<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 border px-3 py-1 text-sm ${filter === f.key ? "bg-black text-white" : ""}`}
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>
@@ -87,6 +122,6 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ f
<Suspense fallback={<Skeleton />}>
<VoucherList filter={filter} />
</Suspense>
</main>
</Shell>
);
}
+37 -26
View File
@@ -1,39 +1,50 @@
import Link from "next/link";
import Shell from "../components/shell";
import { getDb } from "@/lib/db";
import { DEFAULT_TAGE_VORHER } from "@/lib/settings";
import { TestButtons } from "./test-buttons";
export const dynamic = "force-dynamic";
function Card({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="rounded-card border border-line bg-white p-5">
<h2 className="text-[15px] font-semibold tracking-tight">{title}</h2>
<div className="mt-2 text-sm text-muted">{children}</div>
</section>
);
}
export default async function SettingsPage() {
const db = getDb();
const last = await db.notificationLog.findFirst({ orderBy: { sentAt: "desc" } });
return (
<main className="mx-auto max-w-xl space-y-4 p-6">
<Link href="/" className="text-sm underline">← Zurück</Link>
<h1 className="text-xl font-bold">Einstellungen</h1>
<section className="rounded border p-4">
<h2 className="font-medium">Standard-Erinnerungstage</h2>
<p className="text-sm text-gray-600">Neue Gutscheine erhalten diese Erinnerungsregeln: {DEFAULT_TAGE_VORHER.join(", ")} Tage vorher.</p>
</section>
<TestButtons />
<section className="rounded border p-4 text-sm">
<h2 className="font-medium">Letzter Cron-Lauf</h2>
{last ? (
<p>
{last.sentAt.toLocaleString("de-DE")} · {last.kanal} an {last.empfaenger} · {last.status}
{last.fehler ? ` · Fehler: ${last.fehler}` : ""}
</p>
) : (
<p>Noch keine Benachrichtigung versendet.</p>
)}
</section>
<section className="rounded border p-4 text-sm text-gray-600">
<h2 className="font-medium text-black">Konfiguration (.env)</h2>
<p>SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, MAIL_FROM, TELEGRAM_BOT_TOKEN, CRON_SECRET, APP_URL.</p>
<h2 className="mt-2 font-medium text-black">Backup</h2>
<p>SQLite-Datei sichern: Volume <code>gutschein-data</code> bzw. Datei <code>/data/gutscheine.db</code> regelmäßig kopieren.</p>
</section>
</main>
<Shell>
<Link href="/" className="text-sm font-medium text-muted hover:text-ink">
← Zurück
</Link>
<h1 className="mt-2 text-2xl font-semibold tracking-tight">Einstellungen</h1>
<p className="mt-1 text-sm text-muted">Erinnerungen und Benachrichtigungen prüfen.</p>
<div className="mt-5 grid gap-4">
<Card title="Standard-Erinnerungstage">
<p>Neue Gutscheine erhalten diese Erinnerungsregeln: {DEFAULT_TAGE_VORHER.join(", ")} Tage vorher.</p>
</Card>
<TestButtons />
<Card title="Letzter Cron-Lauf">
{last ? (
<p>
{last.sentAt.toLocaleString("de-DE")} · {last.kanal} an {last.empfaenger} · {last.status}
{last.fehler ? ` · Fehler: ${last.fehler}` : ""}
</p>
) : (
<p>Noch keine Benachrichtigung versendet.</p>
)}
</Card>
<Card title="Konfiguration und Backup">
<p>SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, MAIL_FROM, TELEGRAM_BOT_TOKEN, CRON_SECRET, APP_URL.</p>
<p className="mt-2">SQLite-Datei sichern: Volume <code>gutschein-data</code> bzw. Datei <code>/data/gutscheine.db</code> regelmäßig kopieren.</p>
</Card>
</div>
</Shell>
);
}
+36 -12
View File
@@ -29,19 +29,43 @@ export function TestButtons() {
}
return (
<section className="space-y-3 rounded border p-4">
<h2 className="font-medium">Versand testen</h2>
<div>
<label htmlFor="mail" className="block text-sm font-medium">E-Mail-Empfänger</label>
<input id="mail" value={mail} onChange={(e) => setMail(e.target.value)} className="mt-1 w-full rounded border px-3 py-2" />
<section className="rounded-card border border-line bg-white p-5">
<h2 className="text-[15px] font-semibold tracking-tight">Versand testen</h2>
<div className="mt-3 grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="mail" className="block text-[13px] font-semibold">E-Mail-Empfänger</label>
<input
id="mail"
value={mail}
onChange={(e) => setMail(e.target.value)}
className="mt-1.5 h-11 w-full rounded-field border border-line bg-white px-3.5 text-sm text-ink"
/>
<button
type="button"
onClick={testMail}
className="mt-2 h-10 w-full rounded-field bg-brand-600 text-sm font-semibold text-white transition-colors hover:bg-brand-700 active:scale-[0.99]"
>
Test-Mail senden
</button>
</div>
<div>
<label htmlFor="chatId" className="block text-[13px] font-semibold">Telegram-Chat-ID</label>
<input
id="chatId"
value={chatId}
onChange={(e) => setChatId(e.target.value)}
className="mt-1.5 h-11 w-full rounded-field border border-line bg-white px-3.5 text-sm text-ink"
/>
<button
type="button"
onClick={testTelegram}
className="mt-2 h-10 w-full rounded-field bg-brand-600 text-sm font-semibold text-white transition-colors hover:bg-brand-700 active:scale-[0.99]"
>
Test-Telegram senden
</button>
</div>
</div>
<button type="button" onClick={testMail} className="rounded bg-black px-4 py-2 text-white">Test-Mail senden</button>
<div>
<label htmlFor="chatId" className="block text-sm font-medium">Telegram-Chat-ID</label>
<input id="chatId" value={chatId} onChange={(e) => setChatId(e.target.value)} className="mt-1 w-full rounded border px-3 py-2" />
</div>
<button type="button" onClick={testTelegram} className="rounded bg-black px-4 py-2 text-white">Test-Telegram senden</button>
{result && <p role="status" className="text-sm">{result}</p>}
{result && <p role="status" className="mt-3 text-sm text-muted">{result}</p>}
</section>
);
}
+56 -22
View File
@@ -1,6 +1,7 @@
"use client";
import { use, useEffect, useState } from "react";
import Link from "next/link";
import Shell from "../../components/shell";
import { formatEuro } from "@/lib/vouchers";
import { getVoucherStatus } from "@/lib/voucher-status";
@@ -18,6 +19,15 @@ type Voucher = {
contacts?: { contact: { id: number; name: string; email: string | null; telegramChatId: string | null } }[];
};
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-[120px_1fr] gap-3 py-2.5">
<dt className="text-sm text-muted">{label}</dt>
<dd className="text-sm font-medium">{children}</dd>
</div>
);
}
export default function VoucherDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const [v, setV] = useState<Voucher | null>(null);
@@ -41,37 +51,61 @@ export default function VoucherDetailPage({ params }: { params: Promise<{ id: st
if (res.ok) setV(await res.json());
}
if (loading) return <main className="mx-auto max-w-xl animate-pulse space-y-2 p-6"><div className="h-6 w-1/2 bg-gray-200" /><div className="h-4 w-1/3 bg-gray-100" /></main>;
if (loading)
return (
<Shell>
<div className="animate-pulse rounded-card border border-line bg-white p-6">
<div className="h-6 w-1/2 rounded bg-line" />
<div className="mt-2 h-4 w-1/3 rounded bg-wash" />
</div>
</Shell>
);
if (error || !v)
return (
<main className="mx-auto max-w-xl space-y-4 p-6">
<p>{error}</p>
<Link href="/" className="underline">Zurück zur Liste</Link>
</main>
<Shell>
<div className="rounded-card border border-line bg-white p-8 text-center">
<p className="text-sm text-muted">{error}</p>
<Link href="/" className="mt-4 inline-block text-sm font-semibold text-brand-700 underline">
Zurück zur Liste
</Link>
</div>
</Shell>
);
const status = getVoucherStatus({
status: v.status,
ablaufdatum: v.ablaufdatum ? new Date(v.ablaufdatum) : null,
});
return (
<main className="mx-auto max-w-xl space-y-3 p-6">
<Link href="/" className="text-sm underline">← Zurück</Link>
<h1 className="text-xl font-bold">{v.name}</h1>
<dl className="space-y-1 text-sm">
<div><dt className="font-medium">Anbieter</dt><dd>{v.anbieter}</dd></div>
<div><dt className="font-medium">Wert</dt><dd>{formatEuro(v.wertCents)}</dd></div>
<div><dt className="font-medium">Rest</dt><dd>{formatEuro(v.restCents)}</dd></div>
<div><dt className="font-medium">Status</dt><dd>{getVoucherStatus({ status: v.status, ablaufdatum: v.ablaufdatum ? new Date(v.ablaufdatum) : null })}</dd></div>
{v.ablaufdatum && <div><dt className="font-medium">Ablaufdatum</dt><dd>{new Date(v.ablaufdatum).toLocaleDateString("de-DE")}</dd></div>}
{v.codeText && <div><dt className="font-medium">Code</dt><dd>{v.codeText}</dd></div>}
{v.kategorie && <div><dt className="font-medium">Kategorie</dt><dd>{v.kategorie}</dd></div>}
{v.notiz && <div><dt className="font-medium">Notiz</dt><dd>{v.notiz}</dd></div>}
{v.contacts && v.contacts.length > 0 && (
<div><dt className="font-medium">Empfänger</dt><dd>{v.contacts.map((c) => c.contact.name).join(", ")}</dd></div>
<Shell>
<Link href="/" className="text-sm font-medium text-muted hover:text-ink">
← Zurück
</Link>
<h1 className="mt-2 text-2xl font-semibold tracking-tight">{v.name}</h1>
<section aria-label="Details" className="mt-4 divide-y divide-line rounded-card border border-line bg-white px-5 py-2">
<Row label="Anbieter">{v.anbieter}</Row>
<Row label="Wert">{formatEuro(v.wertCents)}</Row>
<Row label="Restguthaben">{formatEuro(v.restCents)}</Row>
<Row label="Status">{status === "aktiv" ? "Aktiv" : status === "abgelaufen" ? "Abgelaufen" : "Eingelöst"}</Row>
{v.ablaufdatum && (
<Row label="Ablaufdatum">{new Date(v.ablaufdatum).toLocaleDateString("de-DE")}</Row>
)}
</dl>
{v.codeText && <Row label="Code">{v.codeText}</Row>}
{v.kategorie && <Row label="Kategorie">{v.kategorie}</Row>}
{v.notiz && <Row label="Notiz">{v.notiz}</Row>}
{v.contacts && v.contacts.length > 0 && (
<Row label="Empfänger">{v.contacts.map((c) => c.contact.name).join(", ")}</Row>
)}
</section>
{v.status !== "eingeloest" && (
<button onClick={redeem} className="rounded bg-black px-4 py-2 text-white">
<button
onClick={redeem}
className="mt-4 h-12 w-full rounded-field bg-brand-600 text-sm font-semibold text-white transition-colors hover:bg-brand-700 active:scale-[0.99] sm:w-auto sm:px-8"
>
Einlösen
</button>
)}
</main>
</Shell>
);
}
+85 -48
View File
@@ -1,21 +1,26 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import Shell from "../../components/shell";
import { validateVoucher } from "@/lib/vouchers";
// Erweiterbar für Task 5 (Kontakte): zusätzliches Feld contactIds + <ContactSelect />
// ohne bestehende Felder anzufassen — Felder als Konfiguration belassen.
const FIELDS = [
{ name: "name", label: "Name", type: "text" },
{ name: "anbieter", label: "Anbieter", type: "text" },
{ name: "wertCents", label: "Wert (Cent)", type: "number" },
{ name: "restCents", label: "Rest (Cent, leer = Wert)", type: "number" },
{ name: "ablaufdatum", label: "Ablaufdatum", type: "date" },
{ name: "codeText", label: "Code", type: "text" },
{ name: "kategorie", label: "Kategorie", type: "text" },
{ name: "notiz", label: "Notiz", type: "text" },
{ name: "name", label: "Name", type: "text", placeholder: "z. B. Therme Erding" },
{ name: "anbieter", label: "Anbieter", type: "text", placeholder: "z. B. Therme Erding" },
{ name: "wertCents", label: "Wert (Cent)", type: "number", placeholder: "5000" },
{ name: "restCents", label: "Rest (Cent, leer = Wert)", type: "number", placeholder: "" },
{ name: "ablaufdatum", label: "Ablaufdatum", type: "date", placeholder: "" },
{ name: "codeText", label: "Code", type: "text", placeholder: "Code oder Nummer" },
{ name: "kategorie", label: "Kategorie", type: "text", placeholder: "z. B. Erlebnis" },
{ name: "notiz", label: "Notiz", type: "text", placeholder: "Optionale Notiz" },
] as const;
const inputClass =
"mt-1.5 h-11 w-full rounded-field border border-line bg-white px-3.5 text-sm text-ink placeholder:text-muted/60";
export default function NewVoucherPage() {
const router = useRouter();
const [values, setValues] = useState<Record<string, string>>({});
@@ -69,27 +74,37 @@ export default function NewVoucherPage() {
}
return (
<main className="mx-auto max-w-xl space-y-4 p-6">
<h1 className="text-xl font-bold">Gutschein anlegen</h1>
<form onSubmit={onSubmit} className="space-y-3" noValidate>
{FIELDS.map((f) => (
<div key={f.name}>
<label htmlFor={f.name} className="block text-sm font-medium">
{f.label}
</label>
<input
id={f.name}
name={f.name}
type={f.type}
value={values[f.name] ?? ""}
onChange={(e) => set(f.name, e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
/>
{errors.includes(f.name) && <p className="mt-1 text-sm text-red-600">Ungültig: {f.name}</p>}
</div>
))}
<div>
<label htmlFor="reminderDays" className="block text-sm font-medium">
<Shell>
<Link href="/" className="text-sm font-medium text-muted hover:text-ink">
← Zurück
</Link>
<h1 className="mt-2 text-2xl font-semibold tracking-tight">Gutschein anlegen</h1>
<p className="mt-1 text-sm text-muted">Alle Angaben lassen sich später noch ändern.</p>
<form onSubmit={onSubmit} className="mt-5 rounded-card border border-line bg-white p-5 sm:p-6" noValidate>
<div className="grid gap-4 sm:grid-cols-2">
{FIELDS.map((f) => (
<div key={f.name} className={f.name === "notiz" ? "sm:col-span-2" : undefined}>
<label htmlFor={f.name} className="block text-[13px] font-semibold">
{f.label}
</label>
<input
id={f.name}
name={f.name}
type={f.type}
value={values[f.name] ?? ""}
placeholder={f.placeholder || undefined}
onChange={(e) => set(f.name, e.target.value)}
aria-invalid={errors.includes(f.name)}
className={`${inputClass} ${errors.includes(f.name) ? "border-danger" : ""}`}
/>
{errors.includes(f.name) && (
<p className="mt-1 text-[13px] font-medium text-danger">Bitte prüfe dieses Feld.</p>
)}
</div>
))}
</div>
<div className="mt-4">
<label htmlFor="reminderDays" className="block text-[13px] font-semibold">
Erinnerung (Tage vorher, Kommaliste)
</label>
<input
@@ -99,30 +114,52 @@ export default function NewVoucherPage() {
value={reminderDays}
onChange={(e) => setReminderDays(e.target.value)}
placeholder="30,6,1"
className="mt-1 w-full rounded border px-3 py-2"
aria-invalid={errors.includes("reminderDays") || errors.includes("reminder")}
className={`${inputClass} ${errors.includes("reminderDays") || errors.includes("reminder") ? "border-danger" : ""}`}
/>
{errors.includes("reminderDays") && <p className="mt-1 text-sm text-red-600">Ungültig: reminderDays</p>}
{(errors.includes("reminderDays") || errors.includes("reminder")) && (
<p className="mt-1 text-[13px] font-medium text-danger">Mindestens ein gültiger Tag nötig.</p>
)}
</div>
<section id="kontakte">
<h2 className="text-sm font-medium">Empfänger</h2>
{contacts.map((c) => (
<label key={c.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={contactIds.includes(c.id)}
onChange={(e) =>
setContactIds((ids) => (e.target.checked ? [...ids, c.id] : ids.filter((x) => x !== c.id)))
}
/>
{c.name}
</label>
))}
<section id="kontakte" aria-label="Empfänger" className="mt-5 rounded-field bg-wash p-4">
<h2 className="text-[13px] font-semibold">Empfänger</h2>
<p className="mt-0.5 text-[13px] text-muted">Wer soll an diesen Gutschein erinnert werden?</p>
{contacts.length === 0 ? (
<p className="mt-2 text-[13px] text-muted">
Noch keine Kontakte. Lege sie unter <Link href="/contacts" className="font-semibold text-brand-700 underline">Kontakte</Link> an.
</p>
) : (
<ul className="mt-2 grid gap-1.5">
{contacts.map((c) => (
<li key={c.id}>
<label className="flex cursor-pointer items-center gap-2.5 rounded-lg px-1 py-1 text-sm hover:bg-white">
<input
type="checkbox"
checked={contactIds.includes(c.id)}
onChange={(e) =>
setContactIds((ids) => (e.target.checked ? [...ids, c.id] : ids.filter((x) => x !== c.id)))
}
className="h-4 w-4 accent-brand-700"
/>
{c.name}
</label>
</li>
))}
</ul>
)}
</section>
{serverError && <p className="text-sm text-red-600">{serverError}</p>}
<button type="submit" className="rounded bg-black px-4 py-2 text-white">
{serverError && (
<p role="alert" className="mt-4 rounded-field bg-danger/10 px-3.5 py-2.5 text-sm font-medium text-danger">
{serverError}
</p>
)}
<button
type="submit"
className="mt-5 h-12 w-full rounded-field bg-brand-600 text-sm font-semibold text-white transition-colors hover:bg-brand-700 active:scale-[0.99] sm:w-auto sm:px-8"
>
Speichern
</button>
</form>
</main>
</Shell>
);
}
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+658 -4
View File
@@ -7,13 +7,15 @@
"name": "gutscheintracker",
"dependencies": {
"@prisma/client": "6.5.0",
"@tailwindcss/postcss": "^4.3.3",
"bcryptjs": "2.4.3",
"jose": "5.9.6",
"next": "15.3.0",
"nodemailer": "6.9.14",
"prisma": "6.5.0",
"react": "19.0.0",
"react-dom": "19.0.0"
"react-dom": "19.0.0",
"tailwindcss": "^4.3.3"
},
"devDependencies": {
"@types/bcryptjs": "2.4.6",
@@ -25,6 +27,18 @@
"vitest": "3.0.0"
}
},
"node_modules/@alloc/quick-lru": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.3.0.tgz",
"integrity": "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
@@ -965,13 +979,51 @@
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@@ -1619,6 +1671,302 @@
"tslib": "^2.8.0"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
"integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"enhanced-resolve": "^5.24.1",
"jiti": "^2.7.0",
"lightningcss": "1.32.0",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.3.3"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
"integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
"license": "MIT",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.3.3",
"@tailwindcss/oxide-darwin-arm64": "4.3.3",
"@tailwindcss/oxide-darwin-x64": "4.3.3",
"@tailwindcss/oxide-freebsd-x64": "4.3.3",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
"@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
"@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
"@tailwindcss/oxide-linux-x64-musl": "4.3.3",
"@tailwindcss/oxide-wasm32-wasi": "4.3.3",
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
"@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
"integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
"integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
"integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
"integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
"integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
"integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
"integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
"integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
"integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
"integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
"@emnapi/runtime",
"@tybys/wasm-util",
"@emnapi/wasi-threads",
"tslib"
],
"cpu": [
"wasm32"
],
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.11.1",
"@emnapi/runtime": "^1.11.1",
"@emnapi/wasi-threads": "^1.2.2",
"@napi-rs/wasm-runtime": "^1.1.4",
"@tybys/wasm-util": "^0.10.2",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
"integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
"integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/postcss": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.3.tgz",
"integrity": "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==",
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@tailwindcss/node": "4.3.3",
"@tailwindcss/oxide": "4.3.3",
"postcss": "^8.5.16",
"tailwindcss": "4.3.3"
}
},
"node_modules/@tailwindcss/postcss/node_modules/postcss": {
"version": "8.5.30",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz",
"integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.20",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
@@ -1941,11 +2289,23 @@
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/enhanced-resolve": {
"version": "5.26.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz",
"integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.3.3"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
@@ -2058,6 +2418,21 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jose": {
"version": "5.9.6",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz",
@@ -2067,6 +2442,267 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
@@ -2078,7 +2714,6 @@
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
@@ -2456,6 +3091,25 @@
}
}
},
"node_modules/tailwindcss": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+3 -1
View File
@@ -9,13 +9,15 @@
},
"dependencies": {
"@prisma/client": "6.5.0",
"@tailwindcss/postcss": "^4.3.3",
"bcryptjs": "2.4.3",
"jose": "5.9.6",
"next": "15.3.0",
"nodemailer": "6.9.14",
"prisma": "6.5.0",
"react": "19.0.0",
"react-dom": "19.0.0"
"react-dom": "19.0.0",
"tailwindcss": "^4.3.3"
},
"devDependencies": {
"@types/bcryptjs": "2.4.6",
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
BIN
View File
Binary file not shown.