PluginProbe ʕ •ᴥ•ʔ
Everest Forms – Contact Form, Payment Form, Quiz, Survey & Custom Form Builder with AI / 3.6.0
Everest Forms – Contact Form, Payment Form, Quiz, Survey & Custom Form Builder with AI v3.6.0
3.6.0 3.5.3 3.5.2 3.5.1 3.5.0 3.4.8 3.4.7 3.4.6 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.5.1 1.1.6 1.1.7 1.1.8 1.1.9 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.4.8 1.4.9 1.5.0 1.5.1 1.5.10 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.6.1 1.6.7 1.7.0 1.7.0.1 1.7.0.2 1.7.0.3 1.7.1 1.7.2 1.7.2.1 1.7.2.2 1.7.3 1.7.4 1.7.5 1.7.5.1 1.7.5.2 1.7.6 1.7.7 1.7.7.1 1.7.7.2 1.7.8 1.7.9 1.8.0 1.8.0.1 1.8.1 1.8.2 1.8.2.1 1.8.2.2 1.8.2.3 1.8.3 1.8.4 1.8.5 1.8.6 1.8.7 1.8.8 1.8.9 1.9.0 1.9.0.1 1.9.1 1.9.2 1.9.3 1.9.4 1.9.4.1 1.9.5 1.9.6 1.9.7 1.9.8 1.9.9 2.0.0 2.0.0.1 2.0.1 2.0.2 2.0.3 2.0.3.1 2.0.4 2.0.4.1 2.0.5 2.0.6 2.0.7 2.0.8 2.0.8.1 2.0.9 3.0.0 3.0.0.1 3.0.1 3.0.2 3.0.3 3.0.3.1 3.0.4 3.0.4.1 3.0.4.2 3.0.5 3.0.5.1 3.0.5.2 3.0.6 3.0.6.1 3.0.7.1 3.0.8 3.0.8.1 3.0.9 3.0.9.1 3.0.9.2 3.0.9.3 3.0.9.4 3.0.9.5 3.1.0 3.1.1 3.1.2 3.2.0 3.2.1 3.2.2 3.2.3 3.2.4 3.2.5 3.2.6 3.3.0 3.4.0 3.4.1 3.4.2 3.4.2.1 3.4.3 3.4.4 3.4.5 trunk 1.0 1.0.1 1.0.2 1.0.3
everest-forms / src / builder-ai / BuilderAIChat.tsx
everest-forms / src / builder-ai Last commit date
BuilderAIChat.tsx 3 days ago index.tsx 2 months ago
BuilderAIChat.tsx
1058 lines
1 import React, { useEffect, useRef, useState } from 'react';
2 import { LuSparkles, LuX, LuSend } from 'react-icons/lu';
3
4 // ── Types ─────────────────────────────────────────────────────────────────────
5
6 interface Message {
7 role: 'user' | 'assistant';
8 text: string;
9 loading?: boolean;
10 notice?: boolean;
11 noticeUrl?: string;
12 reload?: boolean;
13 }
14
15 // Edit-form prompt suggestions shown inside the chat panel.
16 const EDIT_SUGGESTIONS = [
17 'Add a phone number field',
18 'Make all fields required',
19 'Add a date picker field',
20 'Insert a file upload field',
21 'Add a dropdown with Yes / No options',
22 'Remove the last field',
23 'Add an address section',
24 'Insert a multi-line text field',
25 ];
26
27 // Subtle intro message from the AI assistant.
28 const GREETING =
29 "Hi! I'm your AI form assistant. Tell me how to improve this form — or pick a suggestion below.";
30
31 // Discovery hint for anyone who hasn't opened the AI Form Assistant before — shown once,
32 // dismissed forever (per-user, via EVF_AI_Ajax::dismiss_hint()) either by closing it or
33 // by actually opening the panel.
34 const AI_HINT_NAME = 'form';
35
36 const UPGRADE_URL =
37 'https://everestforms.net/upgrade/?utm_source=evf-free&utm_medium=ai-chat&utm_campaign=daily-limit&utm_content=Upgrade+to+Pro';
38
39 // Builder context (form id + nonce) localized by class-evf-admin-assets.php.
40 interface BuilderAIConfig {
41 ajaxUrl?: string;
42 nonce?: string;
43 formId?: number;
44 formTitle?: string;
45 aiDisabled?: boolean;
46 hintDismissed?: boolean;
47 }
48 const cfg: BuilderAIConfig = ( window as any ).evfBuilderAI || {};
49
50 // On local / development sites the AI gateway is unavailable — the assistant is
51 // shown but disabled (greyed trigger, opens nothing, explains why on hover).
52 const AI_DISABLED = !! cfg.aiDisabled;
53
54 // Daily-request usage snapshot the gateway now returns on every AI response.
55 interface UsageInfo {
56 remaining: number;
57 limit: number;
58 used: number;
59 }
60
61 // At/below this many remaining requests the count switches to a gentle amber warning.
62 const USAGE_LOW_THRESHOLD = 3;
63
64 // "7 requests left today" — pluralized.
65 const usageLabel = ( n: number ): string =>
66 `${ n } request${ 1 === n ? '' : 's' } left today`;
67
68 // Read the { remaining, limit, used } usage object off a raw AI response, or null.
69 const readUsage = ( raw: any ): UsageInfo | null => {
70 const u = raw && raw.usage;
71 if ( u && 'number' === typeof u.remaining ) {
72 return { remaining: u.remaining, limit: u.limit, used: u.used };
73 }
74 return null;
75 };
76
77 // Full tooltip text for the credits pill.
78 const usageTooltip = ( usage: UsageInfo ): string =>
79 'number' === typeof usage.limit && usage.limit > 0
80 ? `${ usage.remaining } of ${ usage.limit } AI requests left today · resets daily`
81 : usageLabel( usage.remaining );
82
83 /**
84 * The daily-request "credits" pill for the panel header — a sparkle, an `18/20` count, and a
85 * slim meter that drains as requests are used. Translucent white on the purple header; turns
86 * amber when the count runs low or is exhausted.
87 */
88 const UsagePill: React.FC<{ usage: UsageInfo | null; loading?: boolean }> = ( { usage, loading } ) => {
89 if ( ! usage ) {
90 if ( ! loading ) return null;
91 // Skeleton — holds the pill's place while the first count loads.
92 return (
93 <div
94 style={ {
95 flexShrink: 0,
96 width: 62,
97 height: 22,
98 borderRadius: 20,
99 background: 'rgba(255,255,255,.16)',
100 animation: 'evf-ai-pulse 1.3s ease-in-out infinite',
101 } }
102 />
103 );
104 }
105 const hasLimit = 'number' === typeof usage.limit && usage.limit > 0;
106 const { remaining } = usage;
107 const amber = remaining <= USAGE_LOW_THRESHOLD;
108 const frac = hasLimit ? Math.max( 0, Math.min( 1, remaining / usage.limit ) ) : 1;
109 const numColor = amber ? '#7a4b00' : '#fff';
110 const denColor = amber ? 'rgba(122,75,0,.7)' : 'rgba(255,255,255,.7)';
111 return (
112 <div
113 title={ usageTooltip( usage ) }
114 style={ {
115 flexShrink: 0,
116 display: 'inline-flex',
117 alignItems: 'center',
118 gap: 6,
119 height: 22,
120 padding: '0 10px',
121 borderRadius: 20,
122 lineHeight: 1,
123 background: amber ? '#fbbf24' : 'rgba(255,255,255,.16)',
124 } }
125 >
126 <LuSparkles size={ 11 } color={ numColor } />
127 <span style={ { display: 'inline-flex', alignItems: 'baseline', gap: 1, fontSize: 11.5, fontWeight: 700, color: numColor } }>
128 { remaining }
129 { hasLimit && <span style={ { fontWeight: 500, color: denColor } }>/{ usage.limit }</span> }
130 </span>
131 { hasLimit && (
132 <span
133 style={ {
134 width: 22,
135 height: 4,
136 borderRadius: 20,
137 overflow: 'hidden',
138 display: 'inline-block',
139 background: amber ? 'rgba(122,75,0,.25)' : 'rgba(255,255,255,.28)',
140 } }
141 >
142 <span
143 style={ {
144 display: 'block',
145 height: '100%',
146 width: `${ Math.round( frac * 100 ) }%`,
147 borderRadius: 20,
148 background: amber ? '#7a4b00' : '#fff',
149 } }
150 />
151 </span>
152 ) }
153 </div>
154 );
155 };
156
157 // Edit the current builder form via the ThemeGrill AI Cloud (Python) gateway.
158 const editFormViaAi = async (
159 instruction: string,
160 ): Promise<{
161 ok: boolean;
162 message: string;
163 isNotice?: boolean;
164 noticeUrl?: string;
165 needsReload?: boolean;
166 limitReached?: boolean;
167 limitTier?: string;
168 usage?: UsageInfo | null;
169 }> => {
170 if ( ! cfg.ajaxUrl || ! cfg.nonce || ! cfg.formId ) {
171 return { ok: false, message: 'AI assistant is unavailable on this screen.' };
172 }
173
174 const body = new URLSearchParams();
175 body.append( 'action', 'evf_ai_update_form' );
176 body.append( 'nonce', cfg.nonce );
177 body.append( 'form_id', String( cfg.formId ) );
178 body.append( 'prompt', cfg.formTitle || 'Edit this form' );
179 body.append( 'refine_prompt', instruction );
180
181 try {
182 const resp = await fetch( cfg.ajaxUrl, {
183 method: 'POST',
184 credentials: 'same-origin',
185 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
186 body: body.toString(),
187 } );
188 const json = await resp.json();
189 if ( json?.success ) {
190 return {
191 ok: true,
192 message: json?.data?.notice || "Done — I've updated your form. Refreshing the canvas…",
193 isNotice: !! json?.data?.notice,
194 noticeUrl: json?.data?.notice_url || '',
195 needsReload: !! json?.data?.needs_reload,
196 usage: readUsage( json?.data ),
197 };
198 }
199 // "daily_limit_reached" is today's hard cap (Free AND Pro both have one, Pro's is far
200 // higher) — worth a persistent "you're blocked until tomorrow" state. A plain
201 // "rate_limit" (transient IP throttle) isn't — that's just a normal error to retry.
202 const isLimit = json?.data?.code === 'daily_limit_reached';
203 const tier = json?.data?.tier || 'free';
204 return {
205 ok: false,
206 message: json?.data?.message || 'Sorry, I could not update the form. Please try again.',
207 limitReached: isLimit,
208 limitTier: tier,
209 // Match Style with AI: the daily-limit message carries the "Upgrade to Pro" link
210 // inline in the chat bubble (Free tier only — a Pro user who hit their own, higher
211 // cap gets no upsell). Previously the link only appeared in the button tooltip.
212 noticeUrl: isLimit && 'pro' !== tier ? UPGRADE_URL : '',
213 usage: readUsage( json?.data ),
214 };
215 } catch {
216 return { ok: false, message: 'Could not reach the AI service. Please try again.' };
217 }
218 };
219
220 // ── Component ─────────────────────────────────────────────────────────────────
221
222 const BuilderAIChat: React.FC = () => {
223 const [open, setOpen] = useState(false);
224 const [input, setInput] = useState('');
225 const [messages, setMessages] = useState<Message[]>([
226 { role: 'assistant', text: GREETING },
227 ]);
228 const [loading, setLoading] = useState(false);
229 // Daily-request usage snapshot — drives the header credits pill. Seeded on mount so it's
230 // visible the moment the panel opens, then refreshed from every response.
231 const [usage, setUsage] = useState<UsageInfo | null>(null);
232 const [usageLoading, setUsageLoading] = useState(true);
233 const [buttonHovered, setButtonHovered] = useState(false);
234 const [tooltipHovered, setTooltipHovered] = useState(false);
235 const [rateLimited, setRateLimited] = useState(false);
236 // Which tier hit the daily cap — Pro has its own (much higher) limit too, and shouldn't
237 // be told to "upgrade to Pro" when it's already on it.
238 const [limitTier, setLimitTier] = useState('free');
239 const showTooltip = !open && (buttonHovered || tooltipHovered);
240 const [hintDismissed, setHintDismissed] = useState(!!cfg.hintDismissed);
241 const dismissHint = () => {
242 if (hintDismissed) return;
243 setHintDismissed(true);
244 if (!cfg.ajaxUrl) return;
245 const body = new URLSearchParams();
246 body.append('action', 'evf_ai_dismiss_hint');
247 body.append('hint', AI_HINT_NAME);
248 body.append('nonce', cfg.nonce || '');
249 fetch(cfg.ajaxUrl, {
250 method: 'POST',
251 credentials: 'same-origin',
252 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
253 body: body.toString(),
254 }).catch(() => {
255 // Best-effort only — worst case the hint reappears next visit.
256 });
257 };
258 const showHint = !open && !hintDismissed && !AI_DISABLED;
259 // The builder shell shows its own loading overlay (`.everest-forms-overlay`, faded out on
260 // window `load` — see form-builder.js) while fields/canvas are still booting. Stay hidden
261 // until then so this floating button doesn't sit on top of that loading screen.
262 const [builderLoaded, setBuilderLoaded] = useState(() => document.readyState === 'complete');
263 useEffect(() => {
264 if (builderLoaded) return;
265 const onLoad = () => setBuilderLoaded(true);
266 window.addEventListener('load', onLoad);
267 return () => window.removeEventListener('load', onLoad);
268 }, [builderLoaded]);
269 // Read the customizer button's actual CSS bottom so we stack correctly even
270 // in multi-part mode (where the customizer moves up to 62px). Falls back to
271 // null when the addon is not active — AI button then sits at bottom: 22px.
272 const [customizerBottom, setCustomizerBottom] = useState<number | null>(null);
273 // Multi-Part's own "Add New Part" tab bar pins itself to the same bottom-right corner this
274 // button defaults to when the Style Customizer addon isn't installed/active (so there's no
275 // `.everest-forms-designer-icon` to measure from) — without this, the two overlap (EVF-2736).
276 // Measured the same way as customizerBottom (from the real element's edge, not a guessed
277 // constant) since the bar's own height varies with its content/viewport.
278 const [multiPartBarBottom, setMultiPartBarBottom] = useState<number | null>(null);
279 // Show the assistant only on the Builder (Fields) tab — mirror the Style
280 // Customizer button, which lives inside the Fields panel and is hidden when
281 // other tabs (Settings, Integrations, …) are active.
282 const [onBuilderTab, setOnBuilderTab] = useState(true);
283 const messagesEndRef = useRef<HTMLDivElement>(null);
284 const inputRef = useRef<HTMLTextAreaElement>(null);
285
286 useEffect(() => {
287 const builder = document.getElementById('everest-forms-builder');
288 const read = () => {
289 const el = document.querySelector<HTMLElement>('.everest-forms-designer-icon');
290 if (el) {
291 const v = parseInt(window.getComputedStyle(el).bottom, 10);
292 setCustomizerBottom(isNaN(v) ? null : v);
293 } else {
294 setCustomizerBottom(null);
295 }
296 const bar = document.querySelector<HTMLElement>('.everest-forms-multi-part-tabs');
297 if (bar && builder?.classList.contains('multi-part-activated')) {
298 setMultiPartBarBottom(Math.round(window.innerHeight - bar.getBoundingClientRect().top));
299 } else {
300 setMultiPartBarBottom(null);
301 }
302 };
303 read();
304 // Re-read when builder classes change (multi-part toggle adds/removes class); polling
305 // alongside as a safety net for the same class of DOM-rewrite edge case noted below.
306 const observer = new MutationObserver(read);
307 if (builder) observer.observe(builder, { attributes: true, attributeFilter: ['class'] });
308 const interval = window.setInterval(read, 500);
309 return () => {
310 observer.disconnect();
311 window.clearInterval(interval);
312 };
313 }, []);
314
315 // Track the active builder tab. Switching tabs toggles the `active` class on the Fields
316 // panel; we only render the assistant while that panel is active. Re-query the panel INSIDE
317 // read() rather than capturing it once — Multi-Part's own tab-rebuild JS (triggered when it's
318 // enabled from Settings) can replace that DOM node entirely, which would otherwise leave a
319 // MutationObserver watching a detached element and freeze `onBuilderTab` forever (EVF-2736:
320 // the assistant never comes back after Settings → enable Multi-Part → Fields). Observing the
321 // stable builder root with `subtree: true` catches that swap either direction — but a
322 // half-second poll runs alongside it as a safety net, since some jQuery-driven DOM rewrites
323 // (full innerHTML replacement outside a single attribute mutation, timing around the rebuild)
324 // have still been reported to slip past the observer in the wild; polling can't miss.
325 useEffect(() => {
326 const root = document.getElementById('everest-forms-builder') || document.body;
327 const read = () => {
328 const panel = document.getElementById('everest-forms-panel-fields');
329 setOnBuilderTab(panel ? panel.classList.contains('active') : true);
330 };
331 read();
332 const observer = new MutationObserver(read);
333 observer.observe(root, { attributes: true, attributeFilter: ['class'], subtree: true, childList: true });
334 const interval = window.setInterval(read, 500);
335 return () => {
336 observer.disconnect();
337 window.clearInterval(interval);
338 };
339 }, []);
340
341 // Close the chat panel when navigating away from the Builder tab.
342 useEffect(() => {
343 if (!onBuilderTab) setOpen(false);
344 }, [onBuilderTab]);
345
346 // Auto-scroll to latest message.
347 useEffect(() => {
348 if (open) messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
349 }, [messages, open]);
350
351 // Focus input when panel opens.
352 useEffect(() => {
353 if (open) setTimeout(() => inputRef.current?.focus(), 120);
354 }, [open]);
355
356 // Seed the header credits pill on mount so the count is ready as soon as the panel opens.
357 // Best-effort — on a disabled (local) or unregistered site we skip the call and show no pill.
358 useEffect(() => {
359 if (AI_DISABLED || !cfg.ajaxUrl || !cfg.nonce) {
360 setUsageLoading(false);
361 return;
362 }
363 let cancelled = false;
364 const body = new URLSearchParams();
365 body.append('action', 'evf_ai_get_usage');
366 body.append('nonce', cfg.nonce);
367 fetch(cfg.ajaxUrl, {
368 method: 'POST',
369 credentials: 'same-origin',
370 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
371 body: body.toString(),
372 })
373 .then((r) => r.json())
374 .then((j) => {
375 if (cancelled) return;
376 const u = readUsage(j?.data);
377 if (u) setUsage(u);
378 })
379 .catch(() => {})
380 .finally(() => {
381 if (!cancelled) setUsageLoading(false);
382 });
383 return () => {
384 cancelled = true;
385 };
386 // eslint-disable-next-line react-hooks/exhaustive-deps
387 }, []);
388
389 const sendMessage = async (text: string) => {
390 if (!text.trim() || loading) return;
391 const userText = text.trim();
392 setInput('');
393
394 setMessages(prev => [...prev, { role: 'user', text: userText }]);
395 setLoading(true);
396 setMessages(prev => [...prev, { role: 'assistant', text: '', loading: true }]);
397
398 const result = await editFormViaAi(userText);
399
400 // Keep the header's "N requests left today" chip in sync.
401 if (result.usage) setUsage(result.usage);
402
403 // Track rate limit so the trigger button tooltip updates.
404 if (!result.ok && result.limitReached) {
405 setRateLimited(true);
406 setLimitTier(result.limitTier || 'free');
407 }
408
409 // Show the notice at most once per chat session.
410 if (result.isNotice && messages.some(m => m.notice)) {
411 result.isNotice = false;
412 result.noticeUrl = '';
413 result.message = "Done — I've updated your form. Refreshing the canvas…";
414 }
415
416 // When settings changed (redirect, email, message, etc.) set a clean done text.
417 // The reload link is rendered inside the bubble; no auto-reload happens.
418 if (result.ok && result.needsReload && !result.isNotice) {
419 result.message = "Done — your form settings have been updated.";
420 }
421
422 setMessages(prev => {
423 const copy = [...prev];
424 const last = copy[copy.length - 1];
425 if (!last?.loading) return copy;
426
427 if (result.ok && result.isNotice) {
428 // Edit succeeded but there's a Pro/addon notice — show "Done" first,
429 // then a separate notice bubble below so the user knows the edit applied.
430 copy[copy.length - 1] = {
431 role: 'assistant',
432 text: result.needsReload
433 ? "Done — your form settings have been updated."
434 : "Done — I've updated your form. Refreshing the canvas…",
435 };
436 copy.push({
437 role: 'assistant',
438 text: result.message,
439 notice: true,
440 noticeUrl: result.noticeUrl || '',
441 reload: !! result.needsReload,
442 });
443 } else {
444 copy[copy.length - 1] = {
445 role: 'assistant',
446 text: result.message,
447 notice: result.isNotice || ! result.ok,
448 noticeUrl: result.noticeUrl || '',
449 reload: result.ok && !! result.needsReload,
450 };
451 }
452 return copy;
453 });
454 setLoading(false);
455
456 if (result.ok) {
457 // Re-open the panel if the user collapsed it while the request was processing, so the
458 // success confirmation (and any Pro/reload notice) is visible now that it's done.
459 setOpen(true);
460 const w = window as any;
461 if (result.needsReload) {
462 // Settings changed — don't auto-reload; the bubble shows a manual
463 // "Refresh the page" link so the user can reload when ready.
464 } else if (typeof w.evfReloadBuilderFields === 'function' && cfg.formId && cfg.nonce) {
465 w.evfReloadBuilderFields(cfg.formId, cfg.nonce, () => {});
466 } else {
467 setTimeout(() => window.location.reload(), 1500);
468 }
469 }
470 };
471
472 const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
473 if (e.key === 'Enter' && !e.shiftKey) {
474 e.preventDefault();
475 sendMessage(input);
476 }
477 };
478
479 // Bottom offset of the trigger button.
480 // When the customizer is active we sit 8px above its top edge; otherwise, if Multi-Part's
481 // "Add New Part" bar occupies the usual 22px corner instead, we sit 8px above ITS top edge;
482 // otherwise we share the same plain bottom baseline (22px).
483 const BTN_SIZE = 55;
484 const BTN_RIGHT = 22;
485 const BASE_BOTTOM = multiPartBarBottom !== null ? multiPartBarBottom + 8 : 22;
486 const BTN_BOTTOM = customizerBottom !== null ? customizerBottom + BTN_SIZE + 8 : BASE_BOTTOM;
487 // Modal sits 8px above the top edge of the trigger button.
488 const MODAL_BOTTOM = BTN_BOTTOM + BTN_SIZE + 8;
489
490 // ── Render ──────────────────────────────────────────────────────────────
491
492 // Hidden outside the Builder (Fields) tab.
493 if (!onBuilderTab || !builderLoaded) return null;
494
495 return (
496 <>
497 {/* ── Floating trigger button (always rendered) ────────────────────
498 Shows sparkles when closed, X when open.
499 zIndex sits above the chat panel so it's always clickable. ── */}
500 <button
501 onClick={() => { if (!AI_DISABLED) { setOpen(o => !o); dismissHint(); } }}
502 style={{
503 position: 'fixed',
504 bottom: BTN_BOTTOM,
505 right: BTN_RIGHT,
506 width: BTN_SIZE,
507 height: BTN_SIZE,
508 borderRadius: '50%',
509 background: AI_DISABLED
510 ? 'linear-gradient(135deg,#b4a8cc 0%,#c8bce0 100%)'
511 : open
512 ? 'linear-gradient(135deg,#5c329c 0%,#7545BB 100%)'
513 : 'linear-gradient(135deg,#7545BB 0%,#9660db 100%)',
514 border: 'none',
515 cursor: AI_DISABLED ? 'not-allowed' : 'pointer',
516 display: 'flex',
517 alignItems: 'center',
518 justifyContent: 'center',
519 boxShadow: '0 4px 16px rgba(117,69,187,.45)',
520 // Above .select2-dropdown (999999) — see .evf-subscription-expiry-calendar
521 // for the same precedent — so field-setting dropdowns never cover the button.
522 zIndex: 1000001,
523 transition: 'transform .2s,box-shadow .2s,background .2s',
524 }}
525 onMouseEnter={e => {
526 setButtonHovered(true);
527 if (AI_DISABLED) return;
528 (e.currentTarget as HTMLButtonElement).style.transform = 'scale(1.08)';
529 (e.currentTarget as HTMLButtonElement).style.boxShadow = '0 6px 22px rgba(117,69,187,.55)';
530 }}
531 onMouseLeave={e => {
532 setButtonHovered(false);
533 (e.currentTarget as HTMLButtonElement).style.transform = 'scale(1)';
534 (e.currentTarget as HTMLButtonElement).style.boxShadow = '0 4px 16px rgba(117,69,187,.45)';
535 }}
536 >
537 {open ? <LuX size={22} color="white" /> : <LuSparkles size={24} color="white" />}
538 </button>
539
540 {/* ── Processing ring — spins around the closed trigger while a request is in
541 flight, so the user knows the AI is still working with the panel collapsed. ── */}
542 {loading && !open && !AI_DISABLED && (
543 <div
544 aria-hidden="true"
545 style={{
546 position: 'fixed',
547 bottom: BTN_BOTTOM - 4,
548 right: BTN_RIGHT - 4,
549 width: BTN_SIZE + 8,
550 height: BTN_SIZE + 8,
551 borderRadius: '50%',
552 border: '2.5px solid rgba(117,69,187,.25)',
553 borderTopColor: '#7545BB',
554 animation: 'evf-ai-spin .8s linear infinite',
555 // Below the button (1000001) so it reads as an arc around the button edge.
556 zIndex: 1000000,
557 pointerEvents: 'none',
558 }}
559 />
560 )}
561
562 {/* ── Discovery hint — shown once until dismissed or the panel is opened ── */}
563 {showHint && (
564 <div
565 style={{
566 position: 'fixed',
567 bottom: BTN_BOTTOM + BTN_SIZE + 10,
568 right: BTN_RIGHT,
569 width: 272,
570 zIndex: 1000002,
571 }}
572 >
573 <div
574 style={{
575 position: 'relative',
576 background: '#fff',
577 border: '1px solid #e9e2f3',
578 borderRadius: 14,
579 padding: '14px 16px',
580 boxShadow: '0 10px 30px rgba(88,45,163,.22), 0 2px 8px rgba(20,23,40,.08)',
581 }}
582 >
583 <button
584 type="button"
585 aria-label="Dismiss"
586 onClick={dismissHint}
587 style={{
588 position: 'absolute',
589 top: 8,
590 right: 8,
591 border: 0,
592 background: 'none',
593 color: '#9a95a8',
594 cursor: 'pointer',
595 width: 22,
596 height: 22,
597 borderRadius: '50%',
598 display: 'grid',
599 placeItems: 'center',
600 }}
601 onMouseEnter={e => {
602 e.currentTarget.style.background = 'rgba(117,69,187,.12)';
603 e.currentTarget.style.color = '#7545BB';
604 }}
605 onMouseLeave={e => {
606 e.currentTarget.style.background = 'none';
607 e.currentTarget.style.color = '#9a95a8';
608 }}
609 >
610 <LuX size={13} />
611 </button>
612 <div
613 style={{
614 display: 'flex',
615 alignItems: 'center',
616 gap: 5,
617 fontSize: 11,
618 fontWeight: 700,
619 letterSpacing: '.03em',
620 textTransform: 'uppercase',
621 color: '#7545BB',
622 marginBottom: 6,
623 }}
624 >
625 <LuSparkles size={12} />
626 New
627 </div>
628 <div style={{ fontSize: 13, lineHeight: 1.5, color: '#383838', paddingRight: 14 }}>
629 Tell me what to add or change, and I’ll update your form for you.
630 </div>
631 <div
632 style={{
633 position: 'absolute',
634 bottom: -8,
635 right: 24,
636 width: 0,
637 height: 0,
638 borderLeft: '8px solid transparent',
639 borderRight: '8px solid transparent',
640 borderTop: '8px solid #e9e2f3',
641 }}
642 />
643 <div
644 style={{
645 position: 'absolute',
646 bottom: -6,
647 right: 25,
648 width: 0,
649 height: 0,
650 borderLeft: '7px solid transparent',
651 borderRight: '7px solid transparent',
652 borderTop: '7px solid #fff',
653 }}
654 />
655 </div>
656 </div>
657 )}
658
659 {/* ── Tooltip — matches tooltipster style exactly, appears above the button ── */}
660 {showTooltip && !showHint && (
661 <div
662 onMouseEnter={() => setTooltipHovered(true)}
663 onMouseLeave={() => setTooltipHovered(false)}
664 style={{
665 position: 'fixed',
666 // Sit 8px above the trigger button top edge
667 bottom: BTN_BOTTOM + BTN_SIZE + 8,
668 // Place right edge at button center, then translateX(50%) to center tooltip over button
669 right: BTN_RIGHT + Math.round(BTN_SIZE / 2),
670 transform: 'translateX(50%)',
671 pointerEvents: rateLimited ? 'auto' : 'none',
672 zIndex: 1000002,
673 }}
674 >
675 {/* Box — matches tooltipster-box */}
676 <div style={{
677 background: '#fff',
678 border: '0.8px solid #e1e1e1',
679 borderRadius: 3,
680 padding: '16px 20px',
681 fontSize: 13,
682 color: '#222',
683 whiteSpace: 'nowrap',
684 position: 'relative',
685 }}>
686 {rateLimited ? (
687 <>
688 <div style={{ marginBottom: 8 }}>
689 {limitTier === 'pro'
690 ? "You've reached today's request limit. It resets tomorrow."
691 : "You've reached your daily free limit."}
692 </div>
693 {limitTier !== 'pro' && (
694 <a
695 href={UPGRADE_URL}
696 target="_blank"
697 rel="noopener noreferrer"
698 style={{ color: '#7545BB', fontWeight: 600, fontSize: 12, textDecoration: 'none' }}
699 onMouseEnter={e => (e.currentTarget.style.textDecoration = 'underline')}
700 onMouseLeave={e => (e.currentTarget.style.textDecoration = 'none')}
701 >
702 Upgrade to Pro →
703 </a>
704 )}
705 </>
706 ) : AI_DISABLED ? (
707 'Not available on local sites'
708 ) : (
709 'AI Form Assistant'
710 )}
711 </div>
712 {/* Down-pointing arrow centered under tooltip, pointing to button */}
713 {/* Outer arrow — border colour */}
714 <div style={{
715 position: 'absolute',
716 bottom: -8,
717 left: '50%',
718 marginLeft: -7,
719 width: 0, height: 0,
720 borderLeft: '7px solid transparent',
721 borderRight: '7px solid transparent',
722 borderTop: '8px solid #e1e1e1',
723 }} />
724 {/* Inner arrow — white fill */}
725 <div style={{
726 position: 'absolute',
727 bottom: -6,
728 left: '50%',
729 marginLeft: -6,
730 width: 0, height: 0,
731 borderLeft: '6px solid transparent',
732 borderRight: '6px solid transparent',
733 borderTop: '7px solid #fff',
734 }} />
735 </div>
736 )}
737
738 {/* ── Chat panel ── */}
739 {open && (
740 <div
741 style={{
742 position: 'fixed',
743 bottom: MODAL_BOTTOM,
744 right: BTN_RIGHT,
745 width: 440,
746 height: 520,
747 maxHeight: `calc(100vh - ${MODAL_BOTTOM + 40}px)`,
748 borderRadius: 16,
749 background: '#fff',
750 boxShadow: '0 8px 40px rgba(0,0,0,.18)',
751 border: '1px solid #e2e8f0',
752 display: 'flex',
753 flexDirection: 'column',
754 overflow: 'hidden',
755 zIndex: 1000000,
756 }}
757 >
758 {/* Header */}
759 <div
760 style={{
761 display: 'flex',
762 alignItems: 'center',
763 gap: 10,
764 padding: '0 16px',
765 height: 52,
766 background: 'linear-gradient(135deg,#7545BB 0%,#9660db 100%)',
767 flexShrink: 0,
768 }}
769 >
770 <div
771 style={{
772 width: 28,
773 height: 28,
774 borderRadius: '50%',
775 background: 'rgba(255,255,255,.15)',
776 display: 'flex',
777 alignItems: 'center',
778 justifyContent: 'center',
779 flexShrink: 0,
780 }}
781 >
782 <LuSparkles size={14} color="white" />
783 </div>
784 <div style={{ flex: 1, minWidth: 0 }}>
785 <div style={{ fontSize: 14, fontWeight: 600, color: '#fff', lineHeight: 1.2 }}>
786 AI Form Assistant
787 </div>
788 <div style={{ fontSize: 11, color: 'rgba(255,255,255,.7)', lineHeight: 1.2 }}>
789 Powered by AI
790 </div>
791 </div>
792 {!AI_DISABLED && (usage || usageLoading) && (
793 <UsagePill usage={usage} loading={usageLoading} />
794 )}
795 </div>
796
797 {/* Messages */}
798 <div
799 className="evf-ai-messages"
800 style={{
801 flex: 1,
802 overflowY: 'auto',
803 padding: '16px 14px',
804 display: 'flex',
805 flexDirection: 'column',
806 gap: 10,
807 }}
808 >
809 {messages.map((msg, i) => (
810 <div
811 key={i}
812 style={{
813 display: 'flex',
814 flexDirection: msg.role === 'user' ? 'row-reverse' : 'row',
815 alignItems: 'flex-end',
816 gap: 8,
817 }}
818 >
819 {msg.role === 'assistant' && (
820 <div
821 style={{
822 width: 26,
823 height: 26,
824 borderRadius: '50%',
825 background: 'rgba(117,69,187,.1)',
826 display: 'flex',
827 alignItems: 'center',
828 justifyContent: 'center',
829 flexShrink: 0,
830 }}
831 >
832 <LuSparkles size={13} color="#7545BB" />
833 </div>
834 )}
835
836 <div
837 style={{
838 maxWidth: '82%',
839 padding: '9px 12px',
840 borderRadius:
841 msg.role === 'user'
842 ? '14px 14px 4px 14px'
843 : '4px 14px 14px 14px',
844 background:
845 msg.role === 'user' ? '#7545BB' : msg.notice ? '#fff8f8' : '#f4f0fb',
846 color: msg.role === 'user' ? '#fff' : msg.notice ? '#c0392b' : '#1a1a2e',
847 border: msg.notice ? '1px solid #fca5a5' : 'none',
848 fontSize: 13,
849 lineHeight: 1.55,
850 boxShadow:
851 msg.role === 'user'
852 ? '0 2px 8px rgba(117,69,187,.2)'
853 : 'none',
854 }}
855 >
856 {msg.loading ? (
857 <div style={{ display: 'flex', gap: 4, padding: '2px 0' }}>
858 {[0, 1, 2].map(d => (
859 <span
860 key={d}
861 style={{
862 width: 6,
863 height: 6,
864 borderRadius: '50%',
865 background: '#9660db',
866 display: 'inline-block',
867 animation: `evf-ai-dot 1.1s ease-in-out ${d * 0.18}s infinite`,
868 }}
869 />
870 ))}
871 </div>
872 ) : (
873 <>
874 {msg.text}
875 {(msg.reload || (msg.notice && msg.noticeUrl)) && (
876 <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 14, marginTop: 6 }}>
877 {msg.reload && (
878 <a
879 href="#"
880 onClick={e => { e.preventDefault(); window.location.reload(); }}
881 style={{ color: '#7545BB', fontWeight: 600, fontSize: 12, textDecoration: 'underline', cursor: 'pointer' }}
882 >
883 Refresh the page ↻
884 </a>
885 )}
886 {msg.notice && msg.noticeUrl && (
887 <a
888 href={msg.noticeUrl}
889 target="_blank"
890 rel="noopener noreferrer"
891 style={{ color: '#7545BB', fontWeight: 600, fontSize: 12, textDecoration: 'underline' }}
892 >
893 Upgrade to Pro ↗
894 </a>
895 )}
896 </div>
897 )}
898 </>
899 )}
900 </div>
901 </div>
902 ))}
903 <div ref={messagesEndRef} />
904 </div>
905
906 {/* Suggestions strip */}
907 <div
908 className="evf-ai-suggestions"
909 style={{
910 padding: '0 14px 10px',
911 display: 'flex',
912 gap: 6,
913 overflowX: 'auto',
914 flexShrink: 0,
915 scrollbarWidth: 'thin',
916 scrollbarColor: '#d4c5f0 transparent',
917 }}
918 >
919 {EDIT_SUGGESTIONS.map(s => (
920 <button
921 key={s}
922 onClick={() => sendMessage(s)}
923 style={{
924 flexShrink: 0,
925 padding: '5px 10px',
926 borderRadius: 20,
927 border: '1px solid #e2e8f0',
928 background: '#faf9ff',
929 color: '#7545BB',
930 fontSize: 11.5,
931 fontWeight: 500,
932 cursor: 'pointer',
933 whiteSpace: 'nowrap',
934 transition: 'background .15s,border-color .15s',
935 }}
936 onMouseEnter={e => {
937 (e.currentTarget as HTMLButtonElement).style.background = '#f0ebfa';
938 (e.currentTarget as HTMLButtonElement).style.borderColor = '#b89ee0';
939 }}
940 onMouseLeave={e => {
941 (e.currentTarget as HTMLButtonElement).style.background = '#faf9ff';
942 (e.currentTarget as HTMLButtonElement).style.borderColor = '#e2e8f0';
943 }}
944 >
945 {s}
946 </button>
947 ))}
948 </div>
949
950 {/* Input bar */}
951 <div
952 style={{
953 padding: '10px 14px 14px',
954 borderTop: '1px solid #f1f5f9',
955 flexShrink: 0,
956 }}
957 >
958 <div
959 style={{
960 display: 'flex',
961 alignItems: 'flex-end',
962 gap: 8,
963 border: '1.5px solid #e2e8f0',
964 borderRadius: 12,
965 padding: '8px 10px 8px 14px',
966 background: '#fff',
967 transition: 'border-color .2s',
968 }}
969 >
970 <textarea
971 ref={inputRef}
972 value={input}
973 onChange={e => setInput(e.target.value)}
974 onKeyDown={handleKeyDown}
975 placeholder="Describe what to change…"
976 rows={1}
977 style={{
978 flex: 1,
979 border: 'none',
980 outline: 'none',
981 resize: 'none',
982 fontSize: 13,
983 color: '#1a1a2e',
984 background: 'transparent',
985 lineHeight: 1.5,
986 maxHeight: 80,
987 overflowY: 'auto',
988 fontFamily: 'inherit',
989 }}
990 />
991 <button
992 onClick={() => sendMessage(input)}
993 disabled={!input.trim() || loading}
994 style={{
995 width: 32,
996 height: 32,
997 borderRadius: 8,
998 border: 'none',
999 background: input.trim() && !loading ? '#7545BB' : '#e6e3ee',
1000 cursor: input.trim() && !loading ? 'pointer' : 'not-allowed',
1001 display: 'flex',
1002 alignItems: 'center',
1003 justifyContent: 'center',
1004 flexShrink: 0,
1005 transition: 'background .2s',
1006 }}
1007 >
1008 <LuSend
1009 size={15}
1010 color={input.trim() && !loading ? '#fff' : '#9a9a9a'}
1011 />
1012 </button>
1013 </div>
1014 <p style={{ fontSize: 11, color: '#9ca3af', margin: '6px 0 0', textAlign: 'center' }}>
1015 AI edits update your form and refresh the canvas.
1016 </p>
1017 </div>
1018 </div>
1019 )}
1020
1021 {/* Dot-bounce keyframes + minimal scrollbar styles */}
1022 <style>{`
1023 @keyframes evf-ai-dot {
1024 0%,80%,100%{transform:scale(.4);opacity:.4}
1025 40%{transform:scale(1);opacity:1}
1026 }
1027 @keyframes evf-ai-spin {
1028 to { transform: rotate(360deg); }
1029 }
1030 @keyframes evf-ai-pulse {
1031 0%,100% { opacity: .5; }
1032 50% { opacity: 1; }
1033 }
1034 .evf-ai-messages::-webkit-scrollbar,
1035 .evf-ai-suggestions::-webkit-scrollbar {
1036 width: 4px;
1037 height: 4px;
1038 }
1039 .evf-ai-messages::-webkit-scrollbar-track,
1040 .evf-ai-suggestions::-webkit-scrollbar-track {
1041 background: transparent;
1042 }
1043 .evf-ai-messages::-webkit-scrollbar-thumb,
1044 .evf-ai-suggestions::-webkit-scrollbar-thumb {
1045 background: #d4c5f0;
1046 border-radius: 4px;
1047 }
1048 .evf-ai-messages::-webkit-scrollbar-thumb:hover,
1049 .evf-ai-suggestions::-webkit-scrollbar-thumb:hover {
1050 background: #b89ee0;
1051 }
1052 `}</style>
1053 </>
1054 );
1055 };
1056
1057 export default BuilderAIChat;
1058