1669 lines
63 KiB
TypeScript
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>
|
|
);
|
|
}
|