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 && (
) }
);
}