import { useState, useEffect, useRef, useCallback, createPortal } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { useDraggable } from '@dnd-kit/core';
import { getAllElements, getElement } from '../fields/FieldRegistry';
import SuggestedAmountsEditor from './SuggestedAmountsEditor';
import { openWpMedia } from '../utils/media';
import ColorPicker from './ColorPicker';
const CUSTOM_ICONS = {
progress_bar: (
),
donation_form: (
),
};
/**
* Right sidebar — two tabs:
* "Input Fields" — 2-col grid of draggable element cards
* "Input Customization" — schema-driven settings for the selected element
*/
export default function RightSidebar( {
meta,
layout,
selectedElementId,
selectedColumnId,
dispatch,
} ) {
const [ activeTab, setActiveTab ] = useState( 'fields' );
useEffect( () => {
if ( selectedElementId ) {
setActiveTab( 'customization' );
}
}, [ selectedElementId ] );
return (
{ activeTab === 'fields' ? (
) : (
) }
);
}
// ── Input Fields tab ───────────────────────────────────────────────────────────
function FieldsGrid() {
const elements = getAllElements();
return (
{ elements.map( ( el ) => (
) ) }
);
}
function DraggableFieldCard( { element } ) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable( {
id: `panel-${ element.type }`,
data: {
source: 'panel',
elementType: element.type,
defaultSettings: element.defaultSettings,
},
} );
return (
{ CUSTOM_ICONS[ element.type ]
? { CUSTOM_ICONS[ element.type ] }
:
}
{ element.label }
);
}
// ── Input Customization tab ────────────────────────────────────────────────────
function CustomizationPanel( { meta, layout, selectedElementId, selectedColumnId, dispatch } ) {
const [ innerTab, setInnerTab ] = useState( 'general' );
let selectedElement = null;
if ( selectedElementId && layout?.columns ) {
for ( const col of layout.columns ) {
const found = col.elements.find( ( el ) => el.id === selectedElementId );
if ( found ) { selectedElement = found; break; }
}
}
const def = selectedElement ? getElement( selectedElement.type ) : null;
return (
{ /* General / Advanced tabs — always visible */ }
{ innerTab === 'general' && (
selectedElement ? (
<>
{ def?.label || __( 'Element Settings', 'better-payment' ) }
{ selectedElement.type === 'progress_bar' && (
{ __( 'Note: Progress bars will be visible only when there is a goal set for the campaign.', 'better-payment' ) }
) }
{ ( selectedElement.type === 'social_links' || selectedElement.type === 'social_sharing' ) && (
{ __( "Don't see a social network here that you use and would like added?", 'better-payment' ) }{ ' ' }
{ __( 'Let us know!', 'better-payment' ) }
) }
>
) : (
{ __( 'Click an element on the canvas to customize it.', 'better-payment' ) }
)
) }
{ innerTab === 'advanced' && (
) }
);
}
// ── Theme Colors (Advanced tab) ────────────────────────────────────────────────
const TEMPLATE_BUTTON_DEFAULTS = {
'blank-1col': '#5AA152',
'blank-2col': '#5AA152',
'blank-3col': '#5AA152',
'charity-basic': '#B49A5F',
'medical-relief': '#7A8347',
'education-fund': '#8FA040',
'golf-destinations':'#B8A46A',
'disaster-relief': '#c0392b',
};
const COLOR_FIELDS = [
{ key: 'bpc_color_button', label: __( 'Button', 'better-payment' ), default: '#5AA152' },
];
const PICKER_W = 270;
const PICKER_H_EST = 320; // estimated picker height after size reduction
function ThemeColors( { meta, dispatch } ) {
const [ openKey, setOpenKey ] = useState( null );
const [ pickerPos, setPickerPos ] = useState( { top: 0, left: 0, caretLeft: 0, caretAtBottom: true } );
const swatchRefs = useRef( {} );
const pickerRef = useRef( null );
const openPickerFor = useCallback( ( key ) => {
if ( openKey === key ) { setOpenKey( null ); return; }
const el = swatchRefs.current[ key ];
if ( el ) {
const rect = el.getBoundingClientRect();
// Horizontal: center picker over swatch, clamp to viewport.
let left = rect.left + rect.width / 2 - PICKER_W / 2;
left = Math.max( 8, Math.min( left, window.innerWidth - PICKER_W - 8 ) );
const caretLeft = rect.left + rect.width / 2 - left;
// Vertical: prefer above the swatch, fall back to below.
const spaceAbove = rect.top - 10;
const caretAtBottom = spaceAbove >= PICKER_H_EST;
let top = caretAtBottom
? rect.top - PICKER_H_EST - 10
: rect.bottom + 10;
// Clamp so the picker never leaves the viewport.
top = Math.max( 8, Math.min( top, window.innerHeight - PICKER_H_EST - 8 ) );
setPickerPos( { top, left, caretLeft, caretAtBottom } );
}
setOpenKey( key );
}, [ openKey ] );
// Close picker when clicking outside both the popup and the swatches.
useEffect( () => {
if ( ! openKey ) return;
const handler = ( e ) => {
const inPicker = pickerRef.current?.contains( e.target );
const inSwatch = Object.values( swatchRefs.current ).some( el => el?.contains( e.target ) );
if ( ! inPicker && ! inSwatch ) setOpenKey( null );
};
document.addEventListener( 'mousedown', handler );
return () => document.removeEventListener( 'mousedown', handler );
}, [ openKey ] );
const update = ( key, val ) =>
dispatch( { type: 'UPDATE_META', payload: { [ key ]: val } } );
const activeField = COLOR_FIELDS.find( ( f ) => f.key === openKey );
return (
{ __( 'Theme Colors', 'better-payment' ) }
{ COLOR_FIELDS.map( ( { key, label, default: def } ) => {
const value = meta[ key ] || def;
return (
);
} ) }
{ openKey && activeField && createPortal(
update( openKey, hex ) }
onClose={ () => setOpenKey( null ) }
/>
,
document.body
) }
);
}
function ElementSettings( { element, schema, meta, dispatch } ) {
const updateElement = ( patch ) =>
dispatch( { type: 'UPDATE_ELEMENT_SETTINGS', elementId: element.id, settings: patch } );
const updateMeta = ( patch ) =>
dispatch( { type: 'UPDATE_META', payload: patch } );
const s = element.settings || {};
if ( schema.length === 0 ) {
return (
{ __( 'No settings for this element.', 'better-payment' ) }
);
}
return (
{ schema.map( ( control ) => {
const isMeta = !! control.metaKey;
const rawValue = isMeta ? meta?.[ control.metaKey ] : s[ control.key ];
const value = rawValue !== undefined && rawValue !== null ? rawValue : ( control.defaultValue ?? '' );
const onChange = isMeta
? ( val ) => updateMeta( { [ control.metaKey ]: val } )
: ( val ) => updateElement( { [ control.key ]: val } );
const onMultiChange = ( ! isMeta && control.type === 'image_upload' )
? ( patch ) => updateElement( patch )
: undefined;
const elementSettings = ( ! isMeta && control.type === 'image_upload' ) ? s : undefined;
return (
);
} ) }
);
}
const ALIGN_OPTIONS = [
{
value: 'left',
title: 'Align left',
icon: (
),
},
{
value: 'center',
title: 'Align center',
icon: (
),
},
{
value: 'right',
title: 'Align right',
icon: (
),
},
];
// ── SVG icons for the rich-text toolbar ──────────────────────────────────────
const TB = {
bold: ,
italic: ,
under: ,
link: ,
ol: ,
ul: ,
clear: ,
};
const BLOCKS = [
{ value: 'p', label: 'Normal' },
{ value: 'h2', label: 'Heading 2' },
{ value: 'h3', label: 'Heading 3' },
{ value: 'h4', label: 'Heading 4' },
];
const QUERY_CMDS = [ 'bold', 'italic', 'underline', 'insertOrderedList', 'insertUnorderedList' ];
function RichTextControl( { value, onChange } ) {
const editorRef = useRef( null );
const containerRef = useRef( null );
const focusedRef = useRef( false );
const lastValueRef = useRef( value );
const savedRangeRef = useRef( null );
const linkInputRef = useRef( null );
const [ activeFormats, setActiveFormats ] = useState( {} );
const [ blockValue, setBlockValue ] = useState( 'p' );
const [ linkPopover, setLinkPopover ] = useState( null );
// linkPopover: null | { mode: 'insert'|'view', href: string, anchorEl: Node|null, top: number, left: number }
// Seed editor on mount
useEffect( () => {
if ( editorRef.current ) {
editorRef.current.innerHTML = value || '';
lastValueRef.current = value || '';
}
}, [] ); // eslint-disable-line react-hooks/exhaustive-deps
// Sync external value changes when not focused (different element selected)
useEffect( () => {
if ( editorRef.current && ! focusedRef.current && value !== lastValueRef.current ) {
editorRef.current.innerHTML = value || '';
lastValueRef.current = value || '';
}
}, [ value ] );
// Auto-focus link input when popover opens in insert mode
useEffect( () => {
if ( linkPopover?.mode === 'insert' && linkInputRef.current ) {
linkInputRef.current.focus();
linkInputRef.current.select();
}
}, [ linkPopover ] );
// Close popover on outside click
useEffect( () => {
if ( ! linkPopover ) return;
const onDown = ( e ) => {
if ( ! containerRef.current?.contains( e.target ) ) {
setLinkPopover( null );
}
};
document.addEventListener( 'mousedown', onDown );
return () => document.removeEventListener( 'mousedown', onDown );
}, [ linkPopover ] );
const updateActiveState = useCallback( () => {
const formats = {};
QUERY_CMDS.forEach( ( c ) => {
try { formats[ c ] = document.queryCommandState( c ); } catch ( e ) { formats[ c ] = false; }
} );
// Extra check for list context via DOM traversal (more reliable cross-browser)
const sel = window.getSelection();
if ( sel && sel.rangeCount ) {
let node = sel.getRangeAt( 0 ).startContainer;
while ( node && node !== editorRef.current ) {
const tag = node.nodeName?.toLowerCase();
if ( tag === 'ul' ) { formats.insertUnorderedList = true; break; }
if ( tag === 'ol' ) { formats.insertOrderedList = true; break; }
if ( tag === 'li' ) {
const parent = node.parentNode?.nodeName?.toLowerCase();
if ( parent === 'ul' ) formats.insertUnorderedList = true;
if ( parent === 'ol' ) formats.insertOrderedList = true;
break;
}
node = node.parentNode;
}
}
setActiveFormats( formats );
// Detect current block tag
if ( sel && sel.rangeCount ) {
let node = sel.getRangeAt( 0 ).startContainer;
while ( node && node !== editorRef.current ) {
const tag = node.nodeName?.toLowerCase();
if ( [ 'p', 'h2', 'h3', 'h4' ].includes( tag ) ) {
setBlockValue( tag );
return;
}
node = node.parentNode;
}
}
setBlockValue( 'p' );
}, [] );
const cmd = useCallback( ( command, arg = null ) => {
editorRef.current?.focus();
document.execCommand( command, false, arg );
updateActiveState();
}, [ updateActiveState ] );
const handleInput = useCallback( () => {
const html = editorRef.current?.innerHTML || '';
lastValueRef.current = html;
onChange( html );
}, [ onChange ] );
// Save the current selection and compute popover position relative to container
const openLinkPopover = useCallback( ( e ) => {
e.preventDefault();
const sel = window.getSelection();
if ( ! sel || ! sel.rangeCount ) return;
const range = sel.getRangeAt( 0 );
savedRangeRef.current = range.cloneRange();
// Find if cursor/selection is inside an anchor
let anchor = null;
let node = range.startContainer;
while ( node && node !== editorRef.current ) {
if ( node.nodeName === 'A' ) { anchor = node; break; }
node = node.parentNode;
}
// Position below the selection, spanning the full container width (left/right set via CSS)
const containerRect = containerRef.current?.getBoundingClientRect() ?? { top: 0 };
const selRect = range.getBoundingClientRect();
const top = selRect.bottom - containerRect.top + 4;
if ( anchor ) {
setLinkPopover( { mode: 'view', href: anchor.href, anchorEl: anchor, top } );
} else {
setLinkPopover( { mode: 'insert', href: 'https://', anchorEl: null, top } );
}
}, [] );
// Restore saved selection, then apply execCommand
const restoreAndExec = useCallback( ( command, arg = null ) => {
editorRef.current?.focus();
if ( savedRangeRef.current ) {
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange( savedRangeRef.current );
}
document.execCommand( command, false, arg );
handleInput();
updateActiveState();
}, [ handleInput, updateActiveState ] );
const handleLinkSave = useCallback( ( href ) => {
setLinkPopover( null );
if ( href && href !== 'https://' ) {
restoreAndExec( 'createLink', href );
}
}, [ restoreAndExec ] );
const handleLinkRemove = useCallback( () => {
setLinkPopover( null );
// Select the full anchor node before unlinking
const anchor = linkPopover?.anchorEl;
if ( anchor && editorRef.current?.contains( anchor ) ) {
editorRef.current.focus();
const range = document.createRange();
range.selectNode( anchor );
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange( range );
document.execCommand( 'unlink', false, null );
handleInput();
}
}, [ linkPopover, handleInput ] );
const handleLinkEdit = useCallback( () => {
setLinkPopover( ( prev ) => ( { ...prev, mode: 'insert' } ) );
}, [] );
const handleBlockChange = useCallback( ( e ) => {
const tag = e.target.value;
editorRef.current?.focus();
document.execCommand( 'formatBlock', false, tag );
setBlockValue( tag );
}, [] );
const BTNS = [
{ id: 'bold', icon: TB.bold, title: 'Bold (Ctrl+B)' },
{ id: 'italic', icon: TB.italic,title: 'Italic (Ctrl+I)' },
{ id: 'underline', icon: TB.under, title: 'Underline (Ctrl+U)' },
{ id: 'insertOrderedList', icon: TB.ol, title: 'Ordered list' },
{ id: 'insertUnorderedList', icon: TB.ul, title: 'Unordered list' },
{ id: 'removeFormat', icon: TB.clear, title: 'Clear formatting' },
{ id: 'link', icon: TB.link, title: 'Insert link', isLink: true },
];
return (
{ /* Row 1: block style dropdown */ }
{ /* Row 2: format buttons */ }
{ BTNS.map( ( btn ) => (
) ) }
{ /* Inline link popover */ }
{ linkPopover && (
{ if ( e.key === 'Escape' ) setLinkPopover( null ); } }
>
{ linkPopover.mode === 'insert' ? (
<>
{ __( 'Enter link:', 'better-payment' ) }
{
if ( e.key === 'Enter' ) { e.preventDefault(); handleLinkSave( e.target.value ); }
} }
/>
>
) : (
<>
{ linkPopover.href.length > 40 ? linkPopover.href.slice( 0, 40 ) + '…' : linkPopover.href }
|
|
>
) }
) }
{ focusedRef.current = true; } }
onBlur={ () => { focusedRef.current = false; handleInput(); } }
onInput={ handleInput }
onKeyUp={ updateActiveState }
onMouseUp={ updateActiveState }
/>
);
}
function ImageUploadControl( { value, onMultiChange, elementSettings } ) {
const openMedia = useCallback( () => {
openWpMedia( ( { url, id, sizes } ) =>
onMultiChange( { src: url, src_id: id, src_sizes: sizes } )
);
}, [ onMultiChange ] );
const srcSizes = elementSettings?.src_sizes || {};
const size = elementSettings?.size || 'full';
const displaySrc = ( size !== 'full' && srcSizes[ size ]?.url ) ? srcSizes[ size ].url : ( value || '' );
const hasImage = !! displaySrc;
return (
{ /* Elementor-style preview area */ }
{ hasImage ? (
<>
>
) : (
) }
{ /* Choose Image bar */ }
e.stopPropagation() }>
{ /* URL input + Clear */ }
onMultiChange( { src: e.target.value, src_id: 0, src_sizes: {} } ) }
/>
{ hasImage && (
) }
);
}
function SettingsControl( { control, value, onChange, onMultiChange, elementSettings } ) {
const { key, label, type, placeholder, min, max, rows, options, info, unit, step } = control;
if ( type === 'section_label' ) {
return (
{ label }
);
}
if ( type === 'note' ) {
return
{ label }
;
}
return (
);
}