Files
WooNooW/admin-spa/src/routes/Settings/Notifications/EmailCustomization.tsx
dwindown b6c2b077ee feat: Complete Social Icons & Settings Expansion! 🎨
## Implemented (Tasks 1-6):

### 1. All Social Platforms Added 
**Platforms:**
- Facebook, X (Twitter), Instagram
- LinkedIn, YouTube
- Discord, Spotify, Telegram
- WhatsApp, Threads, Website

**Frontend:** Updated select dropdown with all platforms
**Backend:** Added to allowed_platforms whitelist

### 2. PNG Icons Instead of Emoji 
- Use local PNG files from `/assets/icons/`
- Format: `mage--{platform}-{color}.png`
- Applied to email rendering and preview
- Much more accurate than emoji

### 3. Icon Color Option (Black/White) 
- New setting: `social_icon_color`
- Select dropdown: White Icons / Black Icons
- White for dark backgrounds
- Black for light backgrounds
- Applied to all social icons

### 4. Body Background Color Setting 
- New setting: `body_bg_color`
- Color picker + hex input
- Default: #f8f8f8
- Applied to email body background
- Applied to preview

### 5. Editor Mode Styling 📝
**Note:** Editor mode intentionally shows structure/content
Preview mode shows final styled result with all customizations
This is standard email builder UX pattern

### 6. Hero Preview Text Color Fixed 
- Applied `hero_text_color` directly to h3 and p
- Now correctly shows selected color
- Both heading and paragraph use custom color

## Technical Changes:

**Frontend:**
- Added body_bg_color and social_icon_color to interface
- Updated all social platform icons (Lucide)
- PNG icon URLs in preview
- Hero preview color fix

**Backend:**
- Added body_bg_color and social_icon_color to defaults
- Sanitization for new fields
- Updated allowed_platforms array
- PNG icon URL generation with color param

**Email Rendering:**
- Use PNG icons with color selection
- Apply body_bg_color
- get_social_icon_url() updated for PNG files

## Files Modified:
- `routes/Settings/Notifications/EmailCustomization.tsx`
- `routes/Settings/Notifications/EditTemplate.tsx`
- `includes/Api/NotificationsController.php`
- `includes/Core/Notifications/EmailRenderer.php`

Task 7 (default email content) pending - separate commit.
2025-11-13 14:50:55 +07:00

669 lines
25 KiB
TypeScript

