feat: gemeinsames App-Design mit Seitenleiste, globalem Theme und neuem Login
This commit is contained in:
1 parent
be6aac86ce
commit
fa1e8c2c33
15 files changed
+1248
-189
No files matched your search
@@ -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
@@ -1,10 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import Shell from "../components/shell";
|
||||||
import { validateContact } from "@/lib/contacts";
|
import { validateContact } from "@/lib/contacts";
|
||||||
|
|
||||||
type Contact = { id: number; name: string; email: string | null; telegramChatId: string | null };
|
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() {
|
export default function ContactsPage() {
|
||||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
@@ -39,34 +43,78 @@ export default function ContactsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-xl space-y-4 p-6">
|
<Shell>
|
||||||
<Link href="/" className="text-sm underline">← Zurück</Link>
|
<Link href="/" className="text-sm font-medium text-muted hover:text-ink">
|
||||||
<h1 className="text-xl font-bold">Kontakte</h1>
|
← Zurück
|
||||||
<form onSubmit={onSubmit} className="space-y-3" noValidate>
|
</Link>
|
||||||
<div>
|
<h1 className="mt-2 text-2xl font-semibold tracking-tight">Kontakte</h1>
|
||||||
<label htmlFor="name" className="block text-sm font-medium">Name</label>
|
<p className="mt-1 text-sm text-muted">Mindestens E-Mail oder Telegram pro Kontakt.</p>
|
||||||
<input id="name" value={name} onChange={(e) => setName(e.target.value)} className="mt-1 w-full rounded border px-3 py-2" />
|
<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>
|
||||||
<div>
|
{errors.length > 0 && (
|
||||||
<label htmlFor="email" className="block text-sm font-medium">E-Mail</label>
|
<p role="alert" className="mt-4 rounded-field bg-danger/10 px-3.5 py-2.5 text-sm font-medium text-danger">
|
||||||
<input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="mt-1 w-full rounded border px-3 py-2" />
|
Bitte prüfe die Eingaben: {errors.join(", ")}
|
||||||
</div>
|
</p>
|
||||||
<div>
|
)}
|
||||||
<label htmlFor="telegramChatId" className="block text-sm font-medium">Telegram-Chat-ID</label>
|
<button
|
||||||
<input id="telegramChatId" value={telegramChatId} onChange={(e) => setTelegramChatId(e.target.value)} className="mt-1 w-full rounded border px-3 py-2" />
|
type="submit"
|
||||||
</div>
|
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"
|
||||||
{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>
|
Hinzufügen
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<ul className="divide-y">
|
<section aria-label="Gespeicherte Kontakte" className="mt-5 rounded-card border border-line bg-white px-5 py-2">
|
||||||
{contacts.map((c) => (
|
{contacts.length === 0 ? (
|
||||||
<li key={c.id} className="py-2 text-sm">
|
<p className="py-3 text-sm text-muted">Noch keine Kontakte angelegt.</p>
|
||||||
<span className="font-medium">{c.name}</span>
|
) : (
|
||||||
{c.email && <span> · {c.email}</span>}
|
<ul className="divide-y divide-line">
|
||||||
{c.telegramChatId && <span> · Telegram {c.telegramChatId}</span>}
|
{contacts.map((c) => (
|
||||||
</li>
|
<li key={c.id} className="py-3 text-sm">
|
||||||
))}
|
<span className="font-semibold">{c.name}</span>
|
||||||
</ul>
|
{c.email && <span className="text-muted"> · {c.email}</span>}
|
||||||
</main>
|
{c.telegramChatId && <span className="text-muted"> · Telegram {c.telegramChatId}</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</Shell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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
@@ -1,10 +1,17 @@
|
|||||||
import type { ReactNode } from "react";
|
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 }) {
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="de">
|
<html lang="de" className={geist.className}>
|
||||||
<body>{children}</body>
|
<body>{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
+67
-21
@@ -33,27 +33,73 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ maxWidth: 360, margin: "4rem auto", padding: "0 1rem" }}>
|
<main className="grid min-h-dvh place-items-center bg-wash px-5 py-9">
|
||||||
<h1>Anmelden</h1>
|
<div className="w-full max-w-[420px]">
|
||||||
<form onSubmit={onSubmit}>
|
<p className="mb-6 flex items-center gap-2.5 text-base font-semibold tracking-tight">
|
||||||
<label>
|
<span
|
||||||
Benutzer
|
aria-hidden="true"
|
||||||
<input value={user} onChange={(e) => setUser(e.target.value)} autoComplete="username" />
|
className="grid h-9 w-9 place-items-center rounded-[11px] bg-brand-100 text-[17px] font-bold text-brand-800"
|
||||||
</label>
|
>
|
||||||
<label>
|
G
|
||||||
Passwort
|
</span>
|
||||||
<input
|
Gutscheintracker
|
||||||
type="password"
|
</p>
|
||||||
value={password}
|
<section
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
aria-labelledby="login-title"
|
||||||
autoComplete="current-password"
|
className="rounded-card border border-line bg-white p-8 shadow-[0_16px_38px_rgba(32,55,39,0.07)]"
|
||||||
/>
|
>
|
||||||
</label>
|
<h1 id="login-title" className="text-[27px] font-semibold leading-snug tracking-tight">
|
||||||
{error && <p role="alert">{error}</p>}
|
Willkommen zurück
|
||||||
<button type="submit" disabled={loading}>
|
</h1>
|
||||||
{loading ? "…" : "Anmelden"}
|
<p className="mb-7 mt-2 text-sm leading-relaxed text-muted">
|
||||||
</button>
|
Melde dich mit eurem gemeinsamen Haushaltszugang an.
|
||||||
</form>
|
</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>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+61
-26
@@ -1,7 +1,8 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
import Shell from "./components/shell";
|
||||||
import { getDb } from "@/lib/db";
|
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";
|
import { formatEuro, isExpiringSoon } from "@/lib/vouchers";
|
||||||
|
|
||||||
const FILTERS = [
|
const FILTERS = [
|
||||||
@@ -12,13 +13,25 @@ const FILTERS = [
|
|||||||
{ key: "eingeloest", label: "Eingelöst" },
|
{ key: "eingeloest", label: "Eingelöst" },
|
||||||
] as const;
|
] 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() {
|
function Skeleton() {
|
||||||
return (
|
return (
|
||||||
<ul aria-label="lädt">
|
<ul aria-label="lädt" className="grid gap-3">
|
||||||
{[0, 1, 2].map((i) => (
|
{[0, 1, 2].map((i) => (
|
||||||
<li key={i} className="animate-pulse rounded border p-4">
|
<li key={i} className="animate-pulse rounded-card border border-line bg-white p-5">
|
||||||
<div className="h-4 w-1/3 bg-gray-200" />
|
<div className="h-4 w-1/3 rounded bg-line" />
|
||||||
<div className="mt-2 h-3 w-1/4 bg-gray-100" />
|
<div className="mt-2 h-3 w-1/4 rounded bg-wash" />
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -38,26 +51,38 @@ async function VoucherList({ filter }: { filter: string }) {
|
|||||||
});
|
});
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded border p-8 text-center">
|
<div className="rounded-card border border-line bg-white p-10 text-center">
|
||||||
<p>Keine Gutscheine gefunden.</p>
|
<p className="text-sm text-muted">Keine Gutscheine gefunden.</p>
|
||||||
<Link href="/vouchers/new" className="mt-4 inline-block rounded bg-black px-4 py-2 text-white">
|
<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
|
Gutschein anlegen
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ul className="space-y-2">
|
<ul className="grid gap-3">
|
||||||
{filtered.map((v) => (
|
{filtered.map((v) => {
|
||||||
<li key={v.id} className="rounded border p-4">
|
const s = getVoucherStatus(v);
|
||||||
<Link href={`/vouchers/${v.id}`} className="font-medium underline">
|
return (
|
||||||
{v.name}
|
<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)]">
|
||||||
</Link>
|
<div className="flex items-start justify-between gap-3">
|
||||||
<p className="text-sm text-gray-600">
|
<Link href={`/vouchers/${v.id}`} className="text-[15px] font-semibold tracking-tight hover:text-brand-700">
|
||||||
{v.anbieter} · {formatEuro(v.restCents)} / {formatEuro(v.wertCents)} · {getVoucherStatus(v)}
|
{v.name}
|
||||||
</p>
|
</Link>
|
||||||
</li>
|
<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>
|
</ul>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -65,20 +90,30 @@ async function VoucherList({ filter }: { filter: string }) {
|
|||||||
export default async function Home({ searchParams }: { searchParams: Promise<{ filter?: string }> }) {
|
export default async function Home({ searchParams }: { searchParams: Promise<{ filter?: string }> }) {
|
||||||
const { filter = "" } = await searchParams;
|
const { filter = "" } = await searchParams;
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-2xl space-y-4 p-6">
|
<Shell>
|
||||||
<div className="flex items-center justify-between">
|
<div className="mb-5 flex items-end justify-between gap-3">
|
||||||
<h1 className="text-xl font-bold">Gutscheine</h1>
|
<div>
|
||||||
<Link href="/vouchers/new" className="rounded bg-black px-4 py-2 text-white">
|
<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
|
Neu
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex gap-2" aria-label="Filter">
|
<nav className="mb-5 flex flex-wrap gap-2" aria-label="Filter">
|
||||||
{FILTERS.map((f) => (
|
{FILTERS.map((f) => (
|
||||||
<Link
|
<Link
|
||||||
key={f.key}
|
key={f.key}
|
||||||
href={f.key ? `/?filter=${f.key}` : "/"}
|
href={f.key ? `/?filter=${f.key}` : "/"}
|
||||||
aria-current={filter === f.key ? "page" : undefined}
|
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}
|
{f.label}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -87,6 +122,6 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ f
|
|||||||
<Suspense fallback={<Skeleton />}>
|
<Suspense fallback={<Skeleton />}>
|
||||||
<VoucherList filter={filter} />
|
<VoucherList filter={filter} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</main>
|
</Shell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+37
-26
@@ -1,39 +1,50 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import Shell from "../components/shell";
|
||||||
import { getDb } from "@/lib/db";
|
import { getDb } from "@/lib/db";
|
||||||
import { DEFAULT_TAGE_VORHER } from "@/lib/settings";
|
import { DEFAULT_TAGE_VORHER } from "@/lib/settings";
|
||||||
import { TestButtons } from "./test-buttons";
|
import { TestButtons } from "./test-buttons";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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() {
|
export default async function SettingsPage() {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const last = await db.notificationLog.findFirst({ orderBy: { sentAt: "desc" } });
|
const last = await db.notificationLog.findFirst({ orderBy: { sentAt: "desc" } });
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-xl space-y-4 p-6">
|
<Shell>
|
||||||
<Link href="/" className="text-sm underline">← Zurück</Link>
|
<Link href="/" className="text-sm font-medium text-muted hover:text-ink">
|
||||||
<h1 className="text-xl font-bold">Einstellungen</h1>
|
← Zurück
|
||||||
<section className="rounded border p-4">
|
</Link>
|
||||||
<h2 className="font-medium">Standard-Erinnerungstage</h2>
|
<h1 className="mt-2 text-2xl font-semibold tracking-tight">Einstellungen</h1>
|
||||||
<p className="text-sm text-gray-600">Neue Gutscheine erhalten diese Erinnerungsregeln: {DEFAULT_TAGE_VORHER.join(", ")} Tage vorher.</p>
|
<p className="mt-1 text-sm text-muted">Erinnerungen und Benachrichtigungen prüfen.</p>
|
||||||
</section>
|
<div className="mt-5 grid gap-4">
|
||||||
<TestButtons />
|
<Card title="Standard-Erinnerungstage">
|
||||||
<section className="rounded border p-4 text-sm">
|
<p>Neue Gutscheine erhalten diese Erinnerungsregeln: {DEFAULT_TAGE_VORHER.join(", ")} Tage vorher.</p>
|
||||||
<h2 className="font-medium">Letzter Cron-Lauf</h2>
|
</Card>
|
||||||
{last ? (
|
<TestButtons />
|
||||||
<p>
|
<Card title="Letzter Cron-Lauf">
|
||||||
{last.sentAt.toLocaleString("de-DE")} · {last.kanal} an {last.empfaenger} · {last.status}
|
{last ? (
|
||||||
{last.fehler ? ` · Fehler: ${last.fehler}` : ""}
|
<p>
|
||||||
</p>
|
{last.sentAt.toLocaleString("de-DE")} · {last.kanal} an {last.empfaenger} · {last.status}
|
||||||
) : (
|
{last.fehler ? ` · Fehler: ${last.fehler}` : ""}
|
||||||
<p>Noch keine Benachrichtigung versendet.</p>
|
</p>
|
||||||
)}
|
) : (
|
||||||
</section>
|
<p>Noch keine Benachrichtigung versendet.</p>
|
||||||
<section className="rounded border p-4 text-sm text-gray-600">
|
)}
|
||||||
<h2 className="font-medium text-black">Konfiguration (.env)</h2>
|
</Card>
|
||||||
<p>SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, MAIL_FROM, TELEGRAM_BOT_TOKEN, CRON_SECRET, APP_URL.</p>
|
<Card title="Konfiguration und Backup">
|
||||||
<h2 className="mt-2 font-medium text-black">Backup</h2>
|
<p>SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, MAIL_FROM, TELEGRAM_BOT_TOKEN, CRON_SECRET, APP_URL.</p>
|
||||||
<p>SQLite-Datei sichern: Volume <code>gutschein-data</code> bzw. Datei <code>/data/gutscheine.db</code> regelmäßig kopieren.</p>
|
<p className="mt-2">SQLite-Datei sichern: Volume <code>gutschein-data</code> bzw. Datei <code>/data/gutscheine.db</code> regelmäßig kopieren.</p>
|
||||||
</section>
|
</Card>
|
||||||
</main>
|
</div>
|
||||||
|
</Shell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -29,19 +29,43 @@ export function TestButtons() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3 rounded border p-4">
|
<section className="rounded-card border border-line bg-white p-5">
|
||||||
<h2 className="font-medium">Versand testen</h2>
|
<h2 className="text-[15px] font-semibold tracking-tight">Versand testen</h2>
|
||||||
<div>
|
<div className="mt-3 grid gap-4 sm:grid-cols-2">
|
||||||
<label htmlFor="mail" className="block text-sm font-medium">E-Mail-Empfänger</label>
|
<div>
|
||||||
<input id="mail" value={mail} onChange={(e) => setMail(e.target.value)} className="mt-1 w-full rounded border px-3 py-2" />
|
<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>
|
</div>
|
||||||
<button type="button" onClick={testMail} className="rounded bg-black px-4 py-2 text-white">Test-Mail senden</button>
|
{result && <p role="status" className="mt-3 text-sm text-muted">{result}</p>}
|
||||||
<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>}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+56
-22
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { use, useEffect, useState } from "react";
|
import { use, useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import Shell from "../../components/shell";
|
||||||
import { formatEuro } from "@/lib/vouchers";
|
import { formatEuro } from "@/lib/vouchers";
|
||||||
import { getVoucherStatus } from "@/lib/voucher-status";
|
import { getVoucherStatus } from "@/lib/voucher-status";
|
||||||
|
|
||||||
@@ -18,6 +19,15 @@ type Voucher = {
|
|||||||
contacts?: { contact: { id: number; name: string; email: string | null; telegramChatId: string | null } }[];
|
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 }> }) {
|
export default function VoucherDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = use(params);
|
const { id } = use(params);
|
||||||
const [v, setV] = useState<Voucher | null>(null);
|
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 (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)
|
if (error || !v)
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-xl space-y-4 p-6">
|
<Shell>
|
||||||
<p>{error}</p>
|
<div className="rounded-card border border-line bg-white p-8 text-center">
|
||||||
<Link href="/" className="underline">Zurück zur Liste</Link>
|
<p className="text-sm text-muted">{error}</p>
|
||||||
</main>
|
<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 (
|
return (
|
||||||
<main className="mx-auto max-w-xl space-y-3 p-6">
|
<Shell>
|
||||||
<Link href="/" className="text-sm underline">← Zurück</Link>
|
<Link href="/" className="text-sm font-medium text-muted hover:text-ink">
|
||||||
<h1 className="text-xl font-bold">{v.name}</h1>
|
← Zurück
|
||||||
<dl className="space-y-1 text-sm">
|
</Link>
|
||||||
<div><dt className="font-medium">Anbieter</dt><dd>{v.anbieter}</dd></div>
|
<h1 className="mt-2 text-2xl font-semibold tracking-tight">{v.name}</h1>
|
||||||
<div><dt className="font-medium">Wert</dt><dd>{formatEuro(v.wertCents)}</dd></div>
|
<section aria-label="Details" className="mt-4 divide-y divide-line rounded-card border border-line bg-white px-5 py-2">
|
||||||
<div><dt className="font-medium">Rest</dt><dd>{formatEuro(v.restCents)}</dd></div>
|
<Row label="Anbieter">{v.anbieter}</Row>
|
||||||
<div><dt className="font-medium">Status</dt><dd>{getVoucherStatus({ status: v.status, ablaufdatum: v.ablaufdatum ? new Date(v.ablaufdatum) : null })}</dd></div>
|
<Row label="Wert">{formatEuro(v.wertCents)}</Row>
|
||||||
{v.ablaufdatum && <div><dt className="font-medium">Ablaufdatum</dt><dd>{new Date(v.ablaufdatum).toLocaleDateString("de-DE")}</dd></div>}
|
<Row label="Restguthaben">{formatEuro(v.restCents)}</Row>
|
||||||
{v.codeText && <div><dt className="font-medium">Code</dt><dd>{v.codeText}</dd></div>}
|
<Row label="Status">{status === "aktiv" ? "Aktiv" : status === "abgelaufen" ? "Abgelaufen" : "Eingelöst"}</Row>
|
||||||
{v.kategorie && <div><dt className="font-medium">Kategorie</dt><dd>{v.kategorie}</dd></div>}
|
{v.ablaufdatum && (
|
||||||
{v.notiz && <div><dt className="font-medium">Notiz</dt><dd>{v.notiz}</dd></div>}
|
<Row label="Ablaufdatum">{new Date(v.ablaufdatum).toLocaleDateString("de-DE")}</Row>
|
||||||
{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>
|
|
||||||
)}
|
)}
|
||||||
</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" && (
|
{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
|
Einlösen
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</main>
|
</Shell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+85
-48
@@ -1,21 +1,26 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Shell from "../../components/shell";
|
||||||
import { validateVoucher } from "@/lib/vouchers";
|
import { validateVoucher } from "@/lib/vouchers";
|
||||||
|
|
||||||
// Erweiterbar für Task 5 (Kontakte): zusätzliches Feld contactIds + <ContactSelect />
|
// Erweiterbar für Task 5 (Kontakte): zusätzliches Feld contactIds + <ContactSelect />
|
||||||
// ohne bestehende Felder anzufassen — Felder als Konfiguration belassen.
|
// ohne bestehende Felder anzufassen — Felder als Konfiguration belassen.
|
||||||
const FIELDS = [
|
const FIELDS = [
|
||||||
{ name: "name", label: "Name", type: "text" },
|
{ name: "name", label: "Name", type: "text", placeholder: "z. B. Therme Erding" },
|
||||||
{ name: "anbieter", label: "Anbieter", type: "text" },
|
{ name: "anbieter", label: "Anbieter", type: "text", placeholder: "z. B. Therme Erding" },
|
||||||
{ name: "wertCents", label: "Wert (Cent)", type: "number" },
|
{ name: "wertCents", label: "Wert (Cent)", type: "number", placeholder: "5000" },
|
||||||
{ name: "restCents", label: "Rest (Cent, leer = Wert)", type: "number" },
|
{ name: "restCents", label: "Rest (Cent, leer = Wert)", type: "number", placeholder: "" },
|
||||||
{ name: "ablaufdatum", label: "Ablaufdatum", type: "date" },
|
{ name: "ablaufdatum", label: "Ablaufdatum", type: "date", placeholder: "" },
|
||||||
{ name: "codeText", label: "Code", type: "text" },
|
{ name: "codeText", label: "Code", type: "text", placeholder: "Code oder Nummer" },
|
||||||
{ name: "kategorie", label: "Kategorie", type: "text" },
|
{ name: "kategorie", label: "Kategorie", type: "text", placeholder: "z. B. Erlebnis" },
|
||||||
{ name: "notiz", label: "Notiz", type: "text" },
|
{ name: "notiz", label: "Notiz", type: "text", placeholder: "Optionale Notiz" },
|
||||||
] as const;
|
] 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() {
|
export default function NewVoucherPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [values, setValues] = useState<Record<string, string>>({});
|
const [values, setValues] = useState<Record<string, string>>({});
|
||||||
@@ -69,27 +74,37 @@ export default function NewVoucherPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-xl space-y-4 p-6">
|
<Shell>
|
||||||
<h1 className="text-xl font-bold">Gutschein anlegen</h1>
|
<Link href="/" className="text-sm font-medium text-muted hover:text-ink">
|
||||||
<form onSubmit={onSubmit} className="space-y-3" noValidate>
|
← Zurück
|
||||||
{FIELDS.map((f) => (
|
</Link>
|
||||||
<div key={f.name}>
|
<h1 className="mt-2 text-2xl font-semibold tracking-tight">Gutschein anlegen</h1>
|
||||||
<label htmlFor={f.name} className="block text-sm font-medium">
|
<p className="mt-1 text-sm text-muted">Alle Angaben lassen sich später noch ändern.</p>
|
||||||
{f.label}
|
<form onSubmit={onSubmit} className="mt-5 rounded-card border border-line bg-white p-5 sm:p-6" noValidate>
|
||||||
</label>
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<input
|
{FIELDS.map((f) => (
|
||||||
id={f.name}
|
<div key={f.name} className={f.name === "notiz" ? "sm:col-span-2" : undefined}>
|
||||||
name={f.name}
|
<label htmlFor={f.name} className="block text-[13px] font-semibold">
|
||||||
type={f.type}
|
{f.label}
|
||||||
value={values[f.name] ?? ""}
|
</label>
|
||||||
onChange={(e) => set(f.name, e.target.value)}
|
<input
|
||||||
className="mt-1 w-full rounded border px-3 py-2"
|
id={f.name}
|
||||||
/>
|
name={f.name}
|
||||||
{errors.includes(f.name) && <p className="mt-1 text-sm text-red-600">Ungültig: {f.name}</p>}
|
type={f.type}
|
||||||
</div>
|
value={values[f.name] ?? ""}
|
||||||
))}
|
placeholder={f.placeholder || undefined}
|
||||||
<div>
|
onChange={(e) => set(f.name, e.target.value)}
|
||||||
<label htmlFor="reminderDays" className="block text-sm font-medium">
|
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)
|
Erinnerung (Tage vorher, Kommaliste)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -99,30 +114,52 @@ export default function NewVoucherPage() {
|
|||||||
value={reminderDays}
|
value={reminderDays}
|
||||||
onChange={(e) => setReminderDays(e.target.value)}
|
onChange={(e) => setReminderDays(e.target.value)}
|
||||||
placeholder="30,6,1"
|
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>
|
</div>
|
||||||
<section id="kontakte">
|
<section id="kontakte" aria-label="Empfänger" className="mt-5 rounded-field bg-wash p-4">
|
||||||
<h2 className="text-sm font-medium">Empfänger</h2>
|
<h2 className="text-[13px] font-semibold">Empfänger</h2>
|
||||||
{contacts.map((c) => (
|
<p className="mt-0.5 text-[13px] text-muted">Wer soll an diesen Gutschein erinnert werden?</p>
|
||||||
<label key={c.id} className="flex items-center gap-2 text-sm">
|
{contacts.length === 0 ? (
|
||||||
<input
|
<p className="mt-2 text-[13px] text-muted">
|
||||||
type="checkbox"
|
Noch keine Kontakte. Lege sie unter <Link href="/contacts" className="font-semibold text-brand-700 underline">Kontakte</Link> an.
|
||||||
checked={contactIds.includes(c.id)}
|
</p>
|
||||||
onChange={(e) =>
|
) : (
|
||||||
setContactIds((ids) => (e.target.checked ? [...ids, c.id] : ids.filter((x) => x !== c.id)))
|
<ul className="mt-2 grid gap-1.5">
|
||||||
}
|
{contacts.map((c) => (
|
||||||
/>
|
<li key={c.id}>
|
||||||
{c.name}
|
<label className="flex cursor-pointer items-center gap-2.5 rounded-lg px-1 py-1 text-sm hover:bg-white">
|
||||||
</label>
|
<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>
|
</section>
|
||||||
{serverError && <p className="text-sm text-red-600">{serverError}</p>}
|
{serverError && (
|
||||||
<button type="submit" className="rounded bg-black px-4 py-2 text-white">
|
<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
|
Speichern
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</Shell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Vendored
+5
@@ -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.
|
||||||
Generated
+658
-4
@@ -7,13 +7,15 @@
|
|||||||
"name": "gutscheintracker",
|
"name": "gutscheintracker",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "6.5.0",
|
"@prisma/client": "6.5.0",
|
||||||
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"bcryptjs": "2.4.3",
|
"bcryptjs": "2.4.3",
|
||||||
"jose": "5.9.6",
|
"jose": "5.9.6",
|
||||||
"next": "15.3.0",
|
"next": "15.3.0",
|
||||||
"nodemailer": "6.9.14",
|
"nodemailer": "6.9.14",
|
||||||
"prisma": "6.5.0",
|
"prisma": "6.5.0",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0"
|
"react-dom": "19.0.0",
|
||||||
|
"tailwindcss": "^4.3.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "2.4.6",
|
"@types/bcryptjs": "2.4.6",
|
||||||
@@ -25,6 +27,18 @@
|
|||||||
"vitest": "3.0.0"
|
"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": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.11.3",
|
"version": "1.11.3",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||||
@@ -965,13 +979,51 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"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": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
|
||||||
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
|
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
"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"
|
"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": {
|
"node_modules/@types/bcryptjs": {
|
||||||
"version": "2.4.6",
|
"version": "2.4.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"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": {
|
"node_modules/es-module-lexer": {
|
||||||
"version": "1.7.0",
|
"version": "1.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
"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": "^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": {
|
"node_modules/jose": {
|
||||||
"version": "5.9.6",
|
"version": "5.9.6",
|
||||||
"resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz",
|
"resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz",
|
||||||
@@ -2067,6 +2442,267 @@
|
|||||||
"url": "https://github.com/sponsors/panva"
|
"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": {
|
"node_modules/loupe": {
|
||||||
"version": "3.2.1",
|
"version": "3.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||||
@@ -2078,7 +2714,6 @@
|
|||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@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": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
|
|||||||
+3
-1
@@ -9,13 +9,15 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "6.5.0",
|
"@prisma/client": "6.5.0",
|
||||||
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"bcryptjs": "2.4.3",
|
"bcryptjs": "2.4.3",
|
||||||
"jose": "5.9.6",
|
"jose": "5.9.6",
|
||||||
"next": "15.3.0",
|
"next": "15.3.0",
|
||||||
"nodemailer": "6.9.14",
|
"nodemailer": "6.9.14",
|
||||||
"prisma": "6.5.0",
|
"prisma": "6.5.0",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0"
|
"react-dom": "19.0.0",
|
||||||
|
"tailwindcss": "^4.3.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "2.4.6",
|
"@types/bcryptjs": "2.4.6",
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
Binary file not shown.
Reference in new issue
Block a user