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 / style-customizer-v2 / AiAssistant.tsx
everest-forms / src / style-customizer-v2 Last commit date
AiAssistant.tsx 2 days ago App.tsx 2 days ago BuilderSync.ts 2 days ago ControlRenderer.tsx 2 days ago HoverTip.tsx 2 days ago Popover.tsx 2 days ago PreviewBridge.ts 2 days ago PreviewPane.tsx 2 days ago constants.ts 2 days ago cssVars.ts 2 days ago globals.d.ts 2 days ago index.tsx 2 days ago panes.tsx 2 days ago store.ts 2 days ago style.scss 2 days ago types.ts 2 days ago
AiAssistant.tsx
1046 lines
1 /**
2 * Style Customizer v2 — floating "Style with AI" launcher that turns a prompt into style
3 * tokens via `POST {restBase}/{formId}/ai`.
4 */
5 import React, { useEffect, useRef, useState } from 'react';
6 import { LuSend, LuSparkles, LuX } from 'react-icons/lu';
7 import { UPGRADE_URL } from './panes';
8 import { useStore } from './store';
9 import { Template } from './types';
10
11 const __ = ( window as any ).wp?.i18n?.__ || ( ( s: string ) => s );
12 const _n =
13 ( window as any ).wp?.i18n?._n ||
14 ( ( single: string, plural: string, n: number ) => ( 1 === n ? single : plural ) );
15 const sprintf =
16 ( window as any ).wp?.i18n?.sprintf ||
17 ( ( fmt: string, n: number ) => fmt.replace( '%d', String( n ) ) );
18 const apiFetch = ( window as any ).wp?.apiFetch;
19
20 /** Daily-request usage snapshot the gateway now returns on every AI response. */
21 interface UsageInfo {
22 remaining: number;
23 limit: number;
24 used: number;
25 }
26
27 // At/below this many remaining requests the count switches to a gentle amber warning.
28 const USAGE_LOW_THRESHOLD = 3;
29
30 /** "7 requests left today" — properly pluralized. */
31 const usageLabel = ( remaining: number ): string =>
32 sprintf(
33 _n( '%d request left today', '%d requests left today', remaining, 'everest-forms' ),
34 remaining
35 );
36
37 /** Read the { remaining, limit, used } usage object off a raw AI response/error, or null. */
38 const readUsage = ( raw: any ): UsageInfo | null => {
39 const u = raw && raw.usage;
40 if ( u && 'number' === typeof u.remaining ) {
41 return { remaining: u.remaining, limit: u.limit, used: u.used };
42 }
43 return null;
44 };
45
46 /** Full tooltip text for the credits pill. */
47 const usageTooltip = ( usage: UsageInfo ): string =>
48 'number' === typeof usage.limit && usage.limit > 0
49 ? sprintf(
50 /* translators: 1: remaining requests, 2: daily limit. */
51 __( '%1$d of %2$d AI requests left today · resets daily', 'everest-forms' ),
52 usage.remaining,
53 usage.limit
54 )
55 : usageLabel( usage.remaining );
56
57 /**
58 * The daily-request "credits" pill for the panel header — a sparkle, an `18/20` count, and a
59 * slim meter that drains as requests are used. Translucent white on the purple header; turns
60 * amber when the count runs low or is exhausted.
61 */
62 const UsagePill: React.FC< { usage: UsageInfo | null; loading?: boolean } > = ( { usage, loading } ) => {
63 if ( ! usage ) {
64 if ( ! loading ) return null;
65 // Skeleton — holds the pill's place while the first count loads.
66 return (
67 <div
68 style={ {
69 flexShrink: 0,
70 width: 62,
71 height: 22,
72 borderRadius: 20,
73 background: 'rgba(255,255,255,.16)',
74 animation: 'scv2-ai-pulse 1.3s ease-in-out infinite',
75 } }
76 />
77 );
78 }
79 const hasLimit = 'number' === typeof usage.limit && usage.limit > 0;
80 const { remaining } = usage;
81 const amber = remaining <= USAGE_LOW_THRESHOLD;
82 const frac = hasLimit ? Math.max( 0, Math.min( 1, remaining / usage.limit ) ) : 1;
83 const numColor = amber ? '#7a4b00' : '#fff';
84 const denColor = amber ? 'rgba(122,75,0,.7)' : 'rgba(255,255,255,.7)';
85 return (
86 <div
87 title={ usageTooltip( usage ) }
88 style={ {
89 flexShrink: 0,
90 display: 'inline-flex',
91 alignItems: 'center',
92 gap: 6,
93 height: 22,
94 padding: '0 10px',
95 borderRadius: 20,
96 lineHeight: 1,
97 background: amber ? '#fbbf24' : 'rgba(255,255,255,.16)',
98 } }
99 >
100 <LuSparkles size={ 11 } color={ numColor } />
101 <span style={ { display: 'inline-flex', alignItems: 'baseline', gap: 1, fontSize: 11.5, fontWeight: 700, color: numColor } }>
102 { remaining }
103 { hasLimit && <span style={ { fontWeight: 500, color: denColor } }>/{ usage.limit }</span> }
104 </span>
105 { hasLimit && (
106 <span
107 style={ {
108 width: 22,
109 height: 4,
110 borderRadius: 20,
111 overflow: 'hidden',
112 display: 'inline-block',
113 background: amber ? 'rgba(122,75,0,.25)' : 'rgba(255,255,255,.28)',
114 } }
115 >
116 <span
117 style={ {
118 display: 'block',
119 height: '100%',
120 width: `${ Math.round( frac * 100 ) }%`,
121 borderRadius: 20,
122 background: amber ? '#7a4b00' : '#fff',
123 } }
124 />
125 </span>
126 ) }
127 </div>
128 );
129 };
130
131 /** Matches a prompt against known template names; longest match wins. */
132 function findTemplateMatch( prompt: string, templates: Template[] ): Template | null {
133 const normalize = ( s: string ) => s.toLowerCase().replace( /[^a-z0-9\s]/g, ' ' ).replace( /\s+/g, ' ' ).trim();
134 const p = normalize( prompt );
135 let best: Template | null = null;
136 let bestLen = 0;
137 templates.forEach( ( tpl ) => {
138 const name = normalize( tpl.name );
139 if ( name.length >= 4 && p.includes( name ) && name.length > bestLen ) {
140 best = tpl;
141 bestLen = name.length;
142 }
143 } );
144 return best;
145 }
146
147 const STYLE_SUGGESTIONS = [
148 __( 'Modern & minimal', 'everest-forms' ),
149 __( 'Bold & colorful', 'everest-forms' ),
150 __( 'Elegant dark theme', 'everest-forms' ),
151 __( 'Warm & friendly', 'everest-forms' ),
152 __( 'Corporate & professional', 'everest-forms' ),
153 __( 'Playful & rounded', 'everest-forms' ),
154 ];
155
156 const GREETING = __(
157 "Hi! Describe a look and I'll style your form — or pick a suggestion below.",
158 'everest-forms'
159 );
160
161 // Discovery hint for anyone who hasn't opened Style with AI before — shown once,
162 // dismissed forever (per-user, via EVF_AI_Ajax::dismiss_hint()) either by closing it
163 // or by actually opening the panel.
164 const AI_HINT_NAME = 'style';
165
166 interface AiStyleResult {
167 tokens: Record< string, any >;
168 palette: string;
169 summary: string;
170 /** True when the request was ENTIRELY Pro-blocked — nothing was applied this turn. */
171 notice: boolean;
172 noticeUrl: string;
173 }
174
175 interface Message {
176 role: 'user' | 'assistant';
177 text: string;
178 loading?: boolean;
179 notice?: boolean;
180 noticeUrl?: string;
181 canUndo?: boolean;
182 /** Store version this turn's undo is valid for. */
183 undoVersion?: number;
184 }
185
186 async function requestAiStyle(
187 restBase: string,
188 formId: number,
189 prompt: string,
190 refinePrompt: string,
191 currentRecord: { tokens: Record< string, unknown >; palette: string },
192 history: Array< { role: 'user' | 'assistant'; text: string } >,
193 lastChangedKeys: string[]
194 ): Promise<
195 | { ok: true; data: AiStyleResult; usage: UsageInfo | null }
196 | { ok: false; message: string; noticeUrl?: string; usage: UsageInfo | null }
197 > {
198 if ( ! apiFetch ) {
199 return { ok: false, message: __( 'AI styling is unavailable on this screen.', 'everest-forms' ), usage: null };
200 }
201 try {
202 const data = ( await apiFetch( {
203 path: `${ restBase }/${ formId }/ai`,
204 method: 'POST',
205 data: {
206 prompt,
207 refine_prompt: refinePrompt,
208 current_record: refinePrompt ? currentRecord : undefined,
209 // Conversation memory for a refine call — see class-evf-ai-api.php::style_form()
210 // for why a bare "current_record" token dump isn't enough context on its own.
211 history: refinePrompt ? history : undefined,
212 last_changed_keys: refinePrompt ? lastChangedKeys : undefined,
213 },
214 } ) ) as any;
215 return {
216 ok: true,
217 data: {
218 tokens: data.tokens || {},
219 palette: data.palette || '',
220 summary: data.summary || '',
221 notice: !! data.notice,
222 noticeUrl: data.notice_url || '',
223 },
224 usage: readUsage( data ),
225 };
226 } catch ( e: any ) {
227 const code = e && e.code;
228 const tier = e && e.data && e.data.tier;
229 const usage = readUsage( e && e.data );
230 // "daily_limit_reached" is today's hard cap (Free AND Pro both have one, Pro's is far
231 // higher) — worth its own message and, for Free only, an upgrade link. A plain
232 // "rate_limit" (the transient per-minute/per-hour throttle) still gets the gateway's
233 // own specific message ("Too many requests…" / "Request limit reached — wait a
234 // moment…") rather than a made-up generic string — same as every other error case.
235 if ( 'daily_limit_reached' === code ) {
236 return {
237 ok: false,
238 message: ( e && e.message ) || __( 'Request limit reached. Please try again later.', 'everest-forms' ),
239 noticeUrl: 'pro' === tier ? undefined : UPGRADE_URL,
240 usage,
241 };
242 }
243 const message =
244 ( e && e.message ) || __( 'Could not reach the AI service. Please try again.', 'everest-forms' );
245 return { ok: false, message, usage };
246 }
247 }
248
249 export function AiAssistant() {
250 const store = useStore();
251 // On local / staging sites the AI gateway is unreachable — the launcher is shown but
252 // disabled (greyed trigger, opens nothing, explains why on hover), mirroring the Fields-tab
253 // AI Form Assistant instead of vanishing.
254 const AI_DISABLED = !! store.settings.aiDisabled;
255 const [ open, setOpen ] = useState( false );
256 const [ messages, setMessages ] = useState< Message[] >( [ { role: 'assistant', text: GREETING } ] );
257 const [ input, setInput ] = useState( '' );
258 const [ loading, setLoading ] = useState( false );
259 // Daily-request usage snapshot — drives the header credits pill. Seeded on mount so it's
260 // visible the moment the panel opens, then refreshed from every response.
261 const [ usage, setUsage ] = useState< UsageInfo | null >( null );
262 const [ usageLoading, setUsageLoading ] = useState( true );
263 const [ originalPrompt, setOriginalPrompt ] = useState( '' );
264 // Schema key(s) the AI's own last turn changed — sent back on the next refine so a follow-up
265 // like "increase it to 200px" stays anchored to that same property (see class-evf-ai-api.php).
266 const [ lastChangedKeys, setLastChangedKeys ] = useState< string[] >( [] );
267 const [ onStyleTab, setOnStyleTab ] = useState( true );
268 const [ buttonHovered, setButtonHovered ] = useState( false );
269 const [ tooltipHovered, setTooltipHovered ] = useState( false );
270 const showTooltip = ! open && ( buttonHovered || tooltipHovered );
271 // The builder shell shows its own loading overlay (`.everest-forms-overlay`, faded out on
272 // window `load` — see form-builder.js) while fields/canvas are still booting. Stay hidden
273 // until then so this floating button doesn't sit on top of that loading screen.
274 const [ builderLoaded, setBuilderLoaded ] = useState( () => 'complete' === document.readyState );
275 const [ hintDismissed, setHintDismissed ] = useState( !! store.settings.aiHintDismissed );
276 const dismissHint = () => {
277 if ( hintDismissed ) return;
278 setHintDismissed( true );
279 if ( ! store.settings.ajaxUrl ) return;
280 const body = new URLSearchParams();
281 body.append( 'action', 'evf_ai_dismiss_hint' );
282 body.append( 'hint', AI_HINT_NAME );
283 body.append( 'nonce', store.settings.aiNonce );
284 fetch( store.settings.ajaxUrl, {
285 method: 'POST',
286 credentials: 'same-origin',
287 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
288 body: body.toString(),
289 } ).catch( () => {
290 // Best-effort only — worst case the hint reappears next visit.
291 } );
292 };
293 const showHint = ! open && ! hintDismissed && ! AI_DISABLED;
294 const messagesEndRef = useRef< HTMLDivElement >( null );
295 const inputRef = useRef< HTMLTextAreaElement >( null );
296
297 useEffect( () => {
298 const panel = document.getElementById( 'everest-forms-panel-style' );
299 const read = () => setOnStyleTab( panel ? panel.classList.contains( 'active' ) : true );
300 read();
301 if ( ! panel ) return;
302 const observer = new MutationObserver( read );
303 observer.observe( panel, { attributes: true, attributeFilter: [ 'class' ] } );
304 return () => observer.disconnect();
305 }, [] );
306
307 useEffect( () => {
308 if ( builderLoaded ) return;
309 const onLoad = () => setBuilderLoaded( true );
310 window.addEventListener( 'load', onLoad );
311 return () => window.removeEventListener( 'load', onLoad );
312 }, [ builderLoaded ] );
313
314 useEffect( () => {
315 if ( ! onStyleTab ) setOpen( false );
316 }, [ onStyleTab ] );
317
318 // A click inside the preview iframe may be about to open a native <select> there — those
319 // always paint above fixed page UI, so close the panel first rather than let it get covered.
320 useEffect( () => {
321 setOpen( false );
322 }, [ store.previewInteractionCount ] );
323
324 useEffect( () => {
325 if ( open ) messagesEndRef.current?.scrollIntoView( { behavior: 'smooth' } );
326 }, [ messages, open ] );
327
328 useEffect( () => {
329 if ( open ) setTimeout( () => inputRef.current?.focus(), 120 );
330 }, [ open ] );
331
332 // Seed the header credits pill on mount so the count is ready as soon as the panel opens.
333 // Best-effort — on a disabled (local) or unregistered site we skip the call and show no pill.
334 useEffect( () => {
335 if ( AI_DISABLED || ! store.settings.ajaxUrl ) {
336 setUsageLoading( false );
337 return;
338 }
339 let cancelled = false;
340 const body = new URLSearchParams();
341 body.append( 'action', 'evf_ai_get_usage' );
342 body.append( 'nonce', store.settings.aiNonce );
343 fetch( store.settings.ajaxUrl, {
344 method: 'POST',
345 credentials: 'same-origin',
346 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
347 body: body.toString(),
348 } )
349 .then( ( r ) => r.json() )
350 .then( ( j ) => {
351 if ( cancelled ) return;
352 const u = readUsage( j?.data );
353 if ( u ) setUsage( u );
354 } )
355 .catch( () => {} )
356 .finally( () => {
357 if ( ! cancelled ) setUsageLoading( false );
358 } );
359 return () => {
360 cancelled = true;
361 };
362 // eslint-disable-next-line react-hooks/exhaustive-deps
363 }, [] );
364
365 if ( ! store.settings.aiEnabled || ! onStyleTab || ! builderLoaded ) {
366 return null;
367 }
368
369 const send = async ( text: string ) => {
370 if ( ! text.trim() || loading ) return;
371 const userText = text.trim();
372 const isRefine = '' !== originalPrompt;
373 setInput( '' );
374
375 setMessages( ( prev ) => [ ...prev, { role: 'user', text: userText } ] );
376
377 const matchedTpl = findTemplateMatch( userText, store.allTemplates() );
378 if ( matchedTpl ) {
379 const locked = !! matchedTpl.is_pro && ! store.proActive;
380 if ( locked ) {
381 window.open( UPGRADE_URL, '_blank' );
382 setMessages( ( prev ) => [
383 ...prev,
384 {
385 role: 'assistant',
386 text: `"${ matchedTpl.name }" is a Pro template — upgrade to use it.`,
387 notice: true,
388 },
389 ] );
390 return;
391 }
392 store.applyTemplate( matchedTpl.id, matchedTpl.tokens, matchedTpl.palette );
393 setMessages( ( prev ) => {
394 const cleared = prev.map( ( m ) => ( m.canUndo ? { ...m, canUndo: false } : m ) );
395 return [
396 ...cleared,
397 {
398 role: 'assistant',
399 text: `Applied the "${ matchedTpl.name }" template.`,
400 canUndo: true,
401 undoVersion: store.getVersion(),
402 },
403 ];
404 } );
405 return;
406 }
407
408 setLoading( true );
409 setMessages( ( prev ) => [ ...prev, { role: 'assistant', text: '', loading: true } ] );
410
411 // Snapshot of the dialogue BEFORE this turn's user message — real conversation history,
412 // not just the very first prompt ever typed (see class-evf-ai-api.php::style_form()).
413 const history = messages
414 .filter( ( m ) => ! m.loading )
415 .map( ( m ) => ( { role: m.role, text: m.text } ) );
416
417 const result = await requestAiStyle(
418 store.settings.restBase,
419 store.settings.formId,
420 isRefine ? originalPrompt : userText,
421 isRefine ? userText : '',
422 { tokens: store.tokens, palette: store.palette },
423 history,
424 lastChangedKeys
425 );
426
427 if ( ! isRefine ) setOriginalPrompt( userText );
428
429 if ( result.usage ) setUsage( result.usage );
430
431 if ( result.ok ) {
432 store.applyAiRecord( result.data.tokens, result.data.palette || undefined );
433 setLastChangedKeys( Object.keys( result.data.tokens || {} ) );
434 }
435
436 setMessages( ( prev ) => {
437 const cleared = prev.map( ( m ) => ( m.canUndo ? { ...m, canUndo: false } : m ) );
438 const next = [ ...cleared ];
439 next[ next.length - 1 ] = result.ok
440 ? result.data.notice
441 ? // Entirely Pro-blocked — nothing applied, so no Undo affordance to offer.
442 {
443 role: 'assistant',
444 text: result.data.summary || __( "That's a Pro feature.", 'everest-forms' ),
445 notice: true,
446 noticeUrl: result.data.noticeUrl,
447 }
448 : {
449 role: 'assistant',
450 text: result.data.summary || __( 'Applied your style.', 'everest-forms' ),
451 canUndo: true,
452 undoVersion: store.getVersion(),
453 // Set whenever ANY requested token/palette was Pro-blocked this turn, even
454 // though the rest of the turn applied fine (see RestController::ai_style()) —
455 // the styling below (non-"notice") stays the normal bubble either way.
456 noticeUrl: result.data.noticeUrl || undefined,
457 }
458 : { role: 'assistant', text: result.message, notice: true, noticeUrl: result.noticeUrl };
459 return next;
460 } );
461 setLoading( false );
462
463 // Re-open the panel if the user collapsed it while the request was processing, so the
464 // applied-style summary (and any Undo / Upgrade action) is visible now that it's done.
465 if ( result.ok ) setOpen( true );
466 };
467
468 const handleUndo = () => {
469 store.undo();
470 setMessages( ( prev ) => {
471 const idx = prev.length - 1;
472 if ( ! prev[ idx ]?.canUndo ) return prev;
473 const next = [ ...prev ];
474 next[ idx ] = { ...next[ idx ], canUndo: false };
475 return next;
476 } );
477 };
478
479 const handleKeyDown = ( e: React.KeyboardEvent< HTMLTextAreaElement > ) => {
480 if ( 'Enter' === e.key && ! e.shiftKey ) {
481 e.preventDefault();
482 send( input );
483 }
484 };
485
486 const hasStarted = messages.length > 1;
487
488 const BTN_SIZE = 55;
489 const BTN_RIGHT = 22;
490 const BTN_BOTTOM = 22;
491 const MODAL_BOTTOM = BTN_BOTTOM + BTN_SIZE + 8;
492
493 return (
494 <>
495 <button
496 type="button"
497 aria-label={ __( 'Style with AI', 'everest-forms' ) }
498 aria-expanded={ open }
499 onClick={ () => {
500 if ( AI_DISABLED ) return;
501 setOpen( ( o ) => ! o );
502 dismissHint();
503 } }
504 onMouseEnter={ ( e ) => {
505 setButtonHovered( true );
506 if ( AI_DISABLED ) return;
507 ( e.currentTarget as HTMLButtonElement ).style.transform = 'scale(1.08)';
508 ( e.currentTarget as HTMLButtonElement ).style.boxShadow = '0 6px 22px rgba(117,69,187,.55)';
509 } }
510 onMouseLeave={ ( e ) => {
511 setButtonHovered( false );
512 ( e.currentTarget as HTMLButtonElement ).style.transform = 'scale(1)';
513 ( e.currentTarget as HTMLButtonElement ).style.boxShadow = '0 4px 16px rgba(117,69,187,.45)';
514 } }
515 style={ {
516 position: 'fixed',
517 bottom: BTN_BOTTOM,
518 right: BTN_RIGHT,
519 width: BTN_SIZE,
520 height: BTN_SIZE,
521 borderRadius: '50%',
522 background: AI_DISABLED
523 ? 'linear-gradient(135deg,#b4a8cc 0%,#c8bce0 100%)'
524 : open
525 ? 'linear-gradient(135deg,#5c329c 0%,#7545BB 100%)'
526 : 'linear-gradient(135deg,#7545BB 0%,#9660db 100%)',
527 border: 'none',
528 cursor: AI_DISABLED ? 'not-allowed' : 'pointer',
529 display: 'flex',
530 alignItems: 'center',
531 justifyContent: 'center',
532 boxShadow: '0 4px 16px rgba(117,69,187,.45)',
533 // Above .select2-dropdown (999999) — see .evf-subscription-expiry-calendar
534 // for the same precedent — so field-setting dropdowns never cover the button.
535 zIndex: 1000001,
536 transition: 'transform .2s,box-shadow .2s,background .2s',
537 } }
538 >
539 { open ? <LuX size={ 22 } color="white" /> : <LuSparkles size={ 24 } color="white" /> }
540 </button>
541
542 { /* Processing ring — spins around the closed trigger while a request is in flight, so
543 the user knows the AI is still working even with the panel collapsed. */ }
544 { loading && ! open && ! AI_DISABLED && (
545 <div
546 aria-hidden="true"
547 style={ {
548 position: 'fixed',
549 bottom: BTN_BOTTOM - 4,
550 right: BTN_RIGHT - 4,
551 width: BTN_SIZE + 8,
552 height: BTN_SIZE + 8,
553 borderRadius: '50%',
554 border: '2.5px solid rgba(117,69,187,.25)',
555 borderTopColor: '#7545BB',
556 animation: 'scv2-ai-spin .8s linear infinite',
557 // Below the button (1000001) so the button sits on top and the ring reads as an arc around it.
558 zIndex: 1000000,
559 pointerEvents: 'none',
560 } }
561 />
562 ) }
563
564 { showTooltip && ! showHint && (
565 <div
566 onMouseEnter={ () => setTooltipHovered( true ) }
567 onMouseLeave={ () => setTooltipHovered( false ) }
568 style={ {
569 position: 'fixed',
570 bottom: BTN_BOTTOM + BTN_SIZE + 8,
571 right: BTN_RIGHT + Math.round( BTN_SIZE / 2 ),
572 transform: 'translateX(50%)',
573 pointerEvents: 'none',
574 zIndex: 1000002,
575 } }
576 >
577 <div
578 style={ {
579 background: '#fff',
580 border: '0.8px solid #e1e1e1',
581 borderRadius: 3,
582 padding: '16px 20px',
583 fontSize: 13,
584 color: '#222',
585 whiteSpace: 'nowrap',
586 position: 'relative',
587 } }
588 >
589 { AI_DISABLED
590 ? __( 'Not available on local sites', 'everest-forms' )
591 : __( 'Style with AI', 'everest-forms' ) }
592 </div>
593 <div
594 style={ {
595 position: 'absolute',
596 bottom: -8,
597 left: '50%',
598 marginLeft: -7,
599 width: 0,
600 height: 0,
601 borderLeft: '7px solid transparent',
602 borderRight: '7px solid transparent',
603 borderTop: '8px solid #e1e1e1',
604 } }
605 />
606 <div
607 style={ {
608 position: 'absolute',
609 bottom: -6,
610 left: '50%',
611 marginLeft: -6,
612 width: 0,
613 height: 0,
614 borderLeft: '6px solid transparent',
615 borderRight: '6px solid transparent',
616 borderTop: '7px solid #fff',
617 } }
618 />
619 </div>
620 ) }
621
622 { showHint && (
623 <div
624 style={ {
625 position: 'fixed',
626 bottom: BTN_BOTTOM + BTN_SIZE + 10,
627 right: BTN_RIGHT,
628 width: 272,
629 zIndex: 1000002,
630 } }
631 >
632 <div
633 style={ {
634 position: 'relative',
635 background: '#fff',
636 border: '1px solid #e9e2f3',
637 borderRadius: 14,
638 padding: '14px 16px',
639 boxShadow: '0 10px 30px rgba(88,45,163,.22), 0 2px 8px rgba(20,23,40,.08)',
640 } }
641 >
642 <button
643 type="button"
644 aria-label={ __( 'Dismiss', 'everest-forms' ) }
645 onClick={ dismissHint }
646 style={ {
647 position: 'absolute',
648 top: 8,
649 right: 8,
650 border: 0,
651 background: 'none',
652 color: '#9a95a8',
653 cursor: 'pointer',
654 width: 22,
655 height: 22,
656 borderRadius: '50%',
657 display: 'grid',
658 placeItems: 'center',
659 } }
660 onMouseEnter={ ( e ) => {
661 e.currentTarget.style.background = 'rgba(117,69,187,.12)';
662 e.currentTarget.style.color = '#7545BB';
663 } }
664 onMouseLeave={ ( e ) => {
665 e.currentTarget.style.background = 'none';
666 e.currentTarget.style.color = '#9a95a8';
667 } }
668 >
669 <LuX size={ 13 } />
670 </button>
671 <div
672 style={ {
673 display: 'flex',
674 alignItems: 'center',
675 gap: 5,
676 fontSize: 11,
677 fontWeight: 700,
678 letterSpacing: '.03em',
679 textTransform: 'uppercase',
680 color: '#7545BB',
681 marginBottom: 6,
682 } }
683 >
684 <LuSparkles size={ 12 } />
685 { __( 'New', 'everest-forms' ) }
686 </div>
687 <div style={ { fontSize: 13, lineHeight: 1.5, color: '#383838', paddingRight: 14 } }>
688 { __(
689 'Describe the look you want, and I’ll style your form for you.',
690 'everest-forms'
691 ) }
692 </div>
693 <div
694 style={ {
695 position: 'absolute',
696 bottom: -8,
697 right: 24,
698 width: 0,
699 height: 0,
700 borderLeft: '8px solid transparent',
701 borderRight: '8px solid transparent',
702 borderTop: '8px solid #e9e2f3',
703 } }
704 />
705 <div
706 style={ {
707 position: 'absolute',
708 bottom: -6,
709 right: 25,
710 width: 0,
711 height: 0,
712 borderLeft: '7px solid transparent',
713 borderRight: '7px solid transparent',
714 borderTop: '7px solid #fff',
715 } }
716 />
717 </div>
718 </div>
719 ) }
720
721 { open && (
722 <div
723 role="dialog"
724 aria-label={ __( 'Style with AI', 'everest-forms' ) }
725 style={ {
726 position: 'fixed',
727 bottom: MODAL_BOTTOM,
728 right: BTN_RIGHT,
729 width: 440,
730 height: 520,
731 maxHeight: `calc(100vh - ${ MODAL_BOTTOM + 40 }px)`,
732 borderRadius: 16,
733 background: '#fff',
734 boxShadow: '0 8px 40px rgba(0,0,0,.18)',
735 border: '1px solid #e2e8f0',
736 display: 'flex',
737 flexDirection: 'column',
738 overflow: 'hidden',
739 zIndex: 1000000,
740 } }
741 >
742 <div
743 style={ {
744 display: 'flex',
745 alignItems: 'center',
746 gap: 10,
747 padding: '0 16px',
748 height: 52,
749 background: 'linear-gradient(135deg,#7545BB 0%,#9660db 100%)',
750 flexShrink: 0,
751 } }
752 >
753 <div
754 style={ {
755 width: 28,
756 height: 28,
757 borderRadius: '50%',
758 background: 'rgba(255,255,255,.15)',
759 display: 'flex',
760 alignItems: 'center',
761 justifyContent: 'center',
762 flexShrink: 0,
763 } }
764 >
765 <LuSparkles size={ 14 } color="white" />
766 </div>
767 <div style={ { flex: 1, minWidth: 0 } }>
768 <div style={ { fontSize: 14, fontWeight: 600, color: '#fff', lineHeight: 1.2 } }>
769 { __( 'Style with AI', 'everest-forms' ) }
770 </div>
771 <div style={ { fontSize: 11, color: 'rgba(255,255,255,.7)', lineHeight: 1.2 } }>
772 { __( 'Powered by AI', 'everest-forms' ) }
773 </div>
774 </div>
775 { ! AI_DISABLED && ( usage || usageLoading ) && (
776 <UsagePill usage={ usage } loading={ usageLoading } />
777 ) }
778 </div>
779
780 <div
781 className="scv2-ai-messages"
782 style={ {
783 flex: 1,
784 overflowY: 'auto',
785 padding: '16px 14px',
786 display: 'flex',
787 flexDirection: 'column',
788 gap: 10,
789 } }
790 >
791 { messages.map( ( msg, i ) => (
792 <div
793 key={ i }
794 style={ {
795 display: 'flex',
796 flexDirection: 'user' === msg.role ? 'row-reverse' : 'row',
797 alignItems: 'flex-end',
798 gap: 8,
799 } }
800 >
801 { 'assistant' === msg.role && (
802 <div
803 style={ {
804 width: 26,
805 height: 26,
806 borderRadius: '50%',
807 background: 'rgba(117,69,187,.1)',
808 display: 'flex',
809 alignItems: 'center',
810 justifyContent: 'center',
811 flexShrink: 0,
812 } }
813 >
814 <LuSparkles size={ 13 } color="#7545BB" />
815 </div>
816 ) }
817
818 <div
819 style={ {
820 maxWidth: '82%',
821 padding: '9px 12px',
822 borderRadius: 'user' === msg.role ? '14px 14px 4px 14px' : '4px 14px 14px 14px',
823 background: 'user' === msg.role ? '#7545BB' : msg.notice ? '#fff8f8' : '#f4f0fb',
824 color: 'user' === msg.role ? '#fff' : msg.notice ? '#c0392b' : '#1a1a2e',
825 border: msg.notice ? '1px solid #fca5a5' : 'none',
826 fontSize: 13,
827 lineHeight: 1.55,
828 boxShadow: 'user' === msg.role ? '0 2px 8px rgba(117,69,187,.2)' : 'none',
829 } }
830 >
831 { msg.loading ? (
832 <div style={ { display: 'flex', gap: 4, padding: '2px 0' } }>
833 { [ 0, 1, 2 ].map( ( d ) => (
834 <span
835 key={ d }
836 style={ {
837 width: 6,
838 height: 6,
839 borderRadius: '50%',
840 background: '#9660db',
841 display: 'inline-block',
842 animation: `scv2-ai-dot 1.1s ease-in-out ${ d * 0.18 }s infinite`,
843 } }
844 />
845 ) ) }
846 </div>
847 ) : (
848 <>
849 { msg.text }
850 { ( ( msg.canUndo && msg.undoVersion === store.getVersion() ) || msg.noticeUrl ) && (
851 <div style={ { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 14, marginTop: 6 } }>
852 { msg.canUndo && msg.undoVersion === store.getVersion() && (
853 <a
854 href="#"
855 onClick={ ( e ) => {
856 e.preventDefault();
857 handleUndo();
858 } }
859 style={ {
860 color: '#7545BB',
861 fontWeight: 600,
862 fontSize: 12,
863 textDecoration: 'underline',
864 cursor: 'pointer',
865 } }
866 >
867 { __( 'Undo ↺', 'everest-forms' ) }
868 </a>
869 ) }
870 { msg.noticeUrl && (
871 <a
872 href={ msg.noticeUrl }
873 target="_blank"
874 rel="noopener noreferrer"
875 style={ {
876 color: '#7545BB',
877 fontWeight: 600,
878 fontSize: 12,
879 textDecoration: 'underline',
880 } }
881 >
882 { __( 'Upgrade to Pro ↗', 'everest-forms' ) }
883 </a>
884 ) }
885 </div>
886 ) }
887 </>
888 ) }
889 </div>
890 </div>
891 ) ) }
892 <div ref={ messagesEndRef } />
893 </div>
894
895 <div
896 className="scv2-ai-suggestions"
897 style={ {
898 padding: '0 14px 10px',
899 display: 'flex',
900 gap: 6,
901 overflowX: 'auto',
902 flexShrink: 0,
903 scrollbarWidth: 'thin',
904 scrollbarColor: '#d4c5f0 transparent',
905 } }
906 >
907 { STYLE_SUGGESTIONS.map( ( s ) => (
908 <button
909 type="button"
910 key={ s }
911 onClick={ () => send( s ) }
912 style={ {
913 flexShrink: 0,
914 padding: '5px 10px',
915 borderRadius: 20,
916 border: '1px solid #e2e8f0',
917 background: '#faf9ff',
918 color: '#7545BB',
919 fontSize: 11.5,
920 fontWeight: 500,
921 cursor: 'pointer',
922 whiteSpace: 'nowrap',
923 transition: 'background .15s,border-color .15s',
924 } }
925 onMouseEnter={ ( e ) => {
926 ( e.currentTarget as HTMLButtonElement ).style.background = '#f0ebfa';
927 ( e.currentTarget as HTMLButtonElement ).style.borderColor = '#b89ee0';
928 } }
929 onMouseLeave={ ( e ) => {
930 ( e.currentTarget as HTMLButtonElement ).style.background = '#faf9ff';
931 ( e.currentTarget as HTMLButtonElement ).style.borderColor = '#e2e8f0';
932 } }
933 >
934 { s }
935 </button>
936 ) ) }
937 </div>
938
939 <div
940 style={ {
941 padding: '10px 14px 14px',
942 borderTop: '1px solid #f1f5f9',
943 flexShrink: 0,
944 } }
945 >
946 <div
947 style={ {
948 display: 'flex',
949 alignItems: 'flex-end',
950 gap: 8,
951 border: '1.5px solid #e2e8f0',
952 borderRadius: 12,
953 padding: '8px 10px 8px 14px',
954 background: '#fff',
955 transition: 'border-color .2s',
956 } }
957 >
958 <textarea
959 ref={ inputRef }
960 value={ input }
961 maxLength={ 500 }
962 onChange={ ( e ) => setInput( e.target.value ) }
963 onKeyDown={ handleKeyDown }
964 placeholder={
965 hasStarted
966 ? __( 'Refine it…', 'everest-forms' )
967 : __( 'Describe a look…', 'everest-forms' )
968 }
969 rows={ 1 }
970 style={ {
971 flex: 1,
972 border: 'none',
973 outline: 'none',
974 resize: 'none',
975 fontSize: 13,
976 color: '#1a1a2e',
977 background: 'transparent',
978 lineHeight: 1.5,
979 maxHeight: 80,
980 overflowY: 'auto',
981 fontFamily: 'inherit',
982 } }
983 />
984 <button
985 type="button"
986 onClick={ () => send( input ) }
987 disabled={ ! input.trim() || loading }
988 style={ {
989 width: 32,
990 height: 32,
991 borderRadius: 8,
992 border: 'none',
993 background: input.trim() && ! loading ? '#7545BB' : '#e6e3ee',
994 cursor: input.trim() && ! loading ? 'pointer' : 'not-allowed',
995 display: 'flex',
996 alignItems: 'center',
997 justifyContent: 'center',
998 flexShrink: 0,
999 transition: 'background .2s',
1000 } }
1001 >
1002 <LuSend size={ 15 } color={ input.trim() && ! loading ? '#fff' : '#9a9a9a' } />
1003 </button>
1004 </div>
1005 <p style={ { fontSize: 11, color: '#9ca3af', margin: '6px 0 0', textAlign: 'center' } }>
1006 { __( 'Your style updates live — Save when you’re happy.', 'everest-forms' ) }
1007 </p>
1008 </div>
1009 </div>
1010 ) }
1011
1012 <style>{ `
1013 @keyframes scv2-ai-dot {
1014 0%,80%,100%{transform:scale(.4);opacity:.4}
1015 40%{transform:scale(1);opacity:1}
1016 }
1017 @keyframes scv2-ai-spin {
1018 to { transform: rotate(360deg); }
1019 }
1020 @keyframes scv2-ai-pulse {
1021 0%,100% { opacity: .5; }
1022 50% { opacity: 1; }
1023 }
1024 .scv2-ai-messages::-webkit-scrollbar,
1025 .scv2-ai-suggestions::-webkit-scrollbar {
1026 width: 4px;
1027 height: 4px;
1028 }
1029 .scv2-ai-messages::-webkit-scrollbar-track,
1030 .scv2-ai-suggestions::-webkit-scrollbar-track {
1031 background: transparent;
1032 }
1033 .scv2-ai-messages::-webkit-scrollbar-thumb,
1034 .scv2-ai-suggestions::-webkit-scrollbar-thumb {
1035 background: #d4c5f0;
1036 border-radius: 4px;
1037 }
1038 .scv2-ai-messages::-webkit-scrollbar-thumb:hover,
1039 .scv2-ai-suggestions::-webkit-scrollbar-thumb:hover {
1040 background: #b89ee0;
1041 }
1042 ` }</style>
1043 </>
1044 );
1045 }
1046