/** * Style Customizer v2 — panel panes: DesignList, ElementSlate (schema-driven drill-down), * TemplatesPane and CustomCssPane. All read/write the store. */ import React from 'react'; import { ColorPickerField, ControlRenderer } from './ControlRenderer'; import { SECTION_ICONS, SECTION_SUBTITLES, STATE_LABELS } from './constants'; import { HoverTip } from './HoverTip'; import { useStore } from './store'; import { BoxValue, DeviceBag, Section, Template, Token } from './types'; // The same "PRO" crown badge the builder's Fields sidebar uses. import proIconUrl from '../../assets/images/icons/everest-form-pro-icon.png'; const __ = ( window as any ).wp?.i18n?.__ || ( ( s: string ) => s ); const apiFetch = ( window as any ).wp?.apiFetch; export const UPGRADE_URL = 'https://everestforms.net/pricing/?utm_source=style-customizer&utm_medium=panel'; function Icon( { inner }: { inner: string } ) { return ( ); } /** The small "(i)" glyph the design's advisory banners lead with. */ function InfoNoteIcon() { return ( ); } /** "PRO" marker — the builder's own locked-field crown badge. */ export function ProCrown() { return ; } /** Human label for a paletteMap slot key — shared by "Your Palette"'s edit rows. */ export function paletteSlotLabel( slot: string ): string { switch ( slot ) { case 'form_background': return __( 'Form background', 'everest-forms' ); case 'field_background': return __( 'Field background', 'everest-forms' ); case 'field_label': return __( 'Label', 'everest-forms' ); case 'field_sublabel': return __( 'Sublabel', 'everest-forms' ); case 'button_text': return __( 'Button text', 'everest-forms' ); case 'button_background': return __( 'Button background', 'everest-forms' ); default: return slot; } } /** One "Your Palette" edit row — the same swatch + hex + alpha-picker popover every element * color control uses, so editing a slot feels identical to editing any other color. */ export function PaletteColorRow( { label, value, onChange, gradientable, }: { label: string; value: string; onChange: ( color: string ) => void; gradientable?: boolean; } ) { return (
{ label }
); } /* --------------------------------------------------------------------- * * Colors (browse) — "Your Palette" (live, editable, Pro-tier) + presets + * conditional "Your palettes" custom list (view/apply/delete only). * --------------------------------------------------------------------- */ interface ColorsToast { msg: string; kind?: 'success' | 'info'; actLabel?: string; onAct?: () => void; } export function ColorsPane( { onToast, onPreviewPalette, onClearPreview, }: { onToast: ( t: ColorsToast ) => void; onPreviewPalette: ( colors: Record< string, string > ) => void; onClearPreview: () => void; } ) { const store = useStore(); const [ editing, setEditing ] = React.useState( false ); const [ editSnapshot, setEditSnapshot ] = React.useState< { palette: string; colors: Record< string, string > } | null >( null ); const [ confirmId, setConfirmId ] = React.useState< string | null >( null ); const [ busy, setBusy ] = React.useState( false ); const pro = store.proActive; const slots = Object.keys( store.paletteMap ); const custom = store.customPalettes(); const builtin = store.builtinPalettes(); const palettesBase = store.settings.restBase.replace( /\/styles$/, '/style-palettes' ); // Re-reads on every store version bump (useStore()), so this always reflects live edits — // the same mechanism that already keeps every other control in sync. const currentColors = store.currentPaletteColors(); const appliedPaletteId = store.appliedPaletteId(); const originPaletteId = store.originPaletteId(); const matchedPaletteId = appliedPaletteId || originPaletteId; const matchedPalette = matchedPaletteId ? store.palettes.find( ( p ) => p.id === matchedPaletteId ) : null; // Also flag "Modified" when the colours never matched a named palette AT ALL but have drifted // from the raw schema defaults (e.g. hand-picked colours, or a Template's own colour set). const paletteModified = ! appliedPaletteId && ( !! originPaletteId || ! store.paletteAtDefault() ); // Opening the editor snapshots the current state so "Cancel" can restore it exactly — every // other control in the panel is "live, undo to fix", but a dedicated Cancel button here (per // the design) needs a real, precise revert rather than N separate undo steps. const openEditor = () => { setEditSnapshot( { palette: store.palette, colors: { ...currentColors } } ); setEditing( true ); }; const closeEditorKeep = () => { setEditing( false ); setEditSnapshot( null ); }; const cancelEditor = () => { if ( editSnapshot ) { if ( editSnapshot.palette ) { store.applyPalette( editSnapshot.palette ); } else { slots.forEach( ( slot ) => { store.setPaletteSlotColor( slot, editSnapshot.colors[ slot ] || '#ffffff', paletteSlotLabel( slot ), false ); } ); } } setEditing( false ); setEditSnapshot( null ); }; const swatch = ( colors: Record< string, string > ) => ( ); const applyPreset = ( p: ReturnType< typeof store.customPalettes >[ number ] ) => { if ( p.is_pro && ! pro ) { window.open( UPGRADE_URL, '_blank' ); return; } store.applyPalette( p.id ); onToast( { kind: 'success', msg: `${ __( 'Applied palette', 'everest-forms' ) } “${ p.name }”`, actLabel: __( 'Undo', 'everest-forms' ), onAct: () => store.undo(), } ); }; const deletePalette = async ( id: string ) => { setConfirmId( null ); if ( ! apiFetch ) { return; } setBusy( true ); try { const res = await apiFetch( { path: `${ palettesBase }/${ id }`, method: 'DELETE' } ); store.setCustomPalettes( ( res && res.palettes ) || custom.filter( ( p ) => p.id !== id ) ); onToast( { msg: __( 'Custom palette deleted.', 'everest-forms' ) } ); } catch ( e ) { onToast( { msg: __( 'Could not delete the palette.', 'everest-forms' ) } ); } finally { setBusy( false ); } }; const renderCard = ( p: ReturnType< typeof store.customPalettes >[ number ] ) => { const applied = p.id === appliedPaletteId; const modified = p.id === originPaletteId; const applyLocked = p.is_pro && ! pro; const canDelete = p.is_custom && pro; return (
{ canDelete && ( ) } { confirmId === p.id && (
{ __( 'Delete?', 'everest-forms' ) }
) }
); }; return (

{ __( 'For advanced customization, go to Elements or click the live preview.', 'everest-forms' ) }

{ __( 'Your Palette', 'everest-forms' ) }
{ swatch( currentColors ) } { pro ? ( ) : ( ) }
{ pro && editing && ( <>
{ slots.map( ( slot ) => ( store.setPaletteSlotColor( slot, color, paletteSlotLabel( slot ) ) } gradientable={ store.slotGradientable( slot ) } /> ) ) }
) }
{ matchedPalette ? matchedPalette.name : __( 'Default', 'everest-forms' ) } { paletteModified && ( { __( 'Modified', 'everest-forms' ) } ) } { !! appliedPaletteId && ( { __( 'Base', 'everest-forms' ) } ) }
{ __( 'Presets', 'everest-forms' ) }
{ [ ...custom, ...builtin ].map( renderCard ) }
); } /* --------------------------------------------------------------------- * * Design list (home) * --------------------------------------------------------------------- */ export function DesignList( { sections, onOpen, onNavigateTemplates, onNavigateColors, onNavigateCss, onResetAll, onUndo, onRedo, canUndo, canRedo, undoLabel, redoLabel, }: { sections: Section[]; onOpen: ( key: string ) => void; onNavigateTemplates: () => void; onNavigateColors: () => void; onNavigateCss: () => void; onResetAll: () => void; onUndo: () => void; onRedo: () => void; canUndo: boolean; canRedo: boolean; undoLabel: string; redoLabel: string; } ) { const store = useStore(); // "Your Template"/"Your Palette" summary — value-driven off live store state (same pattern // TemplatesPane already uses for its ✓/"Modified" badges), so these never go stale. const appliedId = store.appliedTemplateId(); const originId = store.originTemplateId(); const matchedTplId = appliedId || originId; const matchedTpl = matchedTplId ? store.allTemplates().find( ( t ) => t.id === matchedTplId ) : null; const templateLabel = matchedTpl ? matchedTpl.name : __( 'Default', 'everest-forms' ); // Also flag "Modified" when nothing ever matched a named template but styles have drifted // from the raw schema defaults. const templateModified = ! appliedId && ( !! originId || ! store.isAtSchemaDefault() ); const templateIsBase = !! appliedId && ! templateModified; const appliedPaletteId = store.appliedPaletteId(); const originPaletteId = store.originPaletteId(); const matchedPaletteId = appliedPaletteId || originPaletteId; const matchedPalette = matchedPaletteId ? store.palettes.find( ( p ) => p.id === matchedPaletteId ) : null; const paletteLabel = matchedPalette ? matchedPalette.name : __( 'Default', 'everest-forms' ); const paletteModified = ! appliedPaletteId && ( !! originPaletteId || ! store.paletteAtDefault() ); const paletteIsBase = !! appliedPaletteId && ! paletteModified; const paletteColors = store.currentPaletteColors(); return (
{ __( 'Pre-defined', 'everest-forms' ) }
{ __( 'Apply Theme Style', 'everest-forms' ) }
{ __( 'Elements', 'everest-forms' ) }

{ __( 'Tip: pick an element to style it, or', 'everest-forms' ) }{ ' ' } { __( 'click it in the live preview.', 'everest-forms' ) }

{ sections.map( ( s ) => { const locked = s.tier === 'pro' && ! store.proActive; return ( ); } ) }
{ __( 'Advanced', 'everest-forms' ) }
); } /* --------------------------------------------------------------------- * * Element slate (drill-down body) * --------------------------------------------------------------------- */ interface GroupedTokens { heading: string; tokens: Token[]; } function groupTokens( tokens: Token[] ): GroupedTokens[] { const out: GroupedTokens[] = []; let current: string | null = null; tokens.forEach( ( t ) => { if ( t.group !== current ) { current = t.group; out.push( { heading: current, tokens: [] } ); } out[ out.length - 1 ].tokens.push( t ); } ); return out; } export function ElementSlate( { section, activeState, onChangeState, pulse, }: { section: Section; activeState: string | null; onChangeState: ( id: string ) => void; pulse: number; } ) { const store = useStore(); const bodyRef = React.useRef< HTMLDivElement >( null ); const tabs = section.states || section.variants || null; const first = tabs ? tabs[ 0 ] : null; const act = activeState || first; // When opened via a preview click (pulse changes), scroll the panel up and briefly flash. React.useEffect( () => { const el = bodyRef.current; if ( ! el || ! pulse ) { return; } const scroller = el.closest( '.panel-scroll' ); if ( scroller ) { scroller.scrollTop = 0; } el.classList.remove( 'flash' ); // Force reflow so the animation restarts on repeated selections. void el.offsetWidth; el.classList.add( 'flash' ); }, [ pulse ] ); // Background sub-options only matter once an image is set. const bgImageSet = !! ( store.tokens[ 'wrap.bgImage' ] && store.tokens[ 'wrap.bgImage' ].desktop ); const visible = store.schema.filter( ( t ) => t.section === section.key && ! t.hidden && ( ! t.show_when_image || bgImageSet ) ); // On a state/variant tab, show only the controls that belong to that state; shared controls // live on the first tab. const enabledByState = tabs ? visible.filter( ( t ) => ( t.state ? t.state === act : act === first ) ) : visible; // Dependency dimming: a border-style set to "none" disables its width/colour deps. const dimmedByDep = new Set< string >(); const depHints: Record< string, string > = {}; visible.forEach( ( t ) => { if ( t.deps && String( store.resolve( t.key ) ) === 'none' ) { t.deps.forEach( ( k ) => dimmedByDep.add( k ) ); depHints[ t.key ] = __( 'Width & color have no effect while the border style is None.', 'everest-forms' ); } } ); const locked = ( t: Token ) => t.tier === 'pro' && ! store.proActive; const hasLocked = visible.some( locked ); // A whole Pro section on a free site: render a locked upgrade teaser instead of an empty slate. const sectionLocked = section.tier === 'pro' && ! store.proActive; const renderControl = ( t: Token ) => ( ); const renderGroups = ( tokens: Token[] ) => groupTokens( tokens ).map( ( g, i ) => (
{ g.heading &&
{ g.heading }
} { section.key === 'form' && g.heading === 'Background' && (

{ __( 'Background color comes from your color palette.', 'everest-forms' ) }

) } { g.tokens.map( renderControl ) }
) ); if ( sectionLocked ) { return (
); } return (
{ hasLocked && (
{ __( 'This is a Pro feature.', 'everest-forms' ) }{ ' ' } { __( 'Upgrade to Pro', 'everest-forms' ) } { ' ' } { __( 'to unlock it.', 'everest-forms' ) }
) } { tabs && (
{ if ( e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' ) { return; } e.preventDefault(); const idx = tabs.indexOf( act as string ); const next = tabs[ ( idx + ( e.key === 'ArrowRight' ? 1 : tabs.length - 1 ) ) % tabs.length ]; onChangeState( next ); document.getElementById( `scv2-state-${ section.key }-${ next }` )?.focus(); } } > { tabs.map( ( id ) => ( ) ) }
) }
{ renderGroups( enabledByState ) }
); } /** Locked upgrade teaser — shown wherever a Pro feature is surfaced on a free site. */ export function ProTeaser( { title, text }: { title: string; text: string } ) { return (

{ title }

{ text }

{ __( 'Upgrade to Pro', 'everest-forms' ) }
); } /** Section-level teaser (a whole Pro design section opened on free). */ function ProSectionTeaser( { section }: { section: Section } ) { return ( ); } /* --------------------------------------------------------------------- * * Templates * --------------------------------------------------------------------- */ /** The desktop value of a token bag, for hover-preview + thumbnails. */ function desktopOf( bag: DeviceBag | undefined ): any { return bag && bag.desktop !== undefined ? bag.desktop : undefined; } /** * Flatten a template's per-device token bags into a { key: desktopValue } map covering every * schema key (not just the ones the template sets), mirroring what `store.applyTemplate()` does * on click so hovering previews exactly what clicking would produce. */ function flattenForPreview( tokens: Record< string, DeviceBag >, schema: Token[] ): Record< string, any > { const out: Record< string, any > = {}; schema.forEach( ( t ) => { out[ t.key ] = tokens && tokens[ t.key ] !== undefined ? desktopOf( tokens[ t.key ] ) : t.default; } ); return out; } function radiusOf( bag: DeviceBag | undefined, fallback: number ): number { const v = desktopOf( bag ) as BoxValue | undefined; return v && typeof v === 'object' ? Number( v.top ) || 0 : fallback; } /** Template thumbnail: real screenshot when it loads, else a live token-driven mini-form. */ function TemplateThumb( { tpl }: { tpl: Template } ) { const [ imgOk, setImgOk ] = React.useState( !! tpl.image ); const th = templateThumb( tpl ); if ( tpl.image && imgOk ) { return ( setImgOk( false ) } /> ); } return ( ); } /** Build a small live thumbnail from a template's migrated tokens — a mini "Name / Message / * Submit" mock-up in the template's own colours, so a custom template reads as recognizably * itself in the grid, the same way a built-in's screenshot does. */ function templateThumb( tpl: Template ) { const t = tpl.tokens; const bg = desktopOf( t[ 'wrap.bg' ] ) || '#ffffff'; const btnBg = desktopOf( t[ 'btn.bg' ] ) || '#3b82f6'; const btnText = desktopOf( t[ 'btn.color' ] ) || '#ffffff'; const inputBg = desktopOf( t[ 'input.bg' ] ) || '#ffffff'; const border = desktopOf( t[ 'input.borderC' ] ) || '#d8dae2'; const label = desktopOf( t[ 'label.color' ] ) || '#1f2433'; const fieldRadius = radiusOf( t[ 'input.radius' ], 6 ); const btnRadius = radiusOf( t[ 'btn.radius' ], 6 ); return { thumb: { background: bg } as React.CSSProperties, line: { background: label } as React.CSSProperties, field: { background: inputBg, borderColor: border, borderRadius: fieldRadius } as React.CSSProperties, button: { background: btnBg, color: btnText, borderRadius: btnRadius } as React.CSSProperties, }; } /** One template card — thumbnail, name, applied/locked/delete affordances. Used for both the * "Your templates" and built-in grids so the two stay visually identical. */ function TemplateCard( { tpl, applied, modified, basedOn, locked, disabled, onPreview, onClearPreview, onApply, confirmingDelete, onRequestDelete, onConfirmDelete, onCancelDelete, }: { tpl: Template; applied: boolean; /** The form was applied FROM this template but has since been edited — show an honest hint * instead of a ✓ (its styles no longer exactly match this template). */ modified?: boolean; /** Name of the built-in template this (custom) template exactly derives from, if any. */ basedOn?: string; locked: boolean; /** True while a DIFFERENT template is being edited — every action on this card is inert. */ disabled?: boolean; onPreview: () => void; onClearPreview: () => void; onApply: () => void; confirmingDelete?: boolean; onRequestDelete?: () => void; onConfirmDelete?: () => void; onCancelDelete?: () => void; } ) { return (
{ onRequestDelete && ( ) } { confirmingDelete && (
{ __( 'Delete?', 'everest-forms' ) }
) }
); } export function TemplatesPane( { onPreview, onClearPreview, onApplied, }: { onPreview: ( overrides: Record< string, any > ) => void; onClearPreview: () => void; onApplied: ( name: string ) => void; } ) { const store = useStore(); const [ confirmDeleteId, setConfirmDeleteId ] = React.useState< string | null >( null ); const templates = store.allTemplates(); // Memoize the flattened override maps so hover doesn't re-flatten on every mouse event. const flat = React.useMemo( () => Object.fromEntries( templates.map( ( tpl ) => [ tpl.id, flattenForPreview( tpl.tokens, store.schema ) ] ) ), // eslint-disable-next-line react-hooks/exhaustive-deps [ templates, store ] ); // Value-driven template state, recomputed whenever the store version bumps. const ver = store.getVersion(); const appliedId = React.useMemo( () => store.appliedTemplateId(), [ store, ver ] ); const originId = React.useMemo( () => store.originTemplateId(), [ store, ver ] ); const parentNames = React.useMemo( () => { const map: Record< string, string > = {}; templates.forEach( ( tpl ) => { const pid = store.templateParentId( tpl ); if ( pid ) { const parent = store.templates.find( ( b ) => b.id === pid ); if ( parent ) { map[ tpl.id ] = parent.name; } } } ); return map; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ templates, store, ver ] ); // "Your Template" card — same value-driven match as the ✓/"Modified" badges above, fed into // the existing TemplateThumb live-mockup renderer via a synthetic, always-current object. const matchedTplId = appliedId || originId; const matchedTpl = matchedTplId ? templates.find( ( t ) => t.id === matchedTplId ) : null; const yourTemplateName = matchedTpl ? matchedTpl.name : __( 'Default', 'everest-forms' ); const yourTemplateModified = ! appliedId && ( !! originId || ! store.isAtSchemaDefault() ); const yourTemplateIsBase = !! appliedId && ! yourTemplateModified; const templatesBase = store.settings.restBase.replace( /\/styles$/, '/style-templates' ); const deleteTemplate = async ( id: string ) => { setConfirmDeleteId( null ); if ( ! apiFetch ) { return; } store.removeUserTemplate( id ); // optimistic try { await apiFetch( { path: `${ templatesBase }/${ id }`, method: 'DELETE' } ); } catch ( e ) { // The list already reflects the removal; a reload restores it if the server rejected. } }; const applyTpl = ( tpl: Template, locked: boolean ) => { if ( locked ) { window.open( UPGRADE_URL, '_blank' ); return; } store.applyTemplate( tpl.id, tpl.tokens, tpl.palette ); onApplied( tpl.name ); }; const renderGrid = ( list: Template[] ) => (
{ list.map( ( tpl ) => { const locked = !! tpl.is_pro && ! store.proActive; const withDelete = !! tpl.custom; return ( onPreview( flat[ tpl.id ] ) } onClearPreview={ onClearPreview } onApply={ () => applyTpl( tpl, locked ) } confirmingDelete={ confirmDeleteId === tpl.id } onRequestDelete={ withDelete ? () => setConfirmDeleteId( tpl.id ) : undefined } onConfirmDelete={ withDelete ? () => deleteTemplate( tpl.id ) : undefined } onCancelDelete={ withDelete ? () => setConfirmDeleteId( null ) : undefined } /> ); } ) }
); return (
{ __( 'Your Template', 'everest-forms' ) }
{ yourTemplateName } { yourTemplateModified && { __( 'Modified', 'everest-forms' ) } } { yourTemplateIsBase && { __( 'Base', 'everest-forms' ) } }
{ __( 'Presets', 'everest-forms' ) }

{ __( 'Hover a card to preview it live', 'everest-forms' ) }{ ' ' } — { __( 'click to apply (you can always undo).', 'everest-forms' ) }

{ /* Legacy set stays in store.templates (see Templates::load_legacy()) for badge matching above; just not offered here. */ } { renderGrid( [ ...store.userTemplates, ...store.templates.filter( ( tpl ) => ! tpl.legacy ) ].sort( ( a, b ) => { if ( !! a.custom !== !! b.custom ) { return a.custom ? -1 : 1; } return ( a.is_pro ? 1 : 0 ) - ( b.is_pro ? 1 : 0 ); } ) ) }
); } /* --------------------------------------------------------------------- * * Custom CSS * --------------------------------------------------------------------- */ const CSS_CHIPS = [ '.evf-container', '.evf-submit-container button', 'input, textarea, select', 'label.evf-field-label', '.everest-forms-notice--success', ]; const CSS_EXAMPLE = `.evf-submit-container button { letter-spacing: .04em; text-transform: uppercase; }`; export function CustomCssPane() { const store = useStore(); const ref = React.useRef< HTMLTextAreaElement >( null ); const insert = ( snippet: string ) => { const el = ref.current; if ( ! el ) { return; } const start = el.selectionStart; const value = el.value.slice( 0, start ) + snippet + el.value.slice( el.selectionEnd ); store.setCustomCss( value ); el.focus(); }; const len = ( store.customCss || '' ).length; return (
{ __( 'Custom CSS', 'everest-forms' ) }

{ __( 'Applied live as you type —', 'everest-forms' ) } { __( 'applies to every form on this site', 'everest-forms' ) }{ ' ' } { __( 'on save (same as the Customizer’s Additional CSS). Click a selector to insert it:', 'everest-forms' ) }

{ CSS_CHIPS.map( ( c ) => ( ) ) }