"use client"; import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { Workflow, Plus, Trash2, Pencil, Loader2, MessageSquare, PlayCircle, PauseCircle, Archive, HelpCircle, UserPlus, FileText, } from "lucide-react"; import { useTranslations } from "next-intl"; import { useCan } from "@/hooks/use-can"; import { Button } from "@/components/ui/button"; import { GatedButton } from "@/components/ui/gated-button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; /** * Flows list page. * * Open to every authenticated user. Flows is in soft-GA — the "Beta" * chip in the header is the only remaining signal that the surface * is new. The previous per-account beta gate was removed in PR #134. */ interface FlowRow { id: string; name: string; description: string | null; status: "draft" | "active" | "archived"; trigger_type: "keyword" | "first_inbound_message" | "manual"; trigger_config: { keywords?: string[] } | Record; execution_count: number; last_executed_at: string | null; created_at: string; updated_at: string; } const STATUS_LABELS = (t: ReturnType): Record => ({ draft: t("statusDraft"), active: t("statusActive"), archived: t("statusArchived"), }); const STATUS_COLORS: Record = { draft: "border-border bg-muted text-muted-foreground", active: "border-emerald-600/40 bg-emerald-500/10 text-emerald-300", archived: "border-border bg-muted/50 text-muted-foreground", }; interface TemplateSummary { slug: string; name: string; description: string; icon: "MessageSquare" | "HelpCircle" | "UserPlus"; trigger_type: string; node_count: number; } const TEMPLATE_ICONS = { MessageSquare, HelpCircle, UserPlus, } as const; export default function FlowsPage() { const router = useRouter(); const canCreate = useCan("send-messages"); const t = useTranslations("Flows.list"); const [flows, setFlows] = useState([]); const [loading, setLoading] = useState(true); const [createOpen, setCreateOpen] = useState(false); const [newName, setNewName] = useState(""); const [creating, setCreating] = useState(false); const [templates, setTemplates] = useState([]); useEffect(() => { let cancelled = false; (async () => { try { const [flowsRes, tmplRes] = await Promise.all([ fetch("/api/flows"), fetch("/api/flows/templates"), ]); if (!flowsRes.ok) { throw new Error(`Failed to load flows: ${flowsRes.status}`); } const flowsJson = (await flowsRes.json()) as { flows: FlowRow[] }; if (!cancelled) setFlows(flowsJson.flows ?? []); // Templates endpoint is forward-looking — if it 404s on an // older deployment, gracefully fall through. if (tmplRes.ok) { const tmplJson = (await tmplRes.json()) as { templates: TemplateSummary[]; }; if (!cancelled) setTemplates(tmplJson.templates ?? []); } } catch (err) { if (!cancelled) { console.error(err); toast.error(t("loadError")); } } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; }, []); async function handleCreate() { if (!newName.trim()) return; setCreating(true); try { const res = await fetch("/api/flows", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newName.trim(), trigger_type: "keyword", trigger_config: { keywords: [] }, }), }); if (!res.ok) throw new Error(`Create failed: ${res.status}`); const json = (await res.json()) as { flow: FlowRow }; setCreateOpen(false); setNewName(""); router.push(`/flows/${json.flow.id}`); } catch (err) { console.error(err); toast.error(t("createError")); } finally { setCreating(false); } } async function handleUseTemplate(slug: string) { setCreating(true); try { const res = await fetch("/api/flows", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ template_slug: slug }), }); if (!res.ok) { const json = await res.json().catch(() => ({})); throw new Error(json.error ?? `Clone failed: ${res.status}`); } const json = (await res.json()) as { flow: FlowRow }; setCreateOpen(false); router.push(`/flows/${json.flow.id}`); } catch (err) { const msg = err instanceof Error ? err.message : t("cloneError"); toast.error(msg); } finally { setCreating(false); } } async function handleDelete(flow: FlowRow) { const yes = window.confirm(t("deleteConfirm", { name: flow.name })); if (!yes) return; try { const res = await fetch(`/api/flows/${flow.id}`, { method: "DELETE" }); if (!res.ok) throw new Error(`Delete failed: ${res.status}`); setFlows((prev) => prev.filter((f) => f.id !== flow.id)); toast.success(t("deleteSuccess")); } catch (err) { console.error(err); toast.error(t("deleteError")); } } if (loading) { return (
); } return (

{t("title")}

{t("beta")}

{t("description")}

setCreateOpen(true)} > {t("newFlow")}
{flows.length === 0 ? ( setCreateOpen(true)} canCreate={canCreate} t={t} /> ) : (
{flows.map((flow) => ( router.push(`/flows/${flow.id}`)} onDelete={() => handleDelete(flow)} t={t} /> ))}
)} {/* `sm:max-w-4xl` not `max-w-4xl` — shadcn's DialogContent has `sm:max-w-sm` baked into its default classes. Without the sm: prefix our override applies at base only and the sm-scoped 384px wins at every real desktop breakpoint. */} {t("createTitle")} {t("createDesc")} {templates.length > 0 && (

{t("startTemplate")}

{templates.map((template) => { const Icon = TEMPLATE_ICONS[template.icon] ?? FileText; return ( ); })}
)}

{t("startBlank")}

setNewName(e.target.value)} placeholder={t("placeholderName")} className="bg-muted" onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }} />
); } function EmptyState({ onCreate, canCreate, t, }: { onCreate: () => void; canCreate: boolean; t: ReturnType; }) { return (

{t("emptyTitle")}

{t("emptyDesc")}

{t("createFirst")}
); } function FlowCard({ flow, onEdit, onDelete, t, }: { flow: FlowRow; onEdit: () => void; onDelete: () => void; t: ReturnType; }) { const triggerSummary = describeTrigger(flow, t); const StatusIcon = flow.status === "active" ? PlayCircle : flow.status === "archived" ? Archive : PauseCircle; return (

{flow.name}

{STATUS_LABELS(t)[flow.status]}

{flow.description || triggerSummary}

{t("runCount", { count: flow.execution_count })}
); } function describeTrigger(flow: FlowRow, t: ReturnType): string { if (flow.trigger_type === "keyword") { const keywords = Array.isArray(flow.trigger_config.keywords) ? (flow.trigger_config.keywords as string[]) : []; if (keywords.length === 0) return t("triggerKeywordNone"); return t("triggerKeyword", { keywords: keywords.join(", ") }); } if (flow.trigger_type === "first_inbound_message") { return t("triggerFirstInbound"); } return t("triggerManual"); }