494 lines
19 KiB
TypeScript
494 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import Link from "next/link";
|
|
import LogoImg from "./logo-img";
|
|
import { validateVoucher, euroToCents } from "@/lib/vouchers";
|
|
import { ALLOWED_REMINDER_DAYS, DEFAULT_REMINDER_DAYS } from "@/lib/reminders";
|
|
import type { LogoCandidate } from "@/lib/logo";
|
|
|
|
const FIELDS = [
|
|
{ name: "name", label: "Name", type: "text", placeholder: "z. B. Therme Erding" },
|
|
{ name: "anbieter", label: "Anbieter", type: "text", placeholder: "z. B. Therme Erding" },
|
|
{ name: "ablaufdatum", label: "Ablaufdatum", type: "date", placeholder: "" },
|
|
{ name: "codeText", label: "Code", type: "text", placeholder: "Code oder Nummer" },
|
|
{ name: "shopUrl", label: "Shop-Link (optional)", type: "url", placeholder: "https://…" },
|
|
{ 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 type VoucherFormInitial = {
|
|
name: string;
|
|
anbieter: string;
|
|
wertTyp: "euro" | "prozent";
|
|
wertEuro: string;
|
|
restEuro: string;
|
|
prozentWert: string;
|
|
mindestEuro: string;
|
|
ablaufdatum: string;
|
|
codeText: string;
|
|
kategorie: string;
|
|
notiz: string;
|
|
shopUrl: string;
|
|
providerDomain: string | null;
|
|
providerName: string;
|
|
scope: "" | "private" | "household";
|
|
contactIds: number[];
|
|
reminderDays: number[];
|
|
};
|
|
|
|
export type VoucherFormPayload = {
|
|
name: string;
|
|
anbieter: string;
|
|
wertTyp: "euro" | "prozent";
|
|
wertCents: number;
|
|
restCents: number;
|
|
prozentWert: number | null;
|
|
mindestbestellwertCents: number | null;
|
|
ablaufdatum: string | null;
|
|
codeText: string | null;
|
|
kategorie: string | null;
|
|
notiz: string | null;
|
|
shopUrl: string | null;
|
|
providerDomain: string | null;
|
|
scope: "private" | "household" | "";
|
|
contactIds: number[];
|
|
reminderDays: number[];
|
|
};
|
|
|
|
export const EMPTY_FORM: VoucherFormInitial = {
|
|
name: "",
|
|
anbieter: "",
|
|
wertTyp: "euro",
|
|
wertEuro: "",
|
|
restEuro: "",
|
|
prozentWert: "",
|
|
mindestEuro: "",
|
|
ablaufdatum: "",
|
|
codeText: "",
|
|
kategorie: "",
|
|
notiz: "",
|
|
shopUrl: "",
|
|
providerDomain: null,
|
|
providerName: "",
|
|
contactIds: [],
|
|
reminderDays: [...DEFAULT_REMINDER_DAYS],
|
|
scope: "",
|
|
};
|
|
|
|
export default function VoucherForm({
|
|
initial,
|
|
showShopLink = true,
|
|
householdName,
|
|
creatorName,
|
|
submitLabel,
|
|
onSubmit,
|
|
onSuccess,
|
|
}: {
|
|
initial: VoucherFormInitial;
|
|
showShopLink?: boolean;
|
|
householdName: string | null;
|
|
creatorName?: string | null;
|
|
submitLabel: string;
|
|
onSubmit: (payload: VoucherFormPayload) => Promise<{ ok: true; id: number } | { ok: false; errors: string[]; serverError: string }>;
|
|
onSuccess: (id: number) => void;
|
|
}) {
|
|
const [values, setValues] = useState<Record<string, string>>(() => ({
|
|
name: initial.name,
|
|
anbieter: initial.anbieter,
|
|
wertEuro: initial.wertEuro,
|
|
restEuro: initial.restEuro,
|
|
prozentWert: initial.prozentWert,
|
|
mindestEuro: initial.mindestEuro,
|
|
ablaufdatum: initial.ablaufdatum,
|
|
codeText: initial.codeText,
|
|
kategorie: initial.kategorie,
|
|
notiz: initial.notiz,
|
|
shopUrl: initial.shopUrl,
|
|
}));
|
|
const [errors, setErrors] = useState<string[]>([]);
|
|
const [serverError, setServerError] = useState("");
|
|
const [contacts, setContacts] = useState<{ id: number; name: string; householdId: number | null }[]>([]);
|
|
const [contactIds, setContactIds] = useState<number[]>(() => [...initial.contactIds]);
|
|
const [target, setTarget] = useState<"" | "private" | "household">(initial.scope);
|
|
const [wertTyp, setWertTyp] = useState<"euro" | "prozent">(initial.wertTyp);
|
|
const [reminderDays, setReminderDays] = useState<number[]>(() => [...initial.reminderDays]);
|
|
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>(() =>
|
|
initial.providerDomain
|
|
? { name: initial.providerName || initial.providerDomain, domain: initial.providerDomain, logoUrl: null }
|
|
: null
|
|
);
|
|
|
|
useEffect(() => {
|
|
fetch("/api/contacts")
|
|
.then((r) => (r.ok ? r.json() : []))
|
|
.then(setContacts)
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
function set(name: string, value: string) {
|
|
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 handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
const fieldErrors: string[] = [];
|
|
if (!target) fieldErrors.push("scope");
|
|
const wertCents = euroToCents(values.wertEuro);
|
|
const restParsed = euroToCents(values.restEuro);
|
|
const mindestParsed = euroToCents(values.mindestEuro);
|
|
const payload: VoucherFormPayload = {
|
|
name: values.name ?? "",
|
|
anbieter: values.anbieter ?? "",
|
|
wertTyp,
|
|
wertCents: wertTyp === "euro" ? (wertCents ?? NaN) : 0,
|
|
restCents: wertTyp === "euro" ? (restParsed === null ? (wertCents ?? NaN) : restParsed) : 0,
|
|
prozentWert: wertTyp === "prozent" ? Number(values.prozentWert) : null,
|
|
mindestbestellwertCents: mindestParsed,
|
|
shopUrl: values.shopUrl || null,
|
|
ablaufdatum: values.ablaufdatum || null,
|
|
codeText: values.codeText || null,
|
|
kategorie: values.kategorie || null,
|
|
notiz: values.notiz || null,
|
|
providerDomain: selectedLogo?.domain ?? null,
|
|
scope: target,
|
|
contactIds,
|
|
reminderDays,
|
|
};
|
|
const errs = [...fieldErrors, ...validateVoucher(payload)];
|
|
setErrors(errs);
|
|
if (errs.length) return;
|
|
const result = await onSubmit(payload);
|
|
if (!result.ok) {
|
|
setErrors(result.errors);
|
|
setServerError(result.serverError);
|
|
return;
|
|
}
|
|
onSuccess(result.id);
|
|
}
|
|
|
|
const visibleContacts = contacts.filter((c) => {
|
|
if (!target) return true;
|
|
if (target === "household") return c.householdId !== null;
|
|
return c.householdId === null;
|
|
});
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="mt-5 rounded-card border border-line bg-white p-5 sm:p-6" noValidate>
|
|
{creatorName !== undefined && (
|
|
<p className="mb-4 text-[13px] text-muted">
|
|
Angelegt von {creatorName ? <span className="font-semibold text-ink">{creatorName}</span> : "nicht erfasst"} (bleibt unverändert).
|
|
</p>
|
|
)}
|
|
<fieldset className="mb-4">
|
|
<legend className="text-[13px] font-semibold">Wo soll der Gutschein gespeichert werden?</legend>
|
|
<div className="mt-2 flex gap-2" role="radiogroup" aria-label="Bestand">
|
|
{(
|
|
[
|
|
{ key: "private", label: "Privat" },
|
|
...(householdName ? [{ key: "household", label: householdName } as const] : []),
|
|
] as const
|
|
).map((t) => (
|
|
<button
|
|
key={t.key}
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={target === t.key}
|
|
onClick={() => {
|
|
setTarget(t.key);
|
|
setContactIds([]);
|
|
}}
|
|
className={`h-11 flex-1 rounded-field border text-sm font-semibold transition-colors sm:flex-none sm:px-8 ${
|
|
target === t.key
|
|
? "border-brand-700 bg-brand-600 text-white"
|
|
: "border-line bg-white hover:border-brand-600"
|
|
}`}
|
|
>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{errors.includes("scope") && (
|
|
<p className="mt-1 text-[13px] font-medium text-danger">Bitte wähle Privat oder einen Haushalt.</p>
|
|
)}
|
|
</fieldset>
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
{FIELDS.filter((f) => showShopLink || f.name !== "shopUrl").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>
|
|
<fieldset className="mt-4">
|
|
<legend className="text-[13px] font-semibold">Wert</legend>
|
|
<div className="mt-2 flex gap-2" role="radiogroup" aria-label="Werttyp">
|
|
{(["euro", "prozent"] as const).map((t) => (
|
|
<button
|
|
key={t}
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={wertTyp === t}
|
|
onClick={() => setWertTyp(t)}
|
|
className={`h-11 flex-1 rounded-field border text-sm font-semibold transition-colors sm:flex-none sm:px-8 ${
|
|
wertTyp === t
|
|
? "border-brand-700 bg-brand-600 text-white"
|
|
: "border-line bg-white hover:border-brand-600"
|
|
}`}
|
|
>
|
|
{t === "euro" ? "Geldwert (€)" : "Rabatt (%)"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="mt-3 grid gap-4 sm:grid-cols-2">
|
|
{wertTyp === "euro" ? (
|
|
<>
|
|
<div>
|
|
<label htmlFor="wertEuro" className="block text-[13px] font-semibold">Wert (€)</label>
|
|
<input
|
|
id="wertEuro"
|
|
type="text"
|
|
inputMode="decimal"
|
|
value={values.wertEuro ?? ""}
|
|
placeholder="25,50"
|
|
onChange={(e) => set("wertEuro", e.target.value)}
|
|
aria-invalid={errors.includes("wertCents")}
|
|
className={`${inputClass} ${errors.includes("wertCents") ? "border-danger" : ""}`}
|
|
/>
|
|
{errors.includes("wertCents") && (
|
|
<p className="mt-1 text-[13px] font-medium text-danger">Bitte einen gültigen Betrag eingeben.</p>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<label htmlFor="restEuro" className="block text-[13px] font-semibold">Rest (€, leer = Wert)</label>
|
|
<input
|
|
id="restEuro"
|
|
type="text"
|
|
inputMode="decimal"
|
|
value={values.restEuro ?? ""}
|
|
placeholder="25,50"
|
|
onChange={(e) => set("restEuro", e.target.value)}
|
|
aria-invalid={errors.includes("restCents")}
|
|
className={`${inputClass} ${errors.includes("restCents") ? "border-danger" : ""}`}
|
|
/>
|
|
{errors.includes("restCents") && (
|
|
<p className="mt-1 text-[13px] font-medium text-danger">Bitte prüfe dieses Feld.</p>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div>
|
|
<label htmlFor="prozentWert" className="block text-[13px] font-semibold">Rabatt (%)</label>
|
|
<input
|
|
id="prozentWert"
|
|
type="number"
|
|
min={1}
|
|
max={100}
|
|
value={values.prozentWert ?? ""}
|
|
placeholder="20"
|
|
onChange={(e) => set("prozentWert", e.target.value)}
|
|
aria-invalid={errors.includes("prozentWert")}
|
|
className={`${inputClass} ${errors.includes("prozentWert") ? "border-danger" : ""}`}
|
|
/>
|
|
{errors.includes("prozentWert") && (
|
|
<p className="mt-1 text-[13px] font-medium text-danger">Ganze Zahl von 1 bis 100.</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div>
|
|
<label htmlFor="mindestEuro" className="block text-[13px] font-semibold">
|
|
Mindestbestellwert (€, optional)
|
|
</label>
|
|
<input
|
|
id="mindestEuro"
|
|
type="text"
|
|
inputMode="decimal"
|
|
value={values.mindestEuro ?? ""}
|
|
placeholder="z. B. 50,00"
|
|
onChange={(e) => set("mindestEuro", e.target.value)}
|
|
aria-invalid={errors.includes("mindestbestellwertCents")}
|
|
className={`${inputClass} ${errors.includes("mindestbestellwertCents") ? "border-danger" : ""}`}
|
|
/>
|
|
{errors.includes("mindestbestellwertCents") && (
|
|
<p className="mt-1 text-[13px] font-medium text-danger">Bitte prüfe dieses Feld.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</fieldset>
|
|
<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>
|
|
<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>
|
|
{!target ? (
|
|
<p className="mt-2 text-[13px] text-muted">Wähle zuerst oben Privat oder Haushalt.</p>
|
|
) : visibleContacts.length === 0 ? (
|
|
<p className="mt-2 text-[13px] text-muted">
|
|
Noch keine Kontakte in diesem Bestand. 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">
|
|
{visibleContacts.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 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"
|
|
>
|
|
{submitLabel}
|
|
</button>
|
|
</form>
|
|
);
|
|
}
|