mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +00:00
added marketing-building-blog-v1
This commit is contained in:
parent
a9f248c91d
commit
dc4f08ab0c
71 changed files with 6307 additions and 183 deletions
423
src/modules/marketing/ui/components/NodeSettingsSheet.tsx
Normal file
423
src/modules/marketing/ui/components/NodeSettingsSheet.tsx
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
"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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue