721 lines
30 KiB
TypeScript
721 lines
30 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { useForm } from "react-hook-form";
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { z } from "zod";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
ArrowLeft,
|
|
ArrowRight,
|
|
Building2,
|
|
CalendarCog,
|
|
CheckCircle2,
|
|
Mail,
|
|
Save,
|
|
Video,
|
|
Zap
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type SettingsMap = Record<string, string>;
|
|
|
|
type SmtpTestState = {
|
|
status: "idle" | "loading" | "success" | "error";
|
|
message: string;
|
|
};
|
|
|
|
function extractAddressFromFromHeader(value: string | undefined) {
|
|
if (!value) return "";
|
|
const match = value.match(/<([^>]+)>/);
|
|
if (match?.[1]) return match[1].trim();
|
|
return value.trim();
|
|
}
|
|
|
|
function extractNameFromFromHeader(value: string | undefined) {
|
|
if (!value) return "";
|
|
const match = value.match(/^([^<]+)<[^>]+>$/);
|
|
if (!match?.[1]) return "";
|
|
return match[1].trim().replace(/^"(.+)"$/, "$1");
|
|
}
|
|
|
|
const JITSI_MODE_OPTIONS = [
|
|
{ value: "public", label: "Öffentlich (meet.jit.si)" },
|
|
{ value: "custom", label: "Eigene Jitsi-URL" }
|
|
] as const;
|
|
|
|
const SMTP_SETUP_STEPS = ["Server", "Absender", "Prüfen"] as const;
|
|
|
|
function emptySmtpTestState(): SmtpTestState {
|
|
return { status: "idle", message: "Noch nicht getestet." };
|
|
}
|
|
|
|
const TABS = [
|
|
{ id: "general", label: "Allgemein", icon: <Building2 className="h-4 w-4" /> },
|
|
{ id: "booking", label: "Buchungsregeln", icon: <CalendarCog className="h-4 w-4" /> },
|
|
{ id: "jitsi", label: "Jitsi Meet", icon: <Video className="h-4 w-4" /> },
|
|
{ id: "smtp", label: "SMTP", icon: <Mail className="h-4 w-4" /> }
|
|
] as const;
|
|
|
|
type TabId = (typeof TABS)[number]["id"];
|
|
|
|
function SmtpTestBox({ state }: { state: SmtpTestState }) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"rounded-xl border p-4 text-sm",
|
|
state.status === "success" && "border-emerald-200 bg-emerald-50 text-emerald-800",
|
|
state.status === "error" && "border-red-200 bg-red-50 text-red-800",
|
|
state.status === "loading" && "border-blue-200 bg-blue-50 text-blue-800",
|
|
state.status === "idle" && "border-slate-200 bg-white text-slate-600"
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
{state.status === "success" ? (
|
|
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
|
|
) : state.status === "error" ? (
|
|
<Zap className="h-4 w-4 text-red-500" />
|
|
) : state.status === "loading" ? (
|
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-blue-400 border-t-transparent" />
|
|
) : null}
|
|
<p className="font-bold">
|
|
{state.status === "success"
|
|
? "SMTP-Test erfolgreich"
|
|
: state.status === "error"
|
|
? "SMTP-Test fehlgeschlagen"
|
|
: state.status === "loading"
|
|
? "SMTP-Test läuft"
|
|
: "SMTP-Test"}
|
|
</p>
|
|
</div>
|
|
<p>{state.message}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const settingsSchema = z
|
|
.object({
|
|
company_name: z.string().min(1),
|
|
contact_email: z.string().email(),
|
|
default_duration_minutes: z.string().min(1),
|
|
buffer_minutes: z.string().min(1),
|
|
booking_lead_hours: z.string().min(1),
|
|
booking_window_days: z.string().min(1),
|
|
cancel_limit_hours: z.string().min(1),
|
|
reminder_primary_hours: z.string().regex(/^\d+$/, "Bitte eine Zahl eingeben"),
|
|
reminder_secondary_hours: z.string().regex(/^\d+$/, "Bitte eine Zahl eingeben"),
|
|
jitsi_meeting_mode: z.enum(["public", "custom"]),
|
|
jitsi_base_url: z.string().trim().url("Bitte eine gültige Jitsi-URL eingeben"),
|
|
jitsi_room_prefix: z
|
|
.string()
|
|
.trim()
|
|
.min(2, "Bitte ein Präfix mit mindestens 2 Zeichen eingeben")
|
|
.regex(/^[a-z0-9-]+$/, "Nur Kleinbuchstaben, Zahlen und Bindestriche erlaubt"),
|
|
booking_notice_text: z.string().min(1),
|
|
smtp_host: z.string().optional().default(""),
|
|
smtp_port: z.string().regex(/^\d+$/, "Bitte einen numerischen SMTP-Port eingeben"),
|
|
smtp_user: z.string().optional().default(""),
|
|
smtp_pass: z.string().optional().default(""),
|
|
smtp_from_name: z.string().min(1),
|
|
smtp_from: z
|
|
.string()
|
|
.trim()
|
|
.refine(
|
|
(value) => value === "" || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
|
|
"Bitte eine gültige Absender-E-Mail eingeben"
|
|
)
|
|
.default("")
|
|
})
|
|
.superRefine((values, ctx) => {
|
|
const first = Number(values.reminder_primary_hours);
|
|
const second = Number(values.reminder_secondary_hours);
|
|
|
|
if (!Number.isFinite(first) || first < 1) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["reminder_primary_hours"],
|
|
message: "Reminder 1 muss mindestens 1 Stunde sein."
|
|
});
|
|
}
|
|
|
|
if (!Number.isFinite(second) || second < 1) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["reminder_secondary_hours"],
|
|
message: "Reminder 2 muss mindestens 1 Stunde sein."
|
|
});
|
|
}
|
|
|
|
if (Number.isFinite(first) && Number.isFinite(second) && first <= second) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["reminder_primary_hours"],
|
|
message: "Reminder 1 muss später liegen als Reminder 2 (z. B. 24 und 1)."
|
|
});
|
|
}
|
|
});
|
|
|
|
type SettingsFormValues = z.infer<typeof settingsSchema>;
|
|
|
|
export function SettingsPanel() {
|
|
const router = useRouter();
|
|
const [settings, setSettings] = useState<SettingsMap | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [activeTab, setActiveTab] = useState<TabId>("general");
|
|
const [smtpTestTo, setSmtpTestTo] = useState("");
|
|
const [smtpTestLoading, setSmtpTestLoading] = useState(false);
|
|
const [smtpStep, setSmtpStep] = useState(0);
|
|
const [smtpTestState, setSmtpTestState] = useState<SmtpTestState>(() => emptySmtpTestState());
|
|
|
|
const settingsForm = useForm<SettingsFormValues>({
|
|
resolver: zodResolver(settingsSchema)
|
|
});
|
|
const jitsiMode = settingsForm.watch("jitsi_meeting_mode");
|
|
const smtpHost = settingsForm.watch("smtp_host") ?? "";
|
|
const smtpPort = settingsForm.watch("smtp_port") ?? "587";
|
|
const smtpUser = settingsForm.watch("smtp_user") ?? "";
|
|
const smtpPass = settingsForm.watch("smtp_pass") ?? "";
|
|
const smtpFromName = settingsForm.watch("smtp_from_name") ?? "CalBook";
|
|
const smtpFrom = settingsForm.watch("smtp_from") ?? "";
|
|
const smtpFromPreview = `${smtpFromName || "CalBook"} <${
|
|
smtpFrom || smtpUser || "no-reply@calbook.local"
|
|
}>`;
|
|
|
|
async function loadSettings() {
|
|
setLoading(true);
|
|
try {
|
|
const settingsRes = await fetch("/api/admin/einstellungen", { cache: "no-store" });
|
|
const settingsData = await settingsRes.json();
|
|
setSettings(settingsData.settings ?? {});
|
|
|
|
setSmtpTestTo(
|
|
settingsData.settings?.contact_email ??
|
|
extractAddressFromFromHeader(settingsData.settings?.smtp_from) ??
|
|
""
|
|
);
|
|
setSmtpStep(0);
|
|
setSmtpTestState(emptySmtpTestState());
|
|
|
|
settingsForm.reset({
|
|
company_name: settingsData.settings?.company_name ?? "CalBook",
|
|
contact_email: settingsData.settings?.contact_email ?? "",
|
|
default_duration_minutes: settingsData.settings?.default_duration_minutes ?? "60",
|
|
buffer_minutes: settingsData.settings?.buffer_minutes ?? "10",
|
|
booking_lead_hours: settingsData.settings?.booking_lead_hours ?? "2",
|
|
booking_window_days: settingsData.settings?.booking_window_days ?? "60",
|
|
cancel_limit_hours: settingsData.settings?.cancel_limit_hours ?? "24",
|
|
reminder_primary_hours: settingsData.settings?.reminder_primary_hours ?? "24",
|
|
reminder_secondary_hours: settingsData.settings?.reminder_secondary_hours ?? "1",
|
|
jitsi_meeting_mode: settingsData.settings?.jitsi_meeting_mode ?? "public",
|
|
jitsi_base_url: settingsData.settings?.jitsi_base_url || "https://meet.jit.si",
|
|
jitsi_room_prefix: settingsData.settings?.jitsi_room_prefix || "calbook",
|
|
booking_notice_text: settingsData.settings?.booking_notice_text ?? "",
|
|
smtp_host: settingsData.settings?.smtp_host ?? "",
|
|
smtp_port: settingsData.settings?.smtp_port ?? "587",
|
|
smtp_user: settingsData.settings?.smtp_user ?? "",
|
|
smtp_pass: settingsData.settings?.smtp_pass ?? "",
|
|
smtp_from_name:
|
|
settingsData.settings?.smtp_from_name ??
|
|
extractNameFromFromHeader(settingsData.settings?.smtp_from) ??
|
|
settingsData.settings?.company_name ??
|
|
"CalBook",
|
|
smtp_from: extractAddressFromFromHeader(settingsData.settings?.smtp_from)
|
|
});
|
|
} catch {
|
|
toast.error("Einstellungen konnten nicht geladen werden.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void loadSettings();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setSmtpTestState((prev) => {
|
|
if (prev.status === "idle" || prev.status === "loading") return prev;
|
|
return { status: "idle", message: "SMTP-Daten wurden geändert. Bitte erneut testen." };
|
|
});
|
|
}, [smtpHost, smtpPort, smtpUser, smtpPass, smtpFromName, smtpFrom]);
|
|
|
|
const onSaveSettings = settingsForm.handleSubmit(
|
|
async (values) => {
|
|
setSaving(true);
|
|
try {
|
|
const res = await fetch("/api/admin/einstellungen", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ values })
|
|
});
|
|
|
|
if (!res.ok) {
|
|
toast.error("Einstellungen konnten nicht gespeichert werden.");
|
|
return;
|
|
}
|
|
|
|
toast.success("Einstellungen gespeichert");
|
|
await loadSettings();
|
|
router.refresh();
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
},
|
|
() => {
|
|
toast.error("Bitte prüfe die Eingaben in den Einstellungen.");
|
|
}
|
|
);
|
|
|
|
async function sendSmtpTest() {
|
|
const values = settingsForm.getValues();
|
|
const host = values.smtp_host?.trim() ?? "";
|
|
const port = values.smtp_port?.trim() ?? "";
|
|
|
|
if (!host || !port) {
|
|
toast.error("Bitte SMTP-Host und SMTP-Port eintragen.");
|
|
setSmtpTestState({ status: "error", message: "SMTP-Host und Port fehlen." });
|
|
return;
|
|
}
|
|
|
|
const formIsValid = await settingsForm.trigger(["smtp_host", "smtp_port", "smtp_from_name", "smtp_from"]);
|
|
if (!formIsValid) {
|
|
toast.error("Bitte prüfe Absendername und Absender-E-Mail.");
|
|
return;
|
|
}
|
|
|
|
if (!smtpTestTo.trim()) {
|
|
toast.error("Bitte eine Empfänger-E-Mail für den SMTP-Test angeben.");
|
|
return;
|
|
}
|
|
|
|
setSmtpTestLoading(true);
|
|
setSmtpTestState({
|
|
status: "loading",
|
|
message: "Testmail wird mit den aktuell eingetragenen SMTP-Daten versendet ..."
|
|
});
|
|
try {
|
|
const res = await fetch("/api/admin/einstellungen/test-smtp", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
to: smtpTestTo.trim(),
|
|
smtp: {
|
|
host,
|
|
port,
|
|
user: values.smtp_user?.trim() ?? "",
|
|
pass: values.smtp_pass ?? "",
|
|
fromName: values.smtp_from_name?.trim() ?? "CalBook",
|
|
from: values.smtp_from?.trim() ?? ""
|
|
}
|
|
})
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
setSmtpTestState({ status: "error", message: data?.message ?? "SMTP-Test fehlgeschlagen." });
|
|
toast.error(data?.message ?? "SMTP-Test fehlgeschlagen.");
|
|
return;
|
|
}
|
|
setSmtpTestState({
|
|
status: "success",
|
|
message: data?.message ?? "Testmail wurde erfolgreich versendet."
|
|
});
|
|
toast.success("SMTP-Testmail wurde versendet.");
|
|
} catch {
|
|
setSmtpTestState({ status: "error", message: "SMTP-Test fehlgeschlagen." });
|
|
toast.error("SMTP-Test fehlgeschlagen.");
|
|
} finally {
|
|
setSmtpTestLoading(false);
|
|
}
|
|
}
|
|
|
|
async function goToNextSmtpStep() {
|
|
if (smtpStep === 0) {
|
|
const values = settingsForm.getValues();
|
|
if (!values.smtp_host?.trim() || !values.smtp_port?.trim()) {
|
|
toast.error("Bitte SMTP-Host und SMTP-Port eintragen.");
|
|
return;
|
|
}
|
|
}
|
|
if (smtpStep === 1) {
|
|
const formIsValid = await settingsForm.trigger(["smtp_from_name", "smtp_from"]);
|
|
if (!formIsValid) {
|
|
toast.error("Bitte prüfe die Absenderdaten.");
|
|
return;
|
|
}
|
|
}
|
|
setSmtpStep((prev) => Math.min(prev + 1, SMTP_SETUP_STEPS.length - 1));
|
|
}
|
|
|
|
if (loading || !settings) return null;
|
|
|
|
return (
|
|
<div className="max-w-6xl mx-auto">
|
|
<form onSubmit={onSaveSettings}>
|
|
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-black tracking-tight text-slate-950">Einstellungen</h1>
|
|
<p className="mt-1 text-sm font-medium text-slate-500">
|
|
Firma, Buchungsregeln und Kommunikation konfigurieren
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="mb-0 flex rounded-t-2xl border border-b-0 border-slate-200 bg-slate-50/80">
|
|
{TABS.map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
onClick={() => setActiveTab(tab.id)}
|
|
className={cn(
|
|
"flex items-center gap-2 px-5 py-3.5 text-sm font-bold transition-all first:rounded-tl-2xl",
|
|
activeTab === tab.id
|
|
? "border-b-2 border-slate-900 bg-white text-slate-900"
|
|
: "text-slate-500 hover:text-slate-700 hover:bg-white/50"
|
|
)}
|
|
>
|
|
{tab.icon}
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Tab content */}
|
|
<div className="rounded-b-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
|
{/* Allgemein */}
|
|
{activeTab === "general" && (
|
|
<div className="animate-in fade-in slide-in-from-left-2 duration-200 space-y-5">
|
|
<div>
|
|
<p className="text-xs font-black uppercase tracking-[0.22em] text-slate-400 mb-4">
|
|
Firmeninformationen
|
|
</p>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="company_name">Firmenname</Label>
|
|
<Input id="company_name" {...settingsForm.register("company_name")} />
|
|
<p className="text-xs text-slate-400">Erscheint in E-Mails und auf der Buchungsseite.</p>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="contact_email">Kontakt-E-Mail</Label>
|
|
<Input id="contact_email" {...settingsForm.register("contact_email")} />
|
|
<p className="text-xs text-slate-400">Wird für SMTP-Test und als Rückfall-Adresse verwendet.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="booking_notice_text">Hinweistext Buchungsseite</Label>
|
|
<Textarea id="booking_notice_text" {...settingsForm.register("booking_notice_text")} />
|
|
<p className="text-xs text-slate-400">Optionaler Hinweis, der auf der öffentlichen Buchungsseite angezeigt wird.</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Buchungsregeln */}
|
|
{activeTab === "booking" && (
|
|
<div className="animate-in fade-in slide-in-from-left-2 duration-200 space-y-5">
|
|
<div>
|
|
<p className="text-xs font-black uppercase tracking-[0.22em] text-slate-400 mb-4">
|
|
Termin-Parameter
|
|
</p>
|
|
<div className="grid gap-4 md:grid-cols-3">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="default_duration_minutes">Standard-Dauer (Min.)</Label>
|
|
<Input id="default_duration_minutes" {...settingsForm.register("default_duration_minutes")} />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="buffer_minutes">Puffer (Min.)</Label>
|
|
<Input id="buffer_minutes" {...settingsForm.register("buffer_minutes")} />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="booking_lead_hours">Buchungsvorlauf (Std.)</Label>
|
|
<Input id="booking_lead_hours" {...settingsForm.register("booking_lead_hours")} />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="booking_window_days">Buchungsfenster (Tage)</Label>
|
|
<Input id="booking_window_days" {...settingsForm.register("booking_window_days")} />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="cancel_limit_hours">Storno-Limit (Std.)</Label>
|
|
<Input id="cancel_limit_hours" {...settingsForm.register("cancel_limit_hours")} />
|
|
</div>
|
|
</div>
|
|
<div className="mt-3 rounded-xl border border-dashed border-slate-200 bg-slate-50 p-3 text-xs text-slate-500">
|
|
Buchbare Wochentage und Uhrzeiten werden pro Personen-Kalender unter{" "}
|
|
<strong>Kalender</strong> gepflegt.
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-xs font-black uppercase tracking-[0.22em] text-slate-400 mb-4">
|
|
Erinnerungen
|
|
</p>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="reminder_primary_hours">Erste Erinnerung (Std. vorher)</Label>
|
|
<Input id="reminder_primary_hours" {...settingsForm.register("reminder_primary_hours")} />
|
|
<p className="text-xs text-slate-400">Z. B. 24 für einen Tag vorher.</p>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="reminder_secondary_hours">Zweite Erinnerung (Std. vorher)</Label>
|
|
<Input id="reminder_secondary_hours" {...settingsForm.register("reminder_secondary_hours")} />
|
|
<p className="text-xs text-slate-400">Muss kleiner als die erste sein (z. B. 1).</p>
|
|
</div>
|
|
</div>
|
|
<div className="mt-3 rounded-xl border border-dashed border-slate-200 bg-slate-50 p-3 text-xs text-slate-500">
|
|
Beide Erinnerungen gelten für Kunde und Kalenderbesitzer, aber nicht für Instant Meetings.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Jitsi Meet */}
|
|
{activeTab === "jitsi" && (
|
|
<div className="animate-in fade-in slide-in-from-left-2 duration-200 space-y-5">
|
|
<p className="text-xs font-black uppercase tracking-[0.22em] text-slate-400 mb-4">
|
|
Videokonferenz
|
|
</p>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="jitsi_meeting_mode">Jitsi-Modus</Label>
|
|
<select
|
|
id="jitsi_meeting_mode"
|
|
className="h-11 w-full rounded-xl border border-slate-200 bg-slate-50 px-4 text-sm font-medium text-slate-900 transition-all focus:border-indigo-600 focus:outline-none focus:ring-1 focus:ring-indigo-600"
|
|
{...settingsForm.register("jitsi_meeting_mode")}
|
|
>
|
|
{JITSI_MODE_OPTIONS.map((mode) => (
|
|
<option key={mode.value} value={mode.value}>
|
|
{mode.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<p className="text-xs text-slate-400">
|
|
Öffentlich vermeidet Moderator-Login und ist für Kundentermine am einfachsten.
|
|
</p>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="jitsi_base_url">Jitsi-Basis-URL</Label>
|
|
<Input
|
|
id="jitsi_base_url"
|
|
{...settingsForm.register("jitsi_base_url")}
|
|
placeholder="https://meet.jit.si"
|
|
disabled={jitsiMode === "public"}
|
|
/>
|
|
<p className="text-xs text-slate-400">
|
|
Beispiel: <code>https://meet.jit.si</code> oder eigene Jitsi-Domain (nur bei „Eigene Jitsi-URL“).
|
|
</p>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="jitsi_room_prefix">Jitsi-Raum-Präfix</Label>
|
|
<Input
|
|
id="jitsi_room_prefix"
|
|
{...settingsForm.register("jitsi_room_prefix")}
|
|
placeholder="calbook"
|
|
/>
|
|
<p className="text-xs text-slate-400">
|
|
Wird vor jede Raum-ID gesetzt, z. B. <code>calbook-abc123</code>.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* SMTP */}
|
|
{activeTab === "smtp" && (
|
|
<div className="animate-in fade-in slide-in-from-left-2 duration-200 space-y-5">
|
|
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
|
<div>
|
|
<p className="text-xs font-black uppercase tracking-[0.22em] text-slate-400">
|
|
SMTP-Assistent
|
|
</p>
|
|
<p className="mt-1 text-sm font-medium text-slate-500">
|
|
Serverdaten eintragen, Absender prüfen und Testmail senden.
|
|
</p>
|
|
</div>
|
|
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-2 text-xs text-slate-600">
|
|
Absender: <span className="font-bold text-slate-900">{smtpFromPreview}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Step indicators */}
|
|
<div className="flex gap-2">
|
|
{SMTP_SETUP_STEPS.map((step, index) => (
|
|
<button
|
|
key={step}
|
|
type="button"
|
|
onClick={() => setSmtpStep(index)}
|
|
className={cn(
|
|
"flex-1 rounded-xl border px-4 py-3 text-left text-sm font-bold transition-all",
|
|
smtpStep === index
|
|
? "border-slate-900 bg-slate-900 text-white shadow-sm"
|
|
: smtpStep > index
|
|
? "border-emerald-200 bg-emerald-50 text-emerald-800"
|
|
: "border-slate-200 bg-white text-slate-500 hover:border-slate-300"
|
|
)}
|
|
>
|
|
<span className="block text-[10px] opacity-70">Schritt {index + 1}</span>
|
|
{smtpStep > index ? (
|
|
<span className="flex items-center gap-1">
|
|
<CheckCircle2 className="h-3.5 w-3.5" /> {step}
|
|
</span>
|
|
) : (
|
|
step
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Step content */}
|
|
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-5">
|
|
{smtpStep === 0 && (
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="smtp-host">SMTP-Host*</Label>
|
|
<Input id="smtp-host" {...settingsForm.register("smtp_host")} placeholder="mail.example.com" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="smtp-port">SMTP-Port*</Label>
|
|
<Input id="smtp-port" {...settingsForm.register("smtp_port")} placeholder="587" />
|
|
<p className="text-xs text-slate-400">
|
|
Port 465 = SSL, sonst STARTTLS falls angeboten.
|
|
</p>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="smtp-user">SMTP-Benutzer</Label>
|
|
<Input id="smtp-user" {...settingsForm.register("smtp_user")} autoComplete="username" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="smtp-pass">SMTP-Passwort</Label>
|
|
<Input
|
|
id="smtp-pass"
|
|
type="password"
|
|
{...settingsForm.register("smtp_pass")}
|
|
autoComplete="new-password"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{smtpStep === 1 && (
|
|
<div className="space-y-4">
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="smtp-from-name">Absender-Name*</Label>
|
|
<Input id="smtp-from-name" {...settingsForm.register("smtp_from_name")} placeholder="CalBook" />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="smtp-from">Absender-E-Mail</Label>
|
|
<Input id="smtp-from" {...settingsForm.register("smtp_from")} placeholder="no-reply@example.com" />
|
|
</div>
|
|
</div>
|
|
<div className="rounded-xl border border-slate-200 bg-white p-4 text-sm">
|
|
<p className="font-bold text-slate-900 mb-1">Vorschau</p>
|
|
<p className="break-all font-mono text-slate-700">{smtpFromPreview}</p>
|
|
<p className="mt-2 text-xs text-slate-400">
|
|
Viele SMTP-Server erzwingen als technische Absenderadresse den SMTP-Benutzer.
|
|
Der sichtbare Name bleibt trotzdem steuerbar.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{smtpStep === 2 && (
|
|
<div className="grid gap-5 lg:grid-cols-[1fr_320px]">
|
|
<div className="space-y-4">
|
|
<div className="rounded-xl border border-slate-200 bg-white p-4 text-sm">
|
|
<p className="mb-2 font-bold text-slate-900">Zusammenfassung</p>
|
|
<div className="grid grid-cols-2 gap-2 text-slate-600">
|
|
<p>Host:</p><p className="font-medium text-slate-900">{smtpHost || "-"}</p>
|
|
<p>Port:</p><p className="font-medium text-slate-900">{smtpPort || "-"}</p>
|
|
<p>Benutzer:</p><p className="font-medium text-slate-900">{smtpUser || "ohne Auth"}</p>
|
|
</div>
|
|
<p className="mt-2 break-all text-xs text-slate-500">
|
|
Absender: <span className="font-medium text-slate-700">{smtpFromPreview}</span>
|
|
</p>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="smtp-test-to">SMTP-Testempfänger*</Label>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Input
|
|
id="smtp-test-to"
|
|
type="email"
|
|
value={smtpTestTo}
|
|
onChange={(e) => {
|
|
setSmtpTestTo(e.target.value);
|
|
setSmtpTestState(emptySmtpTestState());
|
|
}}
|
|
placeholder="test@example.com"
|
|
className="max-w-sm"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() => void sendSmtpTest()}
|
|
disabled={smtpTestLoading}
|
|
>
|
|
{smtpTestLoading ? "Test läuft..." : "Testmail senden"}
|
|
</Button>
|
|
</div>
|
|
<p className="text-xs text-slate-400">
|
|
Der Test nutzt die aktuellen Eingaben, auch wenn sie noch nicht gespeichert sind.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<SmtpTestBox state={smtpTestState} />
|
|
<p className="mt-2 text-xs text-slate-400">
|
|
Nach erfolgreichem Test speichern, damit die SMTP-Daten aktiv für Buchungen,
|
|
Erinnerungen und Instant Meetings genutzt werden.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* SMTP navigation */}
|
|
<div className="mt-5 flex justify-between border-t border-slate-200 pt-4">
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() => setSmtpStep((prev) => Math.max(prev - 1, 0))}
|
|
disabled={smtpStep === 0}
|
|
>
|
|
<ArrowLeft className="mr-1.5 h-4 w-4" />
|
|
Zurück
|
|
</Button>
|
|
{smtpStep < SMTP_SETUP_STEPS.length - 1 ? (
|
|
<Button type="button" onClick={() => void goToNextSmtpStep()}>
|
|
Weiter
|
|
<ArrowRight className="ml-1.5 h-4 w-4" />
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() => void sendSmtpTest()}
|
|
disabled={smtpTestLoading}
|
|
>
|
|
{smtpTestLoading ? "Test läuft..." : "Jetzt SMTP prüfen"}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Bottom save button */}
|
|
<div className="mt-6 flex justify-end">
|
|
<Button type="submit" size="lg" disabled={saving}>
|
|
<Save className="mr-2 h-4 w-4" />
|
|
{saving ? "Speichert..." : "Einstellungen speichern"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|