diff --git a/src/components/ComponentDetail/ButtonGrid.tsx b/src/components/ComponentDetail/ButtonGrid.tsx index 5ef54e9..f4d2f3a 100644 --- a/src/components/ComponentDetail/ButtonGrid.tsx +++ b/src/components/ComponentDetail/ButtonGrid.tsx @@ -1,22 +1,37 @@ -import { Plus } from "lucide-react"; +import { Plus, Grid, MessageSquare, Reply } from "lucide-react"; import EditableButton from "./EditableButton"; -import { GridItem } from "../../types/ComponentDetailForm"; +import { + GridItem, + KeyboardType, + formValuesType, +} from "../../types/ComponentDetailForm"; import { generateUUID } from "./generateUUID"; +import { componentSchemaType } from "./makeFormData"; type ButtonGridProps = { rows: GridItem[][]; setRows: React.Dispatch>; + keyboardType: KeyboardType; + setKeyboardType: React.Dispatch>; + formValues: formValuesType; + componentSchema: componentSchemaType; + componentName: string; }; -export default function ButtonGrid({ rows, setRows }: ButtonGridProps) { - const MAX_ROWS = 5; - const MAX_COLS = 4; +export default function ButtonGrid({ + rows, + setRows, + keyboardType, + setKeyboardType, +}: ButtonGridProps) { + const MAX_ROWS = 3; + const MAX_COLS = 3; const addItemToRow = (rowIndex: number) => { setRows((prev) => prev.map((row, idx) => idx === rowIndex && row.length < MAX_COLS ? - [...row, { id: generateUUID(), label: "Item" }] + [...row, { id: generateUUID(), label: "New Button" }] : row, ), ); @@ -25,52 +40,117 @@ export default function ButtonGrid({ rows, setRows }: ButtonGridProps) { const addRow = () => { setRows((prev) => { if (prev.length >= MAX_ROWS) return prev; - const lastRow = prev[prev.length - 1]; - if (lastRow.length === 0) return prev; - return [...prev, [{ id: generateUUID(), label: "Item" }]]; + return [...prev, [{ id: generateUUID(), label: "New Button" }]]; }); }; return ( -
- {rows.map((row, rowIndex) => ( -
- {row.map((item, itemIndex) => { - return ( -
-
- +
+ {rows.length === 0 || rows.every((row) => row.length === 0) ? +
+ +

+ No Buttons Yet +

+

+ Start by adding your first button to create an interactive grid +

+ +
+ :
+ {/* Header with Type Toggle */} +
+
+ + Keyboard Type: + + + {/* Segmented Control */} +
+ + +
+
+
+ +
+ {rows.map((row, rowIndex) => ( +
+
+ {row.map((item, itemIndex) => ( +
+
+ +
+
+ ))}
+ +
- ); - })} + ))} - {row.length < MAX_COLS && ( - - )} + {rows.length < MAX_ROWS && ( + + )} +
- ))} - - {rows.length < MAX_ROWS && rows[rows.length - 1].length > 0 && ( - - )} + }
); } diff --git a/src/components/ComponentDetail/EditableButton.tsx b/src/components/ComponentDetail/EditableButton.tsx index 680105f..8e0bd82 100644 --- a/src/components/ComponentDetail/EditableButton.tsx +++ b/src/components/ComponentDetail/EditableButton.tsx @@ -1,4 +1,4 @@ -import { X } from "lucide-react"; +import { X, Pencil } from "lucide-react"; import { useState } from "react"; import { GridItem } from "../../types/ComponentDetailForm"; @@ -18,41 +18,28 @@ export default function EditableButton({ const [editingItemId, setEditingItemId] = useState(null); const [editingLabel, setEditingLabel] = useState(""); - function trimRows(newRows: GridItem[][], rowIndex: number): GridItem[][] { - if ( - newRows[rowIndex].length === 0 && - rowIndex + 1 < newRows.length && - newRows[rowIndex + 1].length > 0 - ) { - newRows[rowIndex].push(newRows[rowIndex + 1].shift()!); - } else { - return newRows; - } - - return trimRows(newRows, rowIndex + 1); - } - - const saveEdit = (itemId: string) => { - setRows((prev) => - prev.map((row) => + const saveEdit = (id: string) => { + setRows((prev) => { + const newRows = prev.map((row) => row.map((item) => - item.id === itemId ? { ...item, label: editingLabel } : item, + item.id === id ? { ...item, label: editingLabel } : item, ), - ), - ); + ); + return newRows; + }); setEditingItemId(null); - setEditingLabel(""); }; const removeItem = (rowIndex: number, itemIndex: number) => { setRows((prev) => { - let newRows = prev.map((row) => [...row]); - newRows[rowIndex].splice(itemIndex, 1); - - newRows = trimRows(newRows, rowIndex); + const newRows = [...prev]; + newRows[rowIndex] = newRows[rowIndex].filter( + (_, idx) => idx !== itemIndex, + ); - if (newRows[newRows.length - 1].length === 0 && newRows.length > 1) { - newRows.pop(); + // Remove empty rows + if (newRows[rowIndex].length === 0) { + newRows.splice(rowIndex, 1); } return newRows; @@ -60,7 +47,7 @@ export default function EditableButton({ }; return ( - <> +
{editingItemId === item.id ? setEditingLabel( - e.target.value.length > 0 ? e.target.value : "default", + e.target.value.length > 0 ? e.target.value : "New Button", ) } onBlur={() => saveEdit(item.id)} onKeyDown={(e) => { if (e.key === "Enter") saveEdit(item.id); }} - className="mx-auto my-auto text-center outline-none input-primary" + className="w-full rounded-lg border border-white/20 bg-white/10 px-4 py-2 text-center text-white placeholder-white/50 backdrop-blur-sm transition-all duration-200 outline-none focus:border-white/40 focus:ring-2 focus:ring-white/20" + placeholder="Enter button text..." /> :
{ setEditingItemId(item.id); setEditingLabel(item.label); }} > - {item.label} + {item.label} +
} - +
); } diff --git a/src/components/ComponentDetail/FormFields.tsx b/src/components/ComponentDetail/FormFields.tsx index b065d4e..24faf12 100644 --- a/src/components/ComponentDetail/FormFields.tsx +++ b/src/components/ComponentDetail/FormFields.tsx @@ -184,24 +184,26 @@ export default function FormFields({ })} {canCollapse && ( - + <> + + )}
); diff --git a/src/components/ComponentDetail/TelegramPreview.tsx b/src/components/ComponentDetail/TelegramPreview.tsx new file mode 100644 index 0000000..558520f --- /dev/null +++ b/src/components/ComponentDetail/TelegramPreview.tsx @@ -0,0 +1,525 @@ +import { + GridItem, + KeyboardType, + formValuesType, +} from "../../types/ComponentDetailForm"; +import { + Send, + MessageCircle, + FileText, + Image, + CheckCircle, + XCircle, + Video, + Music, + FileArchive, + FileCode, + FileSpreadsheet, + Download, + Phone, + MoreVertical, + Search, +} from "lucide-react"; +import { componentSchemaType } from "./makeFormData"; + +type TelegramPreviewProps = { + rows: GridItem[][]; + keyboardType: KeyboardType; + formValues: formValuesType; + componentSchema: componentSchemaType; + componentName: string; +}; + +export default function TelegramPreview({ + rows, + keyboardType, + formValues, + componentSchema, + componentName, +}: TelegramPreviewProps) { + const hasButtons = rows.length > 0 && rows.some((row) => row.length > 0); + + // Helper function to get file type and appropriate icon + const getFileTypeInfo = (fileName: string) => { + const ext = fileName.toLowerCase().split(".").pop() || ""; + + if (["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp"].includes(ext)) { + return { type: "image", icon: Image, color: "text-green-400" }; + } + if (["mp4", "avi", "mov", "wmv", "flv", "webm", "mkv"].includes(ext)) { + return { type: "video", icon: Video, color: "text-red-400" }; + } + if (["mp3", "wav", "flac", "aac", "ogg", "m4a"].includes(ext)) { + return { type: "audio", icon: Music, color: "text-purple-400" }; + } + if (["zip", "rar", "7z", "tar", "gz", "bz2"].includes(ext)) { + return { type: "archive", icon: FileArchive, color: "text-orange-400" }; + } + if ( + [ + "js", + "ts", + "jsx", + "tsx", + "html", + "css", + "py", + "java", + "cpp", + "c", + "php", + "rb", + "go", + "rs", + ].includes(ext) + ) { + return { type: "code", icon: FileCode, color: "text-blue-400" }; + } + if (["xlsx", "xls", "csv", "ods"].includes(ext)) { + return { + type: "spreadsheet", + icon: FileSpreadsheet, + color: "text-green-400", + }; + } + if (["pdf", "doc", "docx", "txt", "rtf", "odt"].includes(ext)) { + return { type: "document", icon: FileText, color: "text-blue-400" }; + } + + return { type: "other", icon: Download, color: "text-gray-400" }; + }; + + const renderFieldValue = ( + _key: string, + value: unknown, + schema: { type: string; verbose_name?: string }, + ) => { + if (!value && value !== false) return null; + + switch (schema.type) { + case "BooleanField": + return ( +
+ {value === "true" ? + <> + + Yes + + : <> + + No + + } +
+ ); + + case "FileField": + if (typeof value === "string") { + const fileName = value.split("/").pop() || "file"; + const fileInfo = getFileTypeInfo(fileName); + const IconComponent = fileInfo.icon; + + if (fileInfo.type === "image") { + return ( +
+ {fileName} +
+ ); + } else if (fileInfo.type === "video") { + return ( +
+ +
+ ); + } else if (fileInfo.type === "audio") { + return ( +
+
+
+ +
+
+

+ {fileName} +

+

+ Audio file +

+
+
+ +
+ ); + } else { + return ( +
+
+ +
+
+

+ {fileName} +

+

+ Click to download • {fileInfo.type} +

+
+
+ ); + } + } else if ( + typeof value === "object" && + value !== null && + "name" in value + ) { + // Handle File object (when file is uploaded but not yet saved) + const fileName = (value as { name: string }).name; + const fileInfo = getFileTypeInfo(fileName); + const IconComponent = fileInfo.icon; + + if (fileInfo.type === "image" && value instanceof File) { + const imageUrl = URL.createObjectURL(value); + return ( +
+ {fileName} +
+ ); + } else if (fileInfo.type === "video" && value instanceof File) { + const videoUrl = URL.createObjectURL(value); + return ( +
+ +
+ ); + } else if (fileInfo.type === "audio" && value instanceof File) { + const audioUrl = URL.createObjectURL(value); + return ( +
+
+
+ +
+
+

+ {fileName} +

+

+ Audio file +

+
+
+ +
+ ); + } else { + return ( +
+
+ +
+
+

+ {fileName} +

+

+ Ready to upload • {fileInfo.type} +

+
+
+ ); + } + } + return null; + + default: + return ( +
+ {typeof value === "string" ? + value + : typeof value === "number" ? + String(value) + : typeof value === "boolean" ? + String(value) + : ""} +
+ ); + } + }; + + const getComponentContent = () => { + // Fields that should be displayed in Telegram preview + const displayableFields = [ + "text", + "caption", + "message", + "content", + "description", + ]; + + const fields = Object.entries(componentSchema) + .filter(([key, schema]) => { + const value = formValues[key]; + if (!value && value !== false) return false; + + // Skip internal/technical fields + const internalFields = [ + "chat_id", + "user_id", + "message_id", + "thread_id", + "reply_to_message_id", + "bot_token", + "webhook_url", + ]; + if (internalFields.includes(key.toLowerCase())) return false; + + // Include file fields + if (schema.type === "FileField") return true; + + // Include boolean fields that are user-facing + if (schema.type === "BooleanField") return true; + + // Include displayable text fields + if ( + displayableFields.some((field) => key.toLowerCase().includes(field)) + ) + return true; + + // Include other text fields that aren't internal + if (typeof value === "string" && value.trim() !== "") return true; + + return false; + }) + .map(([key, schema]) => ({ + key, + schema, + value: formValues[key], + label: schema.verbose_name || key, + })); + + return fields; + }; + + const componentFields = getComponentContent(); + + return ( +
+ {/* Telegram Header */} +
+
+
+
+ +
+
+

+ Bot Preview +

+
+
+

+ online +

+
+
+
+ + + +
+
+
+ + {/* Chat Area */} +
+ {/* Chat Pattern Overlay */} +
+ + {/* Bot Message with Component Data */} +
+
+
+
+
+
+
+
+ {/* Message tail */} +
+
+
+ + {/* Component Title */} + {componentFields.length > 0 && ( +
+
+
+ 📋 +
+

+ {componentName} +

+
+
+ )} + + {/* Component Fields */} + {componentFields.length > 0 ? +
+ {componentFields.map(({ key, schema, value }) => ( +
{renderFieldValue(key, value, schema)}
+ ))} +
+ :
+
+ +
+

+ Fill in the form fields to see them here +

+
+ } + + {/* Inline Keyboard */} + {keyboardType === "inline" && hasButtons && ( +
+
+ {rows.map((row, rowIndex) => ( +
+ {row.map((button) => ( + + ))} +
+ ))} +
+
+ )} +
+
+

Bot

+
+

just now

+
+
+ + + + +
+
+
+
+
+ +
+ {/* Input Area */} +
+
+ + + +
+ +
+ + {/* Reply Keyboard */} + {keyboardType === "reply" && hasButtons && ( +
+
+ {rows.map((row, rowIndex) => ( +
+ {row.map((button) => ( + + ))} +
+ ))} +
+
+ )} +
+
+
+ ); +} diff --git a/src/components/ComponentDetail/index.tsx b/src/components/ComponentDetail/index.tsx index 72edf76..f934cbf 100644 --- a/src/components/ComponentDetail/index.tsx +++ b/src/components/ComponentDetail/index.tsx @@ -3,10 +3,14 @@ import { useComponentDetails, useContentTypes, } from "../../services/getQueries"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import Loading from "../Loading"; import api from "../../services/api"; -import { formValuesType, GridItem } from "../../types/ComponentDetailForm"; +import { + formValuesType, + GridItem, + KeyboardType, +} from "../../types/ComponentDetailForm"; import { toast } from "react-toastify"; import CodeEditor from "./CodeEditor"; import ButtonGrid from "./ButtonGrid"; @@ -14,8 +18,8 @@ import { makeFormData } from "./makeFormData"; import FormFields from "./FormFields"; import { useReactFlow } from "reactflow"; import { updateNodeHoverText } from "./updateNodeHoverText"; -import { Check, RefreshCcw, X } from "lucide-react"; -import { generateUUID } from "./generateUUID"; +import { Check, RefreshCcw, X, Eye } from "lucide-react"; +import TelegramPreview from "./TelegramPreview"; type PropsType = { node: ComponentType; @@ -28,9 +32,9 @@ const ComponentDetail = ({ node, onClose }: PropsType) => { const [loading, setLoading] = useState(false); const [formValues, setFormValues] = useState({}); const [formErrors, setFormErrors] = useState>({}); - const [rows, setRows] = useState([ - [{ id: generateUUID(), label: "Item" }], - ]); + const [rows, setRows] = useState([]); + const [keyboardType, setKeyboardType] = useState("inline"); + const modalRef = useRef(null); const { contentTypes } = useContentTypes(); const contentType = contentTypes!.find( @@ -48,6 +52,25 @@ const ComponentDetail = ({ node, onClose }: PropsType) => { setFormValues(details ?? {}); }, [details]); + const canShowPreview = () => { + if (isFetching) return false; + + const requiredFields = Object.entries(componentSchema) + .filter(([_, schema]) => schema.required) + .map(([key, _]) => key); + + if (requiredFields.length === 0) return true; + + const allRequiredFilled = requiredFields.every((field) => { + const value = formValues[field]; + return ( + value !== undefined && value !== null && value !== "" && value !== false + ); + }); + + return allRequiredFilled; + }; + const handleSubmit = (override?: formValuesType) => { const formData = makeFormData(componentSchema, override, formValues); @@ -92,6 +115,19 @@ const ComponentDetail = ({ node, onClose }: PropsType) => {
{node.id}
+ + {/* Preview Button */} + {canShowPreview() && ( + + )} +
} + + {/* Telegram Preview Modal */} + +
+
+

📱 Telegram Preview

+

+ How your component will look in Telegram +

+
+
+ +
+
+ +
+
+
modalRef.current?.close()} + >
+
+
diff --git a/src/types/ComponentDetailForm.ts b/src/types/ComponentDetailForm.ts index b0abff1..eea78f5 100644 --- a/src/types/ComponentDetailForm.ts +++ b/src/types/ComponentDetailForm.ts @@ -1,5 +1,7 @@ export type formValuesType = Record; +export type KeyboardType = "inline" | "reply"; + export type GridItem = { id: string; label: string;