docs: add comprehensive audit report and architectural recommendation
Checkpoint before implementation. Includes audit findings (FINDINGS.md), architectural recommendation (RECOMMENDATION.md), and existing code changes to Form, Order, Render, and form-action.js from recent development. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
196
FINDINGS.md
Normal file
196
FINDINGS.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# 🔍 Formipay Plugin — Comprehensive Audit Report
|
||||
|
||||
**Date:** April 17, 2026
|
||||
**Auditor:** GitHub Copilot
|
||||
**Plugin Version:** 1.0.0
|
||||
**Files Analyzed:** ~60+ files, ~15,000+ lines of PHP, JS, CSS, HTML
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [1. Bugs & Defects](#1-bugs--defects)
|
||||
- [1.1 Critical Bugs](#11-critical-bugs)
|
||||
- [1.2 Moderate Bugs](#12-moderate-bugs)
|
||||
- [2. Security Concerns](#2-security-concerns)
|
||||
- [3. Architecture & Code Quality Issues](#3-architecture--code-quality-issues)
|
||||
- [4. Missing Features & Modules](#4-missing-features--modules)
|
||||
- [5. Performance Issues](#5-performance-issues)
|
||||
- [6. Missing Admin Pages / Settings](#6-missing-admin-pages--settings)
|
||||
- [7. Code Cleanup Needed](#7-code-cleanup-needed)
|
||||
- [8. Opportunities & Nice-to-Haves](#8-opportunities--nice-to-haves)
|
||||
- [9. Summary Priority Matrix](#9-summary-priority-matrix)
|
||||
- [10. Architectural Recommendation](#10-architectural-recommendation)
|
||||
|
||||
---
|
||||
|
||||
## 1. Bugs & Defects
|
||||
|
||||
### 1.1 Critical Bugs
|
||||
|
||||
| # | Location | Issue | Detail |
|
||||
|---|----------|-------|--------|
|
||||
| 1 | `includes/Customer.php` ~line 172 `update()` | **Undefined variable — fatal error** | Method builds `$insert_data` and `$where`, but the `$wpdb->update()` call references undefined `$table_name` and `$new_args`. Every customer update will throw a PHP fatal error. |
|
||||
| 2 | `includes/Order.php` `delete()` | **Undefined `$id` variable** | Uses `$id` in the `$wpdb->delete()` where clause instead of the method parameter `$order_id`. Every order deletion call will fail. |
|
||||
| 3 | `includes/Order.php` `formipay_bulk_delete_order()` | **Iterates wrong variable** | Loops `foreach($ids as $id)` but calls `$this->delete($order_id)` — `$order_id` comes from the outer scope (nonce check), not the loop variable. Bulk delete will repeatedly delete the same (or zero) orders. |
|
||||
| 4 | `includes/Notification/Email.php` `send_email()` | **Wrong class reference — fatal error** | Calls `\Formipay_Notification::update_notification_data()` — this class does not exist. Should use `parent::update_notification_data()`. Email status tracking will crash. |
|
||||
| 5 | `includes/Integration/Paypal.php` `auto_cancel_order_on_timeout()` | **Undefined `Order` class** | Calls `Order::update(...)` but unlike `BankTransfer.php`, `Paypal.php` does not import `use Formipay\Order as Order`. This will throw a class-not-found error on timeout. |
|
||||
| 6 | `includes/Integration/Paypal.php` `process_payment()` | **Undefined `self::paypal_settings`** | The PayPal class never declares a `$paypal_settings` property. Accessing it leads to undefined property notices and broken payment flow. |
|
||||
|
||||
### 1.2 Moderate Bugs
|
||||
|
||||
| # | Location | Issue |
|
||||
|---|----------|-------|
|
||||
| 7 | `includes/Payment/BankTransfer.php` `check_unique_code()` | Uses `MAX(id)+1` for unique codes — predictable and subject to race conditions. Two concurrent orders can receive the same unique code. |
|
||||
| 8 | `includes/Payment/BankTransfer.php` `add_unique_code_details()` | Calls `$this->check_unique_code()` **three times** per request (once for `'item'`, once for `'amount'`, once for `'subtotal'`). Each call queries the DB independently and may return different values. Displayed unique code may not match the stored one. |
|
||||
| 9 | `admin/functions.php` `formipay_field_type_collection()` | Color field label says `'Number'` instead of `'Color'` — copy-paste error. |
|
||||
| 10 | `includes/Render.php` field rendering | No `default` fallback rendered when field type doesn't match any case in the switch — unknown field types silently produce no output. |
|
||||
| 11 | `includes/Order.php` `render_form_submit()` | `$field_value` is sometimes an array (from checkbox fields) but the code assumes string context. Nested `if(is_array($field_value))` only handles one level. |
|
||||
| 12 | `includes/Thankyou.php` `check_parse_query()` vs `formipay_get_order()` | Old cookie-based URL (`base64_encode`) co-exists with new Token-based validation. `formipay_get_order()` generates the old-format URL for the `thankyou` link, but the new Token system expects a different format. Inconsistent access path. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Security Concerns
|
||||
|
||||
| # | Severity | Location | Issue |
|
||||
|---|----------|----------|-------|
|
||||
| 1 | **High** | `includes/Order.php` `retrieve_form_data()` | Cookie `fp_access` uses `maybe_serialize()` — **PHP object injection** risk if an attacker can manipulate cookie values. Should use `json_encode()`/`json_decode()`. |
|
||||
| 2 | **High** | `includes/Thankyou.php` `set_endpoint()` | Calls `flush_rewrite_rules()` on **every `init`** hook — extremely expensive and causes race conditions under concurrent load. Should only flush on activation/deactivation or settings save. |
|
||||
| 3 | **High** | `includes/Payment/Payment.php` `set_endpoint()` | Same `flush_rewrite_rules()` issue — fires on every page load. |
|
||||
| 4 | **Medium** | `includes/Order.php` `retrieve_form_data()` | Thank-you URL uses `base64_encode(form_id:::order_id:::session_id)` — base64 is not encryption. Sequential order IDs can be guessed. The `Token` class provides proper tokens but the old path remains active. |
|
||||
| 5 | **Medium** | `includes/LicenseAPI.php` | All REST endpoints use `permission_callback => '__return_true'`. The `revoke` endpoint has a stub permission callback that always returns `true`. Anyone can revoke or manipulate licenses without authentication. |
|
||||
| 6 | **Medium** | `includes/Integration/Paypal.php` `webhook_endpoint()` | No PayPal webhook signature verification. An attacker could forge webhook calls to mark orders as paid without actual payment. |
|
||||
| 7 | **Medium** | `includes/Customer.php` `formipay_tabledata_customers()` | No nonce check (`check_ajax_referer`). Any authenticated user can dump all customer data via direct AJAX call. |
|
||||
| 8 | **Low** | `includes/Render.php` | Inline `<style>` blocks injected into `<body>` — Content Security Policy (CSP) headers may block these. Should use `wp_add_inline_style()`. |
|
||||
| 9 | **Low** | `includes/Token.php` `validate()` | Uses MySQL `NOW()` for expiry comparison while token generation uses PHP timezone. Server timezone mismatch could cause valid tokens to be rejected or expired tokens to pass. |
|
||||
| 10 | **Medium** | `includes/Render.php` (default timezone) | Hardcoded default timezone `'Asia/Jakarta'` in Render.php instead of using `wp_timezone_string()`. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture & Code Quality Issues
|
||||
|
||||
| # | Issue | Detail |
|
||||
|---|-------|--------|
|
||||
| 1 | **No Composer dependency management** | Custom `spl_autoload_register` works but there's no `composer.json`. The `vendor/` folder contains manually copied libraries with no version locking, no autoload optimization, and no security update path. |
|
||||
| 2 | **SingletonTrait incomplete** | `__wakeup()` is `public` (should be `private`). Missing `__sleep()` to prevent serialization-based singleton bypass. |
|
||||
| 3 | **No database migration system** | `dbDelta()` runs on every `init` for all custom tables. No version tracking for schema changes. Adding columns later requires manual SQL or relying on `dbDelta` diff detection. |
|
||||
| 4 | **Inconsistent class instantiation** | Some classes use Singleton trait, others (`Token`) don't. `new \Formipay\Token` in `Init.php` bypasses the singleton pattern entirely. |
|
||||
| 5 | **Global functions vs OOP** | `admin/functions.php` has ~40 global functions (e.g., `formipay_price_format`, `formipay_get_order`, `formipay_currency_array`). Should be in utility/service classes for testability and namespace hygiene. |
|
||||
| 6 | **Insufficient capability checks** | Several admin-ajax handlers verify nonce but don't check `current_user_can('manage_options')`. Any authenticated user (even subscriber) with a valid nonce could potentially access admin functions. |
|
||||
| 7 | **Hardcoded English strings** | Many UI strings in JS-localized data and some PHP output are hardcoded in English without `__()` translation functions. |
|
||||
| 8 | **No proper REST API** | Form submission goes through `admin-ajax.php`. A proper REST API endpoint would be more cache-friendly, better structured, and follow WordPress standards. |
|
||||
| 9 | **Static version constant** | `FORMIPAY_VERSION` is hardcoded to `'1.0.0'` and never updated programmatically. All cache-busting relies on this value. |
|
||||
| 10 | **Backup file in production** | `includes/Integration/Paypal.phpbak` exists — should be removed from production codebase. |
|
||||
| 11 | **No deactivation/uninstall cleanup** | No `uninstall.php` or deactivation hook to clean up custom tables, scheduled events, or options. |
|
||||
| 12 | **No `.distignore` or build process** | No `.distignore` file for WordPress.org packaging. Development files would be shipped to production. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Missing Features & Modules
|
||||
|
||||
| # | Module / Area | Status | Priority |
|
||||
|---|---------------|--------|----------|
|
||||
| 1 | **ExchangeRateAPI** | File exists but is **completely empty** — just an empty class extending Payment | 🔴 High |
|
||||
| 2 | **License API implementation** | All 4 REST endpoints (`verify`, `activate`, `deactivate`, `revoke`) are **stubs returning hardcoded `ok: true`** | 🔴 High |
|
||||
| 3 | **Subscription / Recurring payments** | Listed in `readme.txt` as "Planned" but no code exists | 🟡 Medium |
|
||||
| 4 | **Donation forms** | `formipay_is_donation()` and `donation_config` filter exist, but no donation-specific frontend UI (no "pay what you want" input, no suggested amounts) | 🟡 Medium |
|
||||
| 5 | **Inventory / Stock management** | Product has `stock_config` in settings but no stock decrement on order, no stock validation before submission, no "out of stock" frontend message | 🟡 Medium |
|
||||
| 6 | **Product variations on frontend** | Variation config exists in Product admin, but `Order.php` doesn't process variation selections — no variation dropdown rendered on frontend | 🟡 Medium |
|
||||
| 7 | **Tax system** | No tax calculation anywhere. Products have price, shipping has fees, but there is no tax engine. Essential for physical product sales in most jurisdictions. | 🟡 Medium |
|
||||
| 8 | **Customer portal / dashboard** | Referenced in readme and `single-formipay.php` template exists, but no customer-facing order history page, no login/registration flow, no "My Orders" view | 🟡 Medium |
|
||||
| 9 | **Refund system** | `refunded` status exists in status list, but no refund workflow, no partial refund, no payment reversal integration | 🟡 Medium |
|
||||
| 10 | **Form analytics / reporting** | No form view tracking, no conversion rate calculation, no abandonment tracking, no dashboard charts | 🟢 Low |
|
||||
| 11 | **Export / Import** | No CSV/Excel export for orders, customers, or products. No bulk import for products. | 🟢 Low |
|
||||
| 12 | **Outgoing webhook system** | No way to notify external systems (Zapier, Slack, custom endpoints) on order events | 🟢 Low |
|
||||
| 13 | **Form template library** | No pre-built form templates for common use cases (simple product, donation, registration) | 🟢 Low |
|
||||
| 14 | **Multi-step form navigation** | Page break config exists in admin, step indicators render, but no frontend JS to actually navigate between steps | 🟡 Medium |
|
||||
| 15 | **Email log admin page** | `formipay_notification_log` table exists but there's no admin page to view email history, resend failed emails, or debug delivery | 🟡 Medium |
|
||||
|
||||
---
|
||||
|
||||
## 5. Performance Issues
|
||||
|
||||
| # | Location | Issue |
|
||||
|---|----------|-------|
|
||||
| 1 | `includes/Thankyou.php` `set_endpoint()` | `flush_rewrite_rules()` on **every page load** — one of the most expensive WordPress operations. Should only run on plugin activation and settings save. |
|
||||
| 2 | `includes/Payment/Payment.php` `set_endpoint()` | Same `flush_rewrite_rules()` issue. |
|
||||
| 3 | `includes/Order.php` `formipay_tabledata_orders()` | Runs **two separate full-table queries** — one fetching ALL orders just to count statuses (`O(n)` in memory), plus the paginated query. Should use `COUNT(*) ... GROUP BY status` for the report. |
|
||||
| 4 | `includes/Customer.php` `formipay_tabledata_customers()` | **No pagination** — loads ALL customers into memory and returns them. Will crash or timeout with large datasets. |
|
||||
| 5 | `admin/functions.php` `formipay_currency_array()` | `file_get_contents()` of `currencies.json` on **every call** with no caching. Should use a static variable or WordPress transient. |
|
||||
| 6 | `admin/functions.php` `formipay_country_array()` | Same issue — reads JSON file from disk on every function call. |
|
||||
| 7 | `includes/Init.php` `default_config()` | The entire ~200-line default config array is loaded on every `plugin_loaded` action. Should be lazy-loaded or only when saving defaults. |
|
||||
| 8 | `includes/Render.php` | All form CSS is output as inline `<style>` in the HTML `<body>` — not cacheable by browsers, duplicated on every page with the form shortcode. |
|
||||
| 9 | `includes/Token.php` `create_db()` | Runs `dbDelta()` on every `init` — redundant after first install. |
|
||||
| 10 | `includes/Customer.php` `create_db()` | Same `dbDelta()` on every `init`. |
|
||||
| 11 | `includes/Order.php` `create_db()` | Same issue. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Missing Admin Pages / Settings
|
||||
|
||||
| # | Missing Page | Description |
|
||||
|---|-------------|-------------|
|
||||
| 1 | **Notification Log** | Table `formipay_notification_log` exists but there's no admin page to view email history, resend failed emails, or debug delivery issues. |
|
||||
| 2 | **License detail/edit** | License list page exists but there's no detail or edit view (can't manually activate, revoke, or extend a license). |
|
||||
| 3 | **Dashboard / Analytics** | No overview dashboard with form views, submissions, revenue charts, or conversion rates. |
|
||||
| 4 | **System Status / Tools** | No page to check PayPal connectivity, email delivery test, database health, or scheduled events. |
|
||||
| 5 | **Customer order history** | Customer detail page exists but doesn't show linked order history or purchase timeline. |
|
||||
| 6 | **Import/Export tools** | No admin UI to bulk import products or export order/customer data. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Code Cleanup Needed
|
||||
|
||||
| # | Item | Detail |
|
||||
|---|------|--------|
|
||||
| 1 | **Commented-out code** | `Init.php` has commented-out PayPal init, taxonomy, and defer-attribute filter. Should be removed or tracked as TODOs. |
|
||||
| 2 | **`Paypal.phpbak`** | Backup file in production directory `includes/Integration/Paypal.phpbak` — must be deleted. |
|
||||
| 3 | **Dead code in `Notification.php`** | `$last_notification_field = count($notification_fields) - 1; $notification_fields[$last_notification_field]['group'] = 'ended'` — uses numeric array index instead of the key name, which breaks if filter order changes. |
|
||||
| 4 | **Inconsistent nonce naming** | Different pages use different nonce names: `formipay-order-details`, `formipay-admin-coupon-page`, `formipay-admin-licenses`, `formipay-admin-product-page`, `formipay-admin-access-nonce`, `formipay-form-editor`, `formipay-thankyou-nonce`. Should follow a single convention. |
|
||||
| 5 | **Deprecated `FILTER_SANITIZE_STRING`** | Used in `Customer.php` `customers_page()` — deprecated since PHP 8.1. Should use `FILTER_SANITIZE_SPECIAL_CHARS` or `sanitize_text_field()`. |
|
||||
| 6 | **Mixed indentation** | Some files use tabs, some spaces, some mix both within the same file. |
|
||||
| 7 | **Missing PHPDoc blocks** | Most methods have no docblocks. Return types are rarely declared. Makes IDE support and static analysis poor. |
|
||||
| 8 | **No `.editorconfig`** | No project-wide formatting standard file. |
|
||||
|
||||
---
|
||||
|
||||
## 8. Opportunities & Nice-to-Haves
|
||||
|
||||
| # | Opportunity | Business Impact |
|
||||
|---|------------|-----------------|
|
||||
| 1 | **Composer package management** | Replace manual vendor copies with proper Composer dependencies for auto-updates, security patches, and smaller distribution |
|
||||
| 2 | **React-based form builder** | Replace the partial Vue editor canvas with a full React drag-and-drop form builder for better UX and extensibility |
|
||||
| 3 | **Webhook/API integrations** | Zapier, Make (Integromat), Slack notifications — extend the notification system for 3rd-party automation |
|
||||
| 4 | **Stripe payment gateway** | Most requested gateway after PayPal. The abstract `Payment` class makes it architecturally straightforward to add |
|
||||
| 5 | **PDF invoice generation** | Auto-generate PDF invoices/receipts for orders — high-value feature for business users |
|
||||
| 6 | **Google Analytics / Facebook Pixel** | E-commerce event tracking (`purchase`, `add_to_cart`) for conversion optimization |
|
||||
| 7 | **Multi-vendor / marketplace** | Architecture already supports per-form products — extend to multi-vendor marketplace |
|
||||
| 8 | **Form A/B testing** | Duplicate form feature already exists — add conversion tracking and statistical comparison |
|
||||
| 9 | **Cart / checkout recovery** | Store partial submissions in `localStorage` + send recovery emails for abandoned forms |
|
||||
| 10 | **Full localization** | Generate `.pot` file, translate to top 10 languages. Currently only partially translatable |
|
||||
| 11 | **WP-CLI commands** | `wp formipay order list`, `wp formipay product create`, `wp formipay license verify`, etc. |
|
||||
| 12 | **Headless / REST API** | Full REST API for headless WordPress + Next.js / Nuxt frontends |
|
||||
| 13 | **Gutenberg block** | Register a `formipay/form` block for native Gutenberg integration instead of shortcode-only |
|
||||
| 14 | **Unit & integration tests** | Zero test coverage currently. Critical for a payment plugin handling money |
|
||||
| 15 | **Rate limiting on public endpoints** | `retrieve_form_data` and `check_coupon_code` have no rate limiting. Vulnerable to brute-force and spam |
|
||||
|
||||
---
|
||||
|
||||
## 9. Summary Priority Matrix
|
||||
|
||||
| Priority | Count | Key Items |
|
||||
|----------|-------|-----------|
|
||||
| 🔴 **Critical (fix immediately)** | 6 | Customer update fatal error, Order delete undefined variable, Bulk delete wrong variable, Email send wrong class, flush_rewrite_rules performance, License API stubs |
|
||||
| 🟡 **High (next sprint)** | 12 | Unique code race condition, PayPal webhook verification, ExchangeRateAPI empty class, Stock management, Donation UI, Notification log page, Customer portal, Tax system |
|
||||
| 🟢 **Medium (backlog)** | 15 | Refund workflow, Analytics dashboard, Export/Import, Form templates, Gutenber block, Localization, Rate limiting |
|
||||
| ⚪ **Nice-to-have (roadmap)** | 12 | WP-CLI, A/B testing, Headless API, PDF invoices, Multi-vendor, Cart recovery, CI/CD pipeline |
|
||||
|
||||
---
|
||||
|
||||
## 10. Architectural Recommendation
|
||||
|
||||
> See `RECOMMENDATION.md` for the detailed technical recommendation.
|
||||
|
||||
---
|
||||
|
||||
*End of audit report.*
|
||||
469
RECOMMENDATION.md
Normal file
469
RECOMMENDATION.md
Normal file
@@ -0,0 +1,469 @@
|
||||
# 🏗️ Formipay — Architectural Recommendation
|
||||
|
||||
**Date:** April 17, 2026
|
||||
**Context:** Based on `FINDINGS.md` audit
|
||||
|
||||
---
|
||||
|
||||
## The Question
|
||||
|
||||
> **Rebuild the plugin with React (admin + frontend), or keep the current SSR shortcode + Vue admin approach and fix all findings?**
|
||||
|
||||
---
|
||||
|
||||
## TL;DR — The Recommendation
|
||||
|
||||
### **Option B: Incremental Modernization (Keep SSR + Vue, Fix & Upgrade)**
|
||||
|
||||
**Do NOT do a full rewrite.** Instead, adopt a **phased modernization strategy** that:
|
||||
|
||||
1. **Fixes all critical bugs immediately** (Phase 1 — weeks 1-2)
|
||||
2. **Introduces React only where it adds clear value** (Phase 2 — weeks 3-6)
|
||||
3. **Gradually migrates Vue admin → React** as features are touched (Phase 3 — ongoing)
|
||||
|
||||
This is the industry-standard approach for WordPress plugins at this stage. Here's why and how.
|
||||
|
||||
---
|
||||
|
||||
## Why NOT a Full React Rebuild
|
||||
|
||||
| Factor | Full React Rebuild | Incremental Modernization |
|
||||
|--------|--------------------|---------------------------|
|
||||
| **Time** | 3-4 months minimum | 2-4 weeks for critical fixes |
|
||||
| **Risk** | Complete rewrite = complete regression risk | Fixes are targeted and testable |
|
||||
| **Revenue** | No updates for 3-4 months | Continuous delivery |
|
||||
| **WordPress ecosystem** | Fighting against WP conventions | Working with WP conventions |
|
||||
| **SEO / Accessibility** | Client-side rendering = SEO problems for public forms | SSR = perfect SEO & accessibility |
|
||||
| **Complexity** | Need build pipeline, state management, API layer | Build on what works |
|
||||
| **Team onboarding** | Entire codebase unfamiliar | Familiar patterns + gradual React intro |
|
||||
|
||||
### Industry Precedent
|
||||
|
||||
- **WooCommerce** — Still PHP/SSR for storefront, React only for admin (block editor, settings). Did NOT rewrite frontend in React.
|
||||
- **Easy Digital Downloads** — Same approach. PHP templates + React for admin features.
|
||||
- **GiveWP** — PHP/SSR frontend, React for admin dashboard and form builder.
|
||||
- **Gravity Forms** — PHP/SSR rendering, React for the form builder only.
|
||||
|
||||
**The pattern is clear:** WordPress payment/e-commerce plugins keep **SSR for public-facing forms** (SEO, accessibility, speed, no-JS fallback) and use **React for admin UX** (form builder, dashboards, settings).
|
||||
|
||||
---
|
||||
|
||||
## Recommended Architecture
|
||||
|
||||
### What to KEEP (SSR / PHP)
|
||||
|
||||
```
|
||||
✅ Public form rendering (shortcode → PHP template)
|
||||
✅ Order processing & payment flow (PHP backend)
|
||||
✅ Email notifications (PHP wp_mail)
|
||||
✅ Database layer (custom tables via dbDelta)
|
||||
✅ Webhook handlers (REST routes in PHP)
|
||||
✅ Thank-you page (PHP template)
|
||||
```
|
||||
|
||||
**Why:** Server-side rendered forms are the **correct choice** for WordPress checkout forms. They provide:
|
||||
- Zero JavaScript dependency (forms work without JS)
|
||||
- Perfect SEO (crawlers see full HTML)
|
||||
- Fast initial paint (no JS bundle download needed)
|
||||
- Native WordPress shortcode/block integration
|
||||
- Accessibility out of the box (screen readers work)
|
||||
|
||||
### What to MIGRATE to React
|
||||
|
||||
```
|
||||
🔄 Admin form builder (currently partial Vue → full React)
|
||||
🔄 Admin dashboard / analytics (new)
|
||||
🔄 Admin order details view (currently Handlebars → React)
|
||||
🔄 Admin settings pages (currently WPCFTO → custom React)
|
||||
🔄 Gutenberg block (new — React is required)
|
||||
```
|
||||
|
||||
### What to ADD as React
|
||||
|
||||
```
|
||||
🆕 React-powered shortcode replacement (as <script> islands)
|
||||
🆕 React-based order tracking widget for customer portal
|
||||
🆕 React-based product catalog (optional)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Critical Fixes & Stabilization (Weeks 1-2)
|
||||
|
||||
**Goal:** Make the existing plugin production-ready.
|
||||
|
||||
```
|
||||
Week 1 — Critical Bug Fixes:
|
||||
├── Fix Customer::update() undefined variables
|
||||
├── Fix Order::delete() $id → $order_id
|
||||
├── Fix Order::bulk_delete() loop variable
|
||||
├── Fix Email::send_email() class reference
|
||||
├── Fix Paypal::auto_cancel_order_on_timeout() import
|
||||
├── Fix BankTransfer unique_code triple-call
|
||||
├── Fix color field label ('Number' → 'Color')
|
||||
└── Add missing nonce checks (Customer::tabledata)
|
||||
|
||||
Week 2 — Performance & Security:
|
||||
├── Remove flush_rewrite_rules() from init → activation hook only
|
||||
├── Replace maybe_serialize() in cookies with json_encode()
|
||||
├── Add PayPal webhook signature verification
|
||||
├── Add rate limiting on public AJAX endpoints
|
||||
├── Cache currency/country JSON reads in static vars
|
||||
├── Add pagination to Customer::tabledata
|
||||
├── Optimize Order::tabledata queries (COUNT + GROUP BY)
|
||||
└── Add uninstall.php for cleanup
|
||||
```
|
||||
|
||||
### Phase 2: React Admin Foundation (Weeks 3-6)
|
||||
|
||||
**Goal:** Set up React build pipeline and migrate the most impactful admin pages.
|
||||
|
||||
```
|
||||
Week 3 — Build Pipeline:
|
||||
├── Set up @wordpress/scripts (wp-scripts) build system
|
||||
├── Configure webpack with React Fast Refresh
|
||||
├── Create React component library structure
|
||||
├── Set up API layer (fetch wrapper with nonce handling)
|
||||
└── Create admin page shell component (sidebar + routing)
|
||||
|
||||
Week 4 — Form Builder (highest admin ROI):
|
||||
├── Build drag-and-drop field palette (React)
|
||||
├── Build field settings panel (React)
|
||||
├── Build live preview canvas (React)
|
||||
├── Connect to existing PHP save endpoints
|
||||
└── Replace current Vue/Classic Editor metabox
|
||||
|
||||
Week 5 — Order Management & Dashboard:
|
||||
├── Build order list page with filters (React + TanStack Table)
|
||||
├── Build order detail view (replace Handlebars templates)
|
||||
├── Build status change workflow with timeline
|
||||
├── Build simple analytics dashboard (orders, revenue, charts)
|
||||
└── Build notification log viewer
|
||||
|
||||
Week 6 — Settings & Product Editor:
|
||||
├── Build global settings page (replace WPCFTO dependency)
|
||||
├── Build product editor page (replace classic editor metaboxes)
|
||||
├── Build coupon editor page
|
||||
├── Build access items manager
|
||||
└── Build license management page
|
||||
```
|
||||
|
||||
### Phase 3: Frontend Enhancements (Weeks 7-10)
|
||||
|
||||
**Goal:** Add React-powered frontend features while keeping SSR as the default.
|
||||
|
||||
```
|
||||
Week 7-8 — React Island Architecture:
|
||||
├── Create a render_php() method (existing SSR — default)
|
||||
├── Create a render_react() method (new — optional)
|
||||
├── Build React form renderer component
|
||||
├── Implement "island hydration" — React attaches to SSR HTML
|
||||
├── Add setting: "Render Mode: Classic (SSR) | Modern (React)"
|
||||
└── Add multi-step form navigation (missing in current SSR)
|
||||
|
||||
Week 9 — Gutenberg Block:
|
||||
├── Register formipay/form block with block.json
|
||||
├── Block renders shortcode server-side in edit.js preview
|
||||
├── Full React experience in editor
|
||||
├── Settings panel in InspectorControls
|
||||
└── Replace shortcode-only workflow
|
||||
|
||||
Week 10 — Customer Portal:
|
||||
├── Build customer order history page (React)
|
||||
├── Build order detail / download access page (React)
|
||||
├── Build access link request form (React)
|
||||
├── Integrate with WordPress user accounts
|
||||
└── Shortcode [formipay_my_orders] for portal
|
||||
```
|
||||
|
||||
### Phase 4: Complete Missing Features (Weeks 11-16)
|
||||
|
||||
```
|
||||
Week 11-12 — Payment & Commerce:
|
||||
├── Implement ExchangeRateAPI (currently empty)
|
||||
├── Implement License API endpoints (currently stubs)
|
||||
├── Add Stripe payment gateway
|
||||
├── Add tax calculation engine
|
||||
└── Add product variations on frontend
|
||||
|
||||
Week 13-14 — Stock & Shipping:
|
||||
├── Implement stock management (decrement, validation, messages)
|
||||
├── Add weight-based shipping calculation
|
||||
├── Add shipping zone support
|
||||
└── Add order fulfillment workflow
|
||||
|
||||
Week 15-16 — Advanced Features:
|
||||
├── Build donation form mode (pay-what-you-want, suggested amounts)
|
||||
├── Add PDF invoice generation
|
||||
├── Add CSV export for orders/customers
|
||||
├── Add webhook notification system
|
||||
└── Add analytics & reporting dashboard
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technical Architecture Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ WordPress Plugin │
|
||||
│ │
|
||||
│ ┌──────────────────────┐ ┌──────────────────────────┐ │
|
||||
│ │ PHP Backend Core │ │ React Admin (wp-scripts)│ │
|
||||
│ │ │ │ │ │
|
||||
│ │ • Custom Post Types │◄──►│ • Form Builder │ │
|
||||
│ │ • Custom DB Tables │ │ • Order Management │ │
|
||||
│ │ • Payment Gateways │ │ • Product Editor │ │
|
||||
│ │ • Email System │ │ • Settings Pages │ │
|
||||
│ │ • REST API Endpoints│ │ • Dashboard/Analytics │ │
|
||||
│ │ • Webhook Handlers │ │ • License Manager │ │
|
||||
│ │ • Cron Jobs │ │ • Notification Log │ │
|
||||
│ └──────────┬───────────┘ └──────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────▼───────────┐ ┌──────────────────────────┐ │
|
||||
│ │ SSR Form Renderer │ │ React Frontend Islands │ │
|
||||
│ │ (PHP + Shortcode) │ │ (Optional Enhancement) │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ • [formipay] render │ │ • Multi-step navigation │ │
|
||||
│ │ • Thank-you page │ │ • Real-time validation │ │
|
||||
│ │ • Payment confirm │ │ • Customer portal │ │
|
||||
│ │ • No-JS fallback ✅ │ │ • Gutenberg block │ │
|
||||
│ └──────────────────────┘ └──────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ Shared API Layer (WordPress REST API + admin-ajax) │ │
|
||||
│ │ • /wp-json/formipay/v1/* │ │
|
||||
│ │ • admin-ajax.php actions (backward compat) │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build Toolchain
|
||||
|
||||
```
|
||||
formipay/
|
||||
├── package.json # @wordpress/scripts + deps
|
||||
├── webpack.config.js # Extend wp-scripts config
|
||||
├── .eslintrc.js # ESLint with WP rules
|
||||
├── .prettierrc # Prettier config
|
||||
├── tsconfig.json # TypeScript (recommended)
|
||||
│
|
||||
├── src/ # React source code
|
||||
│ ├── admin/ # Admin React app
|
||||
│ │ ├── index.js # Entry point
|
||||
│ │ ├── components/ # Shared components
|
||||
│ │ │ ├── DataTable/ # Reusable table component
|
||||
│ │ │ ├── SettingsPanel/ # Settings form builder
|
||||
│ │ │ ├── Modal/ # Confirmation modals
|
||||
│ │ │ └── StatusBadge/ # Order status badges
|
||||
│ │ ├── pages/ # Page components
|
||||
│ │ │ ├── Dashboard/
|
||||
│ │ │ ├── FormBuilder/
|
||||
│ │ │ ├── Orders/
|
||||
│ │ │ ├── Products/
|
||||
│ │ │ ├── Customers/
|
||||
│ │ │ ├── Coupons/
|
||||
│ │ │ ├── Licenses/
|
||||
│ │ │ ├── AccessItems/
|
||||
│ │ │ └── Settings/
|
||||
│ │ ├── hooks/ # Custom React hooks
|
||||
│ │ │ ├── useOrders.js
|
||||
│ │ │ ├── useProducts.js
|
||||
│ │ │ └── useSettings.js
|
||||
│ │ └── api/ # API client
|
||||
│ │ ├── client.js # Fetch wrapper with nonce
|
||||
│ │ ├── orders.js
|
||||
│ │ ├── products.js
|
||||
│ │ └── settings.js
|
||||
│ │
|
||||
│ ├── frontend/ # Frontend React islands
|
||||
│ │ ├── blocks/ # Gutenberg blocks
|
||||
│ │ │ └── formipay-form/
|
||||
│ │ │ ├── block.json
|
||||
│ │ │ ├── edit.jsx
|
||||
│ │ │ └── view.js
|
||||
│ │ └── widgets/ # Embeddable widgets
|
||||
│ │ ├── CustomerPortal/
|
||||
│ │ └── OrderTracker/
|
||||
│ │
|
||||
│ └── shared/ # Shared between admin/frontend
|
||||
│ ├── utils/
|
||||
│ └── constants/
|
||||
│
|
||||
├── assets/build/ # Compiled output (gitignored)
|
||||
│ ├── admin.bundle.js
|
||||
│ ├── admin.bundle.css
|
||||
│ ├── frontend.bundle.js
|
||||
│ └── blocks/
|
||||
│
|
||||
├── includes/ # PHP backend (existing, fixed)
|
||||
├── admin/ # PHP admin pages (kept for PHP render)
|
||||
├── public/ # Public templates (SSR)
|
||||
├── vendor/ # Composer dependencies (new)
|
||||
└── templates/ # WordPress templates
|
||||
```
|
||||
|
||||
### `package.json` (minimal)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "formipay",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "wp-scripts build",
|
||||
"start": "wp-scripts start",
|
||||
"lint:js": "wp-scripts lint-js src/",
|
||||
"lint:css": "wp-scripts lint-style",
|
||||
"format": "wp-scripts format src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"@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",
|
||||
"@tanstack/react-table": "^8.0.0",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"recharts": "^2.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@wordpress/scripts": "^27.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## React Frontend Integration Strategy
|
||||
|
||||
### Pattern: React Islands (Hybrid SSR + React)
|
||||
|
||||
The key pattern is **"islands of interactivity"** — the PHP renders the initial HTML (SSR), and React optionally hydrates specific sections:
|
||||
|
||||
```php
|
||||
// Render.php — The shortcode handler
|
||||
public function shortcode($atts) {
|
||||
// ... existing SSR rendering (default) ...
|
||||
|
||||
// Option: If React mode is enabled, render a mount point instead
|
||||
if ($render_mode === 'react') {
|
||||
return sprintf(
|
||||
'<div id="formipay-react-form-%d" data-form-id="%d" data-nonce="%s"></div>',
|
||||
$post_id,
|
||||
$post_id,
|
||||
wp_create_nonce('formipay_order_submit')
|
||||
);
|
||||
}
|
||||
|
||||
// Default: existing PHP-rendered form (SSR)
|
||||
return $this->render_php_form($post_id);
|
||||
}
|
||||
```
|
||||
|
||||
```jsx
|
||||
// src/frontend/widgets/FormRenderer.jsx
|
||||
import { createRoot } from '@wordpress/element';
|
||||
import Form from './components/Form';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('[id^="formipay-react-form-"]').forEach(el => {
|
||||
const formId = parseInt(el.dataset.formId);
|
||||
const nonce = el.dataset.nonce;
|
||||
createRoot(el).render(<Form formId={formId} nonce={nonce} />);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
This gives you:
|
||||
- **Backward compatibility** — existing `[formipay]` shortcodes keep working with SSR
|
||||
- **Opt-in React** — new features use React when enabled
|
||||
- **Gutenberg block** — renders React in editor, SSR or React on frontend
|
||||
- **No lock-in** — each form can use either mode independently
|
||||
|
||||
---
|
||||
|
||||
## What to Do With the Vue Code
|
||||
|
||||
The existing Vue admin code (`admin/assets/vue/`) should be **deprecated, not deleted immediately**:
|
||||
|
||||
1. **Keep** Vue code working during Phase 1 (critical fixes)
|
||||
2. **Build** React equivalents in Phase 2 alongside Vue
|
||||
3. **Switch** admin pages to React one at a time (form builder first)
|
||||
4. **Remove** Vue code once all pages are migrated in Phase 3
|
||||
|
||||
Do NOT try to mix Vue and React on the same page — they'll conflict. Migrate page-by-page.
|
||||
|
||||
---
|
||||
|
||||
## Gutenberg Block Strategy
|
||||
|
||||
```jsx
|
||||
// src/frontend/blocks/formipay-form/block.json
|
||||
{
|
||||
"apiVersion": 3,
|
||||
"name": "formipay/form",
|
||||
"title": "Formipay Form",
|
||||
"category": "widgets",
|
||||
"attributes": {
|
||||
"formId": { "type": "number", "default": 0 },
|
||||
"renderMode": { "type": "string", "default": "ssr" }
|
||||
},
|
||||
"supports": {
|
||||
"html": false,
|
||||
"align": true
|
||||
},
|
||||
"editorScript": "file:./index.js",
|
||||
"editorStyle": "file:./index.css",
|
||||
"style": "file:./style-index.css",
|
||||
"render": "file:./render.php"
|
||||
}
|
||||
```
|
||||
|
||||
The `render.php` file uses Server-Side Rendering (required by WordPress for blocks), which calls the existing PHP form renderer. The editor experience (`edit.jsx`) is fully React.
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
```
|
||||
Phase 1: Manual testing of all bug fixes
|
||||
Phase 2: PHPUnit for backend + Jest for React components
|
||||
Phase 3: E2E tests with Playwright for critical flows
|
||||
Phase 4: CI/CD with GitHub Actions
|
||||
|
||||
Coverage targets:
|
||||
├── Backend (PHP): 80% on payment + order paths
|
||||
├── Admin (React): 70% on components
|
||||
├── Frontend: E2E for form submission, payment, thank-you
|
||||
└── API: Integration tests for all REST endpoints
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cost-Benefit Summary
|
||||
|
||||
| Approach | Time | Cost | Risk | Deliverable |
|
||||
|----------|------|------|------|-------------|
|
||||
| **Full React rebuild** | 3-4 months | Very high | Very high | Complete rewrite, likely with new bugs |
|
||||
| **Keep PHP + fix only** | 2-3 weeks | Low | Low | Fixes bugs, no UX improvement |
|
||||
| **Incremental modernization** ✅ | 4-6 weeks for Phases 1-2 | Medium | Low-Medium | Bugs fixed + modern admin UX |
|
||||
|
||||
### Recommended: **Incremental Modernization**
|
||||
|
||||
This approach:
|
||||
1. **Delivers value immediately** — critical bugs fixed in Week 1
|
||||
2. **Reduces risk** — changes are targeted and testable
|
||||
3. **Follows WordPress conventions** — SSR forms, React admin
|
||||
4. **Enables future growth** — React foundation for new features
|
||||
5. **Matches industry patterns** — same approach as WooCommerce, GiveWP, Gravity Forms
|
||||
|
||||
---
|
||||
|
||||
*End of recommendation.*
|
||||
@@ -851,11 +851,46 @@ class Form {
|
||||
'group' => 'started',
|
||||
'description' => __( 'Add static product or custom item to form as default and non-editable item in order items.', 'formipay' )
|
||||
],
|
||||
'static_products' => [
|
||||
'type' => 'autocomplete',
|
||||
'post_type' => ['formipay-product'],
|
||||
'label' => __( 'Assign Product', 'formipay' ),
|
||||
'description' => __( 'Selected products will be added to the order items automatically, even if it is not added to cart.', 'formipay' )
|
||||
// 'static_products' => [
|
||||
// 'type' => 'autocomplete',
|
||||
// 'post_type' => ['formipay-product'],
|
||||
// 'label' => __( 'Assign Product', 'formipay' ),
|
||||
// 'description' => __( 'Selected products will be added to the order items automatically, even if it is not added to cart.', 'formipay' )
|
||||
// ],
|
||||
'static_product' => [
|
||||
'type' => 'repeater',
|
||||
'label' => __( 'Static Product', 'formipay' ),
|
||||
'description' => __( 'Selected products will be added to the order items automatically, even if it is not added to cart.', 'formipay' ),
|
||||
'fields' => [
|
||||
'default_qty' => [
|
||||
'type' => 'number',
|
||||
'label' => __( 'Default Qty', 'formipay' ),
|
||||
'description' => __( 'Set default quantity', 'formipay' )
|
||||
],
|
||||
'editable_qty' => [
|
||||
'type' => 'checkbox',
|
||||
'label' => __( 'Editable Qty', 'formipay' ),
|
||||
'description' => __( 'User can set quantity as they want', 'formipay' )
|
||||
],
|
||||
'minimum_qty' => [
|
||||
'type' => 'number',
|
||||
'label' => __( 'Minimum Qty', 'formipay' ),
|
||||
'description' => __( 'Restrict buyer to set below this number', 'formipay' ),
|
||||
'dependency' => [
|
||||
'key' => 'editable_qty',
|
||||
'value' => 'not_empty'
|
||||
]
|
||||
],
|
||||
'maximum_qty' => [
|
||||
'type' => 'number',
|
||||
'label' => __( 'Minimum Qty', 'formipay' ),
|
||||
'description' => __( 'Restrict buyer to set below this number', 'formipay' ),
|
||||
'dependency' => [
|
||||
'key' => 'editable_qty',
|
||||
'value' => 'not_empty'
|
||||
]
|
||||
],
|
||||
]
|
||||
],
|
||||
'static_items' => [
|
||||
'type' => 'repeater',
|
||||
|
||||
@@ -15,7 +15,9 @@ class Order {
|
||||
|
||||
private $order_details;
|
||||
|
||||
private $chosen_currency;
|
||||
private $chosen_currency; // reserved (not used yet)
|
||||
|
||||
private $currency; // 3-letter currency code from request (e.g., IDR, USD)
|
||||
|
||||
/**
|
||||
* Initializes the plugin by setting filters and administration functions.
|
||||
@@ -90,7 +92,7 @@ class Order {
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
|
||||
$order_meta_data = isset($_REQUEST['meta_data']) ? wp_unslash($_REQUEST['meta_data']) : [];
|
||||
$purpose = isset($_REQUEST['purpose']) ? sanitize_text_field(wp_unslash($_REQUEST['purpose'])) : '';
|
||||
$this->currency = isset($_REQUEST['currency']) ? wp_unslash($_REQUEST['currency']) : formipay_default_currency('symbol');
|
||||
$this->currency = isset($_REQUEST['currency']) ? sanitize_text_field( wp_unslash($_REQUEST['currency']) ) : (string) formipay_default_currency('code');
|
||||
|
||||
$this->form_id = $form_id;
|
||||
|
||||
@@ -221,85 +223,49 @@ class Order {
|
||||
|
||||
$details = [];
|
||||
|
||||
// $product_price = floatval(formipay_get_post_meta($this->form_id, 'product_price'));
|
||||
// $details[] = [
|
||||
// 'item' => html_entity_decode(get_the_title($this->form_id)),
|
||||
// 'amount' => $product_price,
|
||||
// 'qty' => (int) $this->order_data['qty'],
|
||||
// 'subtotal' => floatval($product_price) * intval($this->order_data['qty']),
|
||||
// 'context' => 'main'
|
||||
// ];
|
||||
|
||||
// $check_fields = formipay_get_post_meta($this->form_id, 'formipay_settings');
|
||||
|
||||
// if(!empty($check_fields['fields'])){
|
||||
// foreach($check_fields['fields'] as $field){
|
||||
// // if($field['field_type'] == 'select'){
|
||||
// if(in_array($field['field_type'], ['select','checkbox', 'radio'])) {
|
||||
// $options = $field['field_options'];
|
||||
// if(!empty($options)){
|
||||
// foreach($options as $option){
|
||||
|
||||
// $option_value = ($field['show_toggle']['value'] && '' !== $option['value']) ? $option['value'] : $option['label'];
|
||||
|
||||
// if(!empty($this->order_data[$field['field_id']])) {
|
||||
// $field_value = $this->order_data[$field['field_id']];
|
||||
// if($field['field_type'] == 'select'){
|
||||
// $field_value = ($field['show_toggle']['value']) ?
|
||||
// $this->order_data[$field['field_id']]['value'] :
|
||||
// $this->order_data[$field['field_id']]['label'];
|
||||
// }
|
||||
// $field_value = explode(',', $field_value);
|
||||
|
||||
// $context = 'no-context';
|
||||
// if(floatval($option['amount']) < 0){
|
||||
// $context = 'sub';
|
||||
// }elseif(floatval($option['amount']) > 0){
|
||||
// $context = 'add';
|
||||
// }
|
||||
|
||||
// if(!empty($field_value) && $field['show_toggle']['amount'] == 'yes'){
|
||||
// foreach($field_value as $f_value){
|
||||
// if($option_value == $f_value){
|
||||
// $qty = ($option['qty'] == 'yes') ? $this->order_data['qty'] : 1;
|
||||
// $details[] = [
|
||||
// 'item' => $field['label'] .' - '. $option['label'],
|
||||
// 'amount' => floatval($option['amount']),
|
||||
// 'qty' => (int) $qty,
|
||||
// 'subtotal' => floatval($option['amount']) * intval($qty),
|
||||
// 'context' => $context
|
||||
// ];
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
/**
|
||||
* Cart items (not implemented yet)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Attached Product
|
||||
*/
|
||||
// Ensure currency code is present; fallback to form default currency code
|
||||
if (empty($this->currency)) {
|
||||
$default_currency_full = formipay_get_post_meta($this->form_id, 'default_currencies'); // e.g., "IDR:::Indonesian rupiah:::Rp"
|
||||
$parts = explode(':::', (string) $default_currency_full);
|
||||
$this->currency = $parts[0] ?? 'IDR';
|
||||
}
|
||||
|
||||
// Attached static products (qty = 1 each in this case)
|
||||
$products = formipay_get_post_meta($this->form_id, 'static_products');
|
||||
if(!empty($products)){
|
||||
$products = explode(',', $products);
|
||||
foreach($products as $product_id){
|
||||
$product_data = formipay_get_post_meta($product_id);
|
||||
$regular_price = formipay_get_post_meta($product_id, 'setting_product_price_regular_'.$this->currency);
|
||||
$sale_price = formipay_get_post_meta($product_id, 'setting_product_price_sale_'.$this->currency);
|
||||
$this_item = [
|
||||
'item' => html_entity_decode(get_the_title($product_id)),
|
||||
'amount' => (float) $sale_price ?: $regular_price,
|
||||
'qty' => 1,
|
||||
'subtotal' => (float) $sale_price ?: $regular_price,
|
||||
if (!empty($products)) {
|
||||
$products = array_filter(array_map('absint', explode(',', (string) $products)));
|
||||
foreach ($products as $product_id) {
|
||||
$regular_key = 'setting_product_price_regular_' . $this->currency;
|
||||
$sale_key = 'setting_product_price_sale_' . $this->currency;
|
||||
$regular_price = formipay_get_post_meta($product_id, $regular_key);
|
||||
$sale_price = formipay_get_post_meta($product_id, $sale_key);
|
||||
$price = ($sale_price !== '' && $sale_price !== null) ? (float) $sale_price : (float) $regular_price;
|
||||
|
||||
$details[] = [
|
||||
'item' => html_entity_decode(get_the_title($product_id)),
|
||||
'amount' => $price,
|
||||
'qty' => 1,
|
||||
'subtotal' => $price,
|
||||
'context' => 'product',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Static items (fees/bonuses), currency-aware amounts
|
||||
$raw_items = formipay_get_post_meta($this->form_id, 'static_items');
|
||||
if (!empty($raw_items)) {
|
||||
$items = json_decode((string) $raw_items, true) ?: [];
|
||||
foreach ($items as $it) {
|
||||
$label = $it['label'] ?? 'Item';
|
||||
$qty = (int) ($it['quantity'] ?? 1);
|
||||
$key = 'amount_' . $this->currency;
|
||||
$amt = (float) ($it[$key] ?? 0);
|
||||
$details[] = [
|
||||
'item' => $label,
|
||||
'amount' => $amt,
|
||||
'qty' => $qty,
|
||||
'subtotal' => $amt * $qty,
|
||||
'context' => 'item',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,55 +313,32 @@ class Render {
|
||||
<?php
|
||||
break;
|
||||
case 'order_review':
|
||||
$cart = $this->build_cart_from_meta($post_id);
|
||||
?>
|
||||
<div class="form-calculation form-calculate-<?php echo esc_attr($post_id); ?>">
|
||||
<h4><?php echo esc_html(formipay_get_post_meta($post_id, 'order_review_title')); ?></h4>
|
||||
<table id="formipay-review-order">
|
||||
<tbody>
|
||||
<tr class="formipay-product-row formipay-item-row main">
|
||||
<?php
|
||||
$price = formipay_get_post_meta($post_id, 'product_price');
|
||||
if(formipay_get_post_meta($post_id, 'product_quantity_toggle') == 'on') {
|
||||
$stock = formipay_get_post_meta($post_id, 'product_stock');
|
||||
$stock_html = '';
|
||||
if($stock > -1){
|
||||
$stock = ' max="'.$stock.'"';
|
||||
}
|
||||
?>
|
||||
<th>
|
||||
<?php echo esc_html(get_the_title($post_id)); ?> <br>
|
||||
<span class="product-qty-wrapper">
|
||||
<button type="button" class="product-qty qty-min">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
<input type="number" class="product-qty formipay-qty-input" value="<?php echo intval(formipay_get_post_meta($post_id, 'product_quantity_range')); ?>" step="<?php echo intval(formipay_get_post_meta($post_id, 'product_quantity_range')); ?>" min="<?php echo intval(formipay_get_post_meta($post_id, 'product_minimum_purchase')); ?>"<?php echo esc_html($stock) ?>>
|
||||
<button type="button" class="product-qty qty-plus">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v14m-7-7h14" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
</th>
|
||||
<td class="product_price"><?php echo esc_html(formipay_price_format(floatval($price) * intval(formipay_get_post_meta($post_id, 'product_quantity_range')), $post_id)); ?></td>
|
||||
<?php
|
||||
} else {
|
||||
?>
|
||||
<th>
|
||||
<?php echo esc_html(get_the_title($post_id)); ?> <input type="hidden" class="formipay-qty-input" value="1">
|
||||
</th>
|
||||
<td><?php echo esc_html(formipay_price_format(floatval($price), $post_id)); ?></td>
|
||||
<?php
|
||||
}
|
||||
?>
|
||||
<?php foreach ($cart['lines'] as $line): ?>
|
||||
<tr class="formipay-item-row <?php echo $line['type']==='product' ? 'formipay-product-row' : 'formipay-static-item-row'; ?>">
|
||||
<th>
|
||||
<?php echo esc_html($line['name']); ?>
|
||||
<input type="hidden" class="formipay-qty-input" value="<?php echo (int) $line['qty']; ?>">
|
||||
<?php if ((int) $line['qty'] > 1): ?>
|
||||
<div class="formipay-qty-note">x<?php echo (int) $line['qty']; ?></div>
|
||||
<?php endif; ?>
|
||||
</th>
|
||||
<td class="product_price"><?php echo esc_html(formipay_price_format((float) $line['total'], $post_id)); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<tr class="formipay-total-row">
|
||||
<td colspan="2"></td>
|
||||
<th><?php echo esc_html__( 'Subtotal', 'formipay' ); ?></th>
|
||||
<td><?php echo esc_html(formipay_price_format((float) $cart['subtotal'], $post_id)); ?></td>
|
||||
</tr>
|
||||
<tr class="formipay-grand-total-row">
|
||||
<th><?php echo esc_html__( 'Total', 'formipay' ); ?></th>
|
||||
<td class="grand_total"><?php echo esc_html(formipay_price_format(floatval($price), $post_id)); ?></td>
|
||||
<td class="grand_total"><?php echo esc_html(formipay_price_format((float) $cart['grand'], $post_id)); ?></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -369,17 +346,16 @@ class Render {
|
||||
<?php
|
||||
break;
|
||||
case 'submit_button':
|
||||
|
||||
$cart = $cart ?? $this->build_cart_from_meta($post_id);
|
||||
$grand_display = formipay_price_format((float) $cart['grand'], $post_id);
|
||||
?>
|
||||
|
||||
<button type="submit" class="formipay-submit-button"
|
||||
data-button-text="<?php echo esc_attr(formipay_get_post_meta($post_id, 'button_text')); ?>"
|
||||
style="width: <?php echo formipay_get_post_meta($post_id, 'button_width') == 'fit-content' ? 'fit-content' : '100%' ?>;
|
||||
margin-left: <?php echo formipay_get_post_meta($post_id, 'button_position') !== 'left' ? 'auto' : 'unset' ?>;
|
||||
margin-right: <?php echo formipay_get_post_meta($post_id, 'button_position') !== 'right' ? 'auto' : 'unset' ?>;">
|
||||
<?php echo esc_html(formipay_get_post_meta($post_id, 'button_text')); ?> - <?php echo esc_html(formipay_price_format(floatval($price), $post_id)); ?>
|
||||
<?php echo esc_html(formipay_get_post_meta($post_id, 'button_text')); ?> - <?php echo esc_html($grand_display); ?>
|
||||
</button>
|
||||
|
||||
<?php
|
||||
break;
|
||||
case 'submit_response_notice':
|
||||
@@ -432,6 +408,92 @@ class Render {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a simple cart from static products and static items meta
|
||||
*/
|
||||
private function build_cart_from_meta($post_id){
|
||||
$currency_full = formipay_post_currency($post_id); // e.g., "IDR:::Indonesian rupiah:::Rp"
|
||||
$parts = explode(':::', (string) $currency_full);
|
||||
$currency_code = $parts[0] ?? 'IDR';
|
||||
|
||||
$lines = [];
|
||||
$subtotal = 0.0;
|
||||
|
||||
// 1) Static PRODUCTS (IDs stored as comma-separated string)
|
||||
$ids_raw = (string) formipay_get_post_meta($post_id, 'static_products');
|
||||
$ids = array_filter(array_map('absint', explode(',', $ids_raw)));
|
||||
foreach ($ids as $pid) {
|
||||
$pname = get_the_title($pid);
|
||||
$price = $this->get_product_price_for_currency($pid, $currency_code);
|
||||
$qty = 1; // non-donation, no in-cart items => default 1
|
||||
|
||||
$line_total = (float) $price * $qty;
|
||||
$subtotal += $line_total;
|
||||
|
||||
$lines[] = [
|
||||
'type' => 'product',
|
||||
'id' => $pid,
|
||||
'name' => $pname,
|
||||
'qty' => $qty,
|
||||
'unit' => (float) $price,
|
||||
'total'=> $line_total,
|
||||
];
|
||||
}
|
||||
|
||||
// 2) Static ITEMs (JSON array with currency-specific amounts)
|
||||
$raw = formipay_get_post_meta($post_id, 'static_items');
|
||||
if ($raw) {
|
||||
$items = json_decode((string) $raw, true) ?: [];
|
||||
foreach ($items as $it) {
|
||||
$label = $it['label'] ?? 'Item';
|
||||
$qty = (int) ($it['quantity'] ?? 1);
|
||||
$k = 'amount_' . $currency_code;
|
||||
$amt = (float) ($it[$k] ?? 0);
|
||||
|
||||
$line_total = $amt * $qty;
|
||||
$subtotal += $line_total;
|
||||
|
||||
$lines[] = [
|
||||
'type' => 'item',
|
||||
'id' => null,
|
||||
'name' => $label,
|
||||
'qty' => $qty,
|
||||
'unit' => $amt,
|
||||
'total'=> $line_total,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Placeholders for future rules
|
||||
$discount = 0.0;
|
||||
$shipping = 0.0;
|
||||
$tax = 0.0;
|
||||
|
||||
$grand = max($subtotal - $discount + $shipping + $tax, 0);
|
||||
|
||||
return compact('currency_code','lines','subtotal','discount','shipping','tax','grand');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a product's price for a given currency code using common meta keys.
|
||||
* Fallback order: product_price_{CODE} → price_{CODE} → product_price → price → 0
|
||||
*/
|
||||
private function get_product_price_for_currency($product_id, $currency_code){
|
||||
$cands = [
|
||||
'product_price_' . $currency_code,
|
||||
'price_' . $currency_code,
|
||||
'product_price',
|
||||
'price',
|
||||
];
|
||||
foreach ($cands as $key) {
|
||||
$val = formipay_get_post_meta($product_id, $key);
|
||||
if ($val !== '' && $val !== null) {
|
||||
return (float) $val;
|
||||
}
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render payment options
|
||||
*/
|
||||
@@ -602,19 +664,121 @@ class Render {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve currency UI/config for a given 3-letter currency code from wp_options.
|
||||
*/
|
||||
private function resolve_currency_config($currency_code){
|
||||
$opts = get_option('formipay_settings', []);
|
||||
|
||||
// Defaults from global default_* settings
|
||||
$default_full = isset($opts['default_currency']) ? (string)$opts['default_currency'] : 'IDR:::Indonesian rupiah:::Rp';
|
||||
$def_parts = explode(':::', $default_full);
|
||||
$def_symbol = $def_parts[2] ?? '';
|
||||
|
||||
$cfg = [
|
||||
'currency' => $def_symbol,
|
||||
'decimal_digits' => isset($opts['default_currency_decimal_digits']) ? (int)$opts['default_currency_decimal_digits'] : 2,
|
||||
'decimal_symbol' => isset($opts['default_currency_decimal_symbol']) ? (string)$opts['default_currency_decimal_symbol'] : '.',
|
||||
'thousand_separator' => isset($opts['default_currency_thousand_separator']) ? (string)$opts['default_currency_thousand_separator'] : ',',
|
||||
];
|
||||
|
||||
if (!empty($opts['multicurrencies']) && is_array($opts['multicurrencies'])) {
|
||||
foreach ($opts['multicurrencies'] as $mc) {
|
||||
if (empty($mc['currency'])) continue;
|
||||
$parts = explode(':::', (string)$mc['currency']);
|
||||
$code = $parts[0] ?? '';
|
||||
if (strtoupper($code) !== strtoupper($currency_code)) continue;
|
||||
|
||||
$symbol = $parts[2] ?? '';
|
||||
if ($symbol !== '') $cfg['currency'] = $symbol;
|
||||
if ($mc['decimal_digits'] !== '') $cfg['decimal_digits'] = (int)$mc['decimal_digits'];
|
||||
if ($mc['decimal_symbol'] !== '') $cfg['decimal_symbol'] = (string)$mc['decimal_symbol'];
|
||||
if ($mc['thousand_separator'] !== '') $cfg['thousand_separator'] = (string)$mc['thousand_separator'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $cfg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the allowed currency list for a form, including UI config per currency.
|
||||
* Uses form meta 'allowed_currencies' (JSON array of "CODE:::Title:::Symbol")
|
||||
* and 'default_currencies' (single string) to set default.
|
||||
*/
|
||||
private function resolve_allowed_currencies($post_id){
|
||||
// Allowed on the form
|
||||
$raw = formipay_get_post_meta($post_id, 'allowed_currencies');
|
||||
$allowed = [];
|
||||
if (!empty($raw)) {
|
||||
$arr = json_decode((string)$raw, true);
|
||||
if (is_array($arr)) $allowed = $arr;
|
||||
}
|
||||
// Fallback to all global currencies if the form has none
|
||||
if (empty($allowed)) {
|
||||
$opts = get_option('formipay_settings', []);
|
||||
if (!empty($opts['multicurrencies']) && is_array($opts['multicurrencies'])) {
|
||||
foreach ($opts['multicurrencies'] as $mc) {
|
||||
if (!empty($mc['currency'])) $allowed[] = (string)$mc['currency'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default for the form
|
||||
$default_full = formipay_get_post_meta($post_id, 'default_currencies');
|
||||
if (empty($default_full)) {
|
||||
$opts = get_option('formipay_settings', []);
|
||||
$default_full = $opts['default_currency'] ?? 'IDR:::Indonesian rupiah:::Rp';
|
||||
}
|
||||
$def_parts = explode(':::', (string)$default_full);
|
||||
$default_code = $def_parts[0] ?? 'IDR';
|
||||
|
||||
// Compose structured list
|
||||
$list = [];
|
||||
foreach ($allowed as $cur_full) {
|
||||
$parts = explode(':::', (string)$cur_full);
|
||||
$code = $parts[0] ?? '';
|
||||
$title = $parts[1] ?? '';
|
||||
$symbol = $parts[2] ?? '';
|
||||
if (!$code) continue;
|
||||
|
||||
$cfg = $this->resolve_currency_config($code);
|
||||
if ($symbol !== '') $cfg['currency'] = $symbol; // prefer explicit symbol in the tuple
|
||||
|
||||
$list[] = [
|
||||
'code' => $code,
|
||||
'title' => $title,
|
||||
'symbol' => $cfg['currency'],
|
||||
'decimal_digits' => (int)$cfg['decimal_digits'],
|
||||
'decimal_symbol' => (string)$cfg['decimal_symbol'],
|
||||
'thousand_separator' => (string)$cfg['thousand_separator'],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'default_code' => $default_code,
|
||||
'list' => $list,
|
||||
];
|
||||
}
|
||||
|
||||
private function get_form_data(){
|
||||
$form_data = [];
|
||||
foreach (array_unique(self::$form_ids) as $post_id) {
|
||||
$allowed_currency_pack = $this->resolve_allowed_currencies($post_id);
|
||||
$currency_code = $allowed_currency_pack['default_code'];
|
||||
$currency_cfg = $this->resolve_currency_config($currency_code);
|
||||
$form_data[$post_id] = [
|
||||
'form_id' => $post_id,
|
||||
'currency' => formipay_post_currency($post_id),
|
||||
'currency_code' => $currency_code, // active on load
|
||||
'currency' => $currency_cfg['currency'],
|
||||
'decimal_digits' => $currency_cfg['decimal_digits'],
|
||||
'decimal_symbol' => $currency_cfg['decimal_symbol'],
|
||||
'thousand_separator' => $currency_cfg['thousand_separator'],
|
||||
'allowed_currency_pack' => $allowed_currency_pack,
|
||||
'buyer_phone_field' => formipay_get_post_meta($post_id, 'buyer_phone'),
|
||||
'buyer_country_field' => formipay_get_post_meta($post_id, 'buyer_country'),
|
||||
'buyer_phone_allow' => (bool) formipay_get_post_meta($post_id, 'buyer_allow_choose_country_code'),
|
||||
'buyer_phone_country_code' => formipay_get_post_meta($post_id, 'buyer_phone_country_code'),
|
||||
'decimal_digits' => formipay_get_post_meta($post_id, 'product_currency_decimal_digits'),
|
||||
'decimal_symbol' => formipay_get_post_meta($post_id, 'product_currency_decimal_symbol'),
|
||||
'thousand_separator' => formipay_get_post_meta($post_id, 'product_currency_thousand_separator'),
|
||||
'notice_empty_text_message' => formipay_get_post_meta($post_id, 'empty_required_text_field'),
|
||||
'notice_empty_select_message' => formipay_get_post_meta($post_id, 'empty_required_select_field'),
|
||||
'notice_empty_agreement_message' => formipay_get_post_meta($post_id, 'empty_required_agreement_field'),
|
||||
@@ -628,7 +792,10 @@ class Render {
|
||||
'trigger_selector' => formipay_get_post_meta($post_id, 'popup_click_selector') ?
|
||||
formipay_get_post_meta($post_id, 'popup_trigger_selector') :
|
||||
'.formipay-open-popup-button',
|
||||
'modal_selector' => '#formipay-popup-' . $post_id
|
||||
'modal_selector' => '#formipay-popup-' . $post_id,
|
||||
'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)),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -22,8 +22,9 @@ jQuery(function($){
|
||||
return valid;
|
||||
}
|
||||
|
||||
// Optional safety: guard for bad inputs to price_format
|
||||
function price_format(nStr) {
|
||||
nStr = parseFloat(nStr).toFixed(formipay.decimal_digits) + '';
|
||||
nStr = isNaN(parseFloat(nStr)) ? 0 : parseFloat(nStr); nStr = nStr.toFixed(formipay.decimal_digits) + '';
|
||||
var x = nStr.split('.');
|
||||
var x1 = x[0];
|
||||
var x2 = x.length > 1 ? formipay.decimal_symbol + x[1] : '';
|
||||
@@ -34,7 +35,7 @@ jQuery(function($){
|
||||
return formipay.currency + ' ' + x1 + x2;
|
||||
}
|
||||
|
||||
$('.product-price-row').find('td').html(price_format($('#product_price').val()));
|
||||
// Removed unconditional product price write: in static-products mode there is no #product_price and this forced a 0.
|
||||
$('.formipay-payment-option-group:first-child').find('input').trigger('click');
|
||||
|
||||
// PAGE BREAK
|
||||
@@ -220,7 +221,7 @@ jQuery(function($){
|
||||
form_inputs.append('nonce', formipay_form.nonce);
|
||||
form_inputs.append('data[qty]', $('.formipay-qty-input').val());
|
||||
form_inputs.append('form_id', form_id);
|
||||
form_inputs.append('currency', 'IDR');
|
||||
form_inputs.append('currency', formipay.currency_code || 'IDR');
|
||||
|
||||
var $valid = true; // Initialize as true
|
||||
|
||||
@@ -374,27 +375,54 @@ jQuery(function($){
|
||||
$(document).on('formipayCalculateAjaxSuccess', function(event, res, form, action) {
|
||||
|
||||
if(action == 'calculate') {
|
||||
form.find('.formipay-item-row:not(.formipay-product-row):not(.formipay-total-row):not(.formipay-grand-total-row)').remove();
|
||||
var product_price = res.items[0].subtotal;
|
||||
var grand_total = res.total;
|
||||
form.find('td.product_price').html(price_format(product_price));
|
||||
form.find('td.grand_total').html(price_format(grand_total));
|
||||
var button_text = form.find('.formipay-submit-button').data('button-text');
|
||||
form.find('.formipay-submit-button').html(button_text + ' - ' + price_format(grand_total));
|
||||
$.each(res.items, function(index, item){
|
||||
if(index > 0){
|
||||
var qty = '';
|
||||
if('qty' in item && item.qty > 1){
|
||||
qty = ' x '+item.qty;
|
||||
}
|
||||
$('table#formipay-review-order').find('.formipay-total-row').before(`
|
||||
<tr class="formipay-item-row `+item.context+`">
|
||||
<th>`+item.item+qty+`</th>
|
||||
<td>`+price_format(item.subtotal)+`</td>
|
||||
</tr>
|
||||
`);
|
||||
|
||||
// If backend returns structured items, rebuild dynamic rows
|
||||
if(res && Array.isArray(res.items)){
|
||||
// Remove previously injected rows (keep total/grand-total rows)
|
||||
form.find('.formipay-item-row:not(.formipay-total-row):not(.formipay-grand-total-row)').remove();
|
||||
|
||||
// If server provides totals
|
||||
if(typeof res.total !== 'undefined'){
|
||||
form.find('td.grand_total').html(price_format(res.total));
|
||||
}
|
||||
});
|
||||
|
||||
// If server provides per-line subtotals
|
||||
if(res.items.length){
|
||||
// If server distinguishes a primary product subtotal, reflect it
|
||||
// but do not rely on index 0 always being product
|
||||
res.items.forEach(function(item, index){
|
||||
var qty = '';
|
||||
if('qty' in item && item.qty > 1){ qty = ' x ' + item.qty; }
|
||||
|
||||
// If context is 'product' and there is a dedicated product row, update it
|
||||
if(item.context === 'product' && form.find('tr.formipay-product-row').length === 1 && index === 0){
|
||||
form.find('td.product_price').html(price_format(item.subtotal));
|
||||
return; // skip duplicate append below
|
||||
}
|
||||
|
||||
// Append any extra dynamic items before the total row
|
||||
form.find('table#formipay-review-order .formipay-total-row').before(
|
||||
'<tr class="formipay-item-row '+(item.context||'')+'">\n' +
|
||||
' <th>'+ item.item + qty +'</th>\n' +
|
||||
' <td>'+ price_format(item.subtotal) +'</td>\n' +
|
||||
'</tr>'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Update button from new grand total
|
||||
var grand_html = form.find('td.grand_total').text();
|
||||
form.find('.formipay-submit-button').html(button_text + ' - ' + grand_html);
|
||||
} else {
|
||||
// Backend didn’t send calculable payload; keep server-rendered table intact
|
||||
var grand_html = form.find('td.grand_total').text();
|
||||
if(grand_html){
|
||||
form.find('.formipay-submit-button').html(button_text + ' - ' + grand_html);
|
||||
} else {
|
||||
// Fallback: no grand total cell; do nothing
|
||||
}
|
||||
}
|
||||
}else if(action == 'checkout'){
|
||||
var form_id = form.data('form-id');
|
||||
if(res.success) {
|
||||
|
||||
Reference in New Issue
Block a user