Compare commits

...

12 Commits

Author SHA1 Message Date
dwindown
7a6765a579 feat: add searchable dropdown with Popover + Command pattern
Rewrite SelectField to use shadcn/ui Popover + Command (cmdk) pattern
for searchable selects, following best practices. This eliminates
console errors from the previous input-inside-SelectContent approach.

Changes:
- SelectField.js: Use Popover + Command for searchable fields
- Add Command component with CommandInput for proper search
- Update dialog.jsx to use Huge Icons instead of lucide-react
- Simplify searchable logic to follow PHP config directly

The Command component handles keyboard navigation and filtering
properly without focus event conflicts.
2026-04-28 16:45:06 +07:00
dwindown
008188b790 feat: migrate shipping to form-level and integrate flags.json as single source of truth
Shipping Migration:
- Move shipping configuration from product-level to form-level
- Add form shipping tab in form settings (no_shipping, flat_rate, free_shipping)
- Update FlatRate to register at form level instead of product level
- Update checkout logic to read from form settings
- Support percentage-based flat rate calculation
- Simplify shipping method IDs (flat_rate, free_shipping)

Currency Flags Integration:
- Add formipay_get_all_currency_flags() to read from admin/assets/json/flags.json
- Remove hardcoded CURRENCY_FLAGS emoji map from VariationField.js
- Create CurrencyFlag component to render base64 flag images
- Localize currency_flags to window.formipayProductDetails
- Update shipping info display in admin order details

Benefits:
- Form-level shipping prevents multiplying shipping costs per product
- Single source of truth for currency flags (flags.json)
- Better support for future cart system
- Consistent with e-commerce standards
2026-04-23 08:12:40 +07:00
dwindown
0094a3571c fix: scope all Tailwind utilities under .formipay-design-system for WP admin isolation
- Add postcss-prefix-selector to prefix all CSS selectors with .formipay-design-system
- This gives Tailwind utilities higher specificity than WP admin styles
- Prevents WP input/select/button styles from overriding shadcn components
- All 129 selectors now scoped correctly
2026-04-19 18:43:56 +07:00
dwindown
a36e71ed56 fix: normalize all input types (number, date, time) to same pill style
- Remove browser spinner buttons from number inputs
- Reset datetime field wrapper padding for date/time/datetime-local
- Style calendar picker indicator consistently
- Add file input resets
2026-04-19 16:17:43 +07:00
dwindown
1a10c18c31 fix: improve SearchableSelect with label resolution for pre-selected items
- Fetch post titles for pre-selected IDs on mount via include[] param
- Cache labels from search results to avoid re-fetching
- Filter already-selected items from dropdown results
- Add loading spinner and no-results message
- Update PHP autocomplete handler to support include[] param
- Fix CSS class conflict warning
2026-04-19 15:47:59 +07:00
dwindown
7ba92022d5 feat: rewrite CouponMetabox with proper shadcn/ui
- Remove all WPCFTO wrapper components (MetaboxLayout, TabNav, TabPanel, GroupTitle, etc.)
- Use shadcn Tabs with horizontal underline triggers
- Use grid-cols-[1fr_2fr] for clean form field layout
- Use shadcn Switch for toggle fields
- Use shadcn Select for discount type dropdown
- Replace inline Notice with toast notifications
- Add Skeleton loading state
- Add SearchableSelect with Badge chips for relation fields
- Move save button to tab header bar
- No custom CSS classes - all Tailwind utilities
2026-04-19 13:49:42 +07:00
dwindown
c103e368be feat: migrate form builder to shadcn/ui + Huge Icons
- Replace @wordpress/components with shadcn Input, Button, Switch, Select, Textarea, Label
- Replace @wordpress/icons with @hugeicons/core-free-icons and @hugeicons/react
- Update fieldTypes config with Huge Icons component references
- Migrate FormFieldOptions, FieldSettingsPanel, FormField, FormCanvas, FieldPalette
2026-04-19 13:44:10 +07:00
dwindown
99912a9335 feat: rewrite DataTable + design system with shadcn/ui, replace SweetAlert2
- Rewrite DataTable using shadcn Table, Button, Dialog, Input, Select, Checkbox, Skeleton
- Replace all SweetAlert2 calls with shadcn Dialog (confirm) + sonner toast
- Rewrite design system internals to use shadcn Label, Input, Switch, Button, Alert, Badge, Tabs
- Add Toaster to App.js for global toast support
- Remove all @wordpress/components imports from DataTable
- Remove WpcftoDesign.css import from design system
- Replace Swal.fire() in Coupons, Products, Forms, Access, Licenses pages
2026-04-19 13:25:42 +07:00
dwindown
862abc8d74 feat: add shadcn/ui + Tailwind CSS v4 + Huge Icons foundation
- Install tailwindcss, @tailwindcss/postcss, clsx, tailwind-merge, class-variance-authority
- Install @hugeicons/react for icons
- Install Radix UI primitives (switch, tabs, label, separator, select, dialog, checkbox, dropdown-menu, popover)
- Install sonner for toast notifications
- Create postcss.config.js with Tailwind v4 PostCSS plugin
- Create jsconfig.json with @ path alias for src/admin
- Create components.json for shadcn configuration
- Update webpack.config.js with @ resolve alias
- Create globals.css with Tailwind v4 CSS-first config + shadcn CSS variables
- Create cn() utility in lib/utils.js
- Create 17 shadcn UI components (button, input, label, checkbox, switch, tabs, alert, separator, badge, textarea, dialog, sonner, table, skeleton, select, dropdown-menu, popover)
- Create async confirm() utility replacing SweetAlert2
- Create toast utility wrapping sonner
2026-04-19 12:27:20 +07:00
dwindown
fe9efdfeec fix: add defensive checks for map errors and global currencies loading
- Add Array.isArray checks to getGlobalCurrencies helper
- Store global currencies in state to avoid timing issues
- Add null checks when mapping over currencies
- Add defensive checks to TabNav and TabPanel components
- Improve error handling in data loading
2026-04-19 07:14:02 +07:00
dwindown
d1de0015be feat: add React metabox island for coupon editor
- Create CouponMetabox React component with WPCFTO design system
- Add MetaboxLayout with vertical tabs (Rules, Restrictions)
- Implement Rules tab: active toggle, type radio, amount fields, multi-currency support
- Implement Restrictions tab: usage limit, date limit, autocomplete for forms/products/customers
- Add metabox registration in Coupon.php for formipay-coupon post type
- Update ReactAdmin to load assets on post.php edit screens
- Add autocomplete AJAX handler for relation fields
- Disable old WPCFTO metabox in favor of React island
2026-04-19 07:08:54 +07:00
dwindown
bde43d8c66 feat: update design system to match WPCFTO architecture
- Update CSS tokens to match WPCFTO values (sidebar #2c3e50, content #f0f3f5)
- Update Field component to 40%/60% 2-column layout
- Update Checkbox component to toggle switch UI
- Add GroupTitle component for visual section dividers
- Update TabNav component to WPCFTO sidebar styling
- Add MetaboxLayout component for 2-column wrapper
- Update TabPanel styles to match WPCFTO tab content area
- Fix Coupons page to use native WordPress post.php editor instead of modal

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 07:05:14 +07:00
67 changed files with 10683 additions and 1212 deletions

2
.gitignore vendored
View File

@@ -9,4 +9,4 @@ coverage
*.ntvs*
*.njsproj
*.sln
*.sw?
*.sw?node_modules/

View File

@@ -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 = {

View File

@@ -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'];

View File

@@ -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

View File

@@ -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');

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
View 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"
}

View File

@@ -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;
}

