import { useState, useEffect, useCallback } from '@wordpress/element'; import apiFetch from '@wordpress/api-fetch'; import { __ } from '@wordpress/i18n'; const CUSTOM_ICONS = { progress_bar: ( ), donation_form: ( ), }; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { getElement } from '../fields/FieldRegistry'; import { openWpMedia } from '../utils/media'; import { getGlobalCurrencySymbol } from '../utils/currency'; // Mirror of RendererService::scale_inline_font_sizes() — bumps every explicit // px font-size by +2px so editor preview matches the 16px-base frontend. function scaleInlineFontSizes( html ) { if ( ! html ) return html; return html.replace( /\bfont-size\s*:\s*(\d+(?:\.\d+)?)px/gi, ( _, size ) => { return `font-size: ${ parseFloat( size ) + 2 }px`; } ); } /** * A single element card on the canvas — sortable via @dnd-kit/sortable. * Elements that have a live preview render their content directly; * all others fall back to the generic icon + label row. */ export default function ElementCard( { element, columnId, index, totalElements, meta, campaignId, isSelected, dispatch, isDropTarget } ) { const def = getElement( element.type ); const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable( { id: element.id, data: { source: 'canvas', columnId, element }, } ); const style = { transform: CSS.Transform.toString( transform ), transition, opacity: isDragging ? 0.4 : 1, }; const handleClick = ( e ) => { e.stopPropagation(); if ( isSelected ) { dispatch( { type: 'DESELECT_ELEMENT' } ); } else { dispatch( { type: 'SELECT_ELEMENT', elementId: element.id, columnId } ); } }; const handleEdit = ( e ) => { e.stopPropagation(); dispatch( { type: 'SELECT_ELEMENT', elementId: element.id, columnId } ); }; const handleDelete = ( e ) => { e.stopPropagation(); dispatch( { type: 'REMOVE_ELEMENT', columnId, elementId: element.id } ); }; const handleMoveUp = ( e ) => { e.stopPropagation(); dispatch( { type: 'REORDER_ELEMENTS', columnId, oldIndex: index, newIndex: index - 1 } ); }; const handleMoveDown = ( e ) => { e.stopPropagation(); dispatch( { type: 'REORDER_ELEMENTS', columnId, oldIndex: index, newIndex: index + 1 } ); }; const isFirst = index === 0; const isLast = index === totalElements - 1; const preview = getElementPreview( element, meta, dispatch, campaignId, columnId ); return (
{ /* Floating hover toolbar */ }
{ ! isFirst && ( ) } { ! isLast && ( ) }
{ /* Non-preview fallback body */ } { ! preview && (
{ CUSTOM_ICONS[ element.type ] ? { CUSTOM_ICONS[ element.type ] } : } { def?.label || element.type }
) } { /* Live preview body */ } { preview && (
{ preview }
) }
); } // ── Per-element live previews ───────────────────────────────────────────────── function getElementPreview( element, meta, dispatch, campaignId, columnId ) { switch ( element.type ) { case 'campaign_title': return ; case 'campaign_description': return ; case 'photo': return ; case 'social_links': return ; case 'social_sharing': return ; case 'progress_bar': return ; case 'donate_amount': return ; case 'donation_form': return ; case 'campaign_summary': return ; case 'organizer': return ; default: return null; } } // ── Campaign Summary preview ────────────────────────────────────────────────── function CampaignSummaryPreview( { element, meta, campaignId } ) { const s = element.settings || {}; const headline = s.headline || ''; const showRaised = s.show_raised !== false; const showDonors = s.show_donors !== false; const showPercent = s.show_percent !== false; const showDays = s.show_days !== false; const [ stats, setStats ] = useState( null ); useEffect( () => { if ( ! campaignId ) return; apiFetch( { path: `better-payment/v1/campaigns/${ campaignId }/stats` } ) .then( ( data ) => setStats( data ) ) .catch( () => {} ); }, [ campaignId ] ); const raised = stats ? parseFloat( stats.total_raised ) : 0; const donors = stats ? stats.donor_count : 0; const goal = parseFloat( meta?.bpc_goal_amount ) || 0; const days = meta?.bpc_end_date ? Math.max( 0, Math.ceil( ( new Date( meta.bpc_end_date ) - new Date() ) / 86400000 ) ) : 0; const percent = goal > 0 ? Math.min( 100, Math.round( ( raised / goal ) * 1000 ) / 10 ) : 0; const currencySymbol = getGlobalCurrencySymbol(); const raisedFormatted = `${ currencySymbol }${ Number( raised ).toLocaleString( 'en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 } ) }`; return (
{ headline && (

{ headline }

) }
{ showRaised && (
{ raisedFormatted } { __( 'Raised', 'better-payment' ) }
) } { showDonors && (
{ donors } { __( 'Donors', 'better-payment' ) }
) } { showPercent && (
{ percent }% { __( 'Funded', 'better-payment' ) }
) } { showDays && (
{ days } { __( 'Days Left', 'better-payment' ) }
) }
); } // ── Campaign Description preview ───────────────────────────────────────────── function CampaignDescriptionPreview( { element } ) { const s = element.settings || {}; const headline = s.headline || ''; const content = s.content || ''; const width = s.width ?? 100; const align = s.align || 'left'; const wrapStyle = { width: `${ width }%`, textAlign: align, margin: align === 'center' ? '0 auto' : align === 'right' ? '0 0 0 auto' : '0', }; if ( ! headline && ! content ) { return (
{ __( 'Campaign Description', 'better-payment' ) }
); } return (
{ headline && (

{ headline }

) } { content && (
) }
); } // ── Progress Bar preview ────────────────────────────────────────────────────── function ProgressBarPreview( { element, meta, campaignId } ) { const s = element.settings || {}; const headline = s.headline || ''; const showDonated = s.show_donated !== false; const showGoal = s.show_goal !== false; const donateLabel = s.donate_label || 'Donated:'; const goalLabel = s.goal_label || 'Goal:'; const width = s.width ?? 100; const align = s.align || 'left'; const primary = meta?.bpc_color_primary || '#6b63f6'; const [ stats, setStats ] = useState( null ); useEffect( () => { if ( ! campaignId ) return; apiFetch( { path: `better-payment/v1/campaigns/${ campaignId }/stats` } ) .then( ( data ) => setStats( data ) ) .catch( () => {} ); }, [ campaignId ] ); const raised = stats ? stats.total_raised : 0; const goal = parseFloat( meta?.bpc_goal_amount ) || 0; const progress = goal > 0 ? Math.min( 100, ( raised / goal ) * 100 ) : 0; const wrapStyle = { width: `${ width }%`, margin: align === 'center' ? '0 auto' : align === 'right' ? '0 0 0 auto' : '0', }; const roundAmounts = s.round_amounts || false; const currencySymbol = getGlobalCurrencySymbol(); // Display progress: ceiling integer when round_amounts, else 1 decimal from API. const rawRatio = goal > 0 ? Math.min( 100, ( raised / goal ) * 100 ) : 0; const displayProgress = roundAmounts ? Math.ceil( rawRatio ) : progress; // 1-decimal float from CampaignStats const goalFormatted = currencySymbol + ( roundAmounts ? Math.ceil( goal ).toLocaleString() : Number( goal ).toFixed( 2 ) ); return (
{ headline && (

{ headline }

) }
{ ( showDonated || showGoal ) && (
{ showDonated && ( { donateLabel } { displayProgress }% ) } { showGoal && ( { goalLabel } { goalFormatted } ) }
) }
); } // ── Photo preview ───────────────────────────────────────────────────────────── function PhotoPreview( { element, columnId, dispatch } ) { const s = element.settings || {}; const handleImageClick = useCallback( ( e ) => { e.stopPropagation(); openWpMedia( ( { url, id, sizes } ) => { dispatch( { type: 'UPDATE_ELEMENT_SETTINGS', elementId: element.id, settings: { src: url, src_id: id, src_sizes: sizes }, } ); } ); }, [ element.id, dispatch ] ); const src = s.src || ''; const alt = s.alt || ''; const width = s.width ?? 100; const align = s.align || 'center'; const size = s.size || 'full'; const sizes = s.src_sizes || {}; const srcId = s.src_id || 0; // Keyed by attachment ID — fetched once per ID, cached for all size changes. const [ fetchedSizes, setFetchedSizes ] = useState( {} ); useEffect( () => { if ( ! srcId ) { setFetchedSizes( {} ); return; } let cancelled = false; apiFetch( { path: `/wp/v2/media/${ srcId }` } ) .then( ( data ) => { if ( cancelled ) return; const raw = data?.media_details?.sizes || {}; const mapped = {}; Object.keys( raw ).forEach( ( k ) => { mapped[ k ] = raw[ k ].source_url; } ); setFetchedSizes( mapped ); } ) .catch( () => {} ); return () => { cancelled = true; }; }, [ srcId ] ); // Priority: src_sizes (from media modal) → fetched from REST API → original src. const resolvedUrl = size !== 'full' ? ( sizes[ size ]?.url || fetchedSizes[ size ] || src ) : src; const wrapStyle = { textAlign: align }; const imgStyle = { width: 'auto', maxWidth: `${ width }%`, height: 'auto', display: 'block', borderRadius: '4px', margin: align === 'center' ? '0 auto' : align === 'right' ? '0 0 0 auto' : '0', }; if ( ! src ) { return (
); } return (
{
); } // ── Social Links preview ────────────────────────────────────────────────────── const SOCIAL_ICONS = { twitter: ( ), facebook: ( ), linkedin: ( ), instagram: ( ), tiktok: ( ), pinterest: ( ), youtube: ( ), threads: ( ), bluesky: ( ), mastodon: ( ), }; const SOCIAL_ORDER = [ 'twitter', 'facebook', 'linkedin', 'instagram', 'tiktok', 'pinterest', 'youtube', 'threads', 'bluesky', 'mastodon' ]; const SOCIAL_SHARING_ORDER = [ 'twitter', 'facebook', 'linkedin', 'pinterest', 'mastodon', 'threads', 'bluesky' ]; function SocialLinksPreview( { element } ) { const s = element.settings || {}; const headline = s.headline || 'Follow Now'; const align = s.align || 'left'; const activeIcons = SOCIAL_ORDER.filter( ( key ) => !! s[ key ] ); return (
{ headline && (

{ headline }

) } { activeIcons.length > 0 && (
{ activeIcons.map( ( key ) => ( { SOCIAL_ICONS[ key ] } ) ) }
) }
); } function SocialSharingPreview( { element } ) { const s = element.settings || {}; const headline = s.headline !== undefined && s.headline !== null ? s.headline : 'Share Now'; const align = s.align || 'left'; const activeIcons = SOCIAL_SHARING_ORDER.filter( ( key ) => s[ key ] !== false ); return (
{ headline && (

{ headline }

) } { activeIcons.length > 0 && (
{ activeIcons.map( ( key ) => ( { SOCIAL_ICONS[ key ] } ) ) }
) }
); } // ── Campaign Title preview ──────────────────────────────────────────────────── function CampaignTitlePreview( { element, meta } ) { const s = element.settings || {}; const title = meta?.title ?? ''; const align = s.align || 'left'; return (

