feat: Email Global Customization Page! 🎨
## 3. Email Global Customization
**Features:**
- Brand Colors (Primary & Secondary)
- Hero Card Gradient (Start & End colors)
- Button Styling (Text color)
- Logo & Branding (Logo URL, Header/Footer text)
- Live color previews
- Reset to defaults
**Settings:**
- `primary_color` - Primary buttons (#7f54b3)
- `secondary_color` - Outline buttons (#7f54b3)
- `hero_gradient_start` - Hero card gradient start (#667eea)
- `hero_gradient_end` - Hero card gradient end (#764ba2)
- `button_text_color` - Button text (#ffffff)
- `logo_url` - Store logo URL
- `header_text` - Email header text
- `footer_text` - Email footer text
**UI Features:**
- Color pickers with hex input
- Live gradient preview
- Live button preview
- Back navigation
- Reset to defaults button
- Save/loading states
**Navigation:**
- Added card to Notifications page
- Route: `/settings/notifications/email-customization`
- API: `/notifications/email-settings`
**Files:**
- `routes/Settings/Notifications.tsx` - Added card
- `routes/Settings/Notifications/EmailCustomization.tsx` - NEW
- `App.tsx` - Added route
Ready to apply these settings to email templates! 🚀
This commit is contained in:
@@ -203,6 +203,7 @@ import SettingsLocalPickup from '@/routes/Settings/LocalPickup';
|
|||||||
import SettingsNotifications from '@/routes/Settings/Notifications';
|
import SettingsNotifications from '@/routes/Settings/Notifications';
|
||||||
import StaffNotifications from '@/routes/Settings/Notifications/Staff';
|
import StaffNotifications from '@/routes/Settings/Notifications/Staff';
|
||||||
import CustomerNotifications from '@/routes/Settings/Notifications/Customer';
|
import CustomerNotifications from '@/routes/Settings/Notifications/Customer';
|
||||||
|
import EmailCustomization from '@/routes/Settings/Notifications/EmailCustomization';
|
||||||
import EditTemplate from '@/routes/Settings/Notifications/EditTemplate';
|
import EditTemplate from '@/routes/Settings/Notifications/EditTemplate';
|
||||||
import SettingsDeveloper from '@/routes/Settings/Developer';
|
import SettingsDeveloper from '@/routes/Settings/Developer';
|
||||||
import MorePage from '@/routes/More';
|
import MorePage from '@/routes/More';
|
||||||
@@ -493,6 +494,7 @@ function AppRoutes() {
|
|||||||
<Route path="/settings/notifications" element={<SettingsNotifications />} />
|
<Route path="/settings/notifications" element={<SettingsNotifications />} />
|
||||||
<Route path="/settings/notifications/staff" element={<StaffNotifications />} />
|
<Route path="/settings/notifications/staff" element={<StaffNotifications />} />
|
||||||
<Route path="/settings/notifications/customer" element={<CustomerNotifications />} />
|
<Route path="/settings/notifications/customer" element={<CustomerNotifications />} />
|
||||||
|
<Route path="/settings/notifications/email-customization" element={<EmailCustomization />} />
|
||||||
<Route path="/settings/notifications/edit-template" element={<EditTemplate />} />
|
<Route path="/settings/notifications/edit-template" element={<EditTemplate />} />
|
||||||
<Route path="/settings/brand" element={<SettingsIndex />} />
|
<Route path="/settings/brand" element={<SettingsIndex />} />
|
||||||
<Route path="/settings/developer" element={<SettingsDeveloper />} />
|
<Route path="/settings/developer" element={<SettingsDeveloper />} />
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { SettingsLayout } from './components/SettingsLayout';
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { Bell, Users, ChevronRight, Activity } from 'lucide-react';
|
import { Bell, Users, ChevronRight, Activity, Palette } from 'lucide-react';
|
||||||
|
|
||||||
export default function NotificationsSettings() {
|
export default function NotificationsSettings() {
|
||||||
return (
|
return (
|
||||||
@@ -80,6 +80,39 @@ export default function NotificationsSettings() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Email Customization */}
|
||||||
|
<Card className="hover:shadow-md transition-shadow">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-green-500/10 rounded-lg">
|
||||||
|
<Palette className="h-6 w-6 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle>{__('Email Customization')}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{__('Customize email appearance and branding')}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{__('Set your brand colors, logo, and email styling. Customize header, footer, and button colors for all email templates.')}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{__('Colors, Logo, Styling')}
|
||||||
|
</div>
|
||||||
|
<Link to="/settings/notifications/email-customization">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
{__('Customize')}
|
||||||
|
<ChevronRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Activity Log */}
|
{/* Activity Log */}
|
||||||
<Card className="hover:shadow-md transition-shadow">
|
<Card className="hover:shadow-md transition-shadow">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
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 } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface EmailSettings {
|
||||||
|
primary_color: string;
|
||||||
|
secondary_color: string;
|
||||||
|
hero_gradient_start: string;
|
||||||
|
hero_gradient_end: string;
|
||||||
|
button_text_color: string;
|
||||||
|
logo_url: string;
|
||||||
|
header_text: string;
|
||||||
|
footer_text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
button_text_color: '#ffffff',
|
||||||
|
logo_url: '',
|
||||||
|
header_text: '',
|
||||||
|
footer_text: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<EmailSettings>(settings || {
|
||||||
|
primary_color: '#7f54b3',
|
||||||
|
secondary_color: '#7f54b3',
|
||||||
|
hero_gradient_start: '#667eea',
|
||||||
|
hero_gradient_end: '#764ba2',
|
||||||
|
button_text_color: '#ffffff',
|
||||||
|
logo_url: '',
|
||||||
|
header_text: '',
|
||||||
|
footer_text: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<div className="mt-4 p-6 rounded-lg text-white 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">{__('Preview')}</h3>
|
||||||
|
<p className="text-sm opacity-90">{__('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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
<Input
|
||||||
|
id="logo_url"
|
||||||
|
type="url"
|
||||||
|
value={formData.logo_url}
|
||||||
|
onChange={(e) => handleChange('logo_url', e.target.value)}
|
||||||
|
placeholder="https://example.com/logo.png"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{__('Full URL to your logo image (recommended: 200x60px)')}
|
||||||
|
</p>
|
||||||
|
</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={__('© 2024 Your Store. All rights reserved.')}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{__('Text shown in email footer (copyright, address, etc.)')}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user