diff --git a/src/components/flows/flow-builder.tsx b/src/components/flows/flow-builder.tsx index aed9155..a1eaab6 100644 --- a/src/components/flows/flow-builder.tsx +++ b/src/components/flows/flow-builder.tsx @@ -15,7 +15,7 @@ * the same file as small components rather than separate modules. */ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { ArrowLeft, @@ -173,6 +173,110 @@ function uniqueNodeKey(base: string, existing: BuilderNode[]): string { return `${base}_${i}`; } +// Short, single-line content summary used in the collapsed NodeCard +// header — lets users scan a 10-node flow without expanding every card. +// Returns null when there's nothing meaningful to show (start/end, or +// a freshly-added node with no fields filled in). +function truncate(s: string, max = 80): string { + const clean = s.replace(/\s+/g, " ").trim(); + if (clean.length <= max) return clean; + return clean.slice(0, max - 1) + "…"; +} + +function summarizeNode(node: BuilderNode): string | null { + const cfg = node.config; + switch (node.node_type) { + case "start": + case "end": + return null; + case "send_message": { + const text = typeof cfg.text === "string" ? cfg.text : ""; + return text.length > 0 ? truncate(text) : null; + } + case "send_buttons": { + const text = typeof cfg.text === "string" ? cfg.text : ""; + const buttons = Array.isArray(cfg.buttons) + ? (cfg.buttons as Array>) + : []; + const titles = buttons + .map((b) => (typeof b.title === "string" ? b.title : "")) + .filter(Boolean) + .join(" / "); + if (text.length > 0) { + return titles ? `${truncate(text, 40)} · ${truncate(titles, 35)}` : truncate(text); + } + return titles || null; + } + case "send_list": { + const text = typeof cfg.text === "string" ? cfg.text : ""; + const sections = Array.isArray(cfg.sections) + ? (cfg.sections as Array>) + : []; + const rowCount = sections.reduce((sum, s) => { + const rows = Array.isArray(s.rows) ? s.rows : []; + return sum + rows.length; + }, 0); + if (text.length > 0) { + return rowCount > 0 + ? `${truncate(text, 50)} · ${rowCount} option${rowCount === 1 ? "" : "s"}` + : truncate(text); + } + return rowCount > 0 + ? `${rowCount} option${rowCount === 1 ? "" : "s"} across ${sections.length} section${sections.length === 1 ? "" : "s"}` + : null; + } + case "collect_input": { + const prompt = typeof cfg.prompt_text === "string" ? cfg.prompt_text : ""; + const varKey = typeof cfg.var_key === "string" ? cfg.var_key : ""; + if (prompt.length > 0) { + return varKey ? `${truncate(prompt, 50)} → vars.${varKey}` : truncate(prompt); + } + return varKey ? `→ vars.${varKey}` : null; + } + case "condition": { + const subjectKey = + typeof cfg.subject_key === "string" ? cfg.subject_key : ""; + if (!subjectKey) return null; + const subject = + cfg.subject === "tag" + ? "tag" + : cfg.subject === "contact_field" + ? "field" + : "var"; + const subjectStr = + subject === "tag" ? `has tag ${truncate(subjectKey, 24)}` : `${subject}.${subjectKey}`; + const op = + cfg.operator === "equals" + ? "==" + : cfg.operator === "contains" + ? "contains" + : cfg.operator === "present" + ? "exists" + : cfg.operator === "absent" + ? "missing" + : ""; + const value = typeof cfg.value === "string" ? cfg.value : ""; + const valStr = + (cfg.operator === "equals" || cfg.operator === "contains") && value + ? ` "${truncate(value, 20)}"` + : ""; + return subject === "tag" ? subjectStr : `${subjectStr} ${op}${valStr}`; + } + case "set_tag": { + const mode = cfg.mode === "remove" ? "Remove" : "Add"; + const tagId = typeof cfg.tag_id === "string" ? cfg.tag_id : ""; + // No tag name available without an async lookup here; show a + // short prefix of the UUID so users can disambiguate between + // multiple set_tag nodes at a glance. + return tagId ? `${mode} tag ${tagId.slice(0, 8)}…` : `${mode} tag (none picked)`; + } + case "handoff": { + const note = typeof cfg.note === "string" ? cfg.note : ""; + return note.length > 0 ? truncate(note) : null; + } + } +} + function defaultConfigFor(type: NodeType): Record { switch (type) { case "start": @@ -257,6 +361,10 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) { setState(updaterOrValue); }, []); + // Used by jumpToNode() to scroll the target into view + flash its border. + const nodeRefs = useRef>(new Map()); + const [flashedKey, setFlashedKey] = useState(null); + // Browser-level reload / tab-close / external-link guard. SPA // navigation (sidebar links, back button) isn't covered here — Next 16 // routes through the App Router and beforeunload doesn't fire on @@ -455,6 +563,36 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) { }); }, []); + // Jump-to-node: invoked when a user clicks an issue in the validation + // panel. Expand the offending card (so the broken field is visible), + // scroll it into the viewport, then flash its border so the eye lands + // on it. requestAnimationFrame defers the scroll until after React + // commits the expanded layout. + const jumpToNode = useCallback((key: string) => { + setExpanded((prev) => { + if (prev.has(key)) return prev; + const next = new Set(prev); + next.add(key); + return next; + }); + setFlashedKey(key); + requestAnimationFrame(() => { + const el = nodeRefs.current.get(key); + el?.scrollIntoView({ behavior: "smooth", block: "center" }); + }); + window.setTimeout(() => { + setFlashedKey((cur) => (cur === key ? null : cur)); + }, 1600); + }, []); + + const setNodeRef = useCallback( + (key: string) => (el: HTMLDivElement | null) => { + if (el) nodeRefs.current.set(key, el); + else nodeRefs.current.delete(key); + }, + [], + ); + // ---- Render ---- return (
@@ -502,6 +640,8 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) { allNodes={state.nodes} expanded={expanded.has(node.node_key)} isEntry={state.entry_node_id === node.node_key} + isFlashed={flashedKey === node.node_key} + cardRef={setNodeRef(node.node_key)} issues={issues.filter( (i) => i.scope === "node" && i.node_key === node.node_key, )} @@ -517,7 +657,7 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) { )} - +
); } @@ -797,6 +937,8 @@ function NodeCard({ allNodes, expanded, isEntry, + isFlashed, + cardRef, issues, onToggle, onUpdate, @@ -808,6 +950,8 @@ function NodeCard({ allNodes: BuilderNode[]; expanded: boolean; isEntry: boolean; + isFlashed: boolean; + cardRef: (el: HTMLDivElement | null) => void; issues: ValidationIssue[]; onToggle: () => void; onUpdate: (patch: Partial) => void; @@ -817,15 +961,19 @@ function NodeCard({ }) { const meta = NODE_META[node.node_type]; const hasError = issues.some((i) => i.severity === "error"); + const preview = summarizeNode(node); return (
+ {!expanded && preview && ( +

+ {preview} +

+ )} {hasError && ( @@ -916,21 +1069,16 @@ function NodeConfigForm({ onUpdateConfig: (patch: Record) => void; }) { const cfg = node.config; + // Internal identifiers (node_key, reply_id columns on buttons/list rows) + // are auto-generated and the runner is the only consumer. Hide them by + // default so the form reads as plain editing; expose under "Advanced" + // for the rare case where someone wants to lock a key for stable + // analytics or external integration. + const [showAdvanced, setShowAdvanced] = useState(false); + const hasReplyIds = + node.node_type === "send_buttons" || node.node_type === "send_list"; return (
-
- - - onUpdate({ node_key: slugify(e.target.value, node.node_key) }) - } - className="bg-slate-800" - /> -
- {node.node_type === "start" && ( )} @@ -973,6 +1122,7 @@ function NodeConfigForm({ allNodes={allNodes} currentKey={node.node_key} onUpdateConfig={onUpdateConfig} + showAdvanced={showAdvanced} /> )} @@ -1051,6 +1201,44 @@ function NodeConfigForm({ complete. No config needed.

)} + +
+ + {showAdvanced && ( +
+
+ + + onUpdate({ node_key: slugify(e.target.value, node.node_key) }) + } + className="bg-slate-800 font-mono text-xs" + /> +
+ {hasReplyIds && ( +

+ Reply IDs for each option are shown inline above. They're + returned by WhatsApp when a customer taps; you usually don't + need to touch them. +

+ )} +
+ )} +
); } @@ -1068,11 +1256,13 @@ function SendButtonsForm({ allNodes, currentKey, onUpdateConfig, + showAdvanced, }: { cfg: SendButtonsCfg; allNodes: BuilderNode[]; currentKey: string; onUpdateConfig: (patch: Record) => void; + showAdvanced: boolean; }) { const buttons = cfg.buttons ?? []; const updateButton = ( @@ -1120,18 +1310,25 @@ function SendButtonsForm({ {buttons.map((b, i) => (
- - updateButton(i, { - reply_id: slugify(e.target.value, `btn_${i + 1}`), - }) - } - placeholder="reply_id" - className="bg-slate-800 font-mono text-xs" - /> + {showAdvanced && ( + + updateButton(i, { + reply_id: slugify(e.target.value, `btn_${i + 1}`), + }) + } + placeholder="reply_id" + className="bg-slate-800 font-mono text-xs" + /> + )} updateButton(i, { title: e.target.value })} @@ -1195,11 +1392,13 @@ function SendListForm({ allNodes, currentKey, onUpdateConfig, + showAdvanced, }: { cfg: SendListCfg; allNodes: BuilderNode[]; currentKey: string; onUpdateConfig: (patch: Record) => void; + showAdvanced: boolean; }) { const sections = cfg.sections ?? []; const totalRows = sections.reduce((sum, s) => sum + s.rows.length, 0); @@ -1214,6 +1413,24 @@ function SendListForm({ ), }); }; + const addSection = () => + onUpdateConfig({ + sections: [ + ...sections, + { + title: "", + rows: [ + { + reply_id: `row_${totalRows + 1}`, + title: `Option ${totalRows + 1}`, + next_node_key: "", + }, + ], + }, + ], + }); + const removeSection = (sIdx: number) => + onUpdateConfig({ sections: sections.filter((_, i) => i !== sIdx) }); const updateRow = ( sIdx: number, rIdx: number, @@ -1287,32 +1504,52 @@ function SendListForm({ key={sIdx} className="mb-3 rounded-md border border-slate-800 bg-slate-800/40 p-3" > - - updateSection(sIdx, { title: e.target.value }) - } - placeholder="Section title (optional)" - className="mb-2 bg-slate-800 text-xs" - /> +
+ + updateSection(sIdx, { title: e.target.value }) + } + placeholder={`Section ${sIdx + 1} title (optional)`} + className="bg-slate-800 text-xs" + /> + {sections.length > 1 && ( + + )} +
{section.rows.map((row, rIdx) => (
- - updateRow(sIdx, rIdx, { - reply_id: slugify( - e.target.value, - `row_${rIdx + 1}`, - ), - }) - } - placeholder="reply_id" - className="bg-slate-800 font-mono text-xs" - /> + {showAdvanced && ( + + updateRow(sIdx, rIdx, { + reply_id: slugify( + e.target.value, + `row_${rIdx + 1}`, + ), + }) + } + placeholder="reply_id" + className="bg-slate-800 font-mono text-xs" + /> + )} @@ -1354,6 +1591,19 @@ function SendListForm({ )}
))} + {/* WhatsApp's interactive-list spec caps sections at 10. Group rows + by category (Billing / Support / Sales etc.) to give customers a + scannable menu. */} + {sections.length < 10 && ( + + )}
); @@ -1783,7 +2033,13 @@ function AddNodeButton({ onAdd }: { onAdd: (type: NodeType) => void }) { // Validation panel — bottom of the editor // ============================================================ -function ValidationPanel({ issues }: { issues: ValidationIssue[] }) { +function ValidationPanel({ + issues, + onJump, +}: { + issues: ValidationIssue[]; + onJump: (key: string) => void; +}) { if (issues.length === 0) { return (
@@ -1807,28 +2063,28 @@ function ValidationPanel({ issues }: { issues: ValidationIssue[] }) {
{issues.map((i, ix) => ( - + ))}
); } -function IssueLine({ issue }: { issue: ValidationIssue }) { - return ( -
- - +function IssueLine({ + issue, + onJump, +}: { + issue: ValidationIssue; + onJump?: (key: string) => void; +}) { + const tone = + issue.severity === "error" ? "text-red-300" : "text-amber-300"; + const iconTone = + issue.severity === "error" ? "text-red-400" : "text-amber-400"; + const body = ( + <> + + {issue.node_key && ( {issue.node_key} @@ -1836,6 +2092,34 @@ function IssueLine({ issue }: { issue: ValidationIssue }) { )} {issue.message} + + ); + + // Only node-scoped issues can jump; trigger-scoped issues have no + // destination (the trigger panel is already at the top of the page). + if (issue.node_key && onJump) { + return ( + + ); + } + return ( +
+ {body}
); }