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:
dwindown
2025-11-09 23:13:52 +07:00
parent 17afd3911f
commit 603d94b73c
8 changed files with 1447 additions and 1306 deletions

715
ADDON_DEVELOPMENT_GUIDE.md Normal file
View 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.**

View File

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

View File

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

View File

@@ -196,6 +196,7 @@ import SettingsIndex from '@/routes/Settings';
import SettingsStore from '@/routes/Settings/Store';
import SettingsPayments from '@/routes/Settings/Payments';
import SettingsShipping from '@/routes/Settings/Shipping';
import SettingsTax from '@/routes/Settings/Tax';
import MorePage from '@/routes/More';
// Addon Route Component - Dynamically loads addon components
@@ -426,7 +427,7 @@ function AppRoutes() {
<Route path="/settings/store" element={<SettingsStore />} />
<Route path="/settings/payments" element={<SettingsPayments />} />
<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/customers" element={<SettingsIndex />} />
<Route path="/settings/notifications" element={<SettingsIndex />} />

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

View File

@@ -10,6 +10,7 @@ use WooNooW\Api\AuthController;
use WooNooW\API\PaymentsController;
use WooNooW\API\StoreController;
use WooNooW\Api\ShippingController;
use WooNooW\Api\TaxController;
class Routes {
public static function init() {
@@ -54,6 +55,10 @@ class Routes {
// Shipping controller
$shipping_controller = new ShippingController();
$shipping_controller->register_routes();
// Tax controller
$tax_controller = new TaxController();
$tax_controller->register_routes();
});
}
}

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