mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 19:51:17 +00:00
423 lines
15 KiB
TypeScript
423 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { useRef, useTransition } from "react";
|
|
import { Send, Trash2 } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { Button } from "@/src/components/ui/button";
|
|
import { Input } from "@/src/components/ui/input";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/src/components/ui/select";
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetDescription,
|
|
SheetFooter,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from "@/src/components/ui/sheet";
|
|
import { Switch } from "@/src/components/ui/switch";
|
|
import { cn } from "@/src/lib/utils";
|
|
import { sendMarketingTestEmail } from "../../actions/editor-actions";
|
|
import {
|
|
EMAIL_TEMPLATES,
|
|
MILESTONE_LIMITS,
|
|
PLACEHOLDERS,
|
|
REWARD_APPLIES_TO_LABELS,
|
|
TRIGGER_DESCRIPTIONS,
|
|
TRIGGER_KINDS,
|
|
TRIGGER_LABELS,
|
|
WINBACK_LIMITS,
|
|
emailConfigSchema,
|
|
type Reward,
|
|
} from "../../lib/node-config";
|
|
import {
|
|
isTriggerNode,
|
|
type EmailFlowNode,
|
|
type FlowNode,
|
|
type TriggerFlowNode,
|
|
} from "./flow/flow-model";
|
|
|
|
interface NodeSettingsSheetProps {
|
|
node: FlowNode | null;
|
|
businessId: string;
|
|
emailOptions: { id: string; name: string }[];
|
|
/** Nur Trigger: die verbundene E-Mail */
|
|
connectedEmailId: string | null;
|
|
onClose: () => void;
|
|
onConfigChange: (nodeId: string, config: Record<string, unknown>) => void;
|
|
onActiveChange: (nodeId: string, isActive: boolean) => void;
|
|
onConnect: (triggerId: string, emailId: string | null) => void;
|
|
onDelete: (nodeId: string) => void;
|
|
}
|
|
|
|
const fieldClass = "bg-white md:text-sm";
|
|
|
|
/** Einstellungen des angeklickten Bausteins, rechts als Panel. */
|
|
const NodeSettingsSheet = (props: NodeSettingsSheetProps) => {
|
|
const { node, onClose } = props;
|
|
return (
|
|
<Sheet open={node !== null} onOpenChange={(open) => !open && onClose()}>
|
|
<SheetContent className="w-full overflow-y-auto sm:max-w-md">
|
|
{node && isTriggerNode(node) && <TriggerSettings {...props} node={node} />}
|
|
{node && !isTriggerNode(node) && <EmailSettings {...props} node={node} />}
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
};
|
|
|
|
// ─── Trigger ─────────────────────────────────────────────────────────────────
|
|
|
|
const NONE = "__none__";
|
|
|
|
const TriggerSettings = ({
|
|
node,
|
|
emailOptions,
|
|
connectedEmailId,
|
|
onConfigChange,
|
|
onActiveChange,
|
|
onConnect,
|
|
onDelete,
|
|
}: NodeSettingsSheetProps & { node: TriggerFlowNode }) => {
|
|
const { kind, config, isActive, sent, activatedAt } = node.data;
|
|
|
|
const numberField = (key: "bookings" | "days", limits: { min: number; max: number }) => {
|
|
const value = typeof config[key] === "number" ? (config[key] as number) : "";
|
|
const invalid = typeof value !== "number" || value < limits.min || value > limits.max;
|
|
return (
|
|
<Input
|
|
type="number"
|
|
min={limits.min}
|
|
max={limits.max}
|
|
value={value}
|
|
aria-invalid={invalid}
|
|
onChange={(e) =>
|
|
onConfigChange(node.id, {
|
|
...config,
|
|
[key]: e.target.value === "" ? "" : Math.round(Number(e.target.value)),
|
|
})
|
|
}
|
|
className={cn(fieldClass, "w-24")}
|
|
/>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<SheetHeader>
|
|
<SheetTitle>{TRIGGER_LABELS[kind]}</SheetTitle>
|
|
<SheetDescription>{TRIGGER_DESCRIPTIONS[kind]}</SheetDescription>
|
|
</SheetHeader>
|
|
|
|
<div className="flex flex-col gap-5 px-4">
|
|
{kind === "milestone" && (
|
|
<label className="flex flex-wrap items-center gap-2 text-sm">
|
|
Nach dem {numberField("bookings", MILESTONE_LIMITS)}. besuchten Kurs
|
|
</label>
|
|
)}
|
|
{kind === "winback" && (
|
|
<label className="flex flex-wrap items-center gap-2 text-sm">
|
|
Seit {numberField("days", WINBACK_LIMITS)} Tagen kein Kurs und nichts gebucht
|
|
</label>
|
|
)}
|
|
{kind === "birthday" && (
|
|
<p className="text-sm text-gray-600">
|
|
Wer am 29. Februar Geburtstag hat, bekommt die Mail in anderen Jahren am 28. Februar.
|
|
</p>
|
|
)}
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
<span className="text-xs font-medium text-gray-600">Führt zu</span>
|
|
<Select
|
|
value={connectedEmailId ?? NONE}
|
|
onValueChange={(value) => onConnect(node.id, value === NONE ? null : value)}
|
|
>
|
|
<SelectTrigger className="w-full bg-white">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={NONE}>Nicht verbunden</SelectItem>
|
|
{emailOptions.map((email) => (
|
|
<SelectItem key={email.id} value={email.id}>
|
|
E-Mail „{email.name}“
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between gap-4 rounded-lg border p-3">
|
|
<div>
|
|
<p className="text-sm font-medium">Eingeschaltet</p>
|
|
<p className="text-xs text-gray-500">
|
|
{activatedAt
|
|
? `Läuft seit ${new Date(activatedAt).toLocaleDateString("de-DE")}`
|
|
: "Wirkt nach dem Speichern"}
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
checked={isActive}
|
|
onCheckedChange={(checked) => onActiveChange(node.id, checked)}
|
|
/>
|
|
</div>
|
|
|
|
<p className="rounded-lg bg-gray-50 p-3 text-xs leading-relaxed text-gray-600">
|
|
Gezählt wird erst ab dem Einschalten. Wer vorher schon so weit war, bekommt keine Mail -
|
|
so werden beim Einschalten nicht alle Bestandskunden auf einmal angeschrieben. Mails gehen
|
|
nur an Kunden, die zugestimmt haben, und höchstens eine pro Tag.
|
|
</p>
|
|
|
|
<p className="text-sm text-gray-600">
|
|
{sent} {sent === 1 ? "Mail" : "Mails"} über diesen Trigger verschickt
|
|
</p>
|
|
</div>
|
|
|
|
<SheetFooter>
|
|
<Button variant="outline" className="text-red-600" onClick={() => onDelete(node.id)}>
|
|
<Trash2 className="size-4" /> Trigger löschen
|
|
</Button>
|
|
</SheetFooter>
|
|
</>
|
|
);
|
|
};
|
|
|
|
// ─── E-Mail ──────────────────────────────────────────────────────────────────
|
|
|
|
const DEFAULT_REWARD: Reward = { type: "percent", value: 10, validDays: 30, appliesTo: "all" };
|
|
|
|
const EmailSettings = ({
|
|
node,
|
|
businessId,
|
|
onConfigChange,
|
|
onDelete,
|
|
}: NodeSettingsSheetProps & { node: EmailFlowNode }) => {
|
|
const config = node.data.config;
|
|
const reward = (config.reward ?? null) as Reward | null;
|
|
const bodyRef = useRef<HTMLTextAreaElement>(null);
|
|
const [isSending, startSending] = useTransition();
|
|
|
|
const parsed = emailConfigSchema.safeParse(config);
|
|
const errors = new Map<string, string>();
|
|
if (!parsed.success) {
|
|
for (const issue of parsed.error.issues) {
|
|
const key = issue.path.join(".");
|
|
if (!errors.has(key)) errors.set(key, issue.message);
|
|
}
|
|
}
|
|
|
|
const set = (patch: Record<string, unknown>) => onConfigChange(node.id, { ...config, ...patch });
|
|
const setReward = (patch: Partial<Reward>) =>
|
|
set({ reward: { ...(reward ?? DEFAULT_REWARD), ...patch } });
|
|
|
|
const insertPlaceholder = (key: string) => {
|
|
const body = typeof config.body === "string" ? config.body : "";
|
|
const textarea = bodyRef.current;
|
|
const start = textarea?.selectionStart ?? body.length;
|
|
const end = textarea?.selectionEnd ?? body.length;
|
|
set({ body: body.slice(0, start) + key + body.slice(end) });
|
|
requestAnimationFrame(() => {
|
|
textarea?.focus();
|
|
textarea?.setSelectionRange(start + key.length, start + key.length);
|
|
});
|
|
};
|
|
|
|
const sendTest = () => {
|
|
startSending(async () => {
|
|
const result = await sendMarketingTestEmail(businessId, config);
|
|
if (result.success) toast.success(`Testmail an ${result.to} verschickt.`);
|
|
else toast.error(result.error);
|
|
});
|
|
};
|
|
|
|
const error = (key: string) =>
|
|
errors.get(key) ? <p className="text-xs text-red-600">{errors.get(key)}</p> : null;
|
|
|
|
return (
|
|
<>
|
|
<SheetHeader>
|
|
<SheetTitle>E-Mail</SheetTitle>
|
|
<SheetDescription>
|
|
Absender ist dein Studio, Antworten landen bei deiner Studio-E-Mail.
|
|
</SheetDescription>
|
|
</SheetHeader>
|
|
|
|
<div className="flex flex-col gap-4 px-4">
|
|
<div className="flex flex-col gap-1.5">
|
|
<span className="text-xs font-medium text-gray-600">Vorlage einsetzen</span>
|
|
<Select
|
|
value=""
|
|
onValueChange={(kind) =>
|
|
set({ ...EMAIL_TEMPLATES[kind as keyof typeof EMAIL_TEMPLATES] })
|
|
}
|
|
>
|
|
<SelectTrigger className="w-full bg-white">
|
|
<SelectValue placeholder="Vorlage wählen …" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{TRIGGER_KINDS.map((kind) => (
|
|
<SelectItem key={kind} value={kind}>
|
|
{TRIGGER_LABELS[kind]}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<label className="flex flex-col gap-1.5">
|
|
<span className="text-xs font-medium text-gray-600">Name (nur für dich)</span>
|
|
<Input
|
|
value={typeof config.name === "string" ? config.name : ""}
|
|
onChange={(e) => set({ name: e.target.value })}
|
|
aria-invalid={errors.has("name")}
|
|
className={fieldClass}
|
|
/>
|
|
{error("name")}
|
|
</label>
|
|
|
|
<label className="flex flex-col gap-1.5">
|
|
<span className="text-xs font-medium text-gray-600">Betreff</span>
|
|
<Input
|
|
value={typeof config.subject === "string" ? config.subject : ""}
|
|
onChange={(e) => set({ subject: e.target.value })}
|
|
aria-invalid={errors.has("subject")}
|
|
className={fieldClass}
|
|
/>
|
|
{error("subject")}
|
|
</label>
|
|
|
|
<label className="flex flex-col gap-1.5">
|
|
<span className="text-xs font-medium text-gray-600">Text</span>
|
|
<textarea
|
|
ref={bodyRef}
|
|
rows={9}
|
|
value={typeof config.body === "string" ? config.body : ""}
|
|
onChange={(e) => set({ body: e.target.value })}
|
|
aria-invalid={errors.has("body")}
|
|
className="w-full rounded-md border border-input bg-white px-3 py-2 text-sm shadow-xs outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive"
|
|
/>
|
|
{error("body")}
|
|
</label>
|
|
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{PLACEHOLDERS.map((placeholder) => (
|
|
<button
|
|
key={placeholder.key}
|
|
type="button"
|
|
title={placeholder.description}
|
|
onClick={() => insertPlaceholder(placeholder.key)}
|
|
className="rounded-full border bg-white px-2 py-0.5 font-mono text-[11px] text-gray-600 hover:bg-gray-50"
|
|
>
|
|
{placeholder.key}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-3 rounded-lg border p-3">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div>
|
|
<p className="text-sm font-medium">Persönlicher Rabattcode</p>
|
|
<p className="text-xs text-gray-500">
|
|
Jeder Kunde bekommt einen eigenen Code, einmal einlösbar.
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
checked={reward !== null}
|
|
onCheckedChange={(checked) => set({ reward: checked ? DEFAULT_REWARD : null })}
|
|
/>
|
|
</div>
|
|
|
|
{reward && (
|
|
<>
|
|
<div className="flex items-end gap-2">
|
|
<label className="flex flex-1 flex-col gap-1.5">
|
|
<span className="text-xs font-medium text-gray-600">Rabatt</span>
|
|
<Input
|
|
type="number"
|
|
min={reward.type === "percent" ? 1 : 0.01}
|
|
step={reward.type === "percent" ? 1 : 0.01}
|
|
value={reward.type === "percent" ? reward.value : reward.value / 100}
|
|
onChange={(e) => {
|
|
const raw = Number(e.target.value);
|
|
setReward({
|
|
value: reward.type === "percent" ? Math.round(raw) : Math.round(raw * 100),
|
|
});
|
|
}}
|
|
aria-invalid={errors.has("reward.value")}
|
|
className={fieldClass}
|
|
/>
|
|
</label>
|
|
<div className="flex gap-1 rounded-lg border bg-white p-1">
|
|
{(["percent", "fixed"] as const).map((type) => (
|
|
<Button
|
|
key={type}
|
|
type="button"
|
|
size="sm"
|
|
variant={reward.type === type ? "default" : "ghost"}
|
|
onClick={() => setReward({ type, value: type === "percent" ? 10 : 500 })}
|
|
>
|
|
{type === "percent" ? "%" : "€"}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{error("reward.value")}
|
|
|
|
<label className="flex flex-wrap items-center gap-2 text-sm">
|
|
Gültig für
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
max={365}
|
|
value={reward.validDays}
|
|
onChange={(e) => setReward({ validDays: Math.round(Number(e.target.value)) })}
|
|
aria-invalid={errors.has("reward.validDays")}
|
|
className={cn(fieldClass, "w-20")}
|
|
/>
|
|
Tage ab Versand
|
|
</label>
|
|
{error("reward.validDays")}
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
<span className="text-xs font-medium text-gray-600">Gilt für</span>
|
|
<div className="flex gap-1 rounded-lg border bg-white p-1">
|
|
{(Object.keys(REWARD_APPLIES_TO_LABELS) as Reward["appliesTo"][]).map((value) => (
|
|
<Button
|
|
key={value}
|
|
type="button"
|
|
size="sm"
|
|
className="flex-1"
|
|
variant={reward.appliesTo === value ? "default" : "ghost"}
|
|
onClick={() => setReward({ appliesTo: value })}
|
|
>
|
|
{REWARD_APPLIES_TO_LABELS[value]}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<p className="text-sm text-gray-600">
|
|
{node.data.sent} verschickt
|
|
{reward ? ` · ${node.data.redeemed} Codes eingelöst` : ""}
|
|
</p>
|
|
</div>
|
|
|
|
<SheetFooter>
|
|
<Button variant="outline" onClick={sendTest} disabled={isSending || !parsed.success}>
|
|
<Send className="size-4" /> {isSending ? "Wird verschickt…" : "Testmail an mich"}
|
|
</Button>
|
|
<Button variant="outline" className="text-red-600" onClick={() => onDelete(node.id)}>
|
|
<Trash2 className="size-4" /> E-Mail löschen
|
|
</Button>
|
|
</SheetFooter>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default NodeSettingsSheet;
|