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
469 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|