diff --git a/.agent/reports/newsletter-module-audit-2026-02-01.md b/.agent/reports/newsletter-module-audit-2026-02-01.md new file mode 100644 index 0000000..0801fc7 --- /dev/null +++ b/.agent/reports/newsletter-module-audit-2026-02-01.md @@ -0,0 +1,212 @@ +# Newsletter Module Audit Report + +**Date**: 2026-02-01 +**Auditor**: Antigravity AI +**Scope**: Full trace of Newsletter module including broadcast, subscribers, templates, events, and multi-channel support + +--- + +## 1. Module Architecture Overview + +```mermaid +flowchart TD + subgraph Frontend + NF[NewsletterForm.tsx] + end + + subgraph API + NC[NewsletterController.php] + CC[CampaignsController - via CampaignManager] + end + + subgraph Core + CM[CampaignManager.php] + NS[NewsletterSettings.php] + end + + subgraph Notifications + ER[EventRegistry.php] + NM[NotificationManager.php] + ER --> NM + end + + subgraph Admin SPA + SUB[Subscribers.tsx] + CAMP[Campaigns.tsx] + end + + NF -->|POST /subscribe| NC + NC -->|triggers| ER + CM -->|uses| NM + SUB -->|GET /subscribers| NC + CAMP -->|CRUD| CM +``` + +--- + +## 2. Components Traced + +| Component | File | Status | +|-----------|------|--------| +| Subscriber API | `NewsletterController.php` | ✅ Working | +| Subscriber UI | `Subscribers.tsx` | ✅ Working | +| Campaign Manager | `CampaignManager.php` | ✅ Built (CPT-based) | +| Campaign UI | `Campaigns.tsx` | ✅ Working | +| Settings Schema | `NewsletterSettings.php` | ✅ Complete | +| Frontend Form | `NewsletterForm.tsx` | ⚠️ Missing GDPR | +| Unsubscribe | Token-based URL | ✅ Secure | +| Email Events | `EventRegistry.php` | ✅ 3 events registered | + +--- + +## 3. Defects Found + +### 🔴 Critical + +#### 3.1 Double Opt-in NOT Implemented +**Location**: `NewsletterController.php` (Line 130-189) +**Issue**: `NewsletterSettings.php` defines a `double_opt_in` toggle (Line 46-51), but the subscribe function **ignores it completely**. +**Impact**: GDPR non-compliance in EU regions +**Expected**: When enabled, subscribers should receive confirmation email before being marked active + +#### 3.2 Dead Code: `send_welcome_email()` +**Location**: `NewsletterController.php` (Lines 192-203) +**Issue**: This method is **never called**. Welcome emails are now sent via the notification system (`woonoow/notification/event`). +**Impact**: Code bloat, potential confusion +**Recommendation**: Delete this dead method + +--- + +### 🟠 High Priority + +#### 3.3 No Multi-Channel Support (WhatsApp/Telegram/SMS) +**Issue**: Only `email` and `push` channels exist in `NotificationManager.php` +**Impact**: Users cannot broadcast newsletters via WhatsApp, Telegram, or SMS +**Current State**: +- `allowed_platforms` in `NotificationsController.php` (Line 832) lists `telegram`, `whatsapp` for **social links** (not messaging) +- No actual message delivery integration exists + +**Recommendation**: Implement channel bridge pattern for: +1. **WhatsApp Business API** (or Twilio WhatsApp) +2. **Telegram Bot API** +3. **SMS Gateway** (Twilio, Vonage, etc.) + +#### 3.4 Subscriber Storage Not Scalable +**Location**: `NewsletterController.php` (Line 141) +**Issue**: Subscribers stored in `wp_options` as serialized array +**Impact**: Performance degrades with 1000+ subscribers (Options table not designed for large arrays) +**Note**: `NEWSLETTER_CAMPAIGN_PLAN.md` mentions custom table but `wp_woonoow_subscribers` table is **not created** + +**Recommendation**: +```php +// Create migration for custom table +CREATE TABLE wp_woonoow_subscribers ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + user_id BIGINT UNSIGNED NULL, + status ENUM('pending', 'active', 'unsubscribed') DEFAULT 'pending', + consent TINYINT(1) DEFAULT 0, + subscribed_at DATETIME, + unsubscribed_at DATETIME NULL, + ip_address VARCHAR(45), + INDEX idx_status (status), + INDEX idx_email (email) +); +``` + +--- + +### 🟡 Medium Priority + +#### 3.5 GDPR Consent Checkbox Missing in Frontend +**Location**: `NewsletterForm.tsx` +**Issue**: Settings schema has `gdpr_consent` and `consent_text` fields, but the frontend form doesn't render this checkbox +**Impact**: GDPR non-compliance + +**Recommendation**: Add consent checkbox: +```tsx +{settings.gdpr_consent && ( + +)} +``` + +#### 3.6 No Audience Segmentation +**Issue**: All campaigns go to ALL active subscribers +**File**: `CampaignManager.php` (Line 393-410) +**Impact**: Cannot target specific user groups (e.g., "Subscribed in last 30 days", "WP Users only") + +**Recommendation**: Add filter options to `get_subscribers()`: +- By date range +- By user_id (registered vs guest) +- By custom tags (future feature) + +#### 3.7 No Open/Click Tracking +**Issue**: No analytics for campaign performance +**Impact**: Cannot measure engagement or ROI + +**Recommendation** (Phase 3): +- Add tracking pixel for opens +- Wrap links for click tracking +- Store in `wp_woonoow_campaign_events` table + +--- + +## 4. Gaps Between Plan and Implementation + +| Feature | Plan Status | Implementation Status | +|---------|-------------|----------------------| +| Subscribers Table | "Create migration" | ❌ Not created | +| Double Opt-in | Schema defined | ❌ Not enforced | +| Campaign Scheduling | Cron registered | ✅ Working | +| GDPR Consent | Settings exist | ❌ UI not integrated | +| Multi-channel | Not planned | ❌ Not implemented | +| A/B Testing | Phase 3 | ❌ Not started | +| Analytics | Phase 3 | ❌ Not started | + +--- + +## 5. Recommendations Summary + +### Immediate Actions (Bug Fixes) +1. ~~Delete~~ or implement `send_welcome_email()` dead code +2. Connect `double_opt_in` setting to subscribe flow +3. Add GDPR checkbox to `NewsletterForm.tsx` + +### Short-term (1-2 weeks) +4. Create `wp_woonoow_subscribers` table for scalability +5. Add audience segmentation to campaign targeting + +### Medium-term (Future Phases) +6. Implement WhatsApp/Telegram channel bridges +7. Add open/click tracking for analytics + +--- + +## 6. Security Audit + +| Area | Status | Notes | +|------|--------|-------| +| Unsubscribe Token | ✅ Secure | HMAC-SHA256 with auth salt | +| Email Validation | ✅ Validated | `is_email()` + custom validation | +| CSRF Protection | ✅ Via REST nonce | API uses WP nonces | +| IP Logging | ✅ Stored | For GDPR data export if needed | +| Rate Limiting | ⚠️ None | Could be abused for spam subscriptions | + +**Recommendation**: Add rate limiting to `/newsletter/subscribe` endpoint (e.g., 5 requests per IP per hour) + +--- + +## 7. Conclusion + +The Newsletter module is **functionally complete** for basic use cases. The campaign system is well-architected using WordPress Custom Post Types, and the integration with the notification system is clean. + +**Critical gaps** exist around GDPR compliance (double opt-in, consent checkbox) and scalability (options-based storage). Multi-channel support (WhatsApp/Telegram) is **not implemented** and would require significant new development. + +**Priority Order**: +1. GDPR fixes (double opt-in + consent checkbox) +2. Custom subscribers table +3. Audience segmentation +4. Multi-channel bridges (optional, significant scope) diff --git a/admin-spa/src/App.tsx b/admin-spa/src/App.tsx index 2942f5c..433ecce 100644 --- a/admin-spa/src/App.tsx +++ b/admin-spa/src/App.tsx @@ -53,6 +53,8 @@ import { __ } from '@/lib/i18n'; import { ThemeToggle } from '@/components/ThemeToggle'; import { initializeWindowAPI } from '@/lib/windowAPI'; +import { LegacyCampaignRedirect } from '@/components/LegacyCampaignRedirect'; + function useFullscreen() { const [on, setOn] = useState(() => { try { return localStorage.getItem('wnwFullscreen') === '1'; } catch { return false; } @@ -261,6 +263,7 @@ import SettingsPayments from '@/routes/Settings/Payments'; import SettingsShipping from '@/routes/Settings/Shipping'; import SettingsTax from '@/routes/Settings/Tax'; import SettingsCustomers from '@/routes/Settings/Customers'; +import SettingsSecurity from '@/routes/Settings/Security'; import SettingsLocalPickup from '@/routes/Settings/LocalPickup'; import SettingsNotifications from '@/routes/Settings/Notifications'; import StaffNotifications from '@/routes/Settings/Notifications/Staff'; @@ -287,7 +290,9 @@ import AppearanceAccount from '@/routes/Appearance/Account'; import AppearanceMenus from '@/routes/Appearance/Menus/MenuEditor'; import AppearancePages from '@/routes/Appearance/Pages'; import MarketingIndex from '@/routes/Marketing'; -import Newsletter from '@/routes/Marketing/Newsletter'; +import NewsletterLayout from '@/routes/Marketing/Newsletter'; +import NewsletterSubscribers from '@/routes/Marketing/Newsletter/Subscribers'; +import NewsletterCampaignsList from '@/routes/Marketing/Campaigns'; import CampaignEdit from '@/routes/Marketing/Campaigns/Edit'; import MorePage from '@/routes/More'; import Help from '@/routes/Help'; @@ -620,6 +625,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> @@ -653,8 +659,17 @@ function AppRoutes() { {/* Marketing */} } /> - } /> - } /> + }> + } /> + } /> + } /> + } /> + + + {/* Legacy Redirects for Newsletter (using component to preserve params) */} + } /> + } /> + } /> {/* Help - Main menu route with no submenu */} } /> diff --git a/admin-spa/src/components/EmailBuilder/BlockRenderer.tsx b/admin-spa/src/components/EmailBuilder/BlockRenderer.tsx index b88c0e0..d8fa070 100644 --- a/admin-spa/src/components/EmailBuilder/BlockRenderer.tsx +++ b/admin-spa/src/components/EmailBuilder/BlockRenderer.tsx @@ -89,7 +89,7 @@ export function BlockRenderer({ return (
@@ -97,17 +97,17 @@ export function BlockRenderer({ ); case 'button': { - const buttonStyle: React.CSSProperties = block.style === 'solid' - ? { - display: 'inline-block', - background: 'var(--wn-primary, #7f54b3)', - color: '#fff', - padding: '14px 28px', - borderRadius: '6px', - textDecoration: 'none', - fontWeight: 600, - } - : { + // Different styles based on button type + let buttonStyle: React.CSSProperties; + + if (block.style === 'link') { + // Plain link style - just underlined text + buttonStyle = { + color: 'var(--wn-primary, #7f54b3)', + textDecoration: 'underline', + }; + } else if (block.style === 'outline') { + buttonStyle = { display: 'inline-block', background: 'transparent', color: 'var(--wn-secondary, #7f54b3)', @@ -117,18 +117,33 @@ export function BlockRenderer({ textDecoration: 'none', fontWeight: 600, }; + } else { + // Solid style (default) + buttonStyle = { + display: 'inline-block', + background: 'var(--wn-primary, #7f54b3)', + color: '#fff', + padding: '14px 28px', + borderRadius: '6px', + textDecoration: 'none', + fontWeight: 600, + }; + } const containerStyle: React.CSSProperties = { textAlign: block.align || 'center', }; - if (block.widthMode === 'full') { - buttonStyle.display = 'block'; - buttonStyle.width = '100%'; - buttonStyle.textAlign = 'center'; - } else if (block.widthMode === 'custom' && block.customMaxWidth) { - buttonStyle.maxWidth = `${block.customMaxWidth}px`; - buttonStyle.width = '100%'; + // Width modes don't apply to plain links + if (block.style !== 'link') { + if (block.widthMode === 'full') { + buttonStyle.display = 'block'; + buttonStyle.width = '100%'; + buttonStyle.textAlign = 'center'; + } else if (block.widthMode === 'custom' && block.customMaxWidth) { + buttonStyle.maxWidth = `${block.customMaxWidth}px`; + buttonStyle.width = '100%'; + } } return ( diff --git a/admin-spa/src/components/EmailBuilder/EmailBuilder.tsx b/admin-spa/src/components/EmailBuilder/EmailBuilder.tsx index 1159471..8dfa082 100644 --- a/admin-spa/src/components/EmailBuilder/EmailBuilder.tsx +++ b/admin-spa/src/components/EmailBuilder/EmailBuilder.tsx @@ -101,7 +101,6 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP }; const openEditDialog = (block: EmailBlock) => { - console.log('[EmailBuilder] openEditDialog called', { blockId: block.id, blockType: block.type }); setEditingBlockId(block.id); if (block.type === 'card') { @@ -123,7 +122,6 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP setEditingAlign(block.align); } - console.log('[EmailBuilder] Setting editDialogOpen to true'); setEditDialogOpen(true); }; diff --git a/admin-spa/src/components/EmailBuilder/types.ts b/admin-spa/src/components/EmailBuilder/types.ts index e1f2a14..680848c 100644 --- a/admin-spa/src/components/EmailBuilder/types.ts +++ b/admin-spa/src/components/EmailBuilder/types.ts @@ -2,7 +2,7 @@ export type BlockType = 'card' | 'button' | 'divider' | 'spacer' | 'image'; export type CardType = 'default' | 'success' | 'info' | 'warning' | 'hero' | 'basic'; -export type ButtonStyle = 'solid' | 'outline'; +export type ButtonStyle = 'solid' | 'outline' | 'link'; export type ContentWidth = 'fit' | 'full' | 'custom'; diff --git a/admin-spa/src/components/LegacyCampaignRedirect.tsx b/admin-spa/src/components/LegacyCampaignRedirect.tsx new file mode 100644 index 0000000..9e2ed23 --- /dev/null +++ b/admin-spa/src/components/LegacyCampaignRedirect.tsx @@ -0,0 +1,10 @@ +import { Navigate, useParams } from 'react-router-dom'; + +/** + * Legacy redirect for campaign details + * Redirects /marketing/campaigns/:id -> /marketing/newsletter/campaigns/:id + */ +export function LegacyCampaignRedirect() { + const { id } = useParams(); + return ; +} diff --git a/admin-spa/src/components/filters/DateRange.tsx b/admin-spa/src/components/filters/DateRange.tsx index ac09e30..a82b529 100644 --- a/admin-spa/src/components/filters/DateRange.tsx +++ b/admin-spa/src/components/filters/DateRange.tsx @@ -20,7 +20,7 @@ function fmt(d: Date): string { } export default function DateRange({ value, onChange }: Props) { - const [preset, setPreset] = useState(() => "last7"); + const [preset, setPreset] = useState(() => "last30"); const [start, setStart] = useState(value?.date_start); const [end, setEnd] = useState(value?.date_end); @@ -32,8 +32,8 @@ export default function DateRange({ value, onChange }: Props) { return { today: { date_start: todayStr, date_end: todayStr }, last7: { date_start: fmt(last7), date_end: todayStr }, - last30:{ date_start: fmt(last30), date_end: todayStr }, - custom:{ date_start: start, date_end: end }, + last30: { date_start: fmt(last30), date_end: todayStr }, + custom: { date_start: start, date_end: end }, }; }, [start, end]); @@ -41,7 +41,7 @@ export default function DateRange({ value, onChange }: Props) { if (preset === "custom") { onChange?.({ date_start: start, date_end: end, preset }); } else { - const pr = (presets as any)[preset] || presets.last7; + const pr = (presets as any)[preset] || presets.last30; onChange?.({ ...pr, preset }); setStart(pr.date_start); setEnd(pr.date_end); @@ -53,7 +53,7 @@ export default function DateRange({ value, onChange }: Props) {
setButtonStyle(value)}> +
diff --git a/admin-spa/src/components/ui/tiptap-button-extension.ts b/admin-spa/src/components/ui/tiptap-button-extension.ts index a3f27d9..2dcfa3c 100644 --- a/admin-spa/src/components/ui/tiptap-button-extension.ts +++ b/admin-spa/src/components/ui/tiptap-button-extension.ts @@ -7,7 +7,7 @@ export interface ButtonOptions { declare module '@tiptap/core' { interface Commands { button: { - setButton: (options: { text: string; href: string; style?: 'solid' | 'outline' }) => ReturnType; + setButton: (options: { text: string; href: string; style?: 'solid' | 'outline' | 'link' }) => ReturnType; }; } } @@ -70,20 +70,27 @@ export const ButtonExtension = Node.create({ renderHTML({ HTMLAttributes }) { const { text, href, style } = HTMLAttributes; - // Simple link styling - no fancy button appearance in editor - // The actual button styling happens in email rendering (EmailRenderer.php) - // In editor, just show as a styled link (differentiable from regular links) + // Different styling based on button style + let inlineStyle: string; + if (style === 'link') { + // Plain link - just underlined text, no button-like appearance + inlineStyle = 'color: #7f54b3; text-decoration: underline; cursor: pointer;'; + } else { + // Solid/Outline buttons - show as styled link with background hint + inlineStyle = 'color: #7f54b3; text-decoration: underline; cursor: pointer; font-weight: 600; background: rgba(127,84,179,0.1); padding: 2px 6px; border-radius: 3px;'; + } + return [ 'a', mergeAttributes(this.options.HTMLAttributes, { href, - class: 'button-node', - style: 'color: #7f54b3; text-decoration: underline; cursor: pointer; font-weight: 600; background: rgba(127,84,179,0.1); padding: 2px 6px; border-radius: 3px;', + class: style === 'link' ? 'link-node' : 'button-node', + style: inlineStyle, 'data-button': '', 'data-text': text, 'data-href': href, 'data-style': style, - title: `Button: ${text} → ${href}`, + title: style === 'link' ? `Link: ${text}` : `Button: ${text} → ${href}`, }), text, ]; diff --git a/admin-spa/src/index.css b/admin-spa/src/index.css index a932dce..9154d24 100644 --- a/admin-spa/src/index.css +++ b/admin-spa/src/index.css @@ -1,6 +1,7 @@ /* Import design tokens for UI sizing and control defaults */ @import './components/ui/tokens.css'; +/* stylelint-disable at-rule-no-unknown */ @tailwind base; @tailwind components; @tailwind utilities; @@ -82,12 +83,15 @@ } /* Override WordPress common.css focus/active styles */ + /* Override WordPress common.css focus/active styles */ + /* Reverting this override as it causes issues with our custom button styles a:focus, a:active { outline: none !important; box-shadow: none !important; color: inherit !important; } + */ } /* ============================================ @@ -258,12 +262,8 @@ display: none !important; } -/* Optional page presets (opt-in by adding the class to a wrapper before printing) */ -.print-a4 {} - -.print-letter {} - -.print-4x6 {} +/* Optional page presets (opt-in by adding the class to a wrapper before printing) + These classes are used dynamically and styled via @media print rules below */ @media print { @@ -302,7 +302,7 @@ color: white !important; } - .print-letter {} + /* Letter format - extend as needed */ /* Thermal label (4x6in) with minimal margins */ .print-4x6 { diff --git a/admin-spa/src/lib/html-to-markdown.ts b/admin-spa/src/lib/html-to-markdown.ts index ff1de82..5c8ee51 100644 --- a/admin-spa/src/lib/html-to-markdown.ts +++ b/admin-spa/src/lib/html-to-markdown.ts @@ -8,11 +8,27 @@ export function htmlToMarkdown(html: string): string { let markdown = html; - // Headings - markdown = markdown.replace(/

(.*?)<\/h1>/gi, '# $1\n\n'); - markdown = markdown.replace(/

(.*?)<\/h2>/gi, '## $1\n\n'); - markdown = markdown.replace(/

(.*?)<\/h3>/gi, '### $1\n\n'); - markdown = markdown.replace(/

(.*?)<\/h4>/gi, '#### $1\n\n'); + // Store aligned headings for preservation + const alignedHeadings: { [key: string]: string } = {}; + let headingIndex = 0; + + // Process headings with potential style attributes + for (let level = 1; level <= 4; level++) { + const hashes = '#'.repeat(level); + markdown = markdown.replace(new RegExp(`]*)>(.*?)`, 'gis'), (match, attrs, content) => { + // Check for text-align in style attribute + const alignMatch = attrs.match(/text-align:\s*(center|right)/i); + if (alignMatch) { + const align = alignMatch[1].toLowerCase(); + const placeholder = `[[HEADING${headingIndex}]]`; + alignedHeadings[placeholder] = `${content}`; + headingIndex++; + return placeholder + '\n\n'; + } + // No alignment, convert to markdown + return `${hashes} ${content}\n\n`; + }); + } // Bold markdown = markdown.replace(/(.*?)<\/strong>/gi, '**$1**'); @@ -100,6 +116,11 @@ export function htmlToMarkdown(html: string): string { markdown = markdown.replace(placeholder, html); }); + // Restore aligned headings + Object.entries(alignedHeadings).forEach(([placeholder, html]) => { + markdown = markdown.replace(placeholder, html); + }); + // Clean up excessive newlines markdown = markdown.replace(/\n{3,}/g, '\n\n'); diff --git a/admin-spa/src/lib/markdown-utils.ts b/admin-spa/src/lib/markdown-utils.ts index a85f370..659d918 100644 --- a/admin-spa/src/lib/markdown-utils.ts +++ b/admin-spa/src/lib/markdown-utils.ts @@ -96,15 +96,22 @@ export function markdownToHtml(markdown: string): string { }); // Parse [button:style](url)Text[/button] (new syntax) + // Buttons are inline in TipTap, so don't wrap in

html = html.replace(/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/g, (match, style, url, text) => { + if (style === 'link') { + return `${text.trim()}`; + } const buttonClass = style === 'outline' ? 'button-outline' : 'button'; - return `

${text.trim()}

`; + return `${text.trim()}`; }); // Parse [button url="..."] shortcodes (old syntax - backward compatibility) html = html.replace(/\[button\s+url="([^"]+)"(?:\s+style="([^"]+)")?\]([^\[]+)\[\/button\]/g, (match, url, style, text) => { + if (style === 'link') { + return `${text.trim()}`; + } const buttonClass = style === 'outline' ? 'button-outline' : 'button'; - return `

${text.trim()}

`; + return `${text.trim()}`; }); // Parse remaining markdown @@ -153,8 +160,11 @@ export function parseMarkdownBasics(text: string): string { // Allow whitespace and newlines between parts // Include data-button attributes for TipTap recognition html = html.replace(/\[button:(\w+)\]\(([^)]+)\)([\s\S]*?)\[\/button\]/g, (match, style, url, text) => { - const buttonClass = style === 'outline' ? 'button-outline' : 'button'; const trimmedText = text.trim(); + if (style === 'link') { + return `${trimmedText}`; + } + const buttonClass = style === 'outline' ? 'button-outline' : 'button'; return `${trimmedText}`; }); diff --git a/admin-spa/src/routes/Appearance/Footer.tsx b/admin-spa/src/routes/Appearance/Footer.tsx index 655f0e2..2a4c933 100644 --- a/admin-spa/src/routes/Appearance/Footer.tsx +++ b/admin-spa/src/routes/Appearance/Footer.tsx @@ -8,10 +8,12 @@ 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 { Plus, X, Upload, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/lib/api'; import { useModules } from '@/hooks/useModules'; +import { MediaUploader } from '@/components/MediaUploader'; +import { __ } from '@/lib/i18n'; interface SocialLink { id: string; @@ -36,18 +38,37 @@ interface ContactData { show_address: boolean; } +interface PaymentMethod { + id: string; + url: string; + label: string; +} + 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 [copyright, setCopyright] = useState({ + enabled: true, + text: '© 2024 WooNooW. All rights reserved.', + }); + + const [payment, setPayment] = useState<{ + enabled: boolean; + title: string; + methods: PaymentMethod[]; + }>({ + enabled: true, + title: 'We accept', + methods: [] + }); + + // Legacy elements toggle (only for newsletter, social, menu, contact) const [elements, setElements] = useState({ newsletter: true, social: true, - payment: true, - copyright: true, menu: true, contact: true, }); @@ -62,19 +83,16 @@ export default function AppearanceFooter() { 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 }, ]; - + + // Only keeping newsletter_description, titles are now managed per column const [labels, setLabels] = useState({ - contact_title: 'Contact', - menu_title: 'Quick Links', - social_title: 'Follow Us', - newsletter_title: 'Newsletter', newsletter_description: 'Subscribe to get updates', }); @@ -83,12 +101,34 @@ export default function AppearanceFooter() { 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); + + // Handle new structure vs backward compatibility + if (footer.copyright) { + setCopyright(footer.copyright); + } else if (footer.copyright_text) { + // Migration fallback + setCopyright({ + enabled: footer.elements?.copyright ?? true, + text: footer.copyright_text + }); + } + + if (footer.payment) { + setPayment(footer.payment); + } else if (footer.elements?.payment) { + // Migration fallback + setPayment(prev => ({ ...prev, enabled: footer.elements.payment })); + } + + if (footer.elements) { + const { payment, copyright, ...rest } = footer.elements; + setElements(prev => ({ ...prev, ...rest })); + } + if (footer.social_links) setSocialLinks(footer.social_links); if (footer.sections && footer.sections.length > 0) { setSections(footer.sections); @@ -96,11 +136,15 @@ export default function AppearanceFooter() { setSections(defaultSections); } if (footer.contact_data) setContactData(footer.contact_data); - if (footer.labels) setLabels(footer.labels); + + // Only sync description if it exists + if (footer.labels?.newsletter_description) { + setLabels({ newsletter_description: footer.labels.newsletter_description }); + } } else { setSections(defaultSections); } - + // Fetch store identity data try { const identityResponse = await api.get('/settings/store-identity'); @@ -122,7 +166,7 @@ export default function AppearanceFooter() { setLoading(false); } }; - + loadSettings(); }, []); @@ -152,7 +196,7 @@ export default function AppearanceFooter() { ...sections, { id: Date.now().toString(), - title: 'New Section', + title: 'New Column', type: 'custom', content: '', visible: true, @@ -168,12 +212,34 @@ export default function AppearanceFooter() { setSections(sections.map(s => s.id === id ? { ...s, [field]: value } : s)); }; + const addPaymentMethod = () => { + setPayment({ + ...payment, + methods: [...payment.methods, { id: Date.now().toString(), url: '', label: '' }] + }); + }; + + const removePaymentMethod = (id: string) => { + setPayment({ + ...payment, + methods: payment.methods.filter(m => m.id !== id) + }); + }; + + const updatePaymentMethod = (id: string, field: keyof PaymentMethod, value: string) => { + setPayment({ + ...payment, + methods: payment.methods.map(m => m.id === id ? { ...m, [field]: value } : m) + }); + }; + const handleSave = async () => { try { const payload = { columns, style, - copyrightText, + copyright, + payment, elements, socialLinks, sections, @@ -227,177 +293,127 @@ export default function AppearanceFooter() { - {/* Labels */} + {/* Content & Contact */} - - setLabels({ ...labels, contact_title: e.target.value })} - placeholder="Contact" - /> - - - - setLabels({ ...labels, menu_title: e.target.value })} - placeholder="Quick Links" - /> - - - - setLabels({ ...labels, social_title: e.target.value })} - placeholder="Follow Us" - /> - - - - setLabels({ ...labels, newsletter_title: e.target.value })} - placeholder="Newsletter" - /> - - - - setLabels({ ...labels, newsletter_description: e.target.value })} - placeholder="Subscribe to get updates" - /> - - - - {/* Contact Data */} - - - setContactData({ ...contactData, email: e.target.value })} - placeholder="info@store.com" - /> -
- setContactData({ ...contactData, show_email: checked })} - /> - -
-
- - - setContactData({ ...contactData, phone: e.target.value })} - placeholder="(123) 456-7890" - /> -
- setContactData({ ...contactData, show_phone: checked })} - /> - -
-
- - -