View File

@@ -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' ) {
// 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);
wp_localize_script( 'sweetalert2', 'formipay_admin', [
'ajax_url' => admin_url('admin-ajax.php'),
'site_url' => site_url(),
// 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);
}
}

View File

@@ -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'),
'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)

View File

@@ -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
];
}

View File

@@ -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
];

View File

@@ -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
View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/admin/*"]
}
},
"include": ["src/**/*"]
}

1308
node_modules/.package-lock.json generated vendored

File diff suppressed because it is too large Load Diff

1689
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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}`;
},
},
},
};

View 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);

View File

@@ -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}

View 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);
}

View 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>
);
}

View 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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -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>
))}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
))}

View File

@@ -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>
);

View 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 };

View 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 };

View 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 };

View 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 };

View 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,
}

View 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,
};

View 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,
}

View 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,
};

View 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 };

View 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 };

View 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 };

View 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,
};

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View File

@@ -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 = {

View File

@@ -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'
)}
>
&#9660;
</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)}
&#10005;
</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"
>
&#10005;
</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,
};

View File

@@ -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
============================================ */

View File

@@ -18,10 +18,12 @@ export {
Button,
Repeater,
Notice,
GroupTitle,
Spinner,
EmptyState,
Badge,
Table,
MetaboxLayout,
} from './WpcftoComponents';
// Export CSS import helper

View File

@@ -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
View 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
View File

@@ -0,0 +1 @@
export { toast } from 'sonner';

6
src/admin/lib/utils.js Normal file
View File

@@ -0,0 +1,6 @@
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs) {
return twMerge(clsx(inputs));
}

View File

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

View File

@@ -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);
}

View File

@@ -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',

View File

@@ -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'));
});
}}
>

View File

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

View File

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

View 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;
}
}

View File

@@ -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'),
},
},
};