/** * Style Customizer v2 — floating "Style with AI" launcher that turns a prompt into style * tokens via `POST {restBase}/{formId}/ai`. */ import React, { useEffect, useRef, useState } from 'react'; import { LuSend, LuSparkles, LuX } from 'react-icons/lu'; import { UPGRADE_URL } from './panes'; import { useStore } from './store'; import { Template } from './types'; const __ = ( window as any ).wp?.i18n?.__ || ( ( s: string ) => s ); const _n = ( window as any ).wp?.i18n?._n || ( ( single: string, plural: string, n: number ) => ( 1 === n ? single : plural ) ); const sprintf = ( window as any ).wp?.i18n?.sprintf || ( ( fmt: string, n: number ) => fmt.replace( '%d', String( n ) ) ); const apiFetch = ( window as any ).wp?.apiFetch; /** 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" — properly pluralized. */ const usageLabel = ( remaining: number ): string => sprintf( _n( '%d request left today', '%d requests left today', remaining, 'everest-forms' ), remaining ); /** Read the { remaining, limit, used } usage object off a raw AI response/error, 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 ? sprintf( /* translators: 1: remaining requests, 2: daily limit. */ __( '%1$d of %2$d AI requests left today · resets daily', 'everest-forms' ), usage.remaining, usage.limit ) : 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 (
{ remaining } { hasLimit && /{ usage.limit } } { hasLimit && ( ) }
); }; /** Matches a prompt against known template names; longest match wins. */ function findTemplateMatch( prompt: string, templates: Template[] ): Template | null { const normalize = ( s: string ) => s.toLowerCase().replace( /[^a-z0-9\s]/g, ' ' ).replace( /\s+/g, ' ' ).trim(); const p = normalize( prompt ); let best: Template | null = null; let bestLen = 0; templates.forEach( ( tpl ) => { const name = normalize( tpl.name ); if ( name.length >= 4 && p.includes( name ) && name.length > bestLen ) { best = tpl; bestLen = name.length; } } ); return best; } const STYLE_SUGGESTIONS = [ __( 'Modern & minimal', 'everest-forms' ), __( 'Bold & colorful', 'everest-forms' ), __( 'Elegant dark theme', 'everest-forms' ), __( 'Warm & friendly', 'everest-forms' ), __( 'Corporate & professional', 'everest-forms' ), __( 'Playful & rounded', 'everest-forms' ), ]; const GREETING = __( "Hi! Describe a look and I'll style your form — or pick a suggestion below.", 'everest-forms' ); // Discovery hint for anyone who hasn't opened Style with AI 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 = 'style'; interface AiStyleResult { tokens: Record< string, any >; palette: string; summary: string; /** True when the request was ENTIRELY Pro-blocked — nothing was applied this turn. */ notice: boolean; noticeUrl: string; } interface Message { role: 'user' | 'assistant'; text: string; loading?: boolean; notice?: boolean; noticeUrl?: string; canUndo?: boolean; /** Store version this turn's undo is valid for. */ undoVersion?: number; } async function requestAiStyle( restBase: string, formId: number, prompt: string, refinePrompt: string, currentRecord: { tokens: Record< string, unknown >; palette: string }, history: Array< { role: 'user' | 'assistant'; text: string } >, lastChangedKeys: string[] ): Promise< | { ok: true; data: AiStyleResult; usage: UsageInfo | null } | { ok: false; message: string; noticeUrl?: string; usage: UsageInfo | null } > { if ( ! apiFetch ) { return { ok: false, message: __( 'AI styling is unavailable on this screen.', 'everest-forms' ), usage: null }; } try { const data = ( await apiFetch( { path: `${ restBase }/${ formId }/ai`, method: 'POST', data: { prompt, refine_prompt: refinePrompt, current_record: refinePrompt ? currentRecord : undefined, // Conversation memory for a refine call — see class-evf-ai-api.php::style_form() // for why a bare "current_record" token dump isn't enough context on its own. history: refinePrompt ? history : undefined, last_changed_keys: refinePrompt ? lastChangedKeys : undefined, }, } ) ) as any; return { ok: true, data: { tokens: data.tokens || {}, palette: data.palette || '', summary: data.summary || '', notice: !! data.notice, noticeUrl: data.notice_url || '', }, usage: readUsage( data ), }; } catch ( e: any ) { const code = e && e.code; const tier = e && e.data && e.data.tier; const usage = readUsage( e && e.data ); // "daily_limit_reached" is today's hard cap (Free AND Pro both have one, Pro's is far // higher) — worth its own message and, for Free only, an upgrade link. A plain // "rate_limit" (the transient per-minute/per-hour throttle) still gets the gateway's // own specific message ("Too many requests…" / "Request limit reached — wait a // moment…") rather than a made-up generic string — same as every other error case. if ( 'daily_limit_reached' === code ) { return { ok: false, message: ( e && e.message ) || __( 'Request limit reached. Please try again later.', 'everest-forms' ), noticeUrl: 'pro' === tier ? undefined : UPGRADE_URL, usage, }; } const message = ( e && e.message ) || __( 'Could not reach the AI service. Please try again.', 'everest-forms' ); return { ok: false, message, usage }; } } export function AiAssistant() { const store = useStore(); // On local / staging sites the AI gateway is unreachable — the launcher is shown but // disabled (greyed trigger, opens nothing, explains why on hover), mirroring the Fields-tab // AI Form Assistant instead of vanishing. const AI_DISABLED = !! store.settings.aiDisabled; const [ open, setOpen ] = useState( false ); const [ messages, setMessages ] = useState< Message[] >( [ { role: 'assistant', text: GREETING } ] ); const [ input, setInput ] = useState( '' ); 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< UsageInfo | null >( null ); const [ usageLoading, setUsageLoading ] = useState( true ); const [ originalPrompt, setOriginalPrompt ] = useState( '' ); // Schema key(s) the AI's own last turn changed — sent back on the next refine so a follow-up // like "increase it to 200px" stays anchored to that same property (see class-evf-ai-api.php). const [ lastChangedKeys, setLastChangedKeys ] = useState< string[] >( [] ); const [ onStyleTab, setOnStyleTab ] = useState( true ); const [ buttonHovered, setButtonHovered ] = useState( false ); const [ tooltipHovered, setTooltipHovered ] = useState( false ); const showTooltip = ! open && ( buttonHovered || tooltipHovered ); // 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( () => 'complete' === document.readyState ); const [ hintDismissed, setHintDismissed ] = useState( !! store.settings.aiHintDismissed ); const dismissHint = () => { if ( hintDismissed ) return; setHintDismissed( true ); if ( ! store.settings.ajaxUrl ) return; const body = new URLSearchParams(); body.append( 'action', 'evf_ai_dismiss_hint' ); body.append( 'hint', AI_HINT_NAME ); body.append( 'nonce', store.settings.aiNonce ); fetch( store.settings.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; const messagesEndRef = useRef< HTMLDivElement >( null ); const inputRef = useRef< HTMLTextAreaElement >( null ); useEffect( () => { const panel = document.getElementById( 'everest-forms-panel-style' ); const read = () => setOnStyleTab( panel ? panel.classList.contains( 'active' ) : true ); read(); if ( ! panel ) return; const observer = new MutationObserver( read ); observer.observe( panel, { attributes: true, attributeFilter: [ 'class' ] } ); return () => observer.disconnect(); }, [] ); useEffect( () => { if ( builderLoaded ) return; const onLoad = () => setBuilderLoaded( true ); window.addEventListener( 'load', onLoad ); return () => window.removeEventListener( 'load', onLoad ); }, [ builderLoaded ] ); useEffect( () => { if ( ! onStyleTab ) setOpen( false ); }, [ onStyleTab ] ); // A click inside the preview iframe may be about to open a native