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:
@@ -238,6 +238,7 @@ import PushConfiguration from '@/routes/Settings/Notifications/PushConfiguration
|
||||
import EmailCustomization from '@/routes/Settings/Notifications/EmailCustomization';
|
||||
import EditTemplate from '@/routes/Settings/Notifications/EditTemplate';
|
||||
import SettingsDeveloper from '@/routes/Settings/Developer';
|
||||
import SettingsModules from '@/routes/Settings/Modules';
|
||||
import AppearanceIndex from '@/routes/Appearance';
|
||||
import AppearanceGeneral from '@/routes/Appearance/General';
|
||||
import AppearanceHeader from '@/routes/Appearance/Header';
|
||||
@@ -551,6 +552,7 @@ function AppRoutes() {
|
||||
<Route path="/settings/notifications/edit-template" element={<EditTemplate />} />
|
||||
<Route path="/settings/brand" element={<SettingsIndex />} />
|
||||
<Route path="/settings/developer" element={<SettingsDeveloper />} />
|
||||
<Route path="/settings/modules" element={<SettingsModules />} />
|
||||
|
||||
{/* Appearance */}
|
||||
<Route path="/appearance" element={<AppearanceIndex />} />
|
||||
|
||||
31
admin-spa/src/hooks/useModules.ts
Normal file
31
admin-spa/src/hooks/useModules.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface ModulesResponse {
|
||||
enabled: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check if modules are enabled
|
||||
* Uses public endpoint, cached for performance
|
||||
*/
|
||||
export function useModules() {
|
||||
const { data, isLoading } = useQuery<ModulesResponse>({
|
||||
queryKey: ['modules-enabled'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/modules/enabled');
|
||||
return response.data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
});
|
||||
|
||||
const isEnabled = (moduleId: string): boolean => {
|
||||
return data?.enabled?.includes(moduleId) ?? false;
|
||||
};
|
||||
|
||||
return {
|
||||
enabledModules: data?.enabled ?? [],
|
||||
isEnabled,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
import { useModules } from '@/hooks/useModules';
|
||||
|
||||
interface SocialLink {
|
||||
id: string;
|
||||
@@ -36,6 +37,7 @@ interface ContactData {
|
||||
}
|
||||
|
||||
export default function AppearanceFooter() {
|
||||
const { isEnabled } = useModules();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [columns, setColumns] = useState('4');
|
||||
const [style, setStyle] = useState('detailed');
|
||||
@@ -427,7 +429,9 @@ export default function AppearanceFooter() {
|
||||
<SelectItem value="menu">Menu Links</SelectItem>
|
||||
<SelectItem value="contact">Contact Info</SelectItem>
|
||||
<SelectItem value="social">Social Links</SelectItem>
|
||||
<SelectItem value="newsletter">Newsletter Form</SelectItem>
|
||||
{isEnabled('newsletter') && (
|
||||
<SelectItem value="newsletter">Newsletter Form</SelectItem>
|
||||
)}
|
||||
<SelectItem value="custom">Custom HTML</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Download, Trash2, Mail, Search } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useModules } from '@/hooks/useModules';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -21,6 +22,27 @@ export default function NewsletterSubscribers() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const { isEnabled } = useModules();
|
||||
|
||||
if (!isEnabled('newsletter')) {
|
||||
return (
|
||||
<SettingsLayout
|
||||
title="Newsletter Subscribers"
|
||||
description="Newsletter module is disabled"
|
||||
>
|
||||
<div className="bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-900 rounded-lg p-6 text-center">
|
||||
<Mail className="h-12 w-12 text-yellow-600 mx-auto mb-3" />
|
||||
<h3 className="font-semibold text-lg mb-2">Newsletter Module Disabled</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
The newsletter module is currently disabled. Enable it in Settings > Modules to use this feature.
|
||||
</p>
|
||||
<Button onClick={() => navigate('/settings/modules')}>
|
||||
Go to Module Settings
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const { data: subscribersData, isLoading } = useQuery({
|
||||
queryKey: ['newsletter-subscribers'],
|
||||
|
||||
180
admin-spa/src/routes/Settings/Modules.tsx
Normal file
180
admin-spa/src/routes/Settings/Modules.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
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 { Switch } from '@/components/ui/switch';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { RefreshCw, Mail, Heart, Users, RefreshCcw, Key } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { __ } from '@/lib/i18n';
|
||||
|
||||
interface Module {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
category: string;
|
||||
icon: string;
|
||||
enabled: boolean;
|
||||
features: string[];
|
||||
}
|
||||
|
||||
interface ModulesData {
|
||||
modules: Record<string, Module>;
|
||||
grouped: {
|
||||
marketing: Module[];
|
||||
customers: Module[];
|
||||
products: Module[];
|
||||
};
|
||||
}
|
||||
|
||||
export default function Modules() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
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,
|
||||
};
|
||||
const Icon = icons[iconName] || Mail;
|
||||
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;
|
||||
};
|
||||
|
||||
const categories = ['marketing', 'customers', 'products'];
|
||||
|
||||
return (
|
||||
<SettingsLayout
|
||||
title={__('Module Management')}
|
||||
description={__('Enable or disable features to customize your store')}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{/* 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 */}
|
||||
{categories.map((category) => {
|
||||
const modules = modulesData?.grouped[category as keyof typeof modulesData.grouped] || [];
|
||||
|
||||
if (modules.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
key={category}
|
||||
title={getCategoryLabel(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>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* Toggle Switch */}
|
||||
<div className="flex items-center">
|
||||
<Switch
|
||||
checked={module.enabled}
|
||||
onCheckedChange={(enabled) =>
|
||||
toggleModule.mutate({ moduleId: module.id, enabled })
|
||||
}
|
||||
disabled={toggleModule.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
})}
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user