added marketing-building-blog-v1

This commit is contained in:
Rajpreet0 2026-10-02 17:31:08 +02:00
parent a9f248c91d
commit dc4f08ab0c
71 changed files with 6307 additions and 183 deletions

View 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;