import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { SettingsLayout } from '../components/SettingsLayout';
import { SettingsCard } from '../components/SettingsCard';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { __ } from '@/lib/i18n';
import { ArrowLeft, RefreshCw, Upload, Plus, Trash2, Facebook, Twitter, Instagram, Linkedin, Youtube, Globe, MessageCircle, Music, Send, AtSign } from 'lucide-react';
import { toast } from 'sonner';
import { openWPMediaLogo } from '@/lib/wp-media';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
interface SocialLink {
platform: string;
url: string;
}
interface EmailSettings {
primary_color: string;
secondary_color: string;
hero_gradient_start: string;
hero_gradient_end: string;
hero_text_color: string;
button_text_color: string;
body_bg_color: string;
social_icon_color: string;
logo_url: string;
header_text: string;
footer_text: string;
social_links: SocialLink[];
}
export default function EmailCustomization() {
const navigate = useNavigate();
const queryClient = useQueryClient();
// Fetch email settings
const { data: settings, isLoading } = useQuery({
queryKey: ['email-settings'],
queryFn: () => api.get('/notifications/email-settings'),
placeholderData: {
primary_color: '#7f54b3',
secondary_color: '#7f54b3',
hero_gradient_start: '#667eea',
hero_gradient_end: '#764ba2',
hero_text_color: '#ffffff',
button_text_color: '#ffffff',
body_bg_color: '#f8f8f8',
social_icon_color: 'white',
logo_url: '',
header_text: '',
footer_text: '',
social_links: [],
},
});
const [formData, setFormData] = useState<EmailSettings>(settings || {
primary_color: '#7f54b3',
secondary_color: '#7f54b3',
hero_gradient_start: '#667eea',
hero_gradient_end: '#764ba2',
hero_text_color: '#ffffff',
button_text_color: '#ffffff',
body_bg_color: '#f8f8f8',
social_icon_color: 'white',
logo_url: '',
header_text: '',
footer_text: '',
social_links: [],
});
// Update form when settings load
React.useEffect(() => {
if (settings) {
setFormData(settings);
}
}, [settings]);
const saveMutation = useMutation({
mutationFn: async () => {
return api.post('/notifications/email-settings', formData);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['email-settings'] });
toast.success(__('Email settings saved successfully'));
},
onError: (error: any) => {
toast.error(error.message || __('Failed to save email settings'));
},
});
const resetMutation = useMutation({
mutationFn: async () => {
return api.del('/notifications/email-settings');
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['email-settings'] });
toast.success(__('Email settings reset to defaults'));
},
onError: (error: any) => {
toast.error(error.message || __('Failed to reset email settings'));
},
});
const handleSave = async () => {
return new Promise<void>((resolve, reject) => {
saveMutation.mutate(undefined, {
onSuccess: () => resolve(),
onError: () => reject(),
});
});
};
const handleReset = () => {
if (!confirm(__('Are you sure you want to reset all email customization to defaults?'))) return;
resetMutation.mutate();
};
const handleChange = (field: keyof EmailSettings, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleLogoSelect = () => {
openWPMediaLogo((media) => {
if (media && media.url) {
handleChange('logo_url', media.url);
}
});
};
const addSocialLink = () => {
setFormData(prev => ({
...prev,
social_links: [...prev.social_links, { platform: 'facebook', url: '' }],
}));
};
const removeSocialLink = (index: number) => {
setFormData(prev => ({
...prev,
social_links: prev.social_links.filter((_, i) => i !== index),
}));
};
const updateSocialLink = (index: number, field: 'platform' | 'url', value: string) => {
setFormData(prev => ({
...prev,
social_links: prev.social_links.map((link, i) =>
i === index ? { ...link, [field]: value } : link
),
}));
};
const getSocialIcon = (platform: string) => {
const icons: Record<string, any> = {
facebook: Facebook,
x: AtSign,
instagram: Instagram,
linkedin: Linkedin,
youtube: Youtube,
discord: MessageCircle,
spotify: Music,
telegram: Send,
whatsapp: MessageCircle,
threads: AtSign,
website: Globe,
};
const Icon = icons[platform] || Globe;
return <Icon className="h-4 w-4" />;
};
if (isLoading) {
return (
<SettingsLayout
title={__('Email Customization')}
description={__('Loading...')}
>
<div className="flex items-center justify-center py-12">
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
</SettingsLayout>
);
}
return (
<SettingsLayout
title={__('Email Customization')}
description={__('Customize the appearance and branding of all email templates')}
onSave={handleSave}
saveLabel={__('Save Settings')}
isLoading={saveMutation.isPending}
action={
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => navigate('/settings/notifications')}
className="gap-2"
>
<ArrowLeft className="h-4 w-4" />
<span className="hidden sm:inline">{__('Back')}</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={resetMutation.isPending}
>
{resetMutation.isPending ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
__('Reset to Defaults')
)}
</Button>
</div>
}
>
<div className="space-y-6">
{/* Brand Colors */}
<SettingsCard
title={__('Brand Colors')}
description={__('Set your primary and secondary brand colors for buttons and accents')}
>
<div className="grid gap-6 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="primary_color">{__('Primary Color')}</Label>
<div className="flex gap-2">
<Input
id="primary_color"
type="color"
value={formData.primary_color}
onChange={(e) => handleChange('primary_color', e.target.value)}
className="w-20 h-10 p-1 cursor-pointer"
/>
<Input
type="text"
value={formData.primary_color}
onChange={(e) => handleChange('primary_color', e.target.value)}
placeholder="#7f54b3"
className="flex-1"
/>
</div>
<p className="text-xs text-muted-foreground">
{__('Used for primary buttons and main accents')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="secondary_color">{__('Secondary Color')}</Label>
<div className="flex gap-2">
<Input
id="secondary_color"
type="color"
value={formData.secondary_color}
onChange={(e) => handleChange('secondary_color', e.target.value)}
className="w-20 h-10 p-1 cursor-pointer"
/>
<Input
type="text"
value={formData.secondary_color}
onChange={(e) => handleChange('secondary_color', e.target.value)}
placeholder="#7f54b3"
className="flex-1"
/>
</div>
<p className="text-xs text-muted-foreground">
{__('Used for outline buttons and borders')}
</p>
</div>
</div>
</SettingsCard>
{/* Hero Card Gradient */}
<SettingsCard
title={__('Hero Card Gradient')}
description={__('Customize the gradient colors for hero/success card backgrounds')}
>
<div className="grid gap-6 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="hero_gradient_start">{__('Gradient Start')}</Label>
<div className="flex gap-2">
<Input
id="hero_gradient_start"
type="color"
value={formData.hero_gradient_start}
onChange={(e) => handleChange('hero_gradient_start', e.target.value)}
className="w-20 h-10 p-1 cursor-pointer"
/>
<Input
type="text"
value={formData.hero_gradient_start}
onChange={(e) => handleChange('hero_gradient_start', e.target.value)}
placeholder="#667eea"
className="flex-1"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="hero_gradient_end">{__('Gradient End')}</Label>
<div className="flex gap-2">
<Input
id="hero_gradient_end"
type="color"
value={formData.hero_gradient_end}
onChange={(e) => handleChange('hero_gradient_end', e.target.value)}
className="w-20 h-10 p-1 cursor-pointer"
/>
<Input
type="text"
value={formData.hero_gradient_end}
onChange={(e) => handleChange('hero_gradient_end', e.target.value)}
placeholder="#764ba2"
className="flex-1"
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="hero_text_color">{__('Text Color')}</Label>
<div className="flex gap-2">
<Input
id="hero_text_color"
type="color"
value={formData.hero_text_color}
onChange={(e) => handleChange('hero_text_color', e.target.value)}
className="w-20 h-10 p-1 cursor-pointer"
/>
<Input
type="text"
value={formData.hero_text_color}
onChange={(e) => handleChange('hero_text_color', e.target.value)}
placeholder="#ffffff"
className="flex-1"
/>
</div>
<p className="text-xs text-muted-foreground">
{__('Text and heading color for hero cards (usually white)')}
</p>
</div>
{/* Preview */}
<div className="mt-4 p-6 rounded-lg text-center" style={{
background: `linear-gradient(135deg, ${formData.hero_gradient_start} 0%, ${formData.hero_gradient_end} 100%)`
}}>
<h3 className="text-xl font-bold mb-2" style={{ color: formData.hero_text_color }}>{__('Preview')}</h3>
<p className="text-sm opacity-90" style={{ color: formData.hero_text_color }}>{__('This is how your hero cards will look')}</p>
</div>
</SettingsCard>
{/* Button Styling */}
<SettingsCard
title={__('Button Styling')}
description={__('Customize button text color and appearance')}
>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="button_text_color">{__('Button Text Color')}</Label>
<div className="flex gap-2">
<Input
id="button_text_color"
type="color"
value={formData.button_text_color}
onChange={(e) => handleChange('button_text_color', e.target.value)}
className="w-20 h-10 p-1 cursor-pointer"
/>
<Input
type="text"
value={formData.button_text_color}
onChange={(e) => handleChange('button_text_color', e.target.value)}
placeholder="#ffffff"
className="flex-1"
/>
</div>
<p className="text-xs text-muted-foreground">
{__('Text color for buttons (usually white for dark buttons)')}
</p>
</div>
{/* Button Preview */}
<div className="flex gap-3 flex-wrap">
<button
className="px-6 py-3 rounded-lg font-medium"
style={{
backgroundColor: formData.primary_color,
color: formData.button_text_color,
}}
>
{__('Primary Button')}
</button>
<button
className="px-6 py-3 rounded-lg font-medium border-2"
style={{
borderColor: formData.secondary_color,
color: formData.secondary_color,
backgroundColor: 'transparent',
}}
>
{__('Secondary Button')}
</button>
</div>
</div>
</SettingsCard>
{/* Email Background & Social Icons */}
<SettingsCard
title={__('Email Background & Social Icons')}
description={__('Customize email background and social icon colors')}
>
<div className="grid gap-6 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="body_bg_color">{__('Email Background Color')}</Label>
<div className="flex gap-2">
<Input
id="body_bg_color"
type="color"
value={formData.body_bg_color}
onChange={(e) => handleChange('body_bg_color', e.target.value)}
className="w-20 h-10 p-1 cursor-pointer"
/>
<Input
type="text"
value={formData.body_bg_color}
onChange={(e) => handleChange('body_bg_color', e.target.value)}
placeholder="#f8f8f8"
className="flex-1"
/>
</div>
<p className="text-xs text-muted-foreground">
{__('Background color for the email body')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="social_icon_color">{__('Social Icon Color')}</Label>
<Select
value={formData.social_icon_color}
onValueChange={(value) => handleChange('social_icon_color', value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="white">{__('White Icons')}</SelectItem>
<SelectItem value="black">{__('Black Icons')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{__('Choose white icons for dark backgrounds, black for light backgrounds')}
</p>
</div>
</div>
</SettingsCard>
{/* Logo & Branding */}
<SettingsCard
title={__('Logo & Branding')}
description={__('Add your logo and custom header/footer text')}
>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="logo_url">{__('Logo URL')}</Label>
<div className="flex gap-2">
<Input
id="logo_url"
type="url"
value={formData.logo_url}
onChange={(e) => handleChange('logo_url', e.target.value)}
placeholder="https://example.com/logo.png"
className="flex-1"
/>
<Button
type="button"
variant="outline"
onClick={handleLogoSelect}
className="gap-2"
>
<Upload className="h-4 w-4" />
{__('Select')}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{__('Full URL to your logo image (recommended: 200x60px)')}
</p>
{formData.logo_url && (
<div className="mt-2 p-4 border rounded-lg bg-muted/30">
<img
src={formData.logo_url}
alt="Logo preview"
className="max-h-16 object-contain"
/>
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="header_text">{__('Header Text')}</Label>
<Input
id="header_text"
type="text"
value={formData.header_text}
onChange={(e) => handleChange('header_text', e.target.value)}
placeholder={__('Your Store Name')}
/>
<p className="text-xs text-muted-foreground">
{__('Text shown in email header (leave empty to use store name)')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="footer_text">{__('Footer Text')}</Label>
<Input
id="footer_text"
type="text"
value={formData.footer_text}
onChange={(e) => handleChange('footer_text', e.target.value)}
placeholder={__('© {current_year} Your Store. All rights reserved.')}
/>
<p className="text-xs text-muted-foreground">
{__('Text shown in email footer. Use {current_year} for dynamic year.')}
</p>
</div>
{/* Social Links */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>{__('Social Links')}</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={addSocialLink}
className="gap-2"
>
<Plus className="h-4 w-4" />
{__('Add Social Link')}
</Button>
</div>
{formData.social_links.length === 0 ? (
<p className="text-sm text-muted-foreground">
{__('No social links added. Click "Add Social Link" to get started.')}
</p>
) : (
<div className="space-y-2">
{formData.social_links.map((link, index) => (
<div key={index} className="flex gap-2 items-start p-3 border rounded-lg">
<div className="flex-1 grid grid-cols-2 gap-2">
<div>
<Select
value={link.platform}
onValueChange={(value) => updateSocialLink(index, 'platform', value)}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="facebook">
<div className="flex items-center gap-2">
<Facebook className="h-4 w-4" />
Facebook
</div>
</SelectItem>
<SelectItem value="x">
<div className="flex items-center gap-2">
<AtSign className="h-4 w-4" />
X (Twitter)
</div>
</SelectItem>
<SelectItem value="instagram">
<div className="flex items-center gap-2">
<Instagram className="h-4 w-4" />
Instagram
</div>
</SelectItem>
<SelectItem value="linkedin">
<div className="flex items-center gap-2">
<Linkedin className="h-4 w-4" />
LinkedIn
</div>
</SelectItem>
<SelectItem value="youtube">
<div className="flex items-center gap-2">
<Youtube className="h-4 w-4" />
YouTube
</div>
</SelectItem>
<SelectItem value="discord">
<div className="flex items-center gap-2">
<MessageCircle className="h-4 w-4" />
Discord
</div>
</SelectItem>
<SelectItem value="spotify">
<div className="flex items-center gap-2">
<Music className="h-4 w-4" />
Spotify
</div>
</SelectItem>
<SelectItem value="telegram">
<div className="flex items-center gap-2">
<Send className="h-4 w-4" />
Telegram
</div>
</SelectItem>
<SelectItem value="whatsapp">
<div className="flex items-center gap-2">
<MessageCircle className="h-4 w-4" />
WhatsApp
</div>
</SelectItem>
<SelectItem value="threads">
<div className="flex items-center gap-2">
<AtSign className="h-4 w-4" />
Threads
</div>
</SelectItem>
<SelectItem value="website">
<div className="flex items-center gap-2">
<Globe className="h-4 w-4" />
Website
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<Input
type="url"
value={link.url}
onChange={(e) => updateSocialLink(index, 'url', e.target.value)}
placeholder="https://..."
className="h-9"
/>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeSocialLink(index)}
className="h-9 w-9 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
<p className="text-xs text-muted-foreground">
{__('Social links will appear as icons in the email footer')}
</p>
</div>
</div>
</SettingsCard>
{/* Info Box */}
<div className="bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p className="text-sm text-blue-900 dark:text-blue-100">
<strong>{__('Note:')}</strong> {__('These settings will apply to all email templates. Individual templates can still override specific content, but colors and branding will be consistent across all emails.')}
</p>
</div>
</div>
</SettingsLayout>
);
}