From 71df2c8a6d5b10cbf38fe0ddc1337bfa843d7bfb Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Wed, 22 Jul 2026 17:04:51 +0200 Subject: [PATCH] feat: add atomic TipTap variable, AI-variable, and conditional chip nodes with literal-token serialization for the campaign email editor --- .../sequences/nodes/AIVariableNode.tsx | 423 +++++++++++++ .../sequences/nodes/ConditionalNode.tsx | 580 ++++++++++++++++++ .../sequences/nodes/EditorSuggest.tsx | 273 +++++++++ .../sequences/nodes/VariableNode.tsx | 353 +++++++++++ .../campaigns/sequences/nodes/justInserted.ts | 31 + 5 files changed, 1660 insertions(+) create mode 100644 web/src/components/app/campaigns/sequences/nodes/AIVariableNode.tsx create mode 100644 web/src/components/app/campaigns/sequences/nodes/ConditionalNode.tsx create mode 100644 web/src/components/app/campaigns/sequences/nodes/EditorSuggest.tsx create mode 100644 web/src/components/app/campaigns/sequences/nodes/VariableNode.tsx create mode 100644 web/src/components/app/campaigns/sequences/nodes/justInserted.ts diff --git a/web/src/components/app/campaigns/sequences/nodes/AIVariableNode.tsx b/web/src/components/app/campaigns/sequences/nodes/AIVariableNode.tsx new file mode 100644 index 00000000..057b763a --- /dev/null +++ b/web/src/components/app/campaigns/sequences/nodes/AIVariableNode.tsx @@ -0,0 +1,423 @@ +// AIVariableNode — an atomic inline "AI block" that generates unique copy for +// each recipient at send time. The full config rides inline in the editor HTML +// (base64 in a data attribute); the control-plane resolver reads it, renders the +// prompt per contact through the platform humanizer, and swaps the whole span for +// the result (see @/lib/aiVariables). The chip shows the prompt; clicking it opens +// a centered config modal (the app Dialog). The modal auto-opens ONCE right after +// insertion (via the justInserted registry), never again on Edit/Preview remounts. +// +// The instruction is the SAME rich editor used everywhere else, in `minimal` mode: +// {{.Field}} merge tokens render as chips, the {{ type-ahead works, and if/else +// conditionals are available — just small. The dialog runs non-modal so the +// editor's own body-portaled popovers (variable menu, type-ahead, condition +// builder) stay interactive inside it. + +import React from "react"; +import { createPortal } from "react-dom"; +import { Node as TiptapNode, mergeAttributes } from "@tiptap/core"; +import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; +import { AnimatePresence, motion } from "framer-motion"; +import { SparklesIcon, GlobeIcon, TrashIcon } from "lucide-react"; +import toast from "react-hot-toast"; +import type { AppError } from "@/lib/api/client/normalizeError"; +import buildError from "@/lib/helper/buildError"; +import useGenerateAIVariable from "@/lib/api/hooks/app/generation/useGenerateAIVariable"; +import useTypewriter from "@/components/app/ai/useTypewriter"; +import formatUsage from "@/components/app/ai/usage"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import { WRITE_TONES } from "@/lib/api/models/app/generation/Write"; +import { VARIABLES } from "@/lib/templateVars"; +import RichTextEditor from "@/components/app/campaigns/sequences/RichTextEditor"; +import { htmlToPlain, promptToHtml, renderPreview, SAMPLE } from "@/components/app/campaigns/sequences/emailPreview"; +import { markJustInserted, consumeJustInserted } from "./justInserted"; +import { + type AIVariableConfig, + DEFAULT_AI_CONFIG, + aiToken, + newAIVariableId, + encodeConfig, + decodeConfig, +} from "@/lib/aiVariables"; + +declare module "@tiptap/core" { + interface Commands { + aiVariable: { + // Insert a fresh AI block; opens focused for immediate configuration. + insertAIVariable: (config?: Partial) => ReturnType; + }; + } +} + +export const AIVariableNode = TiptapNode.create({ + name: "aiVariable", + inline: true, + group: "inline", + atom: true, + selectable: true, + draggable: false, + + addAttributes() { + return { + id: { + default: "", + parseHTML: (el) => (el as HTMLElement).getAttribute("data-ai-var") || "", + renderHTML: (attrs) => ({ "data-ai-var": attrs.id }), + }, + config: { + default: DEFAULT_AI_CONFIG, + parseHTML: (el) => decodeConfig((el as HTMLElement).getAttribute("data-ai-config") || ""), + renderHTML: (attrs) => ({ "data-ai-config": encodeConfig(attrs.config as AIVariableConfig) }), + }, + }; + }, + + parseHTML() { + return [{ tag: "span[data-ai-var]" }]; + }, + + renderHTML({ node, HTMLAttributes }) { + return ["span", mergeAttributes(HTMLAttributes), aiToken(node.attrs.id)]; + }, + + renderText({ node }) { + return aiToken(node.attrs.id); + }, + + addCommands() { + return { + insertAIVariable: + (config?: Partial) => + ({ chain }) => { + // Mint the id here so it can be flagged as freshly inserted; the + // node view opens its modal once for this id and never again. + const id = newAIVariableId(); + markJustInserted(id); + return chain() + .insertContent({ + type: this.name, + attrs: { id, config: { ...DEFAULT_AI_CONFIG, ...config } }, + }) + .run(); + }, + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(AIVariableChip); + }, +}); + +function truncate(s: string, n: number): string { + const t = s.trim().replace(/\s+/g, " "); + return t.length > n ? t.slice(0, n - 1) + "…" : t; +} + +// Sky-tinted chip that shows the prompt; opens the config modal on click. It +// auto-opens only for a just-inserted block (consumeJustInserted), so toggling +// the Edit/Preview tabs — which remounts every node view — never reopens it. +function AIVariableChip({ node, updateAttributes, deleteNode, selected, editor }: NodeViewProps) { + const config: AIVariableConfig = node.attrs.config || DEFAULT_AI_CONFIG; + const [open, setOpen] = React.useState(() => consumeJustInserted(node.attrs.id)); + + // The email text on both sides of this block, so generation writes a fragment + // that flows with the sentence it lands in. Other AI blocks blank to a neutral + // placeholder; captured lazily (read when the config modal opens). + const getContext = React.useCallback(() => { + try { + const plain = htmlToPlain(editor.getHTML()); + const idx = plain.indexOf(aiToken(node.attrs.id)); + if (idx < 0) return { before: "", after: "" }; + const clean = (s: string) => s.replace(/\[\[ai:[^\]]*\]\]/g, "…").trim(); + return { before: clean(plain.slice(0, idx)), after: clean(plain.slice(idx + aiToken(node.attrs.id).length)) }; + } catch { + return { before: "", after: "" }; + } + }, [editor, node.attrs.id]); + + const label = config.name?.trim() || (config.prompt ? truncate(config.prompt, 44) : "") || "Set up AI block"; + + return ( + + e.preventDefault()} + onClick={() => setOpen(true)} + title={config.prompt ? `AI: ${config.prompt}` : "Configure this AI block"} + className={`tpl-ai ${selected || open ? "tpl-ai-active" : ""} ${config.prompt ? "" : "tpl-ai-empty"}`} + > + + {label} + + {/* Non-modal so the embedded editor's body-portaled popovers (variable menu, + {{ type-ahead, condition builder) stay clickable and focusable; the + onInteractOutside guard keeps a click on one of those from closing it. */} + + {/* Non-modal Radix renders no overlay, so paint our own dimming backdrop. + Clicking it lands outside the content and dismisses via Radix. */} + {open && + createPortal( +
, + document.body, + )} + e.preventDefault()} + onInteractOutside={(e) => { + const target = e.detail.originalEvent.target as HTMLElement | null; + if (target?.closest("[data-floating]")) e.preventDefault(); + }} + > + Configure AI block + {open && ( + updateAttributes({ config: next })} + onRemove={() => { + deleteNode(); + setOpen(false); + }} + onClose={() => setOpen(false)} + /> + )} + +
+
+ ); +} + +function AIVariableConfigBody({ + config, + getContext, + onChange, + onRemove, + onClose, +}: { + config: AIVariableConfig; + getContext: () => { before: string; after: string }; + onChange: (next: AIVariableConfig) => void; + onRemove: () => void; + onClose: () => void; +}) { + const gen = useGenerateAIVariable(); + const typewriter = useTypewriter(); + + const [draft, setDraft] = React.useState(config); + const [preview, setPreview] = React.useState(""); + const [usage, setUsage] = React.useState<{ charged: number; tokens: number } | null>(null); + // Surrounding email, captured when the modal opens (the body isn't edited while open). + const [ctx] = React.useState(getContext); + + // Compute the editor's starting HTML once (the body remounts each time the + // dialog opens), so the editor is effectively uncontrolled after mount and we + // only read plain text back out; feeding derived HTML back would reset the + // caret on every keystroke. + const [initialHtml] = React.useState(() => promptToHtml(config.prompt)); + + const patch = React.useCallback( + (p: Partial) => { + setDraft((d) => { + const next = { ...d, ...p }; + onChange(next); + return next; + }); + }, + [onChange], + ); + + const runPreview = () => { + if (!draft.prompt.trim() || gen.isPending) return; + setPreview(""); + // mode is pinned "instant"; the removed research mode no longer exists. + gen.mutate( + { + mode: "instant", + prompt: draft.prompt, + tone: draft.tone || undefined, + web_search: draft.web_search, + context_before: ctx.before, + context_after: ctx.after, + }, + { + onSuccess: (res) => { + setUsage({ charged: res.credits_charged ?? 0, tokens: res.tokens_used ?? 0 }); + typewriter.run(res.text, (partial) => setPreview(partial)); + }, + onError: (e) => { + const err = e as unknown as AppError; + if (err?.status === 402) { + toast.error("You're out of AI credits. Upgrade or purchase more to preview AI blocks."); + } else { + toast.error(buildError(err)); + } + }, + }, + ); + }; + + return ( +
+ {/* LEFT — the instruction, in the same editor used everywhere else */} +
+
+
+ {/* opening clause — bare sparkle, no badge */} +
+ + For each recipient, write… +
+ patch({ prompt: htmlToPlain(html) })} + variables={VARIABLES} + placeholder="a warm one-line opener for {{.FirstName}} at {{.Company}}" + /> +
+ + {/* tone — quiet chips, all visible */} +
+ Tone + {WRITE_TONES.map((t) => { + const active = (draft.tone || "") === t.value; + return ( + + ); + })} +
+ + {/* the ONE add-on — a switch (globe left, switch on the right) */} + + + {/* cost — honest: it's metered by usage, not a flat number */} +

