Compare commits
12 Commits
d8c81a4022
...
7a6765a579
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a6765a579 | ||
|
|
008188b790 | ||
|
|
0094a3571c | ||
|
|
a36e71ed56 | ||
|
|
1a10c18c31 | ||
|
|
7ba92022d5 | ||
|
|
c103e368be | ||
|
|
99912a9335 | ||
|
|
862abc8d74 | ||
|
|
fe9efdfeec | ||
|
|
d1de0015be | ||
|
|
bde43d8c66 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -9,4 +9,4 @@ coverage
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
*.sw?node_modules/
|
||||
|
||||
@@ -64,6 +64,28 @@ jQuery(function($){
|
||||
$('#order-total').html(res.total_formatted);
|
||||
$('#order_status').val(res.status);
|
||||
|
||||
// Populate shipping info if available
|
||||
var shippingInfo = [];
|
||||
if(res.form_data){
|
||||
$.each(res.form_data, function(key, data){
|
||||
if(data.name === 'shipping_country' || data.name === 'shipping_method'){
|
||||
shippingInfo.push(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
if(shippingInfo.length > 0){
|
||||
var source = $("#shipping-info-template").html();
|
||||
var template = Handlebars.compile(source);
|
||||
var context = {
|
||||
datas: shippingInfo
|
||||
};
|
||||
var html = template(context);
|
||||
$("#shipping-info-list").html(html);
|
||||
} else {
|
||||
$("#shipping-info-list").addClass('d-none');
|
||||
$('#no-shipping-info').removeClass('d-none');
|
||||
}
|
||||
|
||||
var source = $("#form-data-item-template").html();
|
||||
var template = Handlebars.compile(source);
|
||||
var context = {
|
||||
|
||||
@@ -122,15 +122,21 @@ function get_global_currency_array() {
|
||||
if(false === $ifSingleCurrency){
|
||||
// $currency_sort = [];
|
||||
$default_sort_key = null;
|
||||
// Extract currency code from default_currency for comparison (handles case where default has symbol but multicurrencies don't)
|
||||
$default_currency_code = explode(':::', $default_currency)[0];
|
||||
foreach($global_currencies as $key => $currency){
|
||||
$currency_value = $currency['currency'];
|
||||
if($currency_value === $default_currency){
|
||||
// Compare by currency code only (before first :::)
|
||||
$currency_code = explode(':::', $currency_value)[0];
|
||||
if($currency_code === $default_currency_code){
|
||||
$default_sort_key = $key;
|
||||
}
|
||||
}
|
||||
$currency_sort = [$default_sort_key => $global_currencies[$default_sort_key]];
|
||||
unset($global_currencies[$default_sort_key]);
|
||||
$global_currencies = $currency_sort + $global_currencies;
|
||||
// Convert associative array to indexed array for JavaScript
|
||||
$global_currencies = array_values($global_currencies);
|
||||
}else{
|
||||
if(false === boolval($multicurrency)){
|
||||
$global_currencies = [
|
||||
@@ -176,6 +182,31 @@ function formipay_get_flag_by_currency($currency) {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all currency flags from flags.json
|
||||
* Returns an array mapping currency codes to base64 flag images
|
||||
* This is the single source of truth for currency flags - never duplicate this data
|
||||
*
|
||||
* @return array Array of currency code => flag image mapping
|
||||
*/
|
||||
function formipay_get_all_currency_flags() {
|
||||
static $currency_flags = null;
|
||||
|
||||
if ($currency_flags !== null) {
|
||||
return $currency_flags;
|
||||
}
|
||||
|
||||
$json = file_get_contents(FORMIPAY_PATH . 'admin/assets/json/flags.json');
|
||||
$flags = json_decode($json, true);
|
||||
|
||||
$currency_flags = [];
|
||||
foreach ($flags as $item) {
|
||||
$currency_flags[$item['code']] = $item['flag'];
|
||||
}
|
||||
|
||||
return $currency_flags;
|
||||
}
|
||||
|
||||
function formipay_price_format($num = 0, $post_id = 0){
|
||||
|
||||
$decimal_digits = 2;
|
||||
@@ -362,6 +393,14 @@ function formipay_get_order($order_id) {
|
||||
$label = esc_html__( 'Payment Gateway', 'formipay' );
|
||||
break;
|
||||
|
||||
case 'shipping_country':
|
||||
$label = esc_html__( 'Shipping Country', 'formipay' );
|
||||
break;
|
||||
|
||||
case 'shipping_method':
|
||||
$label = esc_html__( 'Shipping Method', 'formipay' );
|
||||
break;
|
||||
|
||||
default:
|
||||
if(!empty($all_fields[$name.'_config'])){
|
||||
$label = $all_fields[$name.'_config']['label'];
|
||||
|
||||
@@ -55,6 +55,26 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="order-detail-card shipping-info-card">
|
||||
<div class="card-title mt-3 mb-0"><?php echo esc_html__( 'Shipping Information', 'formipay' ); ?></div>
|
||||
<div class="card mt-1 border-0 rounded-4 shadow-sm">
|
||||
<div class="card-body p-0 placeholder-glow">
|
||||
<ul class="list-group list-group-flush" id="shipping-info-list">
|
||||
<li class="list-group-item">
|
||||
<b><span class="placeholder col-3"></span></b>
|
||||
<p class="mb-0"><span class="placeholder col-8"></span></p>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<b><span class="placeholder col-3"></span></b>
|
||||
<p class="mb-0"><span class="placeholder col-8"></span></p>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="d-none" id="no-shipping-info">
|
||||
<p class="text-center text-muted my-3"><?php echo esc_html__( 'No shipping information available', 'formipay' ); ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="order-detail-card form-data-card">
|
||||
<div class="card-title mt-3 w-100 mb-0 d-flex justify-content-between align-items-center">
|
||||
<?php echo esc_html__( 'Form Data', 'formipay' ); ?>
|
||||
@@ -274,3 +294,11 @@
|
||||
<p class="mb-0">******</p>
|
||||
</li>
|
||||
</script>
|
||||
<script id="shipping-info-template" type="text/x-handlebars-template">
|
||||
{{#each datas as |data|}}
|
||||
<li class="list-group-item px-0">
|
||||
<b class="field-name">{{data.label}}</b>
|
||||
<p class="field-value mt-1 mb-0">{{data.value}}</p>
|
||||
</li>
|
||||
{{/each}}
|
||||
</script>
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
<?php return array('dependencies' => array('react', 'wp-components', 'wp-element', 'wp-i18n', 'wp-icons/build/arrow-left', 'wp-icons/build/bell', 'wp-icons/build/message', 'wp-icons/build/trash', 'wp-primitives'), 'version' => '4e4bf3366b83c9df1a24');
|
||||
<?php return array('dependencies' => array('react', 'react-dom', 'wp-components', 'wp-element', 'wp-i18n', 'wp-icons/build/arrow-left', 'wp-icons/build/bell', 'wp-icons/build/message', 'wp-icons/build/trash'), 'version' => 'e564b3f018fca608f7b7');
|
||||
|
||||
648
build/admin.css
648
build/admin.css
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
21
components.json
Normal file
21
components.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": false,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/admin/styles/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "hugeicons"
|
||||
}
|
||||
@@ -19,8 +19,11 @@ class ReactAdmin {
|
||||
|
||||
$screen = get_current_screen();
|
||||
|
||||
// Only load React assets on Formipay admin pages
|
||||
if ( strpos( $screen->id, 'formipay' ) === false ) {
|
||||
// Load React assets on Formipay admin pages OR on post edit screens for our CPTs
|
||||
$is_formipay_admin = strpos($screen->id, 'formipay') !== false;
|
||||
$is_formipay_cpt = $screen->base === 'post' && in_array($screen->post_type, ['formipay-coupon', 'formipay-product', 'formipay-form']);
|
||||
|
||||
if (!$is_formipay_admin && !$is_formipay_cpt) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -28,7 +31,7 @@ class ReactAdmin {
|
||||
$build_dir = FORMIPAY_PATH . 'build';
|
||||
$build_url = FORMIPAY_URL . 'build';
|
||||
|
||||
if ( ! file_exists( $build_dir . '/admin.asset.php' ) ) {
|
||||
if (!file_exists($build_dir . '/admin.asset.php')) {
|
||||
error_log('[Formipay] Build files not found at: ' . $build_dir . '/admin.asset.php');
|
||||
return; // Build not generated yet
|
||||
}
|
||||
@@ -61,19 +64,19 @@ class ReactAdmin {
|
||||
);
|
||||
|
||||
// Localize script with required data
|
||||
$data = apply_filters( 'formipay/admin/data', [
|
||||
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||
'restUrl' => rest_url( 'formipay/v1' ),
|
||||
'nonce' => wp_create_nonce( 'formipay-admin' ),
|
||||
$data = apply_filters('formipay/admin/data', [
|
||||
'ajaxUrl' => admin_url('admin-ajax.php'),
|
||||
'restUrl' => rest_url('formipay/v1'),
|
||||
'nonce' => wp_create_nonce('formipay-admin'),
|
||||
'pluginUrl' => FORMIPAY_URL,
|
||||
'siteUrl' => site_url(),
|
||||
] );
|
||||
]);
|
||||
|
||||
// Debug logging
|
||||
error_log('[Formipay] Enqueuing React assets on screen: ' . $screen->id);
|
||||
error_log('[Formipay] Page data: ' . wp_json_encode($data));
|
||||
|
||||
wp_localize_script( 'formipay-admin', 'formipayAdmin', $data );
|
||||
wp_localize_script('formipay-admin', 'formipayAdmin', $data);
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,13 @@ class Coupon {
|
||||
add_action( 'wp_ajax_formipay-delete-coupon', [$this, 'formipay_delete_coupon'] );
|
||||
add_action( 'wp_ajax_formipay-bulk-delete-coupon', [$this, 'formipay_bulk_delete_coupon'] );
|
||||
add_action( 'wp_ajax_formipay-duplicate-coupon', [$this, 'formipay_duplicate_coupon'] );
|
||||
add_action( 'wp_ajax_formipay-get-coupon', [$this, 'formipay_get_coupon'] );
|
||||
add_action( 'wp_ajax_formipay-save-coupon', [$this, 'formipay_save_coupon'] );
|
||||
add_action( 'wp_ajax_formipay-autocomplete-search', [$this, 'formipay_autocomplete_search'] );
|
||||
|
||||
// React Metabox
|
||||
add_action( 'add_meta_boxes', [$this, 'add_react_metabox'] );
|
||||
add_action( 'admin_footer-post.php', [$this, 'render_react_metabox_template'] );
|
||||
|
||||
// Order
|
||||
add_filter( 'formipay/order/order-details', [$this, 'order_details'], 99, 3 );
|
||||
@@ -99,78 +106,28 @@ class Coupon {
|
||||
}
|
||||
|
||||
public function enqueue_admin() {
|
||||
// Assets now handled by ReactAdmin class
|
||||
return;
|
||||
|
||||
if($current_screen->id == 'formipay_page_formipay-coupons') {
|
||||
|
||||
wp_enqueue_style( 'page-coupons', FORMIPAY_URL . 'admin/assets/css/page-coupons.css', [], FORMIPAY_VERSION, 'all' );
|
||||
wp_enqueue_script( 'page-coupons', FORMIPAY_URL . 'admin/assets/js/page-coupons.js', ['jquery', 'gridjs'], FORMIPAY_VERSION, true );
|
||||
|
||||
wp_localize_script( 'page-coupons', 'formipay_coupons_page', [
|
||||
'ajax_url' => admin_url('admin-ajax.php'),
|
||||
'site_url' => site_url(),
|
||||
'columns' => [
|
||||
'id' => esc_html__( 'ID', 'formipay' ),
|
||||
'code' => esc_html__( 'Coupon Code', 'formipay' ),
|
||||
'usages' => esc_html__( 'Usages', 'formipay' ),
|
||||
'date_limit' => esc_html__( 'Date Limit', 'formipay' ),
|
||||
'status' => esc_html__( 'Status', 'formipay' ),
|
||||
'type' => esc_html__( 'Type', 'formipay' ),
|
||||
'amount' => esc_html__( 'Amount', 'formipay' )
|
||||
],
|
||||
'filter_form' => [
|
||||
'products' => [
|
||||
'placeholder' => esc_html__( 'Filter by Product', 'formipay' ),
|
||||
'noresult_text' => esc_html__( 'No results found', 'formipay' )
|
||||
]
|
||||
],
|
||||
'modal' => [
|
||||
'add' => [
|
||||
'title' => esc_html__( 'Your New Coupon Code', 'formipay' ),
|
||||
'validation' => esc_html__( 'Coupon code is still empty. Please input the code before continue', 'formipay' ),
|
||||
'cancelButton' => esc_html__( 'Cancel', 'formipay' ),
|
||||
'confirmButton' => esc_html__( 'Create New Coupon', 'formipay' )
|
||||
],
|
||||
'delete' => [
|
||||
'question' => esc_html__( 'Do you want to delete the coupon?', 'formipay' ),
|
||||
'cancelButton' => esc_html__( 'Cancel', 'formipay' ),
|
||||
'confirmButton' => esc_html__( 'Delete Permanently', 'formipay' )
|
||||
],
|
||||
'bulk_delete' => [
|
||||
'question' => esc_html__( 'Do you want to delete the selected the coupon(s)?', 'formipay' ),
|
||||
'cancelButton' => esc_html__( 'Cancel', 'formipay' ),
|
||||
'confirmButton' => esc_html__( 'Confirm', 'formipay' )
|
||||
],
|
||||
'duplicate' => [
|
||||
'question' => esc_html__( 'Do you want to duplicate the coupon?', 'formipay' ),
|
||||
'cancelButton' => esc_html__( 'Cancel', 'formipay' ),
|
||||
'confirmButton' => esc_html__( 'Confirm', 'formipay' )
|
||||
],
|
||||
],
|
||||
'nonce' => wp_create_nonce('formipay-admin-coupon-page')
|
||||
] );
|
||||
}
|
||||
|
||||
$screen = get_current_screen();
|
||||
|
||||
if ( $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);
|
||||
|
||||
wp_localize_script( 'sweetalert2', 'formipay_admin', [
|
||||
'ajax_url' => admin_url('admin-ajax.php'),
|
||||
'site_url' => site_url(),
|
||||
] );
|
||||
// Enqueue SweetAlert2 for coupon post edit screen
|
||||
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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function cpt_post_fields_box($boxes) {
|
||||
$boxes['formipay_coupon_settings'] = array(
|
||||
'post_type' => array('formipay-coupon'),
|
||||
'label' => __('Details', 'formipay'),
|
||||
);
|
||||
// Disabled - using React metabox instead
|
||||
// $boxes['formipay_coupon_settings'] = array(
|
||||
// 'post_type' => array('formipay-coupon'),
|
||||
// 'label' => __('Details', 'formipay'),
|
||||
// );
|
||||
|
||||
return $boxes;
|
||||
}
|
||||
@@ -888,4 +845,284 @@ class Coupon {
|
||||
] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get coupon data for React editor
|
||||
*/
|
||||
public function formipay_get_coupon() {
|
||||
|
||||
check_ajax_referer( 'formipay-admin', '_wpnonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( [ 'message' => 'Unauthorized' ] );
|
||||
}
|
||||
|
||||
$post_id = isset($_REQUEST['id']) ? intval($_REQUEST['id']) : 0;
|
||||
|
||||
if ( empty($post_id) ) {
|
||||
wp_send_json_error( [ 'message' => esc_html__( 'Coupon ID is required.', 'formipay' ) ] );
|
||||
}
|
||||
|
||||
$post = get_post($post_id);
|
||||
if ( ! $post || $post->post_type !== 'formipay-coupon' ) {
|
||||
wp_send_json_error( [ 'message' => esc_html__( 'Coupon not found.', 'formipay' ) ] );
|
||||
}
|
||||
|
||||
$global_currencies = get_global_currency_array();
|
||||
|
||||
// Build coupon data
|
||||
$coupon_data = [
|
||||
'ID' => $post_id,
|
||||
'post_title' => $post->post_title,
|
||||
'active' => formipay_get_post_meta($post_id, 'active'),
|
||||
'type' => formipay_get_post_meta($post_id, 'type'),
|
||||
'amount_percentage' => formipay_get_post_meta($post_id, 'amount_percentage'),
|
||||
'case_sensitive' => formipay_get_post_meta($post_id, 'case_sensitive'),
|
||||
'free_shipping' => formipay_get_post_meta($post_id, 'free_shipping'),
|
||||
'quantity_active' => formipay_get_post_meta($post_id, 'quantity_active'),
|
||||
'use_limit' => formipay_get_post_meta($post_id, 'use_limit'),
|
||||
'date_limit' => formipay_get_post_meta($post_id, 'date_limit'),
|
||||
'amounts_fixed' => [],
|
||||
'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') ?: [],
|
||||
];
|
||||
|
||||
// Get fixed amounts for each currency
|
||||
foreach ($global_currencies as $currency) {
|
||||
$currency_raw = $currency['currency'];
|
||||
$currency_parts = explode(':::', $currency_raw);
|
||||
$currency_code = $currency_parts[0] ?? '';
|
||||
$symbol = formipay_get_currency_data_by_value($currency_raw, 'symbol');
|
||||
|
||||
$amount_fixed = formipay_get_post_meta($post_id, 'amount_fixed_' . $symbol);
|
||||
if ( $amount_fixed ) {
|
||||
$coupon_data['amounts_fixed'][] = [
|
||||
'currency' => $currency_code,
|
||||
'symbol' => $symbol,
|
||||
'amount' => $amount_fixed,
|
||||
];
|
||||
}
|
||||
|
||||
$max_amount = formipay_get_post_meta($post_id, 'max_amount_' . $symbol);
|
||||
if ( $max_amount ) {
|
||||
$coupon_data['max_amounts'][] = [
|
||||
'currency' => $currency_code,
|
||||
'symbol' => $symbol,
|
||||
'amount' => $max_amount,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
wp_send_json_success( $coupon_data );
|
||||
}
|
||||
|
||||
/**
|
||||
* Save coupon data from React editor
|
||||
*/
|
||||
public function formipay_save_coupon() {
|
||||
|
||||
check_ajax_referer( 'formipay-admin', '_wpnonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( [ 'message' => 'Unauthorized' ] );
|
||||
}
|
||||
|
||||
$post_id = isset($_REQUEST['id']) ? intval($_REQUEST['id']) : 0;
|
||||
$title = isset($_REQUEST['title']) ? sanitize_text_field(wp_unslash($_REQUEST['title'])) : '';
|
||||
|
||||
if ( empty($title) ) {
|
||||
wp_send_json_error( [ 'message' => esc_html__( 'Coupon code is required.', 'formipay' ) ] );
|
||||
}
|
||||
|
||||
$global_currencies = get_global_currency_array();
|
||||
|
||||
// Prepare post data
|
||||
$post_data = [
|
||||
'post_title' => $title,
|
||||
'post_type' => 'formipay-coupon',
|
||||
'post_status' => 'publish',
|
||||
];
|
||||
|
||||
if ( $post_id > 0 ) {
|
||||
$post_data['ID'] = $post_id;
|
||||
$post_data['post_status'] = get_post_status($post_id);
|
||||
$new_post_id = wp_update_post($post_data);
|
||||
} else {
|
||||
$new_post_id = wp_insert_post($post_data);
|
||||
}
|
||||
|
||||
if ( is_wp_error($new_post_id) ) {
|
||||
wp_send_json_error( [ 'message' => $new_post_id->get_error_message() ] );
|
||||
}
|
||||
|
||||
// Save meta fields
|
||||
$meta_fields = [
|
||||
'active',
|
||||
'type',
|
||||
'amount_percentage',
|
||||
'case_sensitive',
|
||||
'free_shipping',
|
||||
'quantity_active',
|
||||
'use_limit',
|
||||
'date_limit',
|
||||
];
|
||||
|
||||
foreach ($meta_fields as $field) {
|
||||
$value = isset($_REQUEST[$field]) ? wp_unslash($_REQUEST[$field]) : '';
|
||||
update_post_meta($new_post_id, $field, $value);
|
||||
}
|
||||
|
||||
// Save fixed amounts
|
||||
foreach ($global_currencies as $currency) {
|
||||
$symbol = formipay_get_currency_data_by_value($currency['currency'], 'symbol');
|
||||
|
||||
if ( isset($_REQUEST['amount_fixed_' . $symbol]) ) {
|
||||
update_post_meta($new_post_id, 'amount_fixed_' . $symbol, wp_unslash($_REQUEST['amount_fixed_' . $symbol]));
|
||||
}
|
||||
|
||||
if ( isset($_REQUEST['max_amount_' . $symbol]) ) {
|
||||
update_post_meta($new_post_id, 'max_amount_' . $symbol, wp_unslash($_REQUEST['max_amount_' . $symbol]));
|
||||
}
|
||||
}
|
||||
|
||||
// Save relation fields
|
||||
$relation_fields = ['forms', 'products', 'customers'];
|
||||
foreach ($relation_fields as $field) {
|
||||
if ( isset($_REQUEST[$field]) ) {
|
||||
$values = is_array($_REQUEST[$field]) ? array_map('intval', $_REQUEST[$field]) : [];
|
||||
update_post_meta($new_post_id, $field, $values);
|
||||
}
|
||||
}
|
||||
|
||||
wp_send_json_success( [
|
||||
'message' => esc_html__( 'Coupon saved successfully.', 'formipay' ),
|
||||
'id' => $new_post_id,
|
||||
] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add React metabox for coupon editor
|
||||
*/
|
||||
public function add_react_metabox() {
|
||||
add_meta_box(
|
||||
'formipay_coupon_reactor_metabox',
|
||||
__( 'Coupon Settings', 'formipay' ),
|
||||
[$this, 'render_react_metabox'],
|
||||
'formipay-coupon',
|
||||
'normal',
|
||||
'high'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render React metabox container
|
||||
*/
|
||||
public function render_react_metabox($post) {
|
||||
?>
|
||||
<div
|
||||
data-formipay-metabox="coupon"
|
||||
data-post-id="<?php echo esc_attr($post->ID); ?>"
|
||||
class="formipay-react-metabox-container"
|
||||
>
|
||||
<div class="formipay-loading">
|
||||
<div class="formipay-spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Render React metabox template (hidden)
|
||||
*/
|
||||
public function render_react_metabox_template() {
|
||||
global $post;
|
||||
|
||||
if (!$post || $post->post_type !== 'formipay-coupon') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get global currencies
|
||||
$global_currencies = get_global_currency_array();
|
||||
|
||||
?>
|
||||
<script type="text/javascript">
|
||||
window.formipayGlobalCurrencies = <?php echo wp_json_encode($global_currencies); ?>;
|
||||
window.formipayGetFlag = function(currencyRaw) {
|
||||
<?php
|
||||
foreach ($global_currencies as $currency) {
|
||||
echo 'if (currencyRaw === "' . esc_js($currency['currency']) . '") return "' . esc_url(formipay_get_flag_by_currency($currency['currency'])) . '";';
|
||||
}
|
||||
?>
|
||||
return '';
|
||||
};
|
||||
</script>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Autocomplete search for relation fields
|
||||
*/
|
||||
public function formipay_autocomplete_search() {
|
||||
|
||||
check_ajax_referer( 'formipay-admin', '_wpnonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( [ 'message' => 'Unauthorized' ] );
|
||||
}
|
||||
|
||||
$post_type = isset($_REQUEST['post_type']) ? sanitize_text_field(wp_unslash($_REQUEST['post_type'])) : '';
|
||||
$search = isset($_REQUEST['search']) ? sanitize_text_field(wp_unslash($_REQUEST['search'])) : '';
|
||||
$include = isset($_REQUEST['include']) ? array_map('intval', (array) $_REQUEST['include']) : [];
|
||||
|
||||
if (empty($post_type)) {
|
||||
wp_send_json_error( [ 'message' => 'Invalid request' ] );
|
||||
}
|
||||
|
||||
// Resolve labels for specific IDs (pre-selected items)
|
||||
if (!empty($include)) {
|
||||
$query = get_posts([
|
||||
'post_type' => $post_type,
|
||||
'post__in' => $include,
|
||||
'posts_per_page' => -1,
|
||||
'post_status' => 'any',
|
||||
]);
|
||||
$results = [];
|
||||
if (!empty($query)) {
|
||||
foreach ($query as $post) {
|
||||
$results[] = [
|
||||
'value' => $post->ID,
|
||||
'label' => $post->post_title,
|
||||
];
|
||||
}
|
||||
}
|
||||
wp_send_json_success($results);
|
||||
return;
|
||||
}
|
||||
|
||||
// Search by keyword
|
||||
if (strlen($search) < 2) {
|
||||
wp_send_json_error( [ 'message' => 'Invalid request' ] );
|
||||
}
|
||||
|
||||
$query = get_posts([
|
||||
'post_type' => $post_type,
|
||||
's' => $search,
|
||||
'posts_per_page' => 20,
|
||||
'post_status' => ['publish', 'draft'],
|
||||
]);
|
||||
|
||||
$results = [];
|
||||
if (!empty($query)) {
|
||||
foreach ($query as $post) {
|
||||
$results[] = [
|
||||
'value' => $post->ID,
|
||||
'label' => $post->post_title,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
wp_send_json_success($results);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -33,6 +33,7 @@ class Product {
|
||||
|
||||
add_action( 'wp_ajax_formipay_product_get_currencies', [$this, 'formipay_product_get_currencies'] );
|
||||
add_action( 'wp_ajax_get_product_variables', [$this, 'get_product_variables'] );
|
||||
add_action( 'wp_ajax_get_product_attributes', [$this, 'get_product_attributes'] );
|
||||
|
||||
add_action('save_post', [$this, 'save_product'], 10, 2);
|
||||
|
||||
@@ -85,14 +86,21 @@ class Product {
|
||||
|
||||
public function add_submenu() {
|
||||
|
||||
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'] );
|
||||
add_action( 'save_post', [$this, 'save_product_metabox_fields'], 10, 2 );
|
||||
|
||||
add_submenu_page(
|
||||
'formipay',
|
||||
__( 'Products', 'formipay' ),
|
||||
__( 'Products', 'formipay' ),
|
||||
__('Products', 'formipay'),
|
||||
__('Products', 'formipay'),
|
||||
'manage_options',
|
||||
'formipay-products',
|
||||
[$this, 'formipay_products'],
|
||||
[$this, 'formipay_product'],
|
||||
2
|
||||
);
|
||||
|
||||
add_submenu_page(
|
||||
'formipay',
|
||||
__('Categories', 'formipay'),
|
||||
@@ -105,6 +113,10 @@ class Product {
|
||||
|
||||
}
|
||||
|
||||
public function formipay_product() {
|
||||
\Formipay\Admin\ReactAdmin::render_mount_point('products');
|
||||
}
|
||||
|
||||
public function enqueue_admin() {
|
||||
// Assets now handled by ReactAdmin class
|
||||
return;
|
||||
@@ -227,9 +239,71 @@ class Product {
|
||||
|
||||
}
|
||||
|
||||
public function formipay_products_react() {
|
||||
|
||||
ReactAdmin::render_mount_point('products');
|
||||
}
|
||||
|
||||
public function add_react_metabox() {
|
||||
add_meta_box(
|
||||
'formipay_product_settings',
|
||||
__('Settings', 'formipay'),
|
||||
[$this, 'render_react_metabox'],
|
||||
'formipay-product',
|
||||
'normal',
|
||||
'high'
|
||||
);
|
||||
}
|
||||
|
||||
public function render_react_metabox($post) {
|
||||
echo '<div data-formipay-field-renderer="product" data-post-id="' . esc_attr($post->ID) . '"></div>';
|
||||
}
|
||||
|
||||
public function render_react_metabox_template() {
|
||||
global $post;
|
||||
|
||||
if (!$post || $post->post_type !== 'formipay-product') {
|
||||
return;
|
||||
}
|
||||
|
||||
$config = \Formipay\Admin\FieldConfigBridge::get_config_for_post($post->ID, $post->post_type);
|
||||
|
||||
// Get multi-currency settings from formipay_settings option
|
||||
$settings = get_option('formipay_settings', []);
|
||||
$is_multicurrency = !empty($settings['enable_multicurrency']);
|
||||
$multicurrencies = $settings['multicurrencies'] ?? [];
|
||||
|
||||
// Build global_selected_currencies from multicurrencies array
|
||||
$global_selected = [];
|
||||
foreach ($multicurrencies as $currency) {
|
||||
if (isset($currency['currency'])) {
|
||||
$code = explode(':::', $currency['currency'])[0];
|
||||
$global_selected[$code] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Use the same helper functions for consistency
|
||||
$global_currencies = get_global_currency_array();
|
||||
$default_currency = formipay_default_currency();
|
||||
|
||||
$product_details = [
|
||||
'multicurrency' => $is_multicurrency,
|
||||
'default_currency' => $default_currency,
|
||||
'global_currencies' => $global_currencies,
|
||||
'global_selected_currencies' => $global_selected,
|
||||
'currency_flags' => formipay_get_all_currency_flags(),
|
||||
];
|
||||
?>
|
||||
<script type="text/javascript">
|
||||
window.formipayFieldConfig = <?php echo wp_json_encode($config); ?>;
|
||||
window.formipayProductDetails = <?php echo wp_json_encode($product_details); ?>;
|
||||
</script>
|
||||
<?php
|
||||
}
|
||||
|
||||
public function formipay_products() {
|
||||
// React admin
|
||||
\Formipay\Admin\ReactAdmin::render_mount_point('products');
|
||||
|
||||
ReactAdmin::render_mount_point('products');
|
||||
}
|
||||
|
||||
public function cpt_post_fields_box($boxes) {
|
||||
@@ -248,7 +322,106 @@ class Product {
|
||||
$fields = apply_filters( 'formipay/product-config', $fields );
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
public function save_product_metabox_fields($post_id, $post) {
|
||||
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
|
||||
return $post_id;
|
||||
}
|
||||
|
||||
if (!current_user_can('manage_options')) {
|
||||
return $post_id;
|
||||
}
|
||||
|
||||
if (!isset($_POST['_wpnonce']) || !wp_verify_nonce($_POST['_wpnonce'], 'update-post_' . $post_id)) {
|
||||
return $post_id;
|
||||
}
|
||||
|
||||
if ($post->post_type !== 'formipay-product') {
|
||||
return $post_id;
|
||||
}
|
||||
|
||||
$meta_fields = [
|
||||
'product_type',
|
||||
'setting_product_price_regular',
|
||||
'setting_product_price_sale',
|
||||
'free_shipping',
|
||||
// Shipping fields
|
||||
'shipping_method',
|
||||
'flat_rate_type',
|
||||
'flat_rate_amount',
|
||||
'flat_rate_label',
|
||||
'free_shipping_label',
|
||||
'free_shipping_add_to_order_review',
|
||||
// Shipping dimensions (for carrier API calculation)
|
||||
'product_weight',
|
||||
'product_length',
|
||||
'product_width',
|
||||
'product_height',
|
||||
// Access and status
|
||||
'product_accesses',
|
||||
'product_access_to_email',
|
||||
'active',
|
||||
];
|
||||
|
||||
$global_currencies = get_global_currency_array();
|
||||
|
||||
foreach ($meta_fields as $field) {
|
||||
if (isset($_POST[$field])) {
|
||||
$value = wp_unslash($_POST[$field]);
|
||||
update_post_meta($post_id, $field, $value);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($global_currencies as $currency) {
|
||||
$symbol = formipay_get_currency_data_by_value($currency['currency'], 'symbol');
|
||||
|
||||
if (isset($_POST['setting_product_price_regular_' . $symbol])) {
|
||||
update_post_meta($post_id, 'setting_product_price_regular_' . $symbol, wp_unslash($_POST['setting_product_price_regular_' . $symbol]));
|
||||
}
|
||||
|
||||
if (isset($_POST['max_amount_' . $symbol])) {
|
||||
update_post_meta($post_id, 'max_amount_' . $symbol, wp_unslash($_POST['max_amount_' . $symbol]));
|
||||
}
|
||||
|
||||
// Save flat rate amounts per currency
|
||||
if (isset($_POST['flat_rate_amount_' . $symbol])) {
|
||||
update_post_meta($post_id, 'flat_rate_amount_' . $symbol, wp_unslash($_POST['flat_rate_amount_' . $symbol]));
|
||||
}
|
||||
}
|
||||
|
||||
// Save product variations (JSON from VariationField)
|
||||
if (isset($_POST['product_variations'])) {
|
||||
$variations_json = wp_unslash($_POST['product_variations']);
|
||||
$variations_data = json_decode($variations_json, true);
|
||||
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_array($variations_data)) {
|
||||
update_post_meta($post_id, 'product_variations', $variations_json);
|
||||
|
||||
// Also save the legacy format for backward compatibility
|
||||
if (isset($variations_data['variations']) && is_array($variations_data['variations'])) {
|
||||
$legacy_variations = $variations_data['variations'];
|
||||
update_post_meta($post_id, 'product_variables', wp_json_encode($legacy_variations));
|
||||
}
|
||||
|
||||
// Save attributes separately for legacy compatibility
|
||||
if (isset($variations_data['attributes']) && is_array($variations_data['attributes'])) {
|
||||
$legacy_attributes = [];
|
||||
foreach ($variations_data['attributes'] as $attr) {
|
||||
$legacy_attributes[] = [
|
||||
'attribute_name' => $attr['attribute_name'] ?? '',
|
||||
'attribute_type' => $attr['attribute_type'] ?? 'select',
|
||||
'attribute_variations' => $attr['attribute_variations'] ?? [],
|
||||
];
|
||||
}
|
||||
update_post_meta($post_id, 'product_variation_attributes', wp_json_encode($legacy_attributes));
|
||||
}
|
||||
} else {
|
||||
delete_post_meta($post_id, 'product_variations');
|
||||
}
|
||||
}
|
||||
|
||||
return $post_id;
|
||||
}
|
||||
|
||||
public function general_config($fields) {
|
||||
@@ -434,7 +607,68 @@ class Product {
|
||||
|
||||
$product_currency_group = apply_filters( 'formipay/product-settings/tab:general/group:product-currency', $product_currency_group );
|
||||
|
||||
$general_all_fields = array_merge($product_details_group, $product_currency_group);
|
||||
// Shipping Dimensions Group (for physical products only)
|
||||
$shipping_dimensions_group = array(
|
||||
'setting_product_shipping_dimensions' => array(
|
||||
'type' => 'group_title',
|
||||
'label' => __( 'Shipping Dimensions', 'formipay' ),
|
||||
'description' => __( 'Weight and dimensions for carrier shipping calculation.', 'formipay' ),
|
||||
'dependency' => array(
|
||||
'key' => 'product_type',
|
||||
'value' => 'physical'
|
||||
),
|
||||
'group' => 'started'
|
||||
),
|
||||
'product_weight' => array(
|
||||
'type' => 'number',
|
||||
'label' => __( 'Weight (kg)', 'formipay' ),
|
||||
'step' => 0.01,
|
||||
'min' => 0,
|
||||
'placeholder' => '0.00',
|
||||
'dependency' => array(
|
||||
'key' => 'product_type',
|
||||
'value' => 'physical'
|
||||
)
|
||||
),
|
||||
'product_length' => array(
|
||||
'type' => 'number',
|
||||
'label' => __( 'Length (cm)', 'formipay' ),
|
||||
'step' => 0.1,
|
||||
'min' => 0,
|
||||
'placeholder' => '0.0',
|
||||
'dependency' => array(
|
||||
'key' => 'product_type',
|
||||
'value' => 'physical'
|
||||
)
|
||||
),
|
||||
'product_width' => array(
|
||||
'type' => 'number',
|
||||
'label' => __( 'Width (cm)', 'formipay' ),
|
||||
'step' => 0.1,
|
||||
'min' => 0,
|
||||
'placeholder' => '0.0',
|
||||
'dependency' => array(
|
||||
'key' => 'product_type',
|
||||
'value' => 'physical'
|
||||
)
|
||||
),
|
||||
'product_height' => array(
|
||||
'type' => 'number',
|
||||
'label' => __( 'Height (cm)', 'formipay' ),
|
||||
'step' => 0.1,
|
||||
'min' => 0,
|
||||
'placeholder' => '0.0',
|
||||
'dependency' => array(
|
||||
'key' => 'product_type',
|
||||
'value' => 'physical'
|
||||
),
|
||||
'group' => 'ended'
|
||||
),
|
||||
);
|
||||
|
||||
$shipping_dimensions_group = apply_filters( 'formipay/product-settings/tab:general/group:shipping-dimensions', $shipping_dimensions_group );
|
||||
|
||||
$general_all_fields = array_merge($product_details_group, $product_currency_group, $shipping_dimensions_group);
|
||||
|
||||
$general_all_fields = apply_filters( 'formipay/product-settings/tab:general', $general_all_fields );
|
||||
|
||||
@@ -448,77 +682,28 @@ class Product {
|
||||
}
|
||||
|
||||
public function variations_config($fields) {
|
||||
// Product Variations Attribute Group
|
||||
$product_attributes_group = array(
|
||||
// Product Variations - Unified React field with attributes and variations table
|
||||
$product_variations_group = array(
|
||||
'setting_product_variations' => array(
|
||||
'type' => 'group_title',
|
||||
'label' => __( 'Attributes', 'formipay' ),
|
||||
'description' => __( 'First we need to build the attribute of this product. For example Color, Size, License Site Count.', 'formipay' ),
|
||||
'label' => __( 'Product Variations', 'formipay' ),
|
||||
'description' => __( 'Configure attributes and their combinations to generate product variations with multi-currency pricing.', 'formipay' ),
|
||||
'group' => 'started'
|
||||
),
|
||||
'product_has_variation' => array(
|
||||
'type' => 'checkbox',
|
||||
'label' => __( 'Product has variations', 'formipay' ),
|
||||
),
|
||||
'product_variation_attributes' => array(
|
||||
'type' => 'repeater',
|
||||
'label' => __('Attributes', 'formipay'),
|
||||
'description' => __( 'Your attributes will generate variation automatically.', 'formipay' ),
|
||||
'fields' => [
|
||||
'attribute_name' => [
|
||||
'type' => 'text',
|
||||
'label' => __( 'Attribute Name', 'formipay' ),
|
||||
'description' => __( 'e.g. Color, Size, etc', 'formipay' ),
|
||||
'is_group_title' => true
|
||||
],
|
||||
'attribute_variations' => [
|
||||
'type' => 'repeater',
|
||||
'label' => esc_html__( 'Variation', 'formipay' ),
|
||||
'fields' => [
|
||||
'variation_label' => [
|
||||
'type' => 'text',
|
||||
'label' => __( 'Title', 'formipay' ),
|
||||
'description' => __( 'e.g. Red, XL, etc', 'formipay' ),
|
||||
'required' => true,
|
||||
'is_group_title' => true
|
||||
],
|
||||
'variation_value' => [
|
||||
'type' => 'text',
|
||||
'label' => __( 'Value', 'formipay' ),
|
||||
'description' => __( 'e.g. red, xl, etc', 'formipay' ),
|
||||
'required' => true
|
||||
]
|
||||
],
|
||||
],
|
||||
],
|
||||
'dependency' => array(
|
||||
'key' => 'product_has_variation',
|
||||
'value' => 'not_empty'
|
||||
),
|
||||
'product_variations' => array(
|
||||
'type' => 'variation',
|
||||
'label' => __( 'Variations', 'formipay' ),
|
||||
'description' => __( 'Add attributes (e.g., Color, Size) and their values. Variations will be generated automatically from all combinations.', 'formipay' ),
|
||||
'name' => 'product_variations',
|
||||
),
|
||||
);
|
||||
|
||||
$product_attributes_group = apply_filters( 'formipay/product-settings/tab:general/group:product-attributes', $product_attributes_group );
|
||||
$product_variations_group = apply_filters( 'formipay/product-settings/tab:variations/group:product-variations', $product_variations_group );
|
||||
|
||||
$last_product_attributes_group = array_key_last($product_attributes_group);
|
||||
$product_attributes_group[$last_product_attributes_group]['group'] = 'ended';
|
||||
$last_product_variations_group = array_key_last($product_variations_group);
|
||||
$product_variations_group[$last_product_variations_group]['group'] = 'ended';
|
||||
|
||||
// Product Variations Attribute Group
|
||||
|
||||
// Define your product variations field group somewhere in your plugin/theme
|
||||
$product_variations_table_html = file_get_contents(FORMIPAY_PATH . 'admin/templates/product-variations.php');
|
||||
|
||||
$product_variations_group = [
|
||||
'variation_table' => [
|
||||
'type' => 'html',
|
||||
'label' => __( 'Variations', 'formipay' ),
|
||||
'html' => $product_variations_table_html
|
||||
],
|
||||
];
|
||||
|
||||
$variation_all_fields = array_merge($product_attributes_group, $product_variations_group);
|
||||
|
||||
$variation_all_fields = apply_filters( 'formipay/product-settings/tab:variations', $variation_all_fields );
|
||||
$variation_all_fields = apply_filters( 'formipay/product-settings/tab:variations', $product_variations_group );
|
||||
|
||||
$fields['formipay_product_settings']['variation'] = array(
|
||||
'name' => __('Variations', 'formipay'),
|
||||
@@ -959,6 +1144,25 @@ class Product {
|
||||
wp_send_json_error();
|
||||
}
|
||||
|
||||
public function get_product_attributes() {
|
||||
$post_id = intval($_POST['post_id'] ?? 0);
|
||||
|
||||
// Check permissions
|
||||
if (!current_user_can('edit_post', $post_id)) {
|
||||
wp_send_json_error(['message' => 'Unauthorized']);
|
||||
}
|
||||
|
||||
// Get attributes from legacy meta key
|
||||
$data = get_post_meta($post_id, 'product_variation_attributes', true);
|
||||
$json = is_string($data) ? json_decode($data, true) : $data;
|
||||
|
||||
if (is_array($json)) {
|
||||
wp_send_json_success($json);
|
||||
}
|
||||
|
||||
wp_send_json_success([]);
|
||||
}
|
||||
|
||||
public function save_product_depracated($post_id, $post) {
|
||||
// Verify nonce and permissions here if you have a nonce field (recommended)
|
||||
|
||||
|
||||
@@ -653,6 +653,7 @@ class Render {
|
||||
wp_enqueue_script( 'choices', FORMIPAY_URL . 'vendor/ChoicesJS/choices.min.js', [], FORMIPAY_VERSION, true );
|
||||
wp_enqueue_script( 'formipay-popup', FORMIPAY_URL . 'public/assets/js/popup-action.js', ['jquery', 'choices'], FORMIPAY_VERSION, true);
|
||||
wp_enqueue_script( 'formipay-form', FORMIPAY_URL . 'public/assets/js/form-action.js', ['jquery', 'choices'], FORMIPAY_VERSION, true);
|
||||
wp_enqueue_script( 'formipay-checkout-shipping', FORMIPAY_URL . 'public/assets/js/checkout-shipping.js', ['jquery', 'formipay-form'], FORMIPAY_VERSION, true);
|
||||
// Localize data for all forms
|
||||
$form_data = [
|
||||
'ajax_url' => admin_url('admin-ajax.php'),
|
||||
@@ -662,6 +663,16 @@ class Render {
|
||||
];
|
||||
wp_localize_script('formipay-form', 'formipay_form', $form_data);
|
||||
|
||||
// Localize shipping labels for checkout
|
||||
$shipping_data = [
|
||||
'labels' => [
|
||||
'country' => __('Shipping Country', 'formipay'),
|
||||
'selectCountry' => __('Select your country', 'formipay'),
|
||||
'shippingMethod' => __('Shipping Method', 'formipay'),
|
||||
]
|
||||
];
|
||||
wp_localize_script('formipay-checkout-shipping', 'formipay_shipping', $shipping_data);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -766,6 +777,11 @@ class Render {
|
||||
$allowed_currency_pack = $this->resolve_allowed_currencies($post_id);
|
||||
$currency_code = $allowed_currency_pack['default_code'];
|
||||
$currency_cfg = $this->resolve_currency_config($currency_code);
|
||||
|
||||
// Get form shipping settings
|
||||
$form_settings = get_post_meta($post_id, 'formipay_form_settings', true);
|
||||
$shipping_enabled = $form_settings['shipping_enabled'] ?? 'no_shipping';
|
||||
|
||||
$form_data[$post_id] = [
|
||||
'form_id' => $post_id,
|
||||
'currency' => formipay_post_currency($post_id),
|
||||
@@ -796,6 +812,7 @@ class Render {
|
||||
'static_products' => array_filter(array_map('absint', explode(',', (string) formipay_get_post_meta($post_id, 'static_products')))),
|
||||
'static_items' => json_decode((string) formipay_get_post_meta($post_id, 'static_items'), true) ?: [],
|
||||
'currency_code' => (function($c){ $p = explode(':::', (string)$c); return $p[0] ?? 'IDR'; })(formipay_post_currency($post_id)),
|
||||
'shipping_enabled' => $shipping_enabled, // Form-level shipping setting
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,9 @@ class FlatRate extends Shipping {
|
||||
|
||||
parent::__construct();
|
||||
|
||||
add_filter( 'formipay/product-config/tab:shipping/method', [$this, 'add_shipping_method'], 15 );
|
||||
add_filter( 'formipay/product-config/tab:shipping', [$this, 'add_shipping_settings'], 15 );
|
||||
// Register flat rate as a form-level shipping method
|
||||
add_filter( 'formipay/form-settings/tab:shipping/method', [$this, 'add_shipping_method'], 15 );
|
||||
add_filter( 'formipay/form-settings/tab:shipping', [$this, 'add_shipping_settings'], 15 );
|
||||
|
||||
// Add to order details
|
||||
add_filter( 'formipay/order/order-details', [$this, 'add_shipping_to_order_details'], 99, 3 );
|
||||
@@ -33,85 +34,66 @@ class FlatRate extends Shipping {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Add flat rate settings to form shipping configuration
|
||||
* These fields are shown when "Flat Rate" is selected as the shipping method
|
||||
*/
|
||||
public function add_shipping_settings($fields) {
|
||||
|
||||
// Get global currencies configuration
|
||||
$global_currencies = get_global_currency_array();
|
||||
|
||||
// Basic flat rate fields (type and label)
|
||||
$flat_rate_fields = array(
|
||||
$this->shipping_method.'_group' => array(
|
||||
'type' => 'group_title',
|
||||
'label' => __( 'Flat Rate Setup', 'formipay' ),
|
||||
'description' => __( 'Configure flat rate shipping cost for this form', 'formipay' ),
|
||||
'dependency' => array(
|
||||
array(
|
||||
'key' => 'product_type',
|
||||
'value' => 'physical',
|
||||
'section' => 'general'
|
||||
),
|
||||
array(
|
||||
'key' => 'shipping_method',
|
||||
'key' => 'shipping_enabled',
|
||||
'value' => 'flat_rate'
|
||||
)
|
||||
),
|
||||
'dependencies' => '&&',
|
||||
'group' => 'started'
|
||||
),
|
||||
$this->shipping_method.'_type' => array(
|
||||
'type' => 'select',
|
||||
'label' => __( 'Type', 'formipay' ),
|
||||
'options' => array(
|
||||
'fixed' => __( 'Fixed', 'formipay' ),
|
||||
'percentage' => __( 'Percentage', 'formipay' )
|
||||
'fixed' => __( 'Fixed Amount', 'formipay' ),
|
||||
'percentage' => __( 'Percentage of Order Total', 'formipay' )
|
||||
),
|
||||
'value' => 'fixed',
|
||||
'dependency' => array(
|
||||
array(
|
||||
'key' => 'product_type',
|
||||
'value' => 'physical',
|
||||
'section' => 'general'
|
||||
),
|
||||
array(
|
||||
'key' => 'shipping_method',
|
||||
'key' => 'shipping_enabled',
|
||||
'value' => 'flat_rate'
|
||||
)
|
||||
),
|
||||
'dependencies' => '&&',
|
||||
),
|
||||
$this->shipping_method.'_amount' => array(
|
||||
'type' => 'number',
|
||||
'label' => __( 'Amount', 'formipay' ),
|
||||
'value' => '10',
|
||||
'dependency' => array(
|
||||
array(
|
||||
'key' => 'product_type',
|
||||
'value' => 'physical',
|
||||
'section' => 'general'
|
||||
),
|
||||
array(
|
||||
'key' => 'shipping_method',
|
||||
'value' => 'flat_rate'
|
||||
)
|
||||
),
|
||||
'dependencies' => '&&',
|
||||
),
|
||||
$this->shipping_method.'_label' => array(
|
||||
'type' => 'text',
|
||||
'label' => __( 'Label', 'formipay' ),
|
||||
'description' => __( 'This will be shown in Order Review and Order Details', 'formipay' ),
|
||||
'value' => __( 'Shipping Fee', 'formipay' ),
|
||||
'dependency' => array(
|
||||
array(
|
||||
'key' => 'product_type',
|
||||
'value' => 'physical',
|
||||
'section' => 'general'
|
||||
),
|
||||
array(
|
||||
'key' => 'shipping_method',
|
||||
'value' => 'flat_rate'
|
||||
)
|
||||
),
|
||||
'dependencies' => '&&',
|
||||
'group' => 'ended'
|
||||
),
|
||||
);
|
||||
|
||||
// Add per-currency amount fields
|
||||
foreach ($global_currencies as $currency) {
|
||||
// Get the currency code (first part of triple) - this is used for meta key suffix
|
||||
$currency_code = formipay_get_currency_data_by_value($currency['currency'], 'symbol');
|
||||
|
||||
$step = ($currency['decimal_digits'] ?? 2) > 0 ? pow(10, -($currency['decimal_digits'] ?? 2)) : 1;
|
||||
$is_last = ($currency === end($global_currencies));
|
||||
|
||||
$flat_rate_fields[$this->shipping_method.'_amount_'.$currency_code] = array(
|
||||
'type' => 'number',
|
||||
'label' => sprintf(__( 'Amount (%s)', 'formipay' ), $currency_code),
|
||||
'description' => $is_last ? __( 'Shipping cost for this form (not per-product)', 'formipay' ) : '',
|
||||
'step' => $step,
|
||||
'min' => 0,
|
||||
'placeholder' => $is_last ? __( 'Enter Amount...', 'formipay' ) : __( 'Auto', 'formipay' ),
|
||||
'dependency' => array(
|
||||
'key' => 'shipping_enabled',
|
||||
'value' => 'flat_rate'
|
||||
),
|
||||
'group' => $is_last ? 'ended' : null,
|
||||
);
|
||||
}
|
||||
|
||||
// Merge fields into the main fields array
|
||||
foreach($flat_rate_fields as $key => $value){
|
||||
$fields[$key] = $value;
|
||||
}
|
||||
@@ -120,20 +102,59 @@ class FlatRate extends Shipping {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Add shipping cost to order details
|
||||
*
|
||||
* @param array $details Order details array
|
||||
* @param int $form_id Product/form ID
|
||||
* @param array $order_data Order data from submission
|
||||
* @return array Updated order details
|
||||
*/
|
||||
public function add_shipping_to_order_details( $details, $form_id, $order_data ) {
|
||||
|
||||
if( formipay_get_post_meta($form_id, 'product_type') == 'physical' && formipay_get_post_meta($form_id, 'shipping_method')){
|
||||
if ( formipay_get_post_meta($form_id, 'product_type') == 'physical' && formipay_get_post_meta($form_id, 'shipping_method') == 'flat_rate' ) {
|
||||
|
||||
$amount = floatval( formipay_price_format( formipay_get_post_meta( $form_id, 'flat_rate_amount' ) ) );
|
||||
$flat_rate_type = formipay_get_post_meta($form_id, 'flat_rate_type');
|
||||
$flat_rate_label = formipay_get_post_meta($form_id, 'flat_rate_label');
|
||||
|
||||
if( formipay_get_post_meta($form_id, 'flat_rate_type') == 'percentage' ) {
|
||||
$price = floatval( formipay_get_post_meta($form_id, 'product_price') );
|
||||
$calculate = $price * $amount / 100;
|
||||
// Get the selected currency from request (same way Order class does it)
|
||||
$currency = isset($_REQUEST['currency']) ? sanitize_text_field( wp_unslash($_REQUEST['currency']) ) : (string) formipay_default_currency('code');
|
||||
|
||||
// Get flat rate amount - check for currency-specific first, then fallback to base
|
||||
$flat_rate_amount = formipay_get_post_meta($form_id, 'flat_rate_amount_' . $currency);
|
||||
if (empty($flat_rate_amount)) {
|
||||
$flat_rate_amount = formipay_get_post_meta($form_id, 'flat_rate_amount');
|
||||
}
|
||||
|
||||
$amount = floatval( formipay_price_format($flat_rate_amount) );
|
||||
|
||||
// For percentage-based, calculate from actual product price paid
|
||||
if ( $flat_rate_type == 'percentage' ) {
|
||||
// Find the actual product price from order details (already currency-aware)
|
||||
$product_price = 0;
|
||||
foreach ($details as $item) {
|
||||
if (isset($item['context']) && $item['context'] == 'product') {
|
||||
// Use the first product's amount (already in selected currency)
|
||||
$product_price = floatval($item['amount']);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no product found in details, fallback to lookup by currency
|
||||
if ($product_price == 0) {
|
||||
$regular_key = 'setting_product_price_regular_' . $currency;
|
||||
$sale_key = 'setting_product_price_sale_' . $currency;
|
||||
$regular_price = formipay_get_post_meta($form_id, $regular_key);
|
||||
$sale_price = formipay_get_post_meta($form_id, $sale_key);
|
||||
$product_price = ($sale_price !== '' && $sale_price !== null) ? floatval($sale_price) : floatval($regular_price);
|
||||
}
|
||||
|
||||
$calculate = $product_price * $amount / 100;
|
||||
$amount = floatval($calculate);
|
||||
}
|
||||
|
||||
$details[] = [
|
||||
'item' => formipay_get_post_meta($form_id, 'flat_rate_label'),
|
||||
'item' => $flat_rate_label,
|
||||
'amount' => $amount,
|
||||
'subtotal' => $amount
|
||||
];
|
||||
|
||||
@@ -4,6 +4,15 @@ use Formipay\Traits\SingletonTrait;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) exit;
|
||||
|
||||
/**
|
||||
* Abstract Shipping Class - Core Carrier Extension System
|
||||
*
|
||||
* This class provides the hook system for carrier API extensions.
|
||||
* Carriers like Rajaongkir, Biteship, etc. can extend Formipay shipping
|
||||
* by registering themselves via the provided hooks.
|
||||
*
|
||||
* @phase 4 - Carrier API Extension System
|
||||
*/
|
||||
abstract class Shipping {
|
||||
|
||||
use SingletonTrait;
|
||||
@@ -14,8 +23,42 @@ abstract class Shipping {
|
||||
|
||||
protected function __construct() {
|
||||
|
||||
// Phase 1-3: Core shipping functionality
|
||||
add_filter( 'formipay/global-settings', [$this, 'add_setting_shipping_menu'], 15 );
|
||||
add_filter( 'formipay/product-config', [$this, 'add_form_shipping_menu'], 75 );
|
||||
add_filter( 'formipay/form-config', [$this, 'add_form_shipping_config'], 75 );
|
||||
add_filter( 'formipay/global-settings/tab:shipping', [$this, 'add_global_shipping_settings'], 15 );
|
||||
|
||||
// Phase 4: Carrier Extension Hooks
|
||||
// Carrier registration - allows carriers to register themselves
|
||||
add_filter( 'formipay/shipping/carriers', '__return_empty_array', 5 );
|
||||
|
||||
// Carrier API keys - inject into global shipping settings
|
||||
add_filter( 'formipay/global-settings/tab:shipping', [$this, 'add_carrier_api_settings'], 20 );
|
||||
|
||||
// Checkout address fields - inject carrier-specific address fields
|
||||
add_filter( 'formipay/checkout/shipping-address-fields', [$this, 'get_carrier_address_fields'], 10, 3 );
|
||||
|
||||
// Live rate fetching - allow carriers to provide real-time rates
|
||||
add_filter( 'formipay/shipping/live-rates', '__return_empty_array', 10, 4 );
|
||||
|
||||
// AJAX handler for testing carrier connection
|
||||
add_action( 'wp_ajax_formipay_test_carrier_connection', [$this, 'ajax_test_carrier_connection'], 10 );
|
||||
add_action( 'wp_ajax_nopriv_formipay_test_carrier_connection', [$this, 'ajax_test_carrier_connection'], 10 );
|
||||
|
||||
// Phase 5: Checkout Integration
|
||||
// AJAX endpoint for getting available shipping methods for checkout
|
||||
add_action( 'wp_ajax_formipay_get_shipping_methods', [$this, 'ajax_get_shipping_methods'], 10 );
|
||||
add_action( 'wp_ajax_nopriv_formipay_get_shipping_methods', [$this, 'ajax_get_shipping_methods'], 10 );
|
||||
|
||||
// AJAX endpoint for getting supported countries
|
||||
add_action( 'wp_ajax_formipay_get_supported_countries', [$this, 'ajax_get_supported_countries'], 10 );
|
||||
add_action( 'wp_ajax_nopriv_formipay_get_supported_countries', [$this, 'ajax_get_supported_countries'], 10 );
|
||||
|
||||
// Hook to add shipping cost to cart calculation
|
||||
add_filter( 'formipay/checkout/cart/calculation', [$this, 'add_shipping_to_cart'], 10, 3 );
|
||||
|
||||
// Hook to add shipping data to order submission
|
||||
add_filter( 'formipay/order/process-data', [$this, 'add_shipping_to_order_data'], 10, 2 );
|
||||
|
||||
}
|
||||
|
||||
@@ -36,19 +79,212 @@ abstract class Shipping {
|
||||
|
||||
}
|
||||
|
||||
public function add_form_shipping_menu($fields) {
|
||||
/**
|
||||
* Add global shipping settings fields
|
||||
* This implements Phase 3 of the shipping module: Global Shipping Settings
|
||||
*/
|
||||
public function add_global_shipping_settings($fields) {
|
||||
|
||||
$shipping_methods = apply_filters( 'formipay/product-settings/tab:shipping/method', [
|
||||
// Load countries from JSON file
|
||||
$countries_json = FORMIPAY_PATH . 'admin/assets/json/country.json';
|
||||
$countries = file_exists($countries_json) ? json_decode(file_get_contents($countries_json), true) : [];
|
||||
|
||||
$country_options = [];
|
||||
if (is_array($countries)) {
|
||||
foreach ($countries as $country) {
|
||||
$code = $country['code'] ?? '';
|
||||
$name = $country['name'] ?? '';
|
||||
if ($code && $name) {
|
||||
$country_options[$code] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store Origin Section
|
||||
$fields['shipping_origin_group'] = array(
|
||||
'type' => 'group_title',
|
||||
'label' => __( 'Store Origin', 'formipay' ),
|
||||
'description' => __( 'Your business location for shipping calculations', 'formipay' ),
|
||||
'group' => 'started'
|
||||
);
|
||||
|
||||
$fields['shipping_origin_country'] = array(
|
||||
'type' => 'select',
|
||||
'label' => __( 'Origin Country', 'formipay' ),
|
||||
'options' => $country_options,
|
||||
'searchable' => true,
|
||||
'description' => __( 'Select the country where your products ship from', 'formipay' ),
|
||||
);
|
||||
|
||||
$fields['shipping_weight_unit'] = array(
|
||||
'type' => 'select',
|
||||
'label' => __( 'Weight Unit', 'formipay' ),
|
||||
'options' => array(
|
||||
'kg' => __( 'Kilograms (kg)', 'formipay' ),
|
||||
'g' => __( 'Grams (g)', 'formipay' ),
|
||||
'lb' => __( 'Pounds (lb)', 'formipay' ),
|
||||
'oz' => __( 'Ounces (oz)', 'formipay' ),
|
||||
),
|
||||
'value' => 'kg',
|
||||
);
|
||||
|
||||
$fields['shipping_dimension_unit'] = array(
|
||||
'type' => 'select',
|
||||
'label' => __( 'Dimension Unit', 'formipay' ),
|
||||
'options' => array(
|
||||
'cm' => __( 'Centimeters (cm)', 'formipay' ),
|
||||
'in' => __( 'Inches (in)', 'formipay' ),
|
||||
'm' => __( 'Meters (m)', 'formipay' ),
|
||||
),
|
||||
'value' => 'cm',
|
||||
'group' => 'ended'
|
||||
);
|
||||
|
||||
// Shipping Calculation Method
|
||||
$fields['shipping_calculation_group'] = array(
|
||||
'type' => 'group_title',
|
||||
'label' => __( 'Shipping Calculation', 'formipay' ),
|
||||
'description' => __( 'How shipping costs are calculated for orders', 'formipay' ),
|
||||
'group' => 'started'
|
||||
);
|
||||
|
||||
$fields['shipping_calculation_method'] = array(
|
||||
'type' => 'select',
|
||||
'label' => __( 'Calculation Method', 'formipay' ),
|
||||
'options' => array(
|
||||
'per_order' => __( 'Per Order (single shipping fee for entire order)', 'formipay' ),
|
||||
'per_item' => __( 'Per Item (shipping fee multiplied by quantity)', 'formipay' ),
|
||||
),
|
||||
'value' => 'per_order',
|
||||
'group' => 'ended'
|
||||
);
|
||||
|
||||
// Supported Destinations Section
|
||||
$fields['shipping_destinations_group'] = array(
|
||||
'type' => 'group_title',
|
||||
'label' => __( 'Supported Destinations', 'formipay' ),
|
||||
'description' => __( 'Configure which countries you ship to and their shipping rates', 'formipay' ),
|
||||
'group' => 'started'
|
||||
);
|
||||
|
||||
// Get enabled currencies for flat rate table
|
||||
$formipay_settings = get_option('formipay_settings', []);
|
||||
$enabled_currencies = [];
|
||||
|
||||
if (!empty($formipay_settings['multicurrencies']) && is_array($formipay_settings['multicurrencies'])) {
|
||||
foreach ($formipay_settings['multicurrencies'] as $currency) {
|
||||
if (isset($currency['currency'])) {
|
||||
$parts = explode(':::', $currency['currency']);
|
||||
$enabled_currencies[] = [
|
||||
'code' => $parts[0] ?? '',
|
||||
'title' => $parts[1] ?? '',
|
||||
'symbol' => $parts[2] ?? $parts[0] ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to default currency if multicurrency is not enabled
|
||||
if (empty($enabled_currencies)) {
|
||||
$default_currency = $formipay_settings['default_currency'] ?? 'IDR:::Indonesian rupiah:::Rp';
|
||||
$parts = explode(':::', $default_currency);
|
||||
$enabled_currencies[] = [
|
||||
'code' => $parts[0] ?? 'IDR',
|
||||
'title' => $parts[1] ?? 'Indonesian rupiah',
|
||||
'symbol' => $parts[2] ?? 'Rp',
|
||||
];
|
||||
}
|
||||
|
||||
// Build currency amount fields for the repeater
|
||||
$currency_fields = [];
|
||||
foreach ($enabled_currencies as $curr) {
|
||||
$code = $curr['code'];
|
||||
$symbol = $curr['symbol'];
|
||||
$currency_fields['flat_rate_' . $code] = array(
|
||||
'type' => 'number',
|
||||
'label' => sprintf(__( 'Flat Rate (%s)', 'formipay' ), $code),
|
||||
'step' => 0.01,
|
||||
'min' => 0,
|
||||
'placeholder' => '0.00',
|
||||
);
|
||||
}
|
||||
|
||||
// Build free shipping threshold field (use primary currency)
|
||||
$primary_currency = $enabled_currencies[0] ?? [];
|
||||
$primary_symbol = $primary_currency['symbol'] ?? '';
|
||||
|
||||
$fields['shipping_destinations'] = array(
|
||||
'type' => 'repeater',
|
||||
'label' => __( 'Destinations', 'formipay' ),
|
||||
'description' => __( 'Add countries you ship to and configure their shipping options', 'formipay' ),
|
||||
'fields' => array_merge(
|
||||
[
|
||||
'country' => array(
|
||||
'type' => 'select',
|
||||
'label' => __('Country', 'formipay'),
|
||||
'options' => $country_options,
|
||||
'required' => true,
|
||||
'searchable' => true,
|
||||
'is_group_title' => true
|
||||
),
|
||||
'rate_source' => array(
|
||||
'type' => 'select',
|
||||
'label' => __('Rate Source', 'formipay'),
|
||||
'options' => array(
|
||||
'flat_rate' => __( 'Flat Rate', 'formipay' ),
|
||||
// 'api' => __( 'Carrier API', 'formipay' ), // Phase 4
|
||||
),
|
||||
'value' => 'flat_rate',
|
||||
),
|
||||
],
|
||||
$currency_fields,
|
||||
[
|
||||
'free_shipping_threshold' => array(
|
||||
'type' => 'number',
|
||||
'label' => sprintf(__( 'Free Shipping Threshold (%s)', 'formipay' ), $primary_symbol),
|
||||
'description' => __( 'Order amount above which shipping is free. Leave empty to disable.', 'formipay' ),
|
||||
'step' => 0.01,
|
||||
'min' => 0,
|
||||
'placeholder' => 'Empty = disabled',
|
||||
),
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
// Note: Carrier API settings will be added in Phase 4
|
||||
$fields['carrier_api_note'] = array(
|
||||
'type' => 'notification_message',
|
||||
'description' => __( '
|
||||
<h3>Carrier API Integration</h3>
|
||||
<p>Live carrier rates (Rajaongkir, Biteship, etc.) will be available in Phase 4 of the shipping module.</p>
|
||||
<p>Currently, only Flat Rate and Free Shipping methods are available at the product level.</p>
|
||||
', 'formipay' ),
|
||||
);
|
||||
|
||||
return $fields;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Add shipping configuration to form settings
|
||||
* This replaces product-level shipping with form-level shipping
|
||||
*/
|
||||
public function add_form_shipping_config($fields) {
|
||||
|
||||
$shipping_methods = apply_filters( 'formipay/form-settings/tab:shipping/method', [
|
||||
'no_shipping' => [
|
||||
'method' => __( 'No Shipping Required', 'formipay' )
|
||||
],
|
||||
'flat_rate' => [
|
||||
'method' => __( 'Flat Rate', 'formipay' )
|
||||
],
|
||||
'free_shipping' => [
|
||||
'method' => __( 'Free Shipping', 'formipay' )
|
||||
]
|
||||
] );
|
||||
|
||||
$shipping_options = [];
|
||||
$shipping_fields = [];
|
||||
|
||||
foreach($shipping_methods as $id => $shipping){
|
||||
// $id = $shipping['id'];
|
||||
$label = $shipping['method'];
|
||||
if(isset($shipping['courier'])){
|
||||
$label .= ' - '.$shipping['courier'];
|
||||
@@ -59,102 +295,440 @@ abstract class Shipping {
|
||||
$shipping_options[$id] = $label;
|
||||
}
|
||||
|
||||
$shipping_fields = [
|
||||
'shipping_notice' => array(
|
||||
'type' => 'notification_message',
|
||||
'image' => FORMIPAY_URL . 'admin/assets/img/logistics.png',
|
||||
'description' => __( '
|
||||
<h1>No Shipping Method Available</h1>
|
||||
<p>Shipping methods only for physical product type. If you insist to use shipping method, change your product type first</p>
|
||||
', 'formipay' ),
|
||||
'dependency' => array(
|
||||
'key' => 'product_type',
|
||||
'value' => 'digital',
|
||||
'section' => 'general'
|
||||
),
|
||||
),
|
||||
'shipping_method' => array(
|
||||
// Main shipping configuration group
|
||||
$shipping_config_group = [
|
||||
'shipping_enable_group' => [
|
||||
'type' => 'group_title',
|
||||
'label' => __( 'Shipping Configuration', 'formipay' ),
|
||||
'description' => __( 'Configure shipping options for this form. Shipping will be calculated based on form settings, not per-product.', 'formipay' ),
|
||||
'group' => 'started'
|
||||
],
|
||||
'shipping_enabled' => [
|
||||
'type' => 'radio',
|
||||
'label' => esc_html__('Shipping Methods', 'formipay'),
|
||||
'label' => __( 'Shipping Method', 'formipay' ),
|
||||
'options' => $shipping_options,
|
||||
'dependency' => array(
|
||||
'key' => 'product_type',
|
||||
'value' => 'physical',
|
||||
'section' => 'general'
|
||||
),
|
||||
)
|
||||
'value' => 'no_shipping',
|
||||
'description' => __( 'Select how shipping should be handled for orders from this form', 'formipay' ),
|
||||
]
|
||||
];
|
||||
|
||||
$free_shipping_fields = array(
|
||||
'free_shipping_group' => array(
|
||||
'type' => 'group_title',
|
||||
'label' => __( 'Free Shipping Setup', 'formipay' ),
|
||||
'description' => __( 'Will not add any shipping fee to the order', 'formipay' ),
|
||||
'dependency' => array(
|
||||
array(
|
||||
'key' => 'product_type',
|
||||
'value' => 'physical',
|
||||
'section' => 'general'
|
||||
),
|
||||
array(
|
||||
'key' => 'shipping_method',
|
||||
'value' => 'free_shipping'
|
||||
)
|
||||
),
|
||||
'dependencies' => '&&',
|
||||
'group' => 'started'
|
||||
),
|
||||
'free_shipping_label' => array(
|
||||
'type' => 'text',
|
||||
'label' => __( 'Label', 'formipay' ),
|
||||
'value' => __( 'Free Shipping', 'formipay' ),
|
||||
'dependency' => array(
|
||||
array(
|
||||
'key' => 'product_type',
|
||||
'value' => 'physical',
|
||||
'section' => 'general'
|
||||
),
|
||||
array(
|
||||
'key' => 'shipping_method',
|
||||
'value' => 'free_shipping'
|
||||
)
|
||||
),
|
||||
'dependencies' => '&&',
|
||||
),
|
||||
'free_shipping_add_to_order_review' => array(
|
||||
'type' => 'checkbox',
|
||||
'label' => __( 'Show in Order Review', 'formipay' ),
|
||||
'dependency' => array(
|
||||
array(
|
||||
'key' => 'product_type',
|
||||
'value' => 'physical',
|
||||
'section' => 'general'
|
||||
),
|
||||
array(
|
||||
'key' => 'shipping_method',
|
||||
'value' => 'free_shipping'
|
||||
)
|
||||
),
|
||||
'dependencies' => '&&',
|
||||
'group' => 'ended'
|
||||
),
|
||||
);
|
||||
$shipping_config_group = apply_filters( 'formipay/form-settings/tab:shipping/group:config', $shipping_config_group );
|
||||
$last_config_key = array_key_last($shipping_config_group);
|
||||
$shipping_config_group[$last_config_key]['group'] = 'ended';
|
||||
|
||||
// Apply carrier-specific settings (Flat Rate, etc.)
|
||||
$carrier_settings = apply_filters( 'formipay/form-settings/tab:shipping', [] );
|
||||
|
||||
$all_shipping_fields = array_merge($shipping_config_group, $carrier_settings);
|
||||
|
||||
$fields['formipay_form_settings']['shipping'] = [
|
||||
'name' => __( 'Shipping', 'formipay' ),
|
||||
'fields' => $all_shipping_fields
|
||||
];
|
||||
|
||||
return $fields;
|
||||
|
||||
foreach($free_shipping_fields as $key => $value) {
|
||||
$shipping_fields[$key] = $value;
|
||||
}
|
||||
|
||||
$shipping_fields = apply_filters( 'formipay/product-settings/tab:shipping', $shipping_fields );
|
||||
/**
|
||||
* =============================================
|
||||
* PHASE 4: CARRIER EXTENSION HOOKS
|
||||
* =============================================
|
||||
*/
|
||||
|
||||
if(!empty($shipping_fields)){
|
||||
$fields['formipay_product_settings']['shipping'] = array(
|
||||
'name' => __( 'Shipping', 'formipay' ),
|
||||
'fields' => $shipping_fields
|
||||
/**
|
||||
* Add carrier API settings to global shipping settings
|
||||
* Carriers can hook into `formipay/global-settings/tab:shipping/carriers` to add their API key fields
|
||||
*
|
||||
* @param array $fields Existing shipping settings fields
|
||||
* @return array Updated fields with carrier API settings
|
||||
*/
|
||||
public function add_carrier_api_settings($fields) {
|
||||
|
||||
// Get all registered carriers
|
||||
$carriers = apply_filters('formipay/shipping/carriers', []);
|
||||
|
||||
if (empty($carriers)) {
|
||||
// No carriers registered, add info message
|
||||
$fields['carrier_api_info'] = array(
|
||||
'type' => 'notification_message',
|
||||
'description' => __( '
|
||||
<h3>Carrier API Integration</h3>
|
||||
<p>To enable live shipping rates, install a carrier extension plugin.</p>
|
||||
<p>Extensions register themselves via the <code>formipay/shipping/carriers</code> filter.</p>
|
||||
', 'formipay' ),
|
||||
);
|
||||
return $fields;
|
||||
}
|
||||
|
||||
// Add carrier API settings section
|
||||
$fields['carrier_api_group'] = array(
|
||||
'type' => 'group_title',
|
||||
'label' => __( 'Carrier API Keys', 'formipay' ),
|
||||
'description' => __( 'Configure API credentials for live shipping rate calculation', 'formipay' ),
|
||||
'group' => 'started'
|
||||
);
|
||||
|
||||
// Allow carriers to inject their API key fields
|
||||
$carrier_fields = apply_filters('formipay/global-settings/tab:shipping/carriers', []);
|
||||
|
||||
foreach ($carrier_fields as $key => $field) {
|
||||
$fields[$key] = $field;
|
||||
}
|
||||
|
||||
// Add test connection buttons for each carrier
|
||||
foreach ($carriers as $carrier_id => $carrier) {
|
||||
if (isset($carrier['test_connection']) && $carrier['test_connection']) {
|
||||
$fields['test_connection_' . $carrier_id] = array(
|
||||
'type' => 'html',
|
||||
'label' => __( 'Test Connection', 'formipay' ),
|
||||
'html' => sprintf(
|
||||
'<button type="button" class="button formipay-test-connection" data-carrier="%s">%s</button>
|
||||
<span class="formipay-connection-result" style="margin-left: 10px;"></span>',
|
||||
esc_attr($carrier_id),
|
||||
esc_html__('Test Connection', 'formipay')
|
||||
),
|
||||
'group' => ($carrier_id === array_key_last($carriers)) ? 'ended' : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $fields;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Get carrier-specific address fields for checkout
|
||||
* Carriers can hook into `formipay/checkout/address-fields/{carrier_id}` to provide their fields
|
||||
*
|
||||
* @param array $fields Current address fields
|
||||
* @param string $carrier_id Carrier identifier (e.g., 'rajaongkir', 'biteship')
|
||||
* @param string $country_code Destination country code
|
||||
* @return array Address fields for this carrier
|
||||
*/
|
||||
public function get_carrier_address_fields($fields, $carrier_id, $country_code) {
|
||||
|
||||
// Get carrier-specific address fields
|
||||
$carrier_fields = apply_filters('formipay/checkout/address-fields/' . $carrier_id, [], $country_code);
|
||||
|
||||
return array_merge($fields, $carrier_fields);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX handler for testing carrier connection
|
||||
* Carriers can hook into `formipay/test_carrier_connection/{carrier_id}` to handle the test
|
||||
*/
|
||||
public function ajax_test_carrier_connection() {
|
||||
|
||||
check_ajax_referer('formipay-admin', 'nonce', true);
|
||||
|
||||
if (!current_user_can('manage_options')) {
|
||||
wp_send_json_error(['message' => __('Unauthorized', 'formipay')]);
|
||||
}
|
||||
|
||||
$carrier_id = isset($_POST['carrier']) ? sanitize_text_field(wp_unslash($_POST['carrier'])) : '';
|
||||
|
||||
if (empty($carrier_id)) {
|
||||
wp_send_json_error(['message' => __('Missing carrier ID', 'formipay')]);
|
||||
}
|
||||
|
||||
// Allow carriers to handle their own connection test
|
||||
$result = apply_filters('formipay/test_carrier_connection/' . $carrier_id, [
|
||||
'success' => false,
|
||||
'message' => __('Carrier does not implement connection test', 'formipay'),
|
||||
], $_POST);
|
||||
|
||||
if ($result['success']) {
|
||||
wp_send_json_success($result);
|
||||
} else {
|
||||
wp_send_json_error($result);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method: Get registered carriers
|
||||
* Returns all carriers that have registered via the filter
|
||||
*
|
||||
* @return array Registered carriers
|
||||
*/
|
||||
public static function get_registered_carriers() {
|
||||
|
||||
return apply_filters('formipay/shipping/carriers', []);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method: Get carrier by ID
|
||||
*
|
||||
* @param string $carrier_id Carrier identifier
|
||||
* @return array|null Carrier data or null if not found
|
||||
*/
|
||||
public static function get_carrier($carrier_id) {
|
||||
|
||||
$carriers = self::get_registered_carriers();
|
||||
return $carriers[$carrier_id] ?? null;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method: Check if carrier supports a country
|
||||
*
|
||||
* @param string $carrier_id Carrier identifier
|
||||
* @param string $country_code Country code to check
|
||||
* @return bool True if carrier supports the country
|
||||
*/
|
||||
public static function carrier_supports_country($carrier_id, $country_code) {
|
||||
|
||||
$carrier = self::get_carrier($carrier_id);
|
||||
if (!$carrier) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$supported_countries = $carrier['countries'] ?? [];
|
||||
return in_array($country_code, $supported_countries, true);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method: Fetch live rates from carrier
|
||||
*
|
||||
* @param string $carrier_id Carrier identifier
|
||||
* @param array $params Rate request parameters (origin, destination, weight, dimensions)
|
||||
* @return array Available rates with costs
|
||||
*/
|
||||
public static function fetch_live_rates($carrier_id, $params) {
|
||||
|
||||
$default_params = [
|
||||
'origin_country' => '',
|
||||
'origin_city' => '',
|
||||
'origin_postcode' => '',
|
||||
'destination_country' => '',
|
||||
'destination_city' => '',
|
||||
'destination_postcode' => '',
|
||||
'weight' => 0,
|
||||
'weight_unit' => 'kg',
|
||||
'length' => 0,
|
||||
'width' => 0,
|
||||
'height' => 0,
|
||||
'dimension_unit' => 'cm',
|
||||
];
|
||||
|
||||
$params = wp_parse_args($params, $default_params);
|
||||
|
||||
return apply_filters('formipay/shipping/live-rates', [], $carrier_id, $params);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* =============================================
|
||||
* PHASE 5: CHECKOUT INTEGRATION
|
||||
* =============================================
|
||||
*/
|
||||
|
||||
/**
|
||||
* AJAX: Get available shipping methods for a form
|
||||
* Now reads from form-level shipping settings instead of global settings
|
||||
*/
|
||||
public function ajax_get_shipping_methods() {
|
||||
|
||||
check_ajax_referer('formipay-public', 'nonce', true);
|
||||
|
||||
$form_id = isset($_POST['form_id']) ? intval($_POST['form_id']) : 0;
|
||||
$country_code = isset($_POST['country']) ? sanitize_text_field(wp_unslash($_POST['country'])) : '';
|
||||
$currency = isset($_POST['currency']) ? sanitize_text_field(wp_unslash($_POST['currency'])) : '';
|
||||
|
||||
if (!$form_id) {
|
||||
wp_send_json_error(['message' => __('Invalid request', 'formipay')]);
|
||||
}
|
||||
|
||||
// Get form shipping settings
|
||||
$form_settings = get_post_meta($form_id, 'formipay_form_settings', true);
|
||||
$shipping_enabled = $form_settings['shipping_enabled'] ?? 'no_shipping';
|
||||
|
||||
$available_methods = [];
|
||||
|
||||
if ($shipping_enabled === 'flat_rate') {
|
||||
// Get flat rate from form settings
|
||||
$currency_code = $currency ?: 'IDR';
|
||||
$rate_key = 'flat_rate_amount_' . $currency_code;
|
||||
$flat_rate = floatval($form_settings[$rate_key] ?? 0);
|
||||
$flat_rate_type = $form_settings['flat_rate_type'] ?? 'fixed';
|
||||
|
||||
// For percentage, we'll calculate on frontend based on cart total
|
||||
// For now, store the type so frontend knows how to handle it
|
||||
$available_methods[] = [
|
||||
'id' => 'flat_rate',
|
||||
'name' => __('Standard Shipping', 'formipay'),
|
||||
'description' => $flat_rate_type === 'percentage'
|
||||
? sprintf(__('%s%% of order total', 'formipay'), $flat_rate)
|
||||
: __('Delivery in 3-5 business days', 'formipay'),
|
||||
'cost' => $flat_rate,
|
||||
'currency' => $currency_code,
|
||||
'type' => $flat_rate_type,
|
||||
];
|
||||
|
||||
} elseif ($shipping_enabled === 'free_shipping') {
|
||||
// Free shipping from form settings
|
||||
$free_label = $form_settings['free_shipping_label'] ?? __('Free Shipping', 'formipay');
|
||||
$available_methods[] = [
|
||||
'id' => 'free_shipping',
|
||||
'name' => $free_label,
|
||||
'description' => __('No shipping cost', 'formipay'),
|
||||
'cost' => 0,
|
||||
'currency' => $currency_code ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
// If country-specific shipping is needed in future, add check here
|
||||
// For now, form-level shipping applies to all countries
|
||||
|
||||
if (empty($available_methods)) {
|
||||
wp_send_json_error([
|
||||
'message' => __('Shipping is not available for this form', 'formipay'),
|
||||
'methods' => []
|
||||
]);
|
||||
}
|
||||
|
||||
wp_send_json_success([
|
||||
'methods' => $available_methods,
|
||||
'default_method' => $available_methods[0]['id'],
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Add shipping cost to cart calculation
|
||||
* Hooked into formipay/checkout/cart/calculation
|
||||
*/
|
||||
public function add_shipping_to_cart($cart, $form_id, $selected_currency) {
|
||||
|
||||
// Check if shipping is enabled for this form
|
||||
$form_settings = get_post_meta($form_id, 'formipay_form_settings', true);
|
||||
$shipping_enabled = $form_settings['shipping_enabled'] ?? 'no_shipping';
|
||||
|
||||
if ($shipping_enabled === 'no_shipping') {
|
||||
return $cart;
|
||||
}
|
||||
|
||||
// Get selected shipping method from POST data or session
|
||||
$shipping_method = isset($_POST['shipping_method']) ? sanitize_text_field(wp_unslash($_POST['shipping_method'])) : '';
|
||||
$shipping_country = isset($_POST['shipping_country']) ? sanitize_text_field(wp_unslash($_POST['shipping_country'])) : '';
|
||||
|
||||
if (empty($shipping_method) || empty($shipping_country)) {
|
||||
return $cart;
|
||||
}
|
||||
|
||||
// Parse shipping method ID to get cost
|
||||
// Format: flat_rate, free_shipping, or {carrier}_{service}
|
||||
if ($shipping_method === 'free_shipping') {
|
||||
// Free shipping
|
||||
$cart['shipping'] = [
|
||||
'name' => __('Free Shipping', 'formipay'),
|
||||
'cost' => 0,
|
||||
];
|
||||
} elseif ($shipping_method === 'flat_rate') {
|
||||
// Flat rate - get cost from form settings
|
||||
$currency_code = $selected_currency ?: 'IDR';
|
||||
$rate_key = 'flat_rate_amount_' . $currency_code;
|
||||
$flat_rate = floatval($form_settings[$rate_key] ?? 0);
|
||||
|
||||
// Check if percentage
|
||||
$flat_rate_type = $form_settings['flat_rate_type'] ?? 'fixed';
|
||||
if ($flat_rate_type === 'percentage') {
|
||||
$subtotal = floatval($cart['subtotal'] ?? 0);
|
||||
$flat_rate = ($subtotal * $flat_rate) / 100;
|
||||
}
|
||||
|
||||
$cart['shipping'] = [
|
||||
'name' => __('Standard Shipping', 'formipay'),
|
||||
'cost' => $flat_rate,
|
||||
];
|
||||
|
||||
// Recalculate totals
|
||||
$cart['subtotal'] = floatval($cart['subtotal'] ?? 0);
|
||||
$cart['tax'] = floatval($cart['tax'] ?? 0);
|
||||
$cart['discount'] = floatval($cart['discount'] ?? 0);
|
||||
|
||||
$cart['grand'] = $cart['subtotal'] + $cart['tax'] + $cart['shipping']['cost'] - $cart['discount'];
|
||||
}
|
||||
|
||||
return $cart;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Add shipping data to order submission
|
||||
* Hooked into formipay/order/process-data
|
||||
*/
|
||||
public function add_shipping_to_order_data($form_data, $form_id) {
|
||||
|
||||
// Check if shipping is enabled for this form
|
||||
$form_settings = get_post_meta($form_id, 'formipay_form_settings', true);
|
||||
$shipping_enabled = $form_settings['shipping_enabled'] ?? 'no_shipping';
|
||||
|
||||
if ($shipping_enabled === 'no_shipping') {
|
||||
return $form_data;
|
||||
}
|
||||
|
||||
// Add shipping info to form data
|
||||
if (isset($_POST['shipping_method'])) {
|
||||
$form_data['shipping_method'] = sanitize_text_field(wp_unslash($_POST['shipping_method']));
|
||||
}
|
||||
|
||||
if (isset($_POST['shipping_country'])) {
|
||||
$form_data['shipping_country'] = sanitize_text_field(wp_unslash($_POST['shipping_country']));
|
||||
}
|
||||
|
||||
return $form_data;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: Get supported shipping countries
|
||||
* With form-level shipping, returns all available countries
|
||||
*/
|
||||
public function ajax_get_supported_countries() {
|
||||
|
||||
// Verify nonce
|
||||
if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'formipay_public_nonce')) {
|
||||
wp_send_json_error(['message' => __('Invalid security token', 'formipay')]);
|
||||
}
|
||||
|
||||
$form_id = isset($_POST['form_id']) ? intval($_POST['form_id']) : 0;
|
||||
|
||||
if (!$form_id) {
|
||||
wp_send_json_error(['message' => __('Invalid form ID', 'formipay')]);
|
||||
}
|
||||
|
||||
// Load countries from JSON file
|
||||
$countries_json = FORMIPAY_PATH . 'admin/assets/json/country.json';
|
||||
$all_countries = file_exists($countries_json) ? json_decode(file_get_contents($countries_json), true) : [];
|
||||
|
||||
// Build country list
|
||||
$countries = [];
|
||||
if (is_array($all_countries)) {
|
||||
foreach ($all_countries as $country) {
|
||||
$code = $country['code'] ?? '';
|
||||
$name = $country['name'] ?? '';
|
||||
if ($code && $name) {
|
||||
$countries[$code] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($countries)) {
|
||||
wp_send_json_error([
|
||||
'message' => __('No countries available', 'formipay'),
|
||||
'countries' => []
|
||||
]);
|
||||
}
|
||||
|
||||
wp_send_json_success([
|
||||
'countries' => $countries,
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
9
jsconfig.json
Normal file
9
jsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/admin/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
1308
node_modules/.package-lock.json
generated
vendored
1308
node_modules/.package-lock.json
generated
vendored
File diff suppressed because it is too large
Load Diff
1689
package-lock.json
generated
1689
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@@ -15,14 +15,33 @@
|
||||
"packages-update": "wp-scripts packages-update"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@wordpress/scripts": "^27.0.0"
|
||||
"@wordpress/scripts": "^27.0.0",
|
||||
"postcss-prefix-selector": "^2.1.1",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hugeicons/core-free-icons": "^4.1.1",
|
||||
"@hugeicons/react": "^1.1.6",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@wordpress/api-fetch": "^6.0.0",
|
||||
"@wordpress/components": "^25.0.0",
|
||||
"@wordpress/data": "^9.0.0",
|
||||
"@wordpress/element": "^5.0.0",
|
||||
"@wordpress/i18n": "^4.0.0",
|
||||
"@wordpress/icons": "^9.0.0"
|
||||
"@wordpress/icons": "^9.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
26
postcss.config.js
Normal file
26
postcss.config.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const prefixSelector = require('postcss-prefix-selector');
|
||||
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
'postcss-prefix-selector': {
|
||||
prefix: '.formipay-design-system',
|
||||
transform(prefix, selector) {
|
||||
// Don't prefix root-level selectors like :root, @keyframes
|
||||
if (selector.startsWith(':root') || selector.startsWith('@keyframes') || selector.startsWith('@font-face')) {
|
||||
return selector;
|
||||
}
|
||||
// Don't prefix html/body selectors
|
||||
if (selector.startsWith('html') || selector.startsWith('body')) {
|
||||
return selector;
|
||||
}
|
||||
// Don't double-prefix
|
||||
if (selector.startsWith(prefix)) {
|
||||
return selector;
|
||||
}
|
||||
return `${prefix} ${selector}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
346
public/assets/js/checkout-shipping.js
Normal file
346
public/assets/js/checkout-shipping.js
Normal file
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* Formipay Checkout Shipping Integration
|
||||
* Handles country selection, shipping method display, and cost calculation
|
||||
*/
|
||||
|
||||
(function($) {
|
||||
'use strict';
|
||||
|
||||
const FormipayCheckoutShipping = {
|
||||
|
||||
selectedCountry: '',
|
||||
selectedMethod: '',
|
||||
availableMethods: [],
|
||||
formId: null,
|
||||
|
||||
init() {
|
||||
this.formId = $('form[data-form-id]').data('form-id');
|
||||
|
||||
if (!this.formId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.bindEvents();
|
||||
this.initializeCountrySelector();
|
||||
},
|
||||
|
||||
bindEvents() {
|
||||
// Country selection change
|
||||
$(document).on('change', '.formipay-shipping-country', (e) => {
|
||||
this.onCountryChange($(e.currentTarget).val());
|
||||
});
|
||||
|
||||
// Shipping method selection change
|
||||
$(document).on('change', '.formipay-shipping-method', (e) => {
|
||||
this.onShippingMethodChange($(e.currentTarget).val());
|
||||
});
|
||||
},
|
||||
|
||||
initializeCountrySelector() {
|
||||
// Check if this form has shipping enabled
|
||||
if (this.isShippingEnabled()) {
|
||||
// Add country selector before payment options
|
||||
this.insertCountrySelector();
|
||||
}
|
||||
},
|
||||
|
||||
isShippingEnabled() {
|
||||
// Check if shipping is enabled for this form
|
||||
// With form-level shipping, we check shipping_enabled setting
|
||||
return window.formipayFormData?.shipping_enabled &&
|
||||
window.formipayFormData.shipping_enabled !== 'no_shipping';
|
||||
},
|
||||
|
||||
insertCountrySelector() {
|
||||
const orderReviewTable = $('#formipay-review-order');
|
||||
|
||||
if (orderReviewTable.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create country selector row before subtotal
|
||||
const countryRow = `
|
||||
<tr class="formipay-shipping-row">
|
||||
<th>
|
||||
<label for="shipping-country-${this.formId}">${formipay_shipping.labels.country || 'Shipping Country'}</label>
|
||||
<select id="shipping-country-${this.formId}"
|
||||
class="formipay-shipping-country"
|
||||
name="shipping_country"
|
||||
required>
|
||||
<option value="">${formipay_shipping.labels.selectCountry || 'Select your country'}</option>
|
||||
</select>
|
||||
</th>
|
||||
<td>
|
||||
<span class="formipay-loading-spinner" style="display:none;">⏳</span>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
orderReviewTable.find('tbody').append(countryRow);
|
||||
|
||||
// Populate countries from settings
|
||||
this.loadCountries();
|
||||
},
|
||||
|
||||
loadCountries() {
|
||||
// Get supported countries from shipping settings
|
||||
$.ajax({
|
||||
url: formipay_admin.ajaxUrl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'formipay_get_supported_countries',
|
||||
nonce: formipay_admin.nonce,
|
||||
form_id: this.formId
|
||||
},
|
||||
success: (response) => {
|
||||
if (response.success && response.data.countries) {
|
||||
this.populateCountries(response.data.countries);
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
// Fallback: show all countries
|
||||
this.populateCountries(this.getAllCountries());
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
populateCountries(countries) {
|
||||
const select = $(`#shipping-country-${this.formId}`);
|
||||
select.find('option:not([value=""])').remove();
|
||||
|
||||
$.each(countries, (code, name) => {
|
||||
select.append(`<option value="${code}">${name}</option>`);
|
||||
});
|
||||
},
|
||||
|
||||
getAllCountries() {
|
||||
// Fallback country list
|
||||
return {
|
||||
'ID': 'Indonesia',
|
||||
'MY': 'Malaysia',
|
||||
'SG': 'Singapore',
|
||||
'TH': 'Thailand',
|
||||
'VN': 'Vietnam',
|
||||
'PH': 'Philippines',
|
||||
'TW': 'Taiwan',
|
||||
'HK': 'Hong Kong',
|
||||
'IN': 'India',
|
||||
'CN': 'China',
|
||||
'JP': 'Japan',
|
||||
'KR': 'South Korea',
|
||||
'AU': 'Australia',
|
||||
'NZ': 'New Zealand',
|
||||
'GB': 'United Kingdom',
|
||||
'US': 'United States',
|
||||
'CA': 'Canada',
|
||||
'FR': 'France',
|
||||
'DE': 'Germany',
|
||||
'IT': 'Italy',
|
||||
'ES': 'Spain',
|
||||
'NL': 'Netherlands',
|
||||
'BE': 'Belgium',
|
||||
'CH': 'Switzerland',
|
||||
'AT': 'Austria',
|
||||
'IE': 'Ireland',
|
||||
'DK': 'Denmark',
|
||||
'SE': 'Sweden',
|
||||
'NO': 'Norway',
|
||||
'FI': 'Finland',
|
||||
};
|
||||
},
|
||||
|
||||
onCountryChange(countryCode) {
|
||||
this.selectedCountry = countryCode;
|
||||
|
||||
if (!countryCode) {
|
||||
this.hideShippingMethods();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get available shipping methods for this country
|
||||
this.fetchShippingMethods(countryCode);
|
||||
},
|
||||
|
||||
fetchShippingMethods(countryCode) {
|
||||
const spinner = $('.formipay-loading-spinner');
|
||||
spinner.show();
|
||||
|
||||
$.ajax({
|
||||
url: formipay_admin.ajaxUrl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'formipay_get_shipping_methods',
|
||||
nonce: formipay_public.nonce,
|
||||
form_id: this.formId,
|
||||
country: countryCode,
|
||||
currency: formipay.currency_code || 'IDR'
|
||||
},
|
||||
success: (response) => {
|
||||
spinner.hide();
|
||||
|
||||
if (response.success) {
|
||||
this.availableMethods = response.data.methods || [];
|
||||
this.displayShippingMethods(this.availableMethods, response.data.default_method);
|
||||
} else {
|
||||
this.showError(response.data.message || 'Unable to load shipping methods');
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
spinner.hide();
|
||||
this.showError('Unable to connect to shipping service');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
displayShippingMethods(methods, defaultMethod) {
|
||||
// Remove existing shipping method selector if any
|
||||
$('.formipay-shipping-method-row').remove();
|
||||
|
||||
if (methods.length === 0) {
|
||||
this.showError('Shipping is not available for this form');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get order total for percentage calculations
|
||||
const orderTotal = this.getOrderTotal();
|
||||
|
||||
let methodsHtml = '<div class="formipay-shipping-methods">';
|
||||
methodsHtml += `<input type="hidden" name="shipping_method" value="${defaultMethod}" class="formipay-shipping-method-input">`;
|
||||
|
||||
$.each(methods, (index, method) => {
|
||||
const methodId = method.id;
|
||||
const isFree = method.cost === 0;
|
||||
const isPercentage = method.type === 'percentage';
|
||||
|
||||
// Calculate actual cost
|
||||
let actualCost = method.cost;
|
||||
let costDisplay = '';
|
||||
|
||||
if (isFree) {
|
||||
costDisplay = 'FREE';
|
||||
} else if (isPercentage) {
|
||||
actualCost = (orderTotal * method.cost) / 100;
|
||||
costDisplay = `${method.cost}% (${this.formatCost(actualCost, method.currency)})`;
|
||||
} else {
|
||||
costDisplay = this.formatCost(method.cost, method.currency);
|
||||
}
|
||||
|
||||
methodsHtml += `
|
||||
<div class="formipay-shipping-option" data-method="${methodId}">
|
||||
<label class="formipay-shipping-label">
|
||||
<input type="radio"
|
||||
name="shipping_method_display"
|
||||
value="${methodId}"
|
||||
${methodId === defaultMethod ? 'checked' : ''}
|
||||
class="formipay-shipping-method"
|
||||
data-cost="${actualCost}"
|
||||
data-type="${method.type || 'fixed'}"
|
||||
data-base-cost="${method.cost}">
|
||||
<span class="shipping-method-name">${method.name}</span>
|
||||
${isFree ? '<span class="badge badge-free">FREE</span>' : ''}
|
||||
<span class="shipping-method-cost">${costDisplay}</span>
|
||||
${method.description ? `<span class="shipping-method-desc">${method.description}</span>` : ''}
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
methodsHtml += '</div>';
|
||||
|
||||
// Insert after country selector
|
||||
const countryRow = $('.formipay-shipping-row');
|
||||
const shippingRow = `
|
||||
<tr class="formipay-shipping-method-row">
|
||||
<td colspan="2">
|
||||
${methodsHtml}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
countryRow.after(shippingRow);
|
||||
|
||||
// Bind shipping method change events
|
||||
$('.formipay-shipping-method').on('change', (e) => {
|
||||
const target = $(e.currentTarget);
|
||||
const cost = parseFloat(target.data('cost'));
|
||||
const type = target.data('type');
|
||||
const baseCost = parseFloat(target.data('base-cost'));
|
||||
const method = target.val();
|
||||
|
||||
// For percentage, recalculate in case order total changed
|
||||
let finalCost = cost;
|
||||
if (type === 'percentage') {
|
||||
finalCost = (this.getOrderTotal() * baseCost) / 100;
|
||||
}
|
||||
|
||||
// Update hidden input
|
||||
$('.formipay-shipping-method-input').val(method);
|
||||
|
||||
// Update order total
|
||||
this.updateOrderTotal(finalCost);
|
||||
});
|
||||
|
||||
// Trigger change on default method to set initial cost
|
||||
$(`input[name="shipping_method_display"][value="${defaultMethod}"]`).trigger('change');
|
||||
},
|
||||
|
||||
hideShippingMethods() {
|
||||
$('.formipay-shipping-method-row').remove();
|
||||
},
|
||||
|
||||
getOrderTotal() {
|
||||
// Get current order total (excluding shipping)
|
||||
const subtotalRow = $('.formipay-total-row td').text();
|
||||
const subtotal = this.parseCurrency(subtotalRow);
|
||||
return subtotal;
|
||||
},
|
||||
|
||||
updateOrderTotal(shippingCost) {
|
||||
const currentTotal = this.getOrderTotal();
|
||||
const newTotal = currentTotal + shippingCost;
|
||||
|
||||
// Update the total display
|
||||
const totalRow = $('.formipay-grand-total-row td');
|
||||
totalRow.text(this.formatCost(newTotal));
|
||||
|
||||
// Update submit button
|
||||
const submitBtn = $('.formipay-submit-button');
|
||||
const currentText = submitBtn.attr('data-button-text');
|
||||
submitBtn.html(`${currentText} - ${this.formatCost(newTotal)}`);
|
||||
},
|
||||
|
||||
formatCost(cost, currency) {
|
||||
// Format cost using same format as product prices
|
||||
const formatted = cost.toFixed(formipay.decimal_digits || 2);
|
||||
return (currency || formipay.currency || '') + ' ' + formatted;
|
||||
},
|
||||
|
||||
parseCurrency(text) {
|
||||
// Parse currency string to get numeric value
|
||||
// Removes currency symbols and formats
|
||||
const numeric = text.replace(/[^\d.-]/g, '');
|
||||
return parseFloat(numeric) || 0;
|
||||
},
|
||||
|
||||
showError(message) {
|
||||
const errorHtml = `
|
||||
<div class="formipay-shipping-error notice notice-error">
|
||||
<p>${message}</p>
|
||||
</div>
|
||||
`;
|
||||
$('.formipay-shipping-method-row').html(`<td colspan="2">${errorHtml}</td>`);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
// Initialize when document is ready
|
||||
$(document).ready(() => {
|
||||
if (typeof formipay_shipping !== 'undefined' && typeof formipay_shipping.labels !== 'undefined') {
|
||||
FormipayCheckoutShipping.init();
|
||||
}
|
||||
});
|
||||
|
||||
// Expose for global access
|
||||
window.FormipayCheckoutShipping = FormipayCheckoutShipping;
|
||||
|
||||
})(jQuery);
|
||||
@@ -11,6 +11,7 @@ import CouponsPage from '../pages/Coupons';
|
||||
import AccessPage from '../pages/Access';
|
||||
import LicensesPage from '../pages/Licenses';
|
||||
import NavigationMenu from './NavigationMenu';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import '../pages/AdminPages.css';
|
||||
|
||||
const pageComponents = {
|
||||
@@ -101,6 +102,7 @@ export default function App({ page: initialPage, initialData }) {
|
||||
|
||||
return (
|
||||
<div className="formipay-admin-wrap">
|
||||
<Toaster />
|
||||
<NavigationMenu
|
||||
currentPage={currentPage}
|
||||
onPageNavigate={handlePageNavigate}
|
||||
|
||||
146
src/admin/components/coupons/CouponMetabox.css
Normal file
146
src/admin/components/coupons/CouponMetabox.css
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Coupon Metabox Styles
|
||||
* WPCFTO-inspired design for React metabox island
|
||||
*/
|
||||
|
||||
.formipay-coupon-metabox {
|
||||
margin: -12px;
|
||||
}
|
||||
|
||||
.formipay-metabox-actions {
|
||||
padding: 20px 30px;
|
||||
background-color: #fff;
|
||||
border-top: 1px solid #f0f0f1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Radio Group */
|
||||
.formipay-radio-group {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.formipay-radio {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 8px 16px;
|
||||
border: 2px solid var(--formipay-color-border-dark);
|
||||
border-radius: var(--formipay-radius-lg);
|
||||
transition: all var(--formipay-transition-fast);
|
||||
}
|
||||
|
||||
.formipay-radio input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.formipay-radio span {
|
||||
font-weight: var(--formipay-font-weight-medium);
|
||||
color: var(--formipay-color-text-muted);
|
||||
}
|
||||
|
||||
.formipay-radio:hover {
|
||||
border-color: var(--formipay-color-primary);
|
||||
}
|
||||
|
||||
.formipay-radio.active {
|
||||
background-color: var(--formipay-color-primary);
|
||||
border-color: var(--formipay-color-primary);
|
||||
}
|
||||
|
||||
.formipay-radio.active span {
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Autocomplete Field */
|
||||
.formipay-autocomplete {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.formipay-autocomplete-selected {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.formipay-autocomplete-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background-color: var(--formipay-color-primary);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-size: var(--formipay-font-size-sm);
|
||||
}
|
||||
|
||||
.formipay-autocomplete-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.formipay-autocomplete-remove:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.formipay-autocomplete-input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.formipay-autocomplete-loading {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--formipay-color-text-muted);
|
||||
}
|
||||
|
||||
.formipay-autocomplete-results {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background-color: white;
|
||||
border: 1px solid var(--formipay-color-border-dark);
|
||||
border-radius: var(--formipay-radius-sm);
|
||||
box-shadow: var(--formipay-shadow-md);
|
||||
z-index: 100;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.formipay-autocomplete-result {
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--formipay-transition-fast);
|
||||
}
|
||||
|
||||
.formipay-autocomplete-result:hover {
|
||||
background-color: var(--formipay-color-content-bg);
|
||||
}
|
||||
|
||||
.formipay-autocomplete-result:first-child {
|
||||
border-radius: var(--formipay-radius-sm) var(--formipay-radius-sm) 0 0;
|
||||
}
|
||||
|
||||
.formipay-autocomplete-result:last-child {
|
||||
border-radius: 0 0 var(--formipay-radius-sm) var(--formipay-radius-sm);
|
||||
}
|
||||
544
src/admin/components/coupons/CouponMetabox.js
Normal file
544
src/admin/components/coupons/CouponMetabox.js
Normal file
@@ -0,0 +1,544 @@
|
||||
/**
|
||||
* Coupon Metabox - React metabox island for post.php editor
|
||||
* Uses shadcn/ui components directly
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from '@wordpress/element';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { toast } from '@/lib/toast';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
// Currency helpers
|
||||
const getGlobalCurrencies = () => {
|
||||
if (window.formipayGlobalCurrencies && Array.isArray(window.formipayGlobalCurrencies)) {
|
||||
return window.formipayGlobalCurrencies;
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const getCurrencySymbol = (raw) => {
|
||||
if (!raw || typeof raw !== 'string') return '';
|
||||
const parts = raw.split(':::');
|
||||
return parts[1] || raw;
|
||||
};
|
||||
|
||||
const getCurrencyCode = (raw) => {
|
||||
if (!raw || typeof raw !== 'string') return '';
|
||||
return raw.split(':::')[0] || raw;
|
||||
};
|
||||
|
||||
const getFlagByCurrency = (raw) => {
|
||||
if (!raw || typeof raw !== 'string') return '';
|
||||
if (window.formipayGetFlag && typeof window.formipayGetFlag === 'function') {
|
||||
return window.formipayGetFlag(raw);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
export default function CouponMetabox({ postId }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [globalCurrencies, setGlobalCurrencies] = useState([]);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
active: 'on',
|
||||
type: 'percentage',
|
||||
amount_percentage: '',
|
||||
case_sensitive: '',
|
||||
free_shipping: '',
|
||||
quantity_active: '',
|
||||
use_limit: '',
|
||||
date_limit: '',
|
||||
amounts_fixed: {},
|
||||
max_amounts: {},
|
||||
forms: [],
|
||||
products: [],
|
||||
customers: [],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setGlobalCurrencies(getGlobalCurrencies());
|
||||
if (postId > 0) loadCouponData();
|
||||
else setLoading(false);
|
||||
}, [postId]);
|
||||
|
||||
const loadCouponData = async () => {
|
||||
try {
|
||||
const res = await fetch(`${window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php'}?action=formipay-get-coupon`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ id: postId, _wpnonce: window.formipayAdmin?.nonce || '' }),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
const d = result.data;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
active: d.active || 'on',
|
||||
type: d.type || 'percentage',
|
||||
amount_percentage: d.amount_percentage || '',
|
||||
case_sensitive: d.case_sensitive || '',
|
||||
free_shipping: d.free_shipping || '',
|
||||
quantity_active: d.quantity_active || '',
|
||||
use_limit: d.use_limit || '',
|
||||
date_limit: d.date_limit || '',
|
||||
amounts_fixed: Array.isArray(d.amounts_fixed) ? d.amounts_fixed.reduce((a, i) => { if (i?.symbol) a[i.symbol] = i.amount; return a; }, {}) : {},
|
||||
max_amounts: Array.isArray(d.max_amounts) ? d.max_amounts.reduce((a, i) => { if (i?.symbol) a[i.symbol] = i.amount; return a; }, {}) : {},
|
||||
forms: Array.isArray(d.forms) ? d.forms : [],
|
||||
products: Array.isArray(d.products) ? d.products : [],
|
||||
customers: Array.isArray(d.customers) ? d.customers : [],
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load coupon:', e);
|
||||
toast.error(__('Failed to load coupon data.', 'formipay'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const set = (field, value) => setFormData(prev => ({ ...prev, [field]: value }));
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
id: postId,
|
||||
title: document.querySelector('#title input')?.value || '',
|
||||
_wpnonce: window.formipayAdmin?.nonce || '',
|
||||
...formData,
|
||||
});
|
||||
Object.entries(formData.amounts_fixed).forEach(([s, a]) => params.append(`amount_fixed_${s}`, a));
|
||||
Object.entries(formData.max_amounts).forEach(([s, a]) => params.append(`max_amount_${s}`, a));
|
||||
|
||||
const res = await fetch(`${window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php'}?action=formipay-save-coupon`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params,
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
toast.success(result.data.message || __('Coupon saved.', 'formipay'));
|
||||
} else {
|
||||
toast.error(result.data?.message || __('Failed to save coupon.', 'formipay'));
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(__('Failed to save coupon.', 'formipay'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isPercentage = formData.type === 'percentage';
|
||||
const isFixed = formData.type === 'fixed';
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4 p-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="formipay-design-system">
|
||||
<Tabs defaultValue="rules" className="w-full">
|
||||
<div className="flex items-center justify-between border-b mb-6">
|
||||
<TabsList className="bg-transparent h-auto p-0 gap-0">
|
||||
<TabsTrigger
|
||||
value="rules"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-2.5"
|
||||
>
|
||||
{__('Rules', 'formipay')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="restriction"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-2.5"
|
||||
>
|
||||
{__('Restrictions', 'formipay')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<Button onClick={handleSave} disabled={saving} size="sm">
|
||||
{saving ? __('Saving...', 'formipay') : __('Save Coupon', 'formipay')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── Rules Tab ── */}
|
||||
<TabsContent value="rules" className="space-y-6 mt-0">
|
||||
<SectionTitle>{__('General', 'formipay')}</SectionTitle>
|
||||
|
||||
<FormField id="active" label={__('Active', 'formipay')} description={__('Enable this coupon.', 'formipay')}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={formData.active === 'on'}
|
||||
onCheckedChange={(v) => set('active', v ? 'on' : '')}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formData.active === 'on' ? __('Yes', 'formipay') : __('No', 'formipay')}
|
||||
</span>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<FormField id="type" label={__('Discount Type', 'formipay')} description={__('Choose fixed or percentage discount.', 'formipay')} required>
|
||||
<Select value={formData.type} onValueChange={(v) => set('type', v)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="percentage">{__('Percentage', 'formipay')}</SelectItem>
|
||||
<SelectItem value="fixed">{__('Fixed Amount', 'formipay')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
{isPercentage && (
|
||||
<FormField id="amount_percentage" label={__('Amount (%)', 'formipay')} description={__('Discount percentage value.', 'formipay')} required>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
placeholder="0"
|
||||
value={formData.amount_percentage}
|
||||
onChange={(e) => set('amount_percentage', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{isFixed && (
|
||||
<>
|
||||
<Separator />
|
||||
<SectionTitle>{__('Discount Amount', 'formipay')}</SectionTitle>
|
||||
{globalCurrencies.map((c) => {
|
||||
if (!c?.currency) return null;
|
||||
const symbol = getCurrencySymbol(c.currency);
|
||||
const code = getCurrencyCode(c.currency);
|
||||
const flag = getFlagByCurrency(c.currency);
|
||||
const step = c.decimal_digits > 0 ? 1 / (c.decimal_digits * 10) : 1;
|
||||
return (
|
||||
<FormField key={code} id={`amount_${code}`} label={
|
||||
<span className="flex items-center gap-1.5">
|
||||
{flag && <img src={flag} alt="" width="16" className="inline-block align-middle" />}
|
||||
{__('Amount in', 'formipay')} {symbol}
|
||||
</span>
|
||||
} required>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step={step}
|
||||
placeholder={__('Enter Amount...', 'formipay')}
|
||||
value={formData.amounts_fixed[symbol] || ''}
|
||||
onChange={(e) => set('amounts_fixed', { ...formData.amounts_fixed, [symbol]: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
<SectionTitle>{__('Max Discount', 'formipay')}</SectionTitle>
|
||||
{globalCurrencies.map((c) => {
|
||||
if (!c?.currency) return null;
|
||||
const symbol = getCurrencySymbol(c.currency);
|
||||
const code = getCurrencyCode(c.currency);
|
||||
const flag = getFlagByCurrency(c.currency);
|
||||
const step = c.decimal_digits > 0 ? 1 / (c.decimal_digits * 10) : 1;
|
||||
return (
|
||||
<FormField key={`max_${code}`} id={`max_${code}`} label={
|
||||
<span className="flex items-center gap-1.5">
|
||||
{flag && <img src={flag} alt="" width="16" className="inline-block align-middle" />}
|
||||
{__('Max in', 'formipay')} {symbol}
|
||||
</span>
|
||||
} description={__('Leave empty for no limit.', 'formipay')}>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step={step}
|
||||
placeholder={__('No limit', 'formipay')}
|
||||
value={formData.max_amounts[symbol] || ''}
|
||||
onChange={(e) => set('max_amounts', { ...formData.max_amounts, [symbol]: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
);
|
||||
})}
|
||||
|
||||
<Separator />
|
||||
<SectionTitle>{__('Rules', 'formipay')}</SectionTitle>
|
||||
|
||||
<SwitchField
|
||||
id="case_sensitive"
|
||||
label={__('Case Sensitive', 'formipay')}
|
||||
description={__('Coupon codes must match exact capitalization.', 'formipay')}
|
||||
checked={formData.case_sensitive === 'on'}
|
||||
onChange={(v) => set('case_sensitive', v ? 'on' : '')}
|
||||
/>
|
||||
|
||||
<SwitchField
|
||||
id="free_shipping"
|
||||
label={__('Free Shipping', 'formipay')}
|
||||
description={__('Shipping cost will be free when this coupon is applied.', 'formipay')}
|
||||
checked={formData.free_shipping === 'on'}
|
||||
onChange={(v) => set('free_shipping', v ? 'on' : '')}
|
||||
/>
|
||||
|
||||
{isFixed && (
|
||||
<SwitchField
|
||||
id="quantity_active"
|
||||
label={__('Influenced by Quantity', 'formipay')}
|
||||
description={__('When buyer buys 4 items, 4 x discount amount will be applied.', 'formipay')}
|
||||
checked={formData.quantity_active === 'on'}
|
||||
onChange={(v) => set('quantity_active', v ? 'on' : '')}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Restrictions Tab ── */}
|
||||
<TabsContent value="restriction" className="space-y-6 mt-0">
|
||||
<SectionTitle>{__('Usage Limits', 'formipay')}</SectionTitle>
|
||||
|
||||
<FormField id="use_limit" label={__('Usage Limit', 'formipay')} description={__('Leave empty or 0 for unlimited.', 'formipay')}>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
value={formData.use_limit}
|
||||
onChange={(e) => set('use_limit', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField id="date_limit" label={__('Expiry Date', 'formipay')} description={__('Last day the coupon can be used.', 'formipay')}>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.date_limit}
|
||||
onChange={(e) => set('date_limit', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<Separator />
|
||||
<SectionTitle>{__('Apply To', 'formipay')}</SectionTitle>
|
||||
|
||||
<FormField id="forms" label={__('Forms', 'formipay')} description={__('Only selected forms can use this coupon. Leave empty for all.', 'formipay')}>
|
||||
<SearchableSelect
|
||||
postType="formipay-form"
|
||||
value={formData.forms}
|
||||
onChange={(v) => set('forms', v)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField id="products" label={__('Products', 'formipay')} description={__('Only selected products can use this coupon. Leave empty for all.', 'formipay')}>
|
||||
<SearchableSelect
|
||||
postType="formipay-product"
|
||||
value={formData.products}
|
||||
onChange={(v) => set('products', v)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField id="customers" label={__('Customers', 'formipay')} description={__('Only selected customers can use this coupon. Leave empty for all.', 'formipay')}>
|
||||
<SearchableSelect
|
||||
postType="formipay-customer"
|
||||
value={formData.customers}
|
||||
onChange={(v) => set('customers', v)}
|
||||
/>
|
||||
</FormField>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Sub-components ── */
|
||||
|
||||
function SectionTitle({ children }) {
|
||||
return <h3 className="text-sm font-medium text-muted-foreground tracking-wide uppercase">{children}</h3>;
|
||||
}
|
||||
|
||||
function FormField({ id, label, description, required, children }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_2fr] gap-4 items-start py-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={id} className={cn(required && "after:content-['*'] after:ml-0.5 after:text-destructive")}>
|
||||
{label}
|
||||
</Label>
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SwitchField({ id, label, description, checked, onChange }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor={id} className="text-sm">{label}</Label>
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
<Switch id={id} checked={checked} onCheckedChange={onChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchableSelect({ postType, value = [], onChange }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [results, setResults] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [labels, setLabels] = useState({});
|
||||
const wrapperRef = useRef(null);
|
||||
|
||||
const selected = Array.isArray(value) ? value : [];
|
||||
const ajaxUrl = window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php';
|
||||
const nonce = window.formipayAdmin?.nonce || '';
|
||||
|
||||
// Resolve labels for pre-selected IDs on mount
|
||||
useEffect(() => {
|
||||
if (selected.length === 0) return;
|
||||
const missing = selected.filter(id => !labels[id]);
|
||||
if (missing.length === 0) return;
|
||||
|
||||
const loadLabels = async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({ post_type: postType, _wpnonce: nonce });
|
||||
missing.forEach(id => params.append('include[]', id));
|
||||
const res = await fetch(`${ajaxUrl}?action=formipay-autocomplete-search&${params.toString()}`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params,
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success && Array.isArray(result.data)) {
|
||||
const newLabels = {};
|
||||
result.data.forEach(item => { newLabels[item.value] = item.label; });
|
||||
setLabels(prev => ({ ...prev, ...newLabels }));
|
||||
}
|
||||
} catch (e) {
|
||||
// Fall back to showing IDs
|
||||
}
|
||||
};
|
||||
loadLabels();
|
||||
}, [selected.join(','), postType]);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
const handleSearch = async (query) => {
|
||||
setSearch(query);
|
||||
if (query.length < 2) { setResults([]); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${ajaxUrl}?action=formipay-autocomplete-search`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ post_type: postType, search: query, _wpnonce: nonce }),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
const items = result.data || [];
|
||||
setResults(items);
|
||||
// Cache labels from search results
|
||||
const newLabels = {};
|
||||
items.forEach(item => { newLabels[item.value] = item.label; });
|
||||
setLabels(prev => ({ ...prev, ...newLabels }));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Search failed:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (item) => {
|
||||
if (!selected.includes(item.value)) {
|
||||
onChange([...selected, item.value]);
|
||||
setLabels(prev => ({ ...prev, [item.value]: item.label }));
|
||||
}
|
||||
setSearch('');
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleRemove = (val) => onChange(selected.filter(v => v !== val));
|
||||
|
||||
const getLabel = (val) => labels[val] || `#${val}`;
|
||||
|
||||
// Filter out already-selected items from results
|
||||
const availableResults = results.filter(r => !selected.includes(r.value));
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="space-y-2">
|
||||
{selected.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selected.map((val) => (
|
||||
<Badge key={val} variant="secondary" className="gap-1 pr-1">
|
||||
{getLabel(val)}
|
||||
<button type="button" className="ml-1 rounded-full hover:bg-muted-foreground/20 p-0.5" onClick={() => handleRemove(val)}>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder={__('Search to add...', 'formipay')}
|
||||
value={search}
|
||||
onChange={(e) => { handleSearch(e.target.value); setOpen(true); }}
|
||||
onFocus={() => setOpen(true)}
|
||||
/>
|
||||
{loading && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
{open && availableResults.length > 0 && (
|
||||
<div className="absolute z-50 top-full mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md overflow-hidden max-h-48 overflow-y-auto">
|
||||
{availableResults.map((item) => (
|
||||
<div
|
||||
key={item.value}
|
||||
className="px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
onClick={() => handleSelect(item)}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{open && search.length >= 2 && !loading && availableResults.length === 0 && (
|
||||
<div className="absolute z-50 top-full mt-1 w-full rounded-md border bg-popover shadow-md px-3 py-2 text-sm text-muted-foreground">
|
||||
{__('No results found.', 'formipay')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
216
src/admin/components/field-renderer/FieldTypes/SelectField.js
Normal file
216
src/admin/components/field-renderer/FieldTypes/SelectField.js
Normal file
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* SelectField - Renders select dropdown fields
|
||||
* - Non-searchable: uses shadcn/ui Select
|
||||
* - Searchable: uses Popover + Command (Combobox pattern)
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from '@wordpress/element';
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from '@/components/ui/command';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { ArrowDown01Icon, Tick01Icon, Cancel01Icon } from '@hugeicons/core-free-icons';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function HtmlContent({ html, className }) {
|
||||
if (!html) return null;
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SelectField({ field, value, onChange, error }) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Append asterisk to HTML if required
|
||||
const labelHtml = field.required
|
||||
? (field.label || '') + '<span class="text-destructive ml-0.5">*</span>'
|
||||
: field.label;
|
||||
|
||||
// Determine if searchable - follows PHP config directly
|
||||
const isSearchable = field.searchable ?? false;
|
||||
|
||||
// Get display label for current value
|
||||
const displayLabel = useMemo(() => {
|
||||
if (!value) return field.placeholder || `Select...`;
|
||||
const label = field.options?.[value];
|
||||
if (typeof label === 'string') {
|
||||
return label;
|
||||
}
|
||||
return String(label || value);
|
||||
}, [value, field.options, field.placeholder]);
|
||||
|
||||
// Filter options based on search query
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!isSearchable || !searchQuery) {
|
||||
return field.options || {};
|
||||
}
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
const filtered = {};
|
||||
|
||||
Object.entries(field.options || {}).forEach(([optValue, optLabel]) => {
|
||||
const label = typeof optLabel === 'string' ? optLabel : String(optLabel);
|
||||
if (label.toLowerCase().includes(query) || optValue.toLowerCase().includes(query)) {
|
||||
filtered[optValue] = optLabel;
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [field.options, searchQuery, isSearchable]);
|
||||
|
||||
const handleSelect = (newValue) => {
|
||||
onChange(newValue);
|
||||
setOpen(false);
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
const handleClear = (e) => {
|
||||
e.stopPropagation();
|
||||
onChange('');
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
// Non-searchable: use standard Select component
|
||||
if (!isSearchable) {
|
||||
return (
|
||||
<div className="grid grid-cols-[30%_70%] gap-4 items-start py-2.5 px-1">
|
||||
<div className="space-y-1 min-w-0">
|
||||
<Label htmlFor={field.name} className="text-sm leading-tight">
|
||||
<HtmlContent html={labelHtml} className="flex flex-wrap gap-1 items-center" />
|
||||
</Label>
|
||||
{field.description && (
|
||||
<HtmlContent
|
||||
html={field.description}
|
||||
className="text-xs text-muted-foreground wrap-break-word"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 text-left">
|
||||
<Select
|
||||
value={value || ''}
|
||||
onValueChange={onChange}
|
||||
>
|
||||
<SelectTrigger className={cn(error && "border-destructive")}>
|
||||
<SelectValue placeholder={field.placeholder || `Select...`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(field.options || {}).map(([optValue, optLabel]) => (
|
||||
<SelectItem key={optValue} value={optValue}>
|
||||
{optLabel}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive mt-1">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Searchable: use Popover + Command pattern (Combobox)
|
||||
return (
|
||||
<div className="grid grid-cols-[30%_70%] gap-4 items-start py-2.5 px-1">
|
||||
<div className="space-y-1 min-w-0">
|
||||
<Label htmlFor={field.name} className="text-sm leading-tight">
|
||||
<HtmlContent html={labelHtml} className="flex flex-wrap gap-1 items-center" />
|
||||
</Label>
|
||||
{field.description && (
|
||||
<HtmlContent
|
||||
html={field.description}
|
||||
className="text-xs text-muted-foreground wrap-break-word"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 text-left">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-between w-full rounded-[30px]! border border-input bg-background shadow-sm px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground h-9",
|
||||
!value && "text-muted-foreground",
|
||||
error && "border-destructive"
|
||||
)}
|
||||
>
|
||||
<span className="truncate flex-1 text-left">{displayLabel}</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{value && (
|
||||
<span
|
||||
className="opacity-50 hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClear(e);
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Cancel01Icon}
|
||||
size={14}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<HugeiconsIcon icon={ArrowDown01Icon} size={16} className="opacity-50" />
|
||||
</div>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0 w-[--radix-popover-trigger-width]" align="start">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={`Search ${field.label}...`}
|
||||
value={searchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
className={"focus:shadow-none! border-none!"}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
No results found
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{Object.entries(filteredOptions).map(([optValue, optLabel]) => (
|
||||
<CommandItem
|
||||
key={optValue}
|
||||
value={optValue}
|
||||
onSelect={() => handleSelect(optValue)}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Tick01Icon}
|
||||
size={14}
|
||||
className={cn(
|
||||
"mr-2",
|
||||
value === optValue ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{optLabel}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive mt-1">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1073
src/admin/components/field-renderer/FieldTypes/VariationField.js
Normal file
1073
src/admin/components/field-renderer/FieldTypes/VariationField.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { Icon } from '@wordpress/icons';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { FIELD_CATEGORIES, getFieldTypesByCategory } from '../../config/fieldTypes';
|
||||
import './FieldPalette.css';
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function FieldPalette({ onDragStart }) {
|
||||
onDragStart={(e) => handleDragStart(e, field.type)}
|
||||
title={field.label}
|
||||
>
|
||||
<Icon icon={field.icon} />
|
||||
<HugeiconsIcon icon={field.icon} size={20} />
|
||||
<span>{ field.label }</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
*/
|
||||
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { TextControl, CheckboxControl, SelectControl, TextareaControl } from '@wordpress/components';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { FIELD_TYPES } from '../../config/fieldTypes';
|
||||
import FormFieldOptions from './FormFieldOptions';
|
||||
import './FieldSettingsPanel.css';
|
||||
@@ -53,44 +57,54 @@ export default function FieldSettingsPanel({
|
||||
</div>
|
||||
|
||||
<div className="formipay-settings-content">
|
||||
<TextControl
|
||||
label={ __('Label', 'formipay') }
|
||||
<div className="formipay-settings-field">
|
||||
<Label>{ __('Label', 'formipay') }</Label>
|
||||
<Input
|
||||
value={field.label || ''}
|
||||
onChange={(value) => handleChange('label', value)}
|
||||
help={ __('The display label for this field', 'formipay') }
|
||||
onChange={(e) => handleChange('label', e.target.value)}
|
||||
/>
|
||||
<p className="formipay-settings-help">{ __('The display label for this field', 'formipay') }</p>
|
||||
</div>
|
||||
|
||||
<TextControl
|
||||
label={ __('Field ID', 'formipay') }
|
||||
<div className="formipay-settings-field">
|
||||
<Label>{ __('Field ID', 'formipay') }</Label>
|
||||
<Input
|
||||
value={field.field_id || ''}
|
||||
onChange={(value) => handleChange('field_id', value)}
|
||||
help={ __('Unique identifier for this field (used in form data)', 'formipay') }
|
||||
onChange={(e) => handleChange('field_id', e.target.value)}
|
||||
/>
|
||||
<p className="formipay-settings-help">{ __('Unique identifier for this field (used in form data)', 'formipay') }</p>
|
||||
</div>
|
||||
|
||||
{ hasPlaceholder && (
|
||||
<TextControl
|
||||
label={ __('Placeholder', 'formipay') }
|
||||
<div className="formipay-settings-field">
|
||||
<Label>{ __('Placeholder', 'formipay') }</Label>
|
||||
<Input
|
||||
value={field.placeholder || ''}
|
||||
onChange={(value) => handleChange('placeholder', value)}
|
||||
onChange={(e) => handleChange('placeholder', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) }
|
||||
|
||||
{ hasDefaultValue && (
|
||||
<TextControl
|
||||
label={ __('Default Value', 'formipay') }
|
||||
<div className="formipay-settings-field">
|
||||
<Label>{ __('Default Value', 'formipay') }</Label>
|
||||
<Input
|
||||
value={field.default_value || ''}
|
||||
onChange={(value) => handleChange('default_value', value)}
|
||||
onChange={(e) => handleChange('default_value', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) }
|
||||
|
||||
{ hasDescription && (
|
||||
<TextareaControl
|
||||
label={ __('Description', 'formipay') }
|
||||
<div className="formipay-settings-field">
|
||||
<Label>{ __('Description', 'formipay') }</Label>
|
||||
<Textarea
|
||||
value={field.description || ''}
|
||||
onChange={(value) => handleChange('description', value)}
|
||||
help={ __('Optional help text displayed below the field', 'formipay') }
|
||||
onChange={(e) => handleChange('description', e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
<p className="formipay-settings-help">{ __('Optional help text displayed below the field', 'formipay') }</p>
|
||||
</div>
|
||||
) }
|
||||
|
||||
{ hasOptions && (
|
||||
@@ -102,26 +116,36 @@ export default function FieldSettingsPanel({
|
||||
) }
|
||||
|
||||
{ hasGridColumns && (
|
||||
<SelectControl
|
||||
label={ __('Option Grid Columns', 'formipay') }
|
||||
value={field.option_grid_columns || 1}
|
||||
options={[
|
||||
{ label: __('1 Column', 'formipay'), value: 1 },
|
||||
{ label: __('2 Columns', 'formipay'), value: 2 },
|
||||
{ label: __('3 Columns', 'formipay'), value: 3 },
|
||||
{ label: __('4 Columns', 'formipay'), value: 4 },
|
||||
]}
|
||||
onChange={(value) => handleChange('option_grid_columns', parseInt(value))}
|
||||
/>
|
||||
<div className="formipay-settings-field">
|
||||
<Label>{ __('Option Grid Columns', 'formipay') }</Label>
|
||||
<Select
|
||||
value={String(field.option_grid_columns || 1)}
|
||||
onValueChange={(value) => handleChange('option_grid_columns', parseInt(value))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">{__('1 Column', 'formipay')}</SelectItem>
|
||||
<SelectItem value="2">{__('2 Columns', 'formipay')}</SelectItem>
|
||||
<SelectItem value="3">{__('3 Columns', 'formipay')}</SelectItem>
|
||||
<SelectItem value="4">{__('4 Columns', 'formipay')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) }
|
||||
|
||||
{ isRequired && (
|
||||
<CheckboxControl
|
||||
label={ __('Required Field', 'formipay') }
|
||||
<div className="formipay-settings-field formipay-settings-switch">
|
||||
<div className="formipay-switch-row">
|
||||
<Label>{ __('Required Field', 'formipay') }</Label>
|
||||
<Switch
|
||||
checked={field.is_required || false}
|
||||
onChange={(value) => handleChange('is_required', value)}
|
||||
help={ __('User must fill this field before submitting', 'formipay') }
|
||||
onCheckedChange={(value) => handleChange('is_required', value)}
|
||||
/>
|
||||
</div>
|
||||
<p className="formipay-settings-help">{ __('User must fill this field before submitting', 'formipay') }</p>
|
||||
</div>
|
||||
) }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
*/
|
||||
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { Icon } from '@wordpress/icons';
|
||||
import plus from '@wordpress/icons/build/plus';
|
||||
import { Add01Icon } from '@hugeicons/react';
|
||||
import { DEFAULT_FIELD_CONFIG, generateFieldId } from '../../config/fieldTypes';
|
||||
import FormField from './FormField';
|
||||
import './FormCanvas.css';
|
||||
@@ -61,7 +60,7 @@ export default function FormCanvas({
|
||||
>
|
||||
{fields.length === 0 ? (
|
||||
<div className="formipay-empty-state">
|
||||
<Icon icon={plus()} size={48} />
|
||||
<Add01Icon size={48} />
|
||||
<p>
|
||||
{ __('Drag fields from the palette to build your form', 'formipay') }
|
||||
</p>
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
*/
|
||||
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { Icon } from '@wordpress/icons';
|
||||
import chevronUp from '@wordpress/icons/build/chevron-up';
|
||||
import chevronDown from '@wordpress/icons/build/chevron-down';
|
||||
import trash from '@wordpress/icons/build/trash';
|
||||
import { ArrowUp01Icon, ArrowDown01Icon, Delete02Icon } from '@hugeicons/react';
|
||||
import { FIELD_TYPES } from '../../config/fieldTypes';
|
||||
import './FormField.css';
|
||||
|
||||
@@ -52,7 +49,7 @@ export default function FormField({
|
||||
disabled={!onMoveUp}
|
||||
title={ __('Move Up', 'formipay') }
|
||||
>
|
||||
<Icon icon={chevronUp()} size={16} />
|
||||
<ArrowUp01Icon size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -61,7 +58,7 @@ export default function FormField({
|
||||
disabled={!onMoveDown}
|
||||
title={ __('Move Down', 'formipay') }
|
||||
>
|
||||
<Icon icon={chevronDown()} size={16} />
|
||||
<ArrowDown01Icon size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -69,7 +66,7 @@ export default function FormField({
|
||||
onClick={(e) => { e.stopPropagation(); onDelete?.(); }}
|
||||
title={ __('Delete', 'formipay') }
|
||||
>
|
||||
<Icon icon={trash()} size={16} />
|
||||
<Delete02Icon size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
*/
|
||||
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { TextControl, Button } from '@wordpress/components';
|
||||
import { Icon as WPIcon } from '@wordpress/icons';
|
||||
import plus from '@wordpress/icons/build/plus';
|
||||
import trash from '@wordpress/icons/build/trash';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Add01Icon, Delete02Icon } from '@hugeicons/react';
|
||||
|
||||
export default function FormFieldOptions({ options = [], onChange, fieldType }) {
|
||||
const handleAddOption = () => {
|
||||
@@ -46,10 +46,10 @@ export default function FormFieldOptions({ options = [], onChange, fieldType })
|
||||
</label>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="compact"
|
||||
size="sm"
|
||||
onClick={handleAddOption}
|
||||
icon={<WPIcon icon={plus()} size={16} />}
|
||||
>
|
||||
<Add01Icon size={16} />
|
||||
{ __('Add Option', 'formipay') }
|
||||
</Button>
|
||||
</div>
|
||||
@@ -58,38 +58,45 @@ export default function FormFieldOptions({ options = [], onChange, fieldType })
|
||||
{options.map((option, index) => (
|
||||
<div key={index} className="formipay-option-item">
|
||||
<div className="formipay-option-fields">
|
||||
<TextControl
|
||||
label={__('Label', 'formipay')}
|
||||
<div className="formipay-option-field">
|
||||
<Label>{__('Label', 'formipay')}</Label>
|
||||
<Input
|
||||
value={option.label || ''}
|
||||
onChange={(value) => handleUpdateOption(index, { label: value })}
|
||||
onChange={(e) => handleUpdateOption(index, { label: e.target.value })}
|
||||
placeholder={__('Option label', 'formipay')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextControl
|
||||
label={__('Value', 'formipay')}
|
||||
<div className="formipay-option-field">
|
||||
<Label>{__('Value', 'formipay')}</Label>
|
||||
<Input
|
||||
value={option.value || ''}
|
||||
onChange={(value) => handleUpdateOption(index, { value: value })}
|
||||
onChange={(e) => handleUpdateOption(index, { value: e.target.value })}
|
||||
placeholder={__('Optional custom value', 'formipay')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextControl
|
||||
label={__('Amount', 'formipay')}
|
||||
<div className="formipay-option-field">
|
||||
<Label>{__('Amount', 'formipay')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={option.amount || ''}
|
||||
onChange={(value) => handleUpdateOption(index, { amount: value })}
|
||||
onChange={(e) => handleUpdateOption(index, { amount: e.target.value })}
|
||||
placeholder={__('0', 'formipay')}
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="formipay-option-actions">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteOption(index)}
|
||||
icon={<WPIcon icon={trash()} size={16} />}
|
||||
label={__('Delete Option', 'formipay')}
|
||||
/>
|
||||
title={__('Delete Option', 'formipay')}
|
||||
>
|
||||
<Delete02Icon size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,21 +1,43 @@
|
||||
/**
|
||||
* Full-featured DataTable component
|
||||
* Supports: selection, filtering, search, sort, pagination, actions
|
||||
*
|
||||
* Built with shadcn/ui components + Tailwind CSS.
|
||||
*/
|
||||
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { useState, useCallback, useEffect } from '@wordpress/element';
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
TextControl,
|
||||
SelectControl,
|
||||
Spinner,
|
||||
} from '@wordpress/components';
|
||||
import './DataTable.css';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { confirm } from '@/lib/confirm';
|
||||
import { toast } from '@/lib/toast';
|
||||
|
||||
// SweetAlert2 is loaded via WordPress (global scope)
|
||||
const Swal = window.Swal;
|
||||
// shadcn/ui components
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
} from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
|
||||
export default function DataTable({
|
||||
// Data fetching
|
||||
@@ -190,15 +212,15 @@ export default function DataTable({
|
||||
const handleBulkDelete = async () => {
|
||||
if (selectedRows.size === 0) return;
|
||||
|
||||
const result = await Swal.fire({
|
||||
icon: 'info',
|
||||
html: __('Do you want to delete the selected item(s)?', 'formipay'),
|
||||
showCancelButton: true,
|
||||
confirmButtonText: __('Confirm', 'formipay'),
|
||||
cancelButtonText: __('Cancel', 'formipay'),
|
||||
const confirmed = await confirm({
|
||||
title: __('Delete Selected', 'formipay'),
|
||||
message: __('Do you want to delete the selected item(s)?', 'formipay'),
|
||||
confirmText: __('Confirm', 'formipay'),
|
||||
cancelText: __('Cancel', 'formipay'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
if (confirmed) {
|
||||
await fetch(`${ajaxUrl}?action=${bulkDeleteAction}`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
@@ -213,21 +235,14 @@ export default function DataTable({
|
||||
setSelectAll(false);
|
||||
loadData();
|
||||
|
||||
Swal.fire({
|
||||
title: __('Done!', 'formipay'),
|
||||
html: __('Items deleted successfully.', 'formipay'),
|
||||
icon: 'success',
|
||||
});
|
||||
toast.success(__('Items deleted successfully.', 'formipay'));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Add New
|
||||
const handleAddNew = async () => {
|
||||
if (!newItemTitle.trim()) {
|
||||
Swal.fire({
|
||||
html: __('Title is required.', 'formipay'),
|
||||
icon: 'error',
|
||||
});
|
||||
toast.error(__('Title is required.', 'formipay'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -253,21 +268,20 @@ export default function DataTable({
|
||||
loadData();
|
||||
}
|
||||
} else {
|
||||
Swal.fire({
|
||||
html: response.data.message || __('Error creating item.', 'formipay'),
|
||||
icon: 'error',
|
||||
});
|
||||
toast.error(response.data.message || __('Error creating item.', 'formipay'));
|
||||
}
|
||||
};
|
||||
|
||||
// Compute total pages
|
||||
const totalPages = Math.ceil(total / currentPageSize);
|
||||
|
||||
return (
|
||||
<div className="formipay-data-table-wrapper">
|
||||
<div className="formipay-design-system">
|
||||
{/* Toolbar */}
|
||||
<div className="formipay-table-toolbar">
|
||||
<div className="flex items-center gap-3 mb-4 flex-wrap">
|
||||
{/* Add New Button */}
|
||||
{actions.addNew && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setIsAddModalOpen(true)}
|
||||
>
|
||||
{actions.addNew.label || __('+ Add New', 'formipay')}
|
||||
@@ -277,8 +291,7 @@ export default function DataTable({
|
||||
{/* Bulk Delete Button */}
|
||||
{actions.bulkDelete && selectable && selectedRows.size > 0 && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
isDestructive
|
||||
variant="destructive"
|
||||
onClick={handleBulkDelete}
|
||||
>
|
||||
{__('Delete Selected', 'formipay')} ({selectedRows.size})
|
||||
@@ -287,37 +300,41 @@ export default function DataTable({
|
||||
|
||||
{/* Search */}
|
||||
{searchable && (
|
||||
<TextControl
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
className="formipay-table-search"
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="max-w-75 grow"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sort */}
|
||||
{sortable && (
|
||||
<SelectControl
|
||||
<Select
|
||||
value={`${sortBy}-${sortOrder}`}
|
||||
options={[
|
||||
{ label: __('ID ↓', 'formipay'), value: 'ID-desc' },
|
||||
{ label: __('ID ↑', 'formipay'), value: 'ID-asc' },
|
||||
{ label: __('Date ↓', 'formipay'), value: 'date-desc' },
|
||||
{ label: __('Date ↑', 'formipay'), value: 'date-asc' },
|
||||
{ label: __('Title A-Z', 'formipay'), value: 'title-asc' },
|
||||
{ label: __('Title Z-A', 'formipay'), value: 'title-desc' },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
onValueChange={(value) => {
|
||||
const [id, sort] = value.split('-');
|
||||
setSortBy(id);
|
||||
setSortOrder(sort);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ID-desc">{__('ID \u2193', 'formipay')}</SelectItem>
|
||||
<SelectItem value="ID-asc">{__('ID \u2191', 'formipay')}</SelectItem>
|
||||
<SelectItem value="date-desc">{__('Date \u2193', 'formipay')}</SelectItem>
|
||||
<SelectItem value="date-asc">{__('Date \u2191', 'formipay')}</SelectItem>
|
||||
<SelectItem value="title-asc">{__('Title A-Z', 'formipay')}</SelectItem>
|
||||
<SelectItem value="title-desc">{__('Title Z-A', 'formipay')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{/* Refresh Button */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="outline"
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
>
|
||||
@@ -327,16 +344,28 @@ export default function DataTable({
|
||||
|
||||
{/* Filter Tabs */}
|
||||
{filterOptions && (
|
||||
<div className="formipay-filter-tabs">
|
||||
<div className="flex gap-2 mb-4">
|
||||
{filterOptions.options.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
className={`filter-tab ${activeFilter === option.value ? 'active' : ''}`}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
|
||||
activeFilter === option.value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'
|
||||
)}
|
||||
onClick={() => handleFilterChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
{statusCounts && (
|
||||
<span className="count">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block min-w-4.5 px-1.5 py-0.5 ml-1.5 rounded-full text-[11px] leading-none',
|
||||
activeFilter === option.value
|
||||
? 'bg-primary-foreground/20 text-primary-foreground'
|
||||
: 'bg-muted-foreground/10 text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{statusCounts[option.value] || 0}
|
||||
</span>
|
||||
)}
|
||||
@@ -346,139 +375,165 @@ export default function DataTable({
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="formipay-table-container">
|
||||
<div className="rounded-lg border bg-card">
|
||||
{loading ? (
|
||||
<div className="formipay-table-loading">
|
||||
<Spinner />
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<div className="w-full space-y-3 px-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-4 w-8" />
|
||||
<Skeleton className="h-4 flex-1" />
|
||||
<Skeleton className="h-4 flex-1" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : data.length === 0 ? (
|
||||
<div className="formipay-table-empty">
|
||||
{emptyMessage}
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<p>{emptyMessage}</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="formipay-table wp-list-table widefat fixed striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{/* Checkbox column */}
|
||||
{selectable && (
|
||||
<th className="column-select">
|
||||
<input
|
||||
type="checkbox"
|
||||
<TableHead className="w-10 text-center">
|
||||
<Checkbox
|
||||
checked={selectAll}
|
||||
onChange={handleSelectAll}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
</th>
|
||||
</TableHead>
|
||||
)}
|
||||
{/* Data columns */}
|
||||
{columns.map((column) => (
|
||||
<th key={column.key} className={`column-${column.key}`}>
|
||||
<TableHead key={column.key}>
|
||||
{column.label}
|
||||
</th>
|
||||
</TableHead>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((row, rowIndex) => {
|
||||
const rowId = row.ID || row.id;
|
||||
return (
|
||||
<tr key={rowIndex} className="formipay-table-row">
|
||||
<TableRow key={rowIndex}>
|
||||
{/* Checkbox */}
|
||||
{selectable && (
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
<TableCell className="w-10 text-center">
|
||||
<Checkbox
|
||||
checked={selectedRows.has(rowId)}
|
||||
onChange={() => handleRowSelect(rowId)}
|
||||
onCheckedChange={() => handleRowSelect(rowId)}
|
||||
/>
|
||||
</td>
|
||||
</TableCell>
|
||||
)}
|
||||
{/* Data columns */}
|
||||
{columns.map((column) => (
|
||||
<td key={column.key}>
|
||||
<TableCell key={column.key}>
|
||||
{column.render ? column.render(row) : row[column.key]}
|
||||
</td>
|
||||
</TableCell>
|
||||
))}
|
||||
</tr>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination && total > currentPageSize && (
|
||||
<div className="formipay-table-pagination">
|
||||
<div className="pagination-info">
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{__('Showing', 'formipay')} {((currentPage - 1) * currentPageSize) + 1} - {Math.min(currentPage * currentPageSize, total)} {__('of', 'formipay')} {total}
|
||||
</div>
|
||||
<div className="pagination-controls">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => setCurrentPage(1)}
|
||||
>
|
||||
{'««'}
|
||||
{'\u00AB'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
>
|
||||
{'‹'}
|
||||
{'\u2039'}
|
||||
</Button>
|
||||
<span className="page-info">
|
||||
{__('Page', 'formipay')} {currentPage} {__('of', 'formipay')} {Math.ceil(total / currentPageSize)}
|
||||
<span className="px-2 text-sm text-muted-foreground">
|
||||
{__('Page', 'formipay')} {currentPage} {__('of', 'formipay')} {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={currentPage >= Math.ceil(total / currentPageSize)}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
>
|
||||
{'›'}
|
||||
{'\u203A'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={currentPage >= Math.ceil(total / currentPageSize)}
|
||||
onClick={() => setCurrentPage(Math.ceil(total / currentPageSize))}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
>
|
||||
{'»'}
|
||||
{'\u00BB'}
|
||||
</Button>
|
||||
<SelectControl
|
||||
<Select
|
||||
value={currentPageSize.toString()}
|
||||
options={pageSizeOptions.map(size => ({
|
||||
label: size.toString(),
|
||||
value: size.toString(),
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
onValueChange={(value) => {
|
||||
setCurrentPageSize(parseInt(value));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pageSizeOptions.map(size => (
|
||||
<SelectItem key={size} value={size.toString()}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add New Modal - only render when open */}
|
||||
{actions.addNew && isAddModalOpen && (
|
||||
<Modal
|
||||
title={actions.addNew.label || __('Add New', 'formipay')}
|
||||
onRequestClose={() => {
|
||||
{/* Add New Dialog */}
|
||||
{actions.addNew && (
|
||||
<Dialog open={isAddModalOpen} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setIsAddModalOpen(false);
|
||||
setNewItemTitle('');
|
||||
}}
|
||||
>
|
||||
<TextControl
|
||||
__next40pxDefaultSize
|
||||
__nextHasNoMarginBottom
|
||||
label={__('Title', 'formipay')}
|
||||
}
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{actions.addNew.label || __('Add New', 'formipay')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<label className="text-sm font-medium mb-1.5 block">
|
||||
{__('Title', 'formipay')}
|
||||
</label>
|
||||
<Input
|
||||
placeholder={__('Enter title...', 'formipay')}
|
||||
value={newItemTitle}
|
||||
onChange={setNewItemTitle}
|
||||
onChange={(e) => setNewItemTitle(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="formipay-modal-actions">
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsAddModalOpen(false);
|
||||
setNewItemTitle('');
|
||||
@@ -487,13 +542,13 @@ export default function DataTable({
|
||||
{__('Cancel', 'formipay')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleAddNew}
|
||||
>
|
||||
{__('Create', 'formipay')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
31
src/admin/components/ui/alert.js
Normal file
31
src/admin/components/ui/alert.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Alert({ className, variant = "default", ...props }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||
variant === "default" && "bg-background text-foreground",
|
||||
variant === "destructive" && "border-destructive/50 text-destructive [&>svg]:text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }) {
|
||||
return (
|
||||
<h5 data-slot="alert-title" className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({ className, ...props }) {
|
||||
return (
|
||||
<div data-slot="alert-description" className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
26
src/admin/components/ui/badge.js
Normal file
26
src/admin/components/ui/badge.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cva } from 'class-variance-authority';
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive: "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
success: "border-transparent bg-green-100 text-green-800",
|
||||
warning: "border-transparent bg-yellow-100 text-yellow-800",
|
||||
info: "border-transparent bg-blue-100 text-blue-800",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
}
|
||||
);
|
||||
|
||||
function Badge({ className, variant, ...props }) {
|
||||
return <div data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
33
src/admin/components/ui/button.js
Normal file
33
src/admin/components/ui/button.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cva } from 'class-variance-authority';
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default", size: "default" },
|
||||
}
|
||||
);
|
||||
|
||||
function Button({ className, variant, size, ...props }) {
|
||||
return (
|
||||
<button data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
25
src/admin/components/ui/checkbox.js
Normal file
25
src/admin/components/ui/checkbox.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Checkbox({ className, checked, onCheckedChange, ...props }) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" className="h-3.5 w-3.5">
|
||||
<path d="M20 6 9 17l-5-5"/>
|
||||
</svg>
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
160
src/admin/components/ui/command.jsx
Normal file
160
src/admin/components/ui/command.jsx
Normal file
@@ -0,0 +1,160 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Search01Icon } from '@hugeicons/core-free-icons';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn("overflow-hidden p-0", className)}
|
||||
showCloseButton={showCloseButton}>
|
||||
<Command
|
||||
className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3">
|
||||
<HugeiconsIcon icon={Search01Icon} className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn("max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}) {
|
||||
return (<CommandPrimitive.Empty data-slot="command-empty" className="py-6 text-center text-sm" {...props} />);
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
86
src/admin/components/ui/dialog.js
Normal file
86
src/admin/components/ui/dialog.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Dialog({ ...props }) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({ className, ...props }) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({ className, children, ...props }) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }) {
|
||||
return (
|
||||
<div data-slot="dialog-header" className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }) {
|
||||
return (
|
||||
<div data-slot="dialog-footer" className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }) {
|
||||
return (
|
||||
<DialogPrimitive.Title data-slot="dialog-title" className={cn("text-lg font-semibold leading-none tracking-tight", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({ className, ...props }) {
|
||||
return (
|
||||
<DialogPrimitive.Description data-slot="dialog-description" className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
146
src/admin/components/ui/dialog.jsx
Normal file
146
src/admin/components/ui/dialog.jsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import * as React from "react"
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { Cancel01Icon } from '@hugeicons/core-free-icons';
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<HugeiconsIcon icon={Cancel01Icon} />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...props}>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
60
src/admin/components/ui/dropdown-menu.js
Normal file
60
src/admin/components/ui/dropdown-menu.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function DropdownMenu({ ...props }) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ ...props }) {
|
||||
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuContent({ className, sideOffset = 4, ...props }) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({ className, ...props }) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({ className, ...props }) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator data-slot="dropdown-menu-separator" className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({ className, ...props }) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label data-slot="dropdown-menu-label" className={cn("px-2 py-1.5 text-sm font-semibold", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
};
|
||||
23
src/admin/components/ui/input.js
Normal file
23
src/admin/components/ui/input.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Input({ className, type, ...props }) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-[30px] border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
/* number input resets */
|
||||
type === 'number' && "[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none",
|
||||
/* date/time/datetime input resets */
|
||||
(type === 'date' || type === 'time' || type === 'datetime-local') && "[&::-webkit-datetime-edit-fields-wrapper]:px-0 [&::-webkit-calendar-picker-indicator]:ml-1 [&::-webkit-calendar-picker-indicator]:opacity-50 [&::-webkit-calendar-picker-indicator]:cursor-pointer",
|
||||
/* file input resets */
|
||||
type === 'file' && "file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
18
src/admin/components/ui/label.js
Normal file
18
src/admin/components/ui/label.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from '@wordpress/element';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Label({ className, ...props }) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
31
src/admin/components/ui/popover.js
Normal file
31
src/admin/components/ui/popover.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Popover({ ...props }) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({ className, align = "center", sideOffset = 4, ...props }) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<div className="formipay-design-system">
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent };
|
||||
103
src/admin/components/ui/select.js
Normal file
103
src/admin/components/ui/select.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { createRoot } from '@wordpress/element';
|
||||
|
||||
function Select({ ...props }) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({ ...props }) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({ ...props }) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({ className, children, ...props }) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-[30px] border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="opacity-50"><path d="m6 9 6 6 6-6"/></svg>
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({ className, children, position = "popper", ...props }) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" && "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="opacity-50"><path d="m18 15-6-6-6 6"/></svg>
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
<SelectPrimitive.Viewport className={cn("p-1", position === "popper" && "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]")}>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="opacity-50"><path d="m6 9 6 6 6-6"/></svg>
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({ className, ...props }) {
|
||||
return (
|
||||
<SelectPrimitive.Label data-slot="select-label" className={cn("px-2 py-1.5 text-sm font-semibold", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({ className, children, ...props }) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({ className, ...props }) {
|
||||
return (
|
||||
<SelectPrimitive.Separator data-slot="select-separator" className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
};
|
||||
20
src/admin/components/ui/separator.js
Normal file
20
src/admin/components/ui/separator.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Separator({ className, orientation = "horizontal", decorative = true, ...props }) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
9
src/admin/components/ui/skeleton.js
Normal file
9
src/admin/components/ui/skeleton.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Skeleton({ className, ...props }) {
|
||||
return (
|
||||
<div data-slot="skeleton" className={cn("animate-pulse rounded-md bg-primary/10", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
21
src/admin/components/ui/sonner.js
Normal file
21
src/admin/components/ui/sonner.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Toaster as SonnerToaster } from 'sonner';
|
||||
|
||||
function Toaster({ ...props }) {
|
||||
return (
|
||||
<SonnerToaster
|
||||
data-slot="toaster"
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Toaster };
|
||||
25
src/admin/components/ui/switch.js
Normal file
25
src/admin/components/ui/switch.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as SwitchPrimitive from '@radix-ui/react-switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Switch({ className, checked, onCheckedChange, ...props }) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Switch };
|
||||
64
src/admin/components/ui/table.js
Normal file
64
src/admin/components/ui/table.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Table({ className, ...props }) {
|
||||
return (
|
||||
<div data-slot="table-container" className="relative w-full overflow-auto">
|
||||
<table data-slot="table" className={cn("w-full caption-bottom text-sm", className)} {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }) {
|
||||
return <thead data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />;
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }) {
|
||||
return <tbody data-slot="table-body" className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }) {
|
||||
return (
|
||||
<tfoot data-slot="table-footer" className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn("border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn("p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCaption({ className, ...props }) {
|
||||
return (
|
||||
<caption data-slot="table-caption" className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
|
||||
46
src/admin/components/ui/tabs.js
Normal file
46
src/admin/components/ui/tabs.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Tabs({ className, ...props }) {
|
||||
return (
|
||||
<TabsPrimitive.Root data-slot="tabs" className={cn("flex flex-col gap-2", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function TabsList({ className, ...props }) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
16
src/admin/components/ui/textarea.js
Normal file
16
src/admin/components/ui/textarea.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Textarea({ className, ...props }) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
@@ -2,23 +2,42 @@
|
||||
* Form Field Type Definitions
|
||||
*/
|
||||
|
||||
import {
|
||||
TextFontIcon,
|
||||
Link01Icon,
|
||||
Mail01Icon,
|
||||
TelephoneIcon,
|
||||
HashtagIcon,
|
||||
Calendar01Icon,
|
||||
Calendar03Icon,
|
||||
PaintBoardIcon,
|
||||
DropdownFieldTypeIcon,
|
||||
CheckmarkSquare01Icon,
|
||||
RadioButtonIcon,
|
||||
EyeIcon,
|
||||
ParagraphIcon,
|
||||
MinusSignIcon,
|
||||
NextIcon,
|
||||
Globe02Icon,
|
||||
} from '@hugeicons/core-free-icons';
|
||||
|
||||
export const FIELD_TYPES = {
|
||||
text: { label: 'Text', icon: 'text', category: 'input' },
|
||||
url: { label: 'URL', icon: 'link', category: 'input' },
|
||||
email: { label: 'Email', icon: 'email', category: 'input' },
|
||||
tel: { label: 'Telephone', icon: 'phone', category: 'input' },
|
||||
number: { label: 'Number', icon: 'number', category: 'input' },
|
||||
date: { label: 'Date', icon: 'calendar', category: 'input' },
|
||||
datetime: { label: 'Date & Time', icon: 'calendar-alt', category: 'input' },
|
||||
color: { label: 'Color', icon: 'art', category: 'input' },
|
||||
select: { label: 'Select Dropdown', icon: 'list-view', category: 'choice' },
|
||||
checkbox: { label: 'Checkbox', icon: 'checkbox', category: 'choice' },
|
||||
radio: { label: 'Radio', icon: 'radio', category: 'choice' },
|
||||
hidden: { label: 'Hidden', icon: 'hidden', category: 'advanced' },
|
||||
textarea: { label: 'Textarea', icon: 'document', category: 'input' },
|
||||
divider: { label: 'Divider', icon: 'minus', category: 'layout' },
|
||||
page_break: { label: 'Page Break', icon: 'page-break', category: 'layout' },
|
||||
country_list: { label: 'Preset: Country List', icon: 'globe', category: 'preset' },
|
||||
text: { label: 'Text', icon: TextFontIcon, category: 'input' },
|
||||
url: { label: 'URL', icon: Link01Icon, category: 'input' },
|
||||
email: { label: 'Email', icon: Mail01Icon, category: 'input' },
|
||||
tel: { label: 'Telephone', icon: TelephoneIcon, category: 'input' },
|
||||
number: { label: 'Number', icon: HashtagIcon, category: 'input' },
|
||||
date: { label: 'Date', icon: Calendar01Icon, category: 'input' },
|
||||
datetime: { label: 'Date & Time', icon: Calendar03Icon, category: 'input' },
|
||||
color: { label: 'Color', icon: PaintBoardIcon, category: 'input' },
|
||||
select: { label: 'Select Dropdown', icon: DropdownFieldTypeIcon, category: 'choice' },
|
||||
checkbox: { label: 'Checkbox', icon: CheckmarkSquare01Icon, category: 'choice' },
|
||||
radio: { label: 'Radio', icon: RadioButtonIcon, category: 'choice' },
|
||||
hidden: { label: 'Hidden', icon: EyeIcon, category: 'advanced' },
|
||||
textarea: { label: 'Textarea', icon: ParagraphIcon, category: 'input' },
|
||||
divider: { label: 'Divider', icon: MinusSignIcon, category: 'layout' },
|
||||
page_break: { label: 'Page Break', icon: NextIcon, category: 'layout' },
|
||||
country_list: { label: 'Preset: Country List', icon: Globe02Icon, category: 'preset' },
|
||||
};
|
||||
|
||||
export const FIELD_CATEGORIES = {
|
||||
|
||||
@@ -1,288 +1,489 @@
|
||||
/**
|
||||
* Formipay Design System - WPCFTO-inspired React components
|
||||
* Reusable components matching WPCFTO visual language
|
||||
* Built on shadcn/ui primitives + Tailwind CSS
|
||||
*/
|
||||
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import './WpcftoDesign.css';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Box Component
|
||||
export function Box({ children, className = '', ...props }) {
|
||||
return (
|
||||
<div className={`formipay-box ${className}`} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// shadcn/ui primitives
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input as ShadcnInput } from '@/components/ui/input';
|
||||
import { Textarea as ShadcnTextarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select as ShadcnSelect,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Button as ShadcnButton } from '@/components/ui/button';
|
||||
import {
|
||||
Alert,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
} from '@/components/ui/alert';
|
||||
import { Badge as ShadcnBadge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table as ShadcnTable,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContent,
|
||||
} from '@/components/ui/tabs';
|
||||
|
||||
// Box Child Component
|
||||
export function BoxChild({ children, className = '', ...props }) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Box
|
||||
// ---------------------------------------------------------------------------
|
||||
export function Box({ children, className, ...props }) {
|
||||
return (
|
||||
<div className={`formipay-box-child ${className}`} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Tab Navigation Component
|
||||
export function TabNav({ tabs, activeTab, onTabChange, orientation = 'vertical' }) {
|
||||
return (
|
||||
<div className={`formipay-tab-nav formipay-tab-nav-${orientation}`}>
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`formipay-nav-item ${activeTab === tab.id ? 'active' : ''}`}
|
||||
className={cn(
|
||||
'bg-[var(--formipay-color-content-bg,#f0f3f5)] rounded-[10px] mb-2.5 min-h-[80px] shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BoxChild
|
||||
// ---------------------------------------------------------------------------
|
||||
export function BoxChild({ children, className, ...props }) {
|
||||
return (
|
||||
<div className={cn(className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TabNav (WPCFTO sidebar style)
|
||||
// ---------------------------------------------------------------------------
|
||||
export function TabNav({ tabs, activeTab, onTabChange, orientation = 'vertical' }) {
|
||||
if (!tabs || !Array.isArray(tabs)) {
|
||||
console.warn('[Formipay] TabNav: tabs is not an array', tabs);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex-col w-[273px] h-auto bg-[#2c3e50] rounded-none p-5',
|
||||
orientation !== 'vertical' && 'flex-row w-auto'
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
{tabs.map((tab) => (
|
||||
<div key={tab.id}>
|
||||
<TabsTrigger
|
||||
value={tab.id}
|
||||
className={cn(
|
||||
'w-full justify-start text-[#bec5cb] uppercase text-sm',
|
||||
'data-[state=active]:bg-[#2985f7] data-[state=active]:text-white rounded-none',
|
||||
activeTab === tab.id && 'bg-[#2985f7] text-white'
|
||||
)}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
>
|
||||
<div className="formipay-nav-title">
|
||||
{tab.icon && <span className="formipay-nav-icon">{tab.icon}</span>}
|
||||
{tab.icon && <i className={tab.icon} />}
|
||||
<span>{tab.label}</span>
|
||||
</div>
|
||||
</TabsTrigger>
|
||||
|
||||
{tab.submenu && (
|
||||
<div className="flex flex-col mt-1">
|
||||
{tab.submenu.map((sub) => (
|
||||
<div
|
||||
key={`${tab.id}_${sub.id}`}
|
||||
className={cn(
|
||||
'px-4 py-2 text-sm cursor-pointer text-[#bec5cb] hover:text-white',
|
||||
activeTab === `${tab.id}_${sub.id}` &&
|
||||
'text-white bg-[#2985f7]'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTabChange(`${tab.id}_${sub.id}`);
|
||||
}}
|
||||
>
|
||||
{sub.label}
|
||||
<i className="fa fa-chevron-right ml-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Tab Panel Component
|
||||
// ---------------------------------------------------------------------------
|
||||
// TabPanel
|
||||
// ---------------------------------------------------------------------------
|
||||
export function TabPanel({ tabs, activeTab, children }) {
|
||||
if (!tabs || !Array.isArray(tabs)) {
|
||||
console.warn('[Formipay] TabPanel: tabs is not an array', tabs);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="formipay-tabs">
|
||||
<div className="flex-1">
|
||||
{tabs.map((tab, index) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`formipay-tab ${tab.id === activeTab ? 'active' : ''}`}
|
||||
className={cn(tab.id !== activeTab && 'hidden')}
|
||||
>
|
||||
<div className="formipay-tab-content">
|
||||
{typeof children === 'function' ? children(tab, index) : children}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Field Component
|
||||
// ---------------------------------------------------------------------------
|
||||
// Field (2-column WPCFTO layout)
|
||||
// ---------------------------------------------------------------------------
|
||||
export function Field({
|
||||
label,
|
||||
description,
|
||||
required = false,
|
||||
children,
|
||||
className = '',
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div className={`formipay-field ${className}`} {...props}>
|
||||
{label && (
|
||||
<div className={`formipay-field-label ${required ? 'required' : ''}`}>
|
||||
<span className="formipay-field-label-text">{label}</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-wrap justify-between p-[1.8rem_1rem_0] w-full rounded-[10px] bg-card mb-2.5',
|
||||
className
|
||||
)}
|
||||
<div className="formipay-field-content">
|
||||
{...props}
|
||||
>
|
||||
<aside className="w-[40%] pr-8">
|
||||
{label && (
|
||||
<Label className={cn(required && "after:content-['*'] after:ml-0.5 after:text-destructive")}>
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
{description && (
|
||||
<p className="mt-2 text-[13px] text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</aside>
|
||||
<div className="w-[60%]">
|
||||
{children}
|
||||
</div>
|
||||
{description && (
|
||||
<div className="formipay-field-description">{description}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Input Component
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input
|
||||
// ---------------------------------------------------------------------------
|
||||
export function Input({
|
||||
label,
|
||||
description,
|
||||
required = false,
|
||||
className = '',
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<Field label={label} description={description} required={required}>
|
||||
<input className={`formipay-input ${className}`} {...props} />
|
||||
<ShadcnInput className={cn(className)} {...props} />
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
// Textarea Component
|
||||
// ---------------------------------------------------------------------------
|
||||
// Textarea
|
||||
// ---------------------------------------------------------------------------
|
||||
export function Textarea({
|
||||
label,
|
||||
description,
|
||||
required = false,
|
||||
rows = 4,
|
||||
className = '',
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<Field label={label} description={description} required={required}>
|
||||
<textarea
|
||||
className={`formipay-textarea ${className}`}
|
||||
rows={rows}
|
||||
{...props}
|
||||
/>
|
||||
<ShadcnTextarea rows={rows} className={cn(className)} {...props} />
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
// Select Component
|
||||
// ---------------------------------------------------------------------------
|
||||
// Select
|
||||
// ---------------------------------------------------------------------------
|
||||
export function Select({
|
||||
label,
|
||||
description,
|
||||
required = false,
|
||||
options = [],
|
||||
className = '',
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
// Derive a current value from props for the Radix select
|
||||
const selectValue = props.value ?? props.defaultValue ?? undefined;
|
||||
|
||||
return (
|
||||
<Field label={label} description={description} required={required}>
|
||||
<select className={`formipay-select ${className}`} {...props}>
|
||||
<ShadcnSelect
|
||||
value={selectValue}
|
||||
onValueChange={(val) => {
|
||||
if (props.onChange) {
|
||||
props.onChange({ target: { value: val } });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={cn('w-full', className)}>
|
||||
<SelectValue placeholder={props.placeholder || __('Select...', 'formipay')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
<SelectItem key={option.value} value={String(option.value)}>
|
||||
{option.label}
|
||||
</option>
|
||||
</SelectItem>
|
||||
))}
|
||||
</select>
|
||||
</SelectContent>
|
||||
</ShadcnSelect>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
// Checkbox Component
|
||||
// ---------------------------------------------------------------------------
|
||||
// Checkbox (toggle-switch style)
|
||||
// ---------------------------------------------------------------------------
|
||||
export function Checkbox({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
className = '',
|
||||
className,
|
||||
isToggle = true,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<label className={`formipay-checkbox ${className}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
<label
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 cursor-pointer',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
onCheckedChange={(val) =>
|
||||
onChange?.({ target: { checked: val } })
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
{label && <span className="text-sm">{label}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// Button Component
|
||||
// ---------------------------------------------------------------------------
|
||||
// Button
|
||||
// ---------------------------------------------------------------------------
|
||||
const variantMap = {
|
||||
primary: 'default',
|
||||
secondary: 'secondary',
|
||||
danger: 'destructive',
|
||||
};
|
||||
const sizeMap = {
|
||||
sm: 'sm',
|
||||
md: 'default',
|
||||
lg: 'lg',
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
icon: Icon,
|
||||
children,
|
||||
className = '',
|
||||
className,
|
||||
disabled = false,
|
||||
onClick,
|
||||
...props
|
||||
}) {
|
||||
const sizeClass = size !== 'md' ? `formipay-btn-${size}` : '';
|
||||
const variantClass = `formipay-btn-${variant}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`formipay-btn ${variantClass} ${sizeClass} ${className}`}
|
||||
<ShadcnButton
|
||||
variant={variantMap[variant] || variant}
|
||||
size={sizeMap[size] || size}
|
||||
className={cn(className)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
{...props}
|
||||
>
|
||||
{Icon && <span className="formipay-btn-icon"><Icon /></span>}
|
||||
{Icon && <Icon />}
|
||||
{children}
|
||||
</button>
|
||||
</ShadcnButton>
|
||||
);
|
||||
}
|
||||
|
||||
// Repeater Component
|
||||
// ---------------------------------------------------------------------------
|
||||
// Repeater
|
||||
// ---------------------------------------------------------------------------
|
||||
export function Repeater({
|
||||
items,
|
||||
renderItem,
|
||||
onAdd,
|
||||
onRemove,
|
||||
addLabel = 'Add Item',
|
||||
className = '',
|
||||
className,
|
||||
}) {
|
||||
return (
|
||||
<div className={`formipay-repeater ${className}`}>
|
||||
<div className={cn('flex flex-col gap-3', className)}>
|
||||
{items.map((item, index) => (
|
||||
<div key={item.id || index} className={`formipay-repeater-item ${item.collapsed ? 'collapsed' : ''}`}>
|
||||
<div
|
||||
className="formipay-repeater-header"
|
||||
key={item.id || index}
|
||||
className={cn(
|
||||
'border rounded-lg overflow-hidden',
|
||||
item.collapsed && 'border-transparent'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 bg-muted/50 cursor-pointer select-none"
|
||||
onClick={() => onToggle?.(item.id)}
|
||||
>
|
||||
<div className="formipay-repeater-title">
|
||||
<span className="formipay-repeater-toggle">▼</span>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<span
|
||||
className={cn(
|
||||
'transition-transform text-xs',
|
||||
item.collapsed && '-rotate-90'
|
||||
)}
|
||||
>
|
||||
▼
|
||||
</span>
|
||||
<span>{item.title || `Item ${index + 1}`}</span>
|
||||
</div>
|
||||
<div className="formipay-repeater-actions">
|
||||
<span
|
||||
className="formipay-repeater-delete"
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-destructive text-sm px-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove?.(item.id, index);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="formipay-repeater-body">
|
||||
{renderItem(item, index)}
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{!item.collapsed && (
|
||||
<div className="p-4">{renderItem(item, index)}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button className="formipay-repeater-add" onClick={onAdd}>
|
||||
<span>+</span>
|
||||
<span>{addLabel}</span>
|
||||
</button>
|
||||
<ShadcnButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start"
|
||||
onClick={onAdd}
|
||||
>
|
||||
<span className="mr-1">+</span>
|
||||
{addLabel}
|
||||
</ShadcnButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Notice Component
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notice
|
||||
// ---------------------------------------------------------------------------
|
||||
const noticeStyles = {
|
||||
success: 'border-l-4 border-l-green-500 bg-green-50 text-green-900',
|
||||
warning: 'border-l-4 border-l-yellow-500 bg-yellow-50 text-yellow-900',
|
||||
error: 'border-l-4 border-l-red-500 bg-red-50 text-red-900',
|
||||
info: 'border-l-4 border-l-blue-500 bg-blue-50 text-blue-900',
|
||||
};
|
||||
|
||||
const noticeIcons = {
|
||||
success: '\u2713',
|
||||
warning: '\u26A0',
|
||||
error: '\u2715',
|
||||
info: '\u2139',
|
||||
};
|
||||
|
||||
export function Notice({
|
||||
type = 'info',
|
||||
title,
|
||||
children,
|
||||
onDismiss,
|
||||
className = '',
|
||||
className,
|
||||
}) {
|
||||
return (
|
||||
<div className={`formipay-notice formipay-notice-${type} ${className}`}>
|
||||
<div className="formipay-notice-icon">
|
||||
{type === 'success' && '✓'}
|
||||
{type === 'warning' && '⚠'}
|
||||
{type === 'error' && '✕'}
|
||||
{type === 'info' && 'ℹ'}
|
||||
</div>
|
||||
<div className="formipay-notice-content">
|
||||
{title && <div className="formipay-notice-title">{title}</div>}
|
||||
<div>{children}</div>
|
||||
<Alert className={cn(noticeStyles[type] || noticeStyles.info, className)}>
|
||||
<span className="absolute left-4 top-4 text-base font-bold">
|
||||
{noticeIcons[type]}
|
||||
</span>
|
||||
<div className="pl-6">
|
||||
{title && <AlertTitle>{title}</AlertTitle>}
|
||||
<AlertDescription>{children}</AlertDescription>
|
||||
</div>
|
||||
{onDismiss && (
|
||||
<button
|
||||
className="formipay-notice-dismiss"
|
||||
type="button"
|
||||
className="absolute right-3 top-3 text-muted-foreground hover:text-foreground"
|
||||
onClick={onDismiss}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
✕
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading Spinner Component
|
||||
export function Spinner({ size = 'md', className = '' }) {
|
||||
const sizeClass = size !== 'md' ? `formipay-spinner-${size}` : '';
|
||||
// ---------------------------------------------------------------------------
|
||||
// GroupTitle
|
||||
// ---------------------------------------------------------------------------
|
||||
export function GroupTitle({ title, icon, className }) {
|
||||
return (
|
||||
<div className={`formipay-loading ${className}`}>
|
||||
<div className={`formipay-spinner ${sizeClass}`}></div>
|
||||
<div
|
||||
className={cn(
|
||||
'w-full pb-3 text-muted-foreground text-sm uppercase tracking-wider border-b flex items-center gap-2 mb-4',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{icon && <i className={icon} />}
|
||||
{title}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty State Component
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spinner
|
||||
// ---------------------------------------------------------------------------
|
||||
export function Spinner({ size = 'md', className }) {
|
||||
const sizeClass = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-6 w-6',
|
||||
lg: 'h-8 w-8',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center', className)}>
|
||||
<div
|
||||
className={cn(
|
||||
'animate-spin rounded-full border-2 border-muted border-t-primary',
|
||||
sizeClass[size] || sizeClass.md
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EmptyState
|
||||
// ---------------------------------------------------------------------------
|
||||
export function EmptyState({
|
||||
icon,
|
||||
title,
|
||||
@@ -290,80 +491,133 @@ export function EmptyState({
|
||||
action,
|
||||
actionLabel,
|
||||
onAction,
|
||||
className = '',
|
||||
className,
|
||||
}) {
|
||||
return (
|
||||
<div className={`formipay-empty-state ${className}`}>
|
||||
{icon && <div className="formipay-empty-icon">{icon}</div>}
|
||||
{title && <div className="formipay-empty-title">{title}</div>}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center py-12 text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{icon && <div className="mb-4 text-4xl">{icon}</div>}
|
||||
{title && <h3 className="text-lg font-semibold mb-1">{title}</h3>}
|
||||
{description && (
|
||||
<div className="formipay-empty-description">{description}</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">{description}</p>
|
||||
)}
|
||||
{action && (
|
||||
<Button onClick={onAction}>
|
||||
<ShadcnButton onClick={onAction}>
|
||||
{actionLabel || __('Take Action', 'formipay')}
|
||||
</Button>
|
||||
</ShadcnButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Status Badge Component
|
||||
export function Badge({
|
||||
variant = 'default',
|
||||
children,
|
||||
className = '',
|
||||
}) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Badge
|
||||
// ---------------------------------------------------------------------------
|
||||
export function Badge({ variant = 'default', children, className }) {
|
||||
return (
|
||||
<span className={`formipay-badge formipay-badge-${variant} ${className}`}>
|
||||
<ShadcnBadge variant={variant} className={cn(className)}>
|
||||
{children}
|
||||
</span>
|
||||
</ShadcnBadge>
|
||||
);
|
||||
}
|
||||
|
||||
// Table Component
|
||||
// ---------------------------------------------------------------------------
|
||||
// Table
|
||||
// ---------------------------------------------------------------------------
|
||||
export function Table({
|
||||
columns,
|
||||
data,
|
||||
emptyMessage = __('No items found', 'formipay'),
|
||||
className = '',
|
||||
className,
|
||||
}) {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className={`formipay-table-wrapper ${className}`}>
|
||||
<EmptyState
|
||||
title={emptyMessage}
|
||||
/>
|
||||
<div className={cn('flex flex-col items-center justify-center py-12 text-muted-foreground', className)}>
|
||||
<EmptyState title={emptyMessage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`formipay-table-wrapper ${className}`}>
|
||||
<table className="formipay-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<div className={cn('rounded-lg border', className)}>
|
||||
<ShadcnTable>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((column) => (
|
||||
<th key={column.key}>{column.label}</th>
|
||||
<TableHead key={column.key}>{column.label}</TableHead>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((row, rowIndex) => (
|
||||
<tr key={rowIndex}>
|
||||
<TableRow key={rowIndex}>
|
||||
{columns.map((column) => (
|
||||
<td key={column.key}>
|
||||
{column.render ? column.render(row, rowIndex) : row[column.key]}
|
||||
</td>
|
||||
<TableCell key={column.key}>
|
||||
{column.render
|
||||
? column.render(row, rowIndex)
|
||||
: row[column.key]}
|
||||
</TableCell>
|
||||
))}
|
||||
</tr>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</ShadcnTable>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MetaboxLayout
|
||||
// ---------------------------------------------------------------------------
|
||||
export function MetaboxLayout({ tabs, activeTab, onTabChange, children }) {
|
||||
return (
|
||||
<div className="bg-white rounded-[10px] overflow-hidden shadow-sm">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={onTabChange}
|
||||
className="flex flex-row"
|
||||
>
|
||||
<TabsList className="flex-col w-[273px] h-auto bg-[#2c3e50] rounded-none p-5">
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
className={cn(
|
||||
'w-full justify-start text-[#bec5cb] uppercase text-sm',
|
||||
'data-[state=active]:bg-[#2985f7] data-[state=active]:text-white rounded-none'
|
||||
)}
|
||||
>
|
||||
{tab.icon && <i className={tab.icon} />}
|
||||
<span>{tab.label}</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-1">
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
className="p-6 mt-0"
|
||||
>
|
||||
{typeof children === 'function'
|
||||
? children(tab)
|
||||
: children}
|
||||
</TabsContent>
|
||||
))}
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default export (aggregate)
|
||||
// ---------------------------------------------------------------------------
|
||||
export default {
|
||||
Box,
|
||||
BoxChild,
|
||||
@@ -377,8 +631,10 @@ export default {
|
||||
Button,
|
||||
Repeater,
|
||||
Notice,
|
||||
GroupTitle,
|
||||
Spinner,
|
||||
EmptyState,
|
||||
Badge,
|
||||
Table,
|
||||
MetaboxLayout,
|
||||
};
|
||||
|
||||
@@ -9,21 +9,23 @@
|
||||
============================================ */
|
||||
|
||||
:root {
|
||||
/* Colors */
|
||||
/* Colors - WPCFTO-matched values */
|
||||
--formipay-color-primary: #2985f7;
|
||||
--formipay-color-sidebar-bg: #1e2a36;
|
||||
--formipay-color-sidebar-text: #fff;
|
||||
--formipay-color-content-bg: #fff;
|
||||
--formipay-color-sidebar-bg: #2c3e50;
|
||||
--formipay-color-sidebar-text: #bec5cb;
|
||||
--formipay-color-sidebar-active: #2985f7;
|
||||
--formipay-color-content-bg: #f0f3f5;
|
||||
--formipay-color-block-bg: #fff;
|
||||
--formipay-color-border: #f0f0f1;
|
||||
--formipay-color-border-dark: #8c99a5;
|
||||
--formipay-color-input-bg: #f6f9fc;
|
||||
--formipay-color-text: #1d2327;
|
||||
--formipay-color-text-muted: #646970;
|
||||
--formipay-color-text: #27374e;
|
||||
--formipay-color-text-muted: #8c99a5;
|
||||
--formipay-color-danger: #d63638;
|
||||
--formipay-color-success: #00a32a;
|
||||
--formipay-color-warning: #dba617;
|
||||
|
||||
/* Spacing */
|
||||
/* Spacing - WPCFTO values */
|
||||
--formipay-spacing-xs: 4px;
|
||||
--formipay-spacing-sm: 8px;
|
||||
--formipay-spacing-md: 12px;
|
||||
@@ -31,13 +33,13 @@
|
||||
--formipay-spacing-xl: 20px;
|
||||
--formipay-spacing-xxl: 24px;
|
||||
|
||||
/* Border Radius */
|
||||
/* Border Radius - WPCFTO values */
|
||||
--formipay-radius-sm: 4px;
|
||||
--formipay-radius-md: 10px;
|
||||
--formipay-radius-lg: 30px;
|
||||
--formipay-radius-full: 50%;
|
||||
|
||||
/* Typography */
|
||||
/* Typography - WPCFTO values */
|
||||
--formipay-font-family: 'Roboto', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--formipay-font-size-sm: 13px;
|
||||
--formipay-font-size-base: 14px;
|
||||
@@ -48,6 +50,11 @@
|
||||
--formipay-font-weight-semibold: 600;
|
||||
--formipay-font-weight-bold: 700;
|
||||
|
||||
/* WPCFTO Layout Dimensions */
|
||||
--formipay-sidebar-width: 273px;
|
||||
--formipay-field-aside-width: 40%;
|
||||
--formipay-field-content-width: 60%;
|
||||
|
||||
/* Shadows */
|
||||
--formipay-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
--formipay-shadow-md: -2px 2px 5px rgba(0, 0, 0, 0.08);
|
||||
@@ -104,86 +111,153 @@
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TAB NAVIGATION (Vertical)
|
||||
TAB NAVIGATION (WPCFTO sidebar)
|
||||
============================================ */
|
||||
|
||||
.formipay-tab-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--formipay-spacing-xs);
|
||||
.formipay-wpcfto-tab-nav {
|
||||
background-color: var(--formipay-color-sidebar-bg);
|
||||
width: var(--formipay-sidebar-width);
|
||||
padding: 21px 0;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.formipay-nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--formipay-spacing-md) var(--formipay-spacing-lg);
|
||||
border-radius: var(--formipay-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--formipay-transition-fast);
|
||||
user-select: none;
|
||||
.formipay-wpcfto-tab-nav.hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.formipay-nav-item:hover {
|
||||
background-color: rgba(41, 133, 247, 0.05);
|
||||
.formipay-wpcfto-tab-nav-inner {
|
||||
position: sticky;
|
||||
top: 133px;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.formipay-nav-item.active {
|
||||
background-color: rgba(41, 133, 247, 0.1);
|
||||
color: var(--formipay-color-primary);
|
||||
}
|
||||
|
||||
.formipay-nav-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--formipay-spacing-sm);
|
||||
.formipay-wpcfto-nav {
|
||||
background-color: transparent;
|
||||
position: relative;
|
||||
padding: 0;
|
||||
font-size: var(--formipay-font-size-base);
|
||||
font-weight: var(--formipay-font-weight-medium);
|
||||
font-weight: 400;
|
||||
text-transform: uppercase;
|
||||
color: var(--formipay-color-sidebar-text);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease 0s;
|
||||
}
|
||||
|
||||
.formipay-nav-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
.formipay-wpcfto-nav-title {
|
||||
padding: 13px 32px 13px 34px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.formipay-wpcfto-nav i {
|
||||
display: block;
|
||||
position: absolute;
|
||||
right: 30px;
|
||||
left: auto;
|
||||
top: 50%;
|
||||
margin-top: -11px;
|
||||
width: 26px;
|
||||
text-align: center;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.formipay-wpcfto-nav.active {
|
||||
background-color: var(--formipay-color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.formipay-wpcfto-nav:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.formipay-wpcfto-submenus {
|
||||
background-color: #1e2a36;
|
||||
padding: 18px 32px 18px 34px;
|
||||
}
|
||||
|
||||
.formipay-wpcfto-submenu-item {
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
text-transform: initial;
|
||||
position: relative;
|
||||
color: var(--formipay-color-text-muted);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.formipay-wpcfto-submenu-item i {
|
||||
font-size: 10px;
|
||||
right: 0;
|
||||
margin-top: -5px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.formipay-wpcfto-submenu-item.active,
|
||||
.formipay-wpcfto-submenu-item:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.formipay-wpcfto-submenu-item.active i {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TAB CONTENT PANELS
|
||||
TAB CONTENT PANELS (WPCFTO tab content area)
|
||||
============================================ */
|
||||
|
||||
.formipay-tabs {
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.formipay-tab {
|
||||
background-color: var(--formipay-color-content-bg);
|
||||
width: 100%;
|
||||
padding: 20px 30px 20px 20px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.formipay-tab.active {
|
||||
display: block;
|
||||
animation: fadeIn 0.4s ease;
|
||||
}
|
||||
|
||||
.formipay-tab-content {
|
||||
padding: var(--formipay-spacing-lg);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
FIELD COMPONENT
|
||||
FIELD COMPONENT - WPCFTO 2-column layout
|
||||
============================================ */
|
||||
|
||||
.formipay-field {
|
||||
.formipay-generic-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--formipay-spacing-lg) 0;
|
||||
border-bottom: 1px solid var(--formipay-color-border);
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
padding: 1.8rem 1rem 0;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
background-color: var(--formipay-color-block-bg);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.formipay-field:last-child {
|
||||
border-bottom: none;
|
||||
.formipay-field-aside {
|
||||
width: var(--formipay-field-aside-width);
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.formipay-field-label {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--formipay-spacing-xs);
|
||||
margin-bottom: var(--formipay-spacing-sm);
|
||||
display: inline;
|
||||
font-size: var(--formipay-font-size-base);
|
||||
font-weight: var(--formipay-font-weight-medium);
|
||||
}
|
||||
@@ -194,10 +268,15 @@
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.formipay-field-content {
|
||||
width: var(--formipay-field-content-width);
|
||||
}
|
||||
|
||||
.formipay-field-description {
|
||||
display: block;
|
||||
margin-top: 0.8em;
|
||||
font-size: var(--formipay-font-size-sm);
|
||||
color: var(--formipay-color-text-muted);
|
||||
margin-top: var(--formipay-spacing-xs);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
@@ -239,21 +318,52 @@
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
CHECKBOX & RADIO
|
||||
CHECKBOX & RADIO - WPCFTO toggle style
|
||||
============================================ */
|
||||
|
||||
.formipay-checkbox,
|
||||
.formipay-radio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--formipay-spacing-sm);
|
||||
.formipay-admin-checkbox {
|
||||
align-self: flex-end;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.formipay-admin-checkbox-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 42px;
|
||||
height: 24px;
|
||||
background-color: #bec5cb;
|
||||
border-radius: 20px;
|
||||
transition: background-color 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.formipay-checkbox input,
|
||||
.formipay-radio input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
.formipay-admin-checkbox-wrapper.active {
|
||||
background-color: var(--formipay-color-primary);
|
||||
}
|
||||
|
||||
.formipay-checkbox-switcher {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.formipay-admin-checkbox-wrapper.active .formipay-checkbox-switcher {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.formipay-admin-checkbox input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.formipay-admin-checkbox label {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -495,6 +605,29 @@
|
||||
color: var(--formipay-color-text-muted);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
GROUP TITLE - WPCFTO section divider
|
||||
============================================ */
|
||||
|
||||
.formipay-group-title {
|
||||
width: 100%;
|
||||
padding: 0 0 12px;
|
||||
color: var(--formipay-color-text-muted);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
text-transform: uppercase;
|
||||
border-bottom: 1px solid #d6dade;
|
||||
margin: 0 0 17px;
|
||||
letter-spacing: 1.4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.formipay-group-title i {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
NOTICE / ALERT COMPONENTS
|
||||
============================================ */
|
||||
@@ -625,6 +758,44 @@
|
||||
.formipay-gap-3 { gap: var(--formipay-spacing-md); }
|
||||
.formipay-gap-4 { gap: var(--formipay-spacing-lg); }
|
||||
|
||||
/* ============================================
|
||||
METABOX LAYOUT - WPCFTO 2-column wrapper
|
||||
============================================ */
|
||||
|
||||
.formipay-wpcfto-metabox {
|
||||
background-color: #fff;
|
||||
border-radius: var(--formipay-radius-md);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--formipay-shadow-sm);
|
||||
}
|
||||
|
||||
.formipay-wpcfto-metabox-inner {
|
||||
display: flex;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.formipay-wpcfto-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Horizontal variant for mobile/narrow contexts */
|
||||
.formipay-wpcfto-container.horizontal {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.formipay-wpcfto-container.horizontal .formipay-wpcfto-tab-nav {
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.formipay-wpcfto-container.horizontal .formipay-wpcfto-tabs {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RESPONSIVE
|
||||
============================================ */
|
||||
|
||||
@@ -18,10 +18,12 @@ export {
|
||||
Button,
|
||||
Repeater,
|
||||
Notice,
|
||||
GroupTitle,
|
||||
Spinner,
|
||||
EmptyState,
|
||||
Badge,
|
||||
Table,
|
||||
MetaboxLayout,
|
||||
} from './WpcftoComponents';
|
||||
|
||||
// Export CSS import helper
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
* Formipay Admin - React Application Entry Point
|
||||
*/
|
||||
|
||||
import './styles/globals.css';
|
||||
import { render } from '@wordpress/element';
|
||||
import App from './components/App';
|
||||
import CouponMetabox from './components/coupons/CouponMetabox';
|
||||
import './components/coupons/CouponMetabox.css';
|
||||
|
||||
// Mount the React app to all available mount points
|
||||
const mountApps = () => {
|
||||
// Mount main admin app pages
|
||||
const mountPoints = document.querySelectorAll('[data-formipay-mount]');
|
||||
|
||||
console.log('[Formipay] Mount points found:', mountPoints.length);
|
||||
@@ -28,6 +32,30 @@ const mountApps = () => {
|
||||
console.error('[Formipay] Failed to mount:', page, error);
|
||||
}
|
||||
});
|
||||
|
||||
// Mount metabox islands
|
||||
const metaboxPoints = document.querySelectorAll('[data-formipay-metabox]');
|
||||
|
||||
console.log('[Formipay] Metabox points found:', metaboxPoints.length);
|
||||
|
||||
metaboxPoints.forEach((mountPoint) => {
|
||||
const metaboxType = mountPoint.dataset.formipayMetabox;
|
||||
const postId = parseInt(mountPoint.dataset.postId || '0');
|
||||
|
||||
console.log('[Formipay] Mounting metabox:', metaboxType, 'for post:', postId);
|
||||
|
||||
try {
|
||||
if (metaboxType === 'coupon') {
|
||||
render(
|
||||
<CouponMetabox postId={postId} />,
|
||||
mountPoint
|
||||
);
|
||||
console.log('[Formipay] Successfully mounted coupon metabox');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Formipay] Failed to mount metabox:', metaboxType, error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Initialize when DOM is ready
|
||||
|
||||
85
src/admin/lib/confirm.js
Normal file
85
src/admin/lib/confirm.js
Normal file
@@ -0,0 +1,85 @@
|
||||
import { createElement, useState, render, unmountComponentAtNode } from '@wordpress/element';
|
||||
import { Dialog, DialogOverlay, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
/**
|
||||
* Async confirm dialog replacing SweetAlert2's Swal.fire({ showCancelButton: true }).
|
||||
*
|
||||
* Usage:
|
||||
* const result = await confirm({ title: 'Delete?', message: 'This cannot be undone.', variant: 'destructive' });
|
||||
* if (result) { // user confirmed }
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {string} props.title - Dialog title.
|
||||
* @param {string} props.message - Dialog description.
|
||||
* @param {string} [props.confirmText] - Label for the confirm button.
|
||||
* @param {string} [props.cancelText] - Label for the cancel button.
|
||||
* @param {string} [props.variant] - 'default' | 'destructive'.
|
||||
* @returns {Promise<boolean>} Resolves true on confirm, false on cancel / dismiss.
|
||||
*/
|
||||
export async function confirm({
|
||||
title,
|
||||
message,
|
||||
confirmText = __('Confirm', 'formipay'),
|
||||
cancelText = __('Cancel', 'formipay'),
|
||||
variant = 'default',
|
||||
} = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
|
||||
const cleanup = (result) => {
|
||||
unmountComponentAtNode(container);
|
||||
container.remove();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
function ConfirmDialog() {
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
return createElement(
|
||||
Dialog,
|
||||
{
|
||||
open,
|
||||
onOpenChange: (value) => {
|
||||
if (!value) {
|
||||
setOpen(false);
|
||||
cleanup(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
createElement(DialogOverlay),
|
||||
createElement(
|
||||
DialogContent,
|
||||
null,
|
||||
createElement(
|
||||
DialogHeader,
|
||||
null,
|
||||
createElement(DialogTitle, null, title),
|
||||
createElement(DialogDescription, null, message),
|
||||
),
|
||||
createElement(
|
||||
DialogFooter,
|
||||
null,
|
||||
createElement(
|
||||
Button,
|
||||
{ variant: 'outline', onClick: () => cleanup(false) },
|
||||
cancelText,
|
||||
),
|
||||
createElement(
|
||||
Button,
|
||||
{
|
||||
variant: variant === 'destructive' ? 'destructive' : 'default',
|
||||
onClick: () => cleanup(true),
|
||||
},
|
||||
confirmText,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
render(createElement(ConfirmDialog), container);
|
||||
});
|
||||
}
|
||||
1
src/admin/lib/toast.js
Normal file
1
src/admin/lib/toast.js
Normal file
@@ -0,0 +1 @@
|
||||
export { toast } from 'sonner';
|
||||
6
src/admin/lib/utils.js
Normal file
6
src/admin/lib/utils.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -4,25 +4,24 @@
|
||||
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import DataTable from '../components/shared/DataTable';
|
||||
import { confirm } from '@/lib/confirm';
|
||||
import { toast } from '@/lib/toast';
|
||||
import './AdminPages.css';
|
||||
|
||||
// SweetAlert2 is loaded via WordPress (global scope)
|
||||
const Swal = window.Swal;
|
||||
|
||||
export default function AccessPage() {
|
||||
const ajaxUrl = window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php';
|
||||
const nonce = window.formipayAdmin?.nonce || '';
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
const result = await Swal.fire({
|
||||
icon: 'info',
|
||||
html: __('Do you want to delete this item?', 'formipay'),
|
||||
showCancelButton: true,
|
||||
confirmButtonText: __('Delete Permanently', 'formipay'),
|
||||
cancelButtonText: __('Cancel', 'formipay'),
|
||||
const result = await confirm({
|
||||
title: __('Delete Item', 'formipay'),
|
||||
message: __('Do you want to delete this item?', 'formipay'),
|
||||
confirmText: __('Delete Permanently', 'formipay'),
|
||||
cancelText: __('Cancel', 'formipay'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
if (result) {
|
||||
await fetch(`${ajaxUrl}?action=formipay-delete-access-item`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
@@ -32,20 +31,20 @@ export default function AccessPage() {
|
||||
_wpnonce: nonce,
|
||||
}),
|
||||
});
|
||||
toast.success(__('Item deleted successfully.', 'formipay'));
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicate = async (id) => {
|
||||
const result = await Swal.fire({
|
||||
icon: 'info',
|
||||
html: __('Do you want to duplicate this item?', 'formipay'),
|
||||
showCancelButton: true,
|
||||
confirmButtonText: __('Confirm', 'formipay'),
|
||||
cancelButtonText: __('Cancel', 'formipay'),
|
||||
const result = await confirm({
|
||||
title: __('Duplicate Item', 'formipay'),
|
||||
message: __('Do you want to duplicate this item?', 'formipay'),
|
||||
confirmText: __('Confirm', 'formipay'),
|
||||
cancelText: __('Cancel', 'formipay'),
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
if (result) {
|
||||
await fetch(`${ajaxUrl}?action=formipay-duplicate-access-item`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
@@ -55,6 +54,7 @@ export default function AccessPage() {
|
||||
_wpnonce: nonce,
|
||||
}),
|
||||
});
|
||||
toast.success(__('Item duplicated successfully.', 'formipay'));
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -92,3 +92,27 @@ code {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/** Modal Overlay **/
|
||||
.formipay-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100000;
|
||||
}
|
||||
|
||||
.formipay-modal-content {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
max-width: 900px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
@@ -4,25 +4,29 @@
|
||||
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import DataTable from '../components/shared/DataTable';
|
||||
import { confirm } from '@/lib/confirm';
|
||||
import { toast } from '@/lib/toast';
|
||||
import './AdminPages.css';
|
||||
|
||||
// SweetAlert2 is loaded via WordPress (global scope)
|
||||
const Swal = window.Swal;
|
||||
|
||||
export default function CouponsPage() {
|
||||
const ajaxUrl = window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php';
|
||||
const nonce = window.formipayAdmin?.nonce || '';
|
||||
|
||||
const openEditor = (couponId) => {
|
||||
// Use native WordPress post editor
|
||||
window.location.href = `/wp-admin/post.php?post=${couponId}&action=edit`;
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
const result = await Swal.fire({
|
||||
icon: 'info',
|
||||
html: __('Do you want to delete this item?', 'formipay'),
|
||||
showCancelButton: true,
|
||||
confirmButtonText: __('Delete Permanently', 'formipay'),
|
||||
cancelButtonText: __('Cancel', 'formipay'),
|
||||
const result = await confirm({
|
||||
title: __('Delete Item', 'formipay'),
|
||||
message: __('Do you want to delete this item?', 'formipay'),
|
||||
confirmText: __('Delete Permanently', 'formipay'),
|
||||
cancelText: __('Cancel', 'formipay'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
if (result) {
|
||||
await fetch(`${ajaxUrl}?action=formipay-delete-coupon`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
@@ -32,20 +36,20 @@ export default function CouponsPage() {
|
||||
_wpnonce: nonce,
|
||||
}),
|
||||
});
|
||||
toast.success(__('Item deleted successfully.', 'formipay'));
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicate = async (id) => {
|
||||
const result = await Swal.fire({
|
||||
icon: 'info',
|
||||
html: __('Do you want to duplicate this item?', 'formipay'),
|
||||
showCancelButton: true,
|
||||
confirmButtonText: __('Confirm', 'formipay'),
|
||||
cancelButtonText: __('Cancel', 'formipay'),
|
||||
const result = await confirm({
|
||||
title: __('Duplicate Item', 'formipay'),
|
||||
message: __('Do you want to duplicate this item?', 'formipay'),
|
||||
confirmText: __('Confirm', 'formipay'),
|
||||
cancelText: __('Cancel', 'formipay'),
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
if (result) {
|
||||
await fetch(`${ajaxUrl}?action=formipay-duplicate-coupon`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
@@ -55,6 +59,7 @@ export default function CouponsPage() {
|
||||
_wpnonce: nonce,
|
||||
}),
|
||||
});
|
||||
toast.success(__('Item duplicated successfully.', 'formipay'));
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
@@ -72,7 +77,15 @@ export default function CouponsPage() {
|
||||
<>
|
||||
<strong>{row.code || row.post_title}</strong>
|
||||
<span className="row-actions">
|
||||
<a href={`${window.formipayAdmin?.siteUrl || ''}/wp-admin/post.php?post=${row.ID}&action=edit`}>{__('edit', 'formipay')}</a>
|
||||
<button
|
||||
className="button-link"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
openEditor(row.ID);
|
||||
}}
|
||||
>
|
||||
{__('edit', 'formipay')}
|
||||
</button>
|
||||
{' | '}
|
||||
<button
|
||||
className="button-link delete"
|
||||
@@ -177,7 +190,7 @@ export default function CouponsPage() {
|
||||
actions={{
|
||||
addNew: {
|
||||
label: __('+ Add New Coupon', 'formipay'),
|
||||
action: 'formipay-create-coupon-post',
|
||||
href: '/wp-admin/post-new.php?post_type=formipay_coupon',
|
||||
},
|
||||
bulkDelete: {
|
||||
action: 'formipay-bulk-delete-coupon',
|
||||
|
||||
@@ -4,24 +4,23 @@
|
||||
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import DataTable from '../components/shared/DataTable';
|
||||
|
||||
// SweetAlert2 is loaded via WordPress (global scope)
|
||||
const Swal = window.Swal;
|
||||
import { confirm } from '@/lib/confirm';
|
||||
import { toast } from '@/lib/toast';
|
||||
|
||||
export default function FormsPage() {
|
||||
const ajaxUrl = window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php';
|
||||
const nonce = window.formipayAdmin?.nonce || '';
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
const result = await Swal.fire({
|
||||
icon: 'info',
|
||||
html: __('Do you want to delete this item?', 'formipay'),
|
||||
showCancelButton: true,
|
||||
confirmButtonText: __('Delete Permanently', 'formipay'),
|
||||
cancelButtonText: __('Cancel', 'formipay'),
|
||||
const result = await confirm({
|
||||
title: __('Delete Item', 'formipay'),
|
||||
message: __('Do you want to delete this item?', 'formipay'),
|
||||
confirmText: __('Delete Permanently', 'formipay'),
|
||||
cancelText: __('Cancel', 'formipay'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
if (result) {
|
||||
await fetch(`${ajaxUrl}?action=formipay-delete-form`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
@@ -31,20 +30,20 @@ export default function FormsPage() {
|
||||
_wpnonce: nonce,
|
||||
}),
|
||||
});
|
||||
toast.success(__('Item deleted successfully.', 'formipay'));
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicate = async (id) => {
|
||||
const result = await Swal.fire({
|
||||
icon: 'info',
|
||||
html: __('Do you want to duplicate this item?', 'formipay'),
|
||||
showCancelButton: true,
|
||||
confirmButtonText: __('Confirm', 'formipay'),
|
||||
cancelButtonText: __('Cancel', 'formipay'),
|
||||
const result = await confirm({
|
||||
title: __('Duplicate Item', 'formipay'),
|
||||
message: __('Do you want to duplicate this item?', 'formipay'),
|
||||
confirmText: __('Confirm', 'formipay'),
|
||||
cancelText: __('Cancel', 'formipay'),
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
if (result) {
|
||||
await fetch(`${ajaxUrl}?action=formipay-duplicate-form`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
@@ -54,6 +53,7 @@ export default function FormsPage() {
|
||||
_wpnonce: nonce,
|
||||
}),
|
||||
});
|
||||
toast.success(__('Item duplicated successfully.', 'formipay'));
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
@@ -155,15 +155,7 @@ export default function FormsPage() {
|
||||
e.currentTarget.innerHTML = originalHTML;
|
||||
}, 2000);
|
||||
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: __('Shortcode copied!', 'formipay'),
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
timerProgressBar: true,
|
||||
});
|
||||
toast.success(__('Shortcode copied!', 'formipay'));
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -4,25 +4,24 @@
|
||||
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import DataTable from '../components/shared/DataTable';
|
||||
import { confirm } from '@/lib/confirm';
|
||||
import { toast } from '@/lib/toast';
|
||||
import './AdminPages.css';
|
||||
|
||||
// SweetAlert2 is loaded via WordPress (global scope)
|
||||
const Swal = window.Swal;
|
||||
|
||||
export default function LicensesPage() {
|
||||
const ajaxUrl = window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php';
|
||||
const nonce = window.formipayAdmin?.nonce || '';
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
const result = await Swal.fire({
|
||||
icon: 'info',
|
||||
html: __('Do you want to delete this item?', 'formipay'),
|
||||
showCancelButton: true,
|
||||
confirmButtonText: __('Delete Permanently', 'formipay'),
|
||||
cancelButtonText: __('Cancel', 'formipay'),
|
||||
const result = await confirm({
|
||||
title: __('Delete Item', 'formipay'),
|
||||
message: __('Do you want to delete this item?', 'formipay'),
|
||||
confirmText: __('Delete Permanently', 'formipay'),
|
||||
cancelText: __('Cancel', 'formipay'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
if (result) {
|
||||
await fetch(`${ajaxUrl}?action=formipay-delete-license`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
@@ -32,6 +31,7 @@ export default function LicensesPage() {
|
||||
_wpnonce: nonce,
|
||||
}),
|
||||
});
|
||||
toast.success(__('Item deleted successfully.', 'formipay'));
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,11 +6,10 @@ import { __ } from '@wordpress/i18n';
|
||||
import { useState } from '@wordpress/element';
|
||||
import DataTable from '../components/shared/DataTable';
|
||||
import VariationPricingTable from '../components/products/VariationPricingTable';
|
||||
import { confirm } from '@/lib/confirm';
|
||||
import { toast } from '@/lib/toast';
|
||||
import './AdminPages.css';
|
||||
|
||||
// SweetAlert2 is loaded via WordPress (global scope)
|
||||
const Swal = window.Swal;
|
||||
|
||||
export default function ProductsPage() {
|
||||
const [isEditor, setIsEditor] = useState(false);
|
||||
const [selectedProductId, setSelectedProductId] = useState(null);
|
||||
@@ -19,15 +18,15 @@ export default function ProductsPage() {
|
||||
const nonce = window.formipayAdmin?.nonce || '';
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
const result = await Swal.fire({
|
||||
icon: 'info',
|
||||
html: __('Do you want to delete this item?', 'formipay'),
|
||||
showCancelButton: true,
|
||||
confirmButtonText: __('Delete Permanently', 'formipay'),
|
||||
cancelButtonText: __('Cancel', 'formipay'),
|
||||
const result = await confirm({
|
||||
title: __('Delete Item', 'formipay'),
|
||||
message: __('Do you want to delete this item?', 'formipay'),
|
||||
confirmText: __('Delete Permanently', 'formipay'),
|
||||
cancelText: __('Cancel', 'formipay'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
if (result) {
|
||||
await fetch(`${ajaxUrl}?action=formipay-delete-product`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
@@ -37,20 +36,20 @@ export default function ProductsPage() {
|
||||
_wpnonce: nonce,
|
||||
}),
|
||||
});
|
||||
toast.success(__('Item deleted successfully.', 'formipay'));
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicate = async (id) => {
|
||||
const result = await Swal.fire({
|
||||
icon: 'info',
|
||||
html: __('Do you want to duplicate this item?', 'formipay'),
|
||||
showCancelButton: true,
|
||||
confirmButtonText: __('Confirm', 'formipay'),
|
||||
cancelButtonText: __('Cancel', 'formipay'),
|
||||
const result = await confirm({
|
||||
title: __('Duplicate Item', 'formipay'),
|
||||
message: __('Do you want to duplicate this item?', 'formipay'),
|
||||
confirmText: __('Confirm', 'formipay'),
|
||||
cancelText: __('Cancel', 'formipay'),
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
if (result) {
|
||||
await fetch(`${ajaxUrl}?action=formipay-duplicate-product`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
@@ -60,6 +59,7 @@ export default function ProductsPage() {
|
||||
_wpnonce: nonce,
|
||||
}),
|
||||
});
|
||||
toast.success(__('Item duplicated successfully.', 'formipay'));
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
66
src/admin/styles/globals.css
Normal file
66
src/admin/styles/globals.css
Normal file
@@ -0,0 +1,66 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-active: var(--sidebar-active);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.546 0.245 262.881);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.546 0.245 262.881);
|
||||
--sidebar: #2c3e50;
|
||||
--sidebar-foreground: #bec5cb;
|
||||
--sidebar-active: #2985f7;
|
||||
}
|
||||
|
||||
/* WP admin style isolation: all Tailwind utilities get !important */
|
||||
@layer base {
|
||||
.formipay-design-system * {
|
||||
@apply border-border;
|
||||
}
|
||||
}
|
||||
@@ -11,4 +11,11 @@ module.exports = {
|
||||
filename: '[name].js',
|
||||
chunkFilename: '[name].[contenthash].js',
|
||||
},
|
||||
resolve: {
|
||||
...defaultConfig.resolve,
|
||||
alias: {
|
||||
...defaultConfig.resolve.alias,
|
||||
'@': path.resolve(__dirname, 'src/admin'),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user