import React, { useEffect, useRef, useState } from 'react'; import { LuSparkles, LuX, LuSend } from 'react-icons/lu'; // ── Types ───────────────────────────────────────────────────────────────────── interface Message { role: 'user' | 'assistant'; text: string; loading?: boolean; notice?: boolean; noticeUrl?: string; reload?: boolean; } // Edit-form prompt suggestions shown inside the chat panel. const EDIT_SUGGESTIONS = [ 'Add a phone number field', 'Make all fields required', 'Add a date picker field', 'Insert a file upload field', 'Add a dropdown with Yes / No options', 'Remove the last field', 'Add an address section', 'Insert a multi-line text field', ]; // Subtle intro message from the AI assistant. const GREETING = "Hi! I'm your AI form assistant. Tell me how to improve this form — or pick a suggestion below."; // Builder context (form id + nonce) localized by class-evf-admin-assets.php. interface BuilderAIConfig { ajaxUrl?: string; nonce?: string; formId?: number; formTitle?: string; aiDisabled?: boolean; } const cfg: BuilderAIConfig = ( window as any ).evfBuilderAI || {}; // On local / development sites the AI gateway is unavailable — the assistant is // shown but disabled (greyed trigger, opens nothing, explains why on hover). const AI_DISABLED = !! cfg.aiDisabled; // Edit the current builder form via the ThemeGrill AI Cloud (Python) gateway. const editFormViaAi = async ( instruction: string, ): Promise<{ ok: boolean; message: string; isNotice?: boolean; noticeUrl?: string; needsReload?: boolean }> => { if ( ! cfg.ajaxUrl || ! cfg.nonce || ! cfg.formId ) { return { ok: false, message: 'AI assistant is unavailable on this screen.' }; } const body = new URLSearchParams(); body.append( 'action', 'evf_ai_update_form' ); body.append( 'nonce', cfg.nonce ); body.append( 'form_id', String( cfg.formId ) ); body.append( 'prompt', cfg.formTitle || 'Edit this form' ); body.append( 'refine_prompt', instruction ); try { const resp = await fetch( cfg.ajaxUrl, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), } ); const json = await resp.json(); if ( json?.success ) { return { ok: true, message: json?.data?.notice || "Done — I've updated your form. Refreshing the canvas…", isNotice: !! json?.data?.notice, noticeUrl: json?.data?.notice_url || '', needsReload: !! json?.data?.needs_reload, }; } return { ok: false, message: json?.data?.message || 'Sorry, I could not update the form. Please try again.', noticeUrl: json?.data?.code === 'rate_limited' ? 'https://everestforms.net/upgrade/?utm_source=evf-free&utm_medium=ai-chat&utm_campaign=daily-limit&utm_content=Upgrade+to+Pro' : '', }; } catch { return { ok: false, message: 'Could not reach the AI service. Please try again.' }; } }; // ── Component ───────────────────────────────────────────────────────────────── const BuilderAIChat: React.FC = () => { const [open, setOpen] = useState(false); const [input, setInput] = useState(''); const [messages, setMessages] = useState([ { role: 'assistant', text: GREETING }, ]); const [loading, setLoading] = useState(false); const [buttonHovered, setButtonHovered] = useState(false); const [tooltipHovered, setTooltipHovered] = useState(false); const [rateLimited, setRateLimited] = useState(false); const [upgradeUrl, setUpgradeUrl] = useState(''); const showTooltip = !open && (buttonHovered || tooltipHovered); // Read the customizer button's actual CSS bottom so we stack correctly even // in multi-part mode (where the customizer moves up to 62px). Falls back to // null when the addon is not active — AI button then sits at bottom: 22px. const [customizerBottom, setCustomizerBottom] = useState(null); // Show the assistant only on the Builder (Fields) tab — mirror the Style // Customizer button, which lives inside the Fields panel and is hidden when // other tabs (Settings, Integrations, …) are active. const [onBuilderTab, setOnBuilderTab] = useState(true); const messagesEndRef = useRef(null); const inputRef = useRef(null); useEffect(() => { const read = () => { const el = document.querySelector('.everest-forms-designer-icon'); if (el) { const v = parseInt(window.getComputedStyle(el).bottom, 10); setCustomizerBottom(isNaN(v) ? null : v); } else { setCustomizerBottom(null); } }; read(); // Re-read when builder classes change (multi-part toggle adds/removes class). const observer = new MutationObserver(read); const builder = document.getElementById('everest-forms-builder'); if (builder) observer.observe(builder, { attributes: true, attributeFilter: ['class'] }); return () => observer.disconnect(); }, []); // Track the active builder tab. Switching tabs toggles the `active` class on // the Fields panel; we only render the assistant while that panel is active. useEffect(() => { const panel = document.getElementById('everest-forms-panel-fields'); const read = () => setOnBuilderTab(panel ? panel.classList.contains('active') : true); read(); if (!panel) return; const observer = new MutationObserver(read); observer.observe(panel, { attributes: true, attributeFilter: ['class'] }); return () => observer.disconnect(); }, []); // Close the chat panel when navigating away from the Builder tab. useEffect(() => { if (!onBuilderTab) setOpen(false); }, [onBuilderTab]); // Auto-scroll to latest message. useEffect(() => { if (open) messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, open]); // Focus input when panel opens. useEffect(() => { if (open) setTimeout(() => inputRef.current?.focus(), 120); }, [open]); const sendMessage = async (text: string) => { if (!text.trim() || loading) return; const userText = text.trim(); setInput(''); setMessages(prev => [...prev, { role: 'user', text: userText }]); setLoading(true); setMessages(prev => [...prev, { role: 'assistant', text: '', loading: true }]); const result = await editFormViaAi(userText); // Track rate limit so the trigger button tooltip updates. if (!result.ok && result.noticeUrl) { setRateLimited(true); setUpgradeUrl(result.noticeUrl); } // Show the notice at most once per chat session. if (result.isNotice && messages.some(m => m.notice)) { result.isNotice = false; result.noticeUrl = ''; result.message = "Done — I've updated your form. Refreshing the canvas…"; } // When settings changed (redirect, email, message, etc.) set a clean done text. // The reload link is rendered inside the bubble; no auto-reload happens. if (result.ok && result.needsReload && !result.isNotice) { result.message = "Done — your form settings have been updated."; } setMessages(prev => { const copy = [...prev]; const last = copy[copy.length - 1]; if (!last?.loading) return copy; if (result.ok && result.isNotice) { // Edit succeeded but there's a Pro/addon notice — show "Done" first, // then a separate notice bubble below so the user knows the edit applied. copy[copy.length - 1] = { role: 'assistant', text: result.needsReload ? "Done — your form settings have been updated." : "Done — I've updated your form. Refreshing the canvas…", }; copy.push({ role: 'assistant', text: result.message, notice: true, noticeUrl: result.noticeUrl || '', reload: !! result.needsReload, }); } else { copy[copy.length - 1] = { role: 'assistant', text: result.message, notice: result.isNotice || ! result.ok, noticeUrl: result.noticeUrl || '', reload: result.ok && !! result.needsReload, }; } return copy; }); setLoading(false); if (result.ok) { const w = window as any; if (result.needsReload) { // Settings changed — don't auto-reload; the bubble shows a manual // "Refresh the page" link so the user can reload when ready. } else if (typeof w.evfReloadBuilderFields === 'function' && cfg.formId && cfg.nonce) { w.evfReloadBuilderFields(cfg.formId, cfg.nonce, () => {}); } else { setTimeout(() => window.location.reload(), 1500); } } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(input); } }; // Bottom offset of the trigger button. // When the customizer is active we sit 8px above its top edge; // otherwise we share the same bottom baseline (22px). const BTN_SIZE = 55; const BTN_RIGHT = 22; const BTN_BOTTOM = customizerBottom !== null ? customizerBottom + BTN_SIZE + 8 : 22; // Modal sits 8px above the top edge of the trigger button. const MODAL_BOTTOM = BTN_BOTTOM + BTN_SIZE + 8; // ── Render ────────────────────────────────────────────────────────────── // Hidden outside the Builder (Fields) tab. if (!onBuilderTab) return null; return ( <> {/* ── Floating trigger button (always rendered) ──────────────────── Shows sparkles when closed, X when open. zIndex sits above the chat panel so it's always clickable. ── */} {/* ── Tooltip — matches tooltipster style exactly, appears above the button ── */} {showTooltip && (
setTooltipHovered(true)} onMouseLeave={() => setTooltipHovered(false)} style={{ position: 'fixed', // Sit 8px above the trigger button top edge bottom: BTN_BOTTOM + BTN_SIZE + 8, // Place right edge at button center, then translateX(50%) to center tooltip over button right: BTN_RIGHT + Math.round(BTN_SIZE / 2), transform: 'translateX(50%)', pointerEvents: rateLimited ? 'auto' : 'none', zIndex: 10001, }} > {/* Box — matches tooltipster-box */}
{rateLimited ? ( <>
You've reached your daily free limit.
(e.currentTarget.style.textDecoration = 'underline')} onMouseLeave={e => (e.currentTarget.style.textDecoration = 'none')} > Upgrade to Pro → ) : AI_DISABLED ? ( 'Not available on local sites' ) : ( 'AI Form Assistant' )}
{/* Down-pointing arrow centered under tooltip, pointing to button */} {/* Outer arrow — border colour */}
{/* Inner arrow — white fill */}
)} {/* ── Chat panel ── */} {open && (
{/* Header */}
AI Form Assistant
Powered by AI
{/* Messages */}
{messages.map((msg, i) => (
{msg.role === 'assistant' && (
)}
))}
{/* Suggestions strip */}
{EDIT_SUGGESTIONS.map(s => ( ))}
{/* Input bar */}