PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.2.0
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.2.0
2.4.1 2.4.0 2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.2 2.2.1 2.2.0 2.1.2 2.1.1 trunk 0.0.1 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 All 68 releases
better-payment / src / campaign-builder / components / RightSidebar.js

RightSidebar.js in Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More 2.2.0, at src/campaign-builder/components/RightSidebar.js

969 lines 41.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { useState, useEffect, useRef, useCallback, createPortal } from '@wordpress/element';
2 import { __ } from '@wordpress/i18n';
3 import { useDraggable } from '@dnd-kit/core';
4 import { getAllElements, getElement } from '../fields/FieldRegistry';
5 import SuggestedAmountsEditor from './SuggestedAmountsEditor';
6 import { openWpMedia } from '../utils/media';
7 import ColorPicker from './ColorPicker';
8
9 const CUSTOM_ICONS = {
10 progress_bar: (
11 <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
12 <rect x="2" y="10" width="20" height="4" rx="2" fill="currentColor" fillOpacity="0.22"/>
13 <rect x="2" y="10" width="12" height="4" rx="2" fill="currentColor"/>
14 <circle cx="14" cy="12" r="3" fill="currentColor"/>
15 </svg>
16 ),
17 donation_form: (
18 <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
19 <path d="M12 9c-.5-1.3-1.8-2.2-3.2-2.2C6.9 6.8 5.4 8.3 5.4 10.1c0 2.3 2.3 4.4 6.6 7.1 4.3-2.7 6.6-4.8 6.6-7.1 0-1.8-1.5-3.3-3.4-3.3C13.8 6.8 12.5 7.7 12 9z" fill="currentColor"/>
20 <rect x="6" y="18.5" width="12" height="3" rx="1.5" stroke="currentColor" strokeWidth="1.4" fill="none"/>
21 <line x1="9.5" y1="18.5" x2="9.5" y2="17" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round"/>
22 <line x1="12" y1="18.5" x2="12" y2="16.5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round"/>
23 <line x1="14.5" y1="18.5" x2="14.5" y2="17" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round"/>
24 </svg>
25 ),
26 };
27
28 /**
29 * Right sidebar — two tabs:
30 * "Input Fields" — 2-col grid of draggable element cards
31 * "Input Customization" — schema-driven settings for the selected element
32 */
33 export default function RightSidebar( {
34 meta,
35 layout,
36 selectedElementId,
37 selectedColumnId,
38 dispatch,
39 } ) {
40 const [ activeTab, setActiveTab ] = useState( 'fields' );
41
42 useEffect( () => {
43 if ( selectedElementId ) {
44 setActiveTab( 'customization' );
45 }
46 }, [ selectedElementId ] );
47
48 return (
49 <div className="bp-cb-right-sidebar">
50 <div className="bp-cb-right-sidebar__tabs">
51 <button
52 className={ `bp-cb-sidebar-tab${ activeTab === 'fields' ? ' is-active' : '' }` }
53 onClick={ () => setActiveTab( 'fields' ) }
54 >
55 { __( 'Elements', 'better-payment' ) }
56 </button>
57 <button
58 className={ `bp-cb-sidebar-tab${ activeTab === 'customization' ? ' is-active' : '' }` }
59 onClick={ () => setActiveTab( 'customization' ) }
60 >
61 { __( 'Customize Element', 'better-payment' ) }
62 </button>
63 </div>
64
65 <div className="bp-cb-right-sidebar__body">
66 { activeTab === 'fields' ? (
67 <FieldsGrid />
68 ) : (
69 <CustomizationPanel
70 meta={ meta }
71 layout={ layout }
72 selectedElementId={ selectedElementId }
73 selectedColumnId={ selectedColumnId }
74 dispatch={ dispatch }
75 />
76 ) }
77 </div>
78 </div>
79 );
80 }
81
82 // ── Input Fields tab ───────────────────────────────────────────────────────────
83
84 function FieldsGrid() {
85 const elements = getAllElements();
86
87 return (
88 <div className="bp-cb-fields-grid">
89 { elements.map( ( el ) => (
90 <DraggableFieldCard key={ el.type } element={ el } />
91 ) ) }
92 </div>
93 );
94 }
95
96 function DraggableFieldCard( { element } ) {
97 const { attributes, listeners, setNodeRef, isDragging } = useDraggable( {
98 id: `panel-${ element.type }`,
99 data: {
100 source: 'panel',
101 elementType: element.type,
102 defaultSettings: element.defaultSettings,
103 },
104 } );
105
106 return (
107 <div
108 ref={ setNodeRef }
109 className={ `bp-cb-field-card${ isDragging ? ' is-dragging' : '' }` }
110 title={ element.label }
111 { ...listeners }
112 { ...attributes }
113 >
114 { CUSTOM_ICONS[ element.type ]
115 ? <span className="bp-cb-field-card__svg-icon">{ CUSTOM_ICONS[ element.type ] }</span>
116 : <span className={ `dashicons dashicons-${ element.icon }` }></span>
117 }
118 <span className="bp-cb-field-card__label">{ element.label }</span>
119 </div>
120 );
121 }
122
123 // ── Input Customization tab ────────────────────────────────────────────────────
124
125
126 function CustomizationPanel( { meta, layout, selectedElementId, selectedColumnId, dispatch } ) {
127 const [ innerTab, setInnerTab ] = useState( 'general' );
128
129 let selectedElement = null;
130 if ( selectedElementId && layout?.columns ) {
131 for ( const col of layout.columns ) {
132 const found = col.elements.find( ( el ) => el.id === selectedElementId );
133 if ( found ) { selectedElement = found; break; }
134 }
135 }
136
137 const def = selectedElement ? getElement( selectedElement.type ) : null;
138
139 return (
140 <div className="bp-cb-element-settings">
141
142 { /* General / Advanced tabs — always visible */ }
143 <div className="bp-cb-inner-tabs">
144 <button
145 className={ `bp-cb-inner-tab${ innerTab === 'general' ? ' is-active' : '' }` }
146 onClick={ () => setInnerTab( 'general' ) }
147 >
148 { __( 'General', 'better-payment' ) }
149 </button>
150 <button
151 className={ `bp-cb-inner-tab${ innerTab === 'advanced' ? ' is-active' : '' }` }
152 onClick={ () => setInnerTab( 'advanced' ) }
153 >
154 { __( 'Advanced', 'better-payment' ) }
155 </button>
156 </div>
157
158 { innerTab === 'general' && (
159 selectedElement ? (
160 <>
161 <div className="bp-cb-element-settings__header">
162 <span className={ `dashicons dashicons-${ def?.icon || 'block-default' }` } />
163 <span className="bp-cb-element-settings__title">
164 { def?.label || __( 'Element Settings', 'better-payment' ) }
165 </span>
166 </div>
167
168 { selectedElement.type === 'progress_bar' && (
169 <p className="bp-cb-widget-note">
170 { __( 'Note: Progress bars will be visible only when there is a goal set for the campaign.', 'better-payment' ) }
171 </p>
172 ) }
173
174 { ( selectedElement.type === 'social_links' || selectedElement.type === 'social_sharing' ) && (
175 <p className="bp-cb-social-info">
176 { __( "Don't see a social network here that you use and would like added?", 'better-payment' ) }{ ' ' }
177 <a href="https://wpdeveloper.com/support" target="_blank" rel="noreferrer">
178 { __( 'Let us know!', 'better-payment' ) }
179 </a>
180 </p>
181 ) }
182
183 <ElementSettings
184 element={ selectedElement }
185 schema={ def?.settingsSchema || [] }
186 meta={ meta }
187 dispatch={ dispatch }
188 />
189 </>
190 ) : (
191 <div className="bp-cb-customization-empty">
192 <svg width="40" height="40" viewBox="0 0 40 40" fill="none">
193 <circle cx="20" cy="20" r="18" stroke="#ddd" strokeWidth="2"/>
194 <path d="M14 20h12M20 14v12" stroke="#ccc" strokeWidth="2" strokeLinecap="round"/>
195 </svg>
196 <p>{ __( 'Click an element on the canvas to customize it.', 'better-payment' ) }</p>
197 </div>
198 )
199 ) }
200
201 { innerTab === 'advanced' && (
202 <ThemeColors meta={ meta } dispatch={ dispatch } />
203 ) }
204 </div>
205 );
206 }
207
208 // ── Theme Colors (Advanced tab) ────────────────────────────────────────────────
209
210 const TEMPLATE_BUTTON_DEFAULTS = {
211 'blank-1col': '#5AA152',
212 'blank-2col': '#5AA152',
213 'blank-3col': '#5AA152',
214 'charity-basic': '#B49A5F',
215 'medical-relief': '#7A8347',
216 'education-fund': '#8FA040',
217 'golf-destinations':'#B8A46A',
218 'disaster-relief': '#c0392b',
219 };
220
221 const COLOR_FIELDS = [
222 { key: 'bpc_color_button', label: __( 'Button', 'better-payment' ), default: '#5AA152' },
223 ];
224
225 const PICKER_W = 270;
226 const PICKER_H_EST = 320; // estimated picker height after size reduction
227
228 function ThemeColors( { meta, dispatch } ) {
229 const [ openKey, setOpenKey ] = useState( null );
230 const [ pickerPos, setPickerPos ] = useState( { top: 0, left: 0, caretLeft: 0, caretAtBottom: true } );
231 const swatchRefs = useRef( {} );
232 const pickerRef = useRef( null );
233
234 const openPickerFor = useCallback( ( key ) => {
235 if ( openKey === key ) { setOpenKey( null ); return; }
236
237 const el = swatchRefs.current[ key ];
238 if ( el ) {
239 const rect = el.getBoundingClientRect();
240
241 // Horizontal: center picker over swatch, clamp to viewport.
242 let left = rect.left + rect.width / 2 - PICKER_W / 2;
243 left = Math.max( 8, Math.min( left, window.innerWidth - PICKER_W - 8 ) );
244 const caretLeft = rect.left + rect.width / 2 - left;
245
246 // Vertical: prefer above the swatch, fall back to below.
247 const spaceAbove = rect.top - 10;
248 const caretAtBottom = spaceAbove >= PICKER_H_EST;
249 let top = caretAtBottom
250 ? rect.top - PICKER_H_EST - 10
251 : rect.bottom + 10;
252
253 // Clamp so the picker never leaves the viewport.
254 top = Math.max( 8, Math.min( top, window.innerHeight - PICKER_H_EST - 8 ) );
255
256 setPickerPos( { top, left, caretLeft, caretAtBottom } );
257 }
258 setOpenKey( key );
259 }, [ openKey ] );
260
261 // Close picker when clicking outside both the popup and the swatches.
262 useEffect( () => {
263 if ( ! openKey ) return;
264 const handler = ( e ) => {
265 const inPicker = pickerRef.current?.contains( e.target );
266 const inSwatch = Object.values( swatchRefs.current ).some( el => el?.contains( e.target ) );
267 if ( ! inPicker && ! inSwatch ) setOpenKey( null );
268 };
269 document.addEventListener( 'mousedown', handler );
270 return () => document.removeEventListener( 'mousedown', handler );
271 }, [ openKey ] );
272
273 const update = ( key, val ) =>
274 dispatch( { type: 'UPDATE_META', payload: { [ key ]: val } } );
275
276 const activeField = COLOR_FIELDS.find( ( f ) => f.key === openKey );
277
278 return (
279 <div className="bp-theme-colors">
280 <p className="bp-theme-colors__title">
281 { __( 'Theme Colors', 'better-payment' ) }
282 </p>
283
284 { COLOR_FIELDS.map( ( { key, label, default: def } ) => {
285 const value = meta[ key ] || def;
286 return (
287 <div key={ key } className="bp-theme-color-row">
288 <span className="bp-theme-color-row__label">{ label }</span>
289 <div className="bp-theme-color-row__control">
290 <button
291 ref={ ( el ) => { swatchRefs.current[ key ] = el; } }
292 type="button"
293 className={ `bp-theme-color-row__swatch${ openKey === key ? ' is-active' : '' }` }
294 style={ { background: value } }
295 onClick={ () => openPickerFor( key ) }
296 />
297 <input
298 type="text"
299 className="bp-theme-color-row__input"
300 value={ value }
301 readOnly
302 onClick={ () => openPickerFor( key ) }
303 />
304 </div>
305 </div>
306 );
307 } ) }
308
309 { openKey && activeField && createPortal(
310 <div
311 ref={ pickerRef }
312 className="bp-color-picker-popup"
313 style={ {
314 position: 'fixed',
315 top: pickerPos.top,
316 left: pickerPos.left,
317 width: PICKER_W,
318 zIndex: 999999,
319 } }
320 >
321 <ColorPicker
322 value={ meta[ openKey ] || activeField.default }
323 defaultValue={
324 TEMPLATE_BUTTON_DEFAULTS[ meta.bpc_template_key ] || activeField.default
325 }
326 onChange={ ( hex ) => update( openKey, hex ) }
327 onClose={ () => setOpenKey( null ) }
328 />
329 <div
330 className={ `bp-color-picker-popup__caret${ pickerPos.caretAtBottom ? '' : ' is-top' }` }
331 style={ { left: pickerPos.caretLeft } }
332 />
333 </div>,
334 document.body
335 ) }
336 </div>
337 );
338 }
339
340 function ElementSettings( { element, schema, meta, dispatch } ) {
341 const updateElement = ( patch ) =>
342 dispatch( { type: 'UPDATE_ELEMENT_SETTINGS', elementId: element.id, settings: patch } );
343 const updateMeta = ( patch ) =>
344 dispatch( { type: 'UPDATE_META', payload: patch } );
345 const s = element.settings || {};
346
347 if ( schema.length === 0 ) {
348 return (
349 <p className="bp-cb-customization-hint">
350 { __( 'No settings for this element.', 'better-payment' ) }
351 </p>
352 );
353 }
354
355 return (
356 <div className="bp-cb-settings-form">
357 { schema.map( ( control ) => {
358 const isMeta = !! control.metaKey;
359 const rawValue = isMeta ? meta?.[ control.metaKey ] : s[ control.key ];
360 const value = rawValue !== undefined && rawValue !== null ? rawValue : ( control.defaultValue ?? '' );
361 const onChange = isMeta
362 ? ( val ) => updateMeta( { [ control.metaKey ]: val } )
363 : ( val ) => updateElement( { [ control.key ]: val } );
364 const onMultiChange = ( ! isMeta && control.type === 'image_upload' )
365 ? ( patch ) => updateElement( patch )
366 : undefined;
367 const elementSettings = ( ! isMeta && control.type === 'image_upload' ) ? s : undefined;
368 return (
369 <SettingsControl
370 key={ control.key }
371 control={ control }
372 value={ value }
373 onChange={ onChange }
374 onMultiChange={ onMultiChange }
375 elementSettings={ elementSettings }
376 />
377 );
378 } ) }
379 </div>
380 );
381 }
382
383 const ALIGN_OPTIONS = [
384 {
385 value: 'left',
386 title: 'Align left',
387 icon: (
388 <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
389 <rect x="2" y="4" width="16" height="2" rx="1" fill="currentColor"/>
390 <rect x="2" y="9" width="11" height="2" rx="1" fill="currentColor"/>
391 <rect x="2" y="14" width="14" height="2" rx="1" fill="currentColor"/>
392 </svg>
393 ),
394 },
395 {
396 value: 'center',
397 title: 'Align center',
398 icon: (
399 <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
400 <rect x="2" y="4" width="16" height="2" rx="1" fill="currentColor"/>
401 <rect x="4.5" y="9" width="11" height="2" rx="1" fill="currentColor"/>
402 <rect x="3" y="14" width="14" height="2" rx="1" fill="currentColor"/>
403 </svg>
404 ),
405 },
406 {
407 value: 'right',
408 title: 'Align right',
409 icon: (
410 <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
411 <rect x="2" y="4" width="16" height="2" rx="1" fill="currentColor"/>
412 <rect x="7" y="9" width="11" height="2" rx="1" fill="currentColor"/>
413 <rect x="4" y="14" width="14" height="2" rx="1" fill="currentColor"/>
414 </svg>
415 ),
416 },
417 ];
418
419 // ── SVG icons for the rich-text toolbar ──────────────────────────────────────
420 const TB = {
421 bold: <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M6 4h8a4 4 0 010 8H6zm0 8h9a4 4 0 010 8H6z" stroke="currentColor" strokeWidth="2" fill="none" strokeLinejoin="round"/></svg>,
422 italic: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>,
423 under: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M6 4v6a6 6 0 0012 0V4"/><line x1="4" y1="20" x2="20" y2="20"/></svg>,
424 link: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>,
425 ol: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg>,
426 ul: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><line x1="9" y1="6" x2="20" y2="6"/><line x1="9" y1="12" x2="20" y2="12"/><line x1="9" y1="18" x2="20" y2="18"/><circle cx="4" cy="6" r="1" fill="currentColor"/><circle cx="4" cy="12" r="1" fill="currentColor"/><circle cx="4" cy="18" r="1" fill="currentColor"/></svg>,
427 clear: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M17 7l-10 10"/><path d="M7 7h10v10"/></svg>,
428 };
429
430 const BLOCKS = [
431 { value: 'p', label: 'Normal' },
432 { value: 'h2', label: 'Heading 2' },
433 { value: 'h3', label: 'Heading 3' },
434 { value: 'h4', label: 'Heading 4' },
435 ];
436
437 const QUERY_CMDS = [ 'bold', 'italic', 'underline', 'insertOrderedList', 'insertUnorderedList' ];
438
439 function RichTextControl( { value, onChange } ) {
440 const editorRef = useRef( null );
441 const containerRef = useRef( null );
442 const focusedRef = useRef( false );
443 const lastValueRef = useRef( value );
444 const savedRangeRef = useRef( null );
445 const linkInputRef = useRef( null );
446 const [ activeFormats, setActiveFormats ] = useState( {} );
447 const [ blockValue, setBlockValue ] = useState( 'p' );
448 const [ linkPopover, setLinkPopover ] = useState( null );
449 // linkPopover: null | { mode: 'insert'|'view', href: string, anchorEl: Node|null, top: number, left: number }
450
451 // Seed editor on mount
452 useEffect( () => {
453 if ( editorRef.current ) {
454 editorRef.current.innerHTML = value || '';
455 lastValueRef.current = value || '';
456 }
457 }, [] ); // eslint-disable-line react-hooks/exhaustive-deps
458
459 // Sync external value changes when not focused (different element selected)
460 useEffect( () => {
461 if ( editorRef.current && ! focusedRef.current && value !== lastValueRef.current ) {
462 editorRef.current.innerHTML = value || '';
463 lastValueRef.current = value || '';
464 }
465 }, [ value ] );
466
467 // Auto-focus link input when popover opens in insert mode
468 useEffect( () => {
469 if ( linkPopover?.mode === 'insert' && linkInputRef.current ) {
470 linkInputRef.current.focus();
471 linkInputRef.current.select();
472 }
473 }, [ linkPopover ] );
474
475 // Close popover on outside click
476 useEffect( () => {
477 if ( ! linkPopover ) return;
478 const onDown = ( e ) => {
479 if ( ! containerRef.current?.contains( e.target ) ) {
480 setLinkPopover( null );
481 }
482 };
483 document.addEventListener( 'mousedown', onDown );
484 return () => document.removeEventListener( 'mousedown', onDown );
485 }, [ linkPopover ] );
486
487 const updateActiveState = useCallback( () => {
488 const formats = {};
489 QUERY_CMDS.forEach( ( c ) => {
490 try { formats[ c ] = document.queryCommandState( c ); } catch ( e ) { formats[ c ] = false; }
491 } );
492
493 // Extra check for list context via DOM traversal (more reliable cross-browser)
494 const sel = window.getSelection();
495 if ( sel && sel.rangeCount ) {
496 let node = sel.getRangeAt( 0 ).startContainer;
497 while ( node && node !== editorRef.current ) {
498 const tag = node.nodeName?.toLowerCase();
499 if ( tag === 'ul' ) { formats.insertUnorderedList = true; break; }
500 if ( tag === 'ol' ) { formats.insertOrderedList = true; break; }
501 if ( tag === 'li' ) {
502 const parent = node.parentNode?.nodeName?.toLowerCase();
503 if ( parent === 'ul' ) formats.insertUnorderedList = true;
504 if ( parent === 'ol' ) formats.insertOrderedList = true;
505 break;
506 }
507 node = node.parentNode;
508 }
509 }
510
511 setActiveFormats( formats );
512
513 // Detect current block tag
514 if ( sel && sel.rangeCount ) {
515 let node = sel.getRangeAt( 0 ).startContainer;
516 while ( node && node !== editorRef.current ) {
517 const tag = node.nodeName?.toLowerCase();
518 if ( [ 'p', 'h2', 'h3', 'h4' ].includes( tag ) ) {
519 setBlockValue( tag );
520 return;
521 }
522 node = node.parentNode;
523 }
524 }
525 setBlockValue( 'p' );
526 }, [] );
527
528 const cmd = useCallback( ( command, arg = null ) => {
529 editorRef.current?.focus();
530 document.execCommand( command, false, arg );
531 updateActiveState();
532 }, [ updateActiveState ] );
533
534 const handleInput = useCallback( () => {
535 const html = editorRef.current?.innerHTML || '';
536 lastValueRef.current = html;
537 onChange( html );
538 }, [ onChange ] );
539
540 // Save the current selection and compute popover position relative to container
541 const openLinkPopover = useCallback( ( e ) => {
542 e.preventDefault();
543 const sel = window.getSelection();
544 if ( ! sel || ! sel.rangeCount ) return;
545
546 const range = sel.getRangeAt( 0 );
547 savedRangeRef.current = range.cloneRange();
548
549 // Find if cursor/selection is inside an anchor
550 let anchor = null;
551 let node = range.startContainer;
552 while ( node && node !== editorRef.current ) {
553 if ( node.nodeName === 'A' ) { anchor = node; break; }
554 node = node.parentNode;
555 }
556
557 // Position below the selection, spanning the full container width (left/right set via CSS)
558 const containerRect = containerRef.current?.getBoundingClientRect() ?? { top: 0 };
559 const selRect = range.getBoundingClientRect();
560 const top = selRect.bottom - containerRect.top + 4;
561
562 if ( anchor ) {
563 setLinkPopover( { mode: 'view', href: anchor.href, anchorEl: anchor, top } );
564 } else {
565 setLinkPopover( { mode: 'insert', href: 'https://', anchorEl: null, top } );
566 }
567 }, [] );
568
569 // Restore saved selection, then apply execCommand
570 const restoreAndExec = useCallback( ( command, arg = null ) => {
571 editorRef.current?.focus();
572 if ( savedRangeRef.current ) {
573 const sel = window.getSelection();
574 sel.removeAllRanges();
575 sel.addRange( savedRangeRef.current );
576 }
577 document.execCommand( command, false, arg );
578 handleInput();
579 updateActiveState();
580 }, [ handleInput, updateActiveState ] );
581
582 const handleLinkSave = useCallback( ( href ) => {
583 setLinkPopover( null );
584 if ( href && href !== 'https://' ) {
585 restoreAndExec( 'createLink', href );
586 }
587 }, [ restoreAndExec ] );
588
589 const handleLinkRemove = useCallback( () => {
590 setLinkPopover( null );
591 // Select the full anchor node before unlinking
592 const anchor = linkPopover?.anchorEl;
593 if ( anchor && editorRef.current?.contains( anchor ) ) {
594 editorRef.current.focus();
595 const range = document.createRange();
596 range.selectNode( anchor );
597 const sel = window.getSelection();
598 sel.removeAllRanges();
599 sel.addRange( range );
600 document.execCommand( 'unlink', false, null );
601 handleInput();
602 }
603 }, [ linkPopover, handleInput ] );
604
605 const handleLinkEdit = useCallback( () => {
606 setLinkPopover( ( prev ) => ( { ...prev, mode: 'insert' } ) );
607 }, [] );
608
609 const handleBlockChange = useCallback( ( e ) => {
610 const tag = e.target.value;
611 editorRef.current?.focus();
612 document.execCommand( 'formatBlock', false, tag );
613 setBlockValue( tag );
614 }, [] );
615
616 const BTNS = [
617 { id: 'bold', icon: TB.bold, title: 'Bold (Ctrl+B)' },
618 { id: 'italic', icon: TB.italic,title: 'Italic (Ctrl+I)' },
619 { id: 'underline', icon: TB.under, title: 'Underline (Ctrl+U)' },
620 { id: 'insertOrderedList', icon: TB.ol, title: 'Ordered list' },
621 { id: 'insertUnorderedList', icon: TB.ul, title: 'Unordered list' },
622 { id: 'removeFormat', icon: TB.clear, title: 'Clear formatting' },
623 { id: 'link', icon: TB.link, title: 'Insert link', isLink: true },
624 ];
625
626 return (
627 <div className="bp-cb-rich-text" ref={ containerRef }>
628 { /* Row 1: block style dropdown */ }
629 <div className="bp-cb-rich-text__toolbar bp-cb-rich-text__toolbar--row1">
630 <select
631 className="bp-cb-rich-text__block-select"
632 value={ blockValue }
633 onChange={ handleBlockChange }
634 >
635 { BLOCKS.map( ( b ) => (
636 <option key={ b.value } value={ b.value }>{ b.label }</option>
637 ) ) }
638 </select>
639 </div>
640
641 { /* Row 2: format buttons */ }
642 <div className="bp-cb-rich-text__toolbar bp-cb-rich-text__toolbar--row2">
643 { BTNS.map( ( btn ) => (
644 <button
645 key={ btn.id }
646 type="button"
647 title={ btn.title }
648 className={ `bp-cb-rich-text__tool-btn${ activeFormats[ btn.id ] ? ' is-active' : '' }` }
649 onMouseDown={ btn.isLink ? openLinkPopover : ( e ) => { e.preventDefault(); cmd( btn.id ); } }
650 >
651 { btn.icon }
652 </button>
653 ) ) }
654 </div>
655
656 { /* Inline link popover */ }
657 { linkPopover && (
658 <div
659 className={ `bp-cb-rich-text__link-popover bp-cb-rich-text__link-popover--${ linkPopover.mode }` }
660 style={ { top: linkPopover.top } }
661 onKeyDown={ ( e ) => { if ( e.key === 'Escape' ) setLinkPopover( null ); } }
662 >
663 { linkPopover.mode === 'insert' ? (
664 <>
665 <span className="bp-cb-rich-text__link-label">
666 { __( 'Enter link:', 'better-payment' ) }
667 </span>
668 <input
669 ref={ linkInputRef }
670 type="url"
671 className="bp-cb-rich-text__link-input"
672 defaultValue={ linkPopover.href }
673 onKeyDown={ ( e ) => {
674 if ( e.key === 'Enter' ) { e.preventDefault(); handleLinkSave( e.target.value ); }
675 } }
676 />
677 <button
678 type="button"
679 className="bp-cb-rich-text__link-save"
680 onMouseDown={ ( e ) => { e.preventDefault(); handleLinkSave( linkInputRef.current?.value ); } }
681 >
682 { __( 'Save', 'better-payment' ) }
683 </button>
684 </>
685 ) : (
686 <>
687 <a
688 href={ linkPopover.href }
689 className="bp-cb-rich-text__link-visit"
690 target="_blank"
691 rel="noreferrer"
692 >
693 { linkPopover.href.length > 40 ? linkPopover.href.slice( 0, 40 ) + '…' : linkPopover.href }
694 </a>
695 <span className="bp-cb-rich-text__link-sep">|</span>
696 <button type="button" className="bp-cb-rich-text__link-action" onMouseDown={ ( e ) => { e.preventDefault(); handleLinkEdit(); } }>
697 { __( 'Edit', 'better-payment' ) }
698 </button>
699 <span className="bp-cb-rich-text__link-sep">|</span>
700 <button type="button" className="bp-cb-rich-text__link-action bp-cb-rich-text__link-action--remove" onMouseDown={ ( e ) => { e.preventDefault(); handleLinkRemove(); } }>
701 { __( 'Remove', 'better-payment' ) }
702 </button>
703 </>
704 ) }
705 </div>
706 ) }
707
708 <div
709 ref={ editorRef }
710 className="bp-cb-rich-text__editor"
711 contentEditable
712 suppressContentEditableWarning
713 data-placeholder={ __( 'Write a description for your campaign…', 'better-payment' ) }
714 onFocus={ () => { focusedRef.current = true; } }
715 onBlur={ () => { focusedRef.current = false; handleInput(); } }
716 onInput={ handleInput }
717 onKeyUp={ updateActiveState }
718 onMouseUp={ updateActiveState }
719 />
720 </div>
721 );
722 }
723
724 function ImageUploadControl( { value, onMultiChange, elementSettings } ) {
725 const openMedia = useCallback( () => {
726 openWpMedia( ( { url, id, sizes } ) =>
727 onMultiChange( { src: url, src_id: id, src_sizes: sizes } )
728 );
729 }, [ onMultiChange ] );
730
731 const srcSizes = elementSettings?.src_sizes || {};
732 const size = elementSettings?.size || 'full';
733 const displaySrc = ( size !== 'full' && srcSizes[ size ]?.url ) ? srcSizes[ size ].url : ( value || '' );
734 const hasImage = !! displaySrc;
735
736 return (
737 <div className="bp-cb-image-upload">
738
739 { /* Elementor-style preview area */ }
740 <div className="bp-cb-image-picker" onClick={ openMedia }>
741 { hasImage ? (
742 <>
743 <img src={ displaySrc } alt="" className="bp-cb-image-picker__img" />
744 <button
745 type="button"
746 className="bp-cb-image-picker__delete"
747 title={ __( 'Remove image', 'better-payment' ) }
748 onClick={ ( e ) => { e.stopPropagation(); onMultiChange( { src: '', src_id: 0, src_sizes: {} } ); } }
749 >
750 <svg width="14" height="14" viewBox="0 0 16 16" fill="none">
751 <path d="M2 4h12M5.333 4V2.667C5.333 2.313 5.473 1.974 5.724 1.724C5.974 1.473 6.313 1.333 6.667 1.333H9.333C9.687 1.333 10.026 1.473 10.276 1.724C10.527 1.974 10.667 2.313 10.667 2.667V4M12.667 4L12 13.333C12 13.687 11.86 14.026 11.609 14.276C11.359 14.527 11.02 14.667 10.667 14.667H5.333C4.98 14.667 4.641 14.527 4.391 14.276C4.14 14.026 4 13.687 4 13.333L3.333 4" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"/>
752 </svg>
753 </button>
754 </>
755 ) : (
756 <div className="bp-cb-image-picker__placeholder">
757 <svg width="24" height="24" viewBox="0 0 24 24" fill="none">
758 <circle cx="12" cy="12" r="11" stroke="currentColor" strokeWidth="1.5"/>
759 <path d="M12 7v10M7 12h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
760 </svg>
761 </div>
762 ) }
763
764 { /* Choose Image bar */ }
765 <div className="bp-cb-image-picker__bar" onClick={ ( e ) => e.stopPropagation() }>
766 <button type="button" className="bp-cb-image-picker__choose" onClick={ openMedia }>
767 { __( 'Choose Image', 'better-payment' ) }
768 </button>
769 </div>
770 </div>
771
772 { /* URL input + Clear */ }
773 <input
774 type="url"
775 className="bp-cb-image-upload__url"
776 value={ value ?? '' }
777 placeholder="https://"
778 onChange={ ( e ) => onMultiChange( { src: e.target.value, src_id: 0, src_sizes: {} } ) }
779 />
780 { hasImage && (
781 <button
782 type="button"
783 className="bp-cb-image-upload__btn bp-cb-image-upload__btn--clear"
784 onClick={ () => onMultiChange( { src: '', src_id: 0, src_sizes: {} } ) }
785 >
786 { __( 'Clear', 'better-payment' ) }
787 </button>
788 ) }
789 </div>
790 );
791 }
792
793 function SettingsControl( { control, value, onChange, onMultiChange, elementSettings } ) {
794 const { key, label, type, placeholder, min, max, rows, options, info, unit, step } = control;
795
796 if ( type === 'section_label' ) {
797 return (
798 <div className="bp-cb-section-label">
799 <span>{ label }</span>
800 </div>
801 );
802 }
803
804 if ( type === 'note' ) {
805 return <p className="bp-cb-widget-note">{ label }</p>;
806 }
807
808 return (
809 <div className="bp-cb-control">
810 { type !== 'toggle' && type !== 'switch' && type !== 'image_upload' && type !== 'rich_text' && (
811 <label className="bp-cb-field-label" htmlFor={ `ctrl-${ key }` }>
812 { label }
813 </label>
814 ) }
815
816 { type === 'image_upload' && (
817 <>
818 <label className="bp-cb-field-label">
819 { label }
820 </label>
821 <ImageUploadControl value={ value } onMultiChange={ onMultiChange } elementSettings={ elementSettings } />
822 </>
823 ) }
824
825 { type === 'rich_text' && (
826 <>
827 <label className="bp-cb-field-label">
828 { label }
829 </label>
830 <RichTextControl value={ value } onChange={ onChange } />
831 </>
832 ) }
833
834 { type === 'range' && (
835 <div className="bp-cb-range-wrap">
836 <input
837 id={ `ctrl-${ key }` }
838 type="range"
839 min={ min ?? 10 }
840 max={ max ?? 100 }
841 step={ step ?? 1 }
842 value={ value ?? ( control.defaultValue ?? 100 ) }
843 onChange={ ( e ) => onChange( parseInt( e.target.value, 10 ) ) }
844 />
845 <span className="bp-cb-range-value">
846 { value ?? ( control.defaultValue ?? 100 ) }{ unit || '%' }
847 </span>
848 </div>
849 ) }
850
851 { type === 'align' && (
852 <div className="bp-cb-align-group">
853 { ALIGN_OPTIONS.map( ( opt ) => (
854 <button
855 key={ opt.value }
856 type="button"
857 title={ opt.title }
858 className={ `bp-cb-align-btn${ ( value || 'left' ) === opt.value ? ' is-active' : '' }` }
859 onClick={ () => onChange( opt.value ) }
860 >
861 { opt.icon }
862 </button>
863 ) ) }
864 </div>
865 ) }
866
867 { type === 'text' && (
868 <input
869 id={ `ctrl-${ key }` }
870 type="text"
871 value={ value ?? '' }
872 placeholder={ placeholder || '' }
873 onChange={ ( e ) => onChange( e.target.value ) }
874 />
875 ) }
876
877 { type === 'url' && (
878 <input
879 id={ `ctrl-${ key }` }
880 type="url"
881 value={ value ?? '' }
882 placeholder={ placeholder || 'https://' }
883 onChange={ ( e ) => onChange( e.target.value ) }
884 />
885 ) }
886
887 { type === 'number' && (
888 <input
889 id={ `ctrl-${ key }` }
890 type="number"
891 value={ value ?? '' }
892 min={ min }
893 max={ max }
894 onChange={ ( e ) => onChange( parseInt( e.target.value, 10 ) ) }
895 />
896 ) }
897
898 { type === 'color' && (
899 <div className="bp-color-row">
900 <input
901 id={ `ctrl-${ key }` }
902 type="color"
903 value={ value || '#6b63f6' }
904 onChange={ ( e ) => onChange( e.target.value ) }
905 />
906 <span className="bp-color-value">{ value || '#6b63f6' }</span>
907 </div>
908 ) }
909
910 { type === 'toggle' && (
911 <label className="bp-cb-toggle-label" htmlFor={ `ctrl-${ key }` }>
912 <input
913 id={ `ctrl-${ key }` }
914 type="checkbox"
915 checked={ !! value }
916 onChange={ ( e ) => onChange( e.target.checked ) }
917 />
918 <span>{ label }</span>
919 </label>
920 ) }
921
922 { type === 'switch' && (
923 <label className="bp-cb-switch-label" htmlFor={ `ctrl-${ key }` }>
924 <span className="bp-cb-switch-wrap">
925 <input
926 id={ `ctrl-${ key }` }
927 type="checkbox"
928 checked={ !! value }
929 onChange={ ( e ) => onChange( e.target.checked ) }
930 />
931 <span className="bp-cb-switch-track" />
932 </span>
933 <span>{ label }</span>
934 </label>
935 ) }
936
937 { type === 'select' && (
938 <select
939 id={ `ctrl-${ key }` }
940 value={ value ?? '' }
941 onChange={ ( e ) => onChange( e.target.value ) }
942 >
943 { ( options || [] ).map( ( opt ) => (
944 <option key={ opt.value } value={ opt.value }>{ opt.label }</option>
945 ) ) }
946 </select>
947 ) }
948
949 { type === 'textarea' && (
950 <textarea
951 id={ `ctrl-${ key }` }
952 rows={ rows || 4 }
953 value={ value ?? '' }
954 placeholder={ placeholder || '' }
955 onChange={ ( e ) => onChange( e.target.value ) }
956 />
957 ) }
958
959 { type === 'suggested_amounts' && (
960 <SuggestedAmountsEditor
961 items={ Array.isArray( value ) ? value : [] }
962 onChange={ onChange }
963 compact
964 />
965 ) }
966 </div>
967 );
968 }
969