react flow
import React, { useState, useCallback, useEffect, useRef } from "react";
import Layout from "@/components/layout";
import { Combobox } from "@/components/ui/combobox";
import {
ReactFlow,
MiniMap,
Controls,
Background,
useNodesState,
useEdgesState,
addEdge,
Handle,
Position,
ReactFlowProvider,
getBezierPath,
} from "@xyflow/react";
import Spinner from "@/components/spinner";
import { getCookie } from "cookies-next";
import "@xyflow/react/dist/style.css";
import toast, { Toaster } from "react-hot-toast";
import { X, Image, List, Type, Database } from "lucide-react";
import axios from "../../../utils/axiosInstance";
import { useRouter } from "next/router";
import { uploadFileToS3 } from "../../../utils/mediaupload";
import { generateHandleId } from "../../../utils/handleIds";
const CustomEdge = ({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style = {},
data,
markerEnd,
}) => {
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
const [isHovered, setIsHovered] = useState(false);
// Ensure we're using the onDelete callback from props.data
const handleEdgeDelete = () => {
if (data?.onDelete) {
data.onDelete(id);
}
};
return (
<>
<path
id={id}
style={{
...style,
strokeWidth: 2,
stroke: "#6366f1",
}}
className="react-flow__edge-path hover:stroke-indigo-400"
d={edgePath}
markerEnd={markerEnd}
/>
<g
transform={`translate(${labelX} ${labelY})`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Make the entire circle clickable */}
<circle
r="18"
className={`
cursor-pointer
transition-all duration-200
${
isHovered
? "fill-red-50 stroke-red-300"
: "fill-white stroke-gray-200"
}
`}
strokeWidth="1.5"
onClick={handleEdgeDelete} // Make sure this is called
style={{ pointerEvents: "all" }} // Ensure clicks are captured
/>
{/* X icon */}
<g
className={`
transform transition-transform duration-200
${isHovered ? "scale-110" : "scale-100"}
`}
style={{ pointerEvents: "none" }} // Prevent X from capturing clicks
>
<path
d="M-6 -6 L6 6 M-6 6 L6 -6"
className={`
transition-all duration-200
${isHovered ? "stroke-red-500" : "stroke-gray-400"}
`}
strokeWidth="2.5"
strokeLinecap="round"
/>
</g>
{/* Ripple effect on hover */}
{isHovered && (
<circle
r="18"
className="fill-none stroke-red-200 animate-ping"
strokeWidth="2"
opacity="0.5"
style={{ pointerEvents: "none" }} // Prevent ripple from capturing clicks
/>
)}
</g>
</>
);
};
const NodeTypeDropdown = ({ options, onSelect, onCancel, position }) => {
const nodeIcons = {
"Text + Button": Type,
Media: Image,
List: List,
Attribute: Database,
};
return (
<div
className="absolute bg-white rounded-xl shadow-2xl border border-gray-200 p-4 z-50 w-[300px]"
style={{
top: position.y,
left: position.x,
transform: "translate(-50%, -50%)",
}}
>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold text-gray-800">Select Action</h3>
<button
onClick={onCancel}
className="text-gray-500 hover:text-gray-700 transition-colors"
title="Cancel"
>
<X className="w-6 h-6" />
</button>
</div>
<div className="grid grid-cols-2 gap-3">
{options.map((option) => {
const Icon = nodeIcons[option];
return (
<button
key={option}
onClick={() => onSelect(option)}
className="
flex flex-col items-center justify-center
p-4 rounded-lg
hover:bg-indigo-50
border border-transparent
hover:border-indigo-200
transition-all
group
"
>
<Icon
className="
w-8 h-8 mb-2
text-gray-500
group-hover:text-indigo-600
transition-colors
"
/>
<span
className="
text-sm font-medium
text-gray-700
group-hover:text-indigo-700
"
>
{option}
</span>
</button>
);
})}
</div>
</div>
);
};
// Common Header Component for Nodes
const NodeHeader = ({
type,
onDelete,
canDelete = true,
messageNumber = null,
}) => (
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
{/* WhatsApp Icon */}
<svg
viewBox="0 0 24 24"
className="w-6 h-6 text-[#25D366] flex-shrink-0"
fill="currentColor"
>
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z" />
</svg>
{/* Node Type */}
<span className="text-[15px] font-medium text-gray-700">{type}</span>
{/* Message Number Badge */}
{messageNumber && (
<span className="bg-indigo-100 text-indigo-700 text-sm font-medium px-3 py-0.5 rounded-full">
Message {messageNumber}
</span>
)}
</div>
{/* Delete Button */}
{canDelete && (
<button
onClick={onDelete}
className="text-gray-400 hover:text-red-500 transition-colors"
title="Delete node"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
)}
</div>
);
// Button Component with Delete Option
const ButtonInput = ({
button,
onChange,
onDelete,
handleId,
showHandle = true,
}) => (
<div className="relative flex items-center gap-2">
<input
type="text"
value={button.label}
onChange={(e) => onChange(e.target.value)}
className="w-full p-2 border rounded-md text-sm"
placeholder="Button text"
/>
<button
onClick={onDelete}
className="text-gray-400 hover:text-red-500 transition-colors"
title="Delete button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
{showHandle && (
<Handle
type="source"
position={Position.Right}
id={handleId}
style={{
backgroundColor: "indigo",
width: "13px",
height: "13px",
borderRadius: "50%",
right: "-20px",
}}
/>
)}
</div>
);
// TriggerNode component
const TriggerNode = ({ data, id }) => {
const [keywords, setKeywords] = useState(data.keywords || ["Hi"]);
const handleAddKeyword = () => {
const newKeywords = [...keywords, "New Keyword"];
setKeywords(newKeywords);
data.onKeywordsUpdate?.(id, newKeywords);
};
const handleKeywordChange = (index, value) => {
const newKeywords = keywords.map((kw, i) => (i === index ? value : kw));
setKeywords(newKeywords);
data.onKeywordsUpdate?.(id, newKeywords);
};
const handleDeleteKeyword = (index) => {
const newKeywords = keywords.filter((_, i) => i !== index);
setKeywords(newKeywords);
data.onKeywordsUpdate?.(id, newKeywords);
};
return (
<div className="bg-white rounded-lg shadow-lg p-4 w-[300px] relative">
<NodeHeader type="Starting Step" canDelete={false} />
<div className="bg-gray-50 rounded-lg p-3 space-y-3">
{/* Add descriptive text */}
<div className="text-xs text-gray-600 mb-3">
Start the flow if the user's message matches any of these keywords:
</div>
{keywords.map((keyword, index) => (
<div key={index} className="relative flex items-center gap-2">
<input
type="text"
value={keyword}
onChange={(e) => handleKeywordChange(index, e.target.value)}
className="w-full p-2 border rounded-md text-sm"
placeholder="Enter keyword"
/>
<button
onClick={() => handleDeleteKeyword(index)}
className="text-gray-400 hover:text-red-500 transition-colors"
title="Delete keyword"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
))}
<button
onClick={handleAddKeyword}
className="text-indigo-500 hover:text-indigo-700 text-sm"
>
+ Add Keyword
</button>
{/* Add note for additional context */}
{/* <div className="text-xs text-gray-500 mt-2 italic">
Note: The flow will start automatically when a user sends any of these
keywords.
</div> */}
</div>
{/* Bottom handle for connecting to other nodes */}
<Handle
type="source"
position={Position.Right}
style={{
backgroundColor: "indigo",
width: "13px",
height: "13px",
borderRadius: "50%",
}}
/>
</div>
);
};
// Text + Button Node Component
const TextButtonNode = ({ data, id }) => {
const [content, setContent] = useState(data.content || "");
const [buttons, setButtons] = useState(
data.buttons?.length > 0
? data.buttons
: [
{
label: "Button 1",
handleId: generateHandleId("btn", Date.now(), 0),
},
]
);
// In TextButtonNode component
useEffect(() => {
if (!data.buttons || data.buttons.length === 0) {
const defaultButton = {
label: "Button 1",
handleId: generateHandleId("btn", Date.now(), 0),
};
data.onButtonsUpdate?.(id, [defaultButton]);
}
}, []);
const handleContentChange = (value) => {
// Limit content to 1024 characters (WhatsApp body text limit)
if (value.length <= 1024) {
setContent(value);
data.onContentUpdate?.(id, value);
}
};
const handleAddButton = () => {
if (buttons.length < 3) {
const newButton = {
label: "New Button",
handleId: generateHandleId("btn", Date.now(), buttons.length),
};
const updatedButtons = [...buttons, newButton];
setButtons(updatedButtons);
data.onButtonsUpdate?.(id, updatedButtons);
}
};
const handleButtonTextChange = (index, value) => {
// Limit button text to 20 characters
if (value.length <= 20) {
const newButtons = buttons.map((btn, i) =>
i === index ? { ...btn, label: value } : btn
);
setButtons(newButtons);
data.onButtonsUpdate?.(id, newButtons);
}
};
const handleDeleteButton = (index) => {
if (buttons.length <= 1) {
toast.error("At least one button is required");
return;
}
const newButtons = buttons.filter((_, i) => i !== index);
setButtons(newButtons);
data.onButtonsUpdate?.(id, newButtons);
};
return (
<div className="bg-white rounded-lg shadow-lg p-4 w-[300px] relative">
<Handle
type="target"
position={Position.Left}
style={{
backgroundColor: "indigo",
width: "13px",
height: "13px",
borderRadius: "50%",
}}
/>
<NodeHeader
type="Text + Button"
onDelete={data.onDelete}
messageNumber={data.messageNumber}
/>
<div className="bg-gray-50 rounded-lg p-3 space-y-3">
<div>
<textarea
value={content}
onChange={(e) => handleContentChange(e.target.value)}
placeholder="Enter your message... (max 1024 chars)"
className="w-full p-2 border rounded-md text-sm min-h-[90px] resize-none"
/>
<div className="text-xs text-gray-500 text-right">
{content.length}/1024 characters
</div>
</div>
{buttons.map((btn, index) => (
<div key={index} className="relative flex items-center gap-2">
<input
type="text"
value={btn.label}
onChange={(e) => handleButtonTextChange(index, e.target.value)}
className="w-full p-2 border rounded-md text-sm"
placeholder="Button text (max 20 chars)"
/>
<div className="text-xs text-gray-500 mr-2">
{btn.label.length}/20
</div>
<button
onClick={() => handleDeleteButton(index)}
className="text-gray-400 hover:text-red-500 transition-colors"
title="Delete button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
<Handle
type="source"
position={Position.Right}
id={btn.handleId}
style={{
backgroundColor: "indigo",
width: "13px",
height: "13px",
borderRadius: "50%",
right: "-20px",
}}
/>
</div>
))}
<button
onClick={handleAddButton}
className="text-indigo-500 hover:text-indigo-700 text-sm"
disabled={buttons.length >= 3}
>
+ Add Button {buttons.length}/3
</button>
</div>
</div>
);
};
// Media Node Component
const MediaNode = ({ data, id }) => {
const [content, setContent] = useState(data.content || "");
const [mediaUrl, setMediaUrl] = useState(data.mediaUrl || "");
const [mediaType, setMediaType] = useState(data.mediaType || "");
const [fileName, setFileName] = useState(data.fileName || "");
const [buttons, setButtons] = useState(
data.buttons?.length > 0
? data.buttons
: [
{
label: "Button 1",
handleId: generateHandleId("btn", Date.now(), 0),
},
]
);
const [isLoading, setIsLoading] = useState(false);
const [uploadedId, setUploadedId] = useState(data.uploadedId || "");
useEffect(() => {
// If there are no buttons in the parent data, update it with the default button
if (!data.buttons || data.buttons.length === 0) {
const defaultButton = {
label: "Button 1",
handleId: generateHandleId("btn", Date.now(), 0),
};
data.onButtonsUpdate?.(id, [defaultButton]);
}
}, []);
const getMediaType = (file) => {
if (file.type.startsWith("image/")) return "image";
if (file.type.startsWith("video/")) return "video";
return "document";
};
const handleMediaUpload = async (event) => {
const file = event.target.files[0];
if (file) {
setIsLoading(true);
try {
// Upload to S3
const result = await uploadFileToS3(
file,
`whatsapp-media-${id}`, // Using node id to make it unique
"whatsapp/automation"
);
if (result.success) {
let mediaPreviewUrl = null;
// Create preview URL for images and videos
if (result.category === "image" || result.category === "video") {
mediaPreviewUrl = result.url; // Use the S3 URL directly
}
// Set all the state values
setUploadedId(result.url); // Store S3 URL instead of WhatsApp media ID
setFileName(result.fileName);
setMediaUrl(mediaPreviewUrl || result.url);
setMediaType(result.category);
// Update node data with all fields
data.onMediaUpdate?.(
id,
result.url, // Use S3 URL
result.category,
result.url, // Using S3 URL as ID
result.fileName
);
toast.success("Media uploaded successfully");
}
} catch (error) {
console.error("File upload failed:", error);
toast.error(
error.message || "Failed to upload media. Please try again."
);
} finally {
setIsLoading(false);
}
}
};
// const handleMediaUpload = async (event) => {
// const file = event.target.files[0];
// if (file) {
// setIsLoading(true);
// const formData = new FormData();
// formData.append("file", file);
// formData.append("messaging_product", "whatsapp");
// const type = getMediaType(file);
// formData.append("type", type);
// const access_token = getCookie("at");
// const wb_phone_number_id = getCookie("pni");
// try {
// const response = await fetch(
// `https://graph.facebook.com/v21.0/${wb_phone_number_id}/media`,
// {
// method: "POST",
// body: formData,
// headers: {
// Authorization: `Bearer ${access_token}`,
// },
// }
// );
// if (response.ok) {
// const responseData = await response.json();
// console.log(responseData, "this is media id data");
// let mediaPreviewUrl = null;
// // Create preview URL for images and videos
// if (type === "image" || type === "video") {
// mediaPreviewUrl = URL.createObjectURL(file);
// }
// // Set all the state values
// setUploadedId(responseData.id);
// setFileName(file.name);
// setMediaUrl(mediaPreviewUrl || "");
// setMediaType(type);
// // Update node data with all fields
// data.onMediaUpdate?.(
// id,
// mediaPreviewUrl, // Use the mediaPreviewUrl we just created
// type,
// responseData.id, // Pass the uploaded ID
// file.name // Pass the file name
// );
// } else {
// const errorData = await response.json();
// toast.error(errorData.error.error_data.details);
// }
// } catch (error) {
// console.error("File upload failed:", error);
// toast.error("Failed to upload media. Please try again.");
// } finally {
// setIsLoading(false);
// }
// }
// };
const renderMediaPreview = () => {
if (isLoading) {
return (
<div className="flex items-center justify-center p-4">
<Spinner />
<span className="ml-2 text-sm text-gray-600">Uploading...</span>
</div>
);
}
if (mediaType === "document") {
return (
<div className="relative w-full bg-white p-4 rounded-lg border">
<div className="flex items-center max-w-full">
<svg
className="w-8 h-8 text-gray-500 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
<div className="ml-3 min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900 truncate max-w-[180px]">
{fileName}
</p>
<p className="text-sm text-gray-500">Document</p>
</div>
</div>
<button
onClick={() => {
setMediaUrl("");
setMediaType("");
setUploadedId("");
setFileName("");
}}
className="absolute top-1 right-1 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm hover:bg-red-600"
>
×
</button>
</div>
);
}
if (mediaUrl) {
return (
<div className="relative w-full">
{mediaType === "image" ? (
<img
src={mediaUrl}
alt="Uploaded media"
className="max-w-full h-auto rounded"
/>
) : (
<video
src={mediaUrl}
controls
className="max-w-full h-auto rounded"
/>
)}
<button
onClick={() => {
setMediaUrl("");
setMediaType("");
setUploadedId("");
setFileName("");
}}
className="absolute top-1 right-1 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm hover:bg-red-600"
>
×
</button>
</div>
);
}
return (
<div className="text-center">
<input
type="file"
accept="image/*,video/*,application/*,.pdf,.doc,.docx,.xls,.xlsx"
onChange={handleMediaUpload}
className="hidden"
id={`media-upload-${id}`}
/>
<label
htmlFor={`media-upload-${id}`}
className="cursor-pointer bg-indigo-500 text-white px-4 py-2 rounded-md hover:bg-indigo-600 inline-block"
>
Upload Media
</label>
</div>
);
};
// Rest of the component remains the same as in the previous implementation
const handleContentChange = (value) => {
// Limit content to 1024 characters
if (value.length <= 1024) {
setContent(value);
data.onContentUpdate?.(id, value);
}
};
const handleAddButton = () => {
if (buttons.length < 3) {
const newButton = {
label: "New Button",
handleId: generateHandleId("btn", Date.now(), buttons.length),
};
const updatedButtons = [...buttons, newButton];
setButtons(updatedButtons);
data.onButtonsUpdate?.(id, updatedButtons);
}
};
const handleButtonTextChange = (index, value) => {
// Limit button text to 20 characters
if (value.length <= 20) {
const newButtons = buttons.map((btn, i) =>
i === index ? { ...btn, label: value } : btn
);
setButtons(newButtons);
data.onButtonsUpdate?.(id, newButtons);
}
};
const handleDeleteButton = (index) => {
if (buttons.length <= 1) {
toast.error("At least one button is required");
return;
}
const newButtons = buttons.filter((_, i) => i !== index);
setButtons(newButtons);
data.onButtonsUpdate?.(id, newButtons);
};
return (
<div className="bg-white rounded-lg shadow-lg p-4 w-[300px] relative">
<Handle
type="target"
position={Position.Left}
style={{
backgroundColor: "indigo",
width: "13px",
height: "13px",
borderRadius: "50%",
}}
/>
<NodeHeader
type="Media"
onDelete={data.onDelete}
messageNumber={data.messageNumber}
/>
<div className="bg-gray-50 rounded-lg p-3 space-y-3">
<div className="flex justify-center items-center bg-gray-100 border-2 border-dashed border-gray-300 rounded-lg p-4">
{renderMediaPreview()}
</div>
<div>
<textarea
value={content}
onChange={(e) => handleContentChange(e.target.value)}
placeholder="Enter your message... (max 1024 chars)"
className="w-full p-2 border rounded-md text-sm min-h-[60px] resize-none"
/>
<div className="text-xs text-gray-500 text-right">
{content.length}/1024 characters
</div>
</div>
{buttons.map((btn, index) => (
<div key={index} className="relative flex items-center gap-2">
<input
type="text"
value={btn.label}
onChange={(e) => handleButtonTextChange(index, e.target.value)}
className="w-full p-2 border rounded-md text-sm"
placeholder="Button text (max 20 chars)"
/>
<div className="text-xs text-gray-500 mr-2">
{btn.label.length}/20
</div>
<button
onClick={() => handleDeleteButton(index)}
className="text-gray-400 hover:text-red-500 transition-colors"
title="Delete button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
<Handle
type="source"
position={Position.Right}
id={btn.handleId}
style={{
backgroundColor: "indigo",
width: "13px",
height: "13px",
borderRadius: "50%",
right: "-20px",
}}
/>
</div>
))}
<button
onClick={handleAddButton}
className="text-indigo-500 hover:text-indigo-700 text-sm"
disabled={buttons.length >= 3}
>
+ Add Button {buttons.length}/3
</button>
</div>
</div>
);
};
const ListNode = ({ data, id }) => {
const [content, setContent] = useState(data.content || "");
const [buttonText, setButtonText] = useState(data.buttonText || "Options");
// Default section and row
const defaultSection = {
title: "Section 1",
rows: [
{
id: generateHandleId("list-row", Date.now(), "0-0"),
title: "Option 1",
description: "Description for Option 1 (Optional)",
},
],
};
const [sections, setSections] = useState(
data.sections?.length > 0 ? data.sections : [defaultSection]
);
// Add this useEffect to sync with parent on mount
useEffect(() => {
if (!data.sections || data.sections.length === 0) {
data.onSectionsUpdate?.(id, [defaultSection]);
}
}, []); // Empty dependency array means this runs once on mount
// Helper function to count total rows across all sections
const getTotalRowCount = useCallback(() => {
return sections.reduce((total, section) => total + section.rows.length, 0);
}, [sections]);
// Content update handler
const handleContentChange = (value) => {
if (value.length <= 4096) {
setContent(value);
data.onContentUpdate?.(id, value);
}
};
// Button text update handler
const handleButtonTextChange = (value) => {
if (value.length <= 20) {
setButtonText(value);
data.onButtonTextUpdate?.(id, value);
}
};
// Section handlers
const handleSectionTitleChange = (sectionIndex, newTitle) => {
const updatedSections = sections.map((section, index) => {
if (index === sectionIndex) {
return { ...section, title: newTitle };
}
return section;
});
setSections(updatedSections);
data.onSectionsUpdate?.(id, updatedSections);
};
const handleDeleteSection = (sectionIndex) => {
const totalRowsAfterDeletion =
getTotalRowCount() - sections[sectionIndex].rows.length;
if (totalRowsAfterDeletion === 0) {
toast.error("Must maintain at least one row in total");
return;
}
const updatedSections = sections.filter(
(_, index) => index !== sectionIndex
);
setSections(updatedSections);
data.onSectionsUpdate?.(id, updatedSections);
};
const handleAddSection = () => {
if (sections.length >= 10) {
toast.error("Maximum of 10 sections allowed");
return;
}
if (getTotalRowCount() + 1 > 10) {
toast.error("Maximum of 10 total rows allowed across all sections");
return;
}
const newSection = {
title: `Section ${sections.length + 1}`,
rows: [
{
id: generateHandleId("list-row", Date.now(), `${sections.length}-0`),
title: "New Option",
description: "Description for new option (Optional)",
},
],
};
const updatedSections = [...sections, newSection];
setSections(updatedSections);
data.onSectionsUpdate?.(id, updatedSections);
};
// Row handlers
const handleRowChange = (sectionIndex, rowIndex, field, value) => {
const updatedSections = sections.map((section, secIndex) => {
if (secIndex === sectionIndex) {
const updatedRows = section.rows.map((row, rowIdx) => {
if (rowIdx === rowIndex) {
return { ...row, [field]: value };
}
return row;
});
return { ...section, rows: updatedRows };
}
return section;
});
setSections(updatedSections);
data.onSectionsUpdate?.(id, updatedSections);
};
const handleDeleteRow = (sectionIndex, rowIndex) => {
if (getTotalRowCount() <= 1) {
toast.error("At least one row is required in total");
return;
}
const updatedSections = sections.map((section, secIndex) => {
if (secIndex === sectionIndex) {
const updatedRows = section.rows.filter((_, idx) => idx !== rowIndex);
return {
...section,
rows: updatedRows.length > 0 ? updatedRows : section.rows,
};
}
return section;
});
setSections(updatedSections);
data.onSectionsUpdate?.(id, updatedSections);
};
const handleAddRow = (sectionIndex) => {
if (getTotalRowCount() >= 10) {
toast.error("Maximum of 10 total rows allowed across all sections");
return;
}
const updatedSections = sections.map((section, index) => {
if (index === sectionIndex) {
const newRow = {
id: generateHandleId(
"list-row",
Date.now(),
`${sectionIndex}-${section.rows.length}`
),
title: "New Option",
description: "Description for new option (Optional)",
};
return {
...section,
rows: [...section.rows, newRow],
};
}
return section;
});
setSections(updatedSections);
data.onSectionsUpdate?.(id, updatedSections);
};
return (
<div className="bg-white rounded-lg shadow-lg p-4 w-[350px] relative">
<Handle
type="target"
position={Position.Left}
style={{
backgroundColor: "#4F46E5",
width: "13px",
height: "13px",
borderRadius: "50%",
}}
/>
<NodeHeader
type="List"
onDelete={data.onDelete}
messageNumber={data.messageNumber}
/>
<div className="bg-gray-50 rounded-lg p-3 space-y-3">
{/* Content Input */}
<div>
<textarea
value={content}
onChange={(e) => handleContentChange(e.target.value)}
placeholder="Message body (max 4096 chars)"
className="w-full p-2 border rounded-md text-sm min-h-[90px] resize-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
maxLength={4096}
/>
<div className="text-xs text-gray-500 text-right">
{content.length}/4096 characters
</div>
</div>
{/* Button Text Input */}
<div>
<input
type="text"
value={buttonText}
onChange={(e) => handleButtonTextChange(e.target.value)}
className="w-full p-2 border rounded-md text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Button text (max 20 chars)"
maxLength={20}
/>
<div className="text-xs text-gray-500 text-right">
{buttonText.length}/20 characters
</div>
</div>
{/* Counters */}
<div className="flex justify-between text-sm text-gray-600 mb-2">
<span>Sections: {sections.length}/10</span>
<span>Total Rows: {getTotalRowCount()}/10</span>
</div>
{/* Sections */}
{sections.map((section, sectionIndex) => (
<div key={sectionIndex} className="border rounded-lg p-2 bg-white">
{/* Section Header */}
<div className="flex justify-between items-center mb-2">
<div className="flex-1 mr-2">
<input
type="text"
value={section.title}
onChange={(e) => {
if (e.target.value.length <= 24) {
handleSectionTitleChange(sectionIndex, e.target.value);
}
}}
className="w-full p-2 border rounded-md text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Section title (max 24 chars)"
maxLength={24}
/>
<div className="text-xs text-gray-500 mt-1">
{section.title.length}/24 characters
</div>
</div>
<button
onClick={() => handleDeleteSection(sectionIndex)}
className="text-red-500 hover:text-red-700 p-1 rounded-full hover:bg-red-50"
title="Delete Section"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Rows */}
{section.rows.map((row, rowIndex) => (
<div
key={row.id}
className="mb-2 p-2 border rounded-lg bg-gray-50 relative"
>
<div className="flex mb-2 items-center">
<input
type="text"
value={row.title}
onChange={(e) => {
if (e.target.value.length <= 24) {
handleRowChange(
sectionIndex,
rowIndex,
"title",
e.target.value
);
}
}}
className="w-full p-2 border rounded-md text-sm mr-2 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Row title (max 24 chars)"
maxLength={24}
/>
<div className="text-xs text-gray-500 mr-2">
{row.title.length}/24
</div>
<button
onClick={() => handleDeleteRow(sectionIndex, rowIndex)}
className="text-red-500 hover:text-red-700 p-1 rounded-full hover:bg-red-50"
title="Delete Row"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div>
<input
type="text"
value={row.description}
onChange={(e) => {
if (e.target.value.length <= 72) {
handleRowChange(
sectionIndex,
rowIndex,
"description",
e.target.value
);
}
}}
className="w-full p-2 border rounded-md text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Row description (max 72 chars)"
maxLength={72}
/>
<div className="text-xs text-gray-500 text-right">
{row.description.length}/72 characters
</div>
</div>
<Handle
type="source"
position={Position.Right}
id={row.id}
style={{
backgroundColor: "#4F46E5",
width: "13px",
height: "13px",
borderRadius: "50%",
right: "-20px",
top: "50%",
transform: "translateY(-50%)",
}}
/>
</div>
))}
{/* Add Row Button */}
<button
onClick={() => handleAddRow(sectionIndex)}
className={`text-indigo-600 hover:text-indigo-700 text-sm mt-2 flex items-center ${
getTotalRowCount() >= 10 ? "opacity-50 cursor-not-allowed" : ""
}`}
disabled={getTotalRowCount() >= 10}
>
<svg
className="w-4 h-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
Add Row
</button>
</div>
))}
{/* Add Section Button */}
<button
onClick={handleAddSection}
className={`text-indigo-600 hover:text-indigo-700 text-sm flex items-center justify-center w-full py-2 border-2 border-dashed border-indigo-200 rounded-lg hover:bg-indigo-50 transition-colors ${
sections.length >= 10 || getTotalRowCount() >= 10
? "opacity-50 cursor-not-allowed"
: ""
}`}
disabled={sections.length >= 10 || getTotalRowCount() >= 10}
>
<svg
className="w-4 h-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
Add Section
</button>
</div>
</div>
);
};
// Attribute Node Component
const AttributeNode = ({ data, id }) => {
const [attributes, setAttributes] = useState([]);
const [selectedAttribute, setSelectedAttribute] = useState(
data.attribute1 || ""
);
const [predefinedValue, setPredefinedValue] = useState(data.attribute2 || "");
// Fetch attributes only once when the component is first created
useEffect(() => {
const fetchAttributes = async () => {
try {
const response = await axios.get(
process.env.NEXT_PUBLIC_BASE_URL + "/whatsapp/attribute/"
);
setAttributes(response.data?.results || []);
} catch (err) {
toast.error("Failed to load attributes");
}
};
// Only fetch if attributes haven't been loaded yet
if (attributes.length === 0) {
fetchAttributes();
}
}, []);
// Effect to update predefined value based on source node's data
useEffect(() => {
// Check for button text from various node types
if (data.sourceButtonText) {
setPredefinedValue(data.sourceButtonText);
data.onAttributeUpdate?.(id, "attribute2", data.sourceButtonText);
}
}, [data.sourceButtonText]);
const handleAttributeChange = (value) => {
setSelectedAttribute(value);
data.onAttributeUpdate?.(id, "attribute1", value);
};
const handlePredefinedValueChange = (value) => {
setPredefinedValue(value);
data.onAttributeUpdate?.(id, "attribute2", value);
};
return (
<div className="bg-white rounded-lg shadow-lg p-4 w-[350px] relative">
{/* Left handle for incoming connections */}
<NodeHeader
type="Attribute"
onDelete={data.onDelete}
messageNumber={data.messageNumber}
/>
<Handle
type="target"
position={Position.Left}
style={{
backgroundColor: "indigo",
width: "13px",
height: "13px",
borderRadius: "50%",
left: "-6px",
}}
/>
{/* Right handle for outgoing connections */}
<Handle
type="source"
position={Position.Right}
style={{
backgroundColor: "indigo",
width: "13px",
height: "13px",
borderRadius: "50%",
right: "-6px",
}}
/>
<div className="bg-gray-50 rounded-lg p-3 space-y-3">
{/* Attribute Dropdown/Combobox */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Select Attribute
</label>
<Combobox
options={attributes.map((attr) => ({
value: attr.name,
label: attr.name,
}))}
value={selectedAttribute}
onValueChange={handleAttributeChange}
placeholder="Choose or create attribute"
createOption
/>
</div>
{/* Predefined Value Input */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Attribute Value
</label>
<input
type="text"
value={predefinedValue}
onChange={(e) => handlePredefinedValueChange(e.target.value)}
className="w-full p-2 border rounded-md text-sm"
placeholder="Attribute value"
/>
</div>
</div>
</div>
);
};
const nodeTypes = {
triggerNode: TriggerNode,
textButtonNode: TextButtonNode,
mediaNode: MediaNode,
listNode: ListNode,
attributeNode: AttributeNode,
};
const edgeTypes = {
custom: CustomEdge,
};
const FlowHeader = ({ flowName, flowId, generateFlowData }) => {
const router = useRouter();
const [isSaving, setIsSaving] = useState(false);
const [isPublishing, setIsPublishing] = useState(false);
const validateNodes = (nodes) => {
const errors = [];
nodes.forEach((node) => {
switch (node.type) {
case "textButtonNode":
// Message body validation
if (!node.data.content?.trim()) {
errors.push(
`Message ${node.data.messageNumber}: Message body is required`
);
}
// Buttons validation
if (!node.data.buttons || node.data.buttons.length === 0) {
errors.push(
`Message ${node.data.messageNumber}: At least one button is required`
);
} else {
// Check each button has label (text)
node.data.buttons.forEach((button, index) => {
if (!button.label?.trim()) {
// Changed from title to label
errors.push(
`Message ${node.data.messageNumber}: Button ${
index + 1
} must have text`
);
}
// Validate button text length
if (button.label?.length > 20) {
errors.push(
`Message ${node.data.messageNumber}: Button ${
index + 1
} text cannot exceed 20 characters`
);
}
});
// Validate maximum buttons
if (node.data.buttons.length > 3) {
errors.push(
`Message ${node.data.messageNumber}: Cannot have more than 3 buttons`
);
}
}
break;
case "mediaNode":
// Message body validation
if (!node.data.content?.trim()) {
errors.push(
`Message ${node.data.messageNumber}: Message body is required`
);
}
// Media validation
if (!node.data.mediaUrl) {
errors.push(
`Message ${node.data.messageNumber}: Media file is required`
);
}
// Buttons validation
if (!node.data.buttons || node.data.buttons.length === 0) {
errors.push(
`Message ${node.data.messageNumber}: At least one button is required`
);
} else {
// Check each button has label (text)
node.data.buttons.forEach((button, index) => {
if (!button.label?.trim()) {
// Changed from title to label
errors.push(
`Message ${node.data.messageNumber}: Button ${
index + 1
} must have text`
);
}
// Validate button text length
if (button.label?.length > 20) {
errors.push(
`Message ${node.data.messageNumber}: Button ${
index + 1
} text cannot exceed 20 characters`
);
}
});
// Validate maximum buttons
if (node.data.buttons.length > 3) {
errors.push(
`Message ${node.data.messageNumber}: Cannot have more than 3 buttons`
);
}
}
break;
case "listNode":
// Message body validation
if (!node.data.content?.trim()) {
errors.push(
`Message ${node.data.messageNumber}: Message body is required`
);
}
// List button text validation
if (!node.data.buttonText?.trim()) {
errors.push(
`Message ${node.data.messageNumber}: List button text is required`
);
}
// Validate button text length
if (node.data.buttonText?.length > 20) {
errors.push(
`Message ${node.data.messageNumber}: List button text cannot exceed 20 characters`
);
}
// Sections validation
if (!node.data.sections || node.data.sections.length === 0) {
errors.push(
`Message ${node.data.messageNumber}: At least one section is required`
);
} else {
// Validate each section
node.data.sections.forEach((section, sectionIndex) => {
// Section title validation
if (!section.title?.trim()) {
errors.push(
`Message ${node.data.messageNumber}: Section ${
sectionIndex + 1
} must have a title`
);
}
// Validate section title length
if (section.title?.length > 24) {
errors.push(
`Message ${node.data.messageNumber}: Section ${
sectionIndex + 1
} title cannot exceed 24 characters`
);
}
// Rows validation
if (!section.rows || section.rows.length === 0) {
errors.push(
`Message ${node.data.messageNumber}: Section ${
sectionIndex + 1
} must have at least one row`
);
} else {
// Validate each row
section.rows.forEach((row, rowIndex) => {
if (!row.title?.trim()) {
errors.push(
`Message ${node.data.messageNumber}: Row ${
rowIndex + 1
} in Section ${sectionIndex + 1} must have a title`
);
}
// Validate row title length
if (row.title?.length > 24) {
errors.push(
`Message ${node.data.messageNumber}: Row ${
rowIndex + 1
} in Section ${
sectionIndex + 1
} title cannot exceed 24 characters`
);
}
// Validate row description length if present
if (row.description && row.description.length > 72) {
errors.push(
`Message ${node.data.messageNumber}: Row ${
rowIndex + 1
} in Section ${
sectionIndex + 1
} description cannot exceed 72 characters`
);
}
});
}
});
// Validate section count
if (node.data.sections.length > 10) {
errors.push(
`Message ${node.data.messageNumber}: Cannot have more than 10 sections`
);
}
// Validate total row count across all sections
const totalRows = node.data.sections.reduce(
(total, section) => total + (section.rows?.length || 0),
0
);
if (totalRows > 10) {
errors.push(
`Message ${node.data.messageNumber}: Total number of rows cannot exceed 10`
);
}
}
break;
case "attributeNode":
if (!node.data.attribute1?.trim()) {
errors.push(`Attribute Node: Select Attribute is required`);
}
if (!node.data.attribute2?.trim()) {
errors.push(`Attribute Node: Attribute Value is required`);
}
break;
}
});
return errors;
};
const handlePublish = async () => {
setIsSaving(true);
try {
setIsPublishing(true);
// Call the function to get flow data
const flowData = generateFlowData();
console.log(flowData, "this is result");
if (!flowData || !flowData.nodes || !flowData.edges) {
toast.error("Invalid flow data");
return;
}
// Basic validation
if (!flowData.nodes.length) {
toast.error("Flow must contain at least one node");
return;
}
if (!flowData.edges.length) {
toast.error("Flow must contain at least one connection");
return;
}
// Validate nodes
const validationErrors = validateNodes(flowData.nodes);
if (validationErrors.length > 0) {
// Show all errors in a list using toast
toast(
(t) => (
<div>
<h3 className="font-semibold mb-2">
Please fix the following errors:
</h3>
<ul className="list-disc pl-4">
{validationErrors.map((error, index) => (
<li key={index} className="text-sm">
{error}
</li>
))}
</ul>
</div>
),
{
duration: 4000, // Longer duration to read all errors
style: {
maxWidth: "500px",
padding: "16px",
},
}
);
return;
}
const response = await axios.post(
`${process.env.NEXT_PUBLIC_BASE_URL}/automation/${flowId}/save/`,
{
flowData: flowData,
}
);
toast.success("Flow published successfully");
setIsSaving(false);
} catch (error) {
setIsSaving(false);
console.error("Error publishing flow:", error);
toast.error("Failed to publish flow");
} finally {
setIsSaving(false);
setIsPublishing(false);
}
};
return (
<div className="bg-white border-b border-gray-200">
<div className="px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
{/* Left side - Back arrow, Flow name and save status */}
<div className="flex items-center space-x-4">
<button
onClick={() => router.push("/automations/whatsapp")}
className="p-1 rounded-full hover:bg-gray-100 transition-colors duration-200"
title="Go back"
>
<svg
className="w-6 h-6 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg>
</button>
<div className="flex items-center space-x-4">
<h1 className="text-xl font-semibold text-gray-900 flex items-center">
{flowName}
</h1>
<span className="text-sm bg-gray-50 px-3 py-1 rounded-full border border-gray-200 text-gray-600 flex items-center">
{isSaving ? (
<div className="flex items-center">
<svg
className="animate-spin h-4 w-4 mr-1.5 text-indigo-600"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Saving changes...
</div>
) : (
<div className="flex items-center">
<svg
className="h-4 w-4 mr-1.5 text-green-500"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M5 13l4 4L19 7" />
</svg>
All changes saved
</div>
)}
</span>
</div>
</div>
{/* Right side - Actions */}
<div className="flex items-center space-x-4">
{/* Test Button */}
<button
onClick={() => {}} // Add your test logic here
className="inline-flex items-center px-4 py-2 border border-indigo-600 rounded-md text-sm font-medium text-indigo-700 bg-white hover:bg-indigo-50 hover:border-indigo-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 shadow-sm"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
Test Flow
</button>
{/* Publish Button */}
<button
onClick={handlePublish}
className={`inline-flex items-center px-4 py-2 border border-transparent rounded-md text-sm font-medium text-white shadow-sm transition-colors duration-200 ${
isPublishing
? "bg-indigo-500 cursor-not-allowed"
: "bg-indigo-600 hover:bg-indigo-700"
} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500`}
disabled={isPublishing}
>
{isPublishing ? (
<>
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Publishing...
</>
) : (
<>
<svg
className="h-4 w-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
Publish Flow
</>
)}
</button>
</div>
</div>
</div>
</div>
);
};
// In your Flow component
const Flow = ({ setGenerateFlowData, initialFlowData, flowId }) => {
// Add event handlers to the initial nodes
const onEdgeDelete = useCallback((edgeId) => {
setEdges((eds) => eds.filter((edge) => edge.id !== edgeId));
}, []);
// Initialize nodes
const defaultTriggerNode = {
id: "1",
type: "triggerNode",
position: { x: 400, y: 200 },
data: {
keywords: ["Hi"],
onKeywordsUpdate: handleKeywordsUpdate,
},
};
const [nodes, setNodes, onNodesChange] = useNodesState(
initialFlowData?.nodes && initialFlowData.nodes.length > 0
? initialFlowData.nodes
: [defaultTriggerNode]
);
// Initialize edges with the ref function
const [edges, setEdges, onEdgesChange] = useEdgesState(
initialFlowData?.edges?.map((edge) => ({
id: edge.sourceHandle
? `edge-${edge.source}-${edge.sourceHandle}-${edge.target}`
: `edge-${edge.source}-${edge.target}`,
source: edge.source,
sourceHandle: edge.sourceHandle,
target: edge.target,
type: "custom",
data: {
sourceButtonText: edge.data?.sourceButtonText,
onDelete: onEdgeDelete,
},
})) || []
);
const [isDropdownVisible, setDropdownVisible] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState({ x: 0, y: 0 });
const [pendingConnection, setPendingConnection] = useState(null);
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
useEffect(() => {
if (initialFlowData?.nodes) {
setNodes((nodes) =>
nodes.map((node) => ({
...node,
data: {
...node.data,
onKeywordsUpdate: handleKeywordsUpdate, // Add this line
onButtonUpdate: handleButtonsUpdate,
onContentUpdate: handleContentUpdate,
onButtonsUpdate: handleButtonsUpdate,
onMediaUpdate: handleMediaUpdate,
onItemsUpdate: handleItemsUpdate,
onAttributeUpdate: handleAttributeUpdate,
onButtonTextUpdate: handleButtonTextUpdate,
onSectionsUpdate: handleSectionsUpdate,
onDelete: () => handleNodeDelete(node.id),
},
}))
);
}
}, [initialFlowData]);
// Handle node deletion
const onNodesDelete = useCallback(
(deleted) => {
// Filter out the trigger node from deletion
const nodesToDelete = deleted.filter(
(node) => node.type !== "triggerNode"
);
// Remove associated edges
const edgesToDelete = edges.filter((edge) =>
nodesToDelete.find(
(node) => node.id === edge.source || node.id === edge.target
)
);
setEdges((eds) =>
eds.filter((edge) => !edgesToDelete.find((del) => del.id === edge.id))
);
},
[edges, setEdges]
);
function handleButtonsUpdate(nodeId, newButtons) {
setNodes((nds) =>
nds.map((node) => {
if (node.id === nodeId) {
return {
...node,
data: {
...node.data,
buttons: newButtons,
},
};
}
return node;
})
);
}
function handleContentUpdate(nodeId, newContent) {
setNodes((nds) =>
nds.map((node) => {
if (node.id === nodeId) {
return {
...node,
data: {
...node.data,
content: newContent,
},
};
}
return node;
})
);
}
function handleMediaUpdate(
nodeId,
mediaUrl,
mediaType,
uploadedId,
fileName
) {
setNodes((nds) =>
nds.map((node) => {
if (node.id === nodeId) {
return {
...node,
data: {
...node.data,
mediaUrl,
mediaType,
uploadedId: uploadedId, // Make sure this is set
fileName: fileName, // Make sure this is set
},
};
}
return node;
})
);
}
function handleItemsUpdate(nodeId, newItems) {
setNodes((nds) =>
nds.map((node) => {
if (node.id === nodeId) {
return {
...node,
data: {
...node.data,
items: newItems,
},
};
}
return node;
})
);
}
function handleAttributeUpdate(nodeId, field, value) {
setNodes((nds) =>
nds.map((node) => {
if (node.id === nodeId) {
return {
...node,
data: {
...node.data,
[field]: value,
},
};
}
return node;
})
);
}
function handleKeywordsUpdate(nodeId, newKeywords) {
setNodes((nds) =>
nds.map((node) => {
if (node.id === nodeId) {
return {
...node,
data: {
...node.data,
keywords: newKeywords,
onKeywordsUpdate: handleKeywordsUpdate,
},
};
}
return node;
})
);
}
function handleButtonTextUpdate(nodeId, newButtonText) {
setNodes((nds) =>
nds.map((node) => {
if (node.id === nodeId) {
return {
...node,
data: {
...node.data,
buttonText: newButtonText,
},
};
}
return node;
})
);
}
function handleSectionsUpdate(nodeId, newSections) {
setNodes((nds) =>
nds.map((node) => {
if (node.id === nodeId) {
return {
...node,
data: {
...node.data,
sections: newSections,
},
};
}
return node;
})
);
}
const onConnectStart = useCallback((_, { nodeId, handleId }) => {
setPendingConnection({ sourceId: nodeId, sourceHandleId: handleId });
setDropdownVisible(false);
}, []);
const onConnectEnd = useCallback((event) => {
const targetNode = event.target.closest(".react-flow__node");
if (!targetNode) {
const bounds = document
.querySelector(".react-flow")
.getBoundingClientRect();
// Calculate position relative to the viewport
const position = {
x: event.clientX - bounds.left,
y: event.clientY - bounds.top,
};
setDropdownPosition(position);
setMousePosition(position);
setDropdownVisible(true);
}
}, []);
/// Modify the onConnect function to ensure onDelete is passed
const onConnect = useCallback(
(params) => {
const sourceNode = nodes.find((node) => node.id === params.source);
const sourceButtonText = sourceNode?.data?.buttons?.find(
(btn) => btn.handleId === params.sourceHandle
)?.label;
const newEdge = {
...params,
id: params.sourceHandle
? `edge-${params.source}-${params.sourceHandle}-${params.target}`
: `edge-${params.source}-${params.target}`,
type: "custom",
data: {
onDelete: onEdgeDelete,
sourceButtonText: sourceButtonText,
},
};
setEdges((eds) => addEdge(newEdge, eds));
},
[nodes, onEdgeDelete]
);
const isConnectionAllowed = useCallback(() => {
return true; // Allow all connections
}, []);
// Add this state to track the flow viewport
const [viewport, setViewport] = useState({ x: 0, y: 0, zoom: 1 });
// Update viewport state when it changes
const onViewportChange = useCallback((viewport) => {
setViewport(viewport);
}, []);
const handleDropdownSelect = useCallback(
(option) => {
if (!pendingConnection) return;
const newNodeId = `node-${Date.now()}`;
const messageNodes = nodes.filter((node) =>
["textButtonNode", "mediaNode", "listNode"].includes(node.type)
);
const messageNumber = messageNodes.length + 1;
// Calculate the actual position considering viewport transform
const position = {
x: (dropdownPosition.x - viewport.x) / viewport.zoom,
y: (dropdownPosition.y - viewport.y) / viewport.zoom,
};
let nodeType;
switch (option) {
case "Text + Button":
nodeType = "textButtonNode";
break;
case "Media":
nodeType = "mediaNode";
break;
case "List":
nodeType = "listNode";
break;
case "Attribute":
nodeType = "attributeNode";
break;
default:
nodeType = "textButtonNode";
}
// Create new node with corrected position
const newNode = {
id: newNodeId,
type: nodeType,
position: position, // Use the corrected position
data: {
type: option,
content: "",
buttons: [],
messageNumber: nodeType !== "attributeNode" ? messageNumber : null,
onButtonUpdate: handleButtonsUpdate,
onContentUpdate: handleContentUpdate,
onButtonsUpdate: handleButtonsUpdate,
onMediaUpdate: handleMediaUpdate,
onItemsUpdate: handleItemsUpdate,
onAttributeUpdate: handleAttributeUpdate,
onButtonTextUpdate: handleButtonTextUpdate,
onSectionsUpdate: handleSectionsUpdate,
onDelete: () => handleNodeDelete(newNodeId),
},
};
// Create new edge if there's a pending connection
if (pendingConnection.sourceId) {
const newEdge = {
id: pendingConnection.sourceHandleId
? `edge-${pendingConnection.sourceId}-${pendingConnection.sourceHandleId}-${newNodeId}`
: `edge-${pendingConnection.sourceId}-${newNodeId}`,
source: pendingConnection.sourceId,
sourceHandle: pendingConnection.sourceHandleId,
target: newNodeId,
type: "custom",
data: {
onDelete: onEdgeDelete,
},
};
setEdges((eds) => [...eds, newEdge]);
}
setNodes((nds) => [...nds, newNode]);
setDropdownVisible(false);
setPendingConnection(null);
},
[pendingConnection, dropdownPosition, viewport, nodes, setNodes, setEdges]
);
// Handle node deletion
const handleNodeDelete = useCallback(
(nodeId) => {
setNodes((nds) => {
const filteredNodes = nds.filter((node) => node.id !== nodeId);
// Recalculate message numbers
return filteredNodes.map((node) => {
if (["textButtonNode", "mediaNode", "listNode"].includes(node.type)) {
const newMessageNumber =
filteredNodes.filter(
(n) =>
["textButtonNode", "mediaNode", "listNode"].includes(
n.type
) && n.id < node.id
).length + 1;
return {
...node,
data: {
...node.data,
messageNumber: newMessageNumber,
},
};
}
return node;
});
});
setEdges((eds) =>
eds.filter((edge) => edge.source !== nodeId && edge.target !== nodeId)
);
},
[setNodes, setEdges]
);
const generateFlowData = useCallback(() => {
const mappedNodes = nodes.map((node) => {
const baseNodeData = {
id: node.id,
type: node.type,
position: node.position,
};
switch (node.type) {
case "triggerNode":
return {
...baseNodeData,
data: {
keywords: node.data.keywords || [],
},
};
case "textButtonNode":
return {
...baseNodeData,
data: {
content: node.data.content || "",
buttons:
node.data.buttons?.map((button) => ({
label: button.label,
handleId: button.handleId,
})) || [],
messageNumber: node.data.messageNumber,
},
};
case "mediaNode":
return {
...baseNodeData,
data: {
content: node.data.content || "",
mediaUrl: node.data.mediaUrl || "",
mediaType: node.data.mediaType || "",
uploadedId: node.data.uploadedId || "",
fileName: node.data.fileName || "",
buttons:
node.data.buttons?.map((button) => ({
label: button.label,
handleId: button.handleId,
})) || [],
messageNumber: node.data.messageNumber,
},
};
case "listNode":
return {
...baseNodeData,
data: {
content: node.data.content || "",
buttonText: node.data.buttonText || "Options",
sections:
node.data.sections?.map((section) => ({
title: section.title,
rows: section.rows.map((row) => ({
id: row.id,
title: row.title,
description: row.description,
})),
})) || [],
messageNumber: node.data.messageNumber,
},
};
case "attributeNode":
return {
...baseNodeData,
data: {
attribute1: node.data.attribute1 || "",
attribute2: node.data.attribute2 || "",
},
};
default:
return baseNodeData;
}
});
// Modify how edges are mapped to include all necessary data
const mappedEdges = edges.map((edge) => ({
id: edge.sourceHandle
? `edge-${edge.source}-${edge.sourceHandle}-${edge.target}`
: `edge-${edge.source}-${edge.target}`,
source: edge.source,
sourceHandle: edge.sourceHandle,
target: edge.target,
type: edge.type,
data: {
sourceButtonText: edge.data?.sourceButtonText,
},
}));
return {
nodes: mappedNodes,
edges: mappedEdges,
};
}, [nodes, edges]);
// Update the parent component with the generation function
useEffect(() => {
setGenerateFlowData(() => generateFlowData);
}, [generateFlowData, setGenerateFlowData]);
const [reactFlowInstance, setReactFlowInstance] = useState(null);
// Function to handle quick navigation
const handleQuickNav = (direction) => {
if (!reactFlowInstance) return;
const nodes = reactFlowInstance.getNodes();
if (!nodes.length) return;
// Calculate bounds
let targetY;
if (direction === "top") {
targetY = Math.min(...nodes.map((node) => node.position.y)) - 100;
} else {
targetY = Math.max(...nodes.map((node) => node.position.y)) + 100;
}
// Smooth scroll to position
reactFlowInstance.setViewport(
{
x: reactFlowInstance.getViewport().x,
y: -targetY,
zoom: reactFlowInstance.getViewport().zoom,
},
{ duration: 800 }
);
};
// Add new state for tracking save status
const [lastSaved, setLastSaved] = useState(null);
const [saveStatus, setSaveStatus] = useState("All changes saved");
// Function to format current date-time in UTC
const formatDateTime = () => {
const now = new Date();
return now.toLocaleTimeString("en-IN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: true,
});
};
// Function to save flow data
const saveFlowData = useCallback(async () => {
try {
const flowData = generateFlowData();
const response = await axios.post(
`${process.env.NEXT_PUBLIC_BASE_URL}/automation/${flowId}/save/`,
{
flowData: flowData,
}
);
if (response.status !== 200) {
throw new Error("Failed to save");
}
setLastSaved(formatDateTime());
setSaveStatus("All changes saved");
console.log("all change saved");
// Optional: Show success toast
// toast.success('Flow autosaved successfully');
} catch (error) {
console.error("Autosave failed:", error);
setSaveStatus("Failed to save");
toast.error("Failed to autosave flow");
}
}, [generateFlowData, flowId]);
// Set up autosave interval
useEffect(() => {
if (!flowId) return; // Don't set up autosave if no flowId
// Save every 5 minutes
const autoSaveInterval = setInterval(() => {
setSaveStatus("Saving...");
saveFlowData();
}, 5 * 60 * 1000); // 5 minutes
// Save when user leaves the page
// const handleBeforeUnload = (e) => {
// saveFlowData();
// e.preventDefault();
// e.returnValue =
// "You have unsaved changes. Are you sure you want to leave?";
// };
// window.addEventListener("beforeunload", handleBeforeUnload);
// Cleanup
return () => {
clearInterval(autoSaveInterval);
// window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, [saveFlowData, flowId]);
return (
<div className="relative w-full h-[calc(100vh-64px)] overflow-hidden">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnectStart={onConnectStart}
onConnectEnd={onConnectEnd}
onConnect={onConnect}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
defaultEdgeOptions={{
type: "custom",
data: { onDelete: onEdgeDelete },
}}
connectionMode="loose"
fitView
onInit={setReactFlowInstance}
onViewportChange={onViewportChange}
>
<Controls
position="bottom-left"
style={{ position: "fixed", left: 20, bottom: 20 }}
showInteractive={false}
/>
<MiniMap
position="bottom-right"
style={{ position: "fixed", right: 20, bottom: 100 }}
zoomable
pannable
/>
<Background gap={12} size={1} />
</ReactFlow>
<div className="absolute right-8 top-4 flex items-center gap-2 bg-white px-4 py-2 rounded-md shadow-sm border border-gray-200 z-[1000]">
<div className="flex items-center gap-2">
{saveStatus === "Saving..." ? (
<svg
className="animate-spin h-4 w-4 text-gray-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
) : saveStatus === "Failed to save" ? (
<svg
className="h-4 w-4 text-red-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
) : (
<svg
className="h-4 w-4 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M5 13l4 4L19 7"
></path>
</svg>
)}
<span className="text-sm text-gray-600">{saveStatus}</span>
</div>
{lastSaved && (
<span className="text-xs text-gray-400">Last saved: {lastSaved}</span>
)}
</div>
{/* Quick Navigation Controls */}
{/* <div className="absolute right-8 top-1/2 transform -translate-y-1/2 flex flex-col gap-4 z-[1000]">
<button
onClick={() => handleQuickNav("top")}
className="p-3 bg-white rounded-full shadow-lg hover:bg-gray-50 transition-colors duration-200 group"
title="Scroll to top"
>
<svg
className="w-5 h-5 text-gray-600 group-hover:text-indigo-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 15l7-7 7 7"
/>
</svg>
</button>
<button
onClick={() => handleQuickNav("bottom")}
className="p-3 bg-white rounded-full shadow-lg hover:bg-gray-50 transition-colors duration-200 group"
title="Scroll to bottom"
>
<svg
className="w-5 h-5 text-gray-600 group-hover:text-indigo-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
</div> */}
{isDropdownVisible && (
<div className="z-[1000]">
<NodeTypeDropdown
options={["Text + Button", "Media", "List", "Attribute"]}
onSelect={handleDropdownSelect}
onCancel={() => {
setDropdownVisible(false);
setPendingConnection(null);
}}
position={dropdownPosition}
/>
</div>
)}
</div>
);
};
// Add this function to handle publishing
export default function WhatsAppFlow() {
const router = useRouter();
const { flowId } = router.query;
const [flowName, setFlowName] = useState("");
const [generateFlowData, setGenerateFlowData] = useState(() => () => ({}));
const [initialFlowData, setInitialFlowData] = useState(null);
useEffect(() => {
if (flowId) {
const fetchFlowDetails = async () => {
try {
const response = await axios.get(
`${process.env.NEXT_PUBLIC_BASE_URL}/automation/${flowId}/`
);
setFlowName(response.data.name);
// If there's no flowData or it's empty, create default structure
const flowData = response.data.flowData || {
nodes: [],
edges: [],
};
setInitialFlowData(flowData);
console.log(response.data.flowData, "!!!!!!!!!!");
} catch (error) {
// If it fails to fetch (new flow), initialize with empty data
setInitialFlowData({
nodes: [],
edges: [],
});
toast.error("Failed to load flow details");
console.error("Error fetching flow details:", error);
}
};
fetchFlowDetails();
}
}, [flowId]);
if (!flowId || !initialFlowData) return null;
return (
<Layout>
<FlowHeader
flowName={flowName}
flowId={flowId}
generateFlowData={generateFlowData}
/>
<ReactFlowProvider>
<Flow
setGenerateFlowData={setGenerateFlowData}
initialFlowData={initialFlowData}
flowId={flowId}
/>
</ReactFlowProvider>
<Toaster position="top-center" />
</Layout>
);
}
Comments
Post a Comment