Phase 2: Schema-Based Form System
- Add ModuleSettingsController with GET/POST/schema endpoints
- Create SchemaField component supporting 8 field types (text, textarea, email, url, number, toggle, checkbox, select)
- Create SchemaForm component for automatic form generation from schema
- Add ModuleSettings page with dynamic routing (/settings/modules/:moduleId)
- Add useModuleSettings React hook for settings management
- Implement NewsletterSettings as example with 8 configurable fields
- Add has_settings flag to module registry
- Settings stored as woonoow_module_{module_id}_settings
Phase 3: Advanced Features
- Create windowAPI.ts exposing React, hooks, components, icons, utils to addons via window.WooNooW
- Add DynamicComponentLoader for loading external React components
- Create TypeScript definitions (woonoow-addon.d.ts) for addon developers
- Initialize Window API in App.tsx on mount
- Enable custom React components for addon settings pages
Phase 4: Production Polish & Example
- Create complete Biteship addon example demonstrating both approaches:
* Schema-based settings (no build required)
* Custom React component (with build)
- Add comprehensive README with installation and testing guide
- Include package.json with esbuild configuration
- Demonstrate window.WooNooW API usage in custom component
Bug Fixes:
- Fix footer newsletter form visibility (remove redundant module check)
- Fix footer contact_data and social_links not saving (parameter name mismatch: snake_case vs camelCase)
- Fix useModules hook returning undefined (remove .data wrapper, add fallback)
- Add optional chaining to footer settings rendering
- Fix TypeScript errors in woonoow-addon.d.ts (use any for external types)
Files Added (15):
- includes/Api/ModuleSettingsController.php
- includes/Modules/NewsletterSettings.php
- admin-spa/src/components/forms/SchemaField.tsx
- admin-spa/src/components/forms/SchemaForm.tsx
- admin-spa/src/routes/Settings/ModuleSettings.tsx
- admin-spa/src/hooks/useModuleSettings.ts
- admin-spa/src/lib/windowAPI.ts
- admin-spa/src/components/DynamicComponentLoader.tsx
- types/woonoow-addon.d.ts
- examples/biteship-addon/biteship-addon.php
- examples/biteship-addon/src/Settings.jsx
- examples/biteship-addon/package.json
- examples/biteship-addon/README.md
- PHASE_2_3_4_SUMMARY.md
Files Modified (11):
- admin-spa/src/App.tsx
- admin-spa/src/hooks/useModules.ts
- admin-spa/src/routes/Appearance/Footer.tsx
- admin-spa/src/routes/Settings/Modules.tsx
- customer-spa/src/hooks/useModules.ts
- customer-spa/src/layouts/BaseLayout.tsx
- customer-spa/src/components/NewsletterForm.tsx
- includes/Api/Routes.php
- includes/Api/ModulesController.php
- includes/Core/ModuleRegistry.php
- woonoow.php
API Endpoints Added:
- GET /woonoow/v1/modules/{module_id}/settings
- POST /woonoow/v1/modules/{module_id}/settings
- GET /woonoow/v1/modules/{module_id}/schema
For Addon Developers:
- Schema-based: Define settings via woonoow/module_settings_schema filter
- Custom React: Build component using window.WooNooW API, externalize react/react-dom
- Both approaches use same storage and retrieval methods
- TypeScript definitions provided for type safety
- Complete working example (Biteship) included
271 lines
9.4 KiB
TypeScript
271 lines
9.4 KiB
TypeScript
import React, { useState, useMemo } 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 { Switch } from '@/components/ui/switch';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Button } from '@/components/ui/button';
|
|
import { RefreshCw, Mail, Heart, Users, RefreshCcw, Key, Search, Settings, Truck, CreditCard, BarChart3, Puzzle } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { __ } from '@/lib/i18n';
|
|
import { useNavigate } from 'react-router-dom';
|
|
|
|
interface Module {
|
|
id: string;
|
|
label: string;
|
|
description: string;
|
|
category: string;
|
|
icon: string;
|
|
enabled: boolean;
|
|
features: string[];
|
|
is_addon?: boolean;
|
|
version?: string;
|
|
author?: string;
|
|
has_settings?: boolean;
|
|
}
|
|
|
|
interface ModulesData {
|
|
modules: Record<string, Module>;
|
|
grouped: Record<string, Module[]>;
|
|
categories: Record<string, string>;
|
|
}
|
|
|
|
export default function Modules() {
|
|
const queryClient = useQueryClient();
|
|
const navigate = useNavigate();
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
|
|
|
const { data: modulesData, isLoading } = useQuery<ModulesData>({
|
|
queryKey: ['modules'],
|
|
queryFn: async () => {
|
|
const response = await api.get('/modules');
|
|
// api.get returns JSON directly, not wrapped in .data
|
|
return response as ModulesData;
|
|
},
|
|
});
|
|
|
|
const toggleModule = useMutation({
|
|
mutationFn: async ({ moduleId, enabled }: { moduleId: string; enabled: boolean }) => {
|
|
return api.post('/modules/toggle', { module_id: moduleId, enabled });
|
|
},
|
|
onSuccess: (data, variables) => {
|
|
queryClient.invalidateQueries({ queryKey: ['modules'] });
|
|
toast.success(
|
|
variables.enabled
|
|
? __('Module enabled successfully')
|
|
: __('Module disabled successfully')
|
|
);
|
|
},
|
|
onError: (error: any) => {
|
|
toast.error(error?.message || __('Failed to toggle module'));
|
|
},
|
|
});
|
|
|
|
const getIcon = (iconName: string) => {
|
|
const icons: Record<string, any> = {
|
|
mail: Mail,
|
|
heart: Heart,
|
|
users: Users,
|
|
'refresh-cw': RefreshCcw,
|
|
key: Key,
|
|
truck: Truck,
|
|
'credit-card': CreditCard,
|
|
'bar-chart-3': BarChart3,
|
|
puzzle: Puzzle,
|
|
};
|
|
const Icon = icons[iconName] || Puzzle;
|
|
return <Icon className="h-5 w-5" />;
|
|
};
|
|
|
|
// Filter modules based on search and category
|
|
const filteredGrouped = useMemo(() => {
|
|
if (!modulesData?.grouped) return {};
|
|
|
|
const filtered: Record<string, Module[]> = {};
|
|
|
|
Object.entries(modulesData.grouped).forEach(([category, modules]) => {
|
|
// Filter by category if selected
|
|
if (selectedCategory && category !== selectedCategory) return;
|
|
|
|
// Filter by search query
|
|
const matchingModules = modules.filter((module) => {
|
|
if (!searchQuery) return true;
|
|
const query = searchQuery.toLowerCase();
|
|
return (
|
|
module.label.toLowerCase().includes(query) ||
|
|
module.description.toLowerCase().includes(query) ||
|
|
module.features.some((f) => f.toLowerCase().includes(query))
|
|
);
|
|
});
|
|
|
|
if (matchingModules.length > 0) {
|
|
filtered[category] = matchingModules;
|
|
}
|
|
});
|
|
|
|
return filtered;
|
|
}, [modulesData, searchQuery, selectedCategory]);
|
|
|
|
const categories = Object.keys(modulesData?.categories || {});
|
|
|
|
return (
|
|
<SettingsLayout
|
|
title={__('Module Management')}
|
|
description={__('Enable or disable features to customize your store')}
|
|
isLoading={isLoading}
|
|
>
|
|
{/* Search and Filters */}
|
|
<div className="mb-6 space-y-4">
|
|
{/* Search Input */}
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder={__('Search modules...')}
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="!pl-9"
|
|
/>
|
|
</div>
|
|
|
|
{/* Category Pills */}
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
variant={selectedCategory === null ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setSelectedCategory(null)}
|
|
>
|
|
{__('All Categories')}
|
|
</Button>
|
|
{categories.map((category) => (
|
|
<Button
|
|
key={category}
|
|
variant={selectedCategory === category ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setSelectedCategory(category)}
|
|
>
|
|
{modulesData?.categories[category] || category}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Info Card */}
|
|
<div className="bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900 rounded-lg p-4 mb-6">
|
|
<div className="text-sm space-y-2">
|
|
<p className="text-blue-900 dark:text-blue-100">
|
|
{__(
|
|
'Modules allow you to enable only the features you need. Disabling unused modules improves performance and reduces clutter in your admin panel.'
|
|
)}
|
|
</p>
|
|
<p className="text-xs text-blue-700 dark:text-blue-300">
|
|
💡 {__('Tip: When you disable a module, its menu items and settings will be hidden from the admin panel, and its features will be disabled on the frontend.')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Module Categories */}
|
|
{Object.keys(filteredGrouped).length === 0 && (
|
|
<div className="text-center py-12 text-muted-foreground">
|
|
<Search className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
|
<p>{__('No modules found matching your search')}</p>
|
|
</div>
|
|
)}
|
|
|
|
{Object.entries(filteredGrouped).map(([category, modules]) => {
|
|
|
|
if (modules.length === 0) return null;
|
|
|
|
return (
|
|
<SettingsCard
|
|
key={category}
|
|
title={modulesData?.categories[category] || category}
|
|
description={__('Manage modules in this category')}
|
|
>
|
|
<div className="space-y-4">
|
|
{modules.map((module) => (
|
|
<div
|
|
key={module.id}
|
|
className="flex items-start gap-4 p-4 border rounded-lg bg-card hover:bg-accent/5 transition-colors"
|
|
>
|
|
{/* Icon */}
|
|
<div
|
|
className={`p-3 rounded-lg ${
|
|
module.enabled
|
|
? 'bg-primary/10 text-primary'
|
|
: 'bg-muted text-muted-foreground'
|
|
}`}
|
|
>
|
|
{getIcon(module.icon)}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<h3 className="font-medium text-sm">{module.label}</h3>
|
|
{module.enabled && (
|
|
<Badge variant="secondary" className="text-xs">
|
|
{__('Active')}
|
|
</Badge>
|
|
)}
|
|
{module.is_addon && (
|
|
<Badge variant="outline" className="text-xs">
|
|
{__('Addon')}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mb-3">
|
|
{module.description}
|
|
</p>
|
|
|
|
{/* Features List */}
|
|
{module.features && module.features.length > 0 && (
|
|
<ul className="space-y-1">
|
|
{module.features.map((feature, index) => (
|
|
<li
|
|
key={index}
|
|
className="text-xs text-muted-foreground flex items-center gap-2"
|
|
>
|
|
<span className="text-primary">•</span>
|
|
{feature}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-2">
|
|
{/* Settings Gear Icon */}
|
|
{module.has_settings && module.enabled && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => navigate(`/settings/modules/${module.id}`)}
|
|
title={__('Module Settings')}
|
|
>
|
|
<Settings className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
|
|
{/* Toggle Switch */}
|
|
<Switch
|
|
checked={module.enabled}
|
|
onCheckedChange={(enabled) =>
|
|
toggleModule.mutate({ moduleId: module.id, enabled })
|
|
}
|
|
disabled={toggleModule.isPending}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</SettingsCard>
|
|
);
|
|
})}
|
|
</SettingsLayout>
|
|
);
|
|
}
|