feat: Erinnerungs-Checkboxen, Anbieterlogos via Logo.dev, Kontakte in Einstellungen

This commit is contained in:
opencode committed 2026-10-18 12:25:56 +02:00
1 parent 11bef8748f
commit a1195223e0
18 files changed
+381 -50

No files matched your search

+7 -1
View File
@@ -2,6 +2,7 @@
import { use, useEffect, useState } from "react";
import Link from "next/link";
import Shell from "../../components/shell";
import LogoImg from "../../components/logo-img";
import { formatEuro } from "@/lib/vouchers";
import { getVoucherStatus } from "@/lib/voucher-status";
@@ -16,6 +17,8 @@ type Voucher = {
kategorie: string | null;
notiz: string | null;
status: string;
providerDomain?: string | null;
logoUrl?: string | null;
contacts?: { contact: { id: number; name: string; email: string | null; telegramChatId: string | null } }[];
};
@@ -82,7 +85,10 @@ export default function VoucherDetailPage({ params }: { params: Promise<{ id: st
<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>
<div className="mt-2 flex items-center gap-3">
<LogoImg logoUrl={v.logoUrl ?? null} name={v.anbieter} size={52} />
<h1 className="text-2xl font-semibold tracking-tight">{v.name}</h1>
</div>
<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>
+121 -19
View File
@@ -3,7 +3,10 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import Shell from "../../components/shell";
import LogoImg from "../../components/logo-img";
import { validateVoucher } from "@/lib/vouchers";
import { ALLOWED_REMINDER_DAYS, DEFAULT_REMINDER_DAYS } from "@/lib/reminders";
import type { LogoCandidate } from "@/lib/logo";
// Erweiterbar für Task 5 (Kontakte): zusätzliches Feld contactIds + <ContactSelect />
// ohne bestehende Felder anzufassen — Felder als Konfiguration belassen.
@@ -28,7 +31,12 @@ export default function NewVoucherPage() {
const [serverError, setServerError] = useState("");
const [contacts, setContacts] = useState<{ id: number; name: string }[]>([]);
const [contactIds, setContactIds] = useState<number[]>([]);
const [reminderDays, setReminderDays] = useState("30,6,1");
const [reminderDays, setReminderDays] = useState<number[]>([...DEFAULT_REMINDER_DAYS]);
const [candidates, setCandidates] = useState<LogoCandidate[]>([]);
const [logoSearching, setLogoSearching] = useState(false);
const [logoError, setLogoError] = useState("");
const [logoSearched, setLogoSearched] = useState(false);
const [selectedLogo, setSelectedLogo] = useState<LogoCandidate | null>(null);
useEffect(() => {
fetch("/api/contacts")
@@ -41,6 +49,38 @@ export default function NewVoucherPage() {
setValues((v) => ({ ...v, [name]: value }));
}
function toggleDay(day: number) {
setReminderDays((days) => (days.includes(day) ? days.filter((d) => d !== day) : [...days, day].sort((a, b) => b - a)));
}
async function searchLogos() {
const q = (values.anbieter ?? "").trim();
if (!q) {
setLogoError("Bitte zuerst einen Anbieter eintragen.");
return;
}
setLogoSearching(true);
setLogoError("");
setLogoSearched(false);
try {
const res = await fetch(`/api/logos/search?q=${encodeURIComponent(q)}`);
const data = await res.json().catch(() => null);
if (!res.ok) {
setLogoError(data?.error ?? "Suche fehlgeschlagen");
setCandidates([]);
} else {
setCandidates(Array.isArray(data) ? data : []);
if (!Array.isArray(data) || data.length === 0) setLogoError("Kein Logo gefunden.");
}
} catch {
setLogoError("Suche fehlgeschlagen");
setCandidates([]);
} finally {
setLogoSearching(false);
setLogoSearched(true);
}
}
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
const wertCents = Number(values.wertCents);
@@ -61,7 +101,7 @@ export default function NewVoucherPage() {
const res = await fetch("/api/vouchers", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...input, contactIds, reminderDays }),
body: JSON.stringify({ ...input, contactIds, reminderDays, providerDomain: selectedLogo?.domain ?? null }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
@@ -103,25 +143,87 @@ export default function NewVoucherPage() {
</div>
))}
</div>
<div className="mt-4">
<label htmlFor="reminderDays" className="block text-[13px] font-semibold">
Erinnerung (Tage vorher, Kommaliste)
</label>
<input
id="reminderDays"
name="reminderDays"
type="text"
value={reminderDays}
onChange={(e) => setReminderDays(e.target.value)}
placeholder="30,6,1"
aria-invalid={errors.includes("reminderDays") || errors.includes("reminder")}
className={`${inputClass} ${errors.includes("reminderDays") || errors.includes("reminder") ? "border-danger" : ""}`}
/>
{(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 className="mt-4 rounded-field bg-wash p-4">
<h2 className="text-[13px] font-semibold">Anbieterlogo</h2>
{selectedLogo ? (
<div className="mt-2 flex items-center gap-3">
<LogoImg logoUrl={selectedLogo.logoUrl} name={selectedLogo.name} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{selectedLogo.name}</p>
<p className="truncate text-[13px] text-muted">{selectedLogo.domain}</p>
</div>
<button
type="button"
onClick={() => setSelectedLogo(null)}
className="shrink-0 rounded-field border border-line bg-white px-3 py-1.5 text-[13px] font-medium hover:text-danger"
>
Entfernen
</button>
</div>
) : (
<>
<p className="mt-0.5 text-[13px] text-muted">Logo anhand des Anbieternamens suchen und bestätigen.</p>
<button
type="button"
onClick={searchLogos}
disabled={logoSearching}
className="mt-2 h-10 rounded-field border border-line bg-white px-4 text-sm font-semibold transition-colors hover:border-brand-600 hover:text-brand-700 disabled:opacity-60"
>
{logoSearching ? "Sucht …" : "Logo suchen"}
</button>
{logoError && <p role="alert" className="mt-2 text-[13px] font-medium text-danger">{logoError}</p>}
{logoSearched && !logoError && candidates.length > 0 && (
<ul className="mt-2 grid gap-1.5">
{candidates.map((c) => (
<li key={c.domain}>
<button
type="button"
onClick={() => {
setSelectedLogo(c);
setCandidates([]);
setLogoSearched(false);
}}
className="flex w-full items-center gap-3 rounded-[10px] px-2 py-1.5 text-left transition-colors hover:bg-white"
>
<LogoImg logoUrl={c.logoUrl} name={c.name} size={36} />
<span className="min-w-0">
<span className="block truncate text-sm font-semibold">{c.name}</span>
<span className="block truncate text-[13px] text-muted">{c.domain}</span>
</span>
</button>
</li>
))}
</ul>
)}
</>
)}
</div>
<section id="kontakte" aria-label="Empfänger" className="mt-5 rounded-field bg-wash p-4">
<fieldset className="mt-4">
<legend className="text-[13px] font-semibold">Erinnerung (Tage vorher)</legend>
<p className="mt-0.5 text-[13px] text-muted">Nichts ausgewählt bedeutet keine Erinnerung.</p>
<div className="mt-2 flex flex-wrap gap-2">
{ALLOWED_REMINDER_DAYS.map((day) => {
const checked = reminderDays.includes(day);
return (
<label
key={day}
className={`flex cursor-pointer items-center gap-2 rounded-full border px-3.5 py-2 text-sm font-medium transition-colors ${
checked ? "border-brand-700 bg-brand-600 text-white" : "border-line bg-white hover:border-brand-600"
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleDay(day)}
className="sr-only"
/>
{day} {day === 1 ? "Tag" : "Tage"}
</label>
);
})}
</div>
</fieldset>
<section id="kontakte" aria-label="Empfänger" className="mt-4 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 ? (