PluginProbe ʕ •ᴥ•ʔ
Everest Forms – Contact Form, Payment Form, Quiz, Survey & Custom Form Builder with AI / 3.5.0
Everest Forms – Contact Form, Payment Form, Quiz, Survey & Custom Form Builder with AI v3.5.0
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 1 month ago index.tsx 1 month ago
BuilderAIChat.tsx
683 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 // Builder context (form id + nonce) localized by class-evf-admin-assets.php.
32 interface BuilderAIConfig {
33 ajaxUrl?: string;
34 nonce?: string;
35 formId?: number;
36 formTitle?: string;
37 aiDisabled?: boolean;
38 }
39 const cfg: BuilderAIConfig = ( window as any ).evfBuilderAI || {};
40
41 // On local / development sites the AI gateway is unavailable — the assistant is
42 // shown but disabled (greyed trigger, opens nothing, explains why on hover).
43 const AI_DISABLED = !! cfg.aiDisabled;
44
45 // Edit the current builder form via the ThemeGrill AI Cloud (Python) gateway.
46 const editFormViaAi = async (
47 instruction: string,
48 ): Promise<{ ok: boolean; message: string; isNotice?: boolean; noticeUrl?: string; needsReload?: boolean }> => {
49 if ( ! cfg.ajaxUrl || ! cfg.nonce || ! cfg.formId ) {
50 return { ok: false, message: 'AI assistant is unavailable on this screen.' };
51 }
52
53 const body = new URLSearchParams();
54 body.append( 'action', 'evf_ai_update_form' );
55 body.append( 'nonce', cfg.nonce );
56 body.append( 'form_id', String( cfg.formId ) );
57 body.append( 'prompt', cfg.formTitle || 'Edit this form' );
58 body.append( 'refine_prompt', instruction );
59
60 try {
61 const resp = await fetch( cfg.ajaxUrl, {
62 method: 'POST',
63 credentials: 'same-origin',
64 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
65 body: body.toString(),
66 } );
67 const json = await resp.json();
68 if ( json?.success ) {
69 return {
70 ok: true,
71 message: json?.data?.notice || "Done — I've updated your form. Refreshing the canvas…",
72 isNotice: !! json?.data?.notice,
73 noticeUrl: json?.data?.notice_url || '',
74 needsReload: !! json?.data?.needs_reload,
75 };
76 }
77 return {
78 ok: false,
79 message: json?.data?.message || 'Sorry, I could not update the form. Please try again.',
80 noticeUrl: json?.data?.code === 'rate_limited'
81 ? 'https://everestforms.net/upgrade/?utm_source=evf-free&utm_medium=ai-chat&utm_campaign=daily-limit&utm_content=Upgrade+to+Pro'
82 : '',
83 };
84 } catch {
85 return { ok: false, message: 'Could not reach the AI service. Please try again.' };
86 }
87 };
88
89 // ── Component ─────────────────────────────────────────────────────────────────
90
91 const BuilderAIChat: React.FC = () => {
92 const [open, setOpen] = useState(false);
93 const [input, setInput] = useState('');
94 const [messages, setMessages] = useState<Message[]>([
95 { role: 'assistant', text: GREETING },
96 ]);
97 const [loading, setLoading] = useState(false);
98 const [buttonHovered, setButtonHovered] = useState(false);
99 const [tooltipHovered, setTooltipHovered] = useState(false);
100 const [rateLimited, setRateLimited] = useState(false);
101 const [upgradeUrl, setUpgradeUrl] = useState('');
102 const showTooltip = !open && (buttonHovered || tooltipHovered);
103 // Read the customizer button's actual CSS bottom so we stack correctly even
104 // in multi-part mode (where the customizer moves up to 62px). Falls back to
105 // null when the addon is not active — AI button then sits at bottom: 22px.
106 const [customizerBottom, setCustomizerBottom] = useState<number | null>(null);
107 // Show the assistant only on the Builder (Fields) tab — mirror the Style
108 // Customizer button, which lives inside the Fields panel and is hidden when
109 // other tabs (Settings, Integrations, …) are active.
110 const [onBuilderTab, setOnBuilderTab] = useState(true);
111 const messagesEndRef = useRef<HTMLDivElement>(null);
112 const inputRef = useRef<HTMLTextAreaElement>(null);
113
114 useEffect(() => {
115 const read = () => {
116 const el = document.querySelector<HTMLElement>('.everest-forms-designer-icon');
117 if (el) {
118 const v = parseInt(window.getComputedStyle(el).bottom, 10);
119 setCustomizerBottom(isNaN(v) ? null : v);
120 } else {
121 setCustomizerBottom(null);
122 }
123 };
124 read();
125 // Re-read when builder classes change (multi-part toggle adds/removes class).
126 const observer = new MutationObserver(read);
127 const builder = document.getElementById('everest-forms-builder');
128 if (builder) observer.observe(builder, { attributes: true, attributeFilter: ['class'] });
129 return () => observer.disconnect();
130 }, []);
131
132 // Track the active builder tab. Switching tabs toggles the `active` class on
133 // the Fields panel; we only render the assistant while that panel is active.
134 useEffect(() => {
135 const panel = document.getElementById('everest-forms-panel-fields');
136 const read = () => setOnBuilderTab(panel ? panel.classList.contains('active') : true);
137 read();
138 if (!panel) return;
139 const observer = new MutationObserver(read);
140 observer.observe(panel, { attributes: true, attributeFilter: ['class'] });
141 return () => observer.disconnect();
142 }, []);
143
144 // Close the chat panel when navigating away from the Builder tab.
145 useEffect(() => {
146 if (!onBuilderTab) setOpen(false);
147 }, [onBuilderTab]);
148
149 // Auto-scroll to latest message.
150 useEffect(() => {
151 if (open) messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
152 }, [messages, open]);
153
154 // Focus input when panel opens.
155 useEffect(() => {
156 if (open) setTimeout(() => inputRef.current?.focus(), 120);
157 }, [open]);
158
159 const sendMessage = async (text: string) => {
160 if (!text.trim() || loading) return;
161 const userText = text.trim();
162 setInput('');
163
164 setMessages(prev => [...prev, { role: 'user', text: userText }]);
165 setLoading(true);
166 setMessages(prev => [...prev, { role: 'assistant', text: '', loading: true }]);
167
168 const result = await editFormViaAi(userText);
169
170 // Track rate limit so the trigger button tooltip updates.
171 if (!result.ok && result.noticeUrl) {
172 setRateLimited(true);
173 setUpgradeUrl(result.noticeUrl);
174 }
175
176 // Show the notice at most once per chat session.
177 if (result.isNotice && messages.some(m => m.notice)) {
178 result.isNotice = false;
179 result.noticeUrl = '';
180 result.message = "Done — I've updated your form. Refreshing the canvas…";
181 }
182
183 // When settings changed (redirect, email, message, etc.) set a clean done text.
184 // The reload link is rendered inside the bubble; no auto-reload happens.
185 if (result.ok && result.needsReload && !result.isNotice) {
186 result.message = "Done — your form settings have been updated.";
187 }
188
189 setMessages(prev => {
190 const copy = [...prev];
191 const last = copy[copy.length - 1];
192 if (!last?.loading) return copy;
193
194 if (result.ok && result.isNotice) {
195 // Edit succeeded but there's a Pro/addon notice — show "Done" first,
196 // then a separate notice bubble below so the user knows the edit applied.
197 copy[copy.length - 1] = {
198 role: 'assistant',
199 text: result.needsReload
200 ? "Done — your form settings have been updated."
201 : "Done — I've updated your form. Refreshing the canvas…",
202 };
203 copy.push({
204 role: 'assistant',
205 text: result.message,
206 notice: true,
207 noticeUrl: result.noticeUrl || '',
208 reload: !! result.needsReload,
209 });
210 } else {
211 copy[copy.length - 1] = {
212 role: 'assistant',
213 text: result.message,
214 notice: result.isNotice || ! result.ok,
215 noticeUrl: result.noticeUrl || '',
216 reload: result.ok && !! result.needsReload,
217 };
218 }
219 return copy;
220 });
221 setLoading(false);
222
223 if (result.ok) {
224 const w = window as any;
225 if (result.needsReload) {
226 // Settings changed — don't auto-reload; the bubble shows a manual
227 // "Refresh the page" link so the user can reload when ready.
228 } else if (typeof w.evfReloadBuilderFields === 'function' && cfg.formId && cfg.nonce) {
229 w.evfReloadBuilderFields(cfg.formId, cfg.nonce, () => {});
230 } else {
231 setTimeout(() => window.location.reload(), 1500);
232 }
233 }
234 };
235
236 const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
237 if (e.key === 'Enter' && !e.shiftKey) {
238 e.preventDefault();
239 sendMessage(input);
240 }
241 };
242
243 // Bottom offset of the trigger button.
244 // When the customizer is active we sit 8px above its top edge;
245 // otherwise we share the same bottom baseline (22px).
246 const BTN_SIZE = 55;
247 const BTN_RIGHT = 22;
248 const BTN_BOTTOM = customizerBottom !== null ? customizerBottom + BTN_SIZE + 8 : 22;
249 // Modal sits 8px above the top edge of the trigger button.
250 const MODAL_BOTTOM = BTN_BOTTOM + BTN_SIZE + 8;
251
252 // ── Render ──────────────────────────────────────────────────────────────
253
254 // Hidden outside the Builder (Fields) tab.
255 if (!onBuilderTab) return null;
256
257 return (
258 <>
259 {/* ── Floating trigger button (always rendered) ────────────────────
260 Shows sparkles when closed, X when open.
261 zIndex sits above the chat panel so it's always clickable. ── */}
262 <button
263 onClick={() => { if (!AI_DISABLED) setOpen(o => !o); }}
264 style={{
265 position: 'fixed',
266 bottom: BTN_BOTTOM,
267 right: BTN_RIGHT,
268 width: BTN_SIZE,
269 height: BTN_SIZE,
270 borderRadius: '50%',
271 background: AI_DISABLED
272 ? 'linear-gradient(135deg,#b4a8cc 0%,#c8bce0 100%)'
273 : open
274 ? 'linear-gradient(135deg,#5c329c 0%,#7545BB 100%)'
275 : 'linear-gradient(135deg,#7545BB 0%,#9660db 100%)',
276 border: 'none',
277 cursor: AI_DISABLED ? 'not-allowed' : 'pointer',
278 display: 'flex',
279 alignItems: 'center',
280 justifyContent: 'center',
281 boxShadow: '0 4px 16px rgba(117,69,187,.45)',
282 zIndex: 10000,
283 transition: 'transform .2s,box-shadow .2s,background .2s',
284 }}
285 onMouseEnter={e => {
286 setButtonHovered(true);
287 if (AI_DISABLED) return;
288 (e.currentTarget as HTMLButtonElement).style.transform = 'scale(1.08)';
289 (e.currentTarget as HTMLButtonElement).style.boxShadow = '0 6px 22px rgba(117,69,187,.55)';
290 }}
291 onMouseLeave={e => {
292 setButtonHovered(false);
293 (e.currentTarget as HTMLButtonElement).style.transform = 'scale(1)';
294 (e.currentTarget as HTMLButtonElement).style.boxShadow = '0 4px 16px rgba(117,69,187,.45)';
295 }}
296 >
297 {open ? <LuX size={22} color="white" /> : <LuSparkles size={24} color="white" />}
298 </button>
299
300 {/* ── Tooltip — matches tooltipster style exactly, appears above the button ── */}
301 {showTooltip && (
302 <div
303 onMouseEnter={() => setTooltipHovered(true)}
304 onMouseLeave={() => setTooltipHovered(false)}
305 style={{
306 position: 'fixed',
307 // Sit 8px above the trigger button top edge
308 bottom: BTN_BOTTOM + BTN_SIZE + 8,
309 // Place right edge at button center, then translateX(50%) to center tooltip over button
310 right: BTN_RIGHT + Math.round(BTN_SIZE / 2),
311 transform: 'translateX(50%)',
312 pointerEvents: rateLimited ? 'auto' : 'none',
313 zIndex: 10001,
314 }}
315 >
316 {/* Box — matches tooltipster-box */}
317 <div style={{
318 background: '#fff',
319 border: '0.8px solid #e1e1e1',
320 borderRadius: 3,
321 padding: '16px 20px',
322 fontSize: 13,
323 color: '#222',
324 whiteSpace: 'nowrap',
325 position: 'relative',
326 }}>
327 {rateLimited ? (
328 <>
329 <div style={{ marginBottom: 8 }}>You've reached your daily free limit.</div>
330 <a
331 href={upgradeUrl}
332 target="_blank"
333 rel="noopener noreferrer"
334 style={{ color: '#7545BB', fontWeight: 600, fontSize: 12, textDecoration: 'none' }}
335 onMouseEnter={e => (e.currentTarget.style.textDecoration = 'underline')}
336 onMouseLeave={e => (e.currentTarget.style.textDecoration = 'none')}
337 >
338 Upgrade to Pro →
339 </a>
340 </>
341 ) : AI_DISABLED ? (
342 'Not available on local sites'
343 ) : (
344 'AI Form Assistant'
345 )}
346 </div>
347 {/* Down-pointing arrow centered under tooltip, pointing to button */}
348 {/* Outer arrow — border colour */}
349 <div style={{
350 position: 'absolute',
351 bottom: -8,
352 left: '50%',
353 marginLeft: -7,
354 width: 0, height: 0,
355 borderLeft: '7px solid transparent',
356 borderRight: '7px solid transparent',
357 borderTop: '8px solid #e1e1e1',
358 }} />
359 {/* Inner arrow — white fill */}
360 <div style={{
361 position: 'absolute',
362 bottom: -6,
363 left: '50%',
364 marginLeft: -6,
365 width: 0, height: 0,
366 borderLeft: '6px solid transparent',
367 borderRight: '6px solid transparent',
368 borderTop: '7px solid #fff',
369 }} />
370 </div>
371 )}
372
373 {/* ── Chat panel ── */}
374 {open && (
375 <div
376 style={{
377 position: 'fixed',
378 bottom: MODAL_BOTTOM,
379 right: BTN_RIGHT,
380 width: 440,
381 height: 520,
382 maxHeight: `calc(100vh - ${MODAL_BOTTOM + 40}px)`,
383 borderRadius: 16,
384 background: '#fff',
385 boxShadow: '0 8px 40px rgba(0,0,0,.18)',
386 border: '1px solid #e2e8f0',
387 display: 'flex',
388 flexDirection: 'column',
389 overflow: 'hidden',
390 zIndex: 9999,
391 }}
392 >
393 {/* Header */}
394 <div
395 style={{
396 display: 'flex',
397 alignItems: 'center',
398 gap: 10,
399 padding: '0 16px',
400 height: 52,
401 background: 'linear-gradient(135deg,#7545BB 0%,#9660db 100%)',
402 flexShrink: 0,
403 }}
404 >
405 <div
406 style={{
407 width: 28,
408 height: 28,
409 borderRadius: '50%',
410 background: 'rgba(255,255,255,.15)',
411 display: 'flex',
412 alignItems: 'center',
413 justifyContent: 'center',
414 flexShrink: 0,
415 }}
416 >
417 <LuSparkles size={14} color="white" />
418 </div>
419 <div style={{ flex: 1, minWidth: 0 }}>
420 <div style={{ fontSize: 14, fontWeight: 600, color: '#fff', lineHeight: 1.2 }}>
421 AI Form Assistant
422 </div>
423 <div style={{ fontSize: 11, color: 'rgba(255,255,255,.7)', lineHeight: 1.2 }}>
424 Powered by AI
425 </div>
426 </div>
427 </div>
428
429 {/* Messages */}
430 <div
431 className="evf-ai-messages"
432 style={{
433 flex: 1,
434 overflowY: 'auto',
435 padding: '16px 14px',
436 display: 'flex',
437 flexDirection: 'column',
438 gap: 10,
439 }}
440 >
441 {messages.map((msg, i) => (
442 <div
443 key={i}
444 style={{
445 display: 'flex',
446 flexDirection: msg.role === 'user' ? 'row-reverse' : 'row',
447 alignItems: 'flex-end',
448 gap: 8,
449 }}
450 >
451 {msg.role === 'assistant' && (
452 <div
453 style={{
454 width: 26,
455 height: 26,
456 borderRadius: '50%',
457 background: 'rgba(117,69,187,.1)',
458 display: 'flex',
459 alignItems: 'center',
460 justifyContent: 'center',
461 flexShrink: 0,
462 }}
463 >
464 <LuSparkles size={13} color="#7545BB" />
465 </div>
466 )}
467
468 <div
469 style={{
470 maxWidth: '82%',
471 padding: '9px 12px',
472 borderRadius:
473 msg.role === 'user'
474 ? '14px 14px 4px 14px'
475 : '4px 14px 14px 14px',
476 background:
477 msg.role === 'user' ? '#7545BB' : msg.notice ? '#fff8f8' : '#f4f0fb',
478 color: msg.role === 'user' ? '#fff' : msg.notice ? '#c0392b' : '#1a1a2e',
479 border: msg.notice ? '1px solid #fca5a5' : 'none',
480 fontSize: 13,
481 lineHeight: 1.55,
482 boxShadow:
483 msg.role === 'user'
484 ? '0 2px 8px rgba(117,69,187,.2)'
485 : 'none',
486 }}
487 >
488 {msg.loading ? (
489 <div style={{ display: 'flex', gap: 4, padding: '2px 0' }}>
490 {[0, 1, 2].map(d => (
491 <span
492 key={d}
493 style={{
494 width: 6,
495 height: 6,
496 borderRadius: '50%',
497 background: '#9660db',
498 display: 'inline-block',
499 animation: `evf-ai-dot 1.1s ease-in-out ${d * 0.18}s infinite`,
500 }}
501 />
502 ))}
503 </div>
504 ) : (
505 <>
506 {msg.text}
507 {msg.reload && (
508 <div style={{ marginTop: 6 }}>
509 <a
510 href="#"
511 onClick={e => { e.preventDefault(); window.location.reload(); }}
512 style={{ color: '#7545BB', fontWeight: 600, fontSize: 12, textDecoration: 'underline', cursor: 'pointer' }}
513 >
514 Refresh the page ↻
515 </a>
516 </div>
517 )}
518 {msg.notice && msg.noticeUrl && (
519 <div style={{ marginTop: 6 }}>
520 <a
521 href={msg.noticeUrl}
522 target="_blank"
523 rel="noopener noreferrer"
524 style={{ color: '#c0392b', fontWeight: 600, fontSize: 12, textDecoration: 'underline' }}
525 >
526 Upgrade to Pro ↗
527 </a>
528 </div>
529 )}
530 </>
531 )}
532 </div>
533 </div>
534 ))}
535 <div ref={messagesEndRef} />
536 </div>
537
538 {/* Suggestions strip */}
539 <div
540 className="evf-ai-suggestions"
541 style={{
542 padding: '0 14px 10px',
543 display: 'flex',
544 gap: 6,
545 overflowX: 'auto',
546 flexShrink: 0,
547 scrollbarWidth: 'thin',
548 scrollbarColor: '#d4c5f0 transparent',
549 }}
550 >
551 {EDIT_SUGGESTIONS.map(s => (
552 <button
553 key={s}
554 onClick={() => sendMessage(s)}
555 style={{
556 flexShrink: 0,
557 padding: '5px 10px',
558 borderRadius: 20,
559 border: '1px solid #e2e8f0',
560 background: '#faf9ff',
561 color: '#7545BB',
562 fontSize: 11.5,
563 fontWeight: 500,
564 cursor: 'pointer',
565 whiteSpace: 'nowrap',
566 transition: 'background .15s,border-color .15s',
567 }}
568 onMouseEnter={e => {
569 (e.currentTarget as HTMLButtonElement).style.background = '#f0ebfa';
570 (e.currentTarget as HTMLButtonElement).style.borderColor = '#b89ee0';
571 }}
572 onMouseLeave={e => {
573 (e.currentTarget as HTMLButtonElement).style.background = '#faf9ff';
574 (e.currentTarget as HTMLButtonElement).style.borderColor = '#e2e8f0';
575 }}
576 >
577 {s}
578 </button>
579 ))}
580 </div>
581
582 {/* Input bar */}
583 <div
584 style={{
585 padding: '10px 14px 14px',
586 borderTop: '1px solid #f1f5f9',
587 flexShrink: 0,
588 }}
589 >
590 <div
591 style={{
592 display: 'flex',
593 alignItems: 'flex-end',
594 gap: 8,
595 border: '1.5px solid #e2e8f0',
596 borderRadius: 12,
597 padding: '8px 10px 8px 14px',
598 background: '#fff',
599 transition: 'border-color .2s',
600 }}
601 >
602 <textarea
603 ref={inputRef}
604 value={input}
605 onChange={e => setInput(e.target.value)}
606 onKeyDown={handleKeyDown}
607 placeholder="Describe what to change…"
608 rows={1}
609 style={{
610 flex: 1,
611 border: 'none',
612 outline: 'none',
613 resize: 'none',
614 fontSize: 13,
615 color: '#1a1a2e',
616 background: 'transparent',
617 lineHeight: 1.5,
618 maxHeight: 80,
619 overflowY: 'auto',
620 fontFamily: 'inherit',
621 }}
622 />
623 <button
624 onClick={() => sendMessage(input)}
625 disabled={!input.trim() || loading}
626 style={{
627 width: 32,
628 height: 32,
629 borderRadius: 8,
630 border: 'none',
631 background: input.trim() && !loading ? '#7545BB' : '#e6e3ee',
632 cursor: input.trim() && !loading ? 'pointer' : 'not-allowed',
633 display: 'flex',
634 alignItems: 'center',
635 justifyContent: 'center',
636 flexShrink: 0,
637 transition: 'background .2s',
638 }}
639 >
640 <LuSend
641 size={15}
642 color={input.trim() && !loading ? '#fff' : '#9a9a9a'}
643 />
644 </button>
645 </div>
646 <p style={{ fontSize: 11, color: '#9ca3af', margin: '6px 0 0', textAlign: 'center' }}>
647 AI edits update your form and refresh the canvas.
648 </p>
649 </div>
650 </div>
651 )}
652
653 {/* Dot-bounce keyframes + minimal scrollbar styles */}
654 <style>{`
655 @keyframes evf-ai-dot {
656 0%,80%,100%{transform:scale(.4);opacity:.4}
657 40%{transform:scale(1);opacity:1}
658 }
659 .evf-ai-messages::-webkit-scrollbar,
660 .evf-ai-suggestions::-webkit-scrollbar {
661 width: 4px;
662 height: 4px;
663 }
664 .evf-ai-messages::-webkit-scrollbar-track,
665 .evf-ai-suggestions::-webkit-scrollbar-track {
666 background: transparent;
667 }
668 .evf-ai-messages::-webkit-scrollbar-thumb,
669 .evf-ai-suggestions::-webkit-scrollbar-thumb {
670 background: #d4c5f0;
671 border-radius: 4px;
672 }
673 .evf-ai-messages::-webkit-scrollbar-thumb:hover,
674 .evf-ai-suggestions::-webkit-scrollbar-thumb:hover {
675 background: #b89ee0;
676 }
677 `}</style>
678 </>
679 );
680 };
681
682 export default BuilderAIChat;
683