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.";
// Discovery hint for anyone who hasn't opened the AI Form Assistant before — shown once,
// dismissed forever (per-user, via EVF_AI_Ajax::dismiss_hint()) either by closing it or
// by actually opening the panel.
const AI_HINT_NAME = 'form';
const UPGRADE_URL =
'https://everestforms.net/upgrade/?utm_source=evf-free&utm_medium=ai-chat&utm_campaign=daily-limit&utm_content=Upgrade+to+Pro';
// Builder context (form id + nonce) localized by class-evf-admin-assets.php.
interface BuilderAIConfig {
ajaxUrl?: string;
nonce?: string;
formId?: number;
formTitle?: string;
aiDisabled?: boolean;
hintDismissed?: 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;
// Daily-request usage snapshot the gateway now returns on every AI response.
interface UsageInfo {
remaining: number;
limit: number;
used: number;
}
// At/below this many remaining requests the count switches to a gentle amber warning.
const USAGE_LOW_THRESHOLD = 3;
// "7 requests left today" — pluralized.
const usageLabel = ( n: number ): string =>
`${ n } request${ 1 === n ? '' : 's' } left today`;
// Read the { remaining, limit, used } usage object off a raw AI response, or null.
const readUsage = ( raw: any ): UsageInfo | null => {
const u = raw && raw.usage;
if ( u && 'number' === typeof u.remaining ) {
return { remaining: u.remaining, limit: u.limit, used: u.used };
}
return null;
};
// Full tooltip text for the credits pill.
const usageTooltip = ( usage: UsageInfo ): string =>
'number' === typeof usage.limit && usage.limit > 0
? `${ usage.remaining } of ${ usage.limit } AI requests left today · resets daily`
: usageLabel( usage.remaining );
/**
* The daily-request "credits" pill for the panel header — a sparkle, an `18/20` count, and a
* slim meter that drains as requests are used. Translucent white on the purple header; turns
* amber when the count runs low or is exhausted.
*/
const UsagePill: React.FC<{ usage: UsageInfo | null; loading?: boolean }> = ( { usage, loading } ) => {
if ( ! usage ) {
if ( ! loading ) return null;
// Skeleton — holds the pill's place while the first count loads.
return (
);
}
const hasLimit = 'number' === typeof usage.limit && usage.limit > 0;
const { remaining } = usage;
const amber = remaining <= USAGE_LOW_THRESHOLD;
const frac = hasLimit ? Math.max( 0, Math.min( 1, remaining / usage.limit ) ) : 1;
const numColor = amber ? '#7a4b00' : '#fff';
const denColor = amber ? 'rgba(122,75,0,.7)' : 'rgba(255,255,255,.7)';
return (
);
};
// 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;
limitReached?: boolean;
limitTier?: string;
usage?: UsageInfo | null;
}> => {
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,
usage: readUsage( json?.data ),
};
}
// "daily_limit_reached" is today's hard cap (Free AND Pro both have one, Pro's is far
// higher) — worth a persistent "you're blocked until tomorrow" state. A plain
// "rate_limit" (transient IP throttle) isn't — that's just a normal error to retry.
const isLimit = json?.data?.code === 'daily_limit_reached';
const tier = json?.data?.tier || 'free';
return {
ok: false,
message: json?.data?.message || 'Sorry, I could not update the form. Please try again.',
limitReached: isLimit,
limitTier: tier,
// Match Style with AI: the daily-limit message carries the "Upgrade to Pro" link
// inline in the chat bubble (Free tier only — a Pro user who hit their own, higher
// cap gets no upsell). Previously the link only appeared in the button tooltip.
noticeUrl: isLimit && 'pro' !== tier ? UPGRADE_URL : '',
usage: readUsage( json?.data ),
};
} 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);
// Daily-request usage snapshot — drives the header credits pill. Seeded on mount so it's
// visible the moment the panel opens, then refreshed from every response.
const [usage, setUsage] = useState(null);
const [usageLoading, setUsageLoading] = useState(true);
const [buttonHovered, setButtonHovered] = useState(false);
const [tooltipHovered, setTooltipHovered] = useState(false);
const [rateLimited, setRateLimited] = useState(false);
// Which tier hit the daily cap — Pro has its own (much higher) limit too, and shouldn't
// be told to "upgrade to Pro" when it's already on it.
const [limitTier, setLimitTier] = useState('free');
const showTooltip = !open && (buttonHovered || tooltipHovered);
const [hintDismissed, setHintDismissed] = useState(!!cfg.hintDismissed);
const dismissHint = () => {
if (hintDismissed) return;
setHintDismissed(true);
if (!cfg.ajaxUrl) return;
const body = new URLSearchParams();
body.append('action', 'evf_ai_dismiss_hint');
body.append('hint', AI_HINT_NAME);
body.append('nonce', cfg.nonce || '');
fetch(cfg.ajaxUrl, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
}).catch(() => {
// Best-effort only — worst case the hint reappears next visit.
});
};
const showHint = !open && !hintDismissed && !AI_DISABLED;
// The builder shell shows its own loading overlay (`.everest-forms-overlay`, faded out on
// window `load` — see form-builder.js) while fields/canvas are still booting. Stay hidden
// until then so this floating button doesn't sit on top of that loading screen.
const [builderLoaded, setBuilderLoaded] = useState(() => document.readyState === 'complete');
useEffect(() => {
if (builderLoaded) return;
const onLoad = () => setBuilderLoaded(true);
window.addEventListener('load', onLoad);
return () => window.removeEventListener('load', onLoad);
}, [builderLoaded]);
// 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);
// Multi-Part's own "Add New Part" tab bar pins itself to the same bottom-right corner this
// button defaults to when the Style Customizer addon isn't installed/active (so there's no
// `.everest-forms-designer-icon` to measure from) — without this, the two overlap (EVF-2736).
// Measured the same way as customizerBottom (from the real element's edge, not a guessed
// constant) since the bar's own height varies with its content/viewport.
const [multiPartBarBottom, setMultiPartBarBottom] = 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 builder = document.getElementById('everest-forms-builder');
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);
}
const bar = document.querySelector('.everest-forms-multi-part-tabs');
if (bar && builder?.classList.contains('multi-part-activated')) {
setMultiPartBarBottom(Math.round(window.innerHeight - bar.getBoundingClientRect().top));
} else {
setMultiPartBarBottom(null);
}
};
read();
// Re-read when builder classes change (multi-part toggle adds/removes class); polling
// alongside as a safety net for the same class of DOM-rewrite edge case noted below.
const observer = new MutationObserver(read);
if (builder) observer.observe(builder, { attributes: true, attributeFilter: ['class'] });
const interval = window.setInterval(read, 500);
return () => {
observer.disconnect();
window.clearInterval(interval);
};
}, []);
// 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. Re-query the panel INSIDE
// read() rather than capturing it once — Multi-Part's own tab-rebuild JS (triggered when it's
// enabled from Settings) can replace that DOM node entirely, which would otherwise leave a
// MutationObserver watching a detached element and freeze `onBuilderTab` forever (EVF-2736:
// the assistant never comes back after Settings → enable Multi-Part → Fields). Observing the
// stable builder root with `subtree: true` catches that swap either direction — but a
// half-second poll runs alongside it as a safety net, since some jQuery-driven DOM rewrites
// (full innerHTML replacement outside a single attribute mutation, timing around the rebuild)
// have still been reported to slip past the observer in the wild; polling can't miss.
useEffect(() => {
const root = document.getElementById('everest-forms-builder') || document.body;
const read = () => {
const panel = document.getElementById('everest-forms-panel-fields');
setOnBuilderTab(panel ? panel.classList.contains('active') : true);
};
read();
const observer = new MutationObserver(read);
observer.observe(root, { attributes: true, attributeFilter: ['class'], subtree: true, childList: true });
const interval = window.setInterval(read, 500);
return () => {
observer.disconnect();
window.clearInterval(interval);
};
}, []);
// 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]);
// Seed the header credits pill on mount so the count is ready as soon as the panel opens.
// Best-effort — on a disabled (local) or unregistered site we skip the call and show no pill.
useEffect(() => {
if (AI_DISABLED || !cfg.ajaxUrl || !cfg.nonce) {
setUsageLoading(false);
return;
}
let cancelled = false;
const body = new URLSearchParams();
body.append('action', 'evf_ai_get_usage');
body.append('nonce', cfg.nonce);
fetch(cfg.ajaxUrl, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
})
.then((r) => r.json())
.then((j) => {
if (cancelled) return;
const u = readUsage(j?.data);
if (u) setUsage(u);
})
.catch(() => {})
.finally(() => {
if (!cancelled) setUsageLoading(false);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
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);
// Keep the header's "N requests left today" chip in sync.
if (result.usage) setUsage(result.usage);
// Track rate limit so the trigger button tooltip updates.
if (!result.ok && result.limitReached) {
setRateLimited(true);
setLimitTier(result.limitTier || 'free');
}
// 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) {
// Re-open the panel if the user collapsed it while the request was processing, so the
// success confirmation (and any Pro/reload notice) is visible now that it's done.
setOpen(true);
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, if Multi-Part's
// "Add New Part" bar occupies the usual 22px corner instead, we sit 8px above ITS top edge;
// otherwise we share the same plain bottom baseline (22px).
const BTN_SIZE = 55;
const BTN_RIGHT = 22;
const BASE_BOTTOM = multiPartBarBottom !== null ? multiPartBarBottom + 8 : 22;
const BTN_BOTTOM = customizerBottom !== null ? customizerBottom + BTN_SIZE + 8 : BASE_BOTTOM;
// 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 || !builderLoaded) 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. ── */}
{/* ── Processing ring — spins around the closed trigger while a request is in
flight, so the user knows the AI is still working with the panel collapsed. ── */}
{loading && !open && !AI_DISABLED && (
)}
{/* ── Discovery hint — shown once until dismissed or the panel is opened ── */}
{showHint && (
New
Tell me what to add or change, and I’ll update your form for you.