"use client"; /** * Per-node configuration form, dispatched by node_type. * * One component, ten branches. Each branch renders the inputs that * map onto the node's `config` JSONB shape (text + buttons for * send_buttons, prompt + var_key for collect_input, etc.) and forwards * edits up via `onUpdateConfig`. * * Why this lives in src/components/flows/forms/ instead of next to * the list editor: PR 2 (canvas editing) needs to mount the same * form in a side panel when a user clicks a node on the canvas. * Keeping the per-node forms here means there's exactly one place * where each form's behaviour and validation lives — drift between * "what the list editor shows" and "what the canvas side panel * shows" becomes impossible. * * `showAdvanced` is the disclosure that surfaces internal * identifiers (node_key, button reply_id, list row reply_id) — owned * by the host (NodeCard / SideSheet) so the toggle is rendered * outside this form alongside whatever delete/cancel buttons that * host wants. The form just reads the boolean and conditionally * renders the advanced rows. */ import { useCallback, useEffect, useRef, useState } from "react"; import { Loader2, Paperclip, Plus, Trash2, Upload, X, } from "lucide-react"; import { toast } from "sonner"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { cn } from "@/lib/utils"; import { uploadAccountMedia, MEDIA_MAX_BYTES } from "@/lib/storage/upload-media"; import { slugify, type BuilderNode } from "../shared"; import { NextNodeRow, NodeKeySelect, TextRow } from "./fields"; interface NodeConfigFormProps { node: BuilderNode; allNodes: BuilderNode[]; showAdvanced: boolean; onUpdateConfig: (patch: Record) => void; } export function NodeConfigForm({ node, allNodes, showAdvanced, onUpdateConfig, }: NodeConfigFormProps) { const t = useTranslations("Flows.builder.form"); const cfg = node.config; switch (node.node_type) { case "start": return ( onUpdateConfig({ next_node_key: v })} label={t("advancesTo")} /> ); case "send_message": return ( <> onUpdateConfig({ text: v })} /> onUpdateConfig({ next_node_key: v })} label={t("advancesTo")} /> ); case "send_buttons": return ( ); case "send_list": return ( ); case "send_media": return ( ); case "collect_input": return ( <> onUpdateConfig({ prompt_text: v })} rows={2} />
onUpdateConfig({ var_key: e.target.value.replace(/[^a-zA-Z0-9_]/g, ""), }) } placeholder={t("varKeyPlaceholder")} className="bg-muted font-mono text-xs" />

{t("varKeyHelp")}{" "} {"{{vars."} {(cfg as { var_key?: string }).var_key || "name"} {"}}"} .

