|
@@ -0,0 +1,945 @@
|
|
|
|
|
+/**
|
|
|
|
|
+ * Workflow Designer — Tidio-inspired flow builder with categories & sub-categories
|
|
|
|
|
+ * Categories: Support Flows, Leads Flows, Sales Flows, Others
|
|
|
|
|
+ * Sub-categories: Deflect, Self Service, Orders, Shipping, Returning, Cancelling
|
|
|
|
|
+ */
|
|
|
|
|
+import { useState, useRef, useCallback, useEffect, useMemo } from "react";
|
|
|
|
|
+import { useAuth } from "@/_core/hooks/useAuth";
|
|
|
|
|
+import { trpc } from "@/lib/trpc";
|
|
|
|
|
+import { Button } from "@/components/ui/button";
|
|
|
|
|
+import { toast } from "sonner";
|
|
|
|
|
+import {
|
|
|
|
|
+ GitBranch, Zap, Save, Trash2, X, Square,
|
|
|
|
|
+ MessageCircle, Bot, Users, HelpCircle,
|
|
|
|
|
+ Database, ShoppingCart, ShieldAlert, Sparkles,
|
|
|
|
|
+ Check, Clock, XCircle, RefreshCw, ChevronDown, ChevronUp,
|
|
|
|
|
+ UserCheck, FileText, AlertTriangle, Plus,
|
|
|
|
|
+ Headphones, TrendingUp, Megaphone, MoreHorizontal,
|
|
|
|
|
+ Package, Truck, RotateCw, ArrowRight, Layers,
|
|
|
|
|
+ Play, Eye, Copy, ChevronRight, FolderOpen,
|
|
|
|
|
+ Shield, Filter, Search, Settings,
|
|
|
|
|
+} from "lucide-react";
|
|
|
|
|
+
|
|
|
|
|
+/* ─── Types ─── */
|
|
|
|
|
+type NodeType = "greeting" | "intent" | "response" | "condition" | "escalation" | "action" | "end" | "customer_data" | "sales_order" | "guardrail";
|
|
|
|
|
+
|
|
|
|
|
+interface FlowNode {
|
|
|
|
|
+ id: string;
|
|
|
|
|
+ type: NodeType;
|
|
|
|
|
+ label: string;
|
|
|
|
|
+ config: Record<string, any>;
|
|
|
|
|
+ x: number;
|
|
|
|
|
+ y: number;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface FlowEdge {
|
|
|
|
|
+ id: string;
|
|
|
|
|
+ sourceId: string;
|
|
|
|
|
+ targetId: string;
|
|
|
|
|
+ label?: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ─── Flow Category & Template Types ─── */
|
|
|
|
|
+interface FlowSubCategory {
|
|
|
|
|
+ id: string;
|
|
|
|
|
+ label: string;
|
|
|
|
|
+ icon: React.ReactNode;
|
|
|
|
|
+ color: string;
|
|
|
|
|
+ description: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface FlowCategory {
|
|
|
|
|
+ id: string;
|
|
|
|
|
+ label: string;
|
|
|
|
|
+ icon: React.ReactNode;
|
|
|
|
|
+ color: string;
|
|
|
|
|
+ description: string;
|
|
|
|
|
+ subCategories: FlowSubCategory[];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface FlowTemplate {
|
|
|
|
|
+ id: string;
|
|
|
|
|
+ name: string;
|
|
|
|
|
+ description: string;
|
|
|
|
|
+ category: string;
|
|
|
|
|
+ subCategory: string;
|
|
|
|
|
+ tags: string[];
|
|
|
|
|
+ nodes: FlowNode[];
|
|
|
|
|
+ edges: FlowEdge[];
|
|
|
|
|
+ isActive: boolean;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ─── Node type config ─── */
|
|
|
|
|
+const NODE_TYPES: Record<NodeType, { icon: React.ReactNode; color: string; bgColor: string; description: string }> = {
|
|
|
|
|
+ greeting: { icon: <MessageCircle className="w-4 h-4" />, color: "#14532D", bgColor: "#14532D14", description: "Welcome message with quick-reply buttons" },
|
|
|
|
|
+ intent: { icon: <HelpCircle className="w-4 h-4" />, color: "#0369a1", bgColor: "#0369a114", description: "Detect user intent from message" },
|
|
|
|
|
+ response: { icon: <Bot className="w-4 h-4" />, color: "#7c3aed", bgColor: "#7c3aed14", description: "Send a response to the user" },
|
|
|
|
|
+ condition: { icon: <GitBranch className="w-4 h-4" />, color: "#ca8a04", bgColor: "#ca8a0414", description: "Branch based on a condition" },
|
|
|
|
|
+ escalation: { icon: <Users className="w-4 h-4" />, color: "#C2410C", bgColor: "#C2410C14", description: "Transfer to a human agent" },
|
|
|
|
|
+ action: { icon: <Zap className="w-4 h-4" />, color: "#059669", bgColor: "#05966914", description: "Perform an action (API call)" },
|
|
|
|
|
+ end: { icon: <Square className="w-4 h-4" />, color: "#78716C", bgColor: "#78716C14", description: "End the conversation flow" },
|
|
|
|
|
+ customer_data: { icon: <Database className="w-4 h-4" />, color: "#7c3aed", bgColor: "#7c3aed14", description: "Look up customer data from CRM" },
|
|
|
|
|
+ sales_order: { icon: <ShoppingCart className="w-4 h-4" />, color: "#0891b2", bgColor: "#0891b214", description: "Query sales order data" },
|
|
|
|
|
+ guardrail: { icon: <ShieldAlert className="w-4 h-4" />, color: "#dc2626", bgColor: "#dc262614", description: "Block sensitive topics" },
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+/* ─── Flow Categories (Tidio-inspired) ─── */
|
|
|
|
|
+const FLOW_CATEGORIES: FlowCategory[] = [
|
|
|
|
|
+ {
|
|
|
|
|
+ id: "support",
|
|
|
|
|
+ label: "Support Flows",
|
|
|
|
|
+ icon: <Headphones className="w-5 h-5" />,
|
|
|
|
|
+ color: "#14532D",
|
|
|
|
|
+ description: "Handle customer support inquiries and resolve issues",
|
|
|
|
|
+ subCategories: [
|
|
|
|
|
+ { id: "orders", label: "Orders", icon: <Package className="w-4 h-4" />, color: "#14532D", description: "Order status, tracking, modifications" },
|
|
|
|
|
+ { id: "shipping", label: "Shipping", icon: <Truck className="w-4 h-4" />, color: "#0369a1", description: "Shipping inquiries, delivery updates" },
|
|
|
|
|
+ { id: "returning", label: "Returning", icon: <RotateCw className="w-4 h-4" />, color: "#ca8a04", description: "Return requests, refund processing" },
|
|
|
|
|
+ { id: "cancelling", label: "Cancelling", icon: <XCircle className="w-4 h-4" />, color: "#dc2626", description: "Order cancellation requests" },
|
|
|
|
|
+ { id: "deflect", label: "Deflect", icon: <Shield className="w-4 h-4" />, color: "#7c3aed", description: "Deflect common questions with self-service" },
|
|
|
|
|
+ { id: "self_service", label: "Self Service", icon: <Search className="w-4 h-4" />, color: "#059669", description: "Enable customers to solve issues themselves" },
|
|
|
|
|
+ ],
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: "leads",
|
|
|
|
|
+ label: "Leads Flows",
|
|
|
|
|
+ icon: <TrendingUp className="w-5 h-5" />,
|
|
|
|
|
+ color: "#0369a1",
|
|
|
|
|
+ description: "Capture and qualify leads from website visitors",
|
|
|
|
|
+ subCategories: [
|
|
|
|
|
+ { id: "capture", label: "Lead Capture", icon: <UserCheck className="w-4 h-4" />, color: "#0369a1", description: "Collect visitor information" },
|
|
|
|
|
+ { id: "qualify", label: "Qualify", icon: <Filter className="w-4 h-4" />, color: "#7c3aed", description: "Qualify leads based on criteria" },
|
|
|
|
|
+ { id: "nurture", label: "Nurture", icon: <MessageCircle className="w-4 h-4" />, color: "#059669", description: "Engage and nurture leads" },
|
|
|
|
|
+ ],
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: "sales",
|
|
|
|
|
+ label: "Sales Flows",
|
|
|
|
|
+ icon: <Megaphone className="w-5 h-5" />,
|
|
|
|
|
+ color: "#ca8a04",
|
|
|
|
|
+ description: "Drive sales through guided product discovery",
|
|
|
|
|
+ subCategories: [
|
|
|
|
|
+ { id: "product_search", label: "Product Search", icon: <Search className="w-4 h-4" />, color: "#ca8a04", description: "Help customers find products" },
|
|
|
|
|
+ { id: "recommendations", label: "Recommendations", icon: <Sparkles className="w-4 h-4" />, color: "#7c3aed", description: "AI-powered product suggestions" },
|
|
|
|
|
+ { id: "dealer_locator", label: "Dealer Locator", icon: <Users className="w-4 h-4" />, color: "#14532D", description: "Find nearby dealers" },
|
|
|
|
|
+ ],
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: "others",
|
|
|
|
|
+ label: "Others",
|
|
|
|
|
+ icon: <MoreHorizontal className="w-5 h-5" />,
|
|
|
|
|
+ color: "#78716C",
|
|
|
|
|
+ description: "Custom flows and miscellaneous automations",
|
|
|
|
|
+ subCategories: [
|
|
|
|
|
+ { id: "faq", label: "FAQ", icon: <HelpCircle className="w-4 h-4" />, color: "#78716C", description: "Frequently asked questions" },
|
|
|
|
|
+ { id: "feedback", label: "Feedback", icon: <FileText className="w-4 h-4" />, color: "#0891b2", description: "Collect customer feedback" },
|
|
|
|
|
+ { id: "custom", label: "Custom", icon: <Settings className="w-4 h-4" />, color: "#a8a29e", description: "Build your own flow" },
|
|
|
|
|
+ ],
|
|
|
|
|
+ },
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+/* ─── Pre-built Flow Templates ─── */
|
|
|
|
|
+const FLOW_TEMPLATES: FlowTemplate[] = [
|
|
|
|
|
+ {
|
|
|
|
|
+ id: "order_status",
|
|
|
|
|
+ name: "Check Order Status",
|
|
|
|
|
+ description: "Let customers check their order status by providing an order number",
|
|
|
|
|
+ category: "support",
|
|
|
|
|
+ subCategory: "orders",
|
|
|
|
|
+ tags: ["popular", "self-service"],
|
|
|
|
|
+ isActive: false,
|
|
|
|
|
+ nodes: [
|
|
|
|
|
+ { id: "g1", type: "greeting", label: "Greeting", config: { message: "👋 What can we help you with today?", quickReplies: ["Orders", "Shipping", "Returning", "Cancelling"], detectCustomerId: true, customerIdSource: "session" }, x: 300, y: 50 },
|
|
|
|
|
+ { id: "g2", type: "guardrail", label: "Content Filter", config: { blockedTopics: ["overall revenue", "profit margin", "internal pricing", "employee data"], blockedMessage: "I'm sorry, I can't share that information." }, x: 600, y: 50 },
|
|
|
|
|
+ { id: "i1", type: "intent", label: "Detect Intent", config: { intents: ["order_status", "track_order", "modify_order"] }, x: 300, y: 180 },
|
|
|
|
|
+ { id: "cd1", type: "customer_data", label: "Customer Lookup", config: { apiEndpoint: "/api/crm/customer", lookupField: "customerId", returnFields: ["name", "email", "accountType"] }, x: 100, y: 310 },
|
|
|
|
|
+ { id: "so1", type: "sales_order", label: "Order Lookup", config: { apiEndpoint: "/api/orders", lookupField: "orderId", returnFields: ["status", "estimatedDelivery", "trackingNumber"] }, x: 500, y: 310 },
|
|
|
|
|
+ { id: "r1", type: "response", label: "Order Status Reply", config: { message: "Your order {{orderId}} is currently {{status}}. Estimated delivery: {{estimatedDelivery}}" }, x: 300, y: 440 },
|
|
|
|
|
+ { id: "e1", type: "end", label: "End", config: {}, x: 300, y: 560 },
|
|
|
|
|
+ ],
|
|
|
|
|
+ edges: [
|
|
|
|
|
+ { id: "e_g1_i1", sourceId: "g1", targetId: "i1" },
|
|
|
|
|
+ { id: "e_i1_cd1", sourceId: "i1", targetId: "cd1", label: "order_status" },
|
|
|
|
|
+ { id: "e_i1_so1", sourceId: "i1", targetId: "so1", label: "track_order" },
|
|
|
|
|
+ { id: "e_cd1_r1", sourceId: "cd1", targetId: "r1" },
|
|
|
|
|
+ { id: "e_so1_r1", sourceId: "so1", targetId: "r1" },
|
|
|
|
|
+ { id: "e_r1_e1", sourceId: "r1", targetId: "e1" },
|
|
|
|
|
+ ],
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: "shipping_tracking",
|
|
|
|
|
+ name: "Track Shipment",
|
|
|
|
|
+ description: "Help customers track their shipment with real-time updates",
|
|
|
|
|
+ category: "support",
|
|
|
|
|
+ subCategory: "shipping",
|
|
|
|
|
+ tags: ["popular"],
|
|
|
|
|
+ isActive: false,
|
|
|
|
|
+ nodes: [
|
|
|
|
|
+ { id: "g1", type: "greeting", label: "Greeting", config: { message: "👋 What can we help you with today?", quickReplies: ["Orders", "Shipping", "Returning", "Cancelling"], detectCustomerId: true, customerIdSource: "session" }, x: 300, y: 50 },
|
|
|
|
|
+ { id: "r1", type: "response", label: "Ask Tracking #", config: { message: "Please provide your tracking number and I'll look up the latest status." }, x: 300, y: 180 },
|
|
|
|
|
+ { id: "a1", type: "action", label: "Track Shipment API", config: { apiEndpoint: "/api/shipping/track", method: "GET" }, x: 300, y: 310 },
|
|
|
|
|
+ { id: "r2", type: "response", label: "Tracking Result", config: { message: "Your package is currently {{currentLocation}}. Estimated arrival: {{estimatedArrival}}" }, x: 300, y: 440 },
|
|
|
|
|
+ { id: "e1", type: "end", label: "End", config: {}, x: 300, y: 560 },
|
|
|
|
|
+ ],
|
|
|
|
|
+ edges: [
|
|
|
|
|
+ { id: "e1", sourceId: "g1", targetId: "r1" },
|
|
|
|
|
+ { id: "e2", sourceId: "r1", targetId: "a1" },
|
|
|
|
|
+ { id: "e3", sourceId: "a1", targetId: "r2" },
|
|
|
|
|
+ { id: "e4", sourceId: "r2", targetId: "e1" },
|
|
|
|
|
+ ],
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: "return_request",
|
|
|
|
|
+ name: "Submit Return Request",
|
|
|
|
|
+ description: "Guide customers through the return process step by step",
|
|
|
|
|
+ category: "support",
|
|
|
|
|
+ subCategory: "returning",
|
|
|
|
|
+ tags: ["self-service"],
|
|
|
|
|
+ isActive: false,
|
|
|
|
|
+ nodes: [
|
|
|
|
|
+ { id: "g1", type: "greeting", label: "Greeting", config: { message: "👋 What can we help you with today?", quickReplies: ["Orders", "Shipping", "Returning", "Cancelling"], detectCustomerId: true, customerIdSource: "session" }, x: 300, y: 50 },
|
|
|
|
|
+ { id: "r1", type: "response", label: "Ask Order #", config: { message: "I'll help you with your return. Please provide your order number." }, x: 300, y: 180 },
|
|
|
|
|
+ { id: "cd1", type: "customer_data", label: "Verify Customer", config: { apiEndpoint: "/api/crm/customer", lookupField: "customerId" }, x: 100, y: 310 },
|
|
|
|
|
+ { id: "c1", type: "condition", label: "Return Eligible?", config: { condition: "order.daysOld <= 30 && order.status === 'delivered'" }, x: 300, y: 310 },
|
|
|
|
|
+ { id: "a1", type: "action", label: "Create Return", config: { apiEndpoint: "/api/returns", method: "POST" }, x: 150, y: 440 },
|
|
|
|
|
+ { id: "r2", type: "response", label: "Not Eligible", config: { message: "Unfortunately, this order is not eligible for return. Would you like to speak with an agent?" }, x: 450, y: 440 },
|
|
|
|
|
+ { id: "esc1", type: "escalation", label: "Transfer to Agent", config: { priority: "normal", department: "returns" }, x: 450, y: 560 },
|
|
|
|
|
+ { id: "e1", type: "end", label: "End", config: {}, x: 300, y: 560 },
|
|
|
|
|
+ ],
|
|
|
|
|
+ edges: [
|
|
|
|
|
+ { id: "e1", sourceId: "g1", targetId: "r1" },
|
|
|
|
|
+ { id: "e2", sourceId: "r1", targetId: "c1" },
|
|
|
|
|
+ { id: "e3", sourceId: "c1", targetId: "a1", label: "eligible" },
|
|
|
|
|
+ { id: "e4", sourceId: "c1", targetId: "r2", label: "not eligible" },
|
|
|
|
|
+ { id: "e5", sourceId: "r2", targetId: "esc1" },
|
|
|
|
|
+ { id: "e6", sourceId: "a1", targetId: "e1" },
|
|
|
|
|
+ ],
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: "cancel_order",
|
|
|
|
|
+ name: "Cancel Order",
|
|
|
|
|
+ description: "Process order cancellation requests with eligibility checks",
|
|
|
|
|
+ category: "support",
|
|
|
|
|
+ subCategory: "cancelling",
|
|
|
|
|
+ tags: [],
|
|
|
|
|
+ isActive: false,
|
|
|
|
|
+ nodes: [
|
|
|
|
|
+ { id: "g1", type: "greeting", label: "Greeting", config: { message: "👋 What can we help you with today?", quickReplies: ["Orders", "Shipping", "Returning", "Cancelling"], detectCustomerId: true, customerIdSource: "session" }, x: 300, y: 50 },
|
|
|
|
|
+ { id: "r1", type: "response", label: "Ask Order #", config: { message: "I can help you cancel an order. Please provide the order number." }, x: 300, y: 180 },
|
|
|
|
|
+ { id: "so1", type: "sales_order", label: "Check Order", config: { apiEndpoint: "/api/orders", lookupField: "orderId" }, x: 300, y: 310 },
|
|
|
|
|
+ { id: "c1", type: "condition", label: "Can Cancel?", config: { condition: "order.status === 'pending' || order.status === 'processing'" }, x: 300, y: 440 },
|
|
|
|
|
+ { id: "a1", type: "action", label: "Cancel Order", config: { apiEndpoint: "/api/orders/cancel", method: "POST" }, x: 150, y: 560 },
|
|
|
|
|
+ { id: "r2", type: "response", label: "Cannot Cancel", config: { message: "This order has already shipped and cannot be cancelled. Would you like to initiate a return instead?" }, x: 450, y: 560 },
|
|
|
|
|
+ { id: "e1", type: "end", label: "End", config: {}, x: 300, y: 680 },
|
|
|
|
|
+ ],
|
|
|
|
|
+ edges: [
|
|
|
|
|
+ { id: "e1", sourceId: "g1", targetId: "r1" },
|
|
|
|
|
+ { id: "e2", sourceId: "r1", targetId: "so1" },
|
|
|
|
|
+ { id: "e3", sourceId: "so1", targetId: "c1" },
|
|
|
|
|
+ { id: "e4", sourceId: "c1", targetId: "a1", label: "yes" },
|
|
|
|
|
+ { id: "e5", sourceId: "c1", targetId: "r2", label: "no" },
|
|
|
|
|
+ { id: "e6", sourceId: "a1", targetId: "e1" },
|
|
|
|
|
+ ],
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: "deflect_faq",
|
|
|
|
|
+ name: "FAQ Deflection",
|
|
|
|
|
+ description: "Answer common questions automatically to reduce agent workload",
|
|
|
|
|
+ category: "support",
|
|
|
|
|
+ subCategory: "deflect",
|
|
|
|
|
+ tags: ["popular"],
|
|
|
|
|
+ isActive: false,
|
|
|
|
|
+ nodes: [
|
|
|
|
|
+ { id: "g1", type: "greeting", label: "Greeting", config: { message: "👋 What can we help you with today?", quickReplies: ["Orders", "Shipping", "Returning", "Cancelling"], detectCustomerId: true, customerIdSource: "session" }, x: 300, y: 50 },
|
|
|
|
|
+ { id: "gr1", type: "guardrail", label: "Content Filter", config: { blockedTopics: ["overall revenue", "profit margin", "internal pricing"] }, x: 600, y: 50 },
|
|
|
|
|
+ { id: "i1", type: "intent", label: "Detect FAQ", config: { intents: ["warranty_info", "delivery_time", "payment_methods", "store_hours"] }, x: 300, y: 180 },
|
|
|
|
|
+ { id: "r1", type: "response", label: "FAQ Answer", config: { message: "Here's the information you requested: {{faqAnswer}}" }, x: 300, y: 310 },
|
|
|
|
|
+ { id: "r2", type: "response", label: "Ask Satisfaction", config: { message: "Did that answer your question?" }, x: 300, y: 440 },
|
|
|
|
|
+ { id: "e1", type: "end", label: "End", config: {}, x: 300, y: 560 },
|
|
|
|
|
+ ],
|
|
|
|
|
+ edges: [
|
|
|
|
|
+ { id: "e1", sourceId: "g1", targetId: "i1" },
|
|
|
|
|
+ { id: "e2", sourceId: "i1", targetId: "r1" },
|
|
|
|
|
+ { id: "e3", sourceId: "r1", targetId: "r2" },
|
|
|
|
|
+ { id: "e4", sourceId: "r2", targetId: "e1" },
|
|
|
|
|
+ ],
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: "lead_capture",
|
|
|
|
|
+ name: "Lead Capture",
|
|
|
|
|
+ description: "Capture visitor information for follow-up by sales team",
|
|
|
|
|
+ category: "leads",
|
|
|
|
|
+ subCategory: "capture",
|
|
|
|
|
+ tags: [],
|
|
|
|
|
+ isActive: false,
|
|
|
|
|
+ nodes: [
|
|
|
|
|
+ { id: "g1", type: "greeting", label: "Welcome", config: { message: "Welcome to Homelegance! Are you a dealer or looking for furniture for your home?" }, x: 300, y: 50 },
|
|
|
|
|
+ { id: "r1", type: "response", label: "Ask Contact", config: { message: "I'd love to help! Could you share your name and email so our team can follow up?" }, x: 300, y: 180 },
|
|
|
|
|
+ { id: "a1", type: "action", label: "Save Lead", config: { apiEndpoint: "/api/leads", method: "POST" }, x: 300, y: 310 },
|
|
|
|
|
+ { id: "r2", type: "response", label: "Confirm", config: { message: "Thank you! A member of our team will reach out shortly." }, x: 300, y: 440 },
|
|
|
|
|
+ { id: "e1", type: "end", label: "End", config: {}, x: 300, y: 560 },
|
|
|
|
|
+ ],
|
|
|
|
|
+ edges: [
|
|
|
|
|
+ { id: "e1", sourceId: "g1", targetId: "r1" },
|
|
|
|
|
+ { id: "e2", sourceId: "r1", targetId: "a1" },
|
|
|
|
|
+ { id: "e3", sourceId: "a1", targetId: "r2" },
|
|
|
|
|
+ { id: "e4", sourceId: "r2", targetId: "e1" },
|
|
|
|
|
+ ],
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: "product_finder",
|
|
|
|
|
+ name: "Product Finder",
|
|
|
|
|
+ description: "Guide customers to find the right furniture with smart questions",
|
|
|
|
|
+ category: "sales",
|
|
|
|
|
+ subCategory: "product_search",
|
|
|
|
|
+ tags: ["popular"],
|
|
|
|
|
+ isActive: false,
|
|
|
|
|
+ nodes: [
|
|
|
|
|
+ { id: "g1", type: "greeting", label: "Welcome", config: { message: "Looking for furniture? I can help you find the perfect piece!" }, x: 300, y: 50 },
|
|
|
|
|
+ { id: "r1", type: "response", label: "Ask Category", config: { message: "What type of furniture are you looking for? (Bedroom, Living Room, Dining, etc.)" }, x: 300, y: 180 },
|
|
|
|
|
+ { id: "r2", type: "response", label: "Ask Style", config: { message: "What style do you prefer? (Modern, Traditional, Transitional)" }, x: 300, y: 310 },
|
|
|
|
|
+ { id: "a1", type: "action", label: "Search Products", config: { apiEndpoint: "/api/products/search", method: "POST" }, x: 300, y: 440 },
|
|
|
|
|
+ { id: "r3", type: "response", label: "Show Results", config: { message: "Here are some options that match your preferences:" }, x: 300, y: 560 },
|
|
|
|
|
+ { id: "e1", type: "end", label: "End", config: {}, x: 300, y: 680 },
|
|
|
|
|
+ ],
|
|
|
|
|
+ edges: [
|
|
|
|
|
+ { id: "e1", sourceId: "g1", targetId: "r1" },
|
|
|
|
|
+ { id: "e2", sourceId: "r1", targetId: "r2" },
|
|
|
|
|
+ { id: "e3", sourceId: "r2", targetId: "a1" },
|
|
|
|
|
+ { id: "e4", sourceId: "a1", targetId: "r3" },
|
|
|
|
|
+ { id: "e5", sourceId: "r3", targetId: "e1" },
|
|
|
|
|
+ ],
|
|
|
|
|
+ },
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+/* ─── View modes ─── */
|
|
|
|
|
+type ViewMode = "gallery" | "editor";
|
|
|
|
|
+
|
|
|
|
|
+/* ─── Flow Node Component ─── */
|
|
|
|
|
+function FlowNodeCard({
|
|
|
|
|
+ node, isSelected, onSelect, onDragStart,
|
|
|
|
|
+}: {
|
|
|
|
|
+ node: FlowNode; isSelected: boolean;
|
|
|
|
|
+ onSelect: () => void; onDragStart: (e: React.MouseEvent) => void;
|
|
|
|
|
+}) {
|
|
|
|
|
+ const typeConfig = NODE_TYPES[node.type];
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div
|
|
|
|
|
+ className="absolute cursor-move select-none"
|
|
|
|
|
+ style={{ left: node.x, top: node.y, zIndex: isSelected ? 20 : 10 }}
|
|
|
|
|
+ onMouseDown={(e) => { e.stopPropagation(); onDragStart(e); onSelect(); }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <div
|
|
|
|
|
+ className="w-44 rounded-xl border-2 shadow-sm transition-shadow hover:shadow-md overflow-hidden"
|
|
|
|
|
+ style={{
|
|
|
|
|
+ borderColor: isSelected ? typeConfig.color : "#e7e0d5",
|
|
|
|
|
+ background: "#fff",
|
|
|
|
|
+ boxShadow: isSelected ? `0 0 0 2px ${typeConfig.color}30` : undefined,
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <div className="px-3 py-2 flex items-center gap-2" style={{ background: typeConfig.bgColor }}>
|
|
|
|
|
+ <div style={{ color: typeConfig.color }}>{typeConfig.icon}</div>
|
|
|
|
|
+ <span className="text-[11px] font-bold truncate" style={{ color: typeConfig.color }}>{node.label}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="px-3 py-1.5">
|
|
|
|
|
+ <span className="text-[10px] capitalize" style={{ color: "#a8a29e" }}>{node.type.replace("_", " ")}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {/* Connection dots */}
|
|
|
|
|
+ <div className="absolute -top-1.5 left-1/2 -translate-x-1/2 w-3 h-3 rounded-full border-2 bg-white" style={{ borderColor: typeConfig.color }} />
|
|
|
|
|
+ <div className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 w-3 h-3 rounded-full border-2 bg-white" style={{ borderColor: typeConfig.color }} />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ─── Edge SVG ─── */
|
|
|
|
|
+function FlowEdgeLine({ edge, nodes }: { edge: FlowEdge; nodes: FlowNode[] }) {
|
|
|
|
|
+ const source = nodes.find(n => n.id === edge.sourceId);
|
|
|
|
|
+ const target = nodes.find(n => n.id === edge.targetId);
|
|
|
|
|
+ if (!source || !target) return null;
|
|
|
|
|
+
|
|
|
|
|
+ const x1 = source.x + 88;
|
|
|
|
|
+ const y1 = source.y + 58;
|
|
|
|
|
+ const x2 = target.x + 88;
|
|
|
|
|
+ const y2 = target.y;
|
|
|
|
|
+ const midY = (y1 + y2) / 2;
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <g>
|
|
|
|
|
+ <path
|
|
|
|
|
+ d={`M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`}
|
|
|
|
|
+ fill="none"
|
|
|
|
|
+ stroke="#d6d3d1"
|
|
|
|
|
+ strokeWidth={2}
|
|
|
|
|
+ />
|
|
|
|
|
+ {edge.label && (
|
|
|
|
|
+ <text
|
|
|
|
|
+ x={(x1 + x2) / 2}
|
|
|
|
|
+ y={midY - 6}
|
|
|
|
|
+ textAnchor="middle"
|
|
|
|
|
+ className="text-[9px] fill-stone-400"
|
|
|
|
|
+ style={{ fontFamily: "'Source Sans 3', sans-serif" }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {edge.label}
|
|
|
|
|
+ </text>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </g>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ─── AI Suggestions Panel ─── */
|
|
|
|
|
+function AISuggestionsPanel() {
|
|
|
|
|
+ const { data: suggestions, isLoading } = trpc.workflow.getSuggestions.useQuery({ workflowId: "default" });
|
|
|
|
|
+ const generateMut = trpc.workflow.generateSuggestions.useMutation({
|
|
|
|
|
+ onSuccess: () => toast.success("Suggestions generated"),
|
|
|
|
|
+ onError: (err) => toast.error(err.message),
|
|
|
|
|
+ });
|
|
|
|
|
+ const reviewMut = trpc.workflow.reviewSuggestion.useMutation({
|
|
|
|
|
+ onSuccess: () => toast.success("Suggestion updated"),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const pending = suggestions?.filter((s: any) => s.status === "pending" || s.status === "waiting") || [];
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="p-3 space-y-2">
|
|
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
|
|
+ <span className="text-[10px] font-bold uppercase tracking-wider" style={{ color: "#78716C" }}>AI Suggestions</span>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => generateMut.mutate({ workflowId: "default" })}
|
|
|
|
|
+ disabled={generateMut.isPending}
|
|
|
|
|
+ className="text-[10px] font-medium px-2 py-0.5 rounded-md transition-colors"
|
|
|
|
|
+ style={{ color: "#7c3aed", background: "#7c3aed14" }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {generateMut.isPending ? "Analyzing..." : "Generate"}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {pending.length === 0 ? (
|
|
|
|
|
+ <p className="text-[10px] italic" style={{ color: "#d6d3d1" }}>No pending suggestions</p>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ pending.map((s: any) => (
|
|
|
|
|
+ <div key={s.id} className="p-2 rounded-lg border" style={{ borderColor: "#e7e0d5", background: "#fff" }}>
|
|
|
|
|
+ <div className="flex items-center gap-1.5">
|
|
|
|
|
+ <Sparkles className="w-3 h-3" style={{ color: "#7c3aed" }} />
|
|
|
|
|
+ <span className="text-[11px] font-medium truncate" style={{ color: "#292524" }}>{s.label}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <p className="text-[9px] mt-0.5" style={{ color: "#a8a29e" }}>{s.description}</p>
|
|
|
|
|
+ <div className="flex gap-1 mt-1.5">
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => reviewMut.mutate({ suggestionId: s.id, status: "approved" })}
|
|
|
|
|
+ className="flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[9px] font-medium"
|
|
|
|
|
+ style={{ background: "#14532D14", color: "#14532D" }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Check className="w-2.5 h-2.5" /> Approve
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => reviewMut.mutate({ suggestionId: s.id, status: "waiting" })}
|
|
|
|
|
+ className="flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[9px] font-medium"
|
|
|
|
|
+ style={{ background: "#ca8a0414", color: "#ca8a04" }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Clock className="w-2.5 h-2.5" /> Wait
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => reviewMut.mutate({ suggestionId: s.id, status: "declined" })}
|
|
|
|
|
+ className="flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[9px] font-medium"
|
|
|
|
|
+ style={{ background: "#dc262614", color: "#dc2626" }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <XCircle className="w-2.5 h-2.5" /> Decline
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ─── Main Component ─── */
|
|
|
|
|
+export default function WorkflowDesigner() {
|
|
|
|
|
+ const { user } = useAuth();
|
|
|
|
|
+ const [viewMode, setViewMode] = useState<ViewMode>("gallery");
|
|
|
|
|
+ const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
|
|
|
|
+ const [selectedSubCategory, setSelectedSubCategory] = useState<string | null>(null);
|
|
|
|
|
+ const [activeTemplate, setActiveTemplate] = useState<FlowTemplate | null>(null);
|
|
|
|
|
+
|
|
|
|
|
+ // Editor state
|
|
|
|
|
+ const [nodes, setNodes] = useState<FlowNode[]>([]);
|
|
|
|
|
+ const [edges, setEdges] = useState<FlowEdge[]>([]);
|
|
|
|
|
+ const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
|
|
|
|
+ const [dragState, setDragState] = useState<{ nodeId: string; offsetX: number; offsetY: number } | null>(null);
|
|
|
|
|
+ const canvasRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
+
|
|
|
|
|
+ const saveWorkflow = trpc.workflow.save.useMutation({
|
|
|
|
|
+ onSuccess: () => toast.success("Workflow saved"),
|
|
|
|
|
+ onError: (err) => toast.error(err.message),
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Load workflow from server
|
|
|
|
|
+ const { data: savedWorkflow } = trpc.workflow.load.useQuery({ workflowId: "default" });
|
|
|
|
|
+
|
|
|
|
|
+ // Drag handlers
|
|
|
|
|
+ const handleDragStart = useCallback((nodeId: string, e: React.MouseEvent) => {
|
|
|
|
|
+ const node = nodes.find(n => n.id === nodeId);
|
|
|
|
|
+ if (!node) return;
|
|
|
|
|
+ setDragState({ nodeId, offsetX: e.clientX - node.x, offsetY: e.clientY - node.y });
|
|
|
|
|
+ }, [nodes]);
|
|
|
|
|
+
|
|
|
|
|
+ const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
|
|
|
|
+ if (!dragState) return;
|
|
|
|
|
+ setNodes(prev => prev.map(n =>
|
|
|
|
|
+ n.id === dragState.nodeId ? { ...n, x: e.clientX - dragState.offsetX, y: e.clientY - dragState.offsetY } : n
|
|
|
|
|
+ ));
|
|
|
|
|
+ }, [dragState]);
|
|
|
|
|
+
|
|
|
|
|
+ const handleMouseUp = useCallback(() => setDragState(null), []);
|
|
|
|
|
+
|
|
|
|
|
+ const addNode = (type: NodeType) => {
|
|
|
|
|
+ const newNode: FlowNode = {
|
|
|
|
|
+ id: `n_${Date.now()}`,
|
|
|
|
|
+ type,
|
|
|
|
|
+ label: type.replace("_", " ").replace(/\b\w/g, c => c.toUpperCase()),
|
|
|
|
|
+ config: type === "greeting" ? {
|
|
|
|
|
+ message: "👋 What can we help you with today?",
|
|
|
|
|
+ quickReplies: ["Orders", "Shipping", "Returning", "Cancelling"],
|
|
|
|
|
+ detectCustomerId: true,
|
|
|
|
|
+ customerIdSource: "session",
|
|
|
|
|
+ } : type === "guardrail" ? {
|
|
|
|
|
+ blockedTopics: ["overall revenue", "profit margin", "internal pricing", "employee data"],
|
|
|
|
|
+ blockedMessage: "I'm sorry, I can't share that information.",
|
|
|
|
|
+ } : {},
|
|
|
|
|
+ x: 300 + Math.random() * 100,
|
|
|
|
|
+ y: 100 + nodes.length * 80,
|
|
|
|
|
+ };
|
|
|
|
|
+ setNodes(prev => [...prev, newNode]);
|
|
|
|
|
+ setSelectedNodeId(newNode.id);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const deleteNode = (nodeId: string) => {
|
|
|
|
|
+ setNodes(prev => prev.filter(n => n.id !== nodeId));
|
|
|
|
|
+ setEdges(prev => prev.filter(e => e.sourceId !== nodeId && e.targetId !== nodeId));
|
|
|
|
|
+ if (selectedNodeId === nodeId) setSelectedNodeId(null);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const workflowId = activeTemplate?.id || "default";
|
|
|
|
|
+
|
|
|
|
|
+ const handleSave = () => {
|
|
|
|
|
+ saveWorkflow.mutate({
|
|
|
|
|
+ workflowId,
|
|
|
|
|
+ nodes: nodes.map(n => ({
|
|
|
|
|
+ workflowId,
|
|
|
|
|
+ nodeId: n.id,
|
|
|
|
|
+ type: n.type,
|
|
|
|
|
+ label: n.label,
|
|
|
|
|
+ config: n.config,
|
|
|
|
|
+ positionX: n.x,
|
|
|
|
|
+ positionY: n.y,
|
|
|
|
|
+ })),
|
|
|
|
|
+ edges: edges.map(e => ({
|
|
|
|
|
+ workflowId,
|
|
|
|
|
+ sourceNodeId: e.sourceId,
|
|
|
|
|
+ targetNodeId: e.targetId,
|
|
|
|
|
+ label: e.label,
|
|
|
|
|
+ })),
|
|
|
|
|
+ });
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const loadTemplate = (template: FlowTemplate) => {
|
|
|
|
|
+ setNodes(template.nodes);
|
|
|
|
|
+ setEdges(template.edges);
|
|
|
|
|
+ setActiveTemplate(template);
|
|
|
|
|
+ setViewMode("editor");
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // Filter templates
|
|
|
|
|
+ const filteredTemplates = useMemo(() => {
|
|
|
|
|
+ let templates = FLOW_TEMPLATES;
|
|
|
|
|
+ if (selectedCategory) templates = templates.filter(t => t.category === selectedCategory);
|
|
|
|
|
+ if (selectedSubCategory) templates = templates.filter(t => t.subCategory === selectedSubCategory);
|
|
|
|
|
+ return templates;
|
|
|
|
|
+ }, [selectedCategory, selectedSubCategory]);
|
|
|
|
|
+
|
|
|
|
|
+ const selectedCategoryObj = FLOW_CATEGORIES.find(c => c.id === selectedCategory);
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="flex flex-col" style={{ height: "calc(100vh - 4rem)" }}>
|
|
|
|
|
+ {/* Top toolbar */}
|
|
|
|
|
+ <div className="border-b px-4 h-12 flex items-center justify-between shrink-0" style={{ background: "#fff", borderColor: "#e7e0d5" }}>
|
|
|
|
|
+ <div className="flex items-center gap-3">
|
|
|
|
|
+ <div className="w-7 h-7 rounded-lg flex items-center justify-center" style={{ background: "#14532D" }}>
|
|
|
|
|
+ <GitBranch className="w-3.5 h-3.5 text-white" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span className="text-sm font-bold" style={{ color: "#14532D", fontFamily: "'Playfair Display', serif" }}>Workflow Designer</span>
|
|
|
|
|
+ {activeTemplate && viewMode === "editor" && (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <ChevronRight className="w-3 h-3" style={{ color: "#a8a29e" }} />
|
|
|
|
|
+ <span className="text-xs font-medium" style={{ color: "#78716C" }}>{activeTemplate.name}</span>
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ {viewMode === "editor" && (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <Button onClick={() => { setViewMode("gallery"); setActiveTemplate(null); }} variant="outline" size="sm" className="text-xs">
|
|
|
|
|
+ <FolderOpen className="w-3 h-3 mr-1" /> Templates
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button onClick={handleSave} size="sm" className="text-xs text-white" style={{ background: "#14532D" }} disabled={saveWorkflow.isPending}>
|
|
|
|
|
+ <Save className="w-3 h-3 mr-1" /> {saveWorkflow.isPending ? "Saving..." : "Save"}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {viewMode === "gallery" && (
|
|
|
|
|
+ <Button onClick={() => { setNodes([]); setEdges([]); setActiveTemplate(null); setViewMode("editor"); }} size="sm" className="text-xs text-white" style={{ background: "#14532D" }}>
|
|
|
|
|
+ <Plus className="w-3 h-3 mr-1" /> New Flow
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* ─── Gallery View ─── */}
|
|
|
|
|
+ {viewMode === "gallery" && (
|
|
|
|
|
+ <div className="flex-1 flex min-h-0">
|
|
|
|
|
+ {/* Left sidebar — Categories */}
|
|
|
|
|
+ <div className="w-56 shrink-0 border-r overflow-y-auto" style={{ background: "#fff", borderColor: "#e7e0d5" }}>
|
|
|
|
|
+ <div className="p-3">
|
|
|
|
|
+ <h3 className="text-[10px] font-bold uppercase tracking-wider mb-2" style={{ color: "#a8a29e" }}>Categories</h3>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => { setSelectedCategory(null); setSelectedSubCategory(null); }}
|
|
|
|
|
+ className="w-full flex items-center gap-2 px-2 py-2 rounded-lg text-left mb-1 transition-colors"
|
|
|
|
|
+ style={{
|
|
|
|
|
+ background: !selectedCategory ? "#14532D0A" : "transparent",
|
|
|
|
|
+ color: !selectedCategory ? "#14532D" : "#78716C",
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Layers className="w-4 h-4" />
|
|
|
|
|
+ <span className="text-[12px] font-medium">All Flows</span>
|
|
|
|
|
+ <span className="ml-auto text-[10px] px-1.5 py-0.5 rounded-full" style={{ background: "#f5f0e8", color: "#a8a29e" }}>
|
|
|
|
|
+ {FLOW_TEMPLATES.length}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </button>
|
|
|
|
|
+
|
|
|
|
|
+ {FLOW_CATEGORIES.map(cat => (
|
|
|
|
|
+ <div key={cat.id}>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => { setSelectedCategory(cat.id); setSelectedSubCategory(null); }}
|
|
|
|
|
+ className="w-full flex items-center gap-2 px-2 py-2 rounded-lg text-left mb-0.5 transition-colors"
|
|
|
|
|
+ style={{
|
|
|
|
|
+ background: selectedCategory === cat.id ? `${cat.color}0A` : "transparent",
|
|
|
|
|
+ color: selectedCategory === cat.id ? cat.color : "#78716C",
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <div style={{ color: cat.color }}>{cat.icon}</div>
|
|
|
|
|
+ <span className="text-[12px] font-medium">{cat.label}</span>
|
|
|
|
|
+ <span className="ml-auto text-[10px] px-1.5 py-0.5 rounded-full" style={{ background: "#f5f0e8", color: "#a8a29e" }}>
|
|
|
|
|
+ {FLOW_TEMPLATES.filter(t => t.category === cat.id).length}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </button>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Sub-categories */}
|
|
|
|
|
+ {selectedCategory === cat.id && (
|
|
|
|
|
+ <div className="ml-4 mb-1 space-y-0.5">
|
|
|
|
|
+ {cat.subCategories.map(sub => (
|
|
|
|
|
+ <button
|
|
|
|
|
+ key={sub.id}
|
|
|
|
|
+ onClick={() => setSelectedSubCategory(selectedSubCategory === sub.id ? null : sub.id)}
|
|
|
|
|
+ className="w-full flex items-center gap-1.5 px-2 py-1.5 rounded-md text-left transition-colors"
|
|
|
|
|
+ style={{
|
|
|
|
|
+ background: selectedSubCategory === sub.id ? `${sub.color}0A` : "transparent",
|
|
|
|
|
+ color: selectedSubCategory === sub.id ? sub.color : "#a8a29e",
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <div style={{ color: sub.color }}>{sub.icon}</div>
|
|
|
|
|
+ <span className="text-[11px] font-medium">{sub.label}</span>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Right — Template cards */}
|
|
|
|
|
+ <div className="flex-1 overflow-y-auto p-4" style={{ background: "#FFFBEB" }}>
|
|
|
|
|
+ {/* Category header */}
|
|
|
|
|
+ {selectedCategoryObj && (
|
|
|
|
|
+ <div className="mb-4 p-4 rounded-xl border" style={{ background: "#fff", borderColor: "#e7e0d5" }}>
|
|
|
|
|
+ <div className="flex items-center gap-3">
|
|
|
|
|
+ <div className="w-10 h-10 rounded-lg flex items-center justify-center" style={{ background: `${selectedCategoryObj.color}14`, color: selectedCategoryObj.color }}>
|
|
|
|
|
+ {selectedCategoryObj.icon}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <h2 className="text-base font-bold" style={{ color: "#292524", fontFamily: "'Playfair Display', serif" }}>{selectedCategoryObj.label}</h2>
|
|
|
|
|
+ <p className="text-xs" style={{ color: "#78716C" }}>{selectedCategoryObj.description}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Template grid */}
|
|
|
|
|
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
|
|
|
+ {filteredTemplates.map(template => {
|
|
|
|
|
+ const cat = FLOW_CATEGORIES.find(c => c.id === template.category);
|
|
|
|
|
+ const sub = cat?.subCategories.find(s => s.id === template.subCategory);
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div key={template.id} className="p-4 rounded-xl border transition-shadow hover:shadow-md" style={{ background: "#fff", borderColor: "#e7e0d5" }}>
|
|
|
|
|
+ <div className="flex items-start gap-3">
|
|
|
|
|
+ <div className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0" style={{ background: sub ? `${sub.color}14` : "#f5f0e8", color: sub?.color || "#78716C" }}>
|
|
|
|
|
+ {sub?.icon || <GitBranch className="w-4 h-4" />}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex-1 min-w-0">
|
|
|
|
|
+ <h3 className="text-sm font-semibold" style={{ color: "#292524" }}>{template.name}</h3>
|
|
|
|
|
+ <p className="text-[11px] mt-0.5" style={{ color: "#78716C" }}>{template.description}</p>
|
|
|
|
|
+ <div className="flex items-center gap-1.5 mt-2">
|
|
|
|
|
+ {sub && (
|
|
|
|
|
+ <span className="text-[9px] px-1.5 py-0.5 rounded-full capitalize" style={{ background: `${sub.color}14`, color: sub.color }}>
|
|
|
|
|
+ {sub.label}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {template.tags.map(tag => (
|
|
|
|
|
+ <span key={tag} className="text-[9px] px-1.5 py-0.5 rounded-full capitalize" style={{ background: "#f5f0e8", color: "#a8a29e" }}>
|
|
|
|
|
+ {tag}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ <span className="text-[9px]" style={{ color: "#d6d3d1" }}>{template.nodes.length} nodes</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex gap-2 mt-3 pt-3 border-t" style={{ borderColor: "#f5f0e8" }}>
|
|
|
|
|
+ <Button onClick={() => loadTemplate(template)} size="sm" className="flex-1 text-xs text-white" style={{ background: sub?.color || "#14532D" }}>
|
|
|
|
|
+ <Play className="w-3 h-3 mr-1" /> Use Template
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button onClick={() => { setActiveTemplate(template); setViewMode("editor"); setNodes(template.nodes); setEdges(template.edges); }} variant="outline" size="sm" className="text-xs">
|
|
|
|
|
+ <Eye className="w-3 h-3 mr-1" /> Preview
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+
|
|
|
|
|
+ {filteredTemplates.length === 0 && (
|
|
|
|
|
+ <div className="col-span-2 text-center py-12">
|
|
|
|
|
+ <FolderOpen className="w-10 h-10 mx-auto mb-3" style={{ color: "#d6d3d1" }} />
|
|
|
|
|
+ <p className="text-sm" style={{ color: "#78716C" }}>No templates in this category yet</p>
|
|
|
|
|
+ <p className="text-xs mt-1" style={{ color: "#a8a29e" }}>Create a new flow or select a different category</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* ─── Editor View ─── */}
|
|
|
|
|
+ {viewMode === "editor" && (
|
|
|
|
|
+ <div className="flex-1 flex min-h-0">
|
|
|
|
|
+ {/* Left sidebar — Node palette & AI suggestions */}
|
|
|
|
|
+ <div className="w-56 shrink-0 border-r overflow-y-auto" style={{ background: "#fff", borderColor: "#e7e0d5" }}>
|
|
|
|
|
+ <div className="p-3">
|
|
|
|
|
+ <h3 className="text-[10px] font-bold uppercase tracking-wider mb-2" style={{ color: "#a8a29e" }}>Add Node</h3>
|
|
|
|
|
+ <div className="space-y-1">
|
|
|
|
|
+ {(Object.entries(NODE_TYPES) as [NodeType, typeof NODE_TYPES[NodeType]][]).map(([type, config]) => (
|
|
|
|
|
+ <button
|
|
|
|
|
+ key={type}
|
|
|
|
|
+ onClick={() => addNode(type)}
|
|
|
|
|
+ className="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-left hover:bg-black/5 transition-colors"
|
|
|
|
|
+ >
|
|
|
|
|
+ <div className="w-6 h-6 rounded-md flex items-center justify-center" style={{ background: config.bgColor, color: config.color }}>
|
|
|
|
|
+ {config.icon}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="min-w-0">
|
|
|
|
|
+ <span className="text-[11px] font-medium capitalize block" style={{ color: "#292524" }}>{type.replace("_", " ")}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* AI Suggestions */}
|
|
|
|
|
+ <div className="border-t" style={{ borderColor: "#e7e0d5" }}>
|
|
|
|
|
+ <AISuggestionsPanel />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Canvas */}
|
|
|
|
|
+ <div
|
|
|
|
|
+ ref={canvasRef}
|
|
|
|
|
+ className="flex-1 relative overflow-auto cursor-crosshair"
|
|
|
|
|
+ style={{ background: "#FFFBEB", backgroundImage: "radial-gradient(circle, #e7e0d5 1px, transparent 1px)", backgroundSize: "24px 24px" }}
|
|
|
|
|
+ onMouseMove={handleMouseMove}
|
|
|
|
|
+ onMouseUp={handleMouseUp}
|
|
|
|
|
+ onMouseLeave={handleMouseUp}
|
|
|
|
|
+ onClick={() => setSelectedNodeId(null)}
|
|
|
|
|
+ >
|
|
|
|
|
+ {/* Edges SVG */}
|
|
|
|
|
+ <svg className="absolute inset-0 w-full h-full pointer-events-none" style={{ minWidth: 1200, minHeight: 800 }}>
|
|
|
|
|
+ {edges.map(edge => (
|
|
|
|
|
+ <FlowEdgeLine key={edge.id} edge={edge} nodes={nodes} />
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </svg>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Nodes */}
|
|
|
|
|
+ {nodes.map(node => (
|
|
|
|
|
+ <FlowNodeCard
|
|
|
|
|
+ key={node.id}
|
|
|
|
|
+ node={node}
|
|
|
|
|
+ isSelected={selectedNodeId === node.id}
|
|
|
|
|
+ onSelect={() => setSelectedNodeId(node.id)}
|
|
|
|
|
+ onDragStart={(e) => handleDragStart(node.id, e)}
|
|
|
|
|
+ />
|
|
|
|
|
+ ))}
|
|
|
|
|
+
|
|
|
|
|
+ {nodes.length === 0 && (
|
|
|
|
|
+ <div className="absolute inset-0 flex items-center justify-center">
|
|
|
|
|
+ <div className="text-center">
|
|
|
|
|
+ <GitBranch className="w-12 h-12 mx-auto mb-3" style={{ color: "#d6d3d1" }} />
|
|
|
|
|
+ <p className="text-sm" style={{ color: "#78716C" }}>Empty canvas</p>
|
|
|
|
|
+ <p className="text-xs mt-1" style={{ color: "#a8a29e" }}>Add nodes from the sidebar or load a template</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Right sidebar — Node properties */}
|
|
|
|
|
+ {selectedNodeId && (() => {
|
|
|
|
|
+ const node = nodes.find(n => n.id === selectedNodeId);
|
|
|
|
|
+ if (!node) return null;
|
|
|
|
|
+ const typeConfig = NODE_TYPES[node.type];
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="w-64 shrink-0 border-l overflow-y-auto" style={{ background: "#fff", borderColor: "#e7e0d5" }}>
|
|
|
|
|
+ <div className="p-3 border-b flex items-center justify-between" style={{ borderColor: "#e7e0d5" }}>
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ <div style={{ color: typeConfig.color }}>{typeConfig.icon}</div>
|
|
|
|
|
+ <span className="text-xs font-bold" style={{ color: "#292524" }}>{node.label}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button onClick={() => deleteNode(node.id)} className="p-1 rounded hover:bg-red-50">
|
|
|
|
|
+ <Trash2 className="w-3 h-3" style={{ color: "#dc2626" }} />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="p-3 space-y-3">
|
|
|
|
|
+ {/* Label */}
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label className="text-[10px] font-semibold uppercase tracking-wider" style={{ color: "#a8a29e" }}>Label</label>
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ value={node.label}
|
|
|
|
|
+ onChange={(e) => setNodes(prev => prev.map(n => n.id === node.id ? { ...n, label: e.target.value } : n))}
|
|
|
|
|
+ className="mt-1 w-full px-2 py-1.5 text-xs rounded-lg border"
|
|
|
|
|
+ style={{ borderColor: "#e7e0d5" }}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Greeting-specific: Quick Replies */}
|
|
|
|
|
+ {node.type === "greeting" && (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label className="text-[10px] font-semibold uppercase tracking-wider" style={{ color: "#a8a29e" }}>Message</label>
|
|
|
|
|
+ <textarea
|
|
|
|
|
+ value={node.config.message || ""}
|
|
|
|
|
+ onChange={(e) => setNodes(prev => prev.map(n => n.id === node.id ? { ...n, config: { ...n.config, message: e.target.value } } : n))}
|
|
|
|
|
+ className="mt-1 w-full px-2 py-1.5 text-xs rounded-lg border resize-none"
|
|
|
|
|
+ rows={2}
|
|
|
|
|
+ style={{ borderColor: "#e7e0d5" }}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label className="text-[10px] font-semibold uppercase tracking-wider" style={{ color: "#a8a29e" }}>Quick Reply Buttons</label>
|
|
|
|
|
+ <div className="mt-1 space-y-1">
|
|
|
|
|
+ {(node.config.quickReplies || []).map((reply: string, i: number) => (
|
|
|
|
|
+ <div key={i} className="flex items-center gap-1">
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ value={reply}
|
|
|
|
|
+ onChange={(e) => {
|
|
|
|
|
+ const updated = [...(node.config.quickReplies || [])];
|
|
|
|
|
+ updated[i] = e.target.value;
|
|
|
|
|
+ setNodes(prev => prev.map(n => n.id === node.id ? { ...n, config: { ...n.config, quickReplies: updated } } : n));
|
|
|
|
|
+ }}
|
|
|
|
|
+ className="flex-1 px-2 py-1 text-[11px] rounded border"
|
|
|
|
|
+ style={{ borderColor: "#e7e0d5" }}
|
|
|
|
|
+ />
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ const updated = (node.config.quickReplies || []).filter((_: any, idx: number) => idx !== i);
|
|
|
|
|
+ setNodes(prev => prev.map(n => n.id === node.id ? { ...n, config: { ...n.config, quickReplies: updated } } : n));
|
|
|
|
|
+ }}
|
|
|
|
|
+ className="p-0.5 rounded hover:bg-red-50"
|
|
|
|
|
+ >
|
|
|
|
|
+ <X className="w-3 h-3" style={{ color: "#dc2626" }} />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ const updated = [...(node.config.quickReplies || []), "New Option"];
|
|
|
|
|
+ setNodes(prev => prev.map(n => n.id === node.id ? { ...n, config: { ...n.config, quickReplies: updated } } : n));
|
|
|
|
|
+ }}
|
|
|
|
|
+ className="text-[10px] font-medium px-2 py-1 rounded-md"
|
|
|
|
|
+ style={{ color: "#14532D", background: "#14532D14" }}
|
|
|
|
|
+ >
|
|
|
|
|
+ + Add Button
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="checkbox"
|
|
|
|
|
+ checked={node.config.detectCustomerId || false}
|
|
|
|
|
+ onChange={(e) => setNodes(prev => prev.map(n => n.id === node.id ? { ...n, config: { ...n.config, detectCustomerId: e.target.checked } } : n))}
|
|
|
|
|
+ className="rounded"
|
|
|
|
|
+ />
|
|
|
|
|
+ <span className="text-[11px]" style={{ color: "#292524" }}>Auto-detect Customer ID</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Guardrail-specific */}
|
|
|
|
|
+ {node.type === "guardrail" && (
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label className="text-[10px] font-semibold uppercase tracking-wider" style={{ color: "#a8a29e" }}>Blocked Topics</label>
|
|
|
|
|
+ <textarea
|
|
|
|
|
+ value={(node.config.blockedTopics || []).join("\n")}
|
|
|
|
|
+ onChange={(e) => setNodes(prev => prev.map(n => n.id === node.id ? { ...n, config: { ...n.config, blockedTopics: e.target.value.split("\n").filter(Boolean) } } : n))}
|
|
|
|
|
+ className="mt-1 w-full px-2 py-1.5 text-xs rounded-lg border resize-none"
|
|
|
|
|
+ rows={4}
|
|
|
|
|
+ placeholder="One topic per line"
|
|
|
|
|
+ style={{ borderColor: "#e7e0d5" }}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Response message */}
|
|
|
|
|
+ {(node.type === "response" || node.type === "escalation") && (
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label className="text-[10px] font-semibold uppercase tracking-wider" style={{ color: "#a8a29e" }}>Message</label>
|
|
|
|
|
+ <textarea
|
|
|
|
|
+ value={node.config.message || ""}
|
|
|
|
|
+ onChange={(e) => setNodes(prev => prev.map(n => n.id === node.id ? { ...n, config: { ...n.config, message: e.target.value } } : n))}
|
|
|
|
|
+ className="mt-1 w-full px-2 py-1.5 text-xs rounded-lg border resize-none"
|
|
|
|
|
+ rows={3}
|
|
|
|
|
+ style={{ borderColor: "#e7e0d5" }}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* API endpoint for data nodes */}
|
|
|
|
|
+ {(node.type === "customer_data" || node.type === "sales_order" || node.type === "action") && (
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label className="text-[10px] font-semibold uppercase tracking-wider" style={{ color: "#a8a29e" }}>API Endpoint</label>
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ value={node.config.apiEndpoint || ""}
|
|
|
|
|
+ onChange={(e) => setNodes(prev => prev.map(n => n.id === node.id ? { ...n, config: { ...n.config, apiEndpoint: e.target.value } } : n))}
|
|
|
|
|
+ className="mt-1 w-full px-2 py-1.5 text-xs rounded-lg border font-mono"
|
|
|
|
|
+ style={{ borderColor: "#e7e0d5" }}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ <p className="text-[10px] italic" style={{ color: "#d6d3d1" }}>{typeConfig.description}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ })()}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|