feat: add React FieldRenderer system for settings and metaboxes

Complete React-based field rendering system that replaces WPCFTO Vue.js
layer while maintaining PHP field configuration compatibility.

Components:
- FieldRenderer: Main renderer with tabs support (metabox) and direct mode (settings)
- FieldTypes: 15+ field types (Text, Number, Select, Radio, Date, etc.)
- RepeaterField: Collapsible repeater with currency label parsing
- DependencyEngine: Show/hide fields based on conditions
- ValidationEngine: Client-side validation with error messages
- SettingsRenderer: Settings page with AJAX save to wp_options

Features:
- Repeater rows collapsed by default with readable currency titles
- Searchable dropdowns using Popover + Command pattern
- Proper label resolution for pre-selected values
- Hidden input sync for WordPress form submission

Also includes:
- FieldConfigBridge: Transform PHP configs to React format
- Updated Settings.php for React-based settings page
- Radio-group UI component
- wp-admin-restore.css for admin panel isolation
This commit is contained in:
dwindown
2026-04-28 16:48:08 +07:00
parent 7a6765a579
commit 622c9f8eb7
206 changed files with 5788 additions and 1612 deletions

View File

@@ -42,6 +42,10 @@ class Coupon {
// React Metabox
add_action( 'add_meta_boxes', [$this, 'add_react_metabox'] );
add_action( 'admin_footer-post.php', [$this, 'render_react_metabox_template'] );
add_action( 'admin_footer-post-new.php', [$this, 'render_react_metabox_template'] );
// Save coupon data via WordPress save_post hook (regular hook with post type check inside)
add_action( 'save_post', [$this, 'save_coupon_on_post_update'], 10, 2 );
// Order
add_filter( 'formipay/order/order-details', [$this, 'order_details'], 99, 3 );
@@ -112,13 +116,6 @@ class Coupon {
if ($screen && $screen->post_type === 'formipay-coupon' && $screen->base === 'post') {
wp_enqueue_style('sweetalert2', FORMIPAY_URL . 'vendor/SweetAlert2/sweetalert2.min.css', [], '11.14.4', 'all');
wp_enqueue_script('sweetalert2', FORMIPAY_URL . 'vendor/SweetAlert2/sweetalert2.min.js', ['jquery'], '11.14.4', true);
// Localize admin data
wp_localize_script('formipay-admin', 'formipayAdmin', [
'ajaxUrl' => admin_url('admin-ajax.php'),
'siteUrl' => site_url(),
'nonce' => wp_create_nonce('formipay-admin'),
]);
}
}
@@ -154,6 +151,7 @@ class Coupon {
$rules_group_1 = array(
'rules_general_group' => array(
'type' => 'group_title',
'label' => __( 'General', 'formipay' ),
'group' => 'started'
),
'active' => array(
@@ -169,6 +167,7 @@ class Coupon {
'fixed' => __( 'Fixed', 'formipay' ),
'percentage' => __( 'Percentage', 'formipay' )
),
'value' => 'fixed'
),
'amount_percentage' => array(
'type' => 'number',
@@ -209,8 +208,7 @@ class Coupon {
$currency_symbol = formipay_get_currency_data_by_value($currency['currency'], 'symbol');
$currency_title = ucwords(formipay_get_currency_data_by_value($currency['currency'], 'title'));
$decimal_digits = intval($currency['decimal_digits']);
$step = $decimal_digits * 10;
$step = $step > 0 ? 1 / $step : 1;
$step = $decimal_digits > 0 ? pow(10, -$decimal_digits) : 1;
$rules_group_2['amount_fixed_'.$currency_symbol] = array(
'type' => 'number',
@@ -238,7 +236,11 @@ class Coupon {
),
'step' => $step,
'min' => 0,
'placeholder' => __( 'Enter Max Amount...', 'formipay' )
'placeholder' => __( 'Enter Max Amount...', 'formipay' ),
'dependency' => array(
'key' => 'type',
'value' => 'percentage'
)
);
}
@@ -324,7 +326,8 @@ class Coupon {
'use_limit' => array(
'type' => 'number',
'label' => __( 'Usage Limit', 'formipay' ),
'description' => __( 'Leave it empty or 0 (zero) to set it as unlimited usage.', 'formipay' )
'description' => __( 'Leave it empty or 0 (zero) to set it as unlimited usage.', 'formipay' ),
'placeholder' => __( 'Set limit...', 'formipay' )
),
'date_limit' => array(
'type' => 'date',
@@ -343,11 +346,11 @@ class Coupon {
'label' => __( 'Products', 'formipay' ),
'description' => __( 'Only selected product(s) can use the coupon. Leave empty to apply to all products.', 'formipay' )
),
'customers' => array(
'users' => array(
'type' => 'autocomplete',
'post_type' => array('formipay-product'),
'object_type' => 'user',
'label' => __( 'Customers', 'formipay' ),
'description' => __( 'Only selected customer(s) can use the coupon. Leave empty to apply to all customers.', 'formipay' )
'description' => __( 'Only selected registered customer(s) can use this coupon. Leave empty to apply to all customers.', 'formipay' )
)
);
@@ -588,6 +591,14 @@ class Coupon {
}
$date_limit = formipay_get_post_meta($coupon->ID, 'date_limit');
$date_limit_display = 'none';
if ($date_limit && $date_limit !== '') {
if (preg_match('/^\d{4}-\d{2}-\d{2}/', $date_limit)) {
$date_limit_display = $date_limit;
} elseif (is_numeric($date_limit)) {
$date_limit_display = formipay_date('Y-m-d', intval($date_limit) / 1000);
}
}
$type = formipay_get_post_meta($coupon->ID, 'type');
$amount_meta_key = "amount_$type";
@@ -622,7 +633,7 @@ class Coupon {
'case_sensitive' => formipay_get_post_meta($coupon->ID, 'case_sensitive'),
'usage_count' => $this->count_order_by_coupon_code(get_the_title($coupon->ID)),
'usages' => $this->count_order_by_coupon_code(get_the_title($coupon->ID)),
'date_limit' => false !== $date_limit ? formipay_date('Y-m-d', intval(formipay_get_post_meta($coupon->ID, 'date_limit')) / 1000) : 'none',
'date_limit' => $date_limit_display,
'active' => $is_active ? 'on' : 'off',
'post_status' => $is_active ? 'active' : 'inactive',
'status' => $is_active ? 'active' : 'inactive'
@@ -885,7 +896,7 @@ class Coupon {
'max_amounts' => [],
'forms' => formipay_get_post_meta($post_id, 'forms') ?: [],
'products' => formipay_get_post_meta($post_id, 'products') ?: [],
'customers' => formipay_get_post_meta($post_id, 'customers') ?: [],
'users' => formipay_get_post_meta($post_id, 'users') ?: [],
];
// Get fixed amounts for each currency
@@ -987,7 +998,7 @@ class Coupon {
}
// Save relation fields
$relation_fields = ['forms', 'products', 'customers'];
$relation_fields = ['forms', 'products', 'users'];
foreach ($relation_fields as $field) {
if ( isset($_REQUEST[$field]) ) {
$values = is_array($_REQUEST[$field]) ? array_map('intval', $_REQUEST[$field]) : [];
@@ -1006,7 +1017,7 @@ class Coupon {
*/
public function add_react_metabox() {
add_meta_box(
'formipay_coupon_reactor_metabox',
'formipay_coupon_settings',
__( 'Coupon Settings', 'formipay' ),
[$this, 'render_react_metabox'],
'formipay-coupon',
@@ -1021,9 +1032,9 @@ class Coupon {
public function render_react_metabox($post) {
?>
<div
data-formipay-metabox="coupon"
data-formipay-field-renderer="coupon"
data-post-id="<?php echo esc_attr($post->ID); ?>"
class="formipay-react-metabox-container"
class="formipay-field-renderer-container"
>
<div class="formipay-loading">
<div class="formipay-spinner"></div>
@@ -1042,9 +1053,39 @@ class Coupon {
return;
}
// Get global currencies
// Add Toaster container for toast notifications
echo '<div id="formipay-toaster-container" class="formipay-design-system" style="position:fixed;bottom:20px;right:20px;z-index:99999;"></div>';
// Get field configuration for this post
$config = \Formipay\Admin\FieldConfigBridge::get_config_for_post($post->ID, $post->post_type);
// Pass config to JavaScript
?>
<script type="text/javascript">
window.formipayFieldConfig = <?php echo wp_json_encode($config); ?>;
</script>
<?php
// Keep the legacy global currencies for backward compatibility during transition
$global_currencies = get_global_currency_array();
// Fallback: if no multicurrencies configured, use default currency
if (empty($global_currencies)) {
$formipay_settings = get_option('formipay_settings');
$default_currency_raw = formipay_default_currency('raw');
if (!empty($default_currency_raw)) {
$global_currencies = [
[
'currency' => $default_currency_raw,
'decimal_digits' => isset($formipay_settings['default_currency_decimal_digits']) ? intval($formipay_settings['default_currency_decimal_digits']) : 2,
'decimal_symbol' => isset($formipay_settings['default_currency_decimal_symbol']) ? $formipay_settings['default_currency_decimal_symbol'] : '.',
'thousand_separator' => isset($formipay_settings['default_currency_thousand_separator']) ? $formipay_settings['default_currency_thousand_separator'] : ',',
]
];
}
}
?>
<script type="text/javascript">
window.formipayGlobalCurrencies = <?php echo wp_json_encode($global_currencies); ?>;
@@ -1062,6 +1103,7 @@ class Coupon {
/**
* Autocomplete search for relation fields
* Supports CPTs (post_type) and WP Users (object_type=user)
*/
public function formipay_autocomplete_search() {
@@ -1072,9 +1114,56 @@ class Coupon {
}
$post_type = isset($_REQUEST['post_type']) ? sanitize_text_field(wp_unslash($_REQUEST['post_type'])) : '';
$object_type = isset($_REQUEST['object_type']) ? sanitize_text_field(wp_unslash($_REQUEST['object_type'])) : '';
$search = isset($_REQUEST['search']) ? sanitize_text_field(wp_unslash($_REQUEST['search'])) : '';
$include = isset($_REQUEST['include']) ? array_map('intval', (array) $_REQUEST['include']) : [];
// Handle WP Users
if ($object_type === 'user') {
// Resolve labels for specific IDs (pre-selected items)
if (!empty($include)) {
$users = get_users(['include' => $include, 'fields' => ['ID', 'display_name', 'user_email']]);
$results = [];
if (!empty($users)) {
foreach ($users as $user) {
$results[] = [
'value' => $user->ID,
'label' => $user->display_name . ' (' . $user->user_email . ')',
];
}
}
wp_send_json_success($results);
return;
}
// Search by keyword
if (strlen($search) < 2) {
wp_send_json_error( [ 'message' => 'Invalid request' ] );
}
$users = get_users([
'search' => '*' . $search . '*',
'search_columns' => ['display_name', 'user_email', 'user_login'],
'number' => 20,
'fields' => ['ID', 'display_name', 'user_email'],
]);
$results = [];
if (!empty($users)) {
foreach ($users as $user) {
$results[] = [
'value' => $user->ID,
'label' => $user->display_name . ' (' . $user->user_email . ')',
];
}
}
wp_send_json_success($results);
return;
}
// Handle CPTs (posts)
if (empty($post_type)) {
wp_send_json_error( [ 'message' => 'Invalid request' ] );
}
@@ -1125,4 +1214,78 @@ class Coupon {
wp_send_json_success($results);
}
/**
* Save coupon data via WordPress save_post hook
* Called when user clicks WordPress Update button
*/
public function save_coupon_on_post_update($post_id, $post) {
// Check if this is an autosave
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
return $post_id;
}
// Check permissions
if (!current_user_can('manage_options')) {
return $post_id;
}
// Verify nonce
if (!isset($_POST['_wpnonce']) || !wp_verify_nonce($_POST['_wpnonce'], 'update-post_' . $post_id)) {
return $post_id;
}
// Only save for our coupon post type
if ($post->post_type !== 'formipay-coupon') {
return $post_id;
}
// Get global currencies for currency field processing
$global_currencies = get_global_currency_array();
// Save basic meta fields
$meta_fields = [
'active',
'type',
'amount_percentage',
'case_sensitive',
'free_shipping',
'quantity_active',
'use_limit',
'date_limit',
];
foreach ($meta_fields as $field) {
if (isset($_POST[$field])) {
$value = wp_unslash($_POST[$field]);
update_post_meta($post_id, $field, $value);
}
}
// Save fixed amounts for each currency
foreach ($global_currencies as $currency) {
$symbol = formipay_get_currency_data_by_value($currency['currency'], 'symbol');
if (isset($_POST['amount_fixed_' . $symbol])) {
update_post_meta($post_id, 'amount_fixed_' . $symbol, wp_unslash($_POST['amount_fixed_' . $symbol]));
}
if (isset($_POST['max_amount_' . $symbol])) {
update_post_meta($post_id, 'max_amount_' . $symbol, wp_unslash($_POST['max_amount_' . $symbol]));
}
}
// Save relation fields
$relation_fields = ['forms', 'products', 'users'];
foreach ($relation_fields as $field) {
if (isset($_POST[$field])) {
$values = is_array($_POST[$field]) ? array_map('intval', $_POST[$field]) : [];
update_post_meta($post_id, $field, $values);
} else {
delete_post_meta($post_id, $field);
}
}
return $post_id;
}
}