feat: Tax settings + unified addon guide + Biteship spec
## 1. Created BITESHIP_ADDON_SPEC.md ✅ - Complete plugin specification - Database schema, API endpoints - WooCommerce integration - React components - Implementation timeline ## 2. Merged Addon Documentation ✅ Created ADDON_DEVELOPMENT_GUIDE.md (single source of truth): - Merged ADDON_INJECTION_GUIDE.md + ADDON_HOOK_SYSTEM.md - Two addon types: Route Injection + Hook System - Clear examples for each type - Best practices and troubleshooting - Deleted old documents ## 3. Tax Settings ✅ Frontend (admin-spa/src/routes/Settings/Tax.tsx): - Enable/disable tax calculation toggle - Display standard/reduced/zero tax rates - Show tax options (prices include tax, based on, display) - Link to WooCommerce for advanced config - Clean, simple UI Backend (includes/Api/TaxController.php): - GET /settings/tax - Fetch tax settings - POST /settings/tax/toggle - Enable/disable taxes - Fetches rates from woocommerce_tax_rates table - Clears WooCommerce cache on update ## 4. Advanced Local Pickup - TODO Will be simple: Admin adds multiple pickup locations ## Key Decisions: ✅ Hook system = No hardcoding, zero coupling ✅ Tax settings = Simple toggle + view, advanced in WC ✅ Single addon guide = One source of truth Next: Advanced Local Pickup locations
This commit is contained in:
715
ADDON_DEVELOPMENT_GUIDE.md
Normal file
715
ADDON_DEVELOPMENT_GUIDE.md
Normal file
@@ -0,0 +1,715 @@
|
|||||||
|
# WooNooW Addon Development Guide
|
||||||
|
|
||||||
|
**Version:** 2.0.0
|
||||||
|
**Last Updated:** November 9, 2025
|
||||||
|
**Status:** Production Ready
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Table of Contents
|
||||||
|
|
||||||
|
1. [Overview](#overview)
|
||||||
|
2. [Addon Types](#addon-types)
|
||||||
|
3. [Quick Start](#quick-start)
|
||||||
|
4. [SPA Route Injection](#spa-route-injection)
|
||||||
|
5. [Hook System Integration](#hook-system-integration)
|
||||||
|
6. [Component Development](#component-development)
|
||||||
|
7. [Best Practices](#best-practices)
|
||||||
|
8. [Examples](#examples)
|
||||||
|
9. [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
WooNooW provides **two powerful addon systems**:
|
||||||
|
|
||||||
|
### 1. **SPA Route Injection** (Admin UI)
|
||||||
|
- ✅ Register custom SPA routes
|
||||||
|
- ✅ Inject navigation menu items
|
||||||
|
- ✅ Add submenu items to existing sections
|
||||||
|
- ✅ Load React components dynamically
|
||||||
|
- ✅ Full isolation and safety
|
||||||
|
|
||||||
|
### 2. **Hook System** (Functional Extension)
|
||||||
|
- ✅ Extend OrderForm, ProductForm, etc.
|
||||||
|
- ✅ Add custom fields and validation
|
||||||
|
- ✅ Inject components at specific points
|
||||||
|
- ✅ Zero coupling with core
|
||||||
|
- ✅ WordPress-style filters and actions
|
||||||
|
|
||||||
|
**Both systems work together seamlessly!**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Addon Types
|
||||||
|
|
||||||
|
### Type A: UI-Only Addon (Route Injection)
|
||||||
|
**Use when:** Adding new pages/sections to admin
|
||||||
|
|
||||||
|
**Example:** Reports, Analytics, Custom Dashboard
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Registers routes + navigation
|
||||||
|
add_filter('woonoow/spa_routes', ...);
|
||||||
|
add_filter('woonoow/nav_tree', ...);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type B: Functional Addon (Hook System)
|
||||||
|
**Use when:** Extending existing functionality
|
||||||
|
|
||||||
|
**Example:** Indonesia Shipping, Custom Fields, Validation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Registers hooks
|
||||||
|
addFilter('woonoow_order_form_after_shipping', ...);
|
||||||
|
addAction('woonoow_order_created', ...);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type C: Full-Featured Addon (Both Systems)
|
||||||
|
**Use when:** Complex integration needed
|
||||||
|
|
||||||
|
**Example:** Subscriptions, Bookings, Memberships
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Backend: Routes + Hooks
|
||||||
|
add_filter('woonoow/spa_routes', ...);
|
||||||
|
add_filter('woonoow/nav_tree', ...);
|
||||||
|
|
||||||
|
// Frontend: Hook registration
|
||||||
|
addonLoader.register({
|
||||||
|
init: () => {
|
||||||
|
addFilter('woonoow_order_form_custom_sections', ...);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Step 1: Create Plugin File
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: My WooNooW Addon
|
||||||
|
* Description: Extends WooNooW functionality
|
||||||
|
* Version: 1.0.0
|
||||||
|
* Requires: WooNooW 1.0.0+
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 1. Register addon
|
||||||
|
add_filter('woonoow/addon_registry', function($addons) {
|
||||||
|
$addons['my-addon'] = [
|
||||||
|
'id' => 'my-addon',
|
||||||
|
'name' => 'My Addon',
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'spa_bundle' => plugin_dir_url(__FILE__) . 'dist/addon.js',
|
||||||
|
'dependencies' => ['woocommerce' => '8.0'],
|
||||||
|
];
|
||||||
|
return $addons;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Register routes (optional - for UI pages)
|
||||||
|
add_filter('woonoow/spa_routes', function($routes) {
|
||||||
|
$routes[] = [
|
||||||
|
'path' => '/my-addon',
|
||||||
|
'component_url' => plugin_dir_url(__FILE__) . 'dist/MyPage.js',
|
||||||
|
'capability' => 'manage_woocommerce',
|
||||||
|
'title' => 'My Addon',
|
||||||
|
];
|
||||||
|
return $routes;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Add navigation (optional - for UI pages)
|
||||||
|
add_filter('woonoow/nav_tree', function($tree) {
|
||||||
|
$tree[] = [
|
||||||
|
'key' => 'my-addon',
|
||||||
|
'label' => 'My Addon',
|
||||||
|
'path' => '/my-addon',
|
||||||
|
'icon' => 'puzzle',
|
||||||
|
];
|
||||||
|
return $tree;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Create Frontend Integration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// admin-spa/src/index.ts
|
||||||
|
|
||||||
|
import { addonLoader, addFilter } from '@woonoow/hooks';
|
||||||
|
|
||||||
|
addonLoader.register({
|
||||||
|
id: 'my-addon',
|
||||||
|
name: 'My Addon',
|
||||||
|
version: '1.0.0',
|
||||||
|
init: () => {
|
||||||
|
// Register hooks here
|
||||||
|
addFilter('woonoow_order_form_custom_sections', (content, formData, setFormData) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{content}
|
||||||
|
<MyCustomSection data={formData} onChange={setFormData} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
**Done!** Your addon is now integrated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SPA Route Injection
|
||||||
|
|
||||||
|
### Register Routes
|
||||||
|
|
||||||
|
```php
|
||||||
|
add_filter('woonoow/spa_routes', function($routes) {
|
||||||
|
$base_url = plugin_dir_url(__FILE__) . 'dist/';
|
||||||
|
|
||||||
|
$routes[] = [
|
||||||
|
'path' => '/subscriptions',
|
||||||
|
'component_url' => $base_url . 'SubscriptionsList.js',
|
||||||
|
'capability' => 'manage_woocommerce',
|
||||||
|
'title' => 'Subscriptions',
|
||||||
|
];
|
||||||
|
|
||||||
|
$routes[] = [
|
||||||
|
'path' => '/subscriptions/:id',
|
||||||
|
'component_url' => $base_url . 'SubscriptionDetail.js',
|
||||||
|
'capability' => 'manage_woocommerce',
|
||||||
|
'title' => 'Subscription Detail',
|
||||||
|
];
|
||||||
|
|
||||||
|
return $routes;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add Navigation
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Main menu item
|
||||||
|
add_filter('woonoow/nav_tree', function($tree) {
|
||||||
|
$tree[] = [
|
||||||
|
'key' => 'subscriptions',
|
||||||
|
'label' => __('Subscriptions', 'my-addon'),
|
||||||
|
'path' => '/subscriptions',
|
||||||
|
'icon' => 'repeat',
|
||||||
|
'children' => [
|
||||||
|
[
|
||||||
|
'label' => __('All Subscriptions', 'my-addon'),
|
||||||
|
'mode' => 'spa',
|
||||||
|
'path' => '/subscriptions',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => __('New', 'my-addon'),
|
||||||
|
'mode' => 'spa',
|
||||||
|
'path' => '/subscriptions/new',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
return $tree;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Or inject into existing section
|
||||||
|
add_filter('woonoow/nav_tree/products/children', function($children) {
|
||||||
|
$children[] = [
|
||||||
|
'label' => __('Bundles', 'my-addon'),
|
||||||
|
'mode' => 'spa',
|
||||||
|
'path' => '/products/bundles',
|
||||||
|
];
|
||||||
|
return $children;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hook System Integration
|
||||||
|
|
||||||
|
### Available Hooks
|
||||||
|
|
||||||
|
#### Order Form Hooks
|
||||||
|
```typescript
|
||||||
|
// Add fields after billing address
|
||||||
|
'woonoow_order_form_after_billing'
|
||||||
|
|
||||||
|
// Add fields after shipping address
|
||||||
|
'woonoow_order_form_after_shipping'
|
||||||
|
|
||||||
|
// Add custom shipping fields
|
||||||
|
'woonoow_order_form_shipping_fields'
|
||||||
|
|
||||||
|
// Add custom sections
|
||||||
|
'woonoow_order_form_custom_sections'
|
||||||
|
|
||||||
|
// Add validation rules
|
||||||
|
'woonoow_order_form_validation'
|
||||||
|
|
||||||
|
// Modify form data before render
|
||||||
|
'woonoow_order_form_data'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Action Hooks
|
||||||
|
```typescript
|
||||||
|
// Before form submission
|
||||||
|
'woonoow_order_form_submit'
|
||||||
|
|
||||||
|
// After order created
|
||||||
|
'woonoow_order_created'
|
||||||
|
|
||||||
|
// After order updated
|
||||||
|
'woonoow_order_updated'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hook Registration Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { addonLoader, addFilter, addAction } from '@woonoow/hooks';
|
||||||
|
|
||||||
|
addonLoader.register({
|
||||||
|
id: 'indonesia-shipping',
|
||||||
|
name: 'Indonesia Shipping',
|
||||||
|
version: '1.0.0',
|
||||||
|
init: () => {
|
||||||
|
// Filter: Add subdistrict selector
|
||||||
|
addFilter('woonoow_order_form_after_shipping', (content, formData, setFormData) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{content}
|
||||||
|
<SubdistrictSelector
|
||||||
|
value={formData.shipping?.subdistrict_id}
|
||||||
|
onChange={(id) => setFormData({
|
||||||
|
...formData,
|
||||||
|
shipping: { ...formData.shipping, subdistrict_id: id }
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter: Add validation
|
||||||
|
addFilter('woonoow_order_form_validation', (errors, formData) => {
|
||||||
|
if (!formData.shipping?.subdistrict_id) {
|
||||||
|
errors.subdistrict = 'Subdistrict is required';
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Action: Log when order created
|
||||||
|
addAction('woonoow_order_created', (orderId, orderData) => {
|
||||||
|
console.log('Order created:', orderId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hook System Benefits
|
||||||
|
|
||||||
|
✅ **Zero Coupling**
|
||||||
|
```typescript
|
||||||
|
// WooNooW Core has no knowledge of your addon
|
||||||
|
{applyFilters('woonoow_order_form_after_shipping', null, formData, setFormData)}
|
||||||
|
|
||||||
|
// If addon exists: Returns your component
|
||||||
|
// If addon doesn't exist: Returns null
|
||||||
|
// No import, no error!
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ **Multiple Addons Can Hook**
|
||||||
|
```typescript
|
||||||
|
// Addon A
|
||||||
|
addFilter('woonoow_order_form_after_shipping', (content) => {
|
||||||
|
return <>{content}<AddonAFields /></>;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Addon B
|
||||||
|
addFilter('woonoow_order_form_after_shipping', (content) => {
|
||||||
|
return <>{content}<AddonBFields /></>;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Both render!
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ **Type Safety**
|
||||||
|
```typescript
|
||||||
|
addFilter<ReactNode, [OrderFormData, SetState<OrderFormData>]>(
|
||||||
|
'woonoow_order_form_after_shipping',
|
||||||
|
(content, formData, setFormData) => {
|
||||||
|
// TypeScript knows the types!
|
||||||
|
return <MyComponent />;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Development
|
||||||
|
|
||||||
|
### Basic Component
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// dist/MyPage.tsx
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function MyPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="rounded-lg border p-6 bg-card">
|
||||||
|
<h2 className="text-xl font-semibold mb-2">My Addon</h2>
|
||||||
|
<p className="text-sm opacity-70">Welcome!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Access WooNooW APIs
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Access REST API
|
||||||
|
const api = (window as any).WNW_API;
|
||||||
|
const response = await fetch(`${api.root}my-addon/endpoint`, {
|
||||||
|
headers: { 'X-WP-Nonce': api.nonce },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Access store data
|
||||||
|
const store = (window as any).WNW_STORE;
|
||||||
|
console.log('Currency:', store.currency);
|
||||||
|
|
||||||
|
// Access site info
|
||||||
|
const wnw = (window as any).wnw;
|
||||||
|
console.log('Site Title:', wnw.siteTitle);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Use WooNooW Components
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { __ } from '@/lib/i18n';
|
||||||
|
import { formatMoney } from '@/lib/currency';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
|
||||||
|
export default function MyPage() {
|
||||||
|
return (
|
||||||
|
<Card className="p-6">
|
||||||
|
<h2>{__('My Addon', 'my-addon')}</h2>
|
||||||
|
<p>{formatMoney(1234.56)}</p>
|
||||||
|
<Button>{__('Click Me', 'my-addon')}</Button>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build Configuration
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// vite.config.js
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: 'src/index.ts',
|
||||||
|
name: 'MyAddon',
|
||||||
|
fileName: 'addon',
|
||||||
|
formats: ['es'],
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['react', 'react-dom'],
|
||||||
|
output: {
|
||||||
|
globals: {
|
||||||
|
react: 'React',
|
||||||
|
'react-dom': 'ReactDOM',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### ✅ DO:
|
||||||
|
|
||||||
|
1. **Use Hook System for Functional Extensions**
|
||||||
|
```typescript
|
||||||
|
// ✅ Good - No hardcoding
|
||||||
|
addFilter('woonoow_order_form_after_shipping', ...);
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Use Route Injection for New Pages**
|
||||||
|
```php
|
||||||
|
// ✅ Good - Separate UI
|
||||||
|
add_filter('woonoow/spa_routes', ...);
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Declare Dependencies**
|
||||||
|
```php
|
||||||
|
'dependencies' => ['woocommerce' => '8.0']
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Check Capabilities**
|
||||||
|
```php
|
||||||
|
'capability' => 'manage_woocommerce'
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Internationalize Strings**
|
||||||
|
```php
|
||||||
|
'label' => __('My Addon', 'my-addon')
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Handle Errors Gracefully**
|
||||||
|
```typescript
|
||||||
|
try {
|
||||||
|
await api.post(...);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to save');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ DON'T:
|
||||||
|
|
||||||
|
1. **Don't Hardcode Addon Components in Core**
|
||||||
|
```typescript
|
||||||
|
// ❌ Bad - Breaks if addon not installed
|
||||||
|
import { SubdistrictSelector } from 'addon';
|
||||||
|
<SubdistrictSelector />
|
||||||
|
|
||||||
|
// ✅ Good - Use hooks
|
||||||
|
{applyFilters('woonoow_order_form_after_shipping', null)}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Don't Skip Capability Checks**
|
||||||
|
```php
|
||||||
|
// ❌ Bad
|
||||||
|
'capability' => ''
|
||||||
|
|
||||||
|
// ✅ Good
|
||||||
|
'capability' => 'manage_woocommerce'
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Don't Modify Core Navigation**
|
||||||
|
```php
|
||||||
|
// ❌ Bad
|
||||||
|
unset($tree[0]);
|
||||||
|
|
||||||
|
// ✅ Good
|
||||||
|
$tree[] = ['key' => 'my-addon', ...];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Example 1: Simple UI Addon (Route Injection Only)
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: WooNooW Reports
|
||||||
|
* Description: Custom reports page
|
||||||
|
*/
|
||||||
|
|
||||||
|
add_filter('woonoow/addon_registry', function($addons) {
|
||||||
|
$addons['reports'] = [
|
||||||
|
'id' => 'reports',
|
||||||
|
'name' => 'Reports',
|
||||||
|
'version' => '1.0.0',
|
||||||
|
];
|
||||||
|
return $addons;
|
||||||
|
});
|
||||||
|
|
||||||
|
add_filter('woonoow/spa_routes', function($routes) {
|
||||||
|
$routes[] = [
|
||||||
|
'path' => '/reports',
|
||||||
|
'component_url' => plugin_dir_url(__FILE__) . 'dist/Reports.js',
|
||||||
|
'title' => 'Reports',
|
||||||
|
];
|
||||||
|
return $routes;
|
||||||
|
});
|
||||||
|
|
||||||
|
add_filter('woonoow/nav_tree', function($tree) {
|
||||||
|
$tree[] = [
|
||||||
|
'key' => 'reports',
|
||||||
|
'label' => 'Reports',
|
||||||
|
'path' => '/reports',
|
||||||
|
'icon' => 'bar-chart',
|
||||||
|
];
|
||||||
|
return $tree;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 2: Functional Addon (Hook System Only)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Indonesia Shipping - No UI pages, just extends OrderForm
|
||||||
|
|
||||||
|
import { addonLoader, addFilter } from '@woonoow/hooks';
|
||||||
|
import { SubdistrictSelector } from './components/SubdistrictSelector';
|
||||||
|
|
||||||
|
addonLoader.register({
|
||||||
|
id: 'indonesia-shipping',
|
||||||
|
name: 'Indonesia Shipping',
|
||||||
|
version: '1.0.0',
|
||||||
|
init: () => {
|
||||||
|
addFilter('woonoow_order_form_after_shipping', (content, formData, setFormData) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{content}
|
||||||
|
<div className="border rounded-lg p-4 mt-4">
|
||||||
|
<h3 className="font-medium mb-3">📍 Shipping Destination</h3>
|
||||||
|
<SubdistrictSelector
|
||||||
|
value={formData.shipping?.subdistrict_id}
|
||||||
|
onChange={(id) => setFormData({
|
||||||
|
...formData,
|
||||||
|
shipping: { ...formData.shipping, subdistrict_id: id }
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 3: Full-Featured Addon (Both Systems)
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: WooNooW Subscriptions
|
||||||
|
* Description: Subscription management
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Backend: Register addon + routes
|
||||||
|
add_filter('woonoow/addon_registry', function($addons) {
|
||||||
|
$addons['subscriptions'] = [
|
||||||
|
'id' => 'subscriptions',
|
||||||
|
'name' => 'Subscriptions',
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'spa_bundle' => plugin_dir_url(__FILE__) . 'dist/addon.js',
|
||||||
|
];
|
||||||
|
return $addons;
|
||||||
|
});
|
||||||
|
|
||||||
|
add_filter('woonoow/spa_routes', function($routes) {
|
||||||
|
$routes[] = [
|
||||||
|
'path' => '/subscriptions',
|
||||||
|
'component_url' => plugin_dir_url(__FILE__) . 'dist/SubscriptionsList.js',
|
||||||
|
];
|
||||||
|
return $routes;
|
||||||
|
});
|
||||||
|
|
||||||
|
add_filter('woonoow/nav_tree', function($tree) {
|
||||||
|
$tree[] = [
|
||||||
|
'key' => 'subscriptions',
|
||||||
|
'label' => 'Subscriptions',
|
||||||
|
'path' => '/subscriptions',
|
||||||
|
'icon' => 'repeat',
|
||||||
|
];
|
||||||
|
return $tree;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Frontend: Hook integration
|
||||||
|
|
||||||
|
import { addonLoader, addFilter } from '@woonoow/hooks';
|
||||||
|
|
||||||
|
addonLoader.register({
|
||||||
|
id: 'subscriptions',
|
||||||
|
name: 'Subscriptions',
|
||||||
|
version: '1.0.0',
|
||||||
|
init: () => {
|
||||||
|
// Add subscription fields to order form
|
||||||
|
addFilter('woonoow_order_form_custom_sections', (content, formData, setFormData) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{content}
|
||||||
|
<SubscriptionOptions data={formData} onChange={setFormData} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add subscription fields to product form
|
||||||
|
addFilter('woonoow_product_form_fields', (content, formData, setFormData) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{content}
|
||||||
|
<SubscriptionSettings data={formData} onChange={setFormData} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Addon Not Appearing?
|
||||||
|
- Check dependencies are met
|
||||||
|
- Verify capability requirements
|
||||||
|
- Check browser console for errors
|
||||||
|
- Flush caches: `?flush_wnw_cache=1`
|
||||||
|
|
||||||
|
### Route Not Loading?
|
||||||
|
- Verify `component_url` is correct
|
||||||
|
- Check file exists and is accessible
|
||||||
|
- Look for JS errors in console
|
||||||
|
- Ensure component exports `default`
|
||||||
|
|
||||||
|
### Hook Not Firing?
|
||||||
|
- Check hook name is correct
|
||||||
|
- Verify addon is registered
|
||||||
|
- Check `window.WNW_ADDONS` in console
|
||||||
|
- Ensure `init()` function runs
|
||||||
|
|
||||||
|
### Component Not Rendering?
|
||||||
|
- Check for React errors in console
|
||||||
|
- Verify component returns valid JSX
|
||||||
|
- Check props are passed correctly
|
||||||
|
- Test component in isolation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support & Resources
|
||||||
|
|
||||||
|
**Documentation:**
|
||||||
|
- `ADDON_INJECTION_GUIDE.md` - SPA route injection (legacy)
|
||||||
|
- `ADDON_HOOK_SYSTEM.md` - Hook system details (legacy)
|
||||||
|
- `BITESHIP_ADDON_SPEC.md` - Indonesia shipping example
|
||||||
|
- `SHIPPING_ADDON_RESEARCH.md` - Shipping integration patterns
|
||||||
|
|
||||||
|
**Code References:**
|
||||||
|
- `includes/Compat/AddonRegistry.php` - Addon registration
|
||||||
|
- `includes/Compat/RouteRegistry.php` - Route management
|
||||||
|
- `includes/Compat/NavigationRegistry.php` - Navigation building
|
||||||
|
- `admin-spa/src/lib/hooks.ts` - Hook system implementation
|
||||||
|
- `admin-spa/src/App.tsx` - Dynamic route loading
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**End of Guide**
|
||||||
|
|
||||||
|
**Version:** 2.0.0
|
||||||
|
**Last Updated:** November 9, 2025
|
||||||
|
**Status:** ✅ Production Ready
|
||||||
|
|
||||||
|
**This is the single source of truth for WooNooW addon development.**
|
||||||
@@ -1,579 +0,0 @@
|
|||||||
# WooNooW Addon Hook System
|
|
||||||
|
|
||||||
## Problem Statement
|
|
||||||
|
|
||||||
**Question:** How can WooNooW SPA support addons without hardcoding specific components?
|
|
||||||
|
|
||||||
**Example of WRONG approach:**
|
|
||||||
```typescript
|
|
||||||
// ❌ This is hardcoding - breaks if addon doesn't exist
|
|
||||||
import { SubdistrictSelector } from 'woonoow-indonesia-shipping';
|
|
||||||
|
|
||||||
<OrderForm>
|
|
||||||
<SubdistrictSelector /> {/* ❌ Error if plugin not installed */}
|
|
||||||
</OrderForm>
|
|
||||||
```
|
|
||||||
|
|
||||||
**This is "supporting specific 3rd party addons" - exactly what we want to AVOID!**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## **The Solution: WordPress-Style Hook System in React**
|
|
||||||
|
|
||||||
### **Architecture Overview**
|
|
||||||
|
|
||||||
```
|
|
||||||
WooNooW Core (Base):
|
|
||||||
- Provides hook points
|
|
||||||
- Renders whatever addons register
|
|
||||||
- No knowledge of specific addons
|
|
||||||
|
|
||||||
Addon Plugins:
|
|
||||||
- Register components via hooks
|
|
||||||
- Only loaded if plugin is active
|
|
||||||
- Self-contained functionality
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## **Implementation**
|
|
||||||
|
|
||||||
### **Step 1: Create Hook System in WooNooW Core**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// admin-spa/src/lib/hooks.ts
|
|
||||||
|
|
||||||
type HookCallback = (...args: any[]) => any;
|
|
||||||
|
|
||||||
class HookSystem {
|
|
||||||
private filters: Map<string, HookCallback[]> = new Map();
|
|
||||||
private actions: Map<string, HookCallback[]> = new Map();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a filter hook
|
|
||||||
* Similar to WordPress add_filter()
|
|
||||||
*/
|
|
||||||
addFilter(hookName: string, callback: HookCallback, priority: number = 10) {
|
|
||||||
if (!this.filters.has(hookName)) {
|
|
||||||
this.filters.set(hookName, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
const hooks = this.filters.get(hookName)!;
|
|
||||||
hooks.push({ callback, priority });
|
|
||||||
hooks.sort((a, b) => a.priority - b.priority);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply filters
|
|
||||||
* Similar to WordPress apply_filters()
|
|
||||||
*/
|
|
||||||
applyFilters(hookName: string, value: any, ...args: any[]): any {
|
|
||||||
const hooks = this.filters.get(hookName) || [];
|
|
||||||
|
|
||||||
return hooks.reduce((currentValue, { callback }) => {
|
|
||||||
return callback(currentValue, ...args);
|
|
||||||
}, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add an action hook
|
|
||||||
* Similar to WordPress add_action()
|
|
||||||
*/
|
|
||||||
addAction(hookName: string, callback: HookCallback, priority: number = 10) {
|
|
||||||
if (!this.actions.has(hookName)) {
|
|
||||||
this.actions.set(hookName, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
const hooks = this.actions.get(hookName)!;
|
|
||||||
hooks.push({ callback, priority });
|
|
||||||
hooks.sort((a, b) => a.priority - b.priority);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do action
|
|
||||||
* Similar to WordPress do_action()
|
|
||||||
*/
|
|
||||||
doAction(hookName: string, ...args: any[]) {
|
|
||||||
const hooks = this.actions.get(hookName) || [];
|
|
||||||
hooks.forEach(({ callback }) => callback(...args));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export singleton instance
|
|
||||||
export const hooks = new HookSystem();
|
|
||||||
|
|
||||||
// Export helper functions
|
|
||||||
export const addFilter = hooks.addFilter.bind(hooks);
|
|
||||||
export const applyFilters = hooks.applyFilters.bind(hooks);
|
|
||||||
export const addAction = hooks.addAction.bind(hooks);
|
|
||||||
export const doAction = hooks.doAction.bind(hooks);
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Step 2: Add Hook Points in OrderForm.tsx**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// admin-spa/src/routes/Orders/OrderForm.tsx
|
|
||||||
import { applyFilters, doAction } from '@/lib/hooks';
|
|
||||||
|
|
||||||
export function OrderForm() {
|
|
||||||
const [formData, setFormData] = useState(initialData);
|
|
||||||
|
|
||||||
// Hook: Allow addons to modify form data
|
|
||||||
const processedFormData = applyFilters('woonoow_order_form_data', formData);
|
|
||||||
|
|
||||||
// Hook: Allow addons to add validation
|
|
||||||
const validateForm = () => {
|
|
||||||
let errors = {};
|
|
||||||
|
|
||||||
// Core validation
|
|
||||||
if (!formData.customer_id) {
|
|
||||||
errors.customer_id = 'Customer is required';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hook: Let addons add their validation
|
|
||||||
errors = applyFilters('woonoow_order_form_validation', errors, formData);
|
|
||||||
|
|
||||||
return errors;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
{/* Customer Section */}
|
|
||||||
<CustomerSection data={formData.customer} onChange={handleCustomerChange} />
|
|
||||||
|
|
||||||
{/* Billing Address */}
|
|
||||||
<AddressSection
|
|
||||||
type="billing"
|
|
||||||
data={formData.billing}
|
|
||||||
onChange={handleBillingChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Hook: Allow addons to inject fields after billing */}
|
|
||||||
{applyFilters('woonoow_order_form_after_billing', null, formData, setFormData)}
|
|
||||||
|
|
||||||
{/* Shipping Address */}
|
|
||||||
<AddressSection
|
|
||||||
type="shipping"
|
|
||||||
data={formData.shipping}
|
|
||||||
onChange={handleShippingChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Hook: Allow addons to inject fields after shipping */}
|
|
||||||
{applyFilters('woonoow_order_form_after_shipping', null, formData, setFormData)}
|
|
||||||
|
|
||||||
{/* Shipping Method Selection */}
|
|
||||||
<ShippingMethodSection>
|
|
||||||
{/* Core shipping method selector */}
|
|
||||||
<Select
|
|
||||||
label="Shipping Method"
|
|
||||||
options={shippingMethods}
|
|
||||||
value={formData.shipping_method}
|
|
||||||
onChange={(value) => setFormData({...formData, shipping_method: value})}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Hook: Allow addons to add custom shipping fields */}
|
|
||||||
{applyFilters('woonoow_order_form_shipping_fields', null, formData, setFormData)}
|
|
||||||
</ShippingMethodSection>
|
|
||||||
|
|
||||||
{/* Products */}
|
|
||||||
<ProductsSection data={formData.line_items} onChange={handleProductsChange} />
|
|
||||||
|
|
||||||
{/* Hook: Allow addons to add custom sections */}
|
|
||||||
{applyFilters('woonoow_order_form_custom_sections', null, formData, setFormData)}
|
|
||||||
|
|
||||||
{/* Submit */}
|
|
||||||
<Button type="submit">Create Order</Button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Step 3: Addon Registration System**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// admin-spa/src/lib/addon-loader.ts
|
|
||||||
|
|
||||||
interface AddonConfig {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
version: string;
|
|
||||||
init: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
class AddonLoader {
|
|
||||||
private addons: Map<string, AddonConfig> = new Map();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register an addon
|
|
||||||
*/
|
|
||||||
register(config: AddonConfig) {
|
|
||||||
if (this.addons.has(config.id)) {
|
|
||||||
console.warn(`Addon ${config.id} is already registered`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.addons.set(config.id, config);
|
|
||||||
|
|
||||||
// Initialize the addon
|
|
||||||
config.init();
|
|
||||||
|
|
||||||
console.log(`✅ Addon registered: ${config.name} v${config.version}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if addon is registered
|
|
||||||
*/
|
|
||||||
isRegistered(addonId: string): boolean {
|
|
||||||
return this.addons.has(addonId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all registered addons
|
|
||||||
*/
|
|
||||||
getAll(): AddonConfig[] {
|
|
||||||
return Array.from(this.addons.values());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const addonLoader = new AddonLoader();
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Step 4: Load Addons from Backend**
|
|
||||||
|
|
||||||
```php
|
|
||||||
// includes/Core/AddonRegistry.php
|
|
||||||
|
|
||||||
namespace WooNooW\Core;
|
|
||||||
|
|
||||||
class AddonRegistry {
|
|
||||||
private static $addons = array();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register an addon
|
|
||||||
*/
|
|
||||||
public static function register($addon_id, $addon_config) {
|
|
||||||
self::$addons[$addon_id] = $addon_config;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all registered addons
|
|
||||||
*/
|
|
||||||
public static function get_all() {
|
|
||||||
return self::$addons;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enqueue addon scripts
|
|
||||||
*/
|
|
||||||
public static function enqueue_addon_scripts() {
|
|
||||||
foreach (self::$addons as $addon_id => $config) {
|
|
||||||
if (isset($config['script_url'])) {
|
|
||||||
wp_enqueue_script(
|
|
||||||
'woonoow-addon-' . $addon_id,
|
|
||||||
$config['script_url'],
|
|
||||||
array('woonoow-admin-spa'),
|
|
||||||
$config['version'],
|
|
||||||
true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pass addon data to frontend
|
|
||||||
*/
|
|
||||||
public static function get_addon_data() {
|
|
||||||
return array(
|
|
||||||
'addons' => self::$addons,
|
|
||||||
'active_addons' => array_keys(self::$addons)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hook to enqueue addon scripts
|
|
||||||
add_action('admin_enqueue_scripts', array('WooNooW\Core\AddonRegistry', 'enqueue_addon_scripts'));
|
|
||||||
```
|
|
||||||
|
|
||||||
```php
|
|
||||||
// includes/Core/Bootstrap.php
|
|
||||||
|
|
||||||
// Add addon data to WNW_CONFIG
|
|
||||||
add_filter('woonoow_admin_config', function($config) {
|
|
||||||
$config['addons'] = \WooNooW\Core\AddonRegistry::get_addon_data();
|
|
||||||
return $config;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## **Example: Indonesia Shipping Addon**
|
|
||||||
|
|
||||||
### **Addon Plugin Structure**
|
|
||||||
|
|
||||||
```
|
|
||||||
woonoow-indonesia-shipping/
|
|
||||||
├── woonoow-indonesia-shipping.php
|
|
||||||
├── includes/
|
|
||||||
│ └── class-addon-integration.php
|
|
||||||
└── admin-spa/
|
|
||||||
├── src/
|
|
||||||
│ ├── components/
|
|
||||||
│ │ ├── SubdistrictSelector.tsx
|
|
||||||
│ │ └── CourierSelector.tsx
|
|
||||||
│ └── index.ts
|
|
||||||
└── dist/
|
|
||||||
└── addon.js (built file)
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Addon Main File**
|
|
||||||
|
|
||||||
```php
|
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* Plugin Name: WooNooW Indonesia Shipping
|
|
||||||
* Description: Indonesian shipping integration for WooNooW
|
|
||||||
* Version: 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Register with WooNooW
|
|
||||||
add_action('woonoow_loaded', function() {
|
|
||||||
\WooNooW\Core\AddonRegistry::register('indonesia-shipping', array(
|
|
||||||
'name' => 'Indonesia Shipping',
|
|
||||||
'version' => '1.0.0',
|
|
||||||
'script_url' => plugin_dir_url(__FILE__) . 'admin-spa/dist/addon.js',
|
|
||||||
'has_settings' => true,
|
|
||||||
'settings_url' => admin_url('admin.php?page=woonoow-indonesia-shipping')
|
|
||||||
));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add REST API endpoints
|
|
||||||
add_action('rest_api_init', function() {
|
|
||||||
register_rest_route('woonoow/v1', '/indonesia-shipping/provinces', array(
|
|
||||||
'methods' => 'GET',
|
|
||||||
'callback' => 'get_provinces',
|
|
||||||
'permission_callback' => function() {
|
|
||||||
return current_user_can('manage_woocommerce');
|
|
||||||
}
|
|
||||||
));
|
|
||||||
|
|
||||||
// ... more endpoints
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Addon Frontend Integration**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// woonoow-indonesia-shipping/admin-spa/src/index.ts
|
|
||||||
|
|
||||||
import { addonLoader, addFilter } from '@woonoow/hooks';
|
|
||||||
import { SubdistrictSelector } from './components/SubdistrictSelector';
|
|
||||||
import { CourierSelector } from './components/CourierSelector';
|
|
||||||
|
|
||||||
// Register addon
|
|
||||||
addonLoader.register({
|
|
||||||
id: 'indonesia-shipping',
|
|
||||||
name: 'Indonesia Shipping',
|
|
||||||
version: '1.0.0',
|
|
||||||
init: () => {
|
|
||||||
console.log('🇮🇩 Indonesia Shipping addon loaded');
|
|
||||||
|
|
||||||
// Hook: Add subdistrict field after shipping address
|
|
||||||
addFilter('woonoow_order_form_after_shipping', (content, formData, setFormData) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{content}
|
|
||||||
<SubdistrictSelector
|
|
||||||
value={formData.shipping?.subdistrict_id}
|
|
||||||
onChange={(subdistrictId) => {
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
shipping: {
|
|
||||||
...formData.shipping,
|
|
||||||
subdistrict_id: subdistrictId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Hook: Add courier selector in shipping section
|
|
||||||
addFilter('woonoow_order_form_shipping_fields', (content, formData, setFormData) => {
|
|
||||||
// Only show if subdistrict is selected
|
|
||||||
if (!formData.shipping?.subdistrict_id) {
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{content}
|
|
||||||
<CourierSelector
|
|
||||||
originSubdistrictId={formData.origin_subdistrict_id}
|
|
||||||
destinationSubdistrictId={formData.shipping.subdistrict_id}
|
|
||||||
weight={calculateTotalWeight(formData.line_items)}
|
|
||||||
onSelect={(courier) => {
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
shipping_method: courier.id,
|
|
||||||
shipping_cost: courier.cost
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Hook: Add validation
|
|
||||||
addFilter('woonoow_order_form_validation', (errors, formData) => {
|
|
||||||
if (!formData.shipping?.subdistrict_id) {
|
|
||||||
errors.subdistrict = 'Subdistrict is required for Indonesian shipping';
|
|
||||||
}
|
|
||||||
return errors;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## **How This Works**
|
|
||||||
|
|
||||||
### **Scenario 1: Addon is Installed**
|
|
||||||
|
|
||||||
1. WordPress loads `woonoow-indonesia-shipping.php`
|
|
||||||
2. Addon registers with `AddonRegistry`
|
|
||||||
3. WooNooW enqueues addon script (`addon.js`)
|
|
||||||
4. Addon script runs `addonLoader.register()`
|
|
||||||
5. Addon hooks are registered
|
|
||||||
6. OrderForm renders → hooks fire → addon components appear
|
|
||||||
|
|
||||||
**Result:** ✅ Subdistrict selector appears in order form
|
|
||||||
|
|
||||||
### **Scenario 2: Addon is NOT Installed**
|
|
||||||
|
|
||||||
1. No addon plugin loaded
|
|
||||||
2. No addon script enqueued
|
|
||||||
3. No hooks registered
|
|
||||||
4. OrderForm renders → hooks fire → return `null`
|
|
||||||
|
|
||||||
**Result:** ✅ No error, form works normally without addon fields
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## **Key Differences from Hardcoding**
|
|
||||||
|
|
||||||
### ❌ **Hardcoding (WRONG)**
|
|
||||||
```typescript
|
|
||||||
// This breaks if addon doesn't exist
|
|
||||||
import { SubdistrictSelector } from 'woonoow-indonesia-shipping';
|
|
||||||
|
|
||||||
<OrderForm>
|
|
||||||
<SubdistrictSelector /> {/* ❌ Import error if plugin not installed */}
|
|
||||||
</OrderForm>
|
|
||||||
```
|
|
||||||
|
|
||||||
### ✅ **Hook System (CORRECT)**
|
|
||||||
```typescript
|
|
||||||
// This works whether addon exists or not
|
|
||||||
{applyFilters('woonoow_order_form_after_shipping', null, formData, setFormData)}
|
|
||||||
|
|
||||||
// If addon exists: Returns <SubdistrictSelector />
|
|
||||||
// If addon doesn't exist: Returns null
|
|
||||||
// No import, no error!
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## **Benefits of Hook System**
|
|
||||||
|
|
||||||
### 1. **Zero Coupling**
|
|
||||||
- WooNooW Core has no knowledge of specific addons
|
|
||||||
- Addons can be installed/uninstalled without breaking core
|
|
||||||
- No hardcoded dependencies
|
|
||||||
|
|
||||||
### 2. **Extensibility**
|
|
||||||
- Any developer can create addons
|
|
||||||
- Multiple addons can hook into same points
|
|
||||||
- Addons can interact with each other
|
|
||||||
|
|
||||||
### 3. **WordPress-Like**
|
|
||||||
- Familiar pattern for WordPress developers
|
|
||||||
- Easy to understand and use
|
|
||||||
- Well-tested architecture
|
|
||||||
|
|
||||||
### 4. **Type Safety** (with TypeScript)
|
|
||||||
```typescript
|
|
||||||
// Define hook types
|
|
||||||
interface OrderFormData {
|
|
||||||
customer_id: number;
|
|
||||||
billing: Address;
|
|
||||||
shipping: Address & { subdistrict_id?: string };
|
|
||||||
line_items: LineItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type-safe hooks
|
|
||||||
addFilter<ReactNode, [OrderFormData, SetState<OrderFormData>]>(
|
|
||||||
'woonoow_order_form_after_shipping',
|
|
||||||
(content, formData, setFormData) => {
|
|
||||||
// TypeScript knows the types!
|
|
||||||
return <SubdistrictSelector />;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## **Available Hook Points**
|
|
||||||
|
|
||||||
### **Order Form Hooks**
|
|
||||||
```typescript
|
|
||||||
// Filter hooks (modify/add content)
|
|
||||||
'woonoow_order_form_data' // Modify form data before render
|
|
||||||
'woonoow_order_form_after_billing' // Add fields after billing address
|
|
||||||
'woonoow_order_form_after_shipping' // Add fields after shipping address
|
|
||||||
'woonoow_order_form_shipping_fields' // Add custom shipping fields
|
|
||||||
'woonoow_order_form_custom_sections' // Add custom sections
|
|
||||||
'woonoow_order_form_validation' // Add validation rules
|
|
||||||
|
|
||||||
// Action hooks (trigger events)
|
|
||||||
'woonoow_order_form_submit' // Before form submission
|
|
||||||
'woonoow_order_created' // After order created
|
|
||||||
'woonoow_order_updated' // After order updated
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Settings Hooks**
|
|
||||||
```typescript
|
|
||||||
'woonoow_settings_tabs' // Add custom settings tabs
|
|
||||||
'woonoow_settings_sections' // Add settings sections
|
|
||||||
'woonoow_shipping_method_settings' // Modify shipping method settings
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Product Hooks**
|
|
||||||
```typescript
|
|
||||||
'woonoow_product_form_fields' // Add custom product fields
|
|
||||||
'woonoow_product_meta_boxes' // Add meta boxes
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## **Conclusion**
|
|
||||||
|
|
||||||
### **This is NOT "supporting specific 3rd party addons"**
|
|
||||||
|
|
||||||
✅ **This IS:**
|
|
||||||
- Providing a hook system
|
|
||||||
- Letting addons register themselves
|
|
||||||
- Rendering whatever addons provide
|
|
||||||
- Zero knowledge of specific addons
|
|
||||||
|
|
||||||
❌ **This is NOT:**
|
|
||||||
- Hardcoding addon components
|
|
||||||
- Importing addon modules
|
|
||||||
- Having addon-specific logic in core
|
|
||||||
|
|
||||||
### **Result:**
|
|
||||||
- WooNooW Core remains universal
|
|
||||||
- Addons can extend functionality
|
|
||||||
- No breaking changes if addon is removed
|
|
||||||
- Perfect separation of concerns! 🎯
|
|
||||||
@@ -1,726 +0,0 @@
|
|||||||
# WooNooW Addon Injection Guide
|
|
||||||
|
|
||||||
**Version:** 1.0.0
|
|
||||||
**Last Updated:** 2025-10-28
|
|
||||||
**Status:** Production Ready
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Table of Contents
|
|
||||||
|
|
||||||
1. [Overview](#overview)
|
|
||||||
2. [Admin SPA Addons](#admin-spa-addons)
|
|
||||||
- [Quick Start](#quick-start)
|
|
||||||
- [Addon Registration](#addon-registration)
|
|
||||||
- [Route Registration](#route-registration)
|
|
||||||
- [Navigation Injection](#navigation-injection)
|
|
||||||
- [Component Development](#component-development)
|
|
||||||
- [Best Practices](#best-practices)
|
|
||||||
3. [Customer SPA Addons](#customer-spa-addons) *(Coming Soon)*
|
|
||||||
4. [Testing & Debugging](#testing--debugging)
|
|
||||||
5. [Examples](#examples)
|
|
||||||
6. [Troubleshooting](#troubleshooting)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
WooNooW provides a **powerful addon injection system** that allows third-party plugins to seamlessly integrate with the React-powered admin SPA. Addons can:
|
|
||||||
|
|
||||||
- ✅ Register custom SPA routes
|
|
||||||
- ✅ Inject navigation menu items
|
|
||||||
- ✅ Add submenu items to existing sections
|
|
||||||
- ✅ Load React components dynamically
|
|
||||||
- ✅ Declare dependencies and capabilities
|
|
||||||
- ✅ Maintain full isolation and safety
|
|
||||||
|
|
||||||
**No iframes, no hacks, just clean React integration!**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Admin SPA Addons
|
|
||||||
|
|
||||||
### Quick Start
|
|
||||||
|
|
||||||
**5-Minute Integration:**
|
|
||||||
|
|
||||||
```php
|
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* Plugin Name: My WooNooW Addon
|
|
||||||
* Description: Adds custom functionality to WooNooW
|
|
||||||
* Version: 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 1. Register your addon
|
|
||||||
add_filter('woonoow/addon_registry', function($addons) {
|
|
||||||
$addons['my-addon'] = [
|
|
||||||
'id' => 'my-addon',
|
|
||||||
'name' => 'My Addon',
|
|
||||||
'version' => '1.0.0',
|
|
||||||
'author' => 'Your Name',
|
|
||||||
'description' => 'My awesome addon',
|
|
||||||
'spa_bundle' => plugin_dir_url(__FILE__) . 'dist/addon.js',
|
|
||||||
'dependencies' => ['woocommerce' => '8.0'],
|
|
||||||
];
|
|
||||||
return $addons;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Register your routes
|
|
||||||
add_filter('woonoow/spa_routes', function($routes) {
|
|
||||||
$routes[] = [
|
|
||||||
'path' => '/my-addon',
|
|
||||||
'component_url' => plugin_dir_url(__FILE__) . 'dist/MyAddonPage.js',
|
|
||||||
'capability' => 'manage_woocommerce',
|
|
||||||
'title' => 'My Addon',
|
|
||||||
];
|
|
||||||
return $routes;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Add navigation item
|
|
||||||
add_filter('woonoow/nav_tree', function($tree) {
|
|
||||||
$tree[] = [
|
|
||||||
'key' => 'my-addon',
|
|
||||||
'label' => 'My Addon',
|
|
||||||
'path' => '/my-addon',
|
|
||||||
'icon' => 'puzzle', // lucide icon name
|
|
||||||
'children' => [],
|
|
||||||
];
|
|
||||||
return $tree;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**That's it!** Your addon is now integrated into WooNooW.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Addon Registration
|
|
||||||
|
|
||||||
**Filter:** `woonoow/addon_registry`
|
|
||||||
**Priority:** 20 (runs on `plugins_loaded`)
|
|
||||||
**File:** `includes/Compat/AddonRegistry.php`
|
|
||||||
|
|
||||||
#### Configuration Schema
|
|
||||||
|
|
||||||
```php
|
|
||||||
add_filter('woonoow/addon_registry', function($addons) {
|
|
||||||
$addons['addon-id'] = [
|
|
||||||
// Required
|
|
||||||
'id' => 'addon-id', // Unique identifier
|
|
||||||
'name' => 'Addon Name', // Display name
|
|
||||||
'version' => '1.0.0', // Semantic version
|
|
||||||
|
|
||||||
// Optional
|
|
||||||
'author' => 'Author Name', // Author name
|
|
||||||
'description' => 'Description', // Short description
|
|
||||||
'spa_bundle' => 'https://...', // Main JS bundle URL
|
|
||||||
|
|
||||||
// Dependencies (optional)
|
|
||||||
'dependencies' => [
|
|
||||||
'woocommerce' => '8.0', // Min WooCommerce version
|
|
||||||
'wordpress' => '6.0', // Min WordPress version
|
|
||||||
],
|
|
||||||
|
|
||||||
// Advanced (optional)
|
|
||||||
'routes' => [], // Route definitions
|
|
||||||
'nav_items' => [], // Nav item definitions
|
|
||||||
'widgets' => [], // Widget definitions
|
|
||||||
];
|
|
||||||
return $addons;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Dependency Validation
|
|
||||||
|
|
||||||
WooNooW automatically validates dependencies:
|
|
||||||
|
|
||||||
```php
|
|
||||||
'dependencies' => [
|
|
||||||
'woocommerce' => '8.0', // Requires WooCommerce 8.0+
|
|
||||||
'wordpress' => '6.4', // Requires WordPress 6.4+
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
If dependencies are not met:
|
|
||||||
- ❌ Addon is disabled automatically
|
|
||||||
- ❌ Routes are not registered
|
|
||||||
- ❌ Navigation items are hidden
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Route Registration
|
|
||||||
|
|
||||||
**Filter:** `woonoow/spa_routes`
|
|
||||||
**Priority:** 25 (runs on `plugins_loaded`)
|
|
||||||
**File:** `includes/Compat/RouteRegistry.php`
|
|
||||||
|
|
||||||
#### Basic Route
|
|
||||||
|
|
||||||
```php
|
|
||||||
add_filter('woonoow/spa_routes', function($routes) {
|
|
||||||
$routes[] = [
|
|
||||||
'path' => '/subscriptions',
|
|
||||||
'component_url' => plugin_dir_url(__FILE__) . 'dist/SubscriptionsList.js',
|
|
||||||
'capability' => 'manage_woocommerce',
|
|
||||||
'title' => 'Subscriptions',
|
|
||||||
];
|
|
||||||
return $routes;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Multiple Routes
|
|
||||||
|
|
||||||
```php
|
|
||||||
add_filter('woonoow/spa_routes', function($routes) {
|
|
||||||
$base_url = plugin_dir_url(__FILE__) . 'dist/';
|
|
||||||
|
|
||||||
$routes[] = [
|
|
||||||
'path' => '/subscriptions',
|
|
||||||
'component_url' => $base_url . 'SubscriptionsList.js',
|
|
||||||
'capability' => 'manage_woocommerce',
|
|
||||||
'title' => 'All Subscriptions',
|
|
||||||
];
|
|
||||||
|
|
||||||
$routes[] = [
|
|
||||||
'path' => '/subscriptions/new',
|
|
||||||
'component_url' => $base_url . 'SubscriptionNew.js',
|
|
||||||
'capability' => 'manage_woocommerce',
|
|
||||||
'title' => 'New Subscription',
|
|
||||||
];
|
|
||||||
|
|
||||||
$routes[] = [
|
|
||||||
'path' => '/subscriptions/:id',
|
|
||||||
'component_url' => $base_url . 'SubscriptionDetail.js',
|
|
||||||
'capability' => 'manage_woocommerce',
|
|
||||||
'title' => 'Subscription Detail',
|
|
||||||
];
|
|
||||||
|
|
||||||
return $routes;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Route Configuration
|
|
||||||
|
|
||||||
| Property | Type | Required | Description |
|
|
||||||
|----------|------|----------|-------------|
|
|
||||||
| `path` | string | ✅ Yes | Route path (must start with `/`) |
|
|
||||||
| `component_url` | string | ✅ Yes | URL to React component JS file |
|
|
||||||
| `capability` | string | No | WordPress capability (default: `manage_woocommerce`) |
|
|
||||||
| `title` | string | No | Page title |
|
|
||||||
| `exact` | boolean | No | Exact path match (default: `false`) |
|
|
||||||
| `props` | object | No | Props to pass to component |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Navigation Injection
|
|
||||||
|
|
||||||
#### Add Main Menu Item
|
|
||||||
|
|
||||||
**Filter:** `woonoow/nav_tree`
|
|
||||||
**Priority:** 30 (runs on `plugins_loaded`)
|
|
||||||
**File:** `includes/Compat/NavigationRegistry.php`
|
|
||||||
|
|
||||||
```php
|
|
||||||
add_filter('woonoow/nav_tree', function($tree) {
|
|
||||||
$tree[] = [
|
|
||||||
'key' => 'subscriptions',
|
|
||||||
'label' => __('Subscriptions', 'my-addon'),
|
|
||||||
'path' => '/subscriptions',
|
|
||||||
'icon' => 'repeat', // lucide-react icon name
|
|
||||||
'children' => [
|
|
||||||
[
|
|
||||||
'label' => __('All Subscriptions', 'my-addon'),
|
|
||||||
'mode' => 'spa',
|
|
||||||
'path' => '/subscriptions',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'label' => __('New', 'my-addon'),
|
|
||||||
'mode' => 'spa',
|
|
||||||
'path' => '/subscriptions/new',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
return $tree;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Inject into Existing Section
|
|
||||||
|
|
||||||
**Filter:** `woonoow/nav_tree/{key}/children`
|
|
||||||
|
|
||||||
```php
|
|
||||||
// Add "Bundles" to Products menu
|
|
||||||
add_filter('woonoow/nav_tree/products/children', function($children) {
|
|
||||||
$children[] = [
|
|
||||||
'label' => __('Bundles', 'my-addon'),
|
|
||||||
'mode' => 'spa',
|
|
||||||
'path' => '/products/bundles',
|
|
||||||
];
|
|
||||||
return $children;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add "Reports" to Dashboard menu
|
|
||||||
add_filter('woonoow/nav_tree/dashboard/children', function($children) {
|
|
||||||
$children[] = [
|
|
||||||
'label' => __('Custom Reports', 'my-addon'),
|
|
||||||
'mode' => 'spa',
|
|
||||||
'path' => '/reports',
|
|
||||||
];
|
|
||||||
return $children;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Available Sections
|
|
||||||
|
|
||||||
| Key | Label | Path |
|
|
||||||
|-----|-------|------|
|
|
||||||
| `dashboard` | Dashboard | `/` |
|
|
||||||
| `orders` | Orders | `/orders` |
|
|
||||||
| `products` | Products | `/products` |
|
|
||||||
| `coupons` | Coupons | `/coupons` |
|
|
||||||
| `customers` | Customers | `/customers` |
|
|
||||||
| `settings` | Settings | `/settings` |
|
|
||||||
|
|
||||||
#### Navigation Item Schema
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
key: string; // Unique key (for main items)
|
|
||||||
label: string; // Display label (i18n recommended)
|
|
||||||
path: string; // Route path
|
|
||||||
icon?: string; // Lucide icon name (main items only)
|
|
||||||
mode: 'spa' | 'bridge'; // Render mode
|
|
||||||
href?: string; // External URL (bridge mode)
|
|
||||||
exact?: boolean; // Exact path match
|
|
||||||
children?: SubItem[]; // Submenu items
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Lucide Icons
|
|
||||||
|
|
||||||
WooNooW uses [lucide-react](https://lucide.dev/) icons (16-20px, 1.5px stroke).
|
|
||||||
|
|
||||||
**Popular icons:**
|
|
||||||
- `layout-dashboard` - Dashboard
|
|
||||||
- `receipt-text` - Orders
|
|
||||||
- `package` - Products
|
|
||||||
- `tag` - Coupons
|
|
||||||
- `users` - Customers
|
|
||||||
- `settings` - Settings
|
|
||||||
- `repeat` - Subscriptions
|
|
||||||
- `calendar` - Bookings
|
|
||||||
- `credit-card` - Payments
|
|
||||||
- `bar-chart` - Analytics
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Component Development
|
|
||||||
|
|
||||||
#### Component Structure
|
|
||||||
|
|
||||||
Your React component will be dynamically imported and rendered:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// dist/MyAddonPage.tsx
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function MyAddonPage(props: any) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="rounded-lg border border-border p-6 bg-card">
|
|
||||||
<h2 className="text-xl font-semibold mb-2">My Addon</h2>
|
|
||||||
<p className="text-sm opacity-70">Welcome to my addon!</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Access WooNooW APIs
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Access REST API
|
|
||||||
const api = (window as any).WNW_API;
|
|
||||||
const response = await fetch(`${api.root}my-addon/endpoint`, {
|
|
||||||
headers: {
|
|
||||||
'X-WP-Nonce': api.nonce,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Access store data
|
|
||||||
const store = (window as any).WNW_STORE;
|
|
||||||
console.log('Currency:', store.currency);
|
|
||||||
console.log('Symbol:', store.currency_symbol);
|
|
||||||
|
|
||||||
// Access site info
|
|
||||||
const wnw = (window as any).wnw;
|
|
||||||
console.log('Site Title:', wnw.siteTitle);
|
|
||||||
console.log('Admin URL:', wnw.adminUrl);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Use WooNooW Components
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { __ } from '@/lib/i18n';
|
|
||||||
import { formatMoney } from '@/lib/currency';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card } from '@/components/ui/card';
|
|
||||||
|
|
||||||
export default function MyAddonPage() {
|
|
||||||
return (
|
|
||||||
<Card className="p-6">
|
|
||||||
<h2>{__('My Addon', 'my-addon')}</h2>
|
|
||||||
<p>{formatMoney(1234.56)}</p>
|
|
||||||
<Button>{__('Click Me', 'my-addon')}</Button>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Build Your Component
|
|
||||||
|
|
||||||
**Using Vite:**
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// vite.config.js
|
|
||||||
import { defineConfig } from 'vite';
|
|
||||||
import react from '@vitejs/plugin-react';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
build: {
|
|
||||||
lib: {
|
|
||||||
entry: 'src/MyAddonPage.tsx',
|
|
||||||
name: 'MyAddon',
|
|
||||||
fileName: 'MyAddonPage',
|
|
||||||
formats: ['es'],
|
|
||||||
},
|
|
||||||
rollupOptions: {
|
|
||||||
external: ['react', 'react-dom'],
|
|
||||||
output: {
|
|
||||||
globals: {
|
|
||||||
react: 'React',
|
|
||||||
'react-dom': 'ReactDOM',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Best Practices
|
|
||||||
|
|
||||||
#### ✅ DO:
|
|
||||||
|
|
||||||
1. **Use Semantic Versioning**
|
|
||||||
```php
|
|
||||||
'version' => '1.2.3'
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Declare Dependencies**
|
|
||||||
```php
|
|
||||||
'dependencies' => ['woocommerce' => '8.0']
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Check Capabilities**
|
|
||||||
```php
|
|
||||||
'capability' => 'manage_woocommerce'
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Internationalize Strings**
|
|
||||||
```php
|
|
||||||
'label' => __('Subscriptions', 'my-addon')
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Use Namespaced Hooks**
|
|
||||||
```php
|
|
||||||
add_filter('woonoow/addon_registry', ...)
|
|
||||||
```
|
|
||||||
|
|
||||||
6. **Validate User Input**
|
|
||||||
```php
|
|
||||||
$value = sanitize_text_field($_POST['value']);
|
|
||||||
```
|
|
||||||
|
|
||||||
7. **Handle Errors Gracefully**
|
|
||||||
```typescript
|
|
||||||
try {
|
|
||||||
// Load component
|
|
||||||
} catch (error) {
|
|
||||||
// Show error message
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
8. **Follow WooNooW UI Patterns**
|
|
||||||
- Use Tailwind CSS classes
|
|
||||||
- Use Shadcn UI components
|
|
||||||
- Follow mobile-first design
|
|
||||||
- Use `.ui-ctrl` class for controls
|
|
||||||
|
|
||||||
#### ❌ DON'T:
|
|
||||||
|
|
||||||
1. **Don't Hardcode URLs**
|
|
||||||
```php
|
|
||||||
// ❌ Bad
|
|
||||||
'component_url' => 'https://mysite.com/addon.js'
|
|
||||||
|
|
||||||
// ✅ Good
|
|
||||||
'component_url' => plugin_dir_url(__FILE__) . 'dist/addon.js'
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Don't Skip Capability Checks**
|
|
||||||
```php
|
|
||||||
// ❌ Bad
|
|
||||||
'capability' => ''
|
|
||||||
|
|
||||||
// ✅ Good
|
|
||||||
'capability' => 'manage_woocommerce'
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Don't Use Generic Hook Names**
|
|
||||||
```php
|
|
||||||
// ❌ Bad
|
|
||||||
add_filter('addon_registry', ...)
|
|
||||||
|
|
||||||
// ✅ Good
|
|
||||||
add_filter('woonoow/addon_registry', ...)
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Don't Modify Core Navigation**
|
|
||||||
```php
|
|
||||||
// ❌ Bad - Don't remove core items
|
|
||||||
unset($tree[0]);
|
|
||||||
|
|
||||||
// ✅ Good - Add your own items
|
|
||||||
$tree[] = ['key' => 'my-addon', ...];
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Don't Block the Main Thread**
|
|
||||||
```typescript
|
|
||||||
// ❌ Bad
|
|
||||||
while (loading) { /* wait */ }
|
|
||||||
|
|
||||||
// ✅ Good
|
|
||||||
if (loading) return <Loader />;
|
|
||||||
```
|
|
||||||
|
|
||||||
6. **Don't Use Inline Styles**
|
|
||||||
```typescript
|
|
||||||
// ❌ Bad
|
|
||||||
<div style={{color: 'red'}}>
|
|
||||||
|
|
||||||
// ✅ Good
|
|
||||||
<div className="text-red-600">
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Customer SPA Addons
|
|
||||||
|
|
||||||
**Status:** 🚧 Coming Soon
|
|
||||||
|
|
||||||
Customer SPA addon injection will support:
|
|
||||||
- Cart page customization
|
|
||||||
- Checkout step injection
|
|
||||||
- My Account page tabs
|
|
||||||
- Widget areas
|
|
||||||
- Custom forms
|
|
||||||
|
|
||||||
**Stay tuned for updates!**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing & Debugging
|
|
||||||
|
|
||||||
### Enable Debug Mode
|
|
||||||
|
|
||||||
```php
|
|
||||||
// wp-config.php
|
|
||||||
define('WNW_DEV', true);
|
|
||||||
```
|
|
||||||
|
|
||||||
This enables:
|
|
||||||
- ✅ Console logging
|
|
||||||
- ✅ Cache flushing
|
|
||||||
- ✅ Detailed error messages
|
|
||||||
|
|
||||||
### Check Addon Registration
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Browser console
|
|
||||||
console.log(window.WNW_ADDONS);
|
|
||||||
console.log(window.WNW_ADDON_ROUTES);
|
|
||||||
console.log(window.WNW_NAV_TREE);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Flush Caches
|
|
||||||
|
|
||||||
```php
|
|
||||||
// Programmatically
|
|
||||||
do_action('woonoow_flush_caches');
|
|
||||||
|
|
||||||
// Or via URL (admins only)
|
|
||||||
// https://yoursite.com/wp-admin/?flush_wnw_cache=1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
**Addon not appearing?**
|
|
||||||
- Check dependencies are met
|
|
||||||
- Verify capability requirements
|
|
||||||
- Check browser console for errors
|
|
||||||
- Flush caches
|
|
||||||
|
|
||||||
**Route not loading?**
|
|
||||||
- Verify `component_url` is correct
|
|
||||||
- Check file exists and is accessible
|
|
||||||
- Look for JS errors in console
|
|
||||||
- Ensure component exports default
|
|
||||||
|
|
||||||
**Navigation not showing?**
|
|
||||||
- Check filter priority
|
|
||||||
- Verify path matches route
|
|
||||||
- Check i18n strings load
|
|
||||||
- Inspect `window.WNW_NAV_TREE`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### Example 1: Simple Addon
|
|
||||||
|
|
||||||
```php
|
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* Plugin Name: WooNooW Hello World
|
|
||||||
* Description: Minimal addon example
|
|
||||||
* Version: 1.0.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
add_filter('woonoow/addon_registry', function($addons) {
|
|
||||||
$addons['hello-world'] = [
|
|
||||||
'id' => 'hello-world',
|
|
||||||
'name' => 'Hello World',
|
|
||||||
'version' => '1.0.0',
|
|
||||||
];
|
|
||||||
return $addons;
|
|
||||||
});
|
|
||||||
|
|
||||||
add_filter('woonoow/spa_routes', function($routes) {
|
|
||||||
$routes[] = [
|
|
||||||
'path' => '/hello',
|
|
||||||
'component_url' => plugin_dir_url(__FILE__) . 'dist/Hello.js',
|
|
||||||
'capability' => 'read', // All logged-in users
|
|
||||||
'title' => 'Hello World',
|
|
||||||
];
|
|
||||||
return $routes;
|
|
||||||
});
|
|
||||||
|
|
||||||
add_filter('woonoow/nav_tree', function($tree) {
|
|
||||||
$tree[] = [
|
|
||||||
'key' => 'hello',
|
|
||||||
'label' => 'Hello',
|
|
||||||
'path' => '/hello',
|
|
||||||
'icon' => 'smile',
|
|
||||||
'children' => [],
|
|
||||||
];
|
|
||||||
return $tree;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// dist/Hello.tsx
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function Hello() {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<h1 className="text-2xl font-bold">Hello, WooNooW!</h1>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example 2: Full-Featured Addon
|
|
||||||
|
|
||||||
See `ADDON_INJECTION_READINESS_REPORT.md` for the complete Subscriptions addon example.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Addon Registry Issues
|
|
||||||
|
|
||||||
**Problem:** Addon not registered
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
1. Check `plugins_loaded` hook fires
|
|
||||||
2. Verify filter name: `woonoow/addon_registry`
|
|
||||||
3. Check dependencies are met
|
|
||||||
4. Look for PHP errors in debug log
|
|
||||||
|
|
||||||
### Route Issues
|
|
||||||
|
|
||||||
**Problem:** Route returns 404
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
1. Verify path starts with `/`
|
|
||||||
2. Check `component_url` is accessible
|
|
||||||
3. Ensure route is registered before navigation
|
|
||||||
4. Check capability requirements
|
|
||||||
|
|
||||||
### Navigation Issues
|
|
||||||
|
|
||||||
**Problem:** Menu item not showing
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
1. Check filter: `woonoow/nav_tree` or `woonoow/nav_tree/{key}/children`
|
|
||||||
2. Verify path matches registered route
|
|
||||||
3. Check i18n strings are loaded
|
|
||||||
4. Inspect `window.WNW_NAV_TREE` in console
|
|
||||||
|
|
||||||
### Component Loading Issues
|
|
||||||
|
|
||||||
**Problem:** Component fails to load
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
1. Check component exports `default`
|
|
||||||
2. Verify file is built correctly
|
|
||||||
3. Check for JS errors in console
|
|
||||||
4. Ensure React/ReactDOM are available
|
|
||||||
5. Test component URL directly in browser
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Support & Resources
|
|
||||||
|
|
||||||
**Documentation:**
|
|
||||||
- `ADDON_INJECTION_READINESS_REPORT.md` - Technical analysis
|
|
||||||
- `ADDONS_ADMIN_UI_REQUIREMENTS.md` - Requirements & status
|
|
||||||
- `PROGRESS_NOTE.md` - Development progress
|
|
||||||
|
|
||||||
**Code References:**
|
|
||||||
- `includes/Compat/AddonRegistry.php` - Addon registration
|
|
||||||
- `includes/Compat/RouteRegistry.php` - Route management
|
|
||||||
- `includes/Compat/NavigationRegistry.php` - Navigation building
|
|
||||||
- `admin-spa/src/App.tsx` - Dynamic route loading
|
|
||||||
- `admin-spa/src/nav/tree.ts` - Navigation tree
|
|
||||||
|
|
||||||
**Community:**
|
|
||||||
- GitHub Issues: Report bugs
|
|
||||||
- Discussions: Ask questions
|
|
||||||
- Examples: Share your addons
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**End of Guide**
|
|
||||||
|
|
||||||
**Version:** 1.0.0
|
|
||||||
**Last Updated:** 2025-10-28
|
|
||||||
**Status:** ✅ Production Ready
|
|
||||||
260
BITESHIP_ADDON_SPEC.md
Normal file
260
BITESHIP_ADDON_SPEC.md
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
# WooNooW Indonesia Shipping (Biteship Integration)
|
||||||
|
|
||||||
|
## Plugin Specification
|
||||||
|
|
||||||
|
**Plugin Name:** WooNooW Indonesia Shipping
|
||||||
|
**Description:** Simple Indonesian shipping integration using Biteship Rate API
|
||||||
|
**Version:** 1.0.0
|
||||||
|
**Requires:** WooNooW 1.0.0+, WooCommerce 8.0+
|
||||||
|
**License:** GPL v2 or later
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A lightweight shipping plugin that integrates Biteship's Rate API with WooNooW SPA, providing:
|
||||||
|
- ✅ Indonesian address fields (Province, City, District, Subdistrict)
|
||||||
|
- ✅ Real-time shipping rate calculation
|
||||||
|
- ✅ Multiple courier support (JNE, SiCepat, J&T, AnterAja, etc.)
|
||||||
|
- ✅ Works in both frontend checkout AND admin order form
|
||||||
|
- ✅ No subscription required (uses free Biteship Rate API)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features Roadmap
|
||||||
|
|
||||||
|
### Phase 1: Core Functionality
|
||||||
|
- [ ] WooCommerce Shipping Method integration
|
||||||
|
- [ ] Biteship Rate API integration
|
||||||
|
- [ ] Indonesian address database (Province → Subdistrict)
|
||||||
|
- [ ] Frontend checkout integration
|
||||||
|
- [ ] Admin settings page
|
||||||
|
|
||||||
|
### Phase 2: SPA Integration
|
||||||
|
- [ ] REST API endpoints for address data
|
||||||
|
- [ ] REST API for rate calculation
|
||||||
|
- [ ] React components (SubdistrictSelector, CourierSelector)
|
||||||
|
- [ ] Hook integration with WooNooW OrderForm
|
||||||
|
- [ ] Admin order form support
|
||||||
|
|
||||||
|
### Phase 3: Advanced Features
|
||||||
|
- [ ] Rate caching (reduce API calls)
|
||||||
|
- [ ] Custom rate markup
|
||||||
|
- [ ] Free shipping threshold
|
||||||
|
- [ ] Multi-origin support
|
||||||
|
- [ ] Shipping label generation (optional, requires paid Biteship plan)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plugin Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
woonoow-indonesia-shipping/
|
||||||
|
├── woonoow-indonesia-shipping.php # Main plugin file
|
||||||
|
├── includes/
|
||||||
|
│ ├── class-shipping-method.php # WooCommerce shipping method
|
||||||
|
│ ├── class-biteship-api.php # Biteship API client
|
||||||
|
│ ├── class-address-database.php # Indonesian address data
|
||||||
|
│ ├── class-addon-integration.php # WooNooW addon integration
|
||||||
|
│ └── Api/
|
||||||
|
│ └── AddressController.php # REST API endpoints
|
||||||
|
├── admin/
|
||||||
|
│ ├── class-settings.php # Admin settings page
|
||||||
|
│ └── views/
|
||||||
|
│ └── settings-page.php # Settings UI
|
||||||
|
├── admin-spa/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ ├── SubdistrictSelector.tsx # Address selector
|
||||||
|
│ │ │ └── CourierSelector.tsx # Courier selection
|
||||||
|
│ │ ├── hooks/
|
||||||
|
│ │ │ ├── useAddressData.ts # Fetch address data
|
||||||
|
│ │ │ └── useRateCalculation.ts # Calculate rates
|
||||||
|
│ │ └── index.ts # Addon registration
|
||||||
|
│ ├── package.json
|
||||||
|
│ └── vite.config.ts
|
||||||
|
├── data/
|
||||||
|
│ └── indonesia-areas.sql # Address database dump
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE `wp_woonoow_indonesia_areas` (
|
||||||
|
`id` bigint(20) NOT NULL AUTO_INCREMENT,
|
||||||
|
`biteship_area_id` varchar(50) NOT NULL,
|
||||||
|
`name` varchar(255) NOT NULL,
|
||||||
|
`type` enum('province','city','district','subdistrict') NOT NULL,
|
||||||
|
`parent_id` bigint(20) DEFAULT NULL,
|
||||||
|
`postal_code` varchar(10) DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `biteship_area_id` (`biteship_area_id`),
|
||||||
|
KEY `parent_id` (`parent_id`),
|
||||||
|
KEY `type` (`type`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WooCommerce Shipping Method
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
// includes/class-shipping-method.php
|
||||||
|
|
||||||
|
class WooNooW_Indonesia_Shipping_Method extends WC_Shipping_Method {
|
||||||
|
|
||||||
|
public function __construct($instance_id = 0) {
|
||||||
|
$this->id = 'woonoow_indonesia_shipping';
|
||||||
|
$this->instance_id = absint($instance_id);
|
||||||
|
$this->method_title = __('Indonesia Shipping', 'woonoow-indonesia-shipping');
|
||||||
|
$this->supports = array('shipping-zones', 'instance-settings');
|
||||||
|
$this->init();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function init_form_fields() {
|
||||||
|
$this->instance_form_fields = array(
|
||||||
|
'api_key' => array(
|
||||||
|
'title' => 'Biteship API Key',
|
||||||
|
'type' => 'text'
|
||||||
|
),
|
||||||
|
'origin_subdistrict_id' => array(
|
||||||
|
'title' => 'Origin Subdistrict',
|
||||||
|
'type' => 'select',
|
||||||
|
'options' => $this->get_subdistrict_options()
|
||||||
|
),
|
||||||
|
'couriers' => array(
|
||||||
|
'title' => 'Available Couriers',
|
||||||
|
'type' => 'multiselect',
|
||||||
|
'options' => array(
|
||||||
|
'jne' => 'JNE',
|
||||||
|
'sicepat' => 'SiCepat',
|
||||||
|
'jnt' => 'J&T Express'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function calculate_shipping($package = array()) {
|
||||||
|
$origin = $this->get_option('origin_subdistrict_id');
|
||||||
|
$destination = $package['destination']['subdistrict_id'] ?? null;
|
||||||
|
|
||||||
|
if (!$origin || !$destination) return;
|
||||||
|
|
||||||
|
$api = new WooNooW_Biteship_API($this->get_option('api_key'));
|
||||||
|
$rates = $api->get_rates($origin, $destination, $package);
|
||||||
|
|
||||||
|
foreach ($rates as $rate) {
|
||||||
|
$this->add_rate(array(
|
||||||
|
'id' => $this->id . ':' . $rate['courier_code'],
|
||||||
|
'label' => $rate['courier_name'] . ' - ' . $rate['service_name'],
|
||||||
|
'cost' => $rate['price']
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## REST API Endpoints
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
// includes/Api/AddressController.php
|
||||||
|
|
||||||
|
register_rest_route('woonoow/v1', '/indonesia-shipping/provinces', array(
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => 'get_provinces'
|
||||||
|
));
|
||||||
|
|
||||||
|
register_rest_route('woonoow/v1', '/indonesia-shipping/calculate-rates', array(
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => 'calculate_rates'
|
||||||
|
));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## React Components
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// admin-spa/src/components/SubdistrictSelector.tsx
|
||||||
|
|
||||||
|
export function SubdistrictSelector({ value, onChange }) {
|
||||||
|
const [provinceId, setProvinceId] = useState('');
|
||||||
|
const [cityId, setCityId] = useState('');
|
||||||
|
|
||||||
|
const { data: provinces } = useQuery({
|
||||||
|
queryKey: ['provinces'],
|
||||||
|
queryFn: () => api.get('/indonesia-shipping/provinces')
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Select label="Province" options={provinces} />
|
||||||
|
<Select label="City" options={cities} />
|
||||||
|
<Select label="Subdistrict" onChange={onChange} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WooNooW Hook Integration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// admin-spa/src/index.ts
|
||||||
|
|
||||||
|
import { addonLoader, addFilter } from '@woonoow/hooks';
|
||||||
|
|
||||||
|
addonLoader.register({
|
||||||
|
id: 'indonesia-shipping',
|
||||||
|
name: 'Indonesia Shipping',
|
||||||
|
version: '1.0.0',
|
||||||
|
init: () => {
|
||||||
|
// Add subdistrict selector in order form
|
||||||
|
addFilter('woonoow_order_form_after_shipping', (content, formData, setFormData) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{content}
|
||||||
|
<SubdistrictSelector
|
||||||
|
value={formData.shipping?.subdistrict_id}
|
||||||
|
onChange={(id) => setFormData({
|
||||||
|
...formData,
|
||||||
|
shipping: { ...formData.shipping, subdistrict_id: id }
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Timeline
|
||||||
|
|
||||||
|
**Week 1: Backend**
|
||||||
|
- Day 1-2: Database schema + address data import
|
||||||
|
- Day 3-4: WooCommerce shipping method class
|
||||||
|
- Day 5: Biteship API integration
|
||||||
|
|
||||||
|
**Week 2: Frontend**
|
||||||
|
- Day 1-2: REST API endpoints
|
||||||
|
- Day 3-4: React components
|
||||||
|
- Day 5: Hook integration + testing
|
||||||
|
|
||||||
|
**Week 3: Polish**
|
||||||
|
- Day 1-2: Error handling + loading states
|
||||||
|
- Day 3: Rate caching
|
||||||
|
- Day 4-5: Documentation + testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Status:** Specification Complete - Ready for Implementation
|
||||||
@@ -196,6 +196,7 @@ import SettingsIndex from '@/routes/Settings';
|
|||||||
import SettingsStore from '@/routes/Settings/Store';
|
import SettingsStore from '@/routes/Settings/Store';
|
||||||
import SettingsPayments from '@/routes/Settings/Payments';
|
import SettingsPayments from '@/routes/Settings/Payments';
|
||||||
import SettingsShipping from '@/routes/Settings/Shipping';
|
import SettingsShipping from '@/routes/Settings/Shipping';
|
||||||
|
import SettingsTax from '@/routes/Settings/Tax';
|
||||||
import MorePage from '@/routes/More';
|
import MorePage from '@/routes/More';
|
||||||
|
|
||||||
// Addon Route Component - Dynamically loads addon components
|
// Addon Route Component - Dynamically loads addon components
|
||||||
@@ -426,7 +427,7 @@ function AppRoutes() {
|
|||||||
<Route path="/settings/store" element={<SettingsStore />} />
|
<Route path="/settings/store" element={<SettingsStore />} />
|
||||||
<Route path="/settings/payments" element={<SettingsPayments />} />
|
<Route path="/settings/payments" element={<SettingsPayments />} />
|
||||||
<Route path="/settings/shipping" element={<SettingsShipping />} />
|
<Route path="/settings/shipping" element={<SettingsShipping />} />
|
||||||
<Route path="/settings/taxes" element={<SettingsIndex />} />
|
<Route path="/settings/tax" element={<SettingsTax />} />
|
||||||
<Route path="/settings/checkout" element={<SettingsIndex />} />
|
<Route path="/settings/checkout" element={<SettingsIndex />} />
|
||||||
<Route path="/settings/customers" element={<SettingsIndex />} />
|
<Route path="/settings/customers" element={<SettingsIndex />} />
|
||||||
<Route path="/settings/notifications" element={<SettingsIndex />} />
|
<Route path="/settings/notifications" element={<SettingsIndex />} />
|
||||||
|
|||||||
309
admin-spa/src/routes/Settings/Tax.tsx
Normal file
309
admin-spa/src/routes/Settings/Tax.tsx
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { SettingsLayout } from './components/SettingsLayout';
|
||||||
|
import { SettingsCard } from './components/SettingsCard';
|
||||||
|
import { ToggleField } from './components/ToggleField';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ExternalLink, RefreshCw } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { __ } from '@/lib/i18n';
|
||||||
|
|
||||||
|
export default function TaxSettings() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const wcAdminUrl = (window as any).WNW_CONFIG?.wpAdminUrl || '/wp-admin';
|
||||||
|
|
||||||
|
// Fetch tax settings
|
||||||
|
const { data: settings, isLoading, refetch } = useQuery({
|
||||||
|
queryKey: ['tax-settings'],
|
||||||
|
queryFn: () => api.get('/settings/tax'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle tax calculation
|
||||||
|
const toggleMutation = useMutation({
|
||||||
|
mutationFn: async (enabled: boolean) => {
|
||||||
|
return api.post('/settings/tax/toggle', { enabled });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tax-settings'] });
|
||||||
|
toast.success(__('Tax settings updated'));
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error?.message || __('Failed to update tax settings'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<SettingsLayout
|
||||||
|
title={__('Tax')}
|
||||||
|
description={__('Configure tax calculation and rates')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</SettingsLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsLayout
|
||||||
|
title={__('Tax')}
|
||||||
|
description={__('Configure tax calculation and rates')}
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
{__('Refresh')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Enable Tax Calculation */}
|
||||||
|
<SettingsCard
|
||||||
|
title={__('Tax Calculation')}
|
||||||
|
description={__('Enable or disable tax calculation for your store')}
|
||||||
|
>
|
||||||
|
<ToggleField
|
||||||
|
label={__('Enable tax rates and calculations')}
|
||||||
|
description={__('When enabled, taxes will be calculated based on customer location and product tax class')}
|
||||||
|
checked={settings?.calc_taxes === 'yes'}
|
||||||
|
onChange={(checked) => toggleMutation.mutate(checked)}
|
||||||
|
disabled={toggleMutation.isPending}
|
||||||
|
/>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
{/* Tax Rates */}
|
||||||
|
{settings?.calc_taxes === 'yes' && (
|
||||||
|
<SettingsCard
|
||||||
|
title={__('Tax Rates')}
|
||||||
|
description={__('Configure tax rates for different locations and tax classes')}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg border p-4 bg-muted/50">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-medium mb-1">{__('Standard Rates')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{__('Tax rates applied to standard products')}
|
||||||
|
</p>
|
||||||
|
{settings?.standard_rates && settings.standard_rates.length > 0 ? (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{settings.standard_rates.map((rate: any, index: number) => (
|
||||||
|
<div key={index} className="flex items-center justify-between text-sm">
|
||||||
|
<span>{rate.country} {rate.state && `- ${rate.state}`}</span>
|
||||||
|
<span className="font-medium">{rate.rate}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
{__('No standard rates configured')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`${wcAdminUrl}/admin.php?page=wc-settings&tab=tax§ion=standard`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4 mr-2" />
|
||||||
|
{__('Manage')}
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border p-4 bg-muted/50">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-medium mb-1">{__('Reduced Rates')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{__('Lower tax rates for specific products')}
|
||||||
|
</p>
|
||||||
|
{settings?.reduced_rates && settings.reduced_rates.length > 0 ? (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{settings.reduced_rates.map((rate: any, index: number) => (
|
||||||
|
<div key={index} className="flex items-center justify-between text-sm">
|
||||||
|
<span>{rate.country} {rate.state && `- ${rate.state}`}</span>
|
||||||
|
<span className="font-medium">{rate.rate}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
{__('No reduced rates configured')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`${wcAdminUrl}/admin.php?page=wc-settings&tab=tax§ion=reduced-rate`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4 mr-2" />
|
||||||
|
{__('Manage')}
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border p-4 bg-muted/50">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-medium mb-1">{__('Zero Rates')}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{__('No tax for specific products or locations')}
|
||||||
|
</p>
|
||||||
|
{settings?.zero_rates && settings.zero_rates.length > 0 ? (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{settings.zero_rates.map((rate: any, index: number) => (
|
||||||
|
<div key={index} className="flex items-center justify-between text-sm">
|
||||||
|
<span>{rate.country} {rate.state && `- ${rate.state}`}</span>
|
||||||
|
<span className="font-medium">0%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
{__('No zero rates configured')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`${wcAdminUrl}/admin.php?page=wc-settings&tab=tax§ion=zero-rate`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4 mr-2" />
|
||||||
|
{__('Manage')}
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tax Options */}
|
||||||
|
{settings?.calc_taxes === 'yes' && (
|
||||||
|
<SettingsCard
|
||||||
|
title={__('Tax Options')}
|
||||||
|
description={__('Additional tax calculation settings')}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">{__('Prices entered with tax')}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{settings?.prices_include_tax === 'yes'
|
||||||
|
? __('Product prices include tax')
|
||||||
|
: __('Product prices exclude tax')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`${wcAdminUrl}/admin.php?page=wc-settings&tab=tax`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{__('Change')}
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between py-2 border-t">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">{__('Calculate tax based on')}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{settings?.tax_based_on === 'shipping' && __('Customer shipping address')}
|
||||||
|
{settings?.tax_based_on === 'billing' && __('Customer billing address')}
|
||||||
|
{settings?.tax_based_on === 'base' && __('Shop base address')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`${wcAdminUrl}/admin.php?page=wc-settings&tab=tax`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{__('Change')}
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between py-2 border-t">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">{__('Display prices in shop')}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{settings?.tax_display_shop === 'incl' && __('Including tax')}
|
||||||
|
{settings?.tax_display_shop === 'excl' && __('Excluding tax')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`${wcAdminUrl}/admin.php?page=wc-settings&tab=tax`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{__('Change')}
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Advanced Settings Link */}
|
||||||
|
<div className="rounded-lg border border-dashed p-6 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{__('For advanced tax configuration, use the WooCommerce settings page')}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`${wcAdminUrl}/admin.php?page=wc-settings&tab=tax`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4 mr-2" />
|
||||||
|
{__('Open Tax Settings in WooCommerce')}
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ use WooNooW\Api\AuthController;
|
|||||||
use WooNooW\API\PaymentsController;
|
use WooNooW\API\PaymentsController;
|
||||||
use WooNooW\API\StoreController;
|
use WooNooW\API\StoreController;
|
||||||
use WooNooW\Api\ShippingController;
|
use WooNooW\Api\ShippingController;
|
||||||
|
use WooNooW\Api\TaxController;
|
||||||
|
|
||||||
class Routes {
|
class Routes {
|
||||||
public static function init() {
|
public static function init() {
|
||||||
@@ -54,6 +55,10 @@ class Routes {
|
|||||||
// Shipping controller
|
// Shipping controller
|
||||||
$shipping_controller = new ShippingController();
|
$shipping_controller = new ShippingController();
|
||||||
$shipping_controller->register_routes();
|
$shipping_controller->register_routes();
|
||||||
|
|
||||||
|
// Tax controller
|
||||||
|
$tax_controller = new TaxController();
|
||||||
|
$tax_controller->register_routes();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
156
includes/Api/TaxController.php
Normal file
156
includes/Api/TaxController.php
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Tax Settings REST API Controller
|
||||||
|
*
|
||||||
|
* @package WooNooW
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace WooNooW\Api;
|
||||||
|
|
||||||
|
use WP_REST_Controller;
|
||||||
|
use WP_REST_Server;
|
||||||
|
use WP_REST_Request;
|
||||||
|
use WP_REST_Response;
|
||||||
|
use WP_Error;
|
||||||
|
|
||||||
|
class TaxController extends WP_REST_Controller {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register routes
|
||||||
|
*/
|
||||||
|
public function register_routes() {
|
||||||
|
$namespace = 'woonoow/v1';
|
||||||
|
|
||||||
|
// Get tax settings
|
||||||
|
register_rest_route(
|
||||||
|
$namespace,
|
||||||
|
'/settings/tax',
|
||||||
|
array(
|
||||||
|
'methods' => WP_REST_Server::READABLE,
|
||||||
|
'callback' => array( $this, 'get_settings' ),
|
||||||
|
'permission_callback' => array( $this, 'check_permission' ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Toggle tax calculation
|
||||||
|
register_rest_route(
|
||||||
|
$namespace,
|
||||||
|
'/settings/tax/toggle',
|
||||||
|
array(
|
||||||
|
'methods' => WP_REST_Server::CREATABLE,
|
||||||
|
'callback' => array( $this, 'toggle_tax' ),
|
||||||
|
'permission_callback' => array( $this, 'check_permission' ),
|
||||||
|
'args' => array(
|
||||||
|
'enabled' => array(
|
||||||
|
'required' => true,
|
||||||
|
'type' => 'boolean',
|
||||||
|
'sanitize_callback' => 'rest_sanitize_boolean',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check permission
|
||||||
|
*/
|
||||||
|
public function check_permission() {
|
||||||
|
return current_user_can( 'manage_woocommerce' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tax settings
|
||||||
|
*/
|
||||||
|
public function get_settings( WP_REST_Request $request ) {
|
||||||
|
try {
|
||||||
|
$settings = array(
|
||||||
|
'calc_taxes' => get_option( 'woocommerce_calc_taxes', 'no' ),
|
||||||
|
'prices_include_tax' => get_option( 'woocommerce_prices_include_tax', 'no' ),
|
||||||
|
'tax_based_on' => get_option( 'woocommerce_tax_based_on', 'shipping' ),
|
||||||
|
'tax_display_shop' => get_option( 'woocommerce_tax_display_shop', 'excl' ),
|
||||||
|
'tax_display_cart' => get_option( 'woocommerce_tax_display_cart', 'excl' ),
|
||||||
|
'standard_rates' => $this->get_tax_rates( 'standard' ),
|
||||||
|
'reduced_rates' => $this->get_tax_rates( 'reduced-rate' ),
|
||||||
|
'zero_rates' => $this->get_tax_rates( 'zero-rate' ),
|
||||||
|
);
|
||||||
|
|
||||||
|
return new WP_REST_Response( $settings, 200 );
|
||||||
|
} catch ( \Exception $e ) {
|
||||||
|
return new WP_REST_Response(
|
||||||
|
array(
|
||||||
|
'error' => 'fetch_failed',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
),
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle tax calculation
|
||||||
|
*/
|
||||||
|
public function toggle_tax( WP_REST_Request $request ) {
|
||||||
|
try {
|
||||||
|
$enabled = $request->get_param( 'enabled' );
|
||||||
|
$value = $enabled ? 'yes' : 'no';
|
||||||
|
|
||||||
|
update_option( 'woocommerce_calc_taxes', $value );
|
||||||
|
|
||||||
|
// Clear WooCommerce cache
|
||||||
|
\WC_Cache_Helper::invalidate_cache_group( 'taxes' );
|
||||||
|
\WC_Cache_Helper::get_transient_version( 'shipping', true );
|
||||||
|
|
||||||
|
return new WP_REST_Response(
|
||||||
|
array(
|
||||||
|
'success' => true,
|
||||||
|
'enabled' => $enabled,
|
||||||
|
'message' => $enabled
|
||||||
|
? __( 'Tax calculation enabled', 'woonoow' )
|
||||||
|
: __( 'Tax calculation disabled', 'woonoow' ),
|
||||||
|
),
|
||||||
|
200
|
||||||
|
);
|
||||||
|
} catch ( \Exception $e ) {
|
||||||
|
return new WP_REST_Response(
|
||||||
|
array(
|
||||||
|
'error' => 'update_failed',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
),
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tax rates for a specific class
|
||||||
|
*/
|
||||||
|
private function get_tax_rates( $tax_class ) {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$rates = $wpdb->get_results(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"SELECT * FROM {$wpdb->prefix}woocommerce_tax_rates
|
||||||
|
WHERE tax_rate_class = %s
|
||||||
|
ORDER BY tax_rate_order ASC",
|
||||||
|
$tax_class
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$formatted_rates = array();
|
||||||
|
|
||||||
|
foreach ( $rates as $rate ) {
|
||||||
|
$formatted_rates[] = array(
|
||||||
|
'id' => $rate->tax_rate_id,
|
||||||
|
'country' => $rate->tax_rate_country,
|
||||||
|
'state' => $rate->tax_rate_state,
|
||||||
|
'rate' => $rate->tax_rate,
|
||||||
|
'name' => $rate->tax_rate_name,
|
||||||
|
'priority' => $rate->tax_rate_priority,
|
||||||
|
'compound' => $rate->tax_rate_compound,
|
||||||
|
'shipping' => $rate->tax_rate_shipping,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $formatted_rates;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user