{ title }

); } // ── Donate Amount preview ───────────────────────────────────────────────────── function hexToRgba( hex, alpha ) { if ( ! hex || hex.length < 7 ) return `rgba(107,99,246,${ alpha })`; const r = parseInt( hex.slice( 1, 3 ), 16 ); const g = parseInt( hex.slice( 3, 5 ), 16 ); const b = parseInt( hex.slice( 5, 7 ), 16 ); return `rgba(${ r },${ g },${ b },${ alpha })`; } function DonateAmountPreview( { element, meta, dispatch } ) { const s = element.settings || {}; const headline = s.headline || ''; const amounts = Array.isArray( meta?.bpc_suggested_amounts ) ? meta.bpc_suggested_amounts : []; const allowCustom = !! meta?.bpc_allow_custom_amount; const primary = meta?.bpc_color_primary || '#6b63f6'; const globalCurrencySymbol = getGlobalCurrencySymbol(); const [ customValue, setCustomValue ] = useState( '' ); const deselectAll = () => dispatch( { type: 'UPDATE_META', payload: { bpc_suggested_amounts: amounts.map( ( a ) => ( { ...a, is_default: false } ) ), }, } ); const handleSelectAmount = ( id, e ) => { e.stopPropagation(); setCustomValue( '' ); dispatch( { type: 'UPDATE_META', payload: { bpc_suggested_amounts: amounts.map( ( a ) => ( { ...a, is_default: a.id === id } ) ), }, } ); }; const handleCustomFocus = ( e ) => { e.stopPropagation(); deselectAll(); }; const handleCustomChange = ( e ) => { setCustomValue( e.target.value ); }; if ( amounts.length === 0 && ! allowCustom ) { return (
{ __( 'No suggested amounts yet — add some in the element settings', 'better-payment' ) }
); } const anySelected = amounts.some( ( a ) => a.is_default ); const customActive = allowCustom && ! anySelected && customValue !== ''; return (
{ headline && (

{ headline }

) } { amounts.length > 0 && (
{ amounts.map( ( item ) => { const selected = !! item.is_default; const btnStyle = selected ? { borderColor: primary, color: primary, backgroundColor: hexToRgba( primary, 0.08 ), } : {}; return ( ); } ) }
) } { allowCustom && (
e.stopPropagation() } > { globalCurrencySymbol }
) }
); } // ── Donation Form (Donate Button) preview ───────────────────────────────────── function DonationFormPreview( { element, meta } ) { const s = element.settings || {}; const label = s.button_label || s.button_text || 'Donate Now'; const primary = meta?.bpc_color_primary || '#6b63f6'; const buttonColor = s.button_color || primary; const width = s.width ?? 100; const align = s.align || 'center'; const wrapStyle = { width: `${ width }%`, margin: align === 'center' ? '0 auto' : align === 'right' ? '0 0 0 auto' : '0', }; return (
{ label }
); } // ── Organizer preview ───────────────────────────────────────────────────────── function OrganizerPreview( { element } ) { const s = element.settings || {}; const roleTitle = s.role_title || 'Organizer'; const description = s.description || ''; const width = s.width ?? 100; const align = s.align || 'left'; // Resolve display name + avatar from the localized element registry options. const def = getElement( 'organizer' ); const creatorOptions = def?.settingsSchema?.find( ( c ) => c.key === 'creator_user_id' )?.options || []; const creator = creatorOptions.find( ( o ) => String( o.value ) === String( s.creator_user_id ) ); const displayName = creator?.label || ''; const avatarUrl = creator?.avatar_url || ''; const wrapStyle = { width: `${ width }%`, margin: align === 'center' ? '0 auto' : align === 'right' ? '0 0 0 auto' : '0', }; return (
{ avatarUrl ? { : }
{ displayName && ( { displayName } ) } { roleTitle && ( { roleTitle } ) } { description && (
) }
); }