/** * Shared editor primitives used by both the linear-list and canvas * views of a flow. * * What lives here vs in flow-builder.tsx / flow-canvas.tsx: * - Types and metadata that BOTH views need to render a node * consistently (icon, label, color, 1-line summary). * - Editing-only helpers (defaultConfigFor, slugify, uniqueNodeKey, * BuilderState) stay in flow-builder.tsx until the canvas grows * editing affordances — pulled across in the PR that adds them. * * Why .tsx and not .ts: NODE_META holds lucide icon components, which * are typed as React components; importing them from a .ts module * works at runtime but trips TypeScript's * `verbatimModuleSyntax`-related linting in some setups. Keeping the * file .tsx future-proofs it for inline JSX in node-card renderers. */ import { Flag, GitFork, Inbox, ListChecks, ListPlus, MessageCircle, Paperclip, PlayCircle, Tag, UserPlus, Workflow, } from 'lucide-react'; import { cn } from '@/lib/utils'; // ============================================================ // Node-type union — single source of truth for every place the UI // enumerates types (add menu, type pickers, switch statements). Kept // in lockstep with `FlowNodeType` in src/lib/flows/types.ts (which // drives the engine's exhaustiveness check); a divergence between the // two is always a bug. // ============================================================ export type NodeType = | 'start' | 'send_message' | 'send_buttons' | 'send_list' | 'send_media' | 'collect_input' | 'condition' | 'set_tag' | 'handoff' | 'end'; export interface BuilderNode { node_key: string; node_type: NodeType; config: Record; /** Optional in v1 — defaults to 0 in the DB. Canvas view reads it * to position nodes; list view ignores it. */ position_x?: number; position_y?: number; } // ============================================================ // Per-node-type metadata used to render icons + labels everywhere // the user sees a node summary. // ============================================================ // ------------------------------------------------------------ // Node categories — buckets the add-step menu groups types under so // the picker stays scannable as the type list grows, and so a fork // adding its own node types has an obvious place to slot them. // // Note there's no "Events / Triggers" category: in wacrm a flow is // triggered by flow-level config (`trigger_type`), not by a node on // the canvas, so `start` is just the entry point under Flow control. // ------------------------------------------------------------ export type NodeCategory = 'messaging' | 'logic' | 'flow'; /** Category labels + the order they render in the add-step menu. */ export const NODE_CATEGORIES: { id: NodeCategory; label: string }[] = [ { id: 'messaging', label: 'Messaging' }, { id: 'logic', label: 'Logic & data' }, { id: 'flow', label: 'Flow control' }, ]; export const NODE_META: Record< NodeType, { label: string; icon: typeof Workflow; color: string; blurb: string; category: NodeCategory; } > = { start: { label: 'Start', icon: PlayCircle, color: 'text-emerald-400', blurb: 'Entry point of the flow', category: 'flow', }, send_message: { label: 'Send message', icon: MessageCircle, color: 'text-sky-400', blurb: 'Sends a WhatsApp text message', category: 'messaging', }, send_buttons: { label: 'Send buttons', icon: ListChecks, color: 'text-primary', blurb: 'Sends quick-reply buttons', category: 'messaging', }, send_list: { label: 'Send list', icon: ListPlus, color: 'text-indigo-400', blurb: 'Sends a tappable list of options', category: 'messaging', }, send_media: { label: 'Send media', icon: Paperclip, color: 'text-cyan-400', blurb: 'Sends an image, video, or document', category: 'messaging', }, collect_input: { label: 'Collect input', icon: Inbox, color: 'text-teal-400', blurb: 'Asks a question, saves the reply', category: 'logic', }, condition: { label: 'If / else', icon: GitFork, color: 'text-fuchsia-400', blurb: 'Branches on a rule', category: 'logic', }, set_tag: { label: 'Tag contact', icon: Tag, color: 'text-pink-400', blurb: 'Adds or removes a contact tag', category: 'logic', }, handoff: { label: 'Handoff to agent', icon: UserPlus, color: 'text-amber-400', blurb: 'Hands the conversation to a human', category: 'flow', }, end: { label: 'End', icon: Flag, color: 'text-muted-foreground', blurb: 'Ends the flow', category: 'flow', }, }; /** * Bucket an ordered list of node types by category, preserving both * the category order (NODE_CATEGORIES) and the within-category order * of the input list. Empty categories are dropped. Used by both the * canvas and list add-step menus so they stay in lockstep. */ export function groupNodeTypesByCategory( types: NodeType[] ): { id: NodeCategory; label: string; types: NodeType[] }[] { return NODE_CATEGORIES.map(({ id, label }) => ({ id, label, types: types.filter((t) => NODE_META[t].category === id), })).filter((group) => group.types.length > 0); } // ============================================================ // Per-node-type color system. // // Each node type gets its own hue so the canvas reads at a glance — // what KIND of step is this. Kept as raw oklch (not Tailwind classes) // so a node card can tint its icon chip, type label, selection ring, // and edge ports from one source, the way the Flow Builder design // handoff does. Hues sit in the same oklch family as the app tokens // in globals.css; they don't replace --primary (the accent), they // complement it. `nodeColors()` derives the soft/ring/text variants. // ============================================================ const NODE_HUE: Record = { start: { l: 0.62, c: 0.13, h: 162 }, // emerald — the start, echoes WhatsApp green send_message: { l: 0.6, c: 0.18, h: 293 }, // violet — the workhorse send_buttons: { l: 0.62, c: 0.16, h: 254 }, // cobalt send_list: { l: 0.62, c: 0.15, h: 277 }, // indigo send_media: { l: 0.65, c: 0.12, h: 210 }, // sky collect_input: { l: 0.65, c: 0.1, h: 185 }, // teal — capture condition: { l: 0.72, c: 0.15, h: 65 }, // amber — a fork in the road set_tag: { l: 0.65, c: 0.15, h: 350 }, // pink handoff: { l: 0.65, c: 0.17, h: 16 }, // rose — hands off end: { l: 0.55, c: 0.01, h: 260 }, // neutral grey — terminal }; export interface NodeColors { /** Full-strength hue — icon glyph, selection ring, port fill. */ solid: string; /** ~14% tint — icon chip background, soft fills. */ soft: string; /** ~45% tint — hover border / focus ring. */ ring: string; /** Hue for the uppercase type label, kept readable in BOTH modes. */ text: string; } export function nodeColors(type: NodeType): NodeColors { const t = NODE_HUE[type]; const solid = `oklch(${t.l} ${t.c} ${t.h})`; return { solid, soft: `oklch(${t.l} ${t.c} ${t.h} / 0.14)`, ring: `oklch(${t.l} ${t.c} ${t.h} / 0.45)`, // Blend the hue toward the live --foreground token so the label // holds contrast in BOTH modes: in dark mode --foreground is // near-white (the label lightens to read on the dark card), in // light mode it's near-black (the label darkens to read on the // white card). The old fixed-light value only worked on dark. text: `color-mix(in oklch, ${solid}, var(--foreground) 38%)`, }; } // ============================================================ // Shared node icon chip — the per-type colored glyph badge used in // the canvas node card, list-view card, inspector header, and the // add-step menu. One component so a styling change (radius, contrast, // hover) lands in every place at once and the `nodeColors()` lookup // lives in exactly one spot. // ============================================================ export function NodeIconChip({ type, size = 24, iconSize = 14, className, }: { type: NodeType; /** Chip side length in px. */ size?: number; /** Glyph side length in px. */ iconSize?: number; className?: string; }) { const meta = NODE_META[type]; const c = nodeColors(type); const Icon = meta.icon; return ( ); } // ============================================================ // Pure editing helpers — used by forms in both views. // ============================================================ /** * Coerce an arbitrary string into a stable identifier (node_key, * reply_id, etc.). Lowercases, collapses non-alphanumerics into * single underscores, and trims leading/trailing underscores. Falls * back to `fallback` for inputs that reduce to an empty string. */ export function slugify(s: string, fallback: string): string { const cleaned = s .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, '_') .replace(/^_+|_+$/g, ''); return cleaned || fallback; } // ============================================================ // Summary helpers — short, single-line content previews used in // collapsed node cards (list view) and node tiles (canvas view). // Returns null when there's nothing meaningful to show (start/end, // or a freshly-added node with no fields filled in). // ============================================================ export 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) + '…'; } export function summarizeNode( node: BuilderNode, t?: (key: string, values?: Record) => string ): 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)} · ${t ? t('options', { count: rowCount }) : `${rowCount} option${rowCount === 1 ? '' : 's'}`}` : truncate(text); } return rowCount > 0 ? t ? t('optionsAcrossSections', { rowCount, sectionCount: sections.length }) : `${rowCount} option${rowCount === 1 ? '' : 's'} across ${sections.length} section${sections.length === 1 ? '' : 's'}` : null; } case 'send_media': { const mediaType = typeof cfg.media_type === 'string' ? cfg.media_type : ''; const filename = typeof cfg.filename === 'string' ? cfg.filename : ''; const url = typeof cfg.media_url === 'string' ? cfg.media_url : ''; const caption = typeof cfg.caption === 'string' ? cfg.caption : ''; const label = mediaType ? t ? t(mediaType) || (mediaType.charAt(0).toUpperCase() + mediaType.slice(1)) : mediaType.charAt(0).toUpperCase() + mediaType.slice(1) : t ? t('media') : 'Media'; if (!url) return t ? t('noFile', { label }) : `${label} (no file uploaded)`; const name = filename || url.split('/').pop() || 'file'; return caption ? `${label}: ${truncate(name, 30)} · ${truncate(caption, 40)}` : `${label}: ${truncate(name, 60)}`; } 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' ? t ? t('hasTag', { tag: truncate(subjectKey, 24) }) : `has tag ${truncate(subjectKey, 24)}` : `${subject}.${subjectKey}`; const op = cfg.operator === 'equals' ? '==' : cfg.operator === 'contains' ? t ? t('opContains') : 'contains' : cfg.operator === 'present' ? t ? t('opExists') : 'exists' : cfg.operator === 'absent' ? t ? t('opMissing') : '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' ? (t ? t('modeRemove') : 'Remove') : (t ? t('modeAdd') : '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 ? t ? t('tagPicked', { mode, tag: tagId.slice(0, 8) }) : `${mode} tag ${tagId.slice(0, 8)}…` : t ? t('tagNone', { mode }) : `${mode} tag (none picked)`; } case 'handoff': { const note = typeof cfg.note === 'string' ? cfg.note : ''; return note.length > 0 ? truncate(note) : null; } } }