Fazekit

Code

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

More in Business

View all →