feat: implement coexistence strategy for Grid.js and React admin

Implement dual-mode rendering allowing classic Grid.js and new React
versions to run side-by-side during migration.

- Add coexistence mode checks to all admin page methods
- Check query param ?react=1 or option 'formipay_use_react_admin'
- Include classic PHP pages when React not active
- Add admin notice showing current version with toggle button
- Add footer toggle link to switch between versions

This ensures zero feature loss - old Grid.js pages continue working
(~20 features per page) while React versions are developed.

Files:
- Form.php, Coupon.php, Access.php, Order.php
- Customer.php, Product.php, License.php
- ReactAdmin.php (added version_notice, footer_toggle)

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
dwindown
2026-04-18 16:55:56 +07:00
parent ab69d03f78
commit bd9cdac02e
12 changed files with 570 additions and 275 deletions

View File

@@ -1,199 +1,281 @@
# Formipay — Vue to React Migration Strategy # Formipay — Migration Strategy
**Date:** April 18, 2026 **Date:** April 18, 2026 (Updated)
**Context:** Phase 2 — React Admin Foundation **Context:** Phase 2 — React Admin Foundation
--- ---
## Overview ## Overview
This document explains how to approach the existing Vue.js code in Formipay during the migration to React admin panels. This document explains how to approach migrating existing Formipay admin interfaces to React while **ensuring zero feature loss**.
**Key Principle:** We are NOT doing a "rewrite everything at once" approach. Vue and React will coexist during Phase 2, with Vue being removed incrementally as React replacements are shipped. **Key Principle:** **Coexistence until Feature Parity** — New React versions must match or exceed old functionality before deprecating old code. No "delete and replace" without validation.
--- ---
## Current Vue.js Usage Inventory ## Existing Technology Inventory
| Type | Location | Purpose | Replacement Phase | | Technology | Location | Purpose | Feature Count | Migration Priority |
|------|----------|---------|-------------------| |-------------|----------|---------|---------------|-------------------|
| **WPCFTO Framework** | `vendor/` | Settings form builder (repeater, fields, etc.) | F2.8 (Global Settings) | | **WPCFTO Framework** | `vendor/` | Settings form builder | N/A | Low (replaced by React settings) |
| **Custom Vue 2 App** | `admin/assets/js/admin-product-editor.js` | Product variation pricing table | F2.9 (Product Editor) | | **Grid.js** | `admin/assets/js/page-*.js` | All admin tables | ~20 features per page | **HIGH** (current gap) |
| **Partial Vue Editor** | `admin/assets/vue/` (if exists) | Form builder canvas | F2.4 (Form Builder) | | **SweetAlert2** | `vendor/SweetAlert2/` | Modal dialogs | N/A | None (keep using) |
| **Custom Vue 2 App** | `admin/assets/js/admin-product-editor.js` | Product variation pricing | ~7 features | Medium |
| **jQuery** | Core WP dependency | DOM manipulation | N/A | Phase out gradually |
--- ---
## Migration Strategy ## Critical: Grid.js Migration Strategy
### Phase 1: Coexistence (Current State) ### Current Grid.js Features (Must Preserve)
**Every admin page with Grid.js has these features:**
| Feature | Forms | Coupons | Access | Orders | Products |
|---------|-------|--------|-------|--------|----------|
| Checkbox column + "Select All" | ✅ | ✅ | ✅ | ✅ | ✅ |
| Bulk delete button | ✅ | ✅ | ✅ | ✅ | ✅ |
| Inline row actions (hover) | ✅ | ✅ | ✅ | ✅ | ✅ |
| Status filter tabs (All/Published/Draft) | ✅ | ✅ | ✅ | ❌ | ✅ |
| Search input | ✅ | ✅ | ✅ | ✅ | ✅ |
| Sort dropdown | ✅ | ✅ | ✅ | ❌ | ✅ |
| Order (ASC/DESC) | ✅ | ✅ | ✅ | ❌ | ✅ |
| Server-side pagination | ✅ | ✅ | ✅ | ✅ | ✅ |
| "Add New" modal (SweetAlert2) | ✅ | ✅ | ✅ | ❌ | ✅ |
| Inline delete action | ✅ | ✅ | ✅ | ✅ | ✅ |
| Inline duplicate action | ✅ | ✅ | ✅ | ❌ | ✅ |
| Shortcode copy button | ✅ | ❌ | ❌ | ❌ | ❌ |
| Multi-currency display | ❌ | ✅ | ❌ | ❌ | ✅ |
**Total: ~20 table features per page**
### Migration Approach: Coexistence
**DO NOT:** Delete old Grid.js code until React replacement is feature-complete
**DO:** Use query param or feature flag to run both versions side-by-side
``` ```
┌─────────────────────────────────────────────┐ Phase 1: Coexistence (Required)
│ Admin Pages │ ┌─────────────────────────────────────────────────────────┐
├─────────────────────────────────────────────┤ │ Formipay Admin │
│ Forms │ Products │ Orders │ Settings │ ├─────────────────────────────────────────────────────────┤
WPCFTO │ WPCFTO │ WPCFTO │ WPCFTO Old: Grid.js Tables → Fully functional
+ Vue │ + Vue │ (none) │ + Vue New: React Tables → Under development, feature-incomplete
│ (custom) │
└─────────────────────────────────────────────┘ │ Access: ?old=1 → Grid.js | ?old=0 → React (default when ready) │
└─────────────────────────────────────────────────────────┘
``` ```
**Status:** All pages working. WPCFTO handles forms, custom Vue handles product variations. ### Implementation Steps
--- **Step 1: Feature Parity Checklist**
### Phase 2: Incremental React Rollout Create `MIGRATION_CHECKLIST.md` with per-page feature requirements (see template below).
**Week 3:** Build pipeline + React infrastructure. Vue untouched. **Step 2: Dual-Mode Rendering**
**Week 4:** React Form Builder replaces WPCFTO form editor. In PHP page callback, check for query param:
```
Forms: WPCFTO/Vue → React ✅
Products: WPCFTO + custom Vue → unchanged
Orders: (none) → React ✅
Settings: WPCFTO → unchanged
```
**Week 5:** React Order Management (no Vue to touch).
```
Orders → React ✅
```
**Week 6:** React Global Settings replaces WPCFTO.
```
Settings: WPCFTO → React ✅
Products: WPCFTO + custom Vue → unchanged
```
**After Week 6:** WPCFTO framework can be fully removed. Only custom Vue remains.
---
### Product Editor Migration (F2.9)
The custom Vue app in `admin-product-editor.js` is the last Vue piece to migrate.
#### Current Features (Must Recreate in React)
| Feature | Complexity | Notes |
|---------|-----------|-------|
| Multi-currency flat pricing | Medium | Show all currencies as columns when enabled |
| Multi-currency expanded mode | High | Inner tables per variation row |
| Dynamic rows from attribute repeater | High | Syncs with WPCFTO repeater in real-time |
| Decimal digits per currency | Medium | Step values calculated from currency config |
| Required field validation | Low | Default currency must have price |
| Real-time JSON update to hidden input | Medium | `product_variation_variables` hidden field |
| SweetAlert2 error dialogs | Low | Uses existing window.Swal or custom alert |
#### Implementation Approach
**Option A: Recreate from Scratch (Recommended)**
1. **Read current Vue code logic** — understand the data flow
2. **Build React component**`VariationPricingTable.jsx`
- Use `@tanstack/react-table` for the table
- Use `@wordpress/components` for form inputs (TextControl, etc.)
- State management via React hooks (useState, useEffect)
3. **Data sync layer** — same API endpoint `get_product_variables`
4. **Validation logic** — port `findFirstMissingDefault()` to React
**Pros:** Clean React code, modern patterns, better type safety
**Cons:** Requires careful testing to match all edge cases
**Option B: Vue-in-React Wrapper (Not Recommended)**
Wrap the existing Vue app in a React component using a library like `vue-react-wrapper`.
**Pros:** Faster, less risky
**Cons:** Technical debt, adds bundle size, mixing paradigms
**Recommendation:** Option A — rewrite in React. The logic is well-contained (~500 lines) and rewriting gives us clean, maintainable React code.
---
## Migration Checklist for F2.9
When implementing the React Product Editor:
- [ ] Read and document current Vue variation table behavior
- [ ] Create `src/admin/pages/Products/VariationPricingTable.jsx`
- [ ] Implement multi-currency flat pricing mode
- [ ] Implement multi-currency expanded mode (inner tables)
- [ ] Implement attribute repeater sync (MutationObserver or polling)
- [ ] Implement decimal digits per currency step calculation
- [ ] Implement required field validation (default currency)
- [ ] Implement real-time JSON update to hidden `product_variation_variables` input
- [ ] Add SweetAlert2 or equivalent for validation errors
- [ ] Test with various currency configurations
- [ ] Test with existing products (data migration)
- [ ] Remove old Vue script enqueuing from Product page
- [ ] Remove Vue dependency if no longer used elsewhere
---
## Data Compatibility
The React component must read/write the same data format as the Vue app:
**Hidden input format** (`product_variation_variables`):
```json
[
{
"key": "Red|||Large",
"name": "Red - Large",
"stock": "",
"weight": 0,
"active": true,
"prices": [
{
"currency": "USD:::United States Dollar:::$",
"regular_price": "29.99",
"sale_price": "24.99",
"currency_decimal_digits": 2
}
]
}
]
```
**API endpoint** (already exists):
```php ```php
// Product.php - add this if not present public function formipay_form() {
add_action('wp_ajax_get_product_variables', [$this, 'ajax_get_product_variables']); $use_react = isset($_GET['react']) || get_option('formipay_use_react_admin', false);
if ($use_react) {
\Formipay\Admin\ReactAdmin::render_mount_point('forms');
} else {
include_once FORMIPAY_PATH . 'admin/page-forms.php';
}
}
```
**Step 3: Feature Flag for Default**
```php
// In settings or option
public function use_react_admin() {
return get_option('formipay_use_react_admin', false);
}
```
**Step 4: Toggle Link in Admin**
```php
// Add to admin footer or menu bar
<?php if (!get_option('formipay_use_react_admin')) : ?>
<a href="<?php echo admin_url('admin.php?page=formipay&react=1'); ?>">
Try React Admin (Beta)
</a>
<?php else: ?>
<a href="<?php echo admin_url('admin.php?page=formipay&react=0'); ?>">
Use Classic Admin
</a>
<?php endif; ?>
```
### Testing Protocol
Before setting `use_react_admin` to true as default:
1. **Manual Feature Testing**: Go through checklist item by item
2. **Regression Testing**: Old Grid.js version still works
3. **Data Compatibility**: Both versions read/write same data format
4. **Performance**: React version not significantly slower
5. **Browser Testing**: Test in Chrome, Firefox, Safari
**Only when ALL checkboxes pass → Enable React by default**
---
## React Component Library Strategy
### Approved Libraries
| Library | Purpose | Why |
|---------|---------|-----|
| **@wordpress/components** | UI primitives (Button, Modal, SelectControl, TextControl) | Native WP styling, already bundled |
| **@tanstack/react-table** (v8) | Headless table engine | Flexible, performant, React 18 compatible |
| **SweetAlert2** (existing) | Modals, confirmations, toasts | Already in use, keep as-is |
| **@wordpress/icons** | Icons | Already bundled, correct WP styling |
### Libraries to AVOID
| Library | Reason to Avoid |
|---------|-----------------|
| **shadcn/ui** | Wrong styling (Tailwind vs WP), requires Tailwind setup |
| **Material UI (@mui/x-data-grid)** | Wrong styling, 100KB+ bundle, overkill |
| **react-table** (v7) | Deprecated, use @tanstack/react-table |
| **react-data-table** | Heavy bundle, opinionated styling |
### Building the Table Component
```jsx
// Use @tanstack/react-table for the engine
import { useReactTable } from '@tanstack/react-table';
// Use @wordpress/components for UI
import { Modal, Button, CheckboxControl } from '@wordpress/components';
// Style with WordPress classes
<table className="wp-list-table widefat fixed striped">
``` ```
--- ---
## Testing Strategy ## Data Compatibility Requirements
1. **Unit Tests:** Test variation price calculation logic in isolation ### AJAX Endpoints (Must Preserve)
2. **Integration Tests:** Test loading/saving variations via AJAX
3. **Manual Tests:** | Endpoint | Request | Response Format |
- Create product with variations |----------|---------|------------------|
- Test multi-currency flat mode | `formipay-tabledata-forms` | GET + params | `{results, total, posts_report}` |
- Test multi-currency expanded mode | `formipay-create-form-post` | POST + title | `{success, data: {edit_post_url}}` |
- Test validation (missing default price) | `formipay-delete-form` | POST + id | `{success, data: {title, message, icon}}` |
- Test attribute repeater sync | `formipay-duplicate-form` | POST + id | `{success, data: {title, message, icon}}` |
- Test editing existing products (data compatibility) | `formipay-bulk-delete-form` | POST + ids[] | `{success, data: {title, message, icon}}` |
| `get_product_variables` | GET + post_id | Variation data object |
| `formipay-tabledata-coupons` | GET + params | `{results, total, posts_report}` |
| `formipay-tabledata-access-items` | GET + params | `{results, total, posts_report}` |
| `formipay-tabledata-orders` | GET + params | `{results, total, posts_report}` |
| `formipay-tabledata-customers` | GET + params | `{results, total, posts_report}` |
**Critical:** Response formats must remain identical for compatibility!
--- ---
## Rollback Plan ## Rollback Plan
If React version has critical bugs: If React version has issues:
1. Keep Vue version as fallback
2. Add feature flag in settings: "Use React Product Editor (beta)" 1. **Immediate:** Set `formipay_use_react_admin` to false
3. Default to Vue, allow opting into React 2. **Users see:** Classic Grid.js version (fully functional)
4. Fix React, then make it default 3. **Fix React:** Debug and fix in development
4. **Retest:** Go through checklist again
5. **Re-enable:** Set flag back to true
**Critical:** Never deploy without working fallback!
---
## Migration Checklist Template
Copy this template for each page migration:
### [Page Name] Migration Checklist
#### Table Core Features
- [ ] Data loads and displays correctly
- [ ] Loading spinner shown during fetch
- [ ] Empty state shown when no data
- [ ] Error state handled gracefully
#### Selection Features
- [ ] Checkbox column renders
- [ ] "Select All" checkbox works
- [ ] Individual row checkboxes work
- [ ] Checkboxes persist across page changes
- [ ] Bulk delete button appears when rows selected
- [ ] Bulk delete confirmation modal
- [ ] Bulk delete refreshes table
#### Row Actions
- [ ] Hover shows action links
- [ ] Edit action navigates correctly
- [ ] Delete action shows confirmation
- [ ] Delete action removes row and refreshes
- [ ] Duplicate action shows confirmation
- [ ] Duplicate action adds new row and refreshes
#### Filtering & Sorting
- [ ] Status filter tabs work
- [ ] Status counts display correctly
- [ ] Active filter highlighted
- [ ] Search input filters results
- [ ] Search debounce (don't spam server)
- [ ] Sort dropdown works
- [ ] Order toggle works
- [ ] Combined filters work (search + status + sort)
#### Pagination
- [ ] Pagination controls display
- [ ] Page numbers correct
- [ ] Next/Previous buttons work
- [ ] Limit per page respected
- [ ] Total count accurate
#### Create Features
- [ ] "Add New" button visible
- [ ] Modal/dialog opens on click
- [ ] Modal has required fields
- [ ] Modal validation works
- [ ] Create action succeeds
- [ ] Post creation redirects to edit
- [ ] Error handling in modal
#### UX Details
- [ ] Row hover effects work
- [ ] Selected row highlighting
- [ ] Toast notifications for actions
- [ ] Confirmation dialogs for destructive actions
- [ ] Keyboard accessibility (Enter, Escape)
- [ ] Loading states during actions
#### Data Compatibility
- [ ] Same AJAX endpoints as old version
- [ ] Same request format
- [ ] Same response format handling
- [ ] Hidden inputs updated (if applicable)
- [ ] WordPress nonces handled correctly
--- ---
## Notes ## Notes
- **No Vue Router used** — the Vue app is a simple mount, no routing to worry about - **Grid.js Library:** ~20KB, well-tested, handles server-side pagination well
- **No Vuex** — state is local component state + hidden input sync - **@tanstack/react-table:** ~9KB, headless, requires more setup but more flexible
- **jQuery dependency** — the Vue app uses jQuery for DOM selection (`$('#product-variables-table')`. React version should eliminate this. - **jQuery:** Still used by WordPress core, will remain for now
- **Timing:** WPCFTO removal happens after Global Settings (F2.8), but Product Editor Vue app is independent — can be migrated anytime after Week 3. - **SweetAlert2:** Keep using, integrates well with React
- **Migration is NOT a race:** Take time to get it right
--- ---

View File

@@ -84,10 +84,21 @@ class Access {
} }
public function formipay_access_items() { public function formipay_access_items() {
// Coexistence mode: check query param or setting for React version
$use_react = isset($_GET['react']) || get_option('formipay_use_react_admin', false);
if ($use_react) {
// New React version
\Formipay\Admin\ReactAdmin::render_mount_point('access'); \Formipay\Admin\ReactAdmin::render_mount_point('access');
} else {
// Classic Grid.js version
include_once FORMIPAY_PATH . 'admin/page-access-items.php';
}
} }
public function enqueue_admin() { public function enqueue_admin() {
// Assets now handled by ReactAdmin class
return;
global $current_screen; global $current_screen;
@@ -432,7 +443,7 @@ class Access {
public function formipay_tabledata_access_items() { public function formipay_tabledata_access_items() {
check_ajax_referer( 'formipay-admin-access-nonce', '_wpnonce' ); check_ajax_referer( 'formipay-admin', '_wpnonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Unauthorized' ] ); wp_send_json_error( [ 'message' => 'Unauthorized' ] );
@@ -526,7 +537,7 @@ class Access {
public function formipay_access_items_get_products() { public function formipay_access_items_get_products() {
check_ajax_referer( 'formipay-admin-access-nonce', 'nonce' ); check_ajax_referer( 'formipay-admin', '_wpnonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Unauthorized' ] ); wp_send_json_error( [ 'message' => 'Unauthorized' ] );
@@ -560,7 +571,7 @@ class Access {
public function formipay_create_access_item_post() { public function formipay_create_access_item_post() {
check_ajax_referer( 'formipay-admin-access-nonce', 'nonce' ); check_ajax_referer( 'formipay-admin', '_wpnonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Unauthorized' ] ); wp_send_json_error( [ 'message' => 'Unauthorized' ] );
@@ -592,7 +603,7 @@ class Access {
public function formipay_delete_access_item() { public function formipay_delete_access_item() {
check_ajax_referer( 'formipay-admin-access-nonce', 'nonce' ); check_ajax_referer( 'formipay-admin', '_wpnonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Unauthorized' ] ); wp_send_json_error( [ 'message' => 'Unauthorized' ] );
@@ -627,7 +638,7 @@ class Access {
public function formipay_bulk_delete_access_item() { public function formipay_bulk_delete_access_item() {
check_ajax_referer( 'formipay-admin-access-nonce', 'nonce' ); check_ajax_referer( 'formipay-admin', '_wpnonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Unauthorized' ] ); wp_send_json_error( [ 'message' => 'Unauthorized' ] );
@@ -674,7 +685,7 @@ class Access {
public function formipay_duplicate_access_item() { public function formipay_duplicate_access_item() {
check_ajax_referer( 'formipay-admin-access-nonce', 'nonce' ); check_ajax_referer( 'formipay-admin', '_wpnonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Unauthorized' ] ); wp_send_json_error( [ 'message' => 'Unauthorized' ] );

View File

@@ -12,6 +12,8 @@ class ReactAdmin {
add_action( 'admin_enqueue_scripts', [$this, 'enqueue_assets'] ); add_action( 'admin_enqueue_scripts', [$this, 'enqueue_assets'] );
add_filter( 'formipay/admin/data', [$this, 'localize_data'] ); add_filter( 'formipay/admin/data', [$this, 'localize_data'] );
add_action( 'admin_notices', [$this, 'version_notice'] );
add_filter( 'admin_footer_text', [$this, 'footer_toggle'] );
} }
@@ -29,16 +31,25 @@ class ReactAdmin {
$build_url = FORMIPAY_URL . 'build'; $build_url = FORMIPAY_URL . 'build';
if ( ! file_exists( $build_dir . '/admin.asset.php' ) ) { if ( ! file_exists( $build_dir . '/admin.asset.php' ) ) {
error_log('[Formipay] Build files not found at: ' . $build_dir . '/admin.asset.php');
return; // Build not generated yet return; // Build not generated yet
} }
$assets_file = require $build_dir . '/admin.asset.php'; $assets_file = require $build_dir . '/admin.asset.php';
$dependencies = $assets_file['dependencies'] ?? []; $dependencies = $assets_file['dependencies'] ?? [];
// Filter out icon build dependencies - they're bundled, not separate scripts
$original_count = count($dependencies);
$dependencies = array_values(array_filter($dependencies, function($dep) {
return strpos($dep, 'wp-icons/build/') === false;
}));
error_log('[Formipay] Filtered dependencies: ' . $original_count . ' -> ' . count($dependencies));
$version = $assets_file['version'] ?? FORMIPAY_VERSION; $version = $assets_file['version'] ?? FORMIPAY_VERSION;
wp_enqueue_style( wp_enqueue_style(
'formipay-admin-style', 'formipay-admin-style',
$build_url . '/style-admin.css', $build_url . '/admin.css',
[], [],
$version $version
); );
@@ -58,6 +69,10 @@ class ReactAdmin {
'nonce' => wp_create_nonce( 'formipay-admin' ), 'nonce' => wp_create_nonce( 'formipay-admin' ),
] ); ] );
// Debug logging
error_log('[Formipay] Enqueuing React assets on screen: ' . $screen->id);
error_log('[Formipay] Page data: ' . wp_json_encode($data));
wp_localize_script( 'formipay-admin', 'formipayAdmin', $data ); wp_localize_script( 'formipay-admin', 'formipayAdmin', $data );
} }
@@ -116,6 +131,14 @@ class ReactAdmin {
$data['currencies'] = formipay_global_currency_options(); $data['currencies'] = formipay_global_currency_options();
break; break;
case 'forms':
case 'coupons':
case 'access':
case 'licenses':
// These pages fetch data via AJAX, no initial data needed
$data = [];
break;
} }
return $data; return $data;
@@ -128,8 +151,63 @@ class ReactAdmin {
public static function render_mount_point( $page ) { public static function render_mount_point( $page ) {
printf( printf(
'<div id="formipay-admin-root" data-formipay-mount="%s"></div>', '<div id="formipay-admin-root" data-formipay-mount="%s">Loading %s...</div>',
esc_attr( $page ) esc_attr( $page ),
esc_html( ucfirst( $page ) )
);
}
/**
* Show admin notice about current admin version
*/
public function version_notice() {
$screen = get_current_screen();
// Only show on Formipay admin pages
if ( strpos( $screen->id, 'formipay' ) === false ) {
return;
}
$use_react = isset($_GET['react']) || get_option('formipay_use_react_admin', false);
$version = $use_react ? 'React (Beta)' : 'Classic';
printf(
'<div class="notice notice-info inline">
<p>
<strong>Formipay Admin:</strong> Using %s version.
<a href="%s" class="button button-small" style="margin-left: 10px;">Switch to %s</a>
</p>
</div>',
esc_html( $version ),
esc_url( add_query_arg( 'react', $use_react ? '0' : '1' ) ),
esc_html( $use_react ? 'Classic' : 'React (Beta)' )
);
}
/**
* Add toggle link to admin footer
*/
public function footer_toggle( $text ) {
$screen = get_current_screen();
// Only add toggle on Formipay admin pages
if ( strpos( $screen->id, 'formipay' ) === false ) {
return $text;
}
$use_react = isset($_GET['react']) || get_option('formipay_use_react_admin', false);
$toggle_url = add_query_arg( 'react', $use_react ? '0' : '1' );
$toggle_text = $use_react ? 'Switch to Classic' : 'Try React (Beta)';
return sprintf(
'%s | <a href="%s">%s</a>',
$text,
esc_url( $toggle_url ),
esc_html( $toggle_text )
); );
} }

View File

@@ -94,10 +94,21 @@ class Coupon {
} }
public function formipay_coupon() { public function formipay_coupon() {
// Coexistence mode: check query param or setting for React version
$use_react = isset($_GET['react']) || get_option('formipay_use_react_admin', false);
if ($use_react) {
// New React version
\Formipay\Admin\ReactAdmin::render_mount_point('coupons'); \Formipay\Admin\ReactAdmin::render_mount_point('coupons');
} else {
// Classic Grid.js version
include_once FORMIPAY_PATH . 'admin/page-coupons.php';
}
} }
public function enqueue_admin() { public function enqueue_admin() {
// Assets now handled by ReactAdmin class
return;
global $current_screen; global $current_screen;
@@ -568,7 +579,7 @@ class Coupon {
public function formipay_tabledata_coupons() { public function formipay_tabledata_coupons() {
check_ajax_referer( 'formipay-admin-coupon-page', '_wpnonce' ); check_ajax_referer( 'formipay-admin', '_wpnonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Unauthorized' ] ); wp_send_json_error( [ 'message' => 'Unauthorized' ] );
@@ -676,7 +687,7 @@ class Coupon {
public function formipay_coupon_get_products() { public function formipay_coupon_get_products() {
check_ajax_referer( 'formipay-admin-coupon-page', '_wpnonce' ); check_ajax_referer( 'formipay-admin', '_wpnonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Unauthorized' ] ); wp_send_json_error( [ 'message' => 'Unauthorized' ] );
@@ -710,7 +721,7 @@ class Coupon {
public function formipay_create_coupon_post() { public function formipay_create_coupon_post() {
check_ajax_referer( 'formipay-admin-coupon-page', '_wpnonce' ); check_ajax_referer( 'formipay-admin', '_wpnonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Unauthorized' ] ); wp_send_json_error( [ 'message' => 'Unauthorized' ] );
@@ -745,7 +756,7 @@ class Coupon {
public function formipay_delete_coupon() { public function formipay_delete_coupon() {
check_ajax_referer( 'formipay-admin-coupon-page', '_wpnonce' ); check_ajax_referer( 'formipay-admin', '_wpnonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Unauthorized' ] ); wp_send_json_error( [ 'message' => 'Unauthorized' ] );
@@ -773,7 +784,7 @@ class Coupon {
public function formipay_bulk_delete_coupon() { public function formipay_bulk_delete_coupon() {
check_ajax_referer( 'formipay-admin-coupon-page', '_wpnonce' ); check_ajax_referer( 'formipay-admin', '_wpnonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Unauthorized' ] ); wp_send_json_error( [ 'message' => 'Unauthorized' ] );
@@ -820,7 +831,7 @@ class Coupon {
public function formipay_duplicate_coupon() { public function formipay_duplicate_coupon() {
check_ajax_referer( 'formipay-admin-coupon-page', '_wpnonce' ); check_ajax_referer( 'formipay-admin', '_wpnonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Unauthorized' ] ); wp_send_json_error( [ 'message' => 'Unauthorized' ] );

View File

@@ -219,12 +219,21 @@ class Customer {
} }
public function customers_page() { public function customers_page() {
// Coexistence mode: check query param or setting for React version
$use_react = isset($_GET['react']) || get_option('formipay_use_react_admin', false);
if ($use_react) {
// New React version
\Formipay\Admin\ReactAdmin::render_mount_point('customers'); \Formipay\Admin\ReactAdmin::render_mount_point('customers');
} else {
// Classic Grid.js version
include_once FORMIPAY_PATH . 'admin/page-customers.php';
}
} }
public function formipay_tabledata_customers() { public function formipay_tabledata_customers() {
check_ajax_referer( 'formipay-admin-access-nonce', '_wpnonce' ); check_ajax_referer( 'formipay-admin', '_wpnonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Unauthorized' ] ); wp_send_json_error( [ 'message' => 'Unauthorized' ] );

View File

@@ -93,7 +93,16 @@ class Form {
} }
public function formipay_form() { public function formipay_form() {
// Coexistence mode: check query param or setting for React version
$use_react = isset($_GET['react']) || get_option('formipay_use_react_admin', false);
if ($use_react) {
// New React version
\Formipay\Admin\ReactAdmin::render_mount_point('forms'); \Formipay\Admin\ReactAdmin::render_mount_point('forms');
} else {
// Classic Grid.js version
include_once FORMIPAY_PATH . 'admin/page-forms.php';
}
} }
public function metaboxes($post) { public function metaboxes($post) {
@@ -1248,6 +1257,8 @@ class Form {
} }
public function enqueue_admin() { public function enqueue_admin() {
// Assets now handled by ReactAdmin class
return;
global $current_screen, $post; global $current_screen, $post;
// Check that we are on the 'Checker' post editor screen // Check that we are on the 'Checker' post editor screen
@@ -1547,7 +1558,14 @@ class Form {
public function formipay_tabledata_forms() { public function formipay_tabledata_forms() {
check_ajax_referer( 'formipay-admin-post', '_wpnonce' ); error_log('[Formipay] formipay_tabledata_forms called');
$nonce_check = check_ajax_referer( 'formipay-admin', '_wpnonce', false );
error_log('[Formipay] Nonce check result: ' . ($nonce_check ? 'valid' : 'invalid'));
if ( ! $nonce_check ) {
wp_send_json_error( [ 'message' => 'Invalid nonce' ] );
}
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Unauthorized' ] ); wp_send_json_error( [ 'message' => 'Unauthorized' ] );
@@ -1629,7 +1647,7 @@ class Form {
public function formipay_create_form_post() { public function formipay_create_form_post() {
check_ajax_referer( 'formipay-admin-post', '_wpnonce' ); check_ajax_referer( 'formipay-admin', '_wpnonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Unauthorized' ] ); wp_send_json_error( [ 'message' => 'Unauthorized' ] );
@@ -1702,7 +1720,7 @@ class Form {
public function formipay_delete_form() { public function formipay_delete_form() {
check_ajax_referer( 'formipay-admin-post', '_wpnonce' ); check_ajax_referer( 'formipay-admin', '_wpnonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Unauthorized' ] ); wp_send_json_error( [ 'message' => 'Unauthorized' ] );
@@ -1730,7 +1748,7 @@ class Form {
public function formipay_bulk_delete_form() { public function formipay_bulk_delete_form() {
check_ajax_referer( 'formipay-admin-post', '_wpnonce' ); check_ajax_referer( 'formipay-admin', '_wpnonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Unauthorized' ] ); wp_send_json_error( [ 'message' => 'Unauthorized' ] );
@@ -1777,7 +1795,7 @@ class Form {
public function formipay_duplicate_form() { public function formipay_duplicate_form() {
check_ajax_referer( 'formipay-admin-post', '_wpnonce' ); check_ajax_referer( 'formipay-admin', '_wpnonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Unauthorized' ] ); wp_send_json_error( [ 'message' => 'Unauthorized' ] );

View File

@@ -70,7 +70,16 @@ class License {
} }
public function page_licenses() { public function page_licenses() {
// Coexistence mode: check query param or setting for React version
$use_react = isset($_GET['react']) || get_option('formipay_use_react_admin', false);
if ($use_react) {
// New React version
\Formipay\Admin\ReactAdmin::render_mount_point('licenses'); \Formipay\Admin\ReactAdmin::render_mount_point('licenses');
} else {
// Classic Grid.js version
include_once FORMIPAY_PATH . 'admin/page-licenses.php';
}
} }
/** Enqueue admin assets for Licenses page */ /** Enqueue admin assets for Licenses page */
@@ -121,7 +130,7 @@ class License {
/** GridJS data source */ /** GridJS data source */
public function tabledata() { public function tabledata() {
check_ajax_referer('formipay-admin-licenses', '_wpnonce'); check_ajax_referer('formipay-admin', '_wpnonce');
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Unauthorized' ] ); wp_send_json_error( [ 'message' => 'Unauthorized' ] );
@@ -202,7 +211,7 @@ class License {
/** Delete single license */ /** Delete single license */
public function delete() { public function delete() {
check_ajax_referer('formipay-admin-licenses', '_wpnonce'); check_ajax_referer('formipay-admin', '_wpnonce');
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Unauthorized' ] ); wp_send_json_error( [ 'message' => 'Unauthorized' ] );
@@ -238,7 +247,7 @@ class License {
/** Bulk delete */ /** Bulk delete */
public function bulk_delete() { public function bulk_delete() {
check_ajax_referer('formipay-admin-licenses', '_wpnonce'); check_ajax_referer('formipay-admin', '_wpnonce');
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Unauthorized' ] ); wp_send_json_error( [ 'message' => 'Unauthorized' ] );

View File

@@ -644,91 +644,29 @@ class Order {
$order_id = isset($_GET['order_id']) ? intval($_GET['order_id']) : 0; $order_id = isset($_GET['order_id']) ? intval($_GET['order_id']) : 0;
$page = $order_id ? 'order-detail' : 'orders'; $page = $order_id ? 'order-detail' : 'orders';
// Render React mount point // Coexistence mode: check query param or setting for React version
$use_react = isset($_GET['react']) || get_option('formipay_use_react_admin', false);
if ($use_react) {
// New React version
printf( printf(
'<div id="formipay-admin-root" data-formipay-mount="%s"></div>', '<div id="formipay-admin-root" data-formipay-mount="%s"></div>',
esc_attr($page) esc_attr($page)
); );
} else {
// Classic Grid.js version
if ($order_id) {
include_once FORMIPAY_PATH . 'admin/page-order-details.php';
} else {
include_once FORMIPAY_PATH . 'admin/page-orders.php';
}
}
} }
public function enqueue() { public function enqueue() {
// Assets now handled by ReactAdmin class
global $current_screen; return;
if($current_screen->id == 'formipay_page_formipay-orders') {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$order_id = isset($_GET['order_id']) ? intval($_GET['order_id']) : 0;
if(empty($order_id)){
wp_enqueue_style( 'page-orders', FORMIPAY_URL . 'admin/assets/css/admin-orders.css', [], FORMIPAY_VERSION, 'all' );
wp_enqueue_script( 'page-orders', FORMIPAY_URL . 'admin/assets/js/admin-orders.js', ['jquery', 'gridjs'], FORMIPAY_VERSION, true );
wp_localize_script( 'page-orders', 'formipay_orders_page', [
'ajax_url' => admin_url('admin-ajax.php'),
'site_url' => site_url(),
'columns' => [
'id' => esc_html__( 'ID', 'formipay' ),
'form' => esc_html__( 'Form', 'formipay' ),
'total' => esc_html__( 'Total', 'formipay' ),
'date' => esc_html__( 'Date', 'formipay' ),
'payment_gateway' => esc_html__( 'Payment Gateway', 'formipay' ),
'status' => esc_html__( 'Status', 'formipay' ),
],
'filter_form' => [
'products' => [
'placeholder' => esc_html__( 'Filter by Product', 'formipay' ),
'noresult_text' => esc_html__( 'No results found', 'formipay' )
],
'currencies' => [
'placeholder' => esc_html__( 'Filter by Currency', 'formipay' ),
'noresult_text' => esc_html__( 'No results found', 'formipay' )
]
],
'nonce' => wp_create_nonce( 'formipay-order-details' )
] );
}else{
wp_enqueue_style( 'bootstrap-icon', FORMIPAY_URL . 'vendor/Bootstrap/bootstrap-icons.css', [], '1.11.1', 'all');
wp_enqueue_style( 'bootstrap', FORMIPAY_URL . 'vendor/Bootstrap/bootstrap.min.css', [], '5.3.2' );
wp_enqueue_style( 'page-orders', FORMIPAY_URL . 'admin/assets/css/admin-order-details.css', [], FORMIPAY_VERSION, 'all' );
wp_enqueue_script( 'handlebars', FORMIPAY_URL . 'vendor/HandleBars/handlebars.min.js', [], '4.7.7', true);
wp_enqueue_script( 'bootstrap', FORMIPAY_URL . 'vendor/Bootstrap/bootstrap.bundle.min.js', ['jquery'], '5.3.2', true );
wp_enqueue_script( 'page-orders', FORMIPAY_URL . 'admin/assets/js/admin-order-details.js', ['jquery'], FORMIPAY_VERSION, true );
wp_localize_script( 'page-orders', 'formipay_order_details_page', [
'ajax_url' => admin_url('admin-ajax.php'),
'site_url' => site_url(),
'order_id' => $order_id,
'order_detail' => [
'change_order_status_confirmation' => esc_html__( 'Are you sure to change status?', 'formipay' ),
'change_order_status_button_confirm' => esc_html__( 'Change', 'formipay' ),
'change_order_status_button_cancel' => esc_html__( 'Cancel', 'formipay' ),
'edit_button_loading_text' => esc_html__( 'Preparing...', 'formipay' ),
'update_button_loading_text' => esc_html__( 'Updating...', 'formipay' ),
'pass_method' => [
'magic_link' => esc_html__( 'Magic Link', 'formipay' ),
'static_password' => esc_html__( 'Static Password', 'formipay' )
]
],
'modal' => [
'delete' => [
'question' => esc_html__( 'Do you want to delete the order?', '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 order(s)?', 'formipay' ),
'cancelButton' => esc_html__( 'Cancel', 'formipay' ),
'confirmButton' => esc_html__( 'Confirm', 'formipay' )
],
],
'nonce' => wp_create_nonce( 'formipay-order-details' )
] );
}
}
} }
public function formipay_get_all_forms() { public function formipay_get_all_forms() {
@@ -767,7 +705,7 @@ class Order {
public function formipay_orders_get_choices() { public function formipay_orders_get_choices() {
check_ajax_referer( 'formipay-order-details', '_wpnonce' ); check_ajax_referer( 'formipay-admin', '_wpnonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Unauthorized' ] ); wp_send_json_error( [ 'message' => 'Unauthorized' ] );
@@ -935,7 +873,7 @@ class Order {
public function formipay_tabledata_orders() { public function formipay_tabledata_orders() {
check_ajax_referer( 'formipay-order-details', '_wpnonce' ); check_ajax_referer( 'formipay-admin', '_wpnonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Unauthorized' ] ); wp_send_json_error( [ 'message' => 'Unauthorized' ] );
@@ -1038,7 +976,7 @@ class Order {
public function formipay_delete_order() { public function formipay_delete_order() {
check_ajax_referer( 'formipay-order-details', '_wpnonce' ); check_ajax_referer( 'formipay-admin', '_wpnonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Unauthorized' ] ); wp_send_json_error( [ 'message' => 'Unauthorized' ] );
@@ -1066,7 +1004,7 @@ class Order {
public function formipay_bulk_delete_order() { public function formipay_bulk_delete_order() {
check_ajax_referer( 'formipay-order-details', '_wpnonce' ); check_ajax_referer( 'formipay-admin', '_wpnonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Unauthorized' ] ); wp_send_json_error( [ 'message' => 'Unauthorized' ] );
@@ -1113,7 +1051,7 @@ class Order {
public function formipay_load_order_data() { public function formipay_load_order_data() {
check_ajax_referer( 'formipay-order-details', '_wpnonce' ); check_ajax_referer( 'formipay-admin', '_wpnonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Unauthorized' ] ); wp_send_json_error( [ 'message' => 'Unauthorized' ] );
@@ -1127,7 +1065,7 @@ class Order {
public function formipay_change_order_status() { public function formipay_change_order_status() {
check_ajax_referer( 'formipay-order-details', '_wpnonce' ); check_ajax_referer( 'formipay-admin', '_wpnonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Unauthorized' ] ); wp_send_json_error( [ 'message' => 'Unauthorized' ] );
@@ -1165,7 +1103,7 @@ class Order {
public function formipay_check_editable_field() { public function formipay_check_editable_field() {
check_ajax_referer( 'formipay-order-details', '_wpnonce' ); check_ajax_referer( 'formipay-admin', '_wpnonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Unauthorized' ] ); wp_send_json_error( [ 'message' => 'Unauthorized' ] );
@@ -1213,7 +1151,7 @@ class Order {
public function formipay_update_editable_field_data() { public function formipay_update_editable_field_data() {
check_ajax_referer( 'formipay-order-details', '_wpnonce' ); check_ajax_referer( 'formipay-admin', '_wpnonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Unauthorized' ] ); wp_send_json_error( [ 'message' => 'Unauthorized' ] );
@@ -1268,7 +1206,7 @@ class Order {
public function formipay_update_digital_access() { public function formipay_update_digital_access() {
check_ajax_referer( 'formipay-order-details', '_wpnonce' ); check_ajax_referer( 'formipay-admin', '_wpnonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( [ 'message' => 'Unauthorized' ] ); wp_send_json_error( [ 'message' => 'Unauthorized' ] );

View File

@@ -106,6 +106,8 @@ class Product {
} }
public function enqueue_admin() { public function enqueue_admin() {
// Assets now handled by ReactAdmin class
return;
global $current_screen; global $current_screen;
@@ -228,7 +230,16 @@ class Product {
} }
public function formipay_products() { public function formipay_products() {
// Coexistence mode: check query param or setting for React version
$use_react = isset($_GET['react']) || get_option('formipay_use_react_admin', false);
if ($use_react) {
// New React version
\Formipay\Admin\ReactAdmin::render_mount_point('products'); \Formipay\Admin\ReactAdmin::render_mount_point('products');
} else {
// Classic Grid.js version
include_once FORMIPAY_PATH . 'admin/page-products.php';
}
} }
public function cpt_post_fields_box($boxes) { public function cpt_post_fields_box($boxes) {

View File

@@ -0,0 +1,26 @@
.formipay-data-table-loading,
.formipay-data-table-empty {
padding: 40px;
text-align: center;
}
.formipay-data-table {
margin-top: 20px;
}
.formipay-data-table thead th {
padding: 12px 10px;
font-weight: 600;
}
.formipay-data-table tbody td {
padding: 10px;
}
.formipay-data-table tbody tr.is-clickable {
cursor: pointer;
}
.formipay-data-table tbody tr.is-clickable:hover {
background-color: #f0f0f1;
}

View File

@@ -0,0 +1,57 @@
/**
* Data Table - Simple table component for admin listings
*/
import { __ } from '@wordpress/i18n';
import './DataTable.css';
export default function DataTable({
columns,
data,
loading,
emptyMessage = __('No items found', 'formipay'),
onRowClick
}) {
if (loading) {
return (
<div className="formipay-data-table-loading">
<span className="spinner is-active" />
</div>
);
}
if (!data || data.length === 0) {
return (
<div className="formipay-data-table-empty">
<p>{ emptyMessage }</p>
</div>
);
}
return (
<table className="formipay-data-table wp-list-table widefat fixed striped">
<thead>
<tr>
{columns.map((column) => (
<th key={column.key}>{column.label}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, rowIndex) => (
<tr
key={rowIndex}
onClick={onRowClick ? () => onRowClick(row) : undefined}
className={onRowClick ? 'is-clickable' : ''}
>
{columns.map((column) => (
<td key={column.key}>
{column.render ? column.render(row) : row[column.key]}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}

View File

@@ -0,0 +1,45 @@
.formipay-page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.formipay-page-header h1 {
margin: 0;
font-size: 23px;
font-weight: 400;
}
.status-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.status-badge.status-publish,
.status-badge.status-active {
background-color: #edfaef;
color: #00a32a;
}
.status-badge.status-draft,
.status-badge.status-inactive {
background-color: #f0f0f1;
color: #646970;
}
.status-badge.status-expired {
background-color: #f6f7f7;
color: #d63638;
}
code {
background-color: #f0f0f1;
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
font-size: 13px;
}