feat: Implement centralized module management system
- Add ModuleRegistry for managing built-in modules (newsletter, wishlist, affiliate, subscription, licensing) - Add ModulesController REST API for module enable/disable - Create Modules settings page with category grouping and toggle controls - Integrate module checks across admin-spa and customer-spa - Add useModules hook for both SPAs to check module status - Hide newsletter from footer builder when module disabled - Hide wishlist features when module disabled (product cards, account menu, wishlist page) - Protect wishlist API endpoints with module checks - Auto-update navigation tree when modules toggled - Clean up obsolete documentation files - Add comprehensive documentation: - MODULE_SYSTEM_IMPLEMENTATION.md - MODULE_INTEGRATION_SUMMARY.md - ADDON_MODULE_INTEGRATION.md (proposal) - ADDON_MODULE_DESIGN_DECISIONS.md (design doc) - FEATURE_ROADMAP.md - SHIPPING_INTEGRATION.md Module system provides: - Centralized enable/disable for all features - Automatic navigation updates - Frontend/backend integration - Foundation for addon-module unification
This commit is contained in:
616
ADDON_MODULE_DESIGN_DECISIONS.md
Normal file
616
ADDON_MODULE_DESIGN_DECISIONS.md
Normal file
@@ -0,0 +1,616 @@
|
||||
# Addon-Module Integration: Design Decisions
|
||||
|
||||
**Date**: December 26, 2025
|
||||
**Status**: 🎯 Decision Document
|
||||
|
||||
---
|
||||
|
||||
## 1. Dynamic Categories (RECOMMENDED)
|
||||
|
||||
### ❌ Problem with Static Categories
|
||||
```php
|
||||
// BAD: Empty categories if no modules use them
|
||||
public static function get_categories() {
|
||||
return [
|
||||
'shipping' => 'Shipping & Fulfillment', // Empty if no shipping modules!
|
||||
'payments' => 'Payments & Checkout', // Empty if no payment modules!
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Solution: Dynamic Category Generation
|
||||
|
||||
```php
|
||||
class ModuleRegistry {
|
||||
|
||||
/**
|
||||
* Get categories dynamically from registered modules
|
||||
*/
|
||||
public static function get_categories() {
|
||||
$all_modules = self::get_all_modules();
|
||||
$categories = [];
|
||||
|
||||
// Extract unique categories from modules
|
||||
foreach ($all_modules as $module) {
|
||||
$cat = $module['category'] ?? 'other';
|
||||
if (!isset($categories[$cat])) {
|
||||
$categories[$cat] = self::get_category_label($cat);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by predefined order (if exists), then alphabetically
|
||||
$order = ['marketing', 'customers', 'products', 'shipping', 'payments', 'analytics', 'other'];
|
||||
uksort($categories, function($a, $b) use ($order) {
|
||||
$pos_a = array_search($a, $order);
|
||||
$pos_b = array_search($b, $order);
|
||||
if ($pos_a === false) $pos_a = 999;
|
||||
if ($pos_b === false) $pos_b = 999;
|
||||
return $pos_a - $pos_b;
|
||||
});
|
||||
|
||||
return $categories;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable label for category
|
||||
*/
|
||||
private static function get_category_label($category) {
|
||||
$labels = [
|
||||
'marketing' => __('Marketing & Sales', 'woonoow'),
|
||||
'customers' => __('Customer Experience', 'woonoow'),
|
||||
'products' => __('Products & Inventory', 'woonoow'),
|
||||
'shipping' => __('Shipping & Fulfillment', 'woonoow'),
|
||||
'payments' => __('Payments & Checkout', 'woonoow'),
|
||||
'analytics' => __('Analytics & Reports', 'woonoow'),
|
||||
'other' => __('Other Extensions', 'woonoow'),
|
||||
];
|
||||
|
||||
return $labels[$category] ?? ucfirst($category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group modules by category
|
||||
*/
|
||||
public static function get_grouped_modules() {
|
||||
$all_modules = self::get_all_modules();
|
||||
$grouped = [];
|
||||
|
||||
foreach ($all_modules as $module) {
|
||||
$cat = $module['category'] ?? 'other';
|
||||
if (!isset($grouped[$cat])) {
|
||||
$grouped[$cat] = [];
|
||||
}
|
||||
$grouped[$cat][] = $module;
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Benefits
|
||||
- ✅ No empty categories
|
||||
- ✅ Addons can define custom categories
|
||||
- ✅ Single registration point (module only)
|
||||
- ✅ Auto-sorted by predefined order
|
||||
|
||||
---
|
||||
|
||||
## 2. Module Settings URL Pattern (RECOMMENDED)
|
||||
|
||||
### ❌ Problem with Custom URLs
|
||||
```php
|
||||
'settings_url' => '/settings/shipping/biteship', // Conflict risk!
|
||||
'settings_url' => '/marketing/newsletter', // Inconsistent!
|
||||
```
|
||||
|
||||
### ✅ Solution: Convention-Based Pattern
|
||||
|
||||
#### Option A: Standardized Pattern (RECOMMENDED)
|
||||
```php
|
||||
// Module registration - NO settings_url needed!
|
||||
$addons['biteship-shipping'] = [
|
||||
'id' => 'biteship-shipping',
|
||||
'name' => 'Biteship Shipping',
|
||||
'has_settings' => true, // Just a flag!
|
||||
];
|
||||
|
||||
// Auto-generated URL pattern:
|
||||
// /settings/modules/{module_id}
|
||||
// Example: /settings/modules/biteship-shipping
|
||||
```
|
||||
|
||||
#### Backend: Auto Route Registration
|
||||
```php
|
||||
class ModuleRegistry {
|
||||
|
||||
/**
|
||||
* Register module settings routes automatically
|
||||
*/
|
||||
public static function register_settings_routes() {
|
||||
$modules = self::get_all_modules();
|
||||
|
||||
foreach ($modules as $module) {
|
||||
if (empty($module['has_settings'])) continue;
|
||||
|
||||
// Auto-register route: /settings/modules/{module_id}
|
||||
add_filter('woonoow/spa_routes', function($routes) use ($module) {
|
||||
$routes[] = [
|
||||
'path' => "/settings/modules/{$module['id']}",
|
||||
'component_url' => $module['settings_component'] ?? null,
|
||||
'title' => sprintf(__('%s Settings', 'woonoow'), $module['label']),
|
||||
];
|
||||
return $routes;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Frontend: Automatic Navigation
|
||||
```tsx
|
||||
// Modules.tsx - Gear icon auto-links
|
||||
{module.has_settings && module.enabled && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/settings/modules/${module.id}`)}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
```
|
||||
|
||||
### Benefits
|
||||
- ✅ No URL conflicts (enforced pattern)
|
||||
- ✅ Consistent navigation
|
||||
- ✅ Simpler addon registration
|
||||
- ✅ Auto-generated breadcrumbs
|
||||
|
||||
---
|
||||
|
||||
## 3. Form Builder vs Custom HTML (HYBRID APPROACH)
|
||||
|
||||
### ✅ Recommended: Provide Both Options
|
||||
|
||||
#### Option A: Schema-Based Form Builder (For Simple Settings)
|
||||
```php
|
||||
// Addon defines settings schema
|
||||
add_filter('woonoow/module_settings_schema', function($schemas) {
|
||||
$schemas['biteship-shipping'] = [
|
||||
'api_key' => [
|
||||
'type' => 'text',
|
||||
'label' => 'API Key',
|
||||
'description' => 'Your Biteship API key',
|
||||
'required' => true,
|
||||
],
|
||||
'enable_tracking' => [
|
||||
'type' => 'toggle',
|
||||
'label' => 'Enable Tracking',
|
||||
'default' => true,
|
||||
],
|
||||
'default_courier' => [
|
||||
'type' => 'select',
|
||||
'label' => 'Default Courier',
|
||||
'options' => [
|
||||
'jne' => 'JNE',
|
||||
'jnt' => 'J&T Express',
|
||||
'sicepat' => 'SiCepat',
|
||||
],
|
||||
],
|
||||
];
|
||||
return $schemas;
|
||||
});
|
||||
```
|
||||
|
||||
**Auto-rendered form** - No React needed!
|
||||
|
||||
#### Option B: Custom React Component (For Complex Settings)
|
||||
```php
|
||||
// Addon provides custom React component
|
||||
add_filter('woonoow/addon_registry', function($addons) {
|
||||
$addons['biteship-shipping'] = [
|
||||
'id' => 'biteship-shipping',
|
||||
'has_settings' => true,
|
||||
'settings_component' => plugin_dir_url(__FILE__) . 'dist/Settings.js',
|
||||
];
|
||||
return $addons;
|
||||
});
|
||||
```
|
||||
|
||||
**Full control** - Custom React UI
|
||||
|
||||
### Implementation
|
||||
|
||||
```php
|
||||
class ModuleSettingsRenderer {
|
||||
|
||||
/**
|
||||
* Render settings page
|
||||
*/
|
||||
public static function render($module_id) {
|
||||
$module = ModuleRegistry::get_module($module_id);
|
||||
|
||||
// Option 1: Has custom component
|
||||
if (!empty($module['settings_component'])) {
|
||||
return self::render_custom_component($module);
|
||||
}
|
||||
|
||||
// Option 2: Has schema - auto-generate form
|
||||
$schema = apply_filters('woonoow/module_settings_schema', []);
|
||||
if (isset($schema[$module_id])) {
|
||||
return self::render_schema_form($module_id, $schema[$module_id]);
|
||||
}
|
||||
|
||||
// Option 3: No settings
|
||||
return ['error' => 'No settings available'];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Benefits
|
||||
- ✅ Simple addons use schema (no React needed)
|
||||
- ✅ Complex addons use custom components
|
||||
- ✅ Consistent data persistence for both
|
||||
- ✅ Gradual complexity curve
|
||||
|
||||
---
|
||||
|
||||
## 4. Settings Data Persistence (STANDARDIZED)
|
||||
|
||||
### ✅ Recommended: Unified Settings API
|
||||
|
||||
#### Backend: Automatic Persistence
|
||||
```php
|
||||
class ModuleSettingsController extends WP_REST_Controller {
|
||||
|
||||
/**
|
||||
* GET /woonoow/v1/modules/{module_id}/settings
|
||||
*/
|
||||
public function get_settings($request) {
|
||||
$module_id = $request['module_id'];
|
||||
$settings = get_option("woonoow_module_{$module_id}_settings", []);
|
||||
|
||||
// Apply defaults from schema
|
||||
$schema = apply_filters('woonoow/module_settings_schema', []);
|
||||
if (isset($schema[$module_id])) {
|
||||
$settings = wp_parse_args($settings, self::get_defaults($schema[$module_id]));
|
||||
}
|
||||
|
||||
return rest_ensure_response($settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /woonoow/v1/modules/{module_id}/settings
|
||||
*/
|
||||
public function update_settings($request) {
|
||||
$module_id = $request['module_id'];
|
||||
$new_settings = $request->get_json_params();
|
||||
|
||||
// Validate against schema
|
||||
$schema = apply_filters('woonoow/module_settings_schema', []);
|
||||
if (isset($schema[$module_id])) {
|
||||
$validated = self::validate_settings($new_settings, $schema[$module_id]);
|
||||
if (is_wp_error($validated)) {
|
||||
return $validated;
|
||||
}
|
||||
$new_settings = $validated;
|
||||
}
|
||||
|
||||
// Save
|
||||
update_option("woonoow_module_{$module_id}_settings", $new_settings);
|
||||
|
||||
// Allow addons to react
|
||||
do_action("woonoow/module_settings_updated/{$module_id}", $new_settings);
|
||||
|
||||
return rest_ensure_response(['success' => true]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Frontend: Unified Hook
|
||||
```tsx
|
||||
// useModuleSettings.ts
|
||||
export function useModuleSettings(moduleId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: ['module-settings', moduleId],
|
||||
queryFn: async () => {
|
||||
const response = await api.get(`/modules/${moduleId}/settings`);
|
||||
return response;
|
||||
},
|
||||
});
|
||||
|
||||
const updateSettings = useMutation({
|
||||
mutationFn: async (newSettings: any) => {
|
||||
return api.post(`/modules/${moduleId}/settings`, newSettings);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['module-settings', moduleId] });
|
||||
toast.success('Settings saved');
|
||||
},
|
||||
});
|
||||
|
||||
return { settings, isLoading, updateSettings };
|
||||
}
|
||||
```
|
||||
|
||||
#### Addon Usage
|
||||
```tsx
|
||||
// Custom settings component
|
||||
export default function BiteshipSettings() {
|
||||
const { settings, updateSettings } = useModuleSettings('biteship-shipping');
|
||||
|
||||
return (
|
||||
<SettingsLayout title="Biteship Settings">
|
||||
<SettingsCard>
|
||||
<Input
|
||||
label="API Key"
|
||||
value={settings?.api_key || ''}
|
||||
onChange={(e) => updateSettings.mutate({ api_key: e.target.value })}
|
||||
/>
|
||||
</SettingsCard>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Benefits
|
||||
- ✅ Consistent storage pattern: `woonoow_module_{id}_settings`
|
||||
- ✅ Automatic validation (if schema provided)
|
||||
- ✅ React hook for easy access
|
||||
- ✅ Action hooks for addon logic
|
||||
|
||||
---
|
||||
|
||||
## 5. React Extension Pattern (DOCUMENTED)
|
||||
|
||||
### ✅ Solution: Window API + Build Externals
|
||||
|
||||
#### WooNooW Core Exposes React
|
||||
```typescript
|
||||
// admin-spa/src/main.tsx
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
|
||||
// Expose for addons
|
||||
window.WooNooW = {
|
||||
React,
|
||||
ReactDOM,
|
||||
hooks: {
|
||||
useQuery,
|
||||
useMutation,
|
||||
useModuleSettings, // Our custom hook!
|
||||
},
|
||||
components: {
|
||||
SettingsLayout,
|
||||
SettingsCard,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Switch,
|
||||
// ... all shadcn components
|
||||
},
|
||||
utils: {
|
||||
api,
|
||||
toast,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
#### Addon Development
|
||||
```typescript
|
||||
// addon/src/Settings.tsx
|
||||
const { React, hooks, components, utils } = window.WooNooW;
|
||||
const { useModuleSettings } = hooks;
|
||||
const { SettingsLayout, SettingsCard, Input, Button } = components;
|
||||
const { toast } = utils;
|
||||
|
||||
export default function BiteshipSettings() {
|
||||
const { settings, updateSettings } = useModuleSettings('biteship-shipping');
|
||||
const [apiKey, setApiKey] = React.useState(settings?.api_key || '');
|
||||
|
||||
const handleSave = () => {
|
||||
updateSettings.mutate({ api_key: apiKey });
|
||||
};
|
||||
|
||||
return React.createElement(SettingsLayout, { title: 'Biteship Settings' },
|
||||
React.createElement(SettingsCard, null,
|
||||
React.createElement(Input, {
|
||||
label: 'API Key',
|
||||
value: apiKey,
|
||||
onChange: (e) => setApiKey(e.target.value),
|
||||
}),
|
||||
React.createElement(Button, { onClick: handleSave }, 'Save')
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### With JSX (Build Required)
|
||||
```tsx
|
||||
// addon/src/Settings.tsx
|
||||
const { React, hooks, components } = window.WooNooW;
|
||||
const { useModuleSettings } = hooks;
|
||||
const { SettingsLayout, SettingsCard, Input, Button } = components;
|
||||
|
||||
export default function BiteshipSettings() {
|
||||
const { settings, updateSettings } = useModuleSettings('biteship-shipping');
|
||||
|
||||
return (
|
||||
<SettingsLayout title="Biteship Settings">
|
||||
<SettingsCard>
|
||||
<Input
|
||||
label="API Key"
|
||||
value={settings?.api_key || ''}
|
||||
onChange={(e) => updateSettings.mutate({ api_key: e.target.value })}
|
||||
/>
|
||||
</SettingsCard>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// vite.config.js
|
||||
export default {
|
||||
build: {
|
||||
lib: {
|
||||
entry: 'src/Settings.tsx',
|
||||
formats: ['es'],
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ['react', 'react-dom'],
|
||||
output: {
|
||||
globals: {
|
||||
react: 'window.WooNooW.React',
|
||||
'react-dom': 'window.WooNooW.ReactDOM',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Benefits
|
||||
- ✅ Addons don't bundle React (use ours)
|
||||
- ✅ Access to all WooNooW components
|
||||
- ✅ Consistent UI automatically
|
||||
- ✅ Type safety with TypeScript
|
||||
|
||||
---
|
||||
|
||||
## 6. Newsletter as Addon Example (RECOMMENDED)
|
||||
|
||||
### ✅ Yes, Refactor Newsletter as Built-in Addon
|
||||
|
||||
#### Why This is Valuable
|
||||
|
||||
1. **Dogfooding** - We use our own addon system
|
||||
2. **Example** - Best reference for addon developers
|
||||
3. **Consistency** - Newsletter follows same pattern as external addons
|
||||
4. **Testing** - Proves the system works
|
||||
|
||||
#### Proposed Structure
|
||||
|
||||
```
|
||||
includes/
|
||||
Modules/
|
||||
Newsletter/
|
||||
NewsletterModule.php # Module registration
|
||||
NewsletterController.php # API endpoints (moved from Api/)
|
||||
NewsletterSettings.php # Settings schema
|
||||
|
||||
admin-spa/src/modules/
|
||||
Newsletter/
|
||||
Settings.tsx # Settings page
|
||||
Subscribers.tsx # Subscribers page
|
||||
index.ts # Module exports
|
||||
```
|
||||
|
||||
#### Registration Pattern
|
||||
```php
|
||||
// includes/Modules/Newsletter/NewsletterModule.php
|
||||
class NewsletterModule {
|
||||
|
||||
public static function register() {
|
||||
// Register as module
|
||||
add_filter('woonoow/builtin_modules', function($modules) {
|
||||
$modules['newsletter'] = [
|
||||
'id' => 'newsletter',
|
||||
'label' => __('Newsletter', 'woonoow'),
|
||||
'description' => __('Email newsletter subscriptions', 'woonoow'),
|
||||
'category' => 'marketing',
|
||||
'icon' => 'mail',
|
||||
'default_enabled' => true,
|
||||
'has_settings' => true,
|
||||
'settings_component' => self::get_settings_url(),
|
||||
];
|
||||
return $modules;
|
||||
});
|
||||
|
||||
// Register routes (only if enabled)
|
||||
if (ModuleRegistry::is_enabled('newsletter')) {
|
||||
self::register_routes();
|
||||
}
|
||||
}
|
||||
|
||||
private static function register_routes() {
|
||||
// Settings route
|
||||
add_filter('woonoow/spa_routes', function($routes) {
|
||||
$routes[] = [
|
||||
'path' => '/settings/modules/newsletter',
|
||||
'component_url' => plugins_url('admin-spa/dist/modules/Newsletter/Settings.js', WOONOOW_FILE),
|
||||
];
|
||||
return $routes;
|
||||
});
|
||||
|
||||
// Subscribers route
|
||||
add_filter('woonoow/spa_routes', function($routes) {
|
||||
$routes[] = [
|
||||
'path' => '/marketing/newsletter',
|
||||
'component_url' => plugins_url('admin-spa/dist/modules/Newsletter/Subscribers.js', WOONOOW_FILE),
|
||||
];
|
||||
return $routes;
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Benefits
|
||||
- ✅ Newsletter becomes reference implementation
|
||||
- ✅ Proves addon system works for complex modules
|
||||
- ✅ Shows best practices
|
||||
- ✅ Easier to maintain (follows pattern)
|
||||
|
||||
---
|
||||
|
||||
## Summary of Decisions
|
||||
|
||||
| # | Question | Decision | Rationale |
|
||||
|---|----------|----------|-----------|
|
||||
| 1 | Categories | **Dynamic from modules** | No empty categories, single registration |
|
||||
| 2 | Settings URL | **Pattern: `/settings/modules/{id}`** | No conflicts, consistent, auto-generated |
|
||||
| 3 | Form Builder | **Hybrid: Schema + Custom** | Simple for basic, flexible for complex |
|
||||
| 4 | Data Persistence | **Unified API + Hook** | Consistent storage, easy access |
|
||||
| 5 | React Extension | **Window API + Externals** | No bundling, access to components |
|
||||
| 6 | Newsletter Refactor | **Yes, as example** | Dogfooding, reference implementation |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Phase 1: Foundation
|
||||
1. ✅ Dynamic category generation
|
||||
2. ✅ Standardized settings URL pattern
|
||||
3. ✅ Module settings API endpoints
|
||||
4. ✅ `useModuleSettings` hook
|
||||
|
||||
### Phase 2: Form System
|
||||
1. ✅ Schema-based form renderer
|
||||
2. ✅ Custom component loader
|
||||
3. ✅ Settings validation
|
||||
|
||||
### Phase 3: UI Enhancement
|
||||
1. ✅ Search input on Modules page
|
||||
2. ✅ Category filter pills
|
||||
3. ✅ Gear icon with auto-routing
|
||||
|
||||
### Phase 4: Example
|
||||
1. ✅ Refactor Newsletter as built-in addon
|
||||
2. ✅ Document pattern
|
||||
3. ✅ Create external addon example (Biteship)
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
**Ready to implement?** We have clear decisions on all 6 questions. Should we:
|
||||
|
||||
1. Start with Phase 1 (Foundation)?
|
||||
2. Create the schema-based form system first?
|
||||
3. Refactor Newsletter as proof-of-concept?
|
||||
|
||||
**Your call!** All design decisions are documented and justified.
|
||||
Reference in New Issue
Block a user