+ Billed by usage — the tokens each snippet uses{draft.web_search ? ", plus the web search" : ""}. + Preview to see a real example. +

+
+ + {/* actions — scoped to the config pane */} +
+ + +
+
+ + {/* RIGHT — the live sample */} +
+ + Sample for Alex Rivera at Acme + + +
+ + {gen.isPending && !preview ? ( + + + Writing a sample… + + ) : preview ? ( + // Show the WHOLE message with the generated fragment in place, so + // you can see it actually fits. Surrounding text renders with the + // sample contact's values; the AI part is highlighted. + + {ctx.before || ctx.after ? ( + <> + {renderPreview(ctx.before, SAMPLE)} + {ctx.before ? " " : ""} + {preview} + {ctx.after ? " " : ""} + {renderPreview(ctx.after, SAMPLE)} + + ) : ( + preview + )} + + ) : ( +

Your snippet appears here.

+ )} +
+ {usage && preview && formatUsage(usage.charged, usage.tokens) && ( +

{formatUsage(usage.charged, usage.tokens)}

+ )} +
+ + +
+
+ ); +} diff --git a/web/src/components/app/campaigns/sequences/nodes/ConditionalNode.tsx b/web/src/components/app/campaigns/sequences/nodes/ConditionalNode.tsx new file mode 100644 index 00000000..079ce070 --- /dev/null +++ b/web/src/components/app/campaigns/sequences/nodes/ConditionalNode.tsx @@ -0,0 +1,580 @@ +// ConditionalNode — an atomic inline chip for a Go-template conditional +// {{if EXPR}}THEN{{else}}ELSE{{end}}. The whole construct (condition + bodies) +// is edited in a builder popover, so the editor never carries fragile, orphanable +// half-tokens. It serializes as the literal template text (the span's content), +// so the send-time Go renderer resolves it unchanged and htmlToPlain keeps it. +// A raw-expression field is the escape hatch, so any condition is possible. + +import React from "react"; +import { createPortal } from "react-dom"; +import { Node as TiptapNode, mergeAttributes } from "@tiptap/core"; +import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; +import { AnimatePresence, motion } from "framer-motion"; +import { GitBranchIcon, XIcon, ChevronDownIcon, PlusIcon } from "lucide-react"; +import useCustomFieldKeys from "@/lib/api/hooks/app/contacts/useCustomFieldKeys"; +import { useAnchoredFloating } from "@/hooks/useAnchoredFloating"; +import { STANDARD_VARS, buildToken, cleanFieldName, isStandardKey } from "@/lib/templateVars"; +import { markJustInserted, consumeJustInserted, freshId } from "./justInserted"; + +interface ConditionalAttrs { + expr: string; // the condition after `{{if `, e.g. `.Company` or `eq .Industry "SaaS"` + thenText: string; + elseText: string; + uid: string; // transient, not serialized — flags a just-inserted chip to open once +} + +const DEFAULT_ATTRS: ConditionalAttrs = { expr: ".Company", thenText: "", elseText: "", uid: "" }; + +declare module "@tiptap/core" { + interface Commands { + conditional: { + insertConditional: (attrs?: Partial) => ReturnType; + }; + } +} + +// buildConditionalToken assembles the literal template text from the attrs. +function buildConditionalToken(a: ConditionalAttrs): string { + const expr = a.expr.trim() || ".Company"; + const then = a.thenText ?? ""; + if (a.elseText && a.elseText.trim()) { + return `{{if ${expr}}}${then}{{else}}${a.elseText}{{end}}`; + } + return `{{if ${expr}}}${then}{{end}}`; +} + +// parseConditionalToken reverses buildConditionalToken so a saved/pasted +// conditional round-trips back into the builder. +function parseConditionalToken(token: string): ConditionalAttrs | null { + const m = token.match(/^\{\{\s*if\s+([\s\S]*?)\s*\}\}([\s\S]*?)(?:\{\{\s*else\s*\}\}([\s\S]*?))?\{\{\s*end\s*\}\}$/); + if (!m) return null; + return { expr: m[1].trim(), thenText: m[2] ?? "", elseText: m[3] ?? "" }; +} + +export const ConditionalNode = TiptapNode.create({ + name: "conditional", + inline: true, + group: "inline", + atom: true, + selectable: true, + + addAttributes() { + return { + expr: { + default: DEFAULT_ATTRS.expr, + parseHTML: (el) => parseConditionalToken((el as HTMLElement).textContent || "")?.expr ?? DEFAULT_ATTRS.expr, + renderHTML: () => ({}), + }, + thenText: { + default: "", + parseHTML: (el) => parseConditionalToken((el as HTMLElement).textContent || "")?.thenText ?? "", + renderHTML: () => ({}), + }, + elseText: { + default: "", + parseHTML: (el) => parseConditionalToken((el as HTMLElement).textContent || "")?.elseText ?? "", + renderHTML: () => ({}), + }, + // Transient: never serialized (regenerates as "" on load), so only a + // fresh toolbar insert carries a uid the chip opens its builder for. + uid: { + default: "", + parseHTML: () => "", + renderHTML: () => ({}), + }, + }; + }, + + parseHTML() { + return [ + { + tag: "span[data-if]", + getAttrs: (el) => { + const parsed = parseConditionalToken(((el as HTMLElement).textContent || "").trim()); + return parsed ? parsed : false; + }, + }, + ]; + }, + + renderHTML({ node, HTMLAttributes }) { + return ["span", mergeAttributes(HTMLAttributes, { "data-if": "" }), buildConditionalToken(node.attrs as ConditionalAttrs)]; + }, + + renderText({ node }) { + return buildConditionalToken(node.attrs as ConditionalAttrs); + }, + + addCommands() { + return { + insertConditional: + (attrs?: Partial) => + ({ chain }) => { + // Flag this insertion so the chip opens its builder exactly once. + const uid = freshId(); + markJustInserted(uid); + return chain().insertContent({ type: this.name, attrs: { ...DEFAULT_ATTRS, ...attrs, uid } }).run(); + }, + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(ConditionalChip); + }, +}); + +function clip(s: string, n: number): string { + const t = s.trim().replace(/\s+/g, " "); + return t.length > n ? t.slice(0, n - 1) + "…" : t; +} + +// Friendly one-line label for the condition itself. +function condLabel(expr: string): string { + const e = expr.trim(); + let m = e.match(/^\.([A-Za-z0-9_ -]+)$/); + if (m) return `If ${m[1]} is set`; + m = e.match(/^not\s+\.([A-Za-z0-9_ -]+)$/); + if (m) return `If ${m[1]} is empty`; + m = e.match(/^eq\s+\.([A-Za-z0-9_ -]+)\s+"([^"]*)"$/); + if (m) return `If ${m[1]} = ${m[2]}`; + m = e.match(/^ne\s+\.([A-Za-z0-9_ -]+)\s+"([^"]*)"$/); + if (m) return `If ${m[1]} ≠ ${m[2]}`; + return "Condition"; +} + +// Chip label shows the condition AND a short preview of each branch, so the +// if/else structure reads clearly without opening the builder. +function summarize(a: ConditionalAttrs): string { + const then = a.thenText.trim(); + const els = a.elseText.trim(); + // A brand-new, unconfigured condition reads clearly as a call to action rather + // than a bare "If Company is set" with no visible effect. + if (!then && !els) return "Set up condition"; + let s = condLabel(a.expr); + if (then) s += ` → ${clip(then, 18)}`; + if (els) s += ` / else ${clip(els, 14)}`; + return s; +} + +function ConditionalChip({ node, updateAttributes, deleteNode, selected, editor, getPos }: NodeViewProps) { + const attrs = node.attrs as ConditionalAttrs; + // Open the builder once, right after insertion — not on every Edit/Preview remount. + const [open, setOpen] = React.useState(() => consumeJustInserted(attrs.uid)); + const { setReference, setFloating, floatingStyle } = useAnchoredFloating(open, { + placement: "bottom-start", + gap: 6, + maxHeight: true, + }); + + // Free raw edit of the whole {{if}}...{{end}} construct. If it re-parses into + // the builder shape, keep it a chip; otherwise replace the node with the raw + // literal so any hand-written Go-template conditional is possible. + const onRaw = React.useCallback( + (rawToken: string) => { + const t = rawToken.trim(); + setOpen(false); + if (!t) return; + const parsed = parseConditionalToken(t); + if (parsed) { + updateAttributes(parsed); + return; + } + const pos = typeof getPos === "function" ? getPos() : null; + if (typeof pos === "number") { + editor.chain().focus().insertContentAt({ from: pos, to: pos + node.nodeSize }, t).run(); + } + }, + [editor, getPos, node, updateAttributes], + ); + + return ( + + setReference(el)} + type="button" + initial={{ scale: 0.92, opacity: 0 }} + animate={{ scale: 1, opacity: 1 }} + whileTap={{ scale: 0.96 }} + transition={{ type: "spring", stiffness: 640, damping: 30 }} + onMouseDown={(e) => e.preventDefault()} + onClick={() => setOpen((o) => !o)} + title={buildConditionalToken(attrs)} + className={`tpl-if ${selected || open ? "tpl-if-active" : ""}`} + > + + {summarize(attrs)} + + {typeof document !== "undefined" && + createPortal( + + {open && ( + updateAttributes(next)} + onRaw={onRaw} + onRemove={() => { + deleteNode(); + setOpen(false); + }} + onClose={() => setOpen(false)} + /> + )} + , + document.body, + )} + + ); +} + +type Op = "set" | "empty" | "eq" | "ne" | "raw"; + +function exprToParts(expr: string): { field: string; op: Op; value: string } { + const e = expr.trim(); + let m = e.match(/^\.([A-Za-z0-9_ -]+)$/); + if (m) return { field: m[1], op: "set", value: "" }; + m = e.match(/^not\s+\.([A-Za-z0-9_ -]+)$/); + if (m) return { field: m[1], op: "empty", value: "" }; + m = e.match(/^eq\s+\.([A-Za-z0-9_ -]+)\s+"([^"]*)"$/); + if (m) return { field: m[1], op: "eq", value: m[2] }; + m = e.match(/^ne\s+\.([A-Za-z0-9_ -]+)\s+"([^"]*)"$/); + if (m) return { field: m[1], op: "ne", value: m[2] }; + return { field: "", op: "raw", value: "" }; +} + +function partsToExpr(field: string, op: Op, value: string, raw: string): string { + const f = cleanFieldName(field) || "Company"; + const v = value.replace(/"/g, "'"); + switch (op) { + case "set": + return `.${f}`; + case "empty": + return `not .${f}`; + case "eq": + return `eq .${f} "${v}"`; + case "ne": + return `ne .${f} "${v}"`; + default: + return raw.trim(); + } +} + +const OPS: { value: Op; label: string }[] = [ + { value: "set", label: "is set" }, + { value: "empty", label: "is empty" }, + { value: "eq", label: "equals" }, + { value: "ne", label: "does not equal" }, + { value: "raw", label: "advanced…" }, +]; + +function ConditionalBuilder({ + setFloating, + floatingStyle, + attrs, + onChange, + onRaw, + onRemove, + onClose, +}: { + setFloating: (el: HTMLElement | null) => void; + floatingStyle: React.CSSProperties; + attrs: ConditionalAttrs; + onChange: (next: Partial) => void; + onRaw: (rawToken: string) => void; + onRemove: () => void; + onClose: () => void; +}) { + const { data: customKeys = [] } = useCustomFieldKeys(); + const localRef = React.useRef(null); + const initial = exprToParts(attrs.expr); + const [field, setField] = React.useState(initial.field || "Company"); + const [op, setOp] = React.useState(initial.op); + const [value, setValue] = React.useState(initial.value); + const [raw, setRaw] = React.useState(op === "raw" ? attrs.expr : ""); + const [thenText, setThenText] = React.useState(attrs.thenText); + const [elseText, setElseText] = React.useState(attrs.elseText); + const [showElse, setShowElse] = React.useState(!!attrs.elseText); + // Full-construct raw mode: edit the entire {{if}}...{{end}} literal freely. + const [rawMode, setRawMode] = React.useState(false); + const [rawFull, setRawFull] = React.useState(""); + const enterRaw = () => { + setRawFull(buildConditionalToken({ expr: partsToExpr(field, op, value, raw), thenText, elseText: showElse ? elseText : "" })); + setRawMode(true); + }; + + const setRefs = React.useCallback( + (el: HTMLDivElement | null) => { + localRef.current = el; + setFloating(el); + }, + [setFloating], + ); + + // Push changes up as the builder is edited (skipped in raw mode, which + // commits through onRaw instead). + React.useEffect(() => { + if (rawMode) return; + onChange({ expr: partsToExpr(field, op, value, raw), thenText, elseText: showElse ? elseText : "" }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [field, op, value, raw, thenText, elseText, showElse, rawMode]); + + React.useEffect(() => { + const onDown = (e: MouseEvent | TouchEvent) => { + if (!localRef.current?.contains(e.target as Node)) onClose(); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.stopPropagation(); + onClose(); + } + }; + document.addEventListener("mousedown", onDown, true); + document.addEventListener("touchstart", onDown, true); + document.addEventListener("keydown", onKey, true); + return () => { + document.removeEventListener("mousedown", onDown, true); + document.removeEventListener("touchstart", onDown, true); + document.removeEventListener("keydown", onKey, true); + }; + }, [onClose]); + + const fields = [ + ...STANDARD_VARS.map((v) => v.key), + ...customKeys.filter((k) => !isStandardKey(k)).map((k) => cleanFieldName(k)), + ]; + + const insertVarInto = (setter: React.Dispatch>) => (token: string) => { + setter((cur) => cur + token); + }; + + return ( + +
+ + + + Condition + + +
+ +
+ {rawMode ? ( +
+
+ Raw template +
+