feat: Erinnerungs-Checkboxen, Anbieterlogos via Logo.dev, Kontakte in Einstellungen
This commit is contained in:
1 parent
11bef8748f
commit
a1195223e0
18 files changed
+381
-50
No files matched your search
+121
-19
@@ -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 ? (
|
||||
|
||||
Reference in new issue
Block a user