PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.2.1
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.2.1
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.1, at src/campaign-builder/components/RightSidebar.js

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