"use client";
import * as React from "react";
import { AnimatePresence, motion, MotionConfig, useReducedMotion } from "motion/react";
import { ChevronLeft, Code2, LayoutTemplate, Monitor, Redo2, Send, Smartphone, Undo2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { BLOCK_ICONS, EmailBlockView } from "./blocks";
import { EmailCanvas, BlockPalette, type DragData } from "./canvas";
import { CampaignList } from "./campaigns";
import { BLOCK_META, DEFAULT_CAMPAIGNS, DEFAULT_TEMPLATES, blankDoc, cloneDoc, createBlock, uid } from "./data";
import { ExportDialog, SendTestDialog, TemplateGallery } from "./dialogs";
import { exportHtml } from "./export-html";
import { Inspector } from "./inspector";
import type { Block, BlockType, Campaign, EmailDoc, Template } from "./types";
import { Button, IconButton, Segmented, Toasts, useToasts } from "./ui";
import { useSortableDrag } from "./use-sortable-drag";
export type { Block, BlockType, Campaign, EmailDoc, Template } from "./types";
export { exportHtml } from "./export-html";
export interface EmailBuilderAppProps {
/** Campaigns shown in the list. Defaults to seeded demo campaigns. */
initialCampaigns?: Campaign[];
/** Starting templates for the gallery. */
templates?: Template[];
/** Open this campaign in the editor on mount (defaults to the first draft). Pass null to start on the list. */
initialCampaignId?: string | null;
/** Brand name shown in the header. */
brand?: string;
/** Default address for test sends. */
testRecipient?: string;
/** Fires (debounced) whenever a campaign's email changes. */
onSave?: (campaign: Campaign) => void;
/** Fires with the generated HTML whenever the export dialog opens. */
onExport?: (html: string, campaign: Campaign) => void;
/** Handle a test send. Awaited before the success toast. */
onSendTest?: (to: string[], subject: string, html: string) => Promise<void> | void;
className?: string;
}
/* ------------------------------- history ------------------------------- */
type History = { past: EmailDoc[]; present: EmailDoc; future: EmailDoc[]; key: string | null; at: number };
function useHistory(initial: EmailDoc) {
const [h, setH] = React.useState<History>({ past: [], present: initial, future: [], key: null, at: 0 });
/** Apply a change; consecutive changes with the same `key` within 900ms merge into one undo step. */
const apply = React.useCallback((fn: (d: EmailDoc) => EmailDoc, key: string | null = null) => {
setH((s) => {
const next = fn(s.present);
if (next === s.present) return s;
const now = performance.now();
const merge = key !== null && key === s.key && now - s.at < 900;
return { past: merge ? s.past : [...s.past.slice(-79), s.present], present: next, future: [], key, at: now };
});
}, []);
const undo = React.useCallback(
() => setH((s) => (s.past.length ? { past: s.past.slice(0, -1), present: s.past[s.past.length - 1], future: [s.present, ...s.future], key: null, at: 0 } : s)),
[],
);
const redo = React.useCallback(
() => setH((s) => (s.future.length ? { past: [...s.past, s.present], present: s.future[0], future: s.future.slice(1), key: null, at: 0 } : s)),
[],
);
const reset = React.useCallback((doc: EmailDoc) => setH({ past: [], present: doc, future: [], key: null, at: 0 }), []);
return { doc: h.present, canUndo: h.past.length > 0, canRedo: h.future.length > 0, apply, undo, redo, reset };
}
/* --------------------------------- app --------------------------------- */
export function EmailBuilderApp(props: EmailBuilderAppProps) {
const rootRef = React.useRef<HTMLDivElement>(null);
return (
<MotionConfig reducedMotion="user">
<div
ref={rootRef}
className={cn("relative isolate flex h-[760px] w-full flex-col overflow-hidden bg-background text-foreground antialiased", props.className)}
>
<Builder {...props} rootRef={rootRef} />
</div>
</MotionConfig>
);
}
type MobilePanel = "blocks" | "canvas" | "style";
function Builder({
initialCampaigns = DEFAULT_CAMPAIGNS,
templates = DEFAULT_TEMPLATES,
initialCampaignId,
brand = "Lumen",
testRecipient = "[email protected]",
onSave,
onExport,
onSendTest,
rootRef,
}: EmailBuilderAppProps & { rootRef: React.RefObject<HTMLDivElement | null> }) {
const startId = initialCampaignId === undefined ? (initialCampaigns.find((c) => c.status === "draft")?.id ?? null) : initialCampaignId;
const [campaigns, setCampaigns] = React.useState<Campaign[]>(initialCampaigns);
const [activeId, setActiveId] = React.useState<string | null>(startId);
const active = campaigns.find((c) => c.id === activeId) ?? null;
const history = useHistory(active?.doc ?? blankDoc());
const { doc, apply } = history;
const [selectedId, setSelectedId] = React.useState<string | null>(null);
const [device, setDevice] = React.useState<"desktop" | "mobile">("desktop");
const [panel, setPanel] = React.useState<MobilePanel>("canvas");
const [dialog, setDialog] = React.useState<null | "templates-new" | "templates" | "export" | "send">(null);
const [saved, setSaved] = React.useState<"saved" | "saving">("saved");
const [narrow, setNarrow] = React.useState(false);
const { toasts, push, dismiss } = useToasts();
const reduced = useReducedMotion() ?? false;
const canvasWrap = React.useRef<HTMLDivElement>(null);
const selected = doc.blocks.find((b) => b.id === selectedId) ?? null;
const mobile = device === "mobile" || narrow;
// Treat narrow canvases as mobile previews (stacks columns etc).
React.useEffect(() => {
const el = canvasWrap.current;
if (!el) return;
const ro = new ResizeObserver(([e]) => setNarrow(e.contentRect.width < 560));
ro.observe(el);
return () => ro.disconnect();
}, [activeId]);
// Write the working doc back into its campaign; notify the host after a debounce.
const onSaveRef = React.useRef(onSave);
React.useLayoutEffect(() => {
onSaveRef.current = onSave;
});
const firstDoc = React.useRef(true);
React.useEffect(() => {
if (!activeId) return;
if (firstDoc.current) {
firstDoc.current = false;
return;
}
setCampaigns((cs) => cs.map((c) => (c.id === activeId ? { ...c, doc, date: c.status === "draft" ? c.date : c.date } : c)));
setSaved("saving");
const t = window.setTimeout(() => {
setSaved("saved");
const c = campaigns.find((x) => x.id === activeId);
if (c) onSaveRef.current?.({ ...c, doc });
}, 700);
return () => window.clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps -- only react to doc edits
}, [doc]);
const openCampaign = (id: string) => {
const c = campaigns.find((x) => x.id === id);
if (!c) return;
firstDoc.current = true;
setActiveId(id);
history.reset(c.doc);
setSelectedId(null);
setPanel("canvas");
};
/* ------------------------------ mutations ------------------------------ */
const insertBlock = React.useCallback(
(type: BlockType, index?: number) => {
const block = createBlock(type);
apply((d) => {
const blocks = [...d.blocks];
const selIdx = selectedId ? blocks.findIndex((b) => b.id === selectedId) : -1;
const at = index ?? (selIdx >= 0 ? selIdx + 1 : blocks.length);
blocks.splice(at, 0, block);
return { ...d, blocks };
});
setSelectedId(block.id);
return block.id;
},
[apply, selectedId],
);
const moveBlockTo = React.useCallback(
(id: string, index: number) =>
apply((d) => {
const from = d.blocks.findIndex((b) => b.id === id);
if (from < 0) return d;
const blocks = d.blocks.filter((b) => b.id !== id);
blocks.splice(Math.max(0, Math.min(index, blocks.length)), 0, d.blocks[from]);
return blocks.every((b, i) => b === d.blocks[i]) ? d : { ...d, blocks };
}),
[apply],
);
const moveBlock = (id: string, dir: -1 | 1) => {
const i = doc.blocks.findIndex((b) => b.id === id);
const to = i + dir;
if (i < 0 || to < 0 || to >= doc.blocks.length) return;
moveBlockTo(id, to);
focusBlock(id);
};
const duplicateBlock = (id: string) => {
const src = doc.blocks.find((b) => b.id === id);
if (!src) return;
const copy = { ...structuredClone(src), id: uid(src.type) } as Block;
apply((d) => {
const i = d.blocks.findIndex((b) => b.id === id);
const blocks = [...d.blocks];
blocks.splice(i + 1, 0, copy);
return { ...d, blocks };
});
setSelectedId(copy.id);
focusBlock(copy.id);
};
const deleteBlock = (id: string) => {
const i = doc.blocks.findIndex((b) => b.id === id);
const next = doc.blocks[i + 1] ?? doc.blocks[i - 1];
apply((d) => ({ ...d, blocks: d.blocks.filter((b) => b.id !== id) }));
setSelectedId(null);
if (next) focusBlock(next.id);
push("Block deleted", "Press ⌘Z to undo.");
};
const patchBlock = (patch: Partial<Block>, key: string) => {
if (!selectedId) return;
apply((d) => ({ ...d, blocks: d.blocks.map((b) => (b.id === selectedId ? ({ ...b, ...patch } as Block) : b)) }), key);
};
const focusBlock = (id: string) =>
requestAnimationFrame(() => rootRef.current?.querySelector<HTMLElement>(`[data-block-id="${CSS.escape(id)}"]`)?.focus({ preventScroll: false }));
/* --------------------------------- drag -------------------------------- */
const { drag, overlayX, overlayY, onPointerDown, registerList, consumeClick } = useSortableDrag<DragData>({
rootRef,
reducedMotion: reduced,
reach: 64,
onDrop: (d, target) => {
if (d.data.kind === "new") {
insertBlock(d.data.type, target.index);
} else {
moveBlockTo(d.data.id, target.index);
setSelectedId(d.data.id);
}
},
});
/* ------------------------------ keyboard ------------------------------- */
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const t = e.target as HTMLElement;
if (!rootRef.current?.contains(t) && t !== document.body) return;
if (rootRef.current?.querySelector("[role=dialog]")) return;
const typing = t.closest("input,textarea,select,[contenteditable]");
const mod = e.metaKey || e.ctrlKey;
if (mod && e.key.toLowerCase() === "z" && !typing) {
e.preventDefault();
if (e.shiftKey) history.redo();
else history.undo();
} else if (mod && e.key.toLowerCase() === "y" && !typing) {
e.preventDefault();
history.redo();
} else if (e.key === "Escape" && !typing && selectedId) {
setSelectedId(null);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [history, rootRef, selectedId]);
/* ------------------------------ templates ------------------------------ */
const pickTemplate = (t: Template | null) => {
const next = cloneDoc(t ? t.doc : blankDoc());
if (dialog === "templates-new") {
const c: Campaign = {
id: uid("c"),
name: t ? `${t.name} — untitled` : "Untitled campaign",
status: "draft",
audience: "All subscribers",
recipients: 18420,
date: "2026-09-24",
openRate: 0,
clickRate: 0,
unsubscribes: 0,
trend: [],
doc: next,
};
setCampaigns((cs) => [c, ...cs]);
firstDoc.current = true;
setActiveId(c.id);
history.reset(next);
push("Campaign created", t ? `Started from “${t.name}”.` : "Started from a blank email.");
} else {
apply(() => next);
push("Template applied", "Press ⌘Z to bring back your previous content.");
}
setSelectedId(null);
setDialog(null);
};
const html = React.useMemo(() => (dialog === "export" ? exportHtml(doc) : ""), [dialog, doc]);
const openExport = () => {
setDialog("export");
if (active) onExport?.(exportHtml(doc), { ...active, doc });
};
const draggedBlock = drag?.data.kind === "block" ? doc.blocks.find((b) => drag.data.kind === "block" && b.id === drag.data.id) : null;
/* -------------------------------- render ------------------------------- */
return (
<>
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-3 sm:gap-3 sm:px-4">
{active ? (
<IconButton label="Back to campaigns" onClick={() => setActiveId(null)}>
<ChevronLeft className="size-4" />
</IconButton>
) : null}
<span aria-hidden className="grid size-7 shrink-0 place-items-center rounded-lg bg-gradient-to-br from-indigo-500 to-fuchsia-500 text-white shadow-sm shadow-indigo-500/30">
<svg viewBox="0 0 16 16" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round">
<path d="M2.5 4.5h11v7h-11z" />
<path d="m2.5 4.8 5.5 4 5.5-4" />
</svg>
</span>
{active ? (
<div className="flex min-w-0 flex-1 items-center gap-2">
<label htmlFor="eb-name" className="sr-only">
Campaign name
</label>
<input
id="eb-name"
value={active.name}
onChange={(e) => setCampaigns((cs) => cs.map((c) => (c.id === active.id ? { ...c, name: e.target.value } : c)))}
className="h-8 min-w-0 max-w-72 flex-1 truncate rounded-md bg-transparent px-1.5 text-sm font-semibold outline-none transition hover:bg-muted focus-visible:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
/>
<span className="hidden shrink-0 items-center gap-1.5 text-[11px] text-muted-foreground md:flex" aria-live="polite">
<span className={cn("size-1.5 rounded-full", saved === "saved" ? "bg-emerald-500" : "animate-pulse bg-amber-500")} aria-hidden />
{saved === "saved" ? "Saved" : "Saving…"}
</span>
</div>
) : (
<p className="flex-1 text-sm font-semibold">
{brand} <span className="font-normal text-muted-foreground">Campaigns</span>
</p>
)}
{active && (
<div className="flex shrink-0 items-center gap-1 sm:gap-2">
<div className="flex items-center">
<IconButton label="Undo (⌘Z)" disabled={!history.canUndo} onClick={history.undo}>
<Undo2 className="size-4" />
</IconButton>
<IconButton label="Redo (⇧⌘Z)" disabled={!history.canRedo} onClick={history.redo}>
<Redo2 className="size-4" />
</IconButton>
</div>
<Segmented
label="Preview device"
value={device}
onChange={setDevice}
className="hidden w-[76px] md:flex"
options={[
{ value: "desktop", label: <Monitor className="size-3.5" />, title: "Desktop preview" },
{ value: "mobile", label: <Smartphone className="size-3.5" />, title: "Mobile preview" },
]}
/>
<Button onClick={() => setDialog("templates")} className="hidden lg:inline-flex">
<LayoutTemplate className="size-3.5" /> Templates
</Button>
<IconButton label="Templates" onClick={() => setDialog("templates")} className="lg:hidden">
<LayoutTemplate className="size-4" />
</IconButton>
<Button onClick={openExport} className="hidden sm:inline-flex">
<Code2 className="size-3.5" /> Export HTML
</Button>
<IconButton label="Export HTML" onClick={openExport} className="sm:hidden">
<Code2 className="size-4" />
</IconButton>
<Button variant="primary" onClick={() => setDialog("send")} className="px-2.5 sm:px-3">
<Send className="size-3.5" />
<span className="hidden sm:inline">Send test</span>
</Button>
</div>
)}
</header>
<AnimatePresence mode="wait" initial={false}>
{!active ? (
<motion.div key="list" className="min-h-0 flex-1" initial={{ opacity: 0, x: -12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -12 }} transition={{ duration: 0.18 }}>
<CampaignList campaigns={campaigns} onOpen={openCampaign} onNew={() => setDialog("templates-new")} />
</motion.div>
) : (
<motion.div
key="editor"
className={cn("flex min-h-0 flex-1 flex-col", drag && "cursor-grabbing [&_*]:cursor-grabbing")}
initial={{ opacity: 0, x: 12 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 12 }}
transition={{ duration: 0.18 }}
>
{/* Mobile / tablet panel switcher */}
<div className="border-b px-3 py-2 lg:hidden">
<Segmented
label="Panel"
value={panel}
onChange={setPanel}
options={[
{ value: "blocks", label: "Blocks" },
{ value: "canvas", label: "Canvas" },
{ value: "style", label: selected ? `Edit ${BLOCK_META[selected.type].label.toLowerCase()}` : "Settings" },
]}
/>
</div>
<div className="flex min-h-0 flex-1">
<aside aria-label="Block palette" className={cn("w-full shrink-0 border-r bg-card/40 lg:block lg:w-60", panel === "blocks" ? "block" : "hidden")}>
<BlockPalette
onPointerDown={onPointerDown}
dragging={Boolean(drag)}
onAdd={(type) => {
const id = insertBlock(type);
setPanel("canvas");
focusBlock(id);
push(`${BLOCK_META[type].label} added`);
}}
/>
</aside>
<main ref={canvasWrap} className={cn("min-w-0 flex-1 lg:block", panel === "canvas" ? "block" : "hidden")} aria-label="Email canvas">
<EmailCanvas
doc={doc}
mobile={mobile}
selectedId={selectedId}
drag={drag}
registerList={registerList}
onPointerDown={onPointerDown}
onSelect={setSelectedId}
onMove={moveBlock}
onDuplicate={duplicateBlock}
onDelete={deleteBlock}
onOpenPalette={() => setPanel("blocks")}
consumeClick={consumeClick}
/>
</main>
<aside aria-label="Inspector" className={cn("w-full shrink-0 border-l bg-card/40 lg:block lg:w-72", panel === "style" ? "block" : "hidden")}>
<Inspector
doc={doc}
block={selected}
onBlock={patchBlock}
onDoc={(patch, key) => apply((d) => ({ ...d, ...patch }), `doc:${key}`)}
onDuplicate={() => selected && duplicateBlock(selected.id)}
onDelete={() => selected && deleteBlock(selected.id)}
/>
</aside>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Drag overlay */}
{drag && (
<motion.div aria-hidden className="pointer-events-none absolute left-0 top-0 z-50" style={{ x: overlayX, y: overlayY, width: drag.width }}>
<motion.div
initial={{ scale: 1, rotate: 0 }}
animate={drag.settling ? { scale: 1, rotate: 0, opacity: 0.9 } : { scale: 1.02, rotate: drag.data.kind === "new" ? -2 : 0.6 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className={cn("overflow-hidden rounded-xl ring-2 ring-indigo-500/60", drag.settling ? "shadow-md" : "shadow-2xl shadow-black/20 dark:shadow-black/60", !drag.over && "opacity-70")}
>
{drag.data.kind === "new" ? (
<NewBlockChip type={drag.data.type} />
) : draggedBlock ? (
<div className="max-h-44 overflow-hidden" style={{ background: doc.content }}>
<EmailBlockView block={draggedBlock} doc={doc} mobile={mobile} />
</div>
) : null}
</motion.div>
</motion.div>
)}
<p aria-live="polite" className="sr-only">
{drag && !drag.settling ? (drag.over ? `Drop position ${drag.over.index + 1}` : "Move over the email to drop") : ""}
</p>
<TemplateGallery open={dialog === "templates" || dialog === "templates-new"} mode={dialog === "templates-new" ? "new" : "replace"} onClose={() => setDialog(null)} templates={templates} onPick={pickTemplate} />
<ExportDialog open={dialog === "export"} onClose={() => setDialog(null)} html={html} name={active?.name ?? "email"} />
<SendTestDialog
open={dialog === "send"}
onClose={() => setDialog(null)}
doc={doc}
defaultTo={testRecipient}
onSend={async (to, subject) => {
await onSendTest?.(to, subject, exportHtml(doc));
push("Test email sent", `Delivered to ${to.join(", ")}.`);
}}
/>
<Toasts toasts={toasts} onDismiss={dismiss} />
</>
);
}
function NewBlockChip({ type }: { type: BlockType }) {
const Icon = BLOCK_ICONS[type];
return (
<div className="flex items-center gap-2.5 bg-card p-2">
<span className="grid size-8 place-items-center rounded-lg bg-primary/10 text-primary">
<Icon className="size-4" />
</span>
<span className="text-[13px] font-medium">{BLOCK_META[type].label}</span>
</div>
);
}
export default EmailBuilderApp;