onUpdateConfig({ next_node_key: v })} label={t("advanceAfterCapture")} /> ); case "condition": return ( ); case "set_tag": return ( ); case "handoff": return ( onUpdateConfig({ note: v })} rows={2} /> ); case "end": return (

{t("endNodeHelp")}

); } } // ============================================================ // send_buttons // ============================================================ interface SendButtonsCfg { text?: string; footer_text?: string; buttons?: Array<{ reply_id: string; title: string; next_node_key: string }>; } function SendButtonsForm({ cfg, allNodes, currentKey, onUpdateConfig, showAdvanced, t, }: { cfg: SendButtonsCfg; allNodes: BuilderNode[]; currentKey: string; onUpdateConfig: (patch: Record) => void; showAdvanced: boolean; t: ReturnType; }) { const buttons = cfg.buttons ?? []; const updateButton = ( idx: number, patch: Partial[number]>, ) => { onUpdateConfig({ buttons: buttons.map((b, i) => (i === idx ? { ...b, ...patch } : b)), }); }; const addButton = () => onUpdateConfig({ buttons: [ ...buttons, { reply_id: `btn_${buttons.length + 1}`, title: "Option", next_node_key: "", }, ], }); const removeButton = (idx: number) => onUpdateConfig({ buttons: buttons.filter((_, i) => i !== idx) }); return ( <> onUpdateConfig({ text: v })} rows={3} /> onUpdateConfig({ footer_text: v })} />
{buttons.map((b, i) => (
{showAdvanced && ( updateButton(i, { reply_id: slugify(e.target.value, `btn_${i + 1}`), }) } placeholder="reply_id" className="bg-muted font-mono text-xs" /> )} updateButton(i, { title: e.target.value })} placeholder={t("optionTitlePlaceholder")} className="bg-muted" maxLength={20} /> updateButton(i, { next_node_key: v ?? "" })} placeholder={t("nextNodePlaceholder")} />
))}
{buttons.length < 3 && ( )}
); } // ============================================================ // send_list // ============================================================ interface SendListCfg { text?: string; button_label?: string; footer_text?: string; sections?: Array<{ title?: string; rows: Array<{ reply_id: string; title: string; description?: string; next_node_key: string; }>; }>; } function SendListForm({ cfg, allNodes, currentKey, onUpdateConfig, showAdvanced, t, }: { cfg: SendListCfg; allNodes: BuilderNode[]; currentKey: string; onUpdateConfig: (patch: Record) => void; showAdvanced: boolean; t: ReturnType; }) { const sections = cfg.sections ?? []; const totalRows = sections.reduce((sum, s) => sum + s.rows.length, 0); const updateSection = ( sIdx: number, patch: Partial[number]>, ) => { onUpdateConfig({ sections: sections.map((s, i) => i === sIdx ? { ...s, ...patch } : s, ), }); }; 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, patch: Partial< NonNullable[number]["rows"][number] >, ) => { onUpdateConfig({ sections: sections.map((s, i) => i === sIdx ? { ...s, rows: s.rows.map((r, j) => (j === rIdx ? { ...r, ...patch } : r)), } : s, ), }); }; const addRow = (sIdx: number) => onUpdateConfig({ sections: sections.map((s, i) => i === sIdx ? { ...s, rows: [ ...s.rows, { reply_id: `row_${totalRows + 1}`, title: `Option ${totalRows + 1}`, next_node_key: "", }, ], } : s, ), }); const removeRow = (sIdx: number, rIdx: number) => onUpdateConfig({ sections: sections.map((s, i) => i === sIdx ? { ...s, rows: s.rows.filter((_, j) => j !== rIdx) } : s, ), }); return ( <> onUpdateConfig({ text: v })} rows={3} />
onUpdateConfig({ button_label: v })} /> onUpdateConfig({ footer_text: v })} />
{sections.map((section, sIdx) => (
updateSection(sIdx, { title: e.target.value }) } placeholder={t("sectionTitlePlaceholder", { count: sIdx + 1 })} className="bg-muted text-xs" /> {sections.length > 1 && ( )}
{section.rows.map((row, rIdx) => (
{showAdvanced && ( updateRow(sIdx, rIdx, { reply_id: slugify( e.target.value, `row_${rIdx + 1}`, ), }) } placeholder="reply_id" className="bg-muted font-mono text-xs" /> )} updateRow(sIdx, rIdx, { title: e.target.value }) } placeholder={t("rowTitlePlaceholder")} className="bg-muted" maxLength={24} /> updateRow(sIdx, rIdx, { next_node_key: v ?? "" }) } placeholder={t("nextNodePlaceholder")} />
))} {totalRows < 10 && ( )}
))} {/* 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 && ( )}
); } // ============================================================ // condition // ============================================================ interface ConditionCfg { subject?: "var" | "tag" | "contact_field"; subject_key?: string; operator?: "equals" | "contains" | "present" | "absent"; value?: string; true_next?: string; false_next?: string; } interface UserTag { id: string; name: string; color?: string; } function ConditionForm({ cfg, allNodes, currentKey, onUpdateConfig, t, }: { cfg: ConditionCfg; allNodes: BuilderNode[]; currentKey: string; onUpdateConfig: (patch: Record) => void; t: ReturnType; }) { const tags = useUserTags(); const subject = cfg.subject ?? "var"; const operator = cfg.operator ?? "equals"; const showValue = operator === "equals" || operator === "contains"; return ( <>
{subject === "tag" && tags.length > 0 ? ( ) : subject === "contact_field" ? ( ) : ( onUpdateConfig({ subject_key: e.target.value }) } placeholder={subject === "var" ? t("varKeyPlaceholder") : t("tagUuidPlaceholder")} className="bg-muted font-mono text-xs" /> )}
{showValue && (
onUpdateConfig({ value: e.target.value })} className="bg-muted" />
)}
onUpdateConfig({ true_next: v })} label={t("ifTrueAdvance")} /> onUpdateConfig({ false_next: v })} label={t("ifFalseAdvance")} />
); } // ============================================================ // set_tag // ============================================================ interface SetTagCfg { mode?: "add" | "remove"; tag_id?: string; next_node_key?: string; } function SetTagForm({ cfg, allNodes, currentKey, onUpdateConfig, t, }: { cfg: SetTagCfg; allNodes: BuilderNode[]; currentKey: string; onUpdateConfig: (patch: Record) => void; t: ReturnType; }) { const tags = useUserTags(); return ( <>
{tags.length > 0 ? ( ) : ( onUpdateConfig({ tag_id: e.target.value })} placeholder={t("tagUuidPlaceholder")} className="bg-muted font-mono text-xs" /> )}
onUpdateConfig({ next_node_key: v })} label={t("thenAdvanceTo")} /> ); } /** * Shared loader for both `condition` (subject=tag) and `set_tag`. * Falls back to raw UUID input if the endpoint is absent on older * deployments — the form remains authorable in that case. */ function useUserTags(): UserTag[] { const [tags, setTags] = useState([]); useEffect(() => { let cancelled = false; (async () => { try { const res = await fetch("/api/tags").catch(() => null); if (!res || !res.ok) return; const json = (await res.json()) as { tags?: UserTag[] }; if (!cancelled) setTags(json.tags ?? []); } catch { // Tags endpoint absent — caller falls back to raw input. } })(); return () => { cancelled = true; }; }, []); return tags; } // ============================================================ // send_media // ============================================================ interface SendMediaCfg { media_type?: "image" | "video" | "document"; media_url?: string; caption?: string; filename?: string; next_node_key?: string; } // Mirrors the bucket's allowed_mime_types from migration 016. Kept in // sync with the storage policy so the picker rejects unsupported files // before they hit the network rather than failing with a confusing // Supabase RLS / mime-type error. const MEDIA_ACCEPT: Record, string> = { image: "image/png,image/jpeg,image/webp", video: "video/mp4,video/3gpp", document: "application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,text/plain", }; const FLOW_MEDIA_BUCKET = "flow-media"; function SendMediaForm({ cfg, allNodes, currentKey, onUpdateConfig, t, }: { cfg: SendMediaCfg; allNodes: BuilderNode[]; currentKey: string; onUpdateConfig: (patch: Record) => void; t: ReturnType; }) { const fileInputRef = useRef(null); const [uploading, setUploading] = useState(false); const mediaType = cfg.media_type ?? "image"; const isDocument = mediaType === "document"; const displayName = cfg.filename || (cfg.media_url ? cfg.media_url.split("/").pop() ?? "" : ""); const handleFile = useCallback( async (file: File) => { if (file.size > MEDIA_MAX_BYTES) { toast.error( `File is ${(file.size / 1024 / 1024).toFixed(1)} MB — limit is 16 MB.`, ); return; } setUploading(true); try { // Account-scoped upload (path `account-/...`) — see // uploadAccountMedia + migration 020's flow-media RLS policy. const { publicUrl } = await uploadAccountMedia(FLOW_MEDIA_BUCKET, file); // Patch all fields in one call so the form doesn't re-render // with a half-uploaded state. onUpdateConfig({ media_url: publicUrl, filename: file.name, }); toast.success("File uploaded."); } catch (err) { const msg = err instanceof Error ? err.message : "Upload failed."; toast.error(msg); } finally { setUploading(false); } }, [onUpdateConfig], ); const handleClear = () => { onUpdateConfig({ media_url: "", filename: "" }); }; return ( <>
{cfg.media_url ? ( ) : ( )} { const f = e.target.files?.[0]; if (f) void handleFile(f); // Reset so picking the same file twice still fires onChange. e.target.value = ""; }} />
onUpdateConfig({ caption: v })} rows={2} /> {isDocument && (
onUpdateConfig({ filename: e.target.value })} placeholder={t("filenamePlaceholder")} className="bg-muted text-xs" />
)} onUpdateConfig({ next_node_key: v })} label={t("advanceAfterSending")} /> ); }