Files
WooNooW/admin-spa/src/routes/Appearance/Footer.tsx
Dwindi Ramadhana c6cef97ef8 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
2025-12-26 21:16:06 +07:00

469 lines
16 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
import { SettingsSection } from '@/routes/Settings/components/SettingsSection';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
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;
platform: string;
url: string;
}
interface FooterSection {
id: string;
title: string;
type: 'menu' | 'contact' | 'social' | 'newsletter' | 'custom';
content: any;
visible: boolean;
}
interface ContactData {
email: string;
phone: string;
address: string;
show_email: boolean;
show_phone: boolean;
show_address: boolean;
}
export default function AppearanceFooter() {
const { isEnabled, isLoading: modulesLoading } = useModules();
const [loading, setLoading] = useState(true);
const [columns, setColumns] = useState('4');
const [style, setStyle] = useState('detailed');
const [copyrightText, setCopyrightText] = useState('© 2024 WooNooW. All rights reserved.');
const [elements, setElements] = useState({
newsletter: true,
social: true,
payment: true,
copyright: true,
menu: true,
contact: true,
});
const [socialLinks, setSocialLinks] = useState<SocialLink[]>([]);
const [sections, setSections] = useState<FooterSection[]>([]);
const [contactData, setContactData] = useState<ContactData>({
email: '',
phone: '',
address: '',
show_email: true,
show_phone: true,
show_address: true,
});
const defaultSections: FooterSection[] = [
{ id: '1', title: 'Contact', type: 'contact', content: '', visible: true },
{ id: '2', title: 'Quick Links', type: 'menu', content: '', visible: true },
{ id: '3', title: 'Follow Us', type: 'social', content: '', visible: true },
{ id: '4', title: 'Newsletter', type: 'newsletter', content: '', visible: true },
];
const [labels, setLabels] = useState({
contact_title: 'Contact',
menu_title: 'Quick Links',
social_title: 'Follow Us',
newsletter_title: 'Newsletter',
newsletter_description: 'Subscribe to get updates',
});
useEffect(() => {
const loadSettings = async () => {
try {
const response = await api.get('/appearance/settings');
const footer = response.data?.footer;
if (footer) {
if (footer.columns) setColumns(footer.columns);
if (footer.style) setStyle(footer.style);
if (footer.copyright_text) setCopyrightText(footer.copyright_text);
if (footer.elements) setElements(footer.elements);
if (footer.social_links) setSocialLinks(footer.social_links);
if (footer.sections && footer.sections.length > 0) {
setSections(footer.sections);
} else {
setSections(defaultSections);
}
if (footer.contact_data) setContactData(footer.contact_data);
if (footer.labels) setLabels(footer.labels);
} else {
setSections(defaultSections);
}
// Fetch store identity data
try {
const identityResponse = await api.get('/settings/store-identity');
const identity = identityResponse.data;
if (identity && !footer?.contact_data) {
setContactData(prev => ({
...prev,
email: identity.email || prev.email,
phone: identity.phone || prev.phone,
address: identity.address || prev.address,
}));
}
} catch (err) {
console.log('Store identity not available');
}
} catch (error) {
console.error('Failed to load settings:', error);
} finally {
setLoading(false);
}
};
loadSettings();
}, []);
const toggleElement = (key: keyof typeof elements) => {
setElements({ ...elements, [key]: !elements[key] });
};
const addSocialLink = () => {
setSocialLinks([
...socialLinks,
{ id: Date.now().toString(), platform: '', url: '' },
]);
};
const removeSocialLink = (id: string) => {
setSocialLinks(socialLinks.filter(link => link.id !== id));
};
const updateSocialLink = (id: string, field: 'platform' | 'url', value: string) => {
setSocialLinks(socialLinks.map(link =>
link.id === id ? { ...link, [field]: value } : link
));
};
const addSection = () => {
setSections([
...sections,
{
id: Date.now().toString(),
title: 'New Section',
type: 'custom',
content: '',
visible: true,
},
]);
};
const removeSection = (id: string) => {
setSections(sections.filter(s => s.id !== id));
};
const updateSection = (id: string, field: keyof FooterSection, value: any) => {
setSections(sections.map(s => s.id === id ? { ...s, [field]: value } : s));
};
const handleSave = async () => {
try {
const payload = {
columns,
style,
copyrightText,
elements,
socialLinks,
sections,
contactData,
labels,
};
const response = await api.post('/appearance/footer', payload);
toast.success('Footer settings saved successfully');
} catch (error) {
console.error('Save error:', error);
toast.error('Failed to save settings');
}
};
return (
<SettingsLayout
title="Footer Settings"
onSave={handleSave}
isLoading={loading}
>
{/* Layout */}
<SettingsCard
title="Layout"
description="Configure footer layout and style"
>
<SettingsSection label="Columns" htmlFor="footer-columns">
<Select value={columns} onValueChange={setColumns}>
<SelectTrigger id="footer-columns">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 Column</SelectItem>
<SelectItem value="2">2 Columns</SelectItem>
<SelectItem value="3">3 Columns</SelectItem>
<SelectItem value="4">4 Columns</SelectItem>
</SelectContent>
</Select>
</SettingsSection>
<SettingsSection label="Style" htmlFor="footer-style">
<Select value={style} onValueChange={setStyle}>
<SelectTrigger id="footer-style">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="simple">Simple</SelectItem>
<SelectItem value="detailed">Detailed</SelectItem>
<SelectItem value="minimal">Minimal</SelectItem>
</SelectContent>
</Select>
</SettingsSection>
</SettingsCard>
{/* Labels */}
<SettingsCard
title="Section Labels"
description="Customize footer section headings and text"
>
<SettingsSection label="Contact Title" htmlFor="contact-title">
<Input
id="contact-title"
value={labels.contact_title}
onChange={(e) => setLabels({ ...labels, contact_title: e.target.value })}
placeholder="Contact"
/>
</SettingsSection>
<SettingsSection label="Menu Title" htmlFor="menu-title">
<Input
id="menu-title"
value={labels.menu_title}
onChange={(e) => setLabels({ ...labels, menu_title: e.target.value })}
placeholder="Quick Links"
/>
</SettingsSection>
<SettingsSection label="Social Title" htmlFor="social-title">
<Input
id="social-title"
value={labels.social_title}
onChange={(e) => setLabels({ ...labels, social_title: e.target.value })}
placeholder="Follow Us"
/>
</SettingsSection>
<SettingsSection label="Newsletter Title" htmlFor="newsletter-title">
<Input
id="newsletter-title"
value={labels.newsletter_title}
onChange={(e) => setLabels({ ...labels, newsletter_title: e.target.value })}
placeholder="Newsletter"
/>
</SettingsSection>
<SettingsSection label="Newsletter Description" htmlFor="newsletter-desc">
<Input
id="newsletter-desc"
value={labels.newsletter_description}
onChange={(e) => setLabels({ ...labels, newsletter_description: e.target.value })}
placeholder="Subscribe to get updates"
/>
</SettingsSection>
</SettingsCard>
{/* Contact Data */}
<SettingsCard
title="Contact Information"
description="Manage contact details from Store Identity"
>
<SettingsSection label="Email" htmlFor="contact-email">
<Input
id="contact-email"
type="email"
value={contactData.email}
onChange={(e) => setContactData({ ...contactData, email: e.target.value })}
placeholder="info@store.com"
/>
<div className="flex items-center gap-2 mt-2">
<Switch
checked={contactData.show_email}
onCheckedChange={(checked) => setContactData({ ...contactData, show_email: checked })}
/>
<Label className="text-sm text-muted-foreground">Show in footer</Label>
</div>
</SettingsSection>
<SettingsSection label="Phone" htmlFor="contact-phone">
<Input
id="contact-phone"
type="tel"
value={contactData.phone}
onChange={(e) => setContactData({ ...contactData, phone: e.target.value })}
placeholder="(123) 456-7890"
/>
<div className="flex items-center gap-2 mt-2">
<Switch
checked={contactData.show_phone}
onCheckedChange={(checked) => setContactData({ ...contactData, show_phone: checked })}
/>
<Label className="text-sm text-muted-foreground">Show in footer</Label>
</div>
</SettingsSection>
<SettingsSection label="Address" htmlFor="contact-address">
<Textarea
id="contact-address"
value={contactData.address}
onChange={(e) => setContactData({ ...contactData, address: e.target.value })}
placeholder="123 Main St, City, State 12345"
rows={2}
/>
<div className="flex items-center gap-2 mt-2">
<Switch
checked={contactData.show_address}
onCheckedChange={(checked) => setContactData({ ...contactData, show_address: checked })}
/>
<Label className="text-sm text-muted-foreground">Show in footer</Label>
</div>
</SettingsSection>
</SettingsCard>
{/* Content */}
<SettingsCard
title="Content"
description="Customize footer content"
>
<SettingsSection label="Copyright Text" htmlFor="copyright">
<Textarea
id="copyright"
value={copyrightText}
onChange={(e) => setCopyrightText(e.target.value)}
rows={2}
placeholder="© 2024 Your Store. All rights reserved."
/>
</SettingsSection>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Social Media Links</Label>
<Button onClick={addSocialLink} variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
Add Link
</Button>
</div>
<div className="space-y-3">
{socialLinks.map((link) => (
<div key={link.id} className="flex gap-2">
<Input
placeholder="Platform (e.g., Facebook)"
value={link.platform}
onChange={(e) => updateSocialLink(link.id, 'platform', e.target.value)}
className="flex-1"
/>
<Input
placeholder="URL"
value={link.url}
onChange={(e) => updateSocialLink(link.id, 'url', e.target.value)}
className="flex-1"
/>
<Button
onClick={() => removeSocialLink(link.id)}
variant="ghost"
size="icon"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
</SettingsCard>
{/* Custom Sections Builder */}
<SettingsCard
title="Custom Sections"
description="Build custom footer sections with flexible content"
>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label>Footer Sections</Label>
<Button onClick={addSection} variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
Add Section
</Button>
</div>
{sections.map((section) => (
<div key={section.id} className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<Input
placeholder="Section Title"
value={section.title}
onChange={(e) => updateSection(section.id, 'title', e.target.value)}
className="flex-1 mr-2"
/>
<Button
onClick={() => removeSection(section.id)}
variant="ghost"
size="icon"
>
<X className="h-4 w-4" />
</Button>
</div>
<Select
value={section.type}
onValueChange={(value) => updateSection(section.id, 'type', value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="menu">Menu Links</SelectItem>
<SelectItem value="contact">Contact Info</SelectItem>
<SelectItem value="social">Social Links</SelectItem>
{isEnabled('newsletter') && (
<SelectItem value="newsletter">Newsletter Form</SelectItem>
)}
<SelectItem value="custom">Custom HTML</SelectItem>
</SelectContent>
</Select>
{section.type === 'custom' && (
<Textarea
placeholder="Custom content (HTML supported)"
value={section.content}
onChange={(e) => updateSection(section.id, 'content', e.target.value)}
rows={4}
/>
)}
<div className="flex items-center gap-2">
<Switch
checked={section.visible}
onCheckedChange={(checked) => updateSection(section.id, 'visible', checked)}
/>
<Label className="text-sm text-muted-foreground">Visible</Label>
</div>
</div>
))}
{sections.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
No custom sections yet. Click "Add Section" to create one.
</p>
)}
</div>
</SettingsCard>
</SettingsLayout>
);
}