Files
Calbook/components/admin/email-templates-panel.tsx

1669 lines
63 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import {
Bell,
CalendarCheck,
CalendarX,
CheckCircle2,
ChevronDown,
Clock,
Eye,
Mail,
Megaphone,
Pencil,
Plus,
Save,
Sparkles,
Trash2,
Users,
X
} from "lucide-react";
import { toast } from "sonner";
import { DEFAULT_SETTINGS, SETTING_KEYS } from "@/lib/constants";
import { EMAIL_STYLE_PRESETS } from "@/lib/email/style-presets";
import { normalizeMeetingButtonTemplate } from "@/lib/email/shortcodes";
import { cn } from "@/lib/utils";
type SettingsMap = Record<string, string>;
const TEMPLATE_TYPES = [
"confirmation",
"staff",
"cancellationCustomer",
"cancellationStaff",
"reminderCustomerPrimary",
"reminderCustomerSecondary",
"reminderStaffPrimary",
"reminderStaffSecondary",
"instantMeeting",
"smtpTest"
] as const;
type TemplateType = (typeof TEMPLATE_TYPES)[number];
type UiTemplate = {
id: string;
name: string;
type: TemplateType;
subject: string;
body: string;
isSystem: boolean;
isDefault: boolean;
};
type StoredTemplate = {
id: string;
name: string;
type: TemplateType;
subject: string;
body: string;
isDefault?: number;
};
type EditorState = {
id: string | null;
name: string;
type: TemplateType;
subject: string;
body: string;
};
const TYPE_LABELS: Record<TemplateType, string> = {
confirmation: "Bestätigung Kunde",
staff: "Neue Buchung Teilnehmer",
cancellationCustomer: "Stornierung Kunde",
cancellationStaff: "Stornierung Kalenderteilnehmer",
reminderCustomerPrimary: "Erinnerung Kunde 24h",
reminderCustomerSecondary: "Erinnerung Kunde 1h",
reminderStaffPrimary: "Erinnerung Kalenderteilnehmer 24h",
reminderStaffSecondary: "Erinnerung Kalenderteilnehmer 1h",
instantMeeting: "Instant Meeting",
smtpTest: "SMTP-Test"
};
const TYPE_SHORT_LABELS: Record<TemplateType, string> = {
confirmation: "Bestätigung",
staff: "Benachrichtigung",
cancellationCustomer: "Stornierung",
cancellationStaff: "Stornierung MA",
reminderCustomerPrimary: "Erinnerung 24h",
reminderCustomerSecondary: "Erinnerung 1h",
reminderStaffPrimary: "Erinnerung MA 24h",
reminderStaffSecondary: "Erinnerung MA 1h",
instantMeeting: "Instant Meeting",
smtpTest: "SMTP-Test"
};
const TYPE_AUDIENCE: Record<
TemplateType,
{ label: string; tone: "customer" | "owner" | "system" }
> = {
confirmation: { label: "Kunde", tone: "customer" },
staff: { label: "Kalenderbesitzer", tone: "owner" },
cancellationCustomer: { label: "Kunde", tone: "customer" },
cancellationStaff: { label: "Kalenderbesitzer", tone: "owner" },
reminderCustomerPrimary: { label: "Kunde", tone: "customer" },
reminderCustomerSecondary: { label: "Kunde", tone: "customer" },
reminderStaffPrimary: { label: "Kalenderbesitzer", tone: "owner" },
reminderStaffSecondary: { label: "Kalenderbesitzer", tone: "owner" },
instantMeeting: { label: "Kalenderbesitzer", tone: "owner" },
smtpTest: { label: "System", tone: "system" }
};
const TYPE_ICONS: Record<TemplateType, React.ReactNode> = {
confirmation: <CalendarCheck className="h-4 w-4" />,
staff: <Bell className="h-4 w-4" />,
cancellationCustomer: <CalendarX className="h-4 w-4" />,
cancellationStaff: <CalendarX className="h-4 w-4" />,
reminderCustomerPrimary: <Clock className="h-4 w-4" />,
reminderCustomerSecondary: <Clock className="h-4 w-4" />,
reminderStaffPrimary: <Clock className="h-4 w-4" />,
reminderStaffSecondary: <Clock className="h-4 w-4" />,
instantMeeting: <Megaphone className="h-4 w-4" />,
smtpTest: <Mail className="h-4 w-4" />
};
const TYPE_ORDER: TemplateType[] = [
"confirmation",
"staff",
"cancellationCustomer",
"cancellationStaff",
"reminderCustomerPrimary",
"reminderCustomerSecondary",
"reminderStaffPrimary",
"reminderStaffSecondary",
"instantMeeting",
"smtpTest"
];
const TYPE_GROUPS: { label: string; types: TemplateType[]; color: string }[] = [
{ label: "Buchung", types: ["confirmation", "staff"], color: "emerald" },
{ label: "Stornierung", types: ["cancellationCustomer", "cancellationStaff"], color: "rose" },
{ label: "Erinnerungen", types: ["reminderCustomerPrimary", "reminderCustomerSecondary", "reminderStaffPrimary", "reminderStaffSecondary"], color: "amber" },
{ label: "Spezial", types: ["instantMeeting", "smtpTest"], color: "slate" }
];
const PREVIEW_REPLACEMENTS: Array<[string, string]> = [
["{name}", "Alex Mustermann"],
["{date}", "24.12.2026"],
["{time}", "15:00"],
["{{customerName}}", "Alex Mustermann"],
["{{date}}", "24.12.2026"],
["{{time}}", "15:00"],
["{{duration}}", "60"],
["{{staffName}}", "Max Beispiel"],
["{{staffNames}}", "Max Beispiel, Mia Beispiel"],
["{{companyName}}", "CalBook"],
["{{cancelUrl}}", "https://example.com/stornieren/token"],
["{{rescheduleUrl}}", "https://example.com/buchen?rescheduleToken=abc"],
["{{meetingUrl}}", "https://meet.jit.si/calbook-demo-room"],
["{{reminderLabel}}", "in 24 Stunden"],
["{{hoursBefore}}", "24"],
["{{reminderKind}}", "primary"],
["{{recipientName}}", "Erika Beispiel"],
["{{recipientEmail}}", "erika@example.com"],
["{{scopeLabel}}", "Alle / Beide Kalender"],
["{{initiatorName}}", "Admin Person"],
["{{customMessage}}", "Kurzes Update vor unserem spontanen Austausch."],
["{{timestamp}}", "2026-05-04T10:15:00.000Z"],
["{{phone}}", "+49 151 123456"],
["{{email}}", "alex@example.com"],
["{{notes}}", "Ich möchte über mein Projekt sprechen."],
["{{dashboardUrl}}", "https://example.com/admin/termine"]
];
const MEETING_BUTTON_SHORTCODES = [
"{{meetingButton}}",
"{{jitsiButton}}",
"[meeting_button]",
"[jitsi_button]"
] as const;
const CANCEL_BUTTON_SHORTCODES = [
"{{cancelButton}}",
"{{stornoButton}}",
"{{cancellationButton}}",
"[cancel_button]"
] as const;
const MEETING_URL_PREVIEW = "https://meet.jit.si/calbook-demo-room";
const CANCEL_URL_PREVIEW = "https://example.com/stornieren/token";
function normalizeText(value: string | null | undefined) {
return (value ?? "")
.replace(/\\r\\n/g, "\n")
.replace(/\\n/g, "\n")
.replace(/\\r/g, "\n")
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
}
function applyPreviewValues(value: string, companyName: string) {
let output = normalizeText(value);
const replacements = PREVIEW_REPLACEMENTS.map(([from, to]) =>
from === "{{companyName}}" ? [from, companyName] : [from, to]
);
for (const [from, to] of replacements) {
output = output.split(from).join(to);
}
return output;
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function toTemplateId(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function getSystemTemplateFromSettings(
settings: SettingsMap,
customTemplates: UiTemplate[]
): UiTemplate[] {
const customDefaultByType = new Set(
customTemplates.filter((template) => template.isDefault).map((template) => template.type)
);
const confirmSubject =
settings[SETTING_KEYS.EMAIL_SUBJECT_CUSTOMER_CONFIRM] ??
DEFAULT_SETTINGS[SETTING_KEYS.EMAIL_SUBJECT_CUSTOMER_CONFIRM];
const staffSubject =
settings[SETTING_KEYS.EMAIL_SUBJECT_STAFF_NOTIFY] ??
DEFAULT_SETTINGS[SETTING_KEYS.EMAIL_SUBJECT_STAFF_NOTIFY];
const cancellationCustomerSubject =
settings[SETTING_KEYS.EMAIL_SUBJECT_CANCELLATION_CUSTOMER] ??
DEFAULT_SETTINGS[SETTING_KEYS.EMAIL_SUBJECT_CANCELLATION_CUSTOMER];
const cancellationStaffSubject =
settings[SETTING_KEYS.EMAIL_SUBJECT_CANCELLATION_STAFF] ??
DEFAULT_SETTINGS[SETTING_KEYS.EMAIL_SUBJECT_CANCELLATION_STAFF];
const reminderCustomerPrimarySubject =
settings[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_CUSTOMER_PRIMARY] ||
settings[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_CUSTOMER] ||
DEFAULT_SETTINGS[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_CUSTOMER_PRIMARY];
const reminderCustomerSecondarySubject =
settings[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_CUSTOMER_SECONDARY] ||
settings[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_CUSTOMER] ||
DEFAULT_SETTINGS[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_CUSTOMER_SECONDARY];
const reminderStaffPrimarySubject =
settings[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_STAFF_PRIMARY] ||
settings[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_STAFF] ||
DEFAULT_SETTINGS[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_STAFF_PRIMARY];
const reminderStaffSecondarySubject =
settings[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_STAFF_SECONDARY] ||
settings[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_STAFF] ||
DEFAULT_SETTINGS[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_STAFF_SECONDARY];
const instantMeetingSubject =
settings[SETTING_KEYS.INSTANT_MEETING_EMAIL_SUBJECT] ??
DEFAULT_SETTINGS[SETTING_KEYS.INSTANT_MEETING_EMAIL_SUBJECT];
const smtpTestSubject =
settings[SETTING_KEYS.EMAIL_SUBJECT_SMTP_TEST] ??
DEFAULT_SETTINGS[SETTING_KEYS.EMAIL_SUBJECT_SMTP_TEST];
const confirmBody =
normalizeMeetingButtonTemplate(
normalizeText(settings[SETTING_KEYS.EMAIL_TEMPLATE_CUSTOMER_CONFIRM])
) || DEFAULT_SETTINGS[SETTING_KEYS.EMAIL_TEMPLATE_CUSTOMER_CONFIRM];
const staffBody =
normalizeMeetingButtonTemplate(
normalizeText(settings[SETTING_KEYS.EMAIL_TEMPLATE_STAFF_NOTIFY])
) || DEFAULT_SETTINGS[SETTING_KEYS.EMAIL_TEMPLATE_STAFF_NOTIFY];
const cancellationCustomerBody =
normalizeText(
settings[SETTING_KEYS.EMAIL_TEMPLATE_CANCELLATION_CUSTOMER] ||
settings[SETTING_KEYS.EMAIL_TEMPLATE_CANCELLATION]
) || DEFAULT_SETTINGS[SETTING_KEYS.EMAIL_TEMPLATE_CANCELLATION_CUSTOMER];
const cancellationStaffBody =
normalizeText(
settings[SETTING_KEYS.EMAIL_TEMPLATE_CANCELLATION_STAFF] ||
settings[SETTING_KEYS.EMAIL_TEMPLATE_CANCELLATION]
) || DEFAULT_SETTINGS[SETTING_KEYS.EMAIL_TEMPLATE_CANCELLATION_STAFF];
const reminderCustomerPrimaryBody =
normalizeMeetingButtonTemplate(
normalizeText(
settings[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_CUSTOMER_PRIMARY] ||
settings[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_CUSTOMER]
)
) || DEFAULT_SETTINGS[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_CUSTOMER_PRIMARY];
const reminderCustomerSecondaryBody =
normalizeMeetingButtonTemplate(
normalizeText(
settings[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_CUSTOMER_SECONDARY] ||
settings[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_CUSTOMER]
)
) || DEFAULT_SETTINGS[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_CUSTOMER_SECONDARY];
const reminderStaffPrimaryBody =
normalizeMeetingButtonTemplate(
normalizeText(
settings[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_STAFF_PRIMARY] ||
settings[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_STAFF]
)
) || DEFAULT_SETTINGS[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_STAFF_PRIMARY];
const reminderStaffSecondaryBody =
normalizeMeetingButtonTemplate(
normalizeText(
settings[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_STAFF_SECONDARY] ||
settings[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_STAFF]
)
) || DEFAULT_SETTINGS[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_STAFF_SECONDARY];
const instantMeetingBody =
normalizeMeetingButtonTemplate(
normalizeText(settings[SETTING_KEYS.INSTANT_MEETING_EMAIL_TEMPLATE])
) || DEFAULT_SETTINGS[SETTING_KEYS.INSTANT_MEETING_EMAIL_TEMPLATE];
const smtpTestBody =
normalizeText(settings[SETTING_KEYS.EMAIL_TEMPLATE_SMTP_TEST]) ||
DEFAULT_SETTINGS[SETTING_KEYS.EMAIL_TEMPLATE_SMTP_TEST];
return [
{
id: "system:confirmation",
name: "Standard - Bestätigung",
type: "confirmation",
subject: confirmSubject,
body: confirmBody,
isSystem: true,
isDefault: !customDefaultByType.has("confirmation")
},
{
id: "system:staff",
name: "Standard - Benachrichtigung",
type: "staff",
subject: staffSubject,
body: staffBody,
isSystem: true,
isDefault: !customDefaultByType.has("staff")
},
{
id: "system:cancellation-customer",
name: "Standard - Stornierung Kunde",
type: "cancellationCustomer",
subject: cancellationCustomerSubject,
body: cancellationCustomerBody,
isSystem: true,
isDefault: !customDefaultByType.has("cancellationCustomer")
},
{
id: "system:cancellation-staff",
name: "Standard - Stornierung Kalenderteilnehmer",
type: "cancellationStaff",
subject: cancellationStaffSubject,
body: cancellationStaffBody,
isSystem: true,
isDefault: !customDefaultByType.has("cancellationStaff")
},
{
id: "system:reminder-customer-primary",
name: "Standard - Erinnerung Kunde 24h",
type: "reminderCustomerPrimary",
subject: reminderCustomerPrimarySubject,
body: reminderCustomerPrimaryBody,
isSystem: true,
isDefault: !customDefaultByType.has("reminderCustomerPrimary")
},
{
id: "system:reminder-customer-secondary",
name: "Standard - Erinnerung Kunde 1h",
type: "reminderCustomerSecondary",
subject: reminderCustomerSecondarySubject,
body: reminderCustomerSecondaryBody,
isSystem: true,
isDefault: !customDefaultByType.has("reminderCustomerSecondary")
},
{
id: "system:reminder-staff-primary",
name: "Standard - Erinnerung Kalenderteilnehmer 24h",
type: "reminderStaffPrimary",
subject: reminderStaffPrimarySubject,
body: reminderStaffPrimaryBody,
isSystem: true,
isDefault: !customDefaultByType.has("reminderStaffPrimary")
},
{
id: "system:reminder-staff-secondary",
name: "Standard - Erinnerung Kalenderteilnehmer 1h",
type: "reminderStaffSecondary",
subject: reminderStaffSecondarySubject,
body: reminderStaffSecondaryBody,
isSystem: true,
isDefault: !customDefaultByType.has("reminderStaffSecondary")
},
{
id: "system:instant-meeting",
name: "Standard - Instant Meeting",
type: "instantMeeting",
subject: instantMeetingSubject,
body: instantMeetingBody,
isSystem: true,
isDefault: !customDefaultByType.has("instantMeeting")
},
{
id: "system:smtp-test",
name: "Standard - SMTP-Test",
type: "smtpTest",
subject: smtpTestSubject,
body: smtpTestBody,
isSystem: true,
isDefault: !customDefaultByType.has("smtpTest")
}
];
}
function parseCustomTemplates(raw: string | undefined): UiTemplate[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
const templates: UiTemplate[] = [];
for (const entry of parsed) {
if (!entry || typeof entry !== "object") continue;
const obj = entry as Record<string, unknown>;
if (
typeof obj.id === "string" &&
typeof obj.name === "string" &&
typeof obj.type === "string" &&
typeof obj.subject === "string" &&
typeof obj.body === "string"
) {
if (obj.type === "reminderCustomer") {
templates.push(
{
id: `${obj.id}:primary`,
name: `${obj.name} - 24h`,
type: "reminderCustomerPrimary",
subject: normalizeText(obj.subject),
body: normalizeMeetingButtonTemplate(normalizeText(obj.body)),
isSystem: false,
isDefault: Number(obj.isDefault ?? 0) === 1
},
{
id: `${obj.id}:secondary`,
name: `${obj.name} - 1h`,
type: "reminderCustomerSecondary",
subject: normalizeText(obj.subject),
body: normalizeMeetingButtonTemplate(normalizeText(obj.body)),
isSystem: false,
isDefault: Number(obj.isDefault ?? 0) === 1
}
);
continue;
}
if (obj.type === "reminderStaff") {
templates.push(
{
id: `${obj.id}:primary`,
name: `${obj.name} - 24h`,
type: "reminderStaffPrimary",
subject: normalizeText(obj.subject),
body: normalizeMeetingButtonTemplate(normalizeText(obj.body)),
isSystem: false,
isDefault: Number(obj.isDefault ?? 0) === 1
},
{
id: `${obj.id}:secondary`,
name: `${obj.name} - 1h`,
type: "reminderStaffSecondary",
subject: normalizeText(obj.subject),
body: normalizeMeetingButtonTemplate(normalizeText(obj.body)),
isSystem: false,
isDefault: Number(obj.isDefault ?? 0) === 1
}
);
continue;
}
if (obj.type === "cancellation") {
templates.push(
{
id: `${obj.id}:customer`,
name: `${obj.name} - Kunde`,
type: "cancellationCustomer",
subject: normalizeText(obj.subject),
body: normalizeText(obj.body),
isSystem: false,
isDefault: Number(obj.isDefault ?? 0) === 1
},
{
id: `${obj.id}:staff`,
name: `${obj.name} - Kalenderteilnehmer`,
type: "cancellationStaff",
subject: normalizeText(obj.subject),
body: normalizeText(obj.body),
isSystem: false,
isDefault: Number(obj.isDefault ?? 0) === 1
}
);
continue;
}
if (!TEMPLATE_TYPES.includes(obj.type as TemplateType)) {
continue;
}
templates.push({
id: obj.id,
name: obj.name,
type: obj.type as TemplateType,
subject: normalizeText(obj.subject),
body: normalizeMeetingButtonTemplate(normalizeText(obj.body)),
isSystem: false,
isDefault: Number(obj.isDefault ?? 0) === 1
});
continue;
}
if (
typeof obj.id === "string" &&
typeof obj.name === "string" &&
obj.values &&
typeof obj.values === "object"
) {
const values = obj.values as Record<string, string>;
templates.push(
{
id: `${obj.id}:confirmation`,
name: `${obj.name} - Bestätigung`,
type: "confirmation",
subject: normalizeText(values.email_subject_customer_confirm ?? ""),
body: normalizeMeetingButtonTemplate(
normalizeText(values.email_template_customer_confirm ?? "")
),
isSystem: false,
isDefault: false
},
{
id: `${obj.id}:staff`,
name: `${obj.name} - Benachrichtigung`,
type: "staff",
subject: normalizeText(values.email_subject_staff_notify ?? ""),
body: normalizeMeetingButtonTemplate(
normalizeText(values.email_template_staff_notify ?? "")
),
isSystem: false,
isDefault: false
},
{
id: `${obj.id}:cancellation-customer`,
name: `${obj.name} - Stornierung Kunde`,
type: "cancellationCustomer",
subject: normalizeText(values.email_subject_cancellation_customer ?? ""),
body: normalizeText(
values.email_template_cancellation_customer ??
values.email_template_cancellation ??
""
),
isSystem: false,
isDefault: false
},
{
id: `${obj.id}:cancellation-staff`,
name: `${obj.name} - Stornierung Kalenderteilnehmer`,
type: "cancellationStaff",
subject: normalizeText(values.email_subject_cancellation_staff ?? ""),
body: normalizeText(
values.email_template_cancellation_staff ??
values.email_template_cancellation ??
""
),
isSystem: false,
isDefault: false
},
{
id: `${obj.id}:reminder-customer-primary`,
name: `${obj.name} - Erinnerung Kunde 24h`,
type: "reminderCustomerPrimary",
subject: normalizeText(
values.email_subject_reminder_customer_primary ??
values.email_subject_reminder_customer ??
""
),
body: normalizeMeetingButtonTemplate(
normalizeText(
values.email_template_reminder_customer_primary ??
values.email_template_reminder_customer ??
""
)
),
isSystem: false,
isDefault: false
},
{
id: `${obj.id}:reminder-customer-secondary`,
name: `${obj.name} - Erinnerung Kunde 1h`,
type: "reminderCustomerSecondary",
subject: normalizeText(
values.email_subject_reminder_customer_secondary ??
values.email_subject_reminder_customer ??
""
),
body: normalizeMeetingButtonTemplate(
normalizeText(
values.email_template_reminder_customer_secondary ??
values.email_template_reminder_customer ??
""
)
),
isSystem: false,
isDefault: false
},
{
id: `${obj.id}:reminder-staff-primary`,
name: `${obj.name} - Erinnerung Kalenderteilnehmer 24h`,
type: "reminderStaffPrimary",
subject: normalizeText(
values.email_subject_reminder_staff_primary ??
values.email_subject_reminder_staff ??
""
),
body: normalizeMeetingButtonTemplate(
normalizeText(
values.email_template_reminder_staff_primary ??
values.email_template_reminder_staff ??
""
)
),
isSystem: false,
isDefault: false
},
{
id: `${obj.id}:reminder-staff-secondary`,
name: `${obj.name} - Erinnerung Kalenderteilnehmer 1h`,
type: "reminderStaffSecondary",
subject: normalizeText(
values.email_subject_reminder_staff_secondary ??
values.email_subject_reminder_staff ??
""
),
body: normalizeMeetingButtonTemplate(
normalizeText(
values.email_template_reminder_staff_secondary ??
values.email_template_reminder_staff ??
""
)
),
isSystem: false,
isDefault: false
},
{
id: `${obj.id}:instant-meeting`,
name: `${obj.name} - Instant Meeting`,
type: "instantMeeting",
subject: normalizeText(values.instant_meeting_email_subject ?? ""),
body: normalizeMeetingButtonTemplate(
normalizeText(values.instant_meeting_email_template ?? "")
),
isSystem: false,
isDefault: false
},
{
id: `${obj.id}:smtp-test`,
name: `${obj.name} - SMTP-Test`,
type: "smtpTest",
subject: normalizeText(values.email_subject_smtp_test ?? ""),
body: normalizeText(values.email_template_smtp_test ?? ""),
isSystem: false,
isDefault: false
}
);
}
}
return templates.filter((template) => template.subject || template.body);
} catch {
return [];
}
}
function serializeCustomTemplates(templates: UiTemplate[]): StoredTemplate[] {
return templates
.filter((template) => !template.isSystem)
.map((template) => ({
id: template.id,
name: template.name,
type: template.type,
subject: normalizeText(template.subject),
body: normalizeText(template.body),
isDefault: template.isDefault ? 1 : 0
}));
}
function templateSettingsPatch(template: Pick<UiTemplate, "type" | "subject" | "body">) {
const patch: Record<string, string> = {};
if (template.type === "confirmation") {
patch[SETTING_KEYS.EMAIL_SUBJECT_CUSTOMER_CONFIRM] = template.subject;
patch[SETTING_KEYS.EMAIL_TEMPLATE_CUSTOMER_CONFIRM] = template.body;
}
if (template.type === "staff") {
patch[SETTING_KEYS.EMAIL_SUBJECT_STAFF_NOTIFY] = template.subject;
patch[SETTING_KEYS.EMAIL_TEMPLATE_STAFF_NOTIFY] = template.body;
}
if (template.type === "cancellationCustomer") {
patch[SETTING_KEYS.EMAIL_SUBJECT_CANCELLATION_CUSTOMER] = template.subject;
patch[SETTING_KEYS.EMAIL_TEMPLATE_CANCELLATION_CUSTOMER] = template.body;
}
if (template.type === "cancellationStaff") {
patch[SETTING_KEYS.EMAIL_SUBJECT_CANCELLATION_STAFF] = template.subject;
patch[SETTING_KEYS.EMAIL_TEMPLATE_CANCELLATION_STAFF] = template.body;
}
if (template.type === "reminderCustomerPrimary") {
patch[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_CUSTOMER_PRIMARY] = template.subject;
patch[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_CUSTOMER_PRIMARY] = template.body;
}
if (template.type === "reminderCustomerSecondary") {
patch[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_CUSTOMER_SECONDARY] = template.subject;
patch[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_CUSTOMER_SECONDARY] = template.body;
}
if (template.type === "reminderStaffPrimary") {
patch[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_STAFF_PRIMARY] = template.subject;
patch[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_STAFF_PRIMARY] = template.body;
}
if (template.type === "reminderStaffSecondary") {
patch[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_STAFF_SECONDARY] = template.subject;
patch[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_STAFF_SECONDARY] = template.body;
}
if (template.type === "instantMeeting") {
patch[SETTING_KEYS.INSTANT_MEETING_EMAIL_SUBJECT] = template.subject;
patch[SETTING_KEYS.INSTANT_MEETING_EMAIL_TEMPLATE] = template.body;
}
if (template.type === "smtpTest") {
patch[SETTING_KEYS.EMAIL_SUBJECT_SMTP_TEST] = template.subject;
patch[SETTING_KEYS.EMAIL_TEMPLATE_SMTP_TEST] = template.body;
}
return patch;
}
function renderPreview(subject: string, body: string, styleId: string, companyName: string) {
const subjectPreview = applyPreviewValues(subject, companyName) || "Betreff...";
const PREVIEW_MEETING_MARKER = "__CB_PREVIEW_MEETING__";
const PREVIEW_CANCEL_MARKER = "__CB_PREVIEW_CANCEL__";
let bodyPreviewRaw = applyPreviewValues(body, companyName);
const replaceCtaWithMarker = (params: {
marker: string;
shortcodes: readonly string[];
url: string;
labelPattern: string;
}) => {
let matched = false;
for (const token of params.shortcodes) {
if (bodyPreviewRaw.includes(token)) {
matched = true;
bodyPreviewRaw = bodyPreviewRaw.split(token).join(params.marker);
}
}
if (bodyPreviewRaw.includes(params.url)) {
matched = true;
const escapedUrl = escapeRegExp(params.url);
bodyPreviewRaw = bodyPreviewRaw
.replace(
new RegExp(`^.*(?:${params.labelPattern}).*${escapedUrl}.*$`, "gim"),
params.marker
)
.replace(new RegExp(`^\\s*${escapedUrl}\\s*$`, "gim"), params.marker)
.replace(new RegExp(escapedUrl, "g"), params.marker);
}
return matched;
};
replaceCtaWithMarker({
marker: PREVIEW_MEETING_MARKER,
shortcodes: [...MEETING_BUTTON_SHORTCODES, "{{meetingUrl}}"],
url: MEETING_URL_PREVIEW,
labelPattern: "jitsi|meeting|video|raum|beitreten"
});
replaceCtaWithMarker({
marker: PREVIEW_CANCEL_MARKER,
shortcodes: [...CANCEL_BUTTON_SHORTCODES, "{{cancelUrl}}"],
url: CANCEL_URL_PREVIEW,
labelPattern: "absag|storn|cancel"
});
bodyPreviewRaw = bodyPreviewRaw
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
const meetingButtonClass = cn(
"inline-flex h-11 items-center justify-center rounded-xl px-4 text-sm font-bold no-underline mr-2 my-1 align-middle",
styleId === "corporate" && "bg-indigo-600 text-white",
styleId === "startup" && "bg-indigo-600 text-white",
styleId === "ink" && "bg-[#1e3a5f] text-white",
styleId === "warm" && "bg-amber-600 text-white",
styleId === "mono" && "bg-slate-900 text-white",
!["corporate", "startup", "ink", "warm", "mono"].includes(styleId) &&
"bg-slate-900 text-white"
);
const cancelButtonClass =
"inline-flex h-11 items-center justify-center rounded-xl px-4 text-sm font-bold no-underline mr-2 my-1 align-middle bg-red-600 text-white";
const previewText = bodyPreviewRaw || "Inhalt...";
const previewParts = previewText.split(
new RegExp(`(${PREVIEW_MEETING_MARKER}|${PREVIEW_CANCEL_MARKER})`, "g")
);
const bodyPreviewNode = previewParts.map((part, index) => {
if (part === PREVIEW_MEETING_MARKER) {
return (
<a
key={`preview-meeting-${index}`}
href="#"
onClick={(event) => event.preventDefault()}
className={meetingButtonClass}
>
Meeting beitreten
</a>
);
}
if (part === PREVIEW_CANCEL_MARKER) {
return (
<a
key={`preview-cancel-${index}`}
href="#"
onClick={(event) => event.preventDefault()}
className={cancelButtonClass}
>
Termin stornieren
</a>
);
}
return <span key={`preview-text-${index}`}>{part}</span>;
});
if (styleId === "corporate") {
return (
<div className="bg-slate-100 p-6 sm:p-8 rounded-xl border border-slate-200 text-left shadow-sm">
<div className="bg-indigo-600 text-white p-5 rounded-t-xl font-bold flex items-center gap-3">
<Mail className="w-5 h-5 text-indigo-200" /> {subjectPreview}
</div>
<div className="bg-white p-6 sm:p-8 rounded-b-xl whitespace-pre-wrap text-slate-700 leading-relaxed text-sm">
{bodyPreviewNode}
</div>
</div>
);
}
if (styleId === "serif") {
return (
<div className="bg-[#faf8f5] p-8 sm:p-12 border border-[#e7e0d5] text-left shadow-sm" style={{ fontFamily: "Georgia, serif" }}>
<div className="text-center mb-9">
<div className="text-xs uppercase tracking-[0.24em] text-[#78716c] border-b border-[#d6cec0] pb-4 inline-block">{subjectPreview}</div>
</div>
<div className="whitespace-pre-wrap leading-[1.9] text-sm text-center text-[#2b2721]">{bodyPreviewNode}</div>
</div>
);
}
if (styleId === "mono") {
return (
<div className="bg-white p-0 border-2 border-slate-900 text-left">
<div className="bg-slate-900 text-white p-5 font-bold text-base">{subjectPreview}</div>
<div className="p-6 sm:p-8 whitespace-pre-wrap leading-relaxed text-sm text-slate-800">{bodyPreviewNode}</div>
</div>
);
}
if (styleId === "glass") {
return (
<div className="bg-white/50 backdrop-blur-sm p-8 sm:p-10 rounded-2xl border border-slate-200/90 text-left shadow-sm">
<div className="bg-white/40 rounded-xl p-4 font-bold text-slate-900 mb-6">{subjectPreview}</div>
<div className="bg-white/60 rounded-xl p-5 whitespace-pre-wrap leading-relaxed text-sm text-slate-700">{bodyPreviewNode}</div>
</div>
);
}
if (styleId === "ink") {
return (
<div className="bg-[#f0f4ff] p-8 sm:p-10 rounded-2xl border border-slate-200 text-left shadow-sm">
<div className="bg-[#1e3a5f] text-[#e8f0fe] p-5 rounded-xl font-bold text-base mb-6">{subjectPreview}</div>
<div className="bg-white p-6 rounded-xl whitespace-pre-wrap leading-relaxed text-sm text-slate-800">{bodyPreviewNode}</div>
</div>
);
}
if (styleId === "warm") {
return (
<div className="bg-[#fef7ed] p-8 sm:p-10 rounded-2xl border border-[#fde68a] text-left shadow-sm">
<div className="bg-[#b45309] text-[#fff7ed] inline-block rounded-full px-4 py-2 text-xs font-black uppercase tracking-widest mb-6">{subjectPreview}</div>
<div className="bg-white p-6 rounded-xl whitespace-pre-wrap leading-relaxed text-sm text-[#431407] font-medium">{bodyPreviewNode}</div>
</div>
);
}
if (styleId === "soft") {
return (
<div className="bg-[#fafafa] p-8 sm:p-10 rounded-2xl border border-[#e5e5e5] text-left shadow-sm">
<div className="bg-[#f5f5f5] rounded-xl p-4 font-semibold text-[#404040] mb-6">{subjectPreview}</div>
<div className="whitespace-pre-wrap leading-relaxed text-sm text-[#525252] px-2">{bodyPreviewNode}</div>
</div>
);
}
return (
<div className="bg-white p-6 sm:p-8 rounded-xl border border-slate-200 text-left shadow-sm">
<div className="uppercase tracking-widest text-[10px] sm:text-xs font-bold mb-6 text-slate-400 border-b border-slate-100 pb-4">
{subjectPreview}
</div>
<div className="whitespace-pre-wrap text-slate-800 leading-relaxed font-medium text-sm">
{bodyPreviewNode}
</div>
</div>
);
}
export function EmailTemplatesPanel() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [selectedStyle, setSelectedStyle] = useState("minimal");
const [previewCompanyName, setPreviewCompanyName] = useState("CalBook");
const [templates, setTemplates] = useState<UiTemplate[]>([]);
const [activeTemplateId, setActiveTemplateId] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [designSectionOpen, setDesignSectionOpen] = useState(false);
const [editor, setEditor] = useState<EditorState>({
id: null,
name: "Neue Vorlage",
type: "confirmation",
subject: "",
body: ""
});
async function patchSettings(values: Record<string, string>) {
const response = await fetch("/api/admin/einstellungen", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ values })
});
if (!response.ok) {
throw new Error("save_failed");
}
}
const reload = async () => {
setLoading(true);
try {
const response = await fetch("/api/admin/einstellungen", {
cache: "no-store"
});
const data = await response.json();
const settings = (data?.settings ?? {}) as SettingsMap;
const styleCandidate =
settings[SETTING_KEYS.EMAIL_STYLE_TEMPLATE_ID] || EMAIL_STYLE_PRESETS[0]?.id || "minimal";
const styleId = EMAIL_STYLE_PRESETS.some((style) => style.id === styleCandidate)
? styleCandidate
: EMAIL_STYLE_PRESETS[0]!.id;
setSelectedStyle(styleId);
setPreviewCompanyName(settings[SETTING_KEYS.COMPANY_NAME] || "CalBook");
const customTemplates = parseCustomTemplates(
settings[SETTING_KEYS.EMAIL_TEMPLATE_CUSTOM_LIBRARY]
);
const systemTemplates = getSystemTemplateFromSettings(settings, customTemplates);
const allTemplates = [...systemTemplates, ...customTemplates];
setTemplates(allTemplates);
setActiveTemplateId((prev) => {
if (prev && allTemplates.some((template) => template.id === prev)) {
return prev;
}
return allTemplates[0]?.id ?? null;
});
} catch {
toast.error("E-Mail-Templates konnten nicht geladen werden.");
} finally {
setLoading(false);
}
};
useEffect(() => {
void reload();
}, []);
const activeTemplate = useMemo(
() => templates.find((template) => template.id === activeTemplateId) ?? null,
[activeTemplateId, templates]
);
const activeType = activeTemplate?.type ?? TYPE_ORDER[0];
const activeTypeTemplates = useMemo(
() => templates.filter((template) => template.type === activeType),
[activeType, templates]
);
function selectTemplateType(type: TemplateType) {
if (isEditing) return;
const typeTemplates = templates.filter((template) => template.type === type);
const defaultTemplate = typeTemplates.find((template) => template.isDefault);
setActiveTemplateId(defaultTemplate?.id ?? typeTemplates[0]?.id ?? null);
}
function startCreate(type: TemplateType = activeType) {
setEditor({
id: null,
name: "Neue Vorlage",
type,
subject: "",
body: ""
});
setIsEditing(true);
}
function startEdit(template: UiTemplate) {
setEditor({
id: template.id,
name: template.name,
type: template.type,
subject: template.subject,
body: template.body
});
setActiveTemplateId(template.id);
setIsEditing(true);
}
async function saveEditor() {
if (!editor.name.trim() || !editor.subject.trim() || !editor.body.trim()) {
toast.error("Bitte Name, Betreff und Inhalt ausfüllen.");
return;
}
setSaving(true);
try {
const nextTemplates = [...templates];
if (!editor.id) {
const created: UiTemplate = {
id: toTemplateId("custom-template"),
name: editor.name.trim(),
type: editor.type,
subject: normalizeText(editor.subject),
body: normalizeText(editor.body),
isSystem: false,
isDefault: false
};
nextTemplates.push(created);
} else {
const idx = nextTemplates.findIndex((template) => template.id === editor.id);
if (idx >= 0) {
const original = nextTemplates[idx]!;
nextTemplates[idx] = {
...original,
name: editor.name.trim(),
type: editor.type,
subject: normalizeText(editor.subject),
body: normalizeText(editor.body)
};
}
}
const patch: Record<string, string> = {
[SETTING_KEYS.EMAIL_STYLE_TEMPLATE_ID]: selectedStyle,
[SETTING_KEYS.EMAIL_TEMPLATE_CUSTOM_LIBRARY]: JSON.stringify(
serializeCustomTemplates(nextTemplates)
)
};
const editedSystem = nextTemplates.find(
(template) => template.id === editor.id && template.isSystem
);
if (editedSystem) {
Object.assign(patch, templateSettingsPatch(editedSystem));
}
const editedActiveTemplate = nextTemplates.find(
(template) => template.id === editor.id && template.isDefault
);
if (editedActiveTemplate) {
Object.assign(patch, templateSettingsPatch(editedActiveTemplate));
}
await patchSettings(patch);
toast.success(editor.id ? "Vorlage gespeichert." : "Vorlage erstellt.");
setIsEditing(false);
await reload();
} catch {
toast.error("Vorlage konnte nicht gespeichert werden.");
} finally {
setSaving(false);
}
}
async function deleteTemplate(templateId: string) {
const target = templates.find((template) => template.id === templateId);
if (!target || target.isSystem) {
toast.error("Standard-Vorlagen können nicht gelöscht werden.");
return;
}
setSaving(true);
try {
const nextTemplates = templates.filter((template) => template.id !== templateId);
await patchSettings({
[SETTING_KEYS.EMAIL_TEMPLATE_CUSTOM_LIBRARY]: JSON.stringify(
serializeCustomTemplates(nextTemplates)
)
});
toast.success("Vorlage gelöscht.");
if (activeTemplateId === templateId) {
const replacement = nextTemplates.find((template) => template.type === target.type);
setActiveTemplateId(replacement?.id ?? null);
}
await reload();
} catch {
toast.error("Vorlage konnte nicht gelöscht werden.");
} finally {
setSaving(false);
}
}
async function setTemplateAsDefault(templateId: string) {
const target = templates.find((template) => template.id === templateId);
if (!target) return;
const patch: Record<string, string> = {
[SETTING_KEYS.EMAIL_STYLE_TEMPLATE_ID]: selectedStyle
};
Object.assign(patch, templateSettingsPatch(target));
const nextTemplates = templates.map((template) => {
if (template.type !== target.type) return template;
return {
...template,
isDefault: template.id === target.id
};
});
patch[SETTING_KEYS.EMAIL_TEMPLATE_CUSTOM_LIBRARY] = JSON.stringify(
serializeCustomTemplates(nextTemplates)
);
setSaving(true);
try {
await patchSettings(patch);
toast.success("Vorlage als aktiv gesetzt.");
await reload();
} catch {
toast.error("Aktive Vorlage konnte nicht gesetzt werden.");
} finally {
setSaving(false);
}
}
async function saveSelectedStyle(styleId: string) {
setSelectedStyle(styleId);
setSaving(true);
try {
await patchSettings({
[SETTING_KEYS.EMAIL_STYLE_TEMPLATE_ID]: styleId
});
toast.success("E-Mail-Design gespeichert.");
} catch {
toast.error("E-Mail-Design konnte nicht gespeichert werden.");
} finally {
setSaving(false);
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-20 text-slate-500 font-bold">
Lade Vorlagen...
</div>
);
}
const audienceTone = TYPE_AUDIENCE[activeType].tone;
const audienceBadgeColor =
audienceTone === "customer"
? "bg-cyan-50 text-cyan-700 ring-cyan-200"
: audienceTone === "owner"
? "bg-amber-50 text-amber-700 ring-amber-200"
: "bg-slate-100 text-slate-600 ring-slate-200";
return (
<div className="max-w-6xl mx-auto space-y-6">
<div className="mb-6 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h1 className="text-3xl font-black tracking-tight text-slate-950">E-Mail Templates</h1>
<p className="mt-1 text-sm font-medium text-slate-500">
Wähle einen Event-Typ, dann bearbeite die zugehörige Vorlage
</p>
</div>
<button
type="button"
onClick={() => startCreate(activeType)}
className="h-10 inline-flex items-center gap-2 rounded-xl bg-slate-950 px-4 text-sm font-bold text-white shadow-sm transition hover:bg-slate-800"
disabled={saving}
>
<Plus className="h-4 w-4" />
Neue Vorlage
</button>
</div>
<div className="grid gap-0 rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden lg:grid-cols-[260px_1fr]">
{/* Left: Event type sidebar */}
<aside className="border-r border-slate-100 bg-slate-50/50 p-3">
<p className="mb-3 px-3 text-[10px] font-black uppercase tracking-[0.22em] text-slate-400">
Event-Typen
</p>
<nav className="flex flex-col gap-1">
{TYPE_GROUPS.map((group) => (
<div key={group.label}>
<p className="mb-1 mt-3 px-3 text-[9px] font-bold uppercase tracking-widest text-slate-400 first:mt-0">
{group.label}
</p>
{group.types.map((type) => {
const active = type === activeType && !isEditing;
const audience = TYPE_AUDIENCE[type];
const variantCount = templates.filter((t) => t.type === type).length;
return (
<button
key={type}
type="button"
onClick={() => selectTemplateType(type)}
className={cn(
"flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left transition-all",
active
? "bg-slate-900 text-white shadow-sm"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900"
)}
>
<span className={cn("shrink-0", active ? "text-white" : "text-slate-400")}>
{TYPE_ICONS[type]}
</span>
<span className="flex-1 truncate text-xs font-bold leading-tight">
{TYPE_SHORT_LABELS[type]}
</span>
<span
className={cn(
"flex h-5 min-w-[20px] items-center justify-center rounded-full px-1.5 text-[10px] font-black",
active
? "bg-white/15 text-white"
: "bg-slate-100 text-slate-500"
)}
>
{variantCount}
</span>
</button>
);
})}
</div>
))}
</nav>
</aside>
{/* Right: Main content */}
<div className="flex flex-col min-h-[500px]">
{/* Header bar */}
<div className="flex items-center gap-3 border-b border-slate-100 px-5 py-3">
<div className="flex items-center gap-2">
<h2 className="text-base font-black text-slate-900">{TYPE_LABELS[activeType]}</h2>
<span
className={cn(
"inline-flex rounded-full px-2 py-0.5 text-[10px] font-black uppercase tracking-widest ring-1",
audienceBadgeColor
)}
>
{TYPE_AUDIENCE[activeType].label}
</span>
</div>
<div className="ml-auto flex items-center gap-2">
<span className="text-xs font-medium text-slate-400">
{activeTypeTemplates.length} {activeTypeTemplates.length === 1 ? "Variante" : "Varianten"}
</span>
</div>
</div>
{/* Template list */}
<div className="flex-1 overflow-auto p-4">
{activeTypeTemplates.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-slate-400">
<Mail className="mb-3 h-10 w-10 opacity-20" />
<p className="text-sm font-bold">Keine Vorlagen vorhanden</p>
<button
type="button"
onClick={() => startCreate(activeType)}
className="mt-3 inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-4 py-2 text-xs font-bold text-slate-600 transition hover:border-indigo-300 hover:text-indigo-600"
>
<Plus className="h-3.5 w-3.5" />
Erste Vorlage erstellen
</button>
</div>
) : (
<div className="grid gap-2">
{activeTypeTemplates.map((template) => {
const isActive = activeTemplateId === template.id && !isEditing;
return (
<div
key={template.id}
onClick={() => {
if (!isEditing) setActiveTemplateId(template.id);
}}
className={cn(
"group rounded-xl border p-4 transition-all cursor-pointer",
isActive
? "border-indigo-300 bg-indigo-50/60 shadow-sm"
: "border-slate-100 bg-white hover:border-slate-200 hover:shadow-sm"
)}
>
<div className="flex items-start gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3
className={cn(
"text-sm font-black truncate",
isActive ? "text-indigo-950" : "text-slate-900"
)}
>
{template.name}
</h3>
{template.isDefault ? (
<span className="shrink-0 inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-black uppercase tracking-wider text-emerald-700">
<CheckCircle2 className="h-3 w-3" />
Aktiv
</span>
) : null}
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider",
template.isSystem
? "bg-slate-100 text-slate-500"
: "bg-indigo-50 text-indigo-600"
)}
>
{template.isSystem ? "Standard" : "Custom"}
</span>
</div>
<p className="text-xs font-medium text-slate-500 line-clamp-1">
Betreff: {template.subject || "(leer)"}
</p>
<p className="mt-1 text-xs text-slate-400 line-clamp-1 font-mono">
{template.body
?.replace(/\n/g, " ")
.substring(0, 120) || "(leer)"}
</p>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
startEdit(template);
}}
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-600 transition hover:border-indigo-300 hover:text-indigo-700 hover:bg-indigo-50"
>
<Pencil className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Bearbeiten</span>
</button>
{!template.isDefault ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void setTemplateAsDefault(template.id);
}}
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-600 transition hover:border-emerald-300 hover:text-emerald-700 hover:bg-emerald-50"
>
<CheckCircle2 className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Aktiv</span>
</button>
) : null}
{!template.isSystem ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void deleteTemplate(template.id);
}}
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-600 transition hover:border-red-300 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Löschen</span>
</button>
) : null}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Editor Panel (shown when editing) */}
{isEditing && (
<div className="border-t-2 border-indigo-200 bg-slate-50/50 p-5 animate-in fade-in slide-in-from-bottom-2 duration-200">
<div className="mb-4 flex items-center justify-between">
<div>
<p className="text-xs font-black uppercase tracking-widest text-indigo-600">
Editor
</p>
<h3 className="text-lg font-black text-slate-900">
{editor.id ? "Vorlage bearbeiten" : "Neue Vorlage erstellen"}
</h3>
</div>
<button
type="button"
onClick={() => setIsEditing(false)}
className="inline-flex h-8 items-center gap-1 rounded-lg border border-slate-200 bg-white px-3 text-xs font-bold text-slate-500 transition hover:text-slate-700"
>
<X className="h-3.5 w-3.5" />
Schließen
</button>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[1fr_180px]">
<div>
<label className="mb-1.5 block text-xs font-bold uppercase tracking-wider text-slate-500">
Interner Name
</label>
<input
value={editor.name}
onChange={(event) =>
setEditor((prev) => ({ ...prev, name: event.target.value }))
}
className="h-10 w-full rounded-lg border border-slate-200 bg-white px-3 text-sm font-bold text-slate-900 transition-all focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-100"
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-bold uppercase tracking-wider text-slate-500">
Event-Typ
</label>
<select
value={editor.type}
onChange={(event) =>
setEditor((prev) => ({
...prev,
type: event.target.value as TemplateType
}))
}
className="h-10 w-full rounded-lg border border-slate-200 bg-white px-3 text-sm font-bold text-slate-900 transition-all focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-100"
>
{TYPE_ORDER.map((type) => (
<option key={type} value={type}>
{TYPE_LABELS[type]}
</option>
))}
</select>
</div>
</div>
<div className="mt-4">
<label className="mb-1.5 block text-xs font-bold uppercase tracking-wider text-slate-500">
E-Mail Betreff
</label>
<input
value={editor.subject}
onChange={(event) =>
setEditor((prev) => ({ ...prev, subject: event.target.value }))
}
placeholder='z.B. Dein Termin am {{date}} um {{time}}'
className="h-10 w-full rounded-lg border border-slate-200 bg-white px-3 text-sm font-medium text-slate-900 transition-all placeholder:font-normal placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-100"
/>
</div>
<div className="mt-4">
<div className="mb-1.5 flex items-center justify-between">
<label className="text-xs font-bold uppercase tracking-wider text-slate-500">
E-Mail Inhalt
</label>
</div>
<textarea
value={editor.body}
onChange={(event) =>
setEditor((prev) => ({ ...prev, body: event.target.value }))
}
className="min-h-[180px] w-full resize-y rounded-lg border border-slate-200 bg-white p-4 font-mono text-sm leading-relaxed text-slate-900 transition-all focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-100"
/>
<details className="mt-3 group">
<summary className="cursor-pointer text-xs font-bold text-slate-500 hover:text-slate-700 select-none">
Verfügbare Platzhalter für diesen Event-Typ
</summary>
<div className="mt-2 grid grid-cols-2 gap-1 text-[11px]">
{(() => {
const common = ["{{customerName}}", "{{date}}", "{{time}}", "{{duration}}", "{{companyName}}", "{{meetingButton}}", "{{cancelButton}}", "{{meetingUrl}}", "{{cancelUrl}}", "{{rescheduleUrl}}"];
const staff = ["{{staffName}}", "{{staffNames}}", "{{phone}}", "{{email}}", "{{notes}}", "{{dashboardUrl}}"];
const reminder = ["{{reminderLabel}}", "{{hoursBefore}}", "{{reminderKind}}"];
const instant = ["{{recipientName}}", "{{recipientEmail}}", "{{scopeLabel}}", "{{initiatorName}}", "{{customMessage}}"];
const test = ["{{timestamp}}"];
let vars: string[] = common;
const t = editor.type;
if (t === "confirmation") vars = [...common, ...staff];
else if (t === "staff") vars = [...common, ...staff];
else if (t.startsWith("cancellation")) vars = [...common, ...staff];
else if (t.startsWith("reminder")) vars = [...common, ...staff, ...reminder];
else if (t === "instantMeeting") vars = [...common, ...instant];
else if (t === "smtpTest") vars = [...common, ...test];
return vars.map((v) => (
<button key={v} type="button"
onClick={() => {
const el = document.createElement("textarea");
el.value = v;
el.style.position = "fixed";
el.style.opacity = "0";
document.body.appendChild(el);
el.select();
document.execCommand("copy");
document.body.removeChild(el);
toast.success(`${v} kopiert`);
}}
className="text-left rounded-md px-2 py-0.5 font-mono text-slate-600 hover:bg-indigo-50 hover:text-indigo-700 transition cursor-pointer">
{v}
</button>
));
})()}
</div>
</details>
</div>
<div className="mt-4 flex justify-end gap-3">
<button
type="button"
onClick={() => setIsEditing(false)}
className="rounded-lg bg-slate-100 px-5 py-2.5 text-sm font-bold text-slate-600 transition-colors hover:bg-slate-200"
disabled={saving}
>
Abbrechen
</button>
<button
type="button"
onClick={() => {
void saveEditor();
}}
disabled={saving}
className="flex items-center gap-2 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-bold text-white shadow-sm transition-colors hover:bg-indigo-700"
>
<Save className="h-4 w-4" />
Speichern
</button>
</div>
</div>
)}
{/* Preview Panel */}
{!isEditing && activeTemplate && (
<div className="border-t border-slate-100 bg-slate-50/50 p-5">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Eye className="h-4 w-4 text-slate-400" />
<p className="text-xs font-black uppercase tracking-widest text-slate-400">
Live Vorschau
</p>
<span className="rounded-full bg-slate-200 px-2 py-0.5 text-[10px] font-bold text-slate-500">
{selectedStyle}
</span>
</div>
<button
type="button"
onClick={() => startEdit(activeTemplate)}
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 text-xs font-bold text-slate-600 transition hover:border-indigo-300 hover:text-indigo-700"
>
<Pencil className="h-3.5 w-3.5" />
Bearbeiten
</button>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-5">
{renderPreview(
activeTemplate.subject,
activeTemplate.body,
selectedStyle,
previewCompanyName
)}
</div>
</div>
)}
</div>
</div>
{/* Design Styles Section */}
<div className="mt-6 rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
<button
type="button"
onClick={() => setDesignSectionOpen(!designSectionOpen)}
className="flex w-full items-center justify-between px-5 py-4 text-left transition hover:bg-slate-50"
>
<div className="flex items-center gap-3">
<Sparkles className="h-4 w-4 text-slate-400" />
<div>
<p className="text-sm font-black text-slate-900">E-Mail Design-Styles</p>
<p className="text-xs font-medium text-slate-500">
Globaler Stil für alle Templates · Aktiv: {EMAIL_STYLE_PRESETS.find(s => s.id === selectedStyle)?.name ?? selectedStyle}
</p>
</div>
</div>
<ChevronDown
className={cn(
"h-5 w-5 text-slate-400 transition-transform",
designSectionOpen && "rotate-180"
)}
/>
</button>
{designSectionOpen && (
<div className="border-t border-slate-100 p-5 animate-in fade-in slide-in-from-top-2 duration-200">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{EMAIL_STYLE_PRESETS.map((style) => {
const active = selectedStyle === style.id;
return (
<button
key={style.id}
type="button"
onClick={() => {
void saveSelectedStyle(style.id);
}}
className={cn(
"rounded-xl border p-3 text-left transition-all",
active
? "border-indigo-400 bg-indigo-50/60 ring-2 ring-indigo-100"
: "border-slate-200 bg-white hover:border-indigo-200 hover:bg-slate-50"
)}
>
<div className="mb-2 flex items-center justify-between">
<p className="text-xs font-bold text-slate-900">{style.name}</p>
{active ? (
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-indigo-600">
<CheckCircle2 className="h-3 w-3 text-white" />
</span>
) : null}
</div>
<div
className="h-10 rounded-lg border"
style={{ backgroundColor: style.canvasColor, borderColor: style.borderColor }}
>
<div
className="h-4 rounded-t-lg"
style={{ backgroundColor: style.headerColor }}
/>
</div>
</button>
);
})}
</div>
</div>
)}
</div>
</div>
);
}