feat: Implement Phase 2, 3, 4 - Module Settings System with Schema Forms and Addon API
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
This commit is contained in:
@@ -44,6 +44,7 @@ import { useActiveSection } from '@/hooks/useActiveSection';
|
||||
import { NAV_TREE_VERSION } from '@/nav/tree';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||
import { initializeWindowAPI } from '@/lib/windowAPI';
|
||||
|
||||
function useFullscreen() {
|
||||
const [on, setOn] = useState<boolean>(() => {
|
||||
@@ -239,6 +240,7 @@ import EmailCustomization from '@/routes/Settings/Notifications/EmailCustomizati
|
||||
import EditTemplate from '@/routes/Settings/Notifications/EditTemplate';
|
||||
import SettingsDeveloper from '@/routes/Settings/Developer';
|
||||
import SettingsModules from '@/routes/Settings/Modules';
|
||||
import ModuleSettings from '@/routes/Settings/ModuleSettings';
|
||||
import AppearanceIndex from '@/routes/Appearance';
|
||||
import AppearanceGeneral from '@/routes/Appearance/General';
|
||||
import AppearanceHeader from '@/routes/Appearance/Header';
|
||||
@@ -553,6 +555,7 @@ function AppRoutes() {
|
||||
<Route path="/settings/brand" element={<SettingsIndex />} />
|
||||
<Route path="/settings/developer" element={<SettingsDeveloper />} />
|
||||
<Route path="/settings/modules" element={<SettingsModules />} />
|
||||
<Route path="/settings/modules/:moduleId" element={<ModuleSettings />} />
|
||||
|
||||
{/* Appearance */}
|
||||
<Route path="/appearance" element={<AppearanceIndex />} />
|
||||
@@ -729,6 +732,11 @@ function AuthWrapper() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
// Initialize Window API for addon developers
|
||||
React.useEffect(() => {
|
||||
initializeWindowAPI();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={qc}>
|
||||
<HashRouter>
|
||||
|
||||
131
admin-spa/src/components/DynamicComponentLoader.tsx
Normal file
131
admin-spa/src/components/DynamicComponentLoader.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Loader2, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface DynamicComponentLoaderProps {
|
||||
componentUrl: string;
|
||||
moduleId: string;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic Component Loader
|
||||
*
|
||||
* Loads external React components from addons dynamically
|
||||
* The component is loaded as a script and should export a default component
|
||||
*/
|
||||
export function DynamicComponentLoader({
|
||||
componentUrl,
|
||||
moduleId,
|
||||
fallback
|
||||
}: DynamicComponentLoaderProps) {
|
||||
const [Component, setComponent] = useState<React.ComponentType | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const loadComponent = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Create a unique global variable name for this component
|
||||
const globalName = `WooNooWAddon_${moduleId.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
||||
|
||||
// Check if already loaded
|
||||
if ((window as any)[globalName]) {
|
||||
if (mounted) {
|
||||
setComponent(() => (window as any)[globalName]);
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Load the script
|
||||
const script = document.createElement('script');
|
||||
script.src = componentUrl;
|
||||
script.async = true;
|
||||
|
||||
script.onload = () => {
|
||||
// The addon script should assign its component to window[globalName]
|
||||
const loadedComponent = (window as any)[globalName];
|
||||
|
||||
if (!loadedComponent) {
|
||||
if (mounted) {
|
||||
setError(`Component not found. The addon must export to window.${globalName}`);
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setComponent(() => loadedComponent);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
if (mounted) {
|
||||
setError('Failed to load component script');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (script.parentNode) {
|
||||
script.parentNode.removeChild(script);
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
if (mounted) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadComponent();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [componentUrl, moduleId]);
|
||||
|
||||
if (loading) {
|
||||
return fallback || (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<span className="ml-3 text-muted-foreground">Loading component...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Failed to Load Component</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">{error}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Component URL: <code className="bg-muted px-2 py-1 rounded">{componentUrl}</code>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!Component) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-sm text-muted-foreground">Component not available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <Component />;
|
||||
}
|
||||
146
admin-spa/src/components/forms/SchemaField.tsx
Normal file
146
admin-spa/src/components/forms/SchemaField.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
|
||||
export interface FieldSchema {
|
||||
type: 'text' | 'textarea' | 'email' | 'url' | 'number' | 'toggle' | 'checkbox' | 'select';
|
||||
label: string;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
default?: any;
|
||||
options?: Record<string, string>;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
interface SchemaFieldProps {
|
||||
name: string;
|
||||
schema: FieldSchema;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function SchemaField({ name, schema, value, onChange, error }: SchemaFieldProps) {
|
||||
const renderField = () => {
|
||||
switch (schema.type) {
|
||||
case 'text':
|
||||
case 'email':
|
||||
case 'url':
|
||||
return (
|
||||
<Input
|
||||
type={schema.type}
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={schema.placeholder}
|
||||
required={schema.required}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value))}
|
||||
placeholder={schema.placeholder}
|
||||
required={schema.required}
|
||||
min={schema.min}
|
||||
max={schema.max}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<Textarea
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={schema.placeholder}
|
||||
required={schema.required}
|
||||
rows={4}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'toggle':
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={!!value}
|
||||
onCheckedChange={onChange}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{value ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'checkbox':
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={!!value}
|
||||
onCheckedChange={onChange}
|
||||
/>
|
||||
<Label className="text-sm font-normal cursor-pointer">
|
||||
{schema.label}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<Select value={value || ''} onValueChange={onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={schema.placeholder || 'Select an option'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{schema.options && Object.entries(schema.options).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={schema.placeholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{schema.type !== 'checkbox' && (
|
||||
<Label htmlFor={name}>
|
||||
{schema.label}
|
||||
{schema.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
|
||||
{renderField()}
|
||||
|
||||
{schema.description && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{schema.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
admin-spa/src/components/forms/SchemaForm.tsx
Normal file
64
admin-spa/src/components/forms/SchemaForm.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { SchemaField, FieldSchema } from './SchemaField';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export type FormSchema = Record<string, FieldSchema>;
|
||||
|
||||
interface SchemaFormProps {
|
||||
schema: FormSchema;
|
||||
initialValues?: Record<string, any>;
|
||||
onSubmit: (values: Record<string, any>) => void | Promise<void>;
|
||||
isSubmitting?: boolean;
|
||||
submitLabel?: string;
|
||||
errors?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function SchemaForm({
|
||||
schema,
|
||||
initialValues = {},
|
||||
onSubmit,
|
||||
isSubmitting = false,
|
||||
submitLabel = 'Save Settings',
|
||||
errors = {},
|
||||
}: SchemaFormProps) {
|
||||
const [values, setValues] = useState<Record<string, any>>(initialValues);
|
||||
|
||||
useEffect(() => {
|
||||
setValues(initialValues);
|
||||
}, [initialValues]);
|
||||
|
||||
const handleChange = (name: string, value: any) => {
|
||||
setValues((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await onSubmit(values);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{Object.entries(schema).map(([name, fieldSchema]) => (
|
||||
<SchemaField
|
||||
key={name}
|
||||
name={name}
|
||||
schema={fieldSchema}
|
||||
value={values[name]}
|
||||
onChange={(value) => handleChange(name, value)}
|
||||
error={errors[name]}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="flex justify-end pt-4 border-t">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
45
admin-spa/src/hooks/useModuleSettings.ts
Normal file
45
admin-spa/src/hooks/useModuleSettings.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
/**
|
||||
* Hook to manage module-specific settings
|
||||
*
|
||||
* @param moduleId - The module ID
|
||||
* @returns Settings data and mutation functions
|
||||
*/
|
||||
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 as Record<string, any>;
|
||||
},
|
||||
enabled: !!moduleId,
|
||||
});
|
||||
|
||||
const updateSettings = useMutation({
|
||||
mutationFn: async (newSettings: Record<string, any>) => {
|
||||
return api.post(`/modules/${moduleId}/settings`, newSettings);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['module-settings', moduleId] });
|
||||
toast.success('Settings saved successfully');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
const message = error?.response?.data?.message || 'Failed to save settings';
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
settings: settings || {},
|
||||
isLoading,
|
||||
updateSettings,
|
||||
saveSetting: (key: string, value: any) => {
|
||||
updateSettings.mutate({ ...settings, [key]: value });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export function useModules() {
|
||||
queryKey: ['modules-enabled'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/modules/enabled');
|
||||
return response.data;
|
||||
return response || { enabled: [] };
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
});
|
||||
|
||||
200
admin-spa/src/lib/windowAPI.ts
Normal file
200
admin-spa/src/lib/windowAPI.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* WooNooW Window API
|
||||
*
|
||||
* Exposes React, hooks, components, and utilities to addon developers
|
||||
* via window.WooNooW object
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// UI Components
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
// Settings Components
|
||||
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
||||
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||
import { SettingsSection } from '@/routes/Settings/components/SettingsSection';
|
||||
|
||||
// Form Components
|
||||
import { SchemaForm } from '@/components/forms/SchemaForm';
|
||||
import { SchemaField } from '@/components/forms/SchemaField';
|
||||
|
||||
// Hooks
|
||||
import { useModules } from '@/hooks/useModules';
|
||||
import { useModuleSettings } from '@/hooks/useModuleSettings';
|
||||
|
||||
// Utils
|
||||
import { api } from '@/lib/api';
|
||||
import { __ } from '@/lib/i18n';
|
||||
|
||||
// Icons (commonly used)
|
||||
import {
|
||||
Settings,
|
||||
Save,
|
||||
Trash2,
|
||||
Edit,
|
||||
Plus,
|
||||
X,
|
||||
Check,
|
||||
AlertCircle,
|
||||
Info,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* WooNooW Window API Interface
|
||||
*/
|
||||
export interface WooNooWAPI {
|
||||
React: typeof React;
|
||||
ReactDOM: typeof ReactDOM;
|
||||
|
||||
hooks: {
|
||||
useQuery: typeof useQuery;
|
||||
useMutation: typeof useMutation;
|
||||
useQueryClient: typeof useQueryClient;
|
||||
useModules: typeof useModules;
|
||||
useModuleSettings: typeof useModuleSettings;
|
||||
};
|
||||
|
||||
components: {
|
||||
// Basic UI
|
||||
Button: typeof Button;
|
||||
Input: typeof Input;
|
||||
Label: typeof Label;
|
||||
Textarea: typeof Textarea;
|
||||
Switch: typeof Switch;
|
||||
Select: typeof Select;
|
||||
SelectContent: typeof SelectContent;
|
||||
SelectItem: typeof SelectItem;
|
||||
SelectTrigger: typeof SelectTrigger;
|
||||
SelectValue: typeof SelectValue;
|
||||
Checkbox: typeof Checkbox;
|
||||
Badge: typeof Badge;
|
||||
Card: typeof Card;
|
||||
CardContent: typeof CardContent;
|
||||
CardDescription: typeof CardDescription;
|
||||
CardFooter: typeof CardFooter;
|
||||
CardHeader: typeof CardHeader;
|
||||
CardTitle: typeof CardTitle;
|
||||
|
||||
// Settings Components
|
||||
SettingsLayout: typeof SettingsLayout;
|
||||
SettingsCard: typeof SettingsCard;
|
||||
SettingsSection: typeof SettingsSection;
|
||||
|
||||
// Form Components
|
||||
SchemaForm: typeof SchemaForm;
|
||||
SchemaField: typeof SchemaField;
|
||||
};
|
||||
|
||||
icons: {
|
||||
Settings: typeof Settings;
|
||||
Save: typeof Save;
|
||||
Trash2: typeof Trash2;
|
||||
Edit: typeof Edit;
|
||||
Plus: typeof Plus;
|
||||
X: typeof X;
|
||||
Check: typeof Check;
|
||||
AlertCircle: typeof AlertCircle;
|
||||
Info: typeof Info;
|
||||
Loader2: typeof Loader2;
|
||||
ChevronDown: typeof ChevronDown;
|
||||
ChevronUp: typeof ChevronUp;
|
||||
ChevronLeft: typeof ChevronLeft;
|
||||
ChevronRight: typeof ChevronRight;
|
||||
};
|
||||
|
||||
utils: {
|
||||
api: typeof api;
|
||||
toast: typeof toast;
|
||||
__: typeof __;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Window API
|
||||
* Exposes WooNooW API to window object for addon developers
|
||||
*/
|
||||
export function initializeWindowAPI() {
|
||||
const windowAPI: WooNooWAPI = {
|
||||
React,
|
||||
ReactDOM,
|
||||
|
||||
hooks: {
|
||||
useQuery,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
useModules,
|
||||
useModuleSettings,
|
||||
},
|
||||
|
||||
components: {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Textarea,
|
||||
Switch,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Checkbox,
|
||||
Badge,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
SettingsLayout,
|
||||
SettingsCard,
|
||||
SettingsSection,
|
||||
SchemaForm,
|
||||
SchemaField,
|
||||
},
|
||||
|
||||
icons: {
|
||||
Settings,
|
||||
Save,
|
||||
Trash2,
|
||||
Edit,
|
||||
Plus,
|
||||
X,
|
||||
Check,
|
||||
AlertCircle,
|
||||
Info,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
},
|
||||
|
||||
utils: {
|
||||
api,
|
||||
toast,
|
||||
__,
|
||||
},
|
||||
};
|
||||
|
||||
// Expose to window
|
||||
(window as any).WooNooW = windowAPI;
|
||||
|
||||
console.log('✅ WooNooW API initialized for addon developers');
|
||||
}
|
||||
@@ -37,7 +37,7 @@ interface ContactData {
|
||||
}
|
||||
|
||||
export default function AppearanceFooter() {
|
||||
const { isEnabled } = useModules();
|
||||
const { isEnabled, isLoading: modulesLoading } = useModules();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [columns, setColumns] = useState('4');
|
||||
const [style, setStyle] = useState('detailed');
|
||||
@@ -170,16 +170,17 @@ export default function AppearanceFooter() {
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await api.post('/appearance/footer', {
|
||||
const payload = {
|
||||
columns,
|
||||
style,
|
||||
copyright_text: copyrightText,
|
||||
copyrightText,
|
||||
elements,
|
||||
social_links: socialLinks,
|
||||
socialLinks,
|
||||
sections,
|
||||
contact_data: contactData,
|
||||
contactData,
|
||||
labels,
|
||||
});
|
||||
};
|
||||
const response = await api.post('/appearance/footer', payload);
|
||||
toast.success('Footer settings saved successfully');
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
|
||||
148
admin-spa/src/routes/Settings/ModuleSettings.tsx
Normal file
148
admin-spa/src/routes/Settings/ModuleSettings.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { SettingsLayout } from './components/SettingsLayout';
|
||||
import { SettingsCard } from './components/SettingsCard';
|
||||
import { SchemaForm, FormSchema } from '@/components/forms/SchemaForm';
|
||||
import { useModuleSettings } from '@/hooks/useModuleSettings';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { DynamicComponentLoader } from '@/components/DynamicComponentLoader';
|
||||
|
||||
interface Module {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
has_settings: boolean;
|
||||
settings_component?: string;
|
||||
}
|
||||
|
||||
export default function ModuleSettings() {
|
||||
const { moduleId } = useParams<{ moduleId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { settings, isLoading: settingsLoading, updateSettings } = useModuleSettings(moduleId || '');
|
||||
|
||||
// Fetch module info
|
||||
const { data: modulesData } = useQuery({
|
||||
queryKey: ['modules'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/modules');
|
||||
return response as { modules: Record<string, Module> };
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch settings schema
|
||||
const { data: schemaData } = useQuery({
|
||||
queryKey: ['module-schema', moduleId],
|
||||
queryFn: async () => {
|
||||
const response = await api.get(`/modules/${moduleId}/schema`);
|
||||
return response as { schema: FormSchema };
|
||||
},
|
||||
enabled: !!moduleId,
|
||||
});
|
||||
|
||||
const module = modulesData?.modules?.[moduleId || ''];
|
||||
|
||||
if (!module) {
|
||||
return (
|
||||
<SettingsLayout title={__('Module Settings')} isLoading={!modulesData}>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">{__('Module not found')}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate('/settings/modules')}
|
||||
className="mt-4"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{__('Back to Modules')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!module.has_settings) {
|
||||
return (
|
||||
<SettingsLayout title={module.label}>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">
|
||||
{__('This module does not have any settings')}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate('/settings/modules')}
|
||||
className="mt-4"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{__('Back to Modules')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// If module has custom component, load it dynamically
|
||||
if (module.settings_component) {
|
||||
return (
|
||||
<SettingsLayout
|
||||
title={`${module.label} ${__('Settings')}`}
|
||||
description={module.description}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate('/settings/modules')}
|
||||
className="mb-4"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{__('Back to Modules')}
|
||||
</Button>
|
||||
|
||||
<DynamicComponentLoader
|
||||
componentUrl={module.settings_component}
|
||||
moduleId={moduleId || ''}
|
||||
/>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, render schema-based form
|
||||
return (
|
||||
<SettingsLayout
|
||||
title={`${module.label} ${__('Settings')}`}
|
||||
description={module.description}
|
||||
isLoading={settingsLoading}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate('/settings/modules')}
|
||||
className="mb-4"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{__('Back to Modules')}
|
||||
</Button>
|
||||
|
||||
<SettingsCard
|
||||
title={__('Configuration')}
|
||||
description={__('Configure module settings below')}
|
||||
>
|
||||
{schemaData?.schema ? (
|
||||
<SchemaForm
|
||||
schema={schemaData.schema}
|
||||
initialValues={settings}
|
||||
onSubmit={(values) => updateSettings.mutate(values)}
|
||||
isSubmitting={updateSettings.isPending}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>{__('No settings schema available for this module')}</p>
|
||||
<p className="text-xs mt-2">
|
||||
{__('The module developer needs to register a settings schema using the woonoow/module_settings_schema filter')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
import React from 'react';
|
||||
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 { RefreshCw, Mail, Heart, Users, RefreshCcw, Key } from 'lucide-react';
|
||||
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;
|
||||
@@ -17,19 +20,23 @@ interface Module {
|
||||
icon: string;
|
||||
enabled: boolean;
|
||||
features: string[];
|
||||
is_addon?: boolean;
|
||||
version?: string;
|
||||
author?: string;
|
||||
has_settings?: boolean;
|
||||
}
|
||||
|
||||
interface ModulesData {
|
||||
modules: Record<string, Module>;
|
||||
grouped: {
|
||||
marketing: Module[];
|
||||
customers: Module[];
|
||||
products: 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'],
|
||||
@@ -64,21 +71,45 @@ export default function Modules() {
|
||||
users: Users,
|
||||
'refresh-cw': RefreshCcw,
|
||||
key: Key,
|
||||
truck: Truck,
|
||||
'credit-card': CreditCard,
|
||||
'bar-chart-3': BarChart3,
|
||||
puzzle: Puzzle,
|
||||
};
|
||||
const Icon = icons[iconName] || Mail;
|
||||
const Icon = icons[iconName] || Puzzle;
|
||||
return <Icon className="h-5 w-5" />;
|
||||
};
|
||||
|
||||
const getCategoryLabel = (category: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
marketing: __('Marketing & Sales'),
|
||||
customers: __('Customer Experience'),
|
||||
products: __('Products & Inventory'),
|
||||
};
|
||||
return labels[category] || category;
|
||||
};
|
||||
// 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 = ['marketing', 'customers', 'products'];
|
||||
const categories = Object.keys(modulesData?.categories || {});
|
||||
|
||||
return (
|
||||
<SettingsLayout
|
||||
@@ -86,6 +117,41 @@ export default function Modules() {
|
||||
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">
|
||||
@@ -101,15 +167,21 @@ export default function Modules() {
|
||||
</div>
|
||||
|
||||
{/* Module Categories */}
|
||||
{categories.map((category) => {
|
||||
const modules = modulesData?.grouped[category as keyof typeof modulesData.grouped] || [];
|
||||
{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={getCategoryLabel(category)}
|
||||
title={modulesData?.categories[category] || category}
|
||||
description={__('Manage modules in this category')}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
@@ -138,6 +210,11 @@ export default function Modules() {
|
||||
{__('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}
|
||||
@@ -159,8 +236,21 @@ export default function Modules() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toggle Switch */}
|
||||
<div className="flex items-center">
|
||||
{/* 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) =>
|
||||
|
||||
Reference in New Issue
Block a user