array(
'name' => 'better-payment/payment-form',
'path' => 'payment-form',
'has_styles' => true,
'has_scripts' => true,
'render_callback' => 'render_payment_form_frontend',
'editor_data' => 'get_payment_form_editor_data',
),
'user-dashboard' => array(
'name' => 'better-payment/user-dashboard',
'path' => 'user-dashboard',
'has_styles' => true,
'has_scripts' => true,
'render_callback' => 'render_user_dashboard_frontend',
'editor_data' => 'get_user_dashboard_editor_data',
),
);
/**
* Get the singleton instance.
*
* @return BlockManager
*/
public static function get_instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Private constructor to prevent direct instantiation.
*/
private function __construct() {
$this->init_hooks();
}
/**
* Initialize hooks.
*
* @return void
*/
private function init_hooks() {
add_action( 'init', array( $this, 'register_blocks' ) );
add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_editor_assets' ) );
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_frontend_assets' ) );
add_filter( 'block_categories_all', array( $this, 'register_block_category' ), 10, 2 );
}
/**
* Render callback for the payment form block frontend.
*
* This method contains all the rendering logic including layout template loading,
* settings mapping, widget proxy object creation, and security measures.
*
* @param array $attributes Block attributes.
* @param string $content Block default content.
* @param WP_Block $block Block instance.
* @return string The rendered HTML output.
*/
public function render_payment_form_frontend( $attributes, $content = '', $block = null ) {
// Get block attributes with defaults.
$form_layout = isset( $attributes['formLayout'] ) ? sanitize_text_field( $attributes['formLayout'] ) : 'layout-1';
// Validate layout - only allow layout-1, layout-2, layout-3.
$allowed_layouts = array( 'layout-1', 'layout-2', 'layout-3' );
if ( ! in_array( $form_layout, $allowed_layouts, true ) ) {
$form_layout = 'layout-1';
}
// Check if layout file exists, fallback to layout-1 if not.
$template_file = BETTER_PAYMENT_ADMIN_VIEWS_PATH . '/elementor/layouts-block/' . $form_layout . '.php';
if ( ! file_exists( $template_file ) ) {
$template_file = BETTER_PAYMENT_ADMIN_VIEWS_PATH . '/elementor/layouts-block/layout-1.php';
if ( ! file_exists( $template_file ) ) {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( 'Better Payment: Layout template not found at ' . $template_file );
}
return '
' .
esc_html__( 'Payment form template not found.', 'better-payment' ) .
'
';
}
}
// Get global settings from database.
$global_settings = DB::get_settings();
// Generate a unique ID for this block instance.
// Use a deterministic ID based on block attributes to ensure consistency across page loads.
// This is critical for payment callback matching.
$block_id = isset( $attributes['blockId'] ) && ! empty( $attributes['blockId'] )
? sanitize_text_field( $attributes['blockId'] )
: 'bp-block-' . md5( get_the_ID() . wp_json_encode( $attributes ) );
// Build the $settings array that the layout files expect.
// This maps block attributes to the Elementor widget settings format.
$settings = $this->build_block_settings( $attributes, $global_settings, $form_layout );
// Check if at least one payment gateway is enabled.
$has_payment_gateway = 'yes' === $settings['better_payment_form_paypal_enable'] ||
'yes' === $settings['better_payment_form_stripe_enable'] ||
'yes' === $settings['better_payment_form_paystack_enable'];
// Build wrapper attributes using WordPress's block wrapper API.
// get_block_wrapper_attributes() reads supports.align from the block context
// (set automatically by WP_Block before invoking this render callback) and adds
// alignwide / alignfull to the class list — no manual $attributes['align'] needed.
$wrapper_class = 'better-payment-block bp-form-' . esc_attr( $form_layout );
$wrapper_attributes = get_block_wrapper_attributes( array( 'class' => $wrapper_class ) );
if ( ! $has_payment_gateway ) {
// A visitor gets nothing: the block has no form to show, and the
// instruction that follows is addressed to whoever can act on it.
// It used to be printed for everyone, so a public page advertised
// that the site owner had not finished setting up payments and named
// the admin screen where it is done.
if ( ! current_user_can( 'manage_options' ) ) {
return '';
}
return '
>
create_user_dashboard_widget_proxy( $block_id, $settings );
// Create a closure to include the template with isolated scope
$render_layout = function ( $template_file, $settings, $widgetObj, $extraDatas ) {
// Make $bp_settings available to templates (templates expect this variable)
$bp_settings = $settings;
include $template_file;
};
// Bind the closure to the widget proxy so $this works in the template
$bound_render = \Closure::bind( $render_layout, $widgetObj, get_class( $widgetObj ) );
$bound_render( $template_file, $settings, $widgetObj, $extraDatas );
?>
' . esc_html__( 'This form is not part of this page’s content. Open the template, template part, pattern or widget area that contains it to change its settings.', 'better-payment' ) . '
';
}
}
return '
' .
'
' . esc_html( $message ) . ' ' . esc_html__( 'This notice is only visible to you.', 'better-payment' ) . '
' .
$hint .
( $links ? '
' . implode( ' ', $links ) . '
' : '' ) .
'
';
}
/**
* The editor URL of the current post, when it holds a Payment Form block.
*
* Empty when there is no current post, the user may not edit it, or its
* content has no Payment Form block (the block came from a template, template
* part, synced pattern or widget area).
*
* @return string Raw edit URL, or ''.
*/
private static function block_edit_url() {
$post_id = (int) get_the_ID();
if ( $post_id <= 0 || ! current_user_can( 'edit_post', $post_id ) ) {
return '';
}
if ( ! has_block( 'better-payment/payment-form', $post_id ) ) {
return '';
}
return (string) get_edit_post_link( $post_id, 'raw' );
}
/**
* "A", "A or B", "A, B or C".
*
* @param string[] $names Gateway names (brand names, not translated).
* @return string Joined list.
*/
private static function join_gateway_names( $names ) {
$names = array_values( $names );
$last = array_pop( $names );
if ( empty( $names ) ) {
return (string) $last;
}
return sprintf(
/* translators: 1: comma-separated gateway names, 2: the last gateway name. */
__( '%1$s or %2$s', 'better-payment' ),
implode( ', ', $names ),
$last
);
}
/**
* Resolve the correct Stripe key based on live mode setting.
*
* This matches Elementor's behavior where the key is resolved before being stored
* in better_payment_stripe_public_key/better_payment_stripe_secret_key.
* Handler::stripe_payment_success() uses better_payment_stripe_secret_key directly.
*
* @param array $global_settings Global settings array.
* @param string $key_type Either 'public' or 'secret'.
* @return string The resolved key.
*/
private function resolve_stripe_key( $global_settings, $key_type ) {
$is_live_mode = ! empty( $global_settings['better_payment_settings_payment_stripe_live_mode'] )
&& 'yes' === $global_settings['better_payment_settings_payment_stripe_live_mode'];
if ( 'public' === $key_type ) {
return $is_live_mode
? ( ! empty( $global_settings['better_payment_settings_payment_stripe_live_public'] ) ? $global_settings['better_payment_settings_payment_stripe_live_public'] : '' )
: ( ! empty( $global_settings['better_payment_settings_payment_stripe_test_public'] ) ? $global_settings['better_payment_settings_payment_stripe_test_public'] : '' );
} else {
return $is_live_mode
? ( ! empty( $global_settings['better_payment_settings_payment_stripe_live_secret'] ) ? $global_settings['better_payment_settings_payment_stripe_live_secret'] : '' )
: ( ! empty( $global_settings['better_payment_settings_payment_stripe_test_secret'] ) ? $global_settings['better_payment_settings_payment_stripe_test_secret'] : '' );
}
}
/**
* Resolve the correct Paystack key based on live mode setting.
*
* This matches Elementor's behavior where the key is resolved before being stored
* in better_payment_paystack_public_key/better_payment_paystack_secret_key.
*
* @param array $global_settings Global settings array.
* @param string $key_type Either 'public' or 'secret'.
* @return string The resolved key.
*/
private function resolve_paystack_key( $global_settings, $key_type ) {
$is_live_mode = ! empty( $global_settings['better_payment_settings_payment_paystack_live_mode'] )
&& 'yes' === $global_settings['better_payment_settings_payment_paystack_live_mode'];
if ( 'public' === $key_type ) {
return $is_live_mode
? ( ! empty( $global_settings['better_payment_settings_payment_paystack_live_public'] ) ? $global_settings['better_payment_settings_payment_paystack_live_public'] : '' )
: ( ! empty( $global_settings['better_payment_settings_payment_paystack_test_public'] ) ? $global_settings['better_payment_settings_payment_paystack_test_public'] : '' );
} else {
return $is_live_mode
? ( ! empty( $global_settings['better_payment_settings_payment_paystack_live_secret'] ) ? $global_settings['better_payment_settings_payment_paystack_live_secret'] : '' )
: ( ! empty( $global_settings['better_payment_settings_payment_paystack_test_secret'] ) ? $global_settings['better_payment_settings_payment_paystack_test_secret'] : '' );
}
}
/**
* Sanitize a comma-separated list of email addresses.
*
* This method validates and sanitizes email addresses to prevent
* email header injection attacks and ensure only valid emails are used.
*
* @param string $email_list Comma-separated list of email addresses.
* @return string Sanitized comma-separated list of valid email addresses.
*/
private function sanitize_email_list( $email_list ) {
if ( empty( $email_list ) ) {
return '';
}
// Split by comma and sanitize each email.
$emails = array_map( 'trim', explode( ',', $email_list ) );
$valid_emails = array();
foreach ( $emails as $email ) {
$sanitized = sanitize_email( $email );
// Only include valid email addresses.
if ( ! empty( $sanitized ) && is_email( $sanitized ) ) {
$valid_emails[] = $sanitized;
}
}
return implode( ', ', $valid_emails );
}
/**
* Convert block formFields array to Elementor widget format.
*
* @param array $attributes Block attributes.
* @return array Converted form fields in Elementor format.
*/
private function convert_form_fields( $attributes ) {
$form_fields = array();
if ( empty( $attributes['formFields'] ) || ! is_array( $attributes['formFields'] ) ) {
// Return default form fields if none provided.
return array(
array(
'_id' => 'first_name_field',
'better_payment_primary_field_type' => 'primary_first_name',
'better_payment_field_name_heading' => __( 'First Name', 'better-payment' ),
'better_payment_field_name_placeholder' => __( 'First Name', 'better-payment' ),
'better_payment_field_type' => 'text',
'better_payment_field_icon' => array( 'value' => 'bp-icon bp-user', 'library' => '' ),
'better_payment_field_name_required' => '',
'better_payment_field_name_show' => 'yes',
),
array(
'_id' => 'last_name_field',
'better_payment_primary_field_type' => 'primary_last_name',
'better_payment_field_name_heading' => __( 'Last Name', 'better-payment' ),
'better_payment_field_name_placeholder' => __( 'Last Name', 'better-payment' ),
'better_payment_field_type' => 'text',
'better_payment_field_icon' => array( 'value' => 'bp-icon bp-user', 'library' => '' ),
'better_payment_field_name_required' => '',
'better_payment_field_name_show' => 'yes',
),
array(
'_id' => 'email_field',
'better_payment_primary_field_type' => 'primary_email',
'better_payment_field_name_heading' => __( 'Email', 'better-payment' ),
'better_payment_field_name_placeholder' => __( 'Email Address', 'better-payment' ),
'better_payment_field_type' => 'email',
'better_payment_field_icon' => array( 'value' => 'bp-icon bp-envelope', 'library' => '' ),
'better_payment_field_name_required' => 'yes',
'better_payment_field_name_show' => 'yes',
),
array(
'_id' => 'amount_field',
'better_payment_primary_field_type' => 'primary_payment_amount',
'better_payment_field_name_heading' => __( 'Amount', 'better-payment' ),
'better_payment_field_name_placeholder' => __( 'Payment Amount', 'better-payment' ),
'better_payment_field_type' => 'number',
'better_payment_field_icon' => array( 'value' => 'bp-icon bp-logo-2', 'library' => '' ),
'better_payment_field_name_required' => 'yes',
'better_payment_field_name_show' => 'yes',
'better_payment_field_name_min' => 1,
'better_payment_field_name_max' => '',
'better_payment_field_name_default' => '',
'better_payment_field_name_default_fixed' => '',
'better_payment_field_name_default_dynamic_enable' => '',
),
);
}
foreach ( $attributes['formFields'] as $index => $field ) {
$raw_icon_value = isset( $field['icon'] ) ? sanitize_text_field( $field['icon'] ) : 'bp-icon bp-user';
$icon_setting = $this->build_icon_control_setting( $raw_icon_value, 'bp-icon bp-user' );
$converted_field = array(
'_id' => 'field_' . $index,
'better_payment_primary_field_type' => isset( $field['primaryFieldType'] ) ? sanitize_text_field( $field['primaryFieldType'] ) : '',
'better_payment_field_name_heading' => isset( $field['label'] ) ? sanitize_text_field( $field['label'] ) : '',
'better_payment_field_name_placeholder' => isset( $field['placeholder'] ) ? sanitize_text_field( $field['placeholder'] ) : '',
'better_payment_field_type' => isset( $field['type'] ) ? sanitize_text_field( $field['type'] ) : 'text',
'better_payment_field_icon' => array(
'value' => $icon_setting['value'],
'library' => $icon_setting['library'],
),
'better_payment_field_name_required' => isset( $field['required'] ) && $field['required'] ? 'yes' : '',
'better_payment_field_name_show' => isset( $field['show'] ) && $field['show'] ? 'yes' : '',
'better_payment_field_name_display_inline' => isset( $field['displayInline'] ) && $field['displayInline'] ? 'inline-block' : '',
);
// Add amount field specific settings.
if ( isset( $field['primaryFieldType'] ) && 'primary_payment_amount' === $field['primaryFieldType'] ) {
$converted_field['better_payment_field_name_min'] = isset( $field['min'] ) ? intval( $field['min'] ) : 1;
$converted_field['better_payment_field_name_max'] = isset( $field['max'] ) ? intval( $field['max'] ) : '';
$converted_field['better_payment_field_name_default'] = isset( $field['default'] ) ? intval( $field['default'] ) : '';
$converted_field['better_payment_field_name_default_fixed'] = isset( $field['defaultFixed'] ) && $field['defaultFixed'] ? 'yes' : '';
$converted_field['better_payment_field_name_default_dynamic_enable'] = isset( $field['defaultDynamicEnable'] ) && $field['defaultDynamicEnable'] ? 'yes' : '';
}
$form_fields[] = $converted_field;
}
return $form_fields;
}
/**
* Convert block amountList array to Elementor widget format.
*
* @param array $attributes Block attributes.
* @return array Converted amount list in Elementor format.
*/
private function convert_amount_list( $attributes ) {
$amount_list = array();
if ( empty( $attributes['amountList'] ) || ! is_array( $attributes['amountList'] ) ) {
return array();
}
foreach ( $attributes['amountList'] as $index => $item ) {
if ( isset( $item['label'] ) && '' !== $item['label'] ) {
$amount_list[] = array(
'_id' => 'amount_' . $index,
'better_payment_amount_val' => sanitize_text_field( $item['label'] ),
);
}
}
return $amount_list;
}
/**
* Normalize icon classes for rendering compatibility.
*
* Dashicons require the base `dashicons` class plus the icon class.
*
* @param string $icon Icon class string.
* @return string
*/
private function normalize_icon_class( $icon ) {
$icon = trim( (string) $icon );
if ( '' === $icon ) {
return $icon;
}
if ( false !== strpos( $icon, 'dashicons-' ) && false === strpos( $icon, 'dashicons ' ) ) {
$icon = 'dashicons ' . $icon;
}
return $icon;
}
/**
* Convert block icon value to Elementor ICONS control format.
*
* Keeps default state empty-library so existing default notice icons are shown.
*
* @param string $icon_value Icon class or uploaded icon URL.
* @param string $default_value Default icon class for this control.
* @return array
*/
private function build_icon_control_setting( $icon_value, $default_value = '' ) {
$icon_value = trim( (string) $icon_value );
// Only return empty library when icon_value is truly empty (no selection)
// Don't treat icons matching the default as empty - they should render as custom icons
if ( '' === $icon_value ) {
return array(
'value' => '',
'library' => '',
);
}
// Check if the value is an image URL (SVG, PNG, JPG, etc.)
if ( $this->is_icon_url( $icon_value ) ) {
return array(
'value' => array(
'url' => esc_url_raw( $icon_value ),
),
'library' => 'svg',
);
}
// For all other non-empty values (custom CSS classes, FontAwesome, etc.)
// Detect the library type and return it so Handler can render correctly
return array(
'value' => $this->normalize_icon_class( $icon_value ),
'library' => $this->detect_icon_library( $icon_value ),
);
}
/**
* Build email logo setting from block email icon value.
*
* Email template expects a media-like structure with a `url` key.
*
* @param string $icon_value Icon class or uploaded icon URL.
* @param string $default_value Default icon class for this control.
* @return array
*/
private function build_email_logo_setting( $icon_value, $default_value = '' ) {
$icon_value = trim( (string) $icon_value );
if ( '' === $icon_value || ( '' !== $default_value && $icon_value === $default_value ) ) {
return array();
}
if ( $this->is_icon_url( $icon_value ) ) {
return array(
'url' => esc_url_raw( $icon_value ),
);
}
return array();
}
/**
* Detect Elementor-style icon library key from icon class string.
*
* @param string $icon_value Icon class.
* @return string
*/
private function detect_icon_library( $icon_value ) {
$icon_value = (string) $icon_value;
// Check for FontAwesome icons (has 'fa-' or 'fa ' prefix)
if ( false !== strpos( $icon_value, 'fa-' ) || false !== strpos( $icon_value, 'fa ' ) ) {
// Check for specific FontAwesome variants (fas, far, fab, fal, fad)
if ( preg_match( '/\bfab\b/', $icon_value ) ) {
return 'fab'; // FontAwesome Brands
}
if ( preg_match( '/\bfar\b/', $icon_value ) ) {
return 'far'; // FontAwesome Regular
}
if ( preg_match( '/\bfal\b/', $icon_value ) ) {
return 'fal'; // FontAwesome Light
}
if ( preg_match( '/\bfad\b/', $icon_value ) ) {
return 'fad'; // FontAwesome Duotone
}
// Default to FontAwesome Solid
return 'fas';
}
// Check for Dashicons
if ( false !== strpos( $icon_value, 'dashicon' ) ) {
return 'dashicons';
}
// Any other CSS class (custom icons like 'bp-icon bp-check-circle')
return 'custom';
}
/**
* Check whether an icon value is a URL-like image path.
*
* @param string $icon_value Icon value.
* @return bool
*/
private function is_icon_url( $icon_value ) {
$icon_value = trim( (string) $icon_value );
if ( '' === $icon_value ) {
return false;
}
return 0 === strpos( $icon_value, 'http://' )
|| 0 === strpos( $icon_value, 'https://' )
|| 0 === strpos( $icon_value, '/' )
|| 0 === strpos( $icon_value, 'data:image/' );
}
/**
* Create a widget proxy object for layout templates.
*
* This provides the methods that layout files expect from $widgetObj and $this.
*
* @param string $block_id The unique block ID.
* @param array $settings The settings array.
* @return object Widget proxy object with required methods.
*/
private function create_widget_proxy( $block_id, $settings ) {
return new class( $block_id, $settings ) {
/**
* Block ID.
*
* @var string
*/
private $id;
/**
* Settings array.
*
* @var array
*/
private $settings;
/**
* Constructor.
*
* @param string $id Block ID.
* @param array $settings Settings array.
*/
public function __construct( $id, $settings ) {
$this->id = $id;
$this->settings = $settings;
}
/**
* Get the block ID.
*
* @return string Block ID.
*/
public function get_id() {
return $this->id;
}
/**
* Get default payment amount text.
*
* @param array $settings Settings array.
* @return string Default amount text.
*/
public function render_attribute_default_text( $settings ) {
$render_attribute_default_text = '';
$items = ! empty( $settings['better_payment_form_fields'] ) ? $settings['better_payment_form_fields'] : array();
foreach ( $items as $item ) {
// Check for primary_payment_amount field type.
if ( ! empty( $item['better_payment_primary_field_type'] ) && 'primary_payment_amount' === $item['better_payment_primary_field_type'] ) {
$render_attribute_default_text = ! empty( $item['better_payment_field_name_default'] )
? $item['better_payment_field_name_default']
: '';
break;
}
}
return $render_attribute_default_text;
}
/**
* Render amount selection element.
*
* @param array $settings Settings array.
* @param array $args Additional arguments.
* @return void
*/
public function render_amount_element( $settings, $args = array() ) {
if ( empty( $settings['better_payment_amount'] ) ) {
return;
}
$items = $settings['better_payment_amount'];
// Get currency symbol and alignment for display.
$bp_helper_obj = new \Better_Payment\Lite\Classes\Helper();
$currency = ! empty( $settings['better_payment_form_currency'] ) ? $settings['better_payment_form_currency'] : 'USD';
$currency_symbol = $bp_helper_obj->get_currency_symbol( esc_html( $currency ) );
$currency_alignment = ! empty( $settings['better_payment_form_currency_alignment'] ) ? $settings['better_payment_form_currency_alignment'] : 'left';
$currency_left = 'left' === $currency_alignment ? $currency_symbol : '';
$currency_right = 'right' === $currency_alignment ? $currency_symbol : '';
foreach ( $items as $item ) :
if ( ! empty( $item['better_payment_amount_val'] ) ) :
$uid = uniqid();
$value = floatval( $item['better_payment_amount_val'] );
?>
';
}
};
}
/**
* Enqueue editor assets (controls library).
*
* @return void
*/
public function enqueue_editor_assets() {
// Only load on block editor pages
$screen = get_current_screen();
if ( ! $screen || ! $screen->is_block_editor() ) {
return;
}
global $pagenow;
$editor_type = false;
if ( $pagenow === 'post-new.php' || $pagenow === 'post.php' ) {
$editor_type = 'edit-post';
} elseif ( $pagenow === 'site-editor.php' || ( $pagenow === 'themes.php' && isset( $_GET[ 'page' ] ) && sanitize_text_field( wp_unslash( $_GET[ 'page' ] ) ) === 'gutenberg-edit-site' ) ) {
$editor_type = 'edit-site';
} elseif ( $pagenow === 'widgets.php' ) {
$editor_type = 'edit-widgets';
}
// Define EssentialBlocksLocalize before controls load (controls access it at module level)
$localize_data = array(
'ajaxurl' => admin_url( 'admin-ajax.php' ),
'ajax_url' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'better_payment_block_nonce' ),
'admin_nonce' => wp_create_nonce( 'better_payment_admin_nonce' ),
'rest_rootURL' => esc_url_raw( rest_url() ),
'is_pro_active' => 'false',
'eb_plugins_url' => BETTER_PAYMENT_URL . '/',
'image_url' => BETTER_PAYMENT_ASSETS . '/img',
'fontAwesome' => 'true',
'googleFont' => 'true',
'quickToolbar' => 'false',
'enableGenerateImage' => '0',
'enableWriteAIInputField' => '0',
'enableWriteAIRichtext' => '0',
'enableRewriteAIContent' => '0',
'hasOpenAiApiKey' => '0',
'all_blocks_default' => array(),
'all_blocks' => array(),
'quick_toolbar_blocks' => array(),
'responsiveBreakpoints' => array(
'tablet' => 1024,
'mobile' => 767,
),
);
// Add inline script to wp-editor which is guaranteed to load in block editor
wp_add_inline_script(
'wp-editor',
'window.EssentialBlocksLocalize = window.EssentialBlocksLocalize || ' . wp_json_encode( $localize_data ) . ';'
);
// Enqueue necessary CSS files for form styles in editor (used in render.php)
wp_enqueue_style( 'better-payment-el' );
wp_enqueue_style( 'bp-icon-front' );
wp_enqueue_style( 'better-payment-style' );
wp_enqueue_style( 'better-payment-common-style' );
wp_enqueue_style( 'better-payment-admin-style' );
wp_enqueue_style( 'bp-dashboard-pagination-style' );
$this->enqueue_font_awesome();
wp_enqueue_style( 'dashicons' );
// Build block data: start with common keys, then merge each block's own data.
// To add data for a new block, implement get_{block-key}_editor_data() and add
// 'editor_data' => 'get_{block-key}_editor_data' to the $blocks registry above.
$block_data = $this->get_common_editor_data();
foreach ( $this->blocks as $block_config ) {
if ( ! empty( $block_config['editor_data'] ) && method_exists( $this, $block_config['editor_data'] ) ) {
$block_data = array_merge( $block_data, $this->{$block_config['editor_data']}() );
}
}
// Canonical filter for the full merged data; the legacy alias preserves backwards
// compatibility with any pro-plugin hooks on the old per-block filter name.
$block_data = apply_filters( 'better_payment/block/editor_block_data', $block_data );
$block_data = apply_filters( 'better_payment/block/user_dashboard/editor_block_data', $block_data );
wp_add_inline_script(
'wp-editor',
'window.betterPaymentBlockData = ' . wp_json_encode( $block_data ) . ';'
);
wp_add_inline_script(
'wp-editor',
'window.eb_conditional_localize = ' . wp_json_encode(
$editor_type !== false ? [ 'editor_type' => $editor_type ] : []
) . ';'
);
}
/**
* Enqueue frontend assets for blocks
* Called on wp_enqueue_scripts hook to allow pro plugin to enqueue scripts early
*
* @return void
*/
public function enqueue_frontend_assets() {
// Allow pro plugin to enqueue scripts for the user dashboard block
do_action( 'better_payment/block/user_dashboard/wp_enqueue_scripts' );
}
/**
* Return the site domain (host only, no protocol) for use in editor data.
*
* @return string
*/
private function get_site_domain() {
$parsed = wp_parse_url( get_site_url() );
return ! empty( $parsed['host'] ) ? esc_html( $parsed['host'] ) : 'example.com';
}
/**
* Return plugin settings with all gateway secret/private keys removed.
*
* Safe to expose to any block editor user regardless of role. Only the keys
* that the editor UI actually reads (email defaults, gateway enable flags,
* public keys) are kept — secrets never leave the server.
*
* @return array
*/
private function get_safe_settings_for_editor() {
$settings = get_option( 'better_payment_settings', array() );
if ( ! is_array( $settings ) ) {
return array();
}
$sensitive_keys = array(
'better_payment_settings_payment_paypal_live_secret',
'better_payment_settings_payment_paypal_test_secret',
'better_payment_settings_payment_stripe_live_secret',
'better_payment_settings_payment_stripe_test_secret',
'better_payment_settings_payment_paystack_live_secret',
'better_payment_settings_payment_paystack_test_secret',
// AI provider API keys (Campaign Builder); the block editor never needs them.
'better_payment_settings_ai_api_key_openai',
'better_payment_settings_ai_api_key_claude',
'better_payment_settings_ai_api_key_gemini',
'better_payment_settings_ai_api_key_openrouter',
);
foreach ( $sensitive_keys as $key ) {
unset( $settings[ $key ] );
}
return $settings;
}
/**
* Return data shared by every registered block in the editor.
*
* All blocks can read these keys from window.betterPaymentBlockData.
* Adding a new block? Common data lives here; block-specific data goes in
* get_{block-key}_editor_data() and is registered via $blocks['editor_data'].
*
* @return array
*/
private function get_common_editor_data() {
return array(
'ajaxurl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'better_payment_block_nonce' ),
'currencies' => $this->get_currency_list(),
'assetUrl' => BETTER_PAYMENT_ASSETS,
'assetsUrl' => BETTER_PAYMENT_ASSETS,
'siteName' => get_bloginfo( 'name' ),
'siteDomain' => $this->get_site_domain(),
'proEnabled' => apply_filters( 'better_payment/pro_enabled', false ),
);
}
/**
* Return editor data specific to the payment-form block.
*
* Provides the global settings (secrets stripped) so the inspector can
* pre-populate email defaults, and the site admin email for the To field.
*
* @return array
*/
private function get_payment_form_editor_data() {
return array(
'betterPaymentSettings' => $this->get_safe_settings_for_editor(),
'adminEmail' => sanitize_email( get_option( 'admin_email' ) ),
// A form with no gateway enabled shows an editor notice instead of
// nothing (components/gateway-notice.js). Both keys are about what to
// tell the user: the link is only offered to someone who can act on
// it, since an author who cannot open Better Payment's settings is
// better told to ask an administrator than handed a link to a screen
// they will be refused.
'settingsUrl' => admin_url( 'admin.php?page=better-payment-admin&tab=settings§ion=general' ),
'canManageSettings' => current_user_can( 'manage_options' ),
);
}
/**
* Return editor data specific to the user-dashboard block.
*
* Fetches the current editor user's transactions, subscriptions, and analytics
* so the block preview shows real data immediately on insertion without a REST
* round-trip. Normalises each row to the same flat shape the UserAPI returns so
* the JS preview component only needs to handle one structure.
*
* @return array
*/
private function get_user_dashboard_editor_data() {
$current_user = wp_get_current_user();
// Cache the 5 DB queries for the duration of the browser session in the editor.
// The cache is keyed per user so each editor sees only their own data.
// TTL: 5 minutes — short enough to reflect recent transactions without hammering
// the DB on every page navigation inside the block editor.
// Entitlement is part of the key: subscription rows are fetched only for
// Pro (below), so a cached free payload must never be served to Pro.
$pro_enabled = (bool) apply_filters( 'better_payment/pro_enabled', false );
$cache_key = 'bp_editor_dash_' . ( $current_user->ID ?? 0 ) . ( $pro_enabled ? '_pro' : '' );
$cache_group = 'better_payment';
$cached = wp_cache_get( $cache_key, $cache_group );
if ( false !== $cached ) {
// Nonces are request-scoped and must never be cached; regenerate on every hit.
$cached['restNonce'] = wp_create_nonce( 'wp_rest' );
return $cached;
}
$user_transactions = array();
$user_subscriptions = array();
$user_analytics = array();
$user_transactions_meta = array( 'total' => 0, 'pages' => 1, 'perPage' => 20 );
// Safe default ensures userSubscriptionsMeta is always fully defined even when
// the user has no subscriptions or is not logged in.
$sub_result = array( 'total' => 0, 'pages' => 1, 'per_page' => 20, 'transactions' => array() );
// Always fetch in the editor — has_block() only checks saved content and returns
// false before first save, leaving the block with empty data on insertion.
if ( $current_user && $current_user->ID ) {
$user_analytics = \Better_Payment\Lite\Admin\DB::get_user_analytics_by_email( $current_user->user_email );
$paged_result = \Better_Payment\Lite\Admin\DB::get_user_transactions_paginated(
$current_user->user_email,
array(
'page' => 1,
'per_page' => 20,
'type' => 'transactions',
)
);
$raw_transactions = $paged_result['transactions'];
$user_transactions_meta = array(
'total' => $paged_result['total'],
'pages' => $paged_result['pages'],
'perPage' => $paged_result['per_page'],
);
// Unserialize and normalise to match UserAPI REST response format.
foreach ( $raw_transactions as $tx ) {
// The status bucket, as UserAPI's `status_type`: the preview labels the row from it
// (an unconfirmed pre-2.4.0 free checkout reads Incomplete).
$tx->status_type = \Better_Payment\Lite\Classes\Helper::transaction_status_type( $tx, 'v2' );
if ( ! empty( $tx->form_fields_info ) ) {
$tx->form_fields_info = maybe_unserialize( $tx->form_fields_info );
}
if ( ! empty( $tx->customer_info ) ) {
$tx->customer_info = maybe_unserialize( $tx->customer_info );
}
$ffi = is_array( $tx->form_fields_info ) ? $tx->form_fields_info : array();
$customer_name = isset( $ffi['primary_first_name'] )
? trim( sanitize_text_field( $ffi['primary_first_name'] ) . ' ' . sanitize_text_field( $ffi['primary_last_name'] ?? '' ) )
: '';
if ( empty( $customer_name ) ) {
$customer_name = isset( $ffi['first_name'] )
? trim( sanitize_text_field( $ffi['first_name'] ) . ' ' . sanitize_text_field( $ffi['last_name'] ?? '' ) )
: '';
}
$tx->customer_name = $customer_name;
$tx->customer_email = sanitize_text_field( $ffi['primary_email'] ?? ( $ffi['email'] ?? '' ) );
// 'One Time' unless Pro labels the row (better_payment/transaction/payment_type), as UserAPI does.
$tx->payment_type_label = \Better_Payment\Lite\Classes\Helper::payment_type_label( $ffi );
$tx->is_subscription = $tx->payment_type_label;
}
$user_transactions = apply_filters( 'better_payment/block/user_dashboard/enrich_user_transactions', $raw_transactions );
// Separate query for subscriptions (not a subset of transactions page 1).
// These rows are Better Payment's own Stripe recurring and split payments —
// Pro features that only Pro's Subscriptions tab displays — so a free
// install skips the query and keeps the empty default.
$raw_subscriptions = array();
if ( $pro_enabled ) {
$sub_result = \Better_Payment\Lite\Admin\DB::get_user_transactions_paginated(
$current_user->user_email,
array(
'page' => 1,
'per_page' => 20,
'type' => 'subscriptions',
)
);
$raw_subscriptions = $sub_result['transactions'];
}
foreach ( $raw_subscriptions as $tx ) {
if ( ! empty( $tx->form_fields_info ) ) {
$tx->form_fields_info = maybe_unserialize( $tx->form_fields_info );
}
}
$user_subscriptions = apply_filters( 'better_payment/block/user_dashboard/enrich_user_subscriptions', $raw_subscriptions );
// Flatten each subscription to match UserAPI flat format so JS handles one structure.
foreach ( $user_subscriptions as $tx ) {
$ffi = is_array( $tx->form_fields_info ) ? $tx->form_fields_info : array();
// The subscription columns come from the same place as UserAPI's REST rows: Pro fills
// `better_payment/user_api/subscription_row`.
$row = array();
$built = apply_filters( 'better_payment/user_api/subscription_row', $row, $ffi, $tx );
$row = is_array( $built ) ? $built : $row;
foreach ( $row as $key => $value ) {
$tx->{$key} = $value;
}
// The enrich_user_subscriptions filter above stores the product name in form_fields_info.
if ( ! empty( $ffi['subscription_product_name'] ) ) {
$tx->subscription_product_name = sanitize_text_field( $ffi['subscription_product_name'] );
}
$tx->is_subscription = Helper::payment_type_label( $ffi );
$customer_name = isset( $ffi['primary_first_name'] )
? trim( sanitize_text_field( $ffi['primary_first_name'] ) . ' ' . sanitize_text_field( $ffi['primary_last_name'] ?? '' ) )
: '';
if ( empty( $customer_name ) ) {
$customer_name = isset( $ffi['first_name'] )
? trim( sanitize_text_field( $ffi['first_name'] ) . ' ' . sanitize_text_field( $ffi['last_name'] ?? '' ) )
: '';
}
$tx->customer_name = $customer_name;
$tx->customer_email = sanitize_text_field( $ffi['primary_email'] ?? ( $ffi['email'] ?? '' ) );
unset( $tx->form_fields_info );
}
}
$data = array(
'restNonce' => wp_create_nonce( 'wp_rest' ),
'restUrl' => get_rest_url( null, 'better-payment/v1/user-transactions' ),
'currentUser' => array(
'id' => $current_user->ID,
'user_email' => $current_user->user_email,
'user_login' => $current_user->user_login,
'user_avatar_url'=> get_avatar_url( $current_user->user_email, array( 'size' => 32 ) ),
'email' => $current_user->user_email,
'login' => $current_user->user_login,
'avatar' => get_avatar_url( $current_user->user_email, array( 'size' => 32 ) ),
),
'userTransactions' => $user_transactions,
'userTransactionsMeta' => $user_transactions_meta,
'userSubscriptions' => $user_subscriptions,
'userSubscriptionsMeta' => array(
'total' => (int) $sub_result['total'],
'pages' => (int) $sub_result['pages'],
'perPage' => 20,
),
'userAnalytics' => $user_analytics,
'proAssets' => array(
'analyticsReportsBanner' => BETTER_PAYMENT_ASSETS . '/img/user-dashboard-analytics-reports-pro-banner.png',
'recurringSubscriptionBanner' => BETTER_PAYMENT_ASSETS . '/img/user-dashboard-recurring-subscription-pro-banner.png',
'splitSubscriptionBanner' => BETTER_PAYMENT_ASSETS . '/img/user-dashboard-split-subscription-pro-banner.png',
'subscriptionProBanner' => BETTER_PAYMENT_ASSETS . '/img/user-dashboard-subscription-pro-banner.png',
),
);
// Store in the object cache for 5 minutes. nonce is excluded from the cached payload
// because nonces are request-scoped — storing them would return stale nonces on cache hit.
// The restNonce above is regenerated fresh on every request (not cached).
$cacheable = $data;
unset( $cacheable['restNonce'] );
wp_cache_set( $cache_key, $cacheable, $cache_group, 5 * MINUTE_IN_SECONDS );
return $data;
}
/**
* Get the current editor type.
*
* @return string
*/
private function get_editor_type() {
if ( function_exists( 'get_current_screen' ) ) {
$screen = get_current_screen();
if ( $screen && method_exists( $screen, 'is_block_editor' ) && $screen->is_block_editor() ) {
return 'edit-post';
}
}
return 'edit-post';
}
/**
* Register the Better Payment block category.
*
* @param array $categories Block categories.
* @param WP_Block_Editor_Context $context Block editor context.
* @return array Modified block categories.
*/
public function register_block_category( $categories, $context ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
// Check if our category already exists
foreach ( $categories as $category ) {
if ( 'better-payment' === $category['slug'] ) {
return $categories;
}
}
// Add our category at the beginning
array_unshift(
$categories,
array(
'slug' => 'better-payment',
'title' => __( 'Better Payment', 'better-payment' ),
'icon' => '',
)
);
return $categories;
}
/**
* Register all blocks.
*
* @return void
*/
public function register_blocks() {
if ( ! function_exists( 'register_block_type' ) ) {
return;
}
foreach ( $this->blocks as $block_key => $block_config ) {
if ( ! $this->is_block_registered( $block_config['name'] ) ) {
$this->register_single_block( $block_key, $block_config );
}
}
}
/**
* Register a single block.
*
* @param string $block_key Block key.
* @param array $block_config Block configuration.
* @return void
*/
private function register_single_block( $block_key, $block_config ) {
// Use assets/blocks path for block.json since src/ is excluded from production builds.
// The block.json is copied to assets/blocks/ during the build process.
$block_json_path = BETTER_PAYMENT_PATH . '/assets/blocks/blocks/' . $block_config['path'] . '/block.json';
// Fallback to src/blocks for development environment (assets may not be built yet)
if ( ! file_exists( $block_json_path ) ) {
$block_json_path = BETTER_PAYMENT_PATH . '/src/blocks/' . $block_config['path'] . '/block.json';
}
// Check if block.json exists
if ( ! file_exists( $block_json_path ) ) {
return;
}
// Get asset file for dependencies
$asset_file = $this->get_asset_file( $block_config['path'] );
// Add controls as a dependency
$dependencies = array_merge( $asset_file['dependencies'], array( 'better-payment-controls' ) );
// Register editor script
$editor_script_handle = 'better-payment-' . $block_key . '-editor';
if ( file_exists( BETTER_PAYMENT_PATH . '/assets/blocks/' . $block_config['path'] . '/index.min.js' ) ) {
wp_register_script(
$editor_script_handle,
BETTER_PAYMENT_ASSETS . '/blocks/' . $block_config['path'] . '/index.min.js',
$dependencies,
$asset_file['version'],
true
);
} elseif ( file_exists( BETTER_PAYMENT_PATH . '/assets/blocks/' . $block_config['path'] . '/index.js' ) ) {
wp_register_script(
$editor_script_handle,
BETTER_PAYMENT_ASSETS . '/blocks/' . $block_config['path'] . '/index.js',
$dependencies,
$asset_file['version'],
true
);
}
// Register frontend/editor style
$style_handle = 'better-payment-' . $block_key . '-style';
$style_dependencies = array(
'better-payment-controls-style',
'better-payment-el',
'bp-icon-front',
'better-payment-style',
'better-payment-common-style',
'better-payment-admin-style',
'dashicons',
);
if ( ! $this->is_font_awesome_provided() ) {
$style_dependencies[] = 'bp-font-awesome';
}
if ( file_exists( BETTER_PAYMENT_PATH . '/assets/blocks/' . $block_config['path'] . '/style.min.css' ) ) {
wp_register_style(
$style_handle,
BETTER_PAYMENT_ASSETS . '/blocks/' . $block_config['path'] . '/style.min.css',
$style_dependencies,
$asset_file['version']
);
} elseif ( file_exists( BETTER_PAYMENT_PATH . '/assets/blocks/' . $block_config['path'] . '/style.css' ) ) {
wp_register_style(
$style_handle,
BETTER_PAYMENT_ASSETS . '/blocks/' . $block_config['path'] . '/style.css',
$style_dependencies,
$asset_file['version']
);
}
// Build block registration args
$block_args = array(
'editor_script' => $editor_script_handle,
'style' => $style_handle,
);
// Add render callback if defined in block config
if ( ! empty( $block_config['render_callback'] ) && method_exists( $this, $block_config['render_callback'] ) ) {
$block_args['render_callback'] = array( $this, $block_config['render_callback'] );
}
// Register block with WordPress
$result = register_block_type( $block_json_path, $block_args );
if ( false === $result ) {
// Log error in debug mode
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( 'Better Payment: Failed to register block ' . $block_config['name'] );
}
}
}
/**
* Get asset file with dependencies and version.
*
* @param string $block_path Block path.
* @return array Asset file data.
*/
private function get_asset_file( $block_path ) {
$asset_file_path = BETTER_PAYMENT_PATH . '/assets/blocks/' . $block_path . '/index.min.asset.php';
if ( ! file_exists( $asset_file_path ) ) {
$asset_file_path = BETTER_PAYMENT_PATH . '/assets/blocks/' . $block_path . '/index.asset.php';
}
if ( file_exists( $asset_file_path ) ) {
return include $asset_file_path;
}
return array(
'dependencies' => array( 'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-i18n' ),
'version' => BETTER_PAYMENT_VERSION,
);
}
/**
* Get the blocks directory path.
*
* @return string
*/
public function get_blocks_path() {
return BETTER_PAYMENT_PATH . '/src/blocks/';
}
/**
* Get the blocks assets URL.
*
* @return string
*/
public function get_blocks_url() {
return BETTER_PAYMENT_ASSETS . '/blocks/';
}
/**
* Check if a block is registered.
*
* @param string $block_name Full block name (e.g., 'better-payment/payment-form').
* @return bool
*/
public function is_block_registered( $block_name ) {
return \WP_Block_Type_Registry::get_instance()->is_registered( $block_name );
}
/**
* Check if Font Awesome is already provided by another plugin (e.g. Elementor).
*
* @return bool
*/
private function is_font_awesome_provided() {
$handles = [
'font-awesome-5-all',
'font-awesome-6-all',
'elementor-icons-fa-solid',
'elementor-icons-fa-brands',
'elementor-icons-fa-regular',
];
foreach ( $handles as $handle ) {
if ( wp_style_is( $handle, 'registered' ) || wp_style_is( $handle, 'enqueued' ) ) {
return true;
}
}
return false;
}
/**
* Enqueue Font Awesome only if not already provided by another plugin.
*
* @return void
*/
private function enqueue_font_awesome() {
if ( ! $this->is_font_awesome_provided() ) {
wp_enqueue_style( 'bp-font-awesome' );
}
}
}