feat: add WPCFTO-inspired design system and React navigation

- Add WPCFTO-inspired design system CSS (colors, spacing, typography)
- Add reusable React components matching WPCFTO visual language
- Implement client-side navigation with hash-based routing
- Add NavigationMenu component for on-page navigation (no SSR)
- Update WordPress submenu highlighting based on current React page
- Add Refresh button to DataTable toolbar
- Fix icon imports in VariationPricingTable
- Remove page headers from all table pages (consistent UX)
- Convert Orders page to use DataTable for consistency

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
dwindown
2026-04-19 05:58:44 +07:00
parent 96ea79600a
commit 057611ef40
19 changed files with 2158 additions and 214 deletions

View File

@@ -65,6 +65,8 @@ class ReactAdmin {
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'restUrl' => rest_url( 'formipay/v1' ),
'nonce' => wp_create_nonce( 'formipay-admin' ),
'pluginUrl' => FORMIPAY_URL,
'siteUrl' => site_url(),
] );
// Debug logging
@@ -149,7 +151,7 @@ class ReactAdmin {
public static function render_mount_point( $page ) {
printf(
'<div id="formipay-admin-root" data-formipay-mount="%s">Loading %s...</div>',
'<div class="wrap"><div id="formipay-admin-root" data-formipay-mount="%s">Loading %s...</div></div>',
esc_attr( $page ),
esc_html( ucfirst( $page ) )
);

View File

@@ -1,8 +1,8 @@
/**
* App Component - Main admin application shell
* App Component - Main admin application shell with client-side routing
*/
import { useEffect } from '@wordpress/element';
import { useState, useEffect } from '@wordpress/element';
import OrdersPage from '../pages/Orders';
import CustomersPage from '../pages/Customers';
import ProductsPage from '../pages/Products';
@@ -10,6 +10,8 @@ import FormsPage from '../pages/Forms';
import CouponsPage from '../pages/Coupons';
import AccessPage from '../pages/Access';
import LicensesPage from '../pages/Licenses';
import NavigationMenu from './NavigationMenu';
import '../pages/AdminPages.css';
const pageComponents = {
orders: OrdersPage,
@@ -21,23 +23,88 @@ const pageComponents = {
licenses: LicensesPage,
};
export default function App({ page, initialData }) {
useEffect(() => {
console.log('[Formipay App] Rendering page:', page, 'with data:', initialData);
}, [page, initialData]);
export default function App({ page: initialPage, initialData }) {
// Use state for client-side routing
// Prioritize hash over initial page for reload support
const [currentPage, setCurrentPage] = useState(() => {
const hash = window.location.hash.replace('#', '');
return (pageComponents[hash]) ? hash : initialPage;
});
const PageComponent = pageComponents[page];
// Update WordPress submenu active state based on current page
useEffect(() => {
// Remove active class from all submenu items
document.querySelectorAll('li.wp-first-item.current, li.wp-first-item .current').forEach(el => {
el.classList.remove('current', 'wp-first-item');
});
// Add active class to current page's submenu item
const pageUrls = {
forms: 'admin.php?page=formipay',
products: 'admin.php?page=formipay-products',
coupons: 'admin.php?page=formipay-coupons',
orders: 'admin.php?page=formipay-orders',
customers: 'admin.php?page=formipay-customers',
access: 'admin.php?page=formipay-access',
licenses: 'admin.php?page=formipay-licenses',
};
const targetUrl = pageUrls[currentPage];
if (targetUrl) {
const submenuLinks = document.querySelectorAll('#toplevel_page_formipay .wp-submenu a');
submenuLinks.forEach(link => {
link.parentElement.classList.remove('current');
link.classList.remove('current');
if (link.getAttribute('href')?.includes(targetUrl)) {
if(currentPage === 'forms') {
document.querySelectorAll('#toplevel_page_formipay .wp-submenu li:nth-child(2) a').forEach(ahref => {
ahref.parentElement.classList.add('current');
ahref.classList.add('current');
});
}else{
link.parentElement.classList.add('current');
link.classList.add('current');
}
}
});
}
}, [currentPage]);
// Listen for hash changes for browser back/forward support
useEffect(() => {
const handleHashChange = () => {
const hash = window.location.hash.replace('#', '');
if (pageComponents[hash]) {
setCurrentPage(hash);
}
};
window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
}, []);
// Update URL when page changes via navigation
const handlePageNavigate = (pageKey) => {
setCurrentPage(pageKey);
window.location.hash = pageKey;
};
const PageComponent = pageComponents[currentPage];
if (!PageComponent) {
return (
<div className="formipay-error">
<p>Unknown page: {page}</p>
<p>Unknown page: {currentPage}</p>
</div>
);
}
return (
<div className="formipay-admin-wrap">
<NavigationMenu
currentPage={currentPage}
onPageNavigate={handlePageNavigate}
/>
<PageComponent initialData={initialData} />
</div>
);

View File

@@ -0,0 +1,49 @@
/**
* Navigation Menu Component - Sticky header with logo and page navigation
* Used for React on-page navigation (no page reload)
*/
import { __ } from '@wordpress/i18n';
export default function NavigationMenu({ currentPage, onPageNavigate }) {
const navItems = [
{ key: 'forms', label: __('Forms', 'formipay') },
{ key: 'products', label: __('Products', 'formipay') },
{ key: 'coupons', label: __('Coupons', 'formipay') },
{ key: 'orders', label: __('Orders', 'formipay') },
{ key: 'customers', label: __('Customers', 'formipay') },
{ key: 'access', label: __('Access', 'formipay') },
{ key: 'licenses', label: __('Licenses', 'formipay') },
];
const handleNavClick = (e, itemKey) => {
e.preventDefault();
if (onPageNavigate) {
onPageNavigate(itemKey);
}
};
return (
<div className="formipay-navigation-menu">
<img
src={`${window.formipayAdmin?.pluginUrl || ''}/admin/assets/img/formipay-logo-circle-white_256.png`}
alt="Formipay"
width="48"
height="48"
/>
<nav className="navigation-links">
{navItems.map((item) => (
<a
key={item.key}
href={`#${item.key}`}
className={`nav-link ${currentPage === item.key ? 'active' : ''}`}
onClick={(e) => handleNavClick(e, item.key)}
>
{item.label}
</a>
))}
</nav>
</div>
);
}

View File

@@ -6,10 +6,7 @@
import { __ } from '@wordpress/i18n';
import { useState, useEffect, useCallback, useRef } from '@wordpress/element';
import { TextControl, Button } from '@wordpress/components';
import { Icon } from '@wordpress/icons';
import minus from '@wordpress/icons/build/minus';
import eyeClosed from '@wordpress/icons/build/eye-closed';
import eyeOpened from '@wordpress/icons/build/eye-opened';
import * as Icons from '@wordpress/icons';
import './VariationPricingTable.css';
export default function VariationPricingTable({ productId, productDetails }) {
@@ -421,12 +418,12 @@ function VariationRow({ row, rowIndex, showFlatPricing, defaultCurrencyCode,
className="toggle-expand"
onClick={onToggleExpanded}
>
<Icon icon={row.expanded ? eyeOpened : eyeClosed} size={16} />
{row.expanded ? '▼' : '▶'}
</button>
<strong>{ row.name }</strong>
</td>
{showFlatPricing ? (
{showFlatPricing && row.prices?.[0] ? (
<>
<PriceCell
price={row.prices[0]}
@@ -465,7 +462,7 @@ function VariationRow({ row, rowIndex, showFlatPricing, defaultCurrencyCode,
size="small"
isDestructive
onClick={onDelete}
icon={minus()}
icon={Icons.trash}
>
{ __('Delete', 'formipay') }
</Button>
@@ -477,7 +474,7 @@ function VariationRow({ row, rowIndex, showFlatPricing, defaultCurrencyCode,
<td colSpan="5">
<table className="inner-table">
<tbody>
{row.prices.map((price, currencyIndex) => {
{(row.prices || []).map((price, currencyIndex) => {
const code = String(price.currency).split(':::')[0];
const isDefault = code === defaultCurrencyCode;
const step = price.currency_decimal_digits
@@ -523,6 +520,10 @@ function VariationRow({ row, rowIndex, showFlatPricing, defaultCurrencyCode,
// Price input cell component
function PriceCell({ price, field, onChange }) {
if (!price) {
return <td className="price-cell">-</td>;
}
const step = price.currency_decimal_digits
? 1 / Math.pow(10, price.currency_decimal_digits)
: 0.01;

View File

@@ -21,6 +21,18 @@
min-width: 150px;
}
.formipay-table-toolbar *:is(button,input,select) {
height: 40px!important;
}
.formipay-table-toolbar .components-base-control__field {
margin-bottom: unset!important;
}
.formipay-table-toolbar *:is(button, input, select, .components-input-control__backdrop){
border-radius: 4px!important;
}
/* Filter Tabs */
.formipay-filter-tabs {
display: flex;
@@ -112,6 +124,14 @@
background-color: #f0f0f1;
}
.formipay-table *:is(td, th):first-child {
text-align: center;
}
.formipay-table th.column-select > input {
margin-left: 0;
}
/* Checkbox Column */
.formipay-table .column-select {
width: 40px;

View File

@@ -60,6 +60,9 @@ export default function DataTable({
tableAction, // e.g., 'formipay-tabledata-forms'
deleteAction,
duplicateAction,
// Selection callback
onSelectionChange,
}) {
// State
const [data, setData] = useState(initialData);
@@ -82,6 +85,13 @@ export default function DataTable({
const [selectedRows, setSelectedRows] = useState(new Set());
const [selectAll, setSelectAll] = useState(false);
// Notify parent of selection changes
useEffect(() => {
if (onSelectionChange) {
onSelectionChange(selectedRows);
}
}, [selectedRows, onSelectionChange]);
// Add New Modal
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [newItemTitle, setNewItemTitle] = useState('');
@@ -89,8 +99,6 @@ export default function DataTable({
// Derive action names from tableAction
const baseActionName = tableAction.replace('formipay-tabledata-', '');
const bulkDeleteAction = actions.bulkDelete?.action || `formipay-bulk-delete-${baseActionName}`;
const deleteActionName = deleteAction || `formipay-delete-${baseActionName}`;
const duplicateActionName = duplicateAction || `formipay-duplicate-${baseActionName}`;
// Load data
const loadData = useCallback(async () => {
@@ -213,56 +221,6 @@ export default function DataTable({
}
};
// Handle inline delete
const handleDelete = async (id) => {
const result = await Swal.fire({
icon: 'info',
html: __('Do you want to delete this item?', 'formipay'),
showCancelButton: true,
confirmButtonText: __('Delete Permanently', 'formipay'),
cancelButtonText: __('Cancel', 'formipay'),
});
if (result.isConfirmed) {
await fetch(`${ajaxUrl}?action=${deleteActionName}`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
id,
_wpnonce: nonce,
}),
});
loadData();
}
};
// Handle duplicate
const handleDuplicate = async (id) => {
const result = await Swal.fire({
icon: 'info',
html: __('Do you want to duplicate this item?', 'formipay'),
showCancelButton: true,
confirmButtonText: __('Confirm', 'formipay'),
cancelButtonText: __('Cancel', 'formipay'),
});
if (result.isConfirmed) {
await fetch(`${ajaxUrl}?action=${duplicateActionName}`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
id,
_wpnonce: nonce,
}),
});
loadData();
}
};
// Handle Add New
const handleAddNew = async () => {
if (!newItemTitle.trim()) {
@@ -356,6 +314,15 @@ export default function DataTable({
}}
/>
)}
{/* Refresh Button */}
<Button
variant="secondary"
onClick={loadData}
disabled={loading}
>
{loading ? __('Refreshing...', 'formipay') : __('Refresh', 'formipay')}
</Button>
</div>
{/* Filter Tabs */}
@@ -408,10 +375,6 @@ export default function DataTable({
{column.label}
</th>
))}
{/* Actions column */}
{actions.inline && (
<th className="column-actions">{__('Actions', 'formipay')}</th>
)}
</tr>
</thead>
<tbody>
@@ -435,41 +398,6 @@ export default function DataTable({
{column.render ? column.render(row) : row[column.key]}
</td>
))}
{/* Actions */}
{actions.inline && (
<td className="column-actions">
<a href={`${window.formipayAdmin?.siteUrl || ''}/wp-admin/post.php?post=${rowId}&action=edit`}>
{__('Edit', 'formipay')}
</a>
{' | '}
<span
className="row-actions"
style={{ visibility: 'hidden' }}
>
<button
className="button-link delete"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDelete(rowId);
}}
>
{__('Delete', 'formipay')}
</button>
{' | '}
<button
className="button-link duplicate"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDuplicate(rowId);
}}
>
{__('Duplicate', 'formipay')}
</button>
</span>
</td>
)}
</tr>
);
})}
@@ -531,7 +459,7 @@ export default function DataTable({
</div>
)}
{/* Add New Modal - only render when actually open */}
{/* Add New Modal - only render when open */}
{actions.addNew && isAddModalOpen && (
<Modal
title={actions.addNew.label || __('Add New', 'formipay')}
@@ -539,7 +467,6 @@ export default function DataTable({
setIsAddModalOpen(false);
setNewItemTitle('');
}}
isDismissible
>
<TextControl
__next40pxDefaultSize

View File

@@ -0,0 +1,35 @@
/**
* Screen Menu Component - Page title and bulk delete action
* Navigation is handled by NavigationMenu component
* Add New button is in DataTable toolbar
*/
import { __ } from '@wordpress/i18n';
export default function ScreenMenu({
title,
showBulkDelete = false,
bulkDeleteCount = 0,
onBulkDelete,
}) {
// Derive bulk delete visibility from count
const bulkDeleteVisible = showBulkDelete || bulkDeleteCount > 0;
return (
<div className="formipay-screen-menu">
<h1>{title}</h1>
{bulkDeleteVisible && onBulkDelete && (
<button
className="button button-secondary"
onClick={onBulkDelete}
>
{__('Delete Selected', 'formipay')} ({bulkDeleteCount})
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M20 6a1 1 0 0 1 .117 1.993L20 8h-.081L19 19a3 3 0 0 1-2.824 2.995L16 22H8c-1.598 0-2.904-1.249-2.992-2.75l-.005-.167L4.08 8H4a1 1 0 0 1-.117-1.993L4 6zm-9.489 5.14a1 1 0 0 0-1.218 1.567L10.585 14l-1.292 1.293l-.083.094a1 1 0 0 0 1.497 1.32L12 15.415l1.293 1.292l.094.083a1 1 0 0 0 1.32-1.497L13.415 14l1.292-1.293l.083-.094a1 1 0 0 0-1.497-1.32L12 12.585l-1.293-1.292l-.094-.083zM14 2a2 2 0 0 1 2 2a1 1 0 0 1-1.993.117L14 4h-4l-.007.117A1 1 0 0 1 8 4a2 2 0 0 1 1.85-1.995L10 2z" />
</svg>
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,224 @@
# Formipay Design System - WPCFTO-Inspired
A comprehensive React component library that matches the visual language of WPCFTO Vue components.
## Installation
Import the CSS in your main React entry point:
```javascript
import './design-system/WpcftoDesign.css';
```
Import components:
```javascript
import { Box, Field, Input, Button, Repeater, TabNav } from './design-system';
// OR
import * as DS from './design-system';
```
## Components
### Box & BoxChild
Container components with WPCFTO styling.
```jsx
<Box>
<BoxChild>
<h2>Section Title</h2>
<p>Content goes here...</p>
</BoxChild>
</Box>
```
### TabNav & TabPanel
Vertical/horizontal tab navigation matching WPCFTO sidebar.
```jsx
function MyComponent() {
const [activeTab, setActiveTab] = useState('general');
const tabs = [
{ id: 'general', label: 'General', icon: '⚙' },
{ id: 'settings', label: 'Settings', icon: '🔧' },
];
return (
<>
<TabNav
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
<TabPanel tabs={tabs} activeTab={activeTab}>
{({ id }) => (
id === 'general' && <GeneralContent />
)}
</TabPanel>
</>
);
}
```
### Field, Input, Textarea, Select
Form field components with consistent styling.
```jsx
<Field
label="Product Name"
description="Enter the product name"
required
>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Product Name"
/>
</Field>
<Textarea
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={5}
/>
<Select
label="Status"
options={[
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
]}
value={status}
onChange={(e) => setStatus(e.target.value)}
/>
```
### Button
Button with variants and sizes.
```jsx
<Button variant="primary" onClick={handleSave}>
Save Changes
</Button>
<Button variant="secondary" size="sm">
Cancel
</Button>
<Button variant="danger" icon={() => <span>🗑</span>}>
Delete
</Button>
```
### Repeater
Expandable/collapsible repeater component matching WPCFTO repeater.
```jsx
const [items, setItems] = useState([
{ id: 1, title: 'Item 1', collapsed: false },
]);
<Repeater
items={items}
renderItem={(item, index) => (
<Field label="Item Name">
<Input
value={item.title}
onChange={(e) => updateItem(index, 'title', e.target.value)}
/>
</Field>
)}
onAdd={() => setItems([...items, { id: Date.now(), title: 'New Item', collapsed: false }])}
onRemove={(id, index) => setItems(items.filter((_, i) => i !== index))}
addLabel="Add New Item"
/>
```
### Notice
Alert/notice component for messages.
```jsx
<Notice type="success" title="Saved!">
Your changes have been successfully saved.
</Notice>
<Notice type="error" title="Error" onDismiss={() => setError(null)}>
{errorMessage}
</Notice>
```
### Badge
Status badge component.
```jsx
<Badge variant="success">Active</Badge>
<Badge variant="warning">Pending</Badge>
<Badge variant="danger">Expired</Badge>
```
### Table
Styled table component.
```jsx
<Table
columns={[
{ key: 'id', label: 'ID' },
{ key: 'name', label: 'Name' },
{ key: 'status', label: 'Status', render: (row) => (
<Badge variant={row.status === 'active' ? 'success' : 'default'}>
{row.status}
</Badge>
)},
]}
data={tableData}
emptyMessage="No items found"
/>
```
## Design Tokens
### Colors
- Primary: `#2985f7` (Blue)
- Sidebar: `#1e2a36` (Dark)
- Success: `#00a32a` (Green)
- Warning: `#dba617` (Yellow)
- Danger: `#d63638` (Red)
### Spacing
- xs: 4px, sm: 8px, md: 12px, lg: 16px, xl: 20px, xxl: 24px
### Border Radius
- sm: 4px, md: 10px, lg: 30px, full: 50%
### Typography
- Font: Roboto
- Sizes: 13px (sm), 14px (base), 15px (md), 18px (lg)
## Usage Guidelines
1. **Use Box/BoxChild** for content sections with proper spacing and borders
2. **Use TabNav/TabPanel** for settings pages with multiple sections
3. **Use Field** wrapper for all form inputs to get consistent labels and descriptions
4. **Use Repeater** for repeatable content like variations, attributes, etc.
5. **Use Notice** for success/error/warning messages
6. **Use Badge** for status indicators
## Migration Notes
When migrating from WPCFTO Vue to React:
1. **Structure**: Match the `.wpcfto-box``.formipay-box` class structure
2. **Colors**: Use CSS custom properties for consistent theming
3. **Interactions**: Maintain same hover states and transitions
4. **Validation**: Reuse WPCFTO validation patterns where possible
## Future Enhancements
- [ ] Add Sortable for drag-drop reordering
- [ ] Add ColorPicker for color inputs
- [ ] Add DatePicker for date/time selection
- [ ] Add RichText for WYSIWYG editing
- [ ] Add FileUpload for media handling
- [ ] Add IconPicker for icon selection

View File

@@ -0,0 +1,384 @@
/**
* Formipay Design System - WPCFTO-inspired React components
* Reusable components matching WPCFTO visual language
*/
import { __ } from '@wordpress/i18n';
import './WpcftoDesign.css';
// Box Component
export function Box({ children, className = '', ...props }) {
return (
<div className={`formipay-box ${className}`} {...props}>
{children}
</div>
);
}
// Box Child Component
export function BoxChild({ children, className = '', ...props }) {
return (
<div className={`formipay-box-child ${className}`} {...props}>
{children}
</div>
);
}
// Tab Navigation Component
export function TabNav({ tabs, activeTab, onTabChange, orientation = 'vertical' }) {
return (
<div className={`formipay-tab-nav formipay-tab-nav-${orientation}`}>
{tabs.map((tab) => (
<div
key={tab.id}
className={`formipay-nav-item ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => onTabChange(tab.id)}
>
<div className="formipay-nav-title">
{tab.icon && <span className="formipay-nav-icon">{tab.icon}</span>}
<span>{tab.label}</span>
</div>
</div>
))}
</div>
);
}
// Tab Panel Component
export function TabPanel({ tabs, activeTab, children }) {
return (
<div className="formipay-tabs">
{tabs.map((tab, index) => (
<div
key={tab.id}
className={`formipay-tab ${tab.id === activeTab ? 'active' : ''}`}
>
<div className="formipay-tab-content">
{typeof children === 'function' ? children(tab, index) : children}
</div>
</div>
))}
</div>
);
}
// Field Component
export function Field({
label,
description,
required = false,
children,
className = '',
...props
}) {
return (
<div className={`formipay-field ${className}`} {...props}>
{label && (
<div className={`formipay-field-label ${required ? 'required' : ''}`}>
<span className="formipay-field-label-text">{label}</span>
</div>
)}
<div className="formipay-field-content">
{children}
</div>
{description && (
<div className="formipay-field-description">{description}</div>
)}
</div>
);
}
// Input Component
export function Input({
label,
description,
required = false,
className = '',
...props
}) {
return (
<Field label={label} description={description} required={required}>
<input className={`formipay-input ${className}`} {...props} />
</Field>
);
}
// Textarea Component
export function Textarea({
label,
description,
required = false,
rows = 4,
className = '',
...props
}) {
return (
<Field label={label} description={description} required={required}>
<textarea
className={`formipay-textarea ${className}`}
rows={rows}
{...props}
/>
</Field>
);
}
// Select Component
export function Select({
label,
description,
required = false,
options = [],
className = '',
...props
}) {
return (
<Field label={label} description={description} required={required}>
<select className={`formipay-select ${className}`} {...props}>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</Field>
);
}
// Checkbox Component
export function Checkbox({
label,
checked,
onChange,
className = '',
...props
}) {
return (
<label className={`formipay-checkbox ${className}`}>
<input
type="checkbox"
checked={checked}
onChange={onChange}
{...props}
/>
<span>{label}</span>
</label>
);
}
// Button Component
export function Button({
variant = 'primary',
size = 'md',
icon: Icon,
children,
className = '',
disabled = false,
onClick,
...props
}) {
const sizeClass = size !== 'md' ? `formipay-btn-${size}` : '';
const variantClass = `formipay-btn-${variant}`;
return (
<button
className={`formipay-btn ${variantClass} ${sizeClass} ${className}`}
disabled={disabled}
onClick={onClick}
{...props}
>
{Icon && <span className="formipay-btn-icon"><Icon /></span>}
{children}
</button>
);
}
// Repeater Component
export function Repeater({
items,
renderItem,
onAdd,
onRemove,
addLabel = 'Add Item',
className = '',
}) {
return (
<div className={`formipay-repeater ${className}`}>
{items.map((item, index) => (
<div key={item.id || index} className={`formipay-repeater-item ${item.collapsed ? 'collapsed' : ''}`}>
<div
className="formipay-repeater-header"
onClick={() => onToggle?.(item.id)}
>
<div className="formipay-repeater-title">
<span className="formipay-repeater-toggle"></span>
<span>{item.title || `Item ${index + 1}`}</span>
</div>
<div className="formipay-repeater-actions">
<span
className="formipay-repeater-delete"
onClick={(e) => {
e.stopPropagation();
onRemove?.(item.id, index);
}}
>
</span>
</div>
</div>
<div className="formipay-repeater-body">
{renderItem(item, index)}
</div>
</div>
))}
<button className="formipay-repeater-add" onClick={onAdd}>
<span>+</span>
<span>{addLabel}</span>
</button>
</div>
);
}
// Notice Component
export function Notice({
type = 'info',
title,
children,
onDismiss,
className = '',
}) {
return (
<div className={`formipay-notice formipay-notice-${type} ${className}`}>
<div className="formipay-notice-icon">
{type === 'success' && '✓'}
{type === 'warning' && '⚠'}
{type === 'error' && '✕'}
{type === 'info' && ''}
</div>
<div className="formipay-notice-content">
{title && <div className="formipay-notice-title">{title}</div>}
<div>{children}</div>
</div>
{onDismiss && (
<button
className="formipay-notice-dismiss"
onClick={onDismiss}
aria-label="Dismiss"
>
</button>
)}
</div>
);
}
// Loading Spinner Component
export function Spinner({ size = 'md', className = '' }) {
const sizeClass = size !== 'md' ? `formipay-spinner-${size}` : '';
return (
<div className={`formipay-loading ${className}`}>
<div className={`formipay-spinner ${sizeClass}`}></div>
</div>
);
}
// Empty State Component
export function EmptyState({
icon,
title,
description,
action,
actionLabel,
onAction,
className = '',
}) {
return (
<div className={`formipay-empty-state ${className}`}>
{icon && <div className="formipay-empty-icon">{icon}</div>}
{title && <div className="formipay-empty-title">{title}</div>}
{description && (
<div className="formipay-empty-description">{description}</div>
)}
{action && (
<Button onClick={onAction}>
{actionLabel || __('Take Action', 'formipay')}
</Button>
)}
</div>
);
}
// Status Badge Component
export function Badge({
variant = 'default',
children,
className = '',
}) {
return (
<span className={`formipay-badge formipay-badge-${variant} ${className}`}>
{children}
</span>
);
}
// Table Component
export function Table({
columns,
data,
emptyMessage = __('No items found', 'formipay'),
className = '',
}) {
if (!data || data.length === 0) {
return (
<div className={`formipay-table-wrapper ${className}`}>
<EmptyState
title={emptyMessage}
/>
</div>
);
}
return (
<div className={`formipay-table-wrapper ${className}`}>
<table className="formipay-table">
<thead>
<tr>
{columns.map((column) => (
<th key={column.key}>{column.label}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, rowIndex) => (
<tr key={rowIndex}>
{columns.map((column) => (
<td key={column.key}>
{column.render ? column.render(row, rowIndex) : row[column.key]}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
export default {
Box,
BoxChild,
TabNav,
TabPanel,
Field,
Input,
Textarea,
Select,
Checkbox,
Button,
Repeater,
Notice,
Spinner,
EmptyState,
Badge,
Table,
};

View File

@@ -0,0 +1,645 @@
/**
* Formipay Design System - WPCFTO-inspired component styles
* Matches the visual language of WPCFTO Vue components
* Use these classes in React components for consistency
*/
/* ============================================
DESIGN TOKENS
============================================ */
:root {
/* Colors */
--formipay-color-primary: #2985f7;
--formipay-color-sidebar-bg: #1e2a36;
--formipay-color-sidebar-text: #fff;
--formipay-color-content-bg: #fff;
--formipay-color-border: #f0f0f1;
--formipay-color-border-dark: #8c99a5;
--formipay-color-input-bg: #f6f9fc;
--formipay-color-text: #1d2327;
--formipay-color-text-muted: #646970;
--formipay-color-danger: #d63638;
--formipay-color-success: #00a32a;
--formipay-color-warning: #dba617;
/* Spacing */
--formipay-spacing-xs: 4px;
--formipay-spacing-sm: 8px;
--formipay-spacing-md: 12px;
--formipay-spacing-lg: 16px;
--formipay-spacing-xl: 20px;
--formipay-spacing-xxl: 24px;
/* Border Radius */
--formipay-radius-sm: 4px;
--formipay-radius-md: 10px;
--formipay-radius-lg: 30px;
--formipay-radius-full: 50%;
/* Typography */
--formipay-font-family: 'Roboto', -apple-system, BlinkMacSystemFont, sans-serif;
--formipay-font-size-sm: 13px;
--formipay-font-size-base: 14px;
--formipay-font-size-md: 15px;
--formipay-font-size-lg: 18px;
--formipay-font-weight-normal: 400;
--formipay-font-weight-medium: 500;
--formipay-font-weight-semibold: 600;
--formipay-font-weight-bold: 700;
/* Shadows */
--formipay-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.05);
--formipay-shadow-md: -2px 2px 5px rgba(0, 0, 0, 0.08);
--formipay-shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.1);
/* Transitions */
--formipay-transition-fast: 0.15s ease;
--formipay-transition-base: 0.3s ease;
}
/* ============================================
BASE STYLES
============================================ */
.formipay-design-system * {
box-sizing: border-box;
}
.formipay-design-system {
font-family: var(--formipay-font-family);
color: var(--formipay-color-text);
line-height: 1.5;
}
/* ============================================
BOX COMPONENT
============================================ */
.formipay-box {
background-color: var(--formipay-color-content-bg);
border-radius: var(--formipay-radius-md);
margin: 0 0 10px;
min-height: 80px;
position: relative;
box-shadow: var(--formipay-shadow-sm);
}
.formipay-box-child {
position: relative;
}
.formipay-box-child + .formipay-box-child {
margin-top: 1em;
}
.formipay-box a {
color: var(--formipay-color-primary);
text-decoration: none;
transition: color var(--formipay-transition-base);
}
.formipay-box a:hover {
text-decoration: underline;
}
/* ============================================
TAB NAVIGATION (Vertical)
============================================ */
.formipay-tab-nav {
display: flex;
flex-direction: column;
gap: var(--formipay-spacing-xs);
}
.formipay-nav-item {
display: flex;
flex-direction: column;
padding: var(--formipay-spacing-md) var(--formipay-spacing-lg);
border-radius: var(--formipay-radius-sm);
cursor: pointer;
transition: background-color var(--formipay-transition-fast);
user-select: none;
}
.formipay-nav-item:hover {
background-color: rgba(41, 133, 247, 0.05);
}
.formipay-nav-item.active {
background-color: rgba(41, 133, 247, 0.1);
color: var(--formipay-color-primary);
}
.formipay-nav-title {
display: flex;
align-items: center;
gap: var(--formipay-spacing-sm);
font-size: var(--formipay-font-size-base);
font-weight: var(--formipay-font-weight-medium);
}
.formipay-nav-icon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
}
/* ============================================
TAB CONTENT PANELS
============================================ */
.formipay-tab {
display: none;
}
.formipay-tab.active {
display: block;
}
.formipay-tab-content {
padding: var(--formipay-spacing-lg);
}
/* ============================================
FIELD COMPONENT
============================================ */
.formipay-field {
display: flex;
flex-direction: column;
padding: var(--formipay-spacing-lg) 0;
border-bottom: 1px solid var(--formipay-color-border);
}
.formipay-field:last-child {
border-bottom: none;
}
.formipay-field-label {
display: flex;
align-items: flex-start;
gap: var(--formipay-spacing-xs);
margin-bottom: var(--formipay-spacing-sm);
font-size: var(--formipay-font-size-base);
font-weight: var(--formipay-font-weight-medium);
}
.formipay-field-label.required .formipay-field-label-text::after {
content: '*';
color: var(--formipay-color-danger);
margin-left: 2px;
}
.formipay-field-description {
font-size: var(--formipay-font-size-sm);
color: var(--formipay-color-text-muted);
margin-top: var(--formipay-spacing-xs);
}
/* ============================================
INPUTS
============================================ */
.formipay-input,
.formipay-select,
.formipay-textarea {
width: 100%;
padding: 10px 16px;
border: 1px solid var(--formipay-color-border-dark);
border-radius: var(--formipay-radius-lg);
background-color: var(--formipay-color-input-bg);
font-family: inherit;
font-size: var(--formipay-font-size-base);
color: var(--formipay-color-text);
transition: border-color var(--formipay-transition-fast), box-shadow var(--formipay-transition-fast);
}
.formipay-input:focus,
.formipay-select:focus,
.formipay-textarea:focus {
outline: none;
border-color: var(--formipay-color-primary);
box-shadow: 0 0 0 3px rgba(41, 133, 247, 0.1);
}
.formipay-input:disabled,
.formipay-select:disabled,
.formipay-textarea:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.formipay-textarea {
min-height: 100px;
resize: vertical;
}
/* ============================================
CHECKBOX & RADIO
============================================ */
.formipay-checkbox,
.formipay-radio {
display: flex;
align-items: center;
gap: var(--formipay-spacing-sm);
cursor: pointer;
}
.formipay-checkbox input,
.formipay-radio input {
width: 16px;
height: 16px;
cursor: pointer;
}
/* ============================================
BUTTONS
============================================ */
.formipay-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--formipay-spacing-sm);
padding: 10px 20px;
border: none;
border-radius: var(--formipay-radius-sm);
font-family: inherit;
font-size: var(--formipay-font-size-base);
font-weight: var(--formipay-font-weight-medium);
cursor: pointer;
transition: all var(--formipay-transition-fast);
text-decoration: none;
}
.formipay-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.formipay-btn-primary {
background-color: var(--formipay-color-primary);
color: #fff;
}
.formipay-btn-primary:hover:not(:disabled) {
background-color: #1e6ae6;
}
.formipay-btn-secondary {
background-color: #f0f0f1;
color: var(--formipay-color-text);
}
.formipay-btn-secondary:hover:not(:disabled) {
background-color: #e0e0e1;
}
.formipay-btn-danger {
background-color: var(--formipay-color-danger);
color: #fff;
}
.formipay-btn-danger:hover:not(:disabled) {
background-color: #b32d2e;
}
.formipay-btn-sm {
padding: 6px 12px;
font-size: var(--formipay-font-size-sm);
}
.formipay-btn-icon {
padding: 8px;
aspect-ratio: 1;
}
/* ============================================
REPEATER COMPONENT
============================================ */
.formipay-repeater {
display: flex;
flex-direction: column;
gap: var(--formipay-spacing-md);
}
.formipay-repeater-item {
background-color: #fafafa;
border: 1px solid var(--formipay-color-border);
border-radius: var(--formipay-radius-sm);
overflow: hidden;
}
.formipay-repeater-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--formipay-spacing-md) var(--formipay-spacing-lg);
background-color: #fff;
border-bottom: 1px solid var(--formipay-color-border);
cursor: pointer;
user-select: none;
}
.formipay-repeater-header:hover {
background-color: #f8f9fa;
}
.formipay-repeater-title {
display: flex;
align-items: center;
gap: var(--formipay-spacing-sm);
font-weight: var(--formipay-font-weight-medium);
}
.formipay-repeater-toggle {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
transition: transform var(--formipay-transition-fast);
}
.formipay-repeater-item.collapsed .formipay-repeater-toggle {
transform: rotate(-90deg);
}
.formipay-repeater-body {
padding: var(--formipay-spacing-lg);
}
.formipay-repeater-item.collapsed .formipay-repeater-body {
display: none;
}
.formipay-repeater-actions {
display: flex;
gap: var(--formipay-spacing-sm);
}
.formipay-repeater-delete {
color: var(--formipay-color-danger);
cursor: pointer;
transition: color var(--formipay-transition-fast);
}
.formipay-repeater-delete:hover {
color: #b32d2e;
}
.formipay-repeater-add {
display: flex;
align-items: center;
gap: var(--formipay-spacing-sm);
padding: var(--formipay-spacing-md) var(--formipay-spacing-lg);
background-color: transparent;
border: 1px dashed var(--formipay-color-border-dark);
border-radius: var(--formipay-radius-sm);
color: var(--formipay-color-text-muted);
cursor: pointer;
transition: all var(--formipay-transition-fast);
}
.formipay-repeater-add:hover {
border-color: var(--formipay-color-primary);
color: var(--formipay-color-primary);
background-color: rgba(41, 133, 247, 0.05);
}
/* ============================================
TABLE STYLES (WPCFTO-style)
============================================ */
.formipay-table-wrapper {
background-color: var(--formipay-color-content-bg);
border-radius: var(--formipay-radius-md);
overflow: hidden;
box-shadow: var(--formipay-shadow-sm);
}
.formipay-table {
width: 100%;
border-collapse: collapse;
}
.formipay-table thead {
background-color: #f8f9fa;
}
.formipay-table th {
padding: 12px 16px;
text-align: left;
font-size: var(--formipay-font-size-sm);
font-weight: var(--formipay-font-weight-semibold);
color: var(--formipay-color-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--formipay-color-border);
}
.formipay-table td {
padding: 12px 16px;
border-bottom: 1px solid var(--formipay-color-border);
}
.formipay-table tbody tr:last-child td {
border-bottom: none;
}
.formipay-table tbody tr:hover {
background-color: #f8f9fa;
}
/* ============================================
STATUS BADGES
============================================ */
.formipay-badge {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: var(--formipay-radius-full);
font-size: var(--formipay-font-size-sm);
font-weight: var(--formipay-font-weight-medium);
}
.formipay-badge-success {
background-color: #edfaef;
color: var(--formipay-color-success);
}
.formipay-badge-warning {
background-color: #fff8e5;
color: var(--formipay-color-warning);
}
.formipay-badge-danger {
background-color: #fce8e6;
color: var(--formipay-color-danger);
}
.formipay-badge-info {
background-color: #e7f3ff;
color: var(--formipay-color-primary);
}
.formipay-badge-default {
background-color: #f0f0f1;
color: var(--formipay-color-text-muted);
}
/* ============================================
NOTICE / ALERT COMPONENTS
============================================ */
.formipay-notice {
display: flex;
align-items: flex-start;
gap: var(--formipay-spacing-md);
padding: var(--formipay-spacing-md) var(--formipay-spacing-lg);
border-radius: var(--formipay-radius-sm);
margin: var(--formipay-spacing-md) 0;
}
.formipay-notice-icon {
flex-shrink: 0;
width: 20px;
height: 20px;
}
.formipay-notice-content {
flex: 1;
}
.formipay-notice-title {
font-weight: var(--formipay-font-weight-semibold);
margin-bottom: var(--formipay-spacing-xs);
}
.formipay-notice-success {
background-color: #edfaef;
border-left: 4px solid var(--formipay-color-success);
}
.formipay-notice-warning {
background-color: #fff8e5;
border-left: 4px solid var(--formipay-color-warning);
}
.formipay-notice-error {
background-color: #fce8e6;
border-left: 4px solid var(--formipay-color-danger);
}
.formipay-notice-info {
background-color: #e7f3ff;
border-left: 4px solid var(--formipay-color-primary);
}
/* ============================================
LOADING & EMPTY STATES
============================================ */
.formipay-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
}
.formipay-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--formipay-color-border);
border-top-color: var(--formipay-color-primary);
border-radius: 50%;
animation: formipay-spin 0.8s linear infinite;
}
@keyframes formipay-spin {
to { transform: rotate(360deg); }
}
.formipay-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 40px;
text-align: center;
}
.formipay-empty-icon {
font-size: 48px;
color: var(--formipay-color-border-dark);
margin-bottom: var(--formipay-spacing-md);
}
.formipay-empty-title {
font-size: var(--formipay-font-size-lg);
font-weight: var(--formipay-font-weight-semibold);
margin-bottom: var(--formipay-spacing-sm);
}
.formipay-empty-description {
color: var(--formipay-color-text-muted);
margin-bottom: var(--formipay-spacing-lg);
}
/* ============================================
UTILITIES
============================================ */
.formipay-text-center { text-align: center; }
.formipay-text-right { text-align: right; }
.formipay-text-muted { color: var(--formipay-color-text-muted); }
.formipay-text-small { font-size: var(--formipay-font-size-sm); }
.formipay-text-large { font-size: var(--formipay-font-size-lg); }
.formipay-mt-0 { margin-top: 0; }
.formipay-mt-1 { margin-top: var(--formipay-spacing-xs); }
.formipay-mt-2 { margin-top: var(--formipay-spacing-sm); }
.formipay-mt-3 { margin-top: var(--formipay-spacing-md); }
.formipay-mt-4 { margin-top: var(--formipay-spacing-lg); }
.formipay-mb-0 { margin-bottom: 0; }
.formipay-mb-1 { margin-bottom: var(--formipay-spacing-xs); }
.formipay-mb-2 { margin-bottom: var(--formipay-spacing-sm); }
.formipay-mb-3 { margin-bottom: var(--formipay-spacing-md); }
.formipay-mb-4 { margin-bottom: var(--formipay-spacing-lg); }
.formipay-flex { display: flex; }
.formipay-flex-center { display: flex; align-items: center; justify-content: center; }
.formipay-flex-between { display: flex; align-items: center; justify-content: space-between; }
.formipay-flex-column { display: flex; flex-direction: column; }
.formipay-gap-1 { gap: var(--formipay-spacing-xs); }
.formipay-gap-2 { gap: var(--formipay-spacing-sm); }
.formipay-gap-3 { gap: var(--formipay-spacing-md); }
.formipay-gap-4 { gap: var(--formipay-spacing-lg); }
/* ============================================
RESPONSIVE
============================================ */
@media (max-width: 768px) {
.formipay-tab-nav {
flex-direction: row;
overflow-x: auto;
}
.formipay-nav-item {
flex-shrink: 0;
}
.formipay-field {
padding: var(--formipay-spacing-md) 0;
}
}

View File

@@ -0,0 +1,129 @@
/**
* Formipay Design System - WPCFTO-inspired
* Exports design system CSS and React components
*/
// Export components
export { default } from './WpcftoComponents';
export {
Box,
BoxChild,
TabNav,
TabPanel,
Field,
Input,
Textarea,
Select,
Checkbox,
Button,
Repeater,
Notice,
Spinner,
EmptyState,
Badge,
Table,
} from './WpcftoComponents';
// Export CSS import helper
export const WpcftoDesign = {
css: './WpcftoDesign.css',
description: 'WPCFTO-inspired design system for React components',
version: '1.0.0',
};
// Design tokens documentation
export const tokens = {
colors: {
primary: '#2985f7',
sidebarBg: '#1e2a36',
sidebarText: '#fff',
contentBg: '#fff',
border: '#f0f0f1',
borderDark: '#8c99a5',
inputBg: '#f6f9fc',
text: '#1d2327',
textMuted: '#646970',
danger: '#d63638',
success: '#00a32a',
warning: '#dba617',
},
spacing: {
xs: '4px',
sm: '8px',
md: '12px',
lg: '16px',
xl: '20px',
xxl: '24px',
},
borderRadius: {
sm: '4px',
md: '10px',
lg: '30px',
full: '50%',
},
fontSize: {
sm: '13px',
base: '14px',
md: '15px',
lg: '18px',
},
fontFamily: "'Roboto', -apple-system, BlinkMacSystemFont, sans-serif",
};
// Component usage examples
export const usage = {
Box: `
<Box>
<BoxChild>
Content goes here
</BoxChild>
</Box>
`,
TabNav: `
<TabNav
tabs={[
{ id: 'general', label: 'General', icon: '⚙' },
{ id: 'settings', label: 'Settings', icon: '🔧' }
]}
activeTab="general"
onTabChange={(tabId) => console.log(tabId)}
/>
`,
Field: `
<Field
label="Field Label"
description="Field description"
required
>
<Input value={value} onChange={setValue} />
</Field>
`,
Button: `
<Button variant="primary" onClick={handleClick}>
Click Me
</Button>
`,
Repeater: `
<Repeater
items={items}
renderItem={(item, index) => <YourComponent item={item} />}
onAdd={handleAdd}
onRemove={handleRemove}
addLabel="Add Item"
/>
`,
Notice: `
<Notice type="success" title="Success!">
Your changes have been saved.
</Notice>
`,
Badge: `
<Badge variant="success">Active</Badge>
`,
};

View File

@@ -6,7 +6,59 @@ import { __ } from '@wordpress/i18n';
import DataTable from '../components/shared/DataTable';
import './AdminPages.css';
// SweetAlert2 is loaded via WordPress (global scope)
const Swal = window.Swal;
export default function AccessPage() {
const ajaxUrl = window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php';
const nonce = window.formipayAdmin?.nonce || '';
const handleDelete = async (id) => {
const result = await Swal.fire({
icon: 'info',
html: __('Do you want to delete this item?', 'formipay'),
showCancelButton: true,
confirmButtonText: __('Delete Permanently', 'formipay'),
cancelButtonText: __('Cancel', 'formipay'),
});
if (result.isConfirmed) {
await fetch(`${ajaxUrl}?action=formipay-delete-access-item`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
id,
_wpnonce: nonce,
}),
});
window.location.reload();
}
};
const handleDuplicate = async (id) => {
const result = await Swal.fire({
icon: 'info',
html: __('Do you want to duplicate this item?', 'formipay'),
showCancelButton: true,
confirmButtonText: __('Confirm', 'formipay'),
cancelButtonText: __('Cancel', 'formipay'),
});
if (result.isConfirmed) {
await fetch(`${ajaxUrl}?action=formipay-duplicate-access-item`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
id,
_wpnonce: nonce,
}),
});
window.location.reload();
}
};
const columns = [
{
key: 'ID',
@@ -16,6 +68,38 @@ export default function AccessPage() {
{
key: 'title',
label: __('Title', 'formipay'),
render: (row) => (
<>
<a href={`${window.formipayAdmin?.siteUrl || ''}/wp-admin/post.php?post=${row.ID}&action=edit`}>
<strong>{row.title || row.post_title || __('Untitled', 'formipay')}</strong>
</a>
<span className="row-actions">
<a href={`${window.formipayAdmin?.siteUrl || ''}/wp-admin/post.php?post=${row.ID}&action=edit`}>{__('edit', 'formipay')}</a>
{' | '}
<button
className="button-link delete"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDelete(row.ID);
}}
>
{__('delete', 'formipay')}
</button>
{' | '}
<button
className="button-link duplicate"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDuplicate(row.ID);
}}
>
{__('duplicate', 'formipay')}
</button>
</span>
</>
)
},
{
key: 'product_name',
@@ -50,10 +134,6 @@ export default function AccessPage() {
return (
<div className="formipay-page-access">
<div className="formipay-page-header">
<h1>{ __('Access Items', 'formipay') }</h1>
</div>
<DataTable
columns={columns}
ajaxUrl={window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php'}

View File

@@ -11,6 +11,55 @@
font-weight: 400;
}
/** Navigation Menu (App-level) **/
.formipay-navigation-menu {
margin-left: -20px;
padding: 12px 20px 12px 50px;
background-color: white;
display: flex;
gap: 2em;
align-items: center;
position: sticky;
top: 32px;
left: 0;
z-index: 1000;
box-shadow: 0 4px 12px #d2d2d2;
width: calc(100% + 20px);
margin-top: -10px;
}
.formipay-navigation-menu > img {
width: 48px;
height: 48px;
flex-shrink: 0;
}
.navigation-links {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.navigation-links .nav-link {
padding: 8px 16px;
text-decoration: none;
color: #646970;
border-radius: 4px;
transition: all 0.2s;
font-weight: 500;
font-size: 14px;
}
.navigation-links .nav-link:hover {
background-color: #f6f7f7;
color: #2271b1;
}
.navigation-links .nav-link.active {
background-color: #2271b1;
color: white;
}
.status-badge {
display: inline-block;
padding: 4px 8px;

View File

@@ -6,7 +6,59 @@ import { __ } from '@wordpress/i18n';
import DataTable from '../components/shared/DataTable';
import './AdminPages.css';
// SweetAlert2 is loaded via WordPress (global scope)
const Swal = window.Swal;
export default function CouponsPage() {
const ajaxUrl = window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php';
const nonce = window.formipayAdmin?.nonce || '';
const handleDelete = async (id) => {
const result = await Swal.fire({
icon: 'info',
html: __('Do you want to delete this item?', 'formipay'),
showCancelButton: true,
confirmButtonText: __('Delete Permanently', 'formipay'),
cancelButtonText: __('Cancel', 'formipay'),
});
if (result.isConfirmed) {
await fetch(`${ajaxUrl}?action=formipay-delete-coupon`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
id,
_wpnonce: nonce,
}),
});
window.location.reload();
}
};
const handleDuplicate = async (id) => {
const result = await Swal.fire({
icon: 'info',
html: __('Do you want to duplicate this item?', 'formipay'),
showCancelButton: true,
confirmButtonText: __('Confirm', 'formipay'),
cancelButtonText: __('Cancel', 'formipay'),
});
if (result.isConfirmed) {
await fetch(`${ajaxUrl}?action=formipay-duplicate-coupon`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
id,
_wpnonce: nonce,
}),
});
window.location.reload();
}
};
const columns = [
{
key: 'ID',
@@ -16,7 +68,36 @@ export default function CouponsPage() {
{
key: 'code',
label: __('Coupon Code', 'formipay'),
render: (row) => <strong>{row.code || row.post_title}</strong>
render: (row) => (
<>
<strong>{row.code || row.post_title}</strong>
<span className="row-actions">
<a href={`${window.formipayAdmin?.siteUrl || ''}/wp-admin/post.php?post=${row.ID}&action=edit`}>{__('edit', 'formipay')}</a>
{' | '}
<button
className="button-link delete"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDelete(row.ID);
}}
>
{__('delete', 'formipay')}
</button>
{' | '}
<button
className="button-link duplicate"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDuplicate(row.ID);
}}
>
{__('duplicate', 'formipay')}
</button>
</span>
</>
)
},
{
key: 'type',
@@ -30,8 +111,21 @@ export default function CouponsPage() {
key: 'amount',
label: __('Amount', 'formipay'),
render: (row) => {
// Multi-currency display would go here
return row.amount || '-';
const amount = row.amount;
// Handle multi-currency amounts (array of objects)
if (Array.isArray(amount)) {
return amount.map((a) => (
<div key={a.raw} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
{a.flag && <img src={a.flag} alt="" height="14" style={{ verticalAlign: 'middle' }} />}
<span>{a.amount}</span>
</div>
));
}
// Handle single currency amount (number or string)
if (typeof amount === 'number') {
return amount + '%';
}
return amount || '-';
}
},
{
@@ -65,10 +159,6 @@ export default function CouponsPage() {
return (
<div className="formipay-page-coupons">
<div className="formipay-page-header">
<h1>{ __('Coupons', 'formipay') }</h1>
</div>
<DataTable
columns={columns}
ajaxUrl={window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php'}

View File

@@ -9,14 +9,21 @@ import './AdminPages.css';
export default function CustomersPage() {
const columns = [
{
key: 'id',
key: 'ID',
label: __('ID', 'formipay'),
render: (row) => <strong>#{row.id}</strong>
render: (row) => <strong>#{row.ID}</strong>
},
{
key: 'name',
label: __('Name', 'formipay'),
render: (row) => row.name || row.full_name || '-'
render: (row) => (
<>
<strong>{row.name || row.full_name || '-'}</strong>
<span className="row-actions">
<a href={`${window.formipayAdmin?.siteUrl || ''}/wp-admin/admin.php?page=formipay-customers&customer_id=${row.ID}`}>{__('view', 'formipay')}</a>
</span>
</>
)
},
{
key: 'email',
@@ -32,23 +39,10 @@ export default function CustomersPage() {
label: __('Total Orders', 'formipay'),
render: (row) => row.total_order || row.total_orders || 0
},
{
key: 'created_date',
label: __('Date', 'formipay'),
render: (row) => {
const date = row.created_date || row.date;
if (!date) return '-';
return new Date(date).toLocaleDateString();
}
},
];
return (
<div className="formipay-page-customers">
<div className="formipay-page-header">
<h1>{ __('Customers', 'formipay') }</h1>
</div>
<DataTable
columns={columns}
ajaxUrl={window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php'}

View File

@@ -4,12 +4,60 @@
import { __ } from '@wordpress/i18n';
import DataTable from '../components/shared/DataTable';
import './AdminPages.css';
// SweetAlert2 is loaded via WordPress (global scope)
const Swal = window.Swal;
export default function FormsPage() {
const ajaxUrl = window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php';
const nonce = window.formipayAdmin?.nonce || '';
const handleDelete = async (id) => {
const result = await Swal.fire({
icon: 'info',
html: __('Do you want to delete this item?', 'formipay'),
showCancelButton: true,
confirmButtonText: __('Delete Permanently', 'formipay'),
cancelButtonText: __('Cancel', 'formipay'),
});
if (result.isConfirmed) {
await fetch(`${ajaxUrl}?action=formipay-delete-form`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
id,
_wpnonce: nonce,
}),
});
window.location.reload();
}
};
const handleDuplicate = async (id) => {
const result = await Swal.fire({
icon: 'info',
html: __('Do you want to duplicate this item?', 'formipay'),
showCancelButton: true,
confirmButtonText: __('Confirm', 'formipay'),
cancelButtonText: __('Cancel', 'formipay'),
});
if (result.isConfirmed) {
await fetch(`${ajaxUrl}?action=formipay-duplicate-form`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
id,
_wpnonce: nonce,
}),
});
window.location.reload();
}
};
const columns = [
{
key: 'ID',
@@ -20,9 +68,36 @@ export default function FormsPage() {
key: 'title',
label: __('Title', 'formipay'),
render: (row) => (
<a href={`${window.formipayAdmin?.siteUrl || ''}/wp-admin/post.php?post=${row.ID}&action=edit`}>
<strong>{row.title}</strong>
</a>
<>
<a href={`${window.formipayAdmin?.siteUrl || ''}/wp-admin/post.php?post=${row.ID}&action=edit`}>
<strong>{row.title}</strong>
</a>
<span className="row-actions">
<a href={`${window.formipayAdmin?.siteUrl || ''}/wp-admin/post.php?post=${row.ID}&action=edit`}>{__('edit', 'formipay')}</a>
{' | '}
<button
className="button-link delete"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDelete(row.ID);
}}
>
{__('delete', 'formipay')}
</button>
{' | '}
<button
className="button-link duplicate"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDuplicate(row.ID);
}}
>
{__('duplicate', 'formipay')}
</button>
</span>
</>
)
},
{
@@ -75,7 +150,7 @@ export default function FormsPage() {
const text = e.currentTarget.dataset.copy;
navigator.clipboard.writeText(text).then(() => {
const originalHTML = e.currentTarget.innerHTML;
e.currentTarget.innerHTML = 'Copied';
e.currentTarget.innerHTML = '[Copied]';
setTimeout(() => {
e.currentTarget.innerHTML = originalHTML;
}, 2000);
@@ -92,7 +167,7 @@ export default function FormsPage() {
});
}}
>
📋 {__('Copy', 'formipay')}
{__('Copy', 'formipay')}
</button>
</>
)
@@ -100,38 +175,32 @@ export default function FormsPage() {
];
return (
<div className="formipay-page-forms">
<div className="formipay-page-header">
<h1>{ __('Forms', 'formipay') }</h1>
</div>
<DataTable
columns={columns}
ajaxUrl={window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php'}
nonce={window.formipayAdmin?.nonce || ''}
tableAction="formipay-tabledata-forms"
deleteAction="formipay-delete-form"
duplicateAction="formipay-duplicate-form"
filterOptions={{
key: 'post_status',
options: [
{ value: 'all', label: __('All', 'formipay') },
{ value: 'publish', label: __('Published', 'formipay') },
{ value: 'draft', label: __('Draft', 'formipay') },
]
}}
actions={{
addNew: {
label: __('+ Add New Form', 'formipay'),
action: 'formipay-create-form-post',
},
bulkDelete: {
action: 'formipay-bulk-delete-form',
},
inline: true,
}}
emptyMessage={__('No forms found', 'formipay')}
/>
</div>
<DataTable
columns={columns}
ajaxUrl={window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php'}
nonce={window.formipayAdmin?.nonce || ''}
tableAction="formipay-tabledata-forms"
deleteAction="formipay-delete-form"
duplicateAction="formipay-duplicate-form"
filterOptions={{
key: 'post_status',
options: [
{ value: 'all', label: __('All', 'formipay') },
{ value: 'publish', label: __('Published', 'formipay') },
{ value: 'draft', label: __('Draft', 'formipay') },
]
}}
actions={{
addNew: {
label: __('+ Add New Form', 'formipay'),
action: 'formipay-create-form-post',
},
bulkDelete: {
action: 'formipay-bulk-delete-form',
},
inline: true,
}}
emptyMessage={__('No forms found', 'formipay')}
/>
);
}

View File

@@ -6,7 +6,36 @@ import { __ } from '@wordpress/i18n';
import DataTable from '../components/shared/DataTable';
import './AdminPages.css';
// SweetAlert2 is loaded via WordPress (global scope)
const Swal = window.Swal;
export default function LicensesPage() {
const ajaxUrl = window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php';
const nonce = window.formipayAdmin?.nonce || '';
const handleDelete = async (id) => {
const result = await Swal.fire({
icon: 'info',
html: __('Do you want to delete this item?', 'formipay'),
showCancelButton: true,
confirmButtonText: __('Delete Permanently', 'formipay'),
cancelButtonText: __('Cancel', 'formipay'),
});
if (result.isConfirmed) {
await fetch(`${ajaxUrl}?action=formipay-delete-license`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
id,
_wpnonce: nonce,
}),
});
window.location.reload();
}
};
const columns = [
{
key: 'id',
@@ -16,7 +45,23 @@ export default function LicensesPage() {
{
key: 'license_key',
label: __('License Key', 'formipay'),
render: (row) => <code>{row.license_key || '-'}</code>
render: (row) => (
<>
<code>{row.license_key || '-'}</code>
<span className="row-actions">
<button
className="button-link delete"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDelete(row.id);
}}
>
{__('delete', 'formipay')}
</button>
</span>
</>
)
},
{
key: 'product',
@@ -54,10 +99,6 @@ export default function LicensesPage() {
return (
<div className="formipay-page-licenses">
<div className="formipay-page-header">
<h1>{ __('Licenses', 'formipay') }</h1>
</div>
<DataTable
columns={columns}
ajaxUrl={window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php'}

View File

@@ -4,10 +4,10 @@
import { __ } from '@wordpress/i18n';
import { useState } from '@wordpress/element';
import OrderList from '../components/orders/OrderList';
import DataTable from '../components/shared/DataTable';
import OrderDetail from '../components/orders/OrderDetail';
export default function OrdersPage({ initialData }) {
export default function OrdersPage() {
const [selectedOrderId, setSelectedOrderId] = useState(null);
if (selectedOrderId) {
@@ -19,9 +19,85 @@ export default function OrdersPage({ initialData }) {
);
}
const columns = [
{
key: 'ID',
label: __('ID', 'formipay'),
render: (row) => <strong>#{row.id}</strong>
},
{
key: 'created_date',
label: __('Date', 'formipay'),
render: (row) => {
const date = row.created_date || row.date;
if (!date) return '-';
const d = new Date(date);
return d.toLocaleDateString();
}
},
{
key: 'customer',
label: __('Customer', 'formipay'),
render: (row) => {
// Extract customer info from form_data
if (row.form_data && Array.isArray(row.form_data)) {
const name = row.form_data.find(f => f.name === 'name')?.value;
const email = row.form_data.find(f => f.name === 'email')?.value;
return name || email || '-';
}
return '-';
}
},
{
key: 'total',
label: __('Total', 'formipay'),
render: (row) => row.total_formatted || row.total || '-'
},
{
key: 'status',
label: __('Status', 'formipay'),
render: (row) => {
const statusLabels = {
'on-hold': __('On Hold', 'formipay'),
'payment-confirm': __('Payment Confirmed', 'formipay'),
'in-progress': __('In Progress', 'formipay'),
'shipping': __('Shipping', 'formipay'),
'completed': __('Completed', 'formipay'),
'failed': __('Failed', 'formipay'),
'refunded': __('Refunded', 'formipay'),
'cancelled': __('Cancelled', 'formipay'),
};
const status = row.status || 'unknown';
return (
<span className={`status-label status-${status}`}>
{statusLabels[status] || status}
</span>
);
}
},
{
key: 'actions',
label: __('Actions', 'formipay'),
render: (row) => (
<button
className="button button-small"
onClick={() => setSelectedOrderId(row.id)}
>
{__('View', 'formipay')}
</button>
)
},
];
return (
<OrderList
onSelectOrder={(orderId) => setSelectedOrderId(orderId)}
<DataTable
columns={columns}
ajaxUrl={window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php'}
nonce={window.formipayAdmin?.nonce || ''}
tableAction="formipay-tabledata-orders"
selectable={false}
inline={false}
emptyMessage={__('No orders found', 'formipay')}
/>
);
}

View File

@@ -8,10 +8,62 @@ import DataTable from '../components/shared/DataTable';
import VariationPricingTable from '../components/products/VariationPricingTable';
import './AdminPages.css';
// SweetAlert2 is loaded via WordPress (global scope)
const Swal = window.Swal;
export default function ProductsPage() {
const [isEditor, setIsEditor] = useState(false);
const [selectedProductId, setSelectedProductId] = useState(null);
const ajaxUrl = window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php';
const nonce = window.formipayAdmin?.nonce || '';
const handleDelete = async (id) => {
const result = await Swal.fire({
icon: 'info',
html: __('Do you want to delete this item?', 'formipay'),
showCancelButton: true,
confirmButtonText: __('Delete Permanently', 'formipay'),
cancelButtonText: __('Cancel', 'formipay'),
});
if (result.isConfirmed) {
await fetch(`${ajaxUrl}?action=formipay-delete-product`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
id,
_wpnonce: nonce,
}),
});
window.location.reload();
}
};
const handleDuplicate = async (id) => {
const result = await Swal.fire({
icon: 'info',
html: __('Do you want to duplicate this item?', 'formipay'),
showCancelButton: true,
confirmButtonText: __('Confirm', 'formipay'),
cancelButtonText: __('Cancel', 'formipay'),
});
if (result.isConfirmed) {
await fetch(`${ajaxUrl}?action=formipay-duplicate-product`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
id,
_wpnonce: nonce,
}),
});
window.location.reload();
}
};
// If in editor mode, show the variation pricing table
if (isEditor && selectedProductId) {
return (
@@ -48,32 +100,33 @@ export default function ProductsPage() {
label: __('Title', 'formipay'),
render: (row) => (
<>
<strong>
<button
className="button-link"
onClick={() => {
setSelectedProductId(row.ID);
setIsEditor(true);
}}
>
{row.post_title || row.title || __('Untitled', 'formipay')}
</button>
</strong>
<br />
<span className="row-actions" style={{ display: 'none', visibility: 'hidden' }}>
<a href={`${window.formipayAdmin?.siteUrl || ''}/wp-admin/post.php?post=${row.ID}&action=edit`}>
<strong>{row.post_title || row.title || __('Untitled', 'formipay')}</strong>
</a>
<span className="row-actions">
<a href={`${window.formipayAdmin?.siteUrl || ''}/wp-admin/post.php?post=${row.ID}&action=edit`}>{__('edit', 'formipay')}</a>
{' | '}
<button className="button-link" onClick={() => {
setSelectedProductId(row.ID);
setIsEditor(true);
}}>
{__('Edit Variations', 'formipay')}
{__('edit variations', 'formipay')}
</button>
{' | '}
<button className="button-link delete" data-id={row.ID}>
{__('Delete', 'formipay')}
<button className="button-link delete" onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDelete(row.ID);
}}>
{__('delete', 'formipay')}
</button>
{' | '}
<button className="button-link duplicate" data-id={row.ID}>
{__('Duplicate', 'formipay')}
<button className="button-link duplicate" onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDuplicate(row.ID);
}}>
{__('duplicate', 'formipay')}
</button>
</span>
</>
@@ -83,16 +136,29 @@ export default function ProductsPage() {
key: 'price',
label: __('Price', 'formipay'),
render: (row) => {
// Multi-currency price display
const prices = row.prices || row.price;
if (typeof prices === 'object' && prices !== null) {
return Object.entries(prices).map(([currency, price]) => (
<div key={currency}>
{currency}: {price}
if (Array.isArray(prices) && prices.length > 0) {
return prices.map((p) => (
<div key={p.currency} style={{ display: 'flex', alignItems: 'center', gap: '4px', marginBottom: '2px' }}>
{p.flag && <img src={p.flag} alt="" height="14" style={{ verticalAlign: 'middle' }} />}
{p.has_sale ? (
<>
<span style={{ fontSize: '12px', textDecoration: 'line-through', opacity: '0.7', marginRight: '4px' }}>
{p.regular_price}
</span>
<span style={{ fontSize: '13px', fontWeight: '500' }}>
{p.sale_price}
</span>
</>
) : (
<span style={{ fontSize: '13px' }}>
{p.regular_price}
</span>
)}
</div>
));
}
return prices || '-';
return '-';
}
},
{
@@ -132,10 +198,6 @@ export default function ProductsPage() {
return (
<div className="formipay-page-products">
<div className="formipay-page-header">
<h1>{ __('Products', 'formipay') }</h1>
</div>
<DataTable
columns={columns}
ajaxUrl={window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php'}