Files
WooNooW/admin-spa/src/routes/Settings/Notifications/EditTemplate.tsx

632 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { SettingsLayout } from '../components/SettingsLayout';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { EmailBuilder, EmailBlock, blocksToMarkdown, markdownToBlocks } from '@/components/EmailBuilder';
import { CodeEditor } from '@/components/ui/code-editor';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ArrowLeft, Eye, Edit, RotateCcw, FileText, Send } from 'lucide-react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { toast } from 'sonner';
import { __ } from '@/lib/i18n';
import { markdownToHtml } from '@/lib/markdown-utils';
export default function EditTemplate() {
// Mobile responsive check
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const eventId = searchParams.get('event');
const channelId = searchParams.get('channel');
const recipientType = searchParams.get('recipient') || 'customer'; // Default to customer
const [subject, setSubject] = useState('');
const [markdownContent, setMarkdownContent] = useState(''); // Source of truth: Markdown
const [blocks, setBlocks] = useState<EmailBlock[]>([]); // Visual mode view (derived from markdown)
const [activeTab, setActiveTab] = useState('preview');
// Send Test Email state
const [testEmailDialogOpen, setTestEmailDialogOpen] = useState(false);
const [testEmail, setTestEmail] = useState('');
// Fetch email customization settings (for non-color settings like logo, footer, social links)
const { data: emailSettings } = useQuery({
queryKey: ['email-settings'],
queryFn: () => api.get('/notifications/email-settings'),
});
// Fetch appearance settings for unified colors
const { data: appearanceSettings } = useQuery({
queryKey: ['appearance-settings'],
queryFn: () => api.get('/appearance/settings'),
});
// Fetch template
const { data: template, isLoading, error } = useQuery({
queryKey: ['notification-template', eventId, channelId, recipientType],
queryFn: async () => {
console.log('Fetching template for:', eventId, channelId, recipientType);
const response = await api.get(`/notifications/templates/${eventId}/${channelId}?recipient=${recipientType}`);
console.log('API Response:', response);
console.log('API Response.data:', response.data);
console.log('API Response type:', typeof response);
// The api.get might already unwrap response.data
// Return the response directly if it has the template fields
if (response && (response.subject !== undefined || response.body !== undefined)) {
console.log('Returning response directly:', response);
return response;
}
// Otherwise return response.data
if (response && response.data) {
console.log('Returning response.data:', response.data);
return response.data;
}
return null;
},
enabled: !!eventId && !!channelId,
});
useEffect(() => {
if (template) {
setSubject(template.subject || '');
// Always treat body as markdown (source of truth)
const markdown = template.body || '';
setMarkdownContent(markdown);
// Convert to blocks for visual mode
const initialBlocks = markdownToBlocks(markdown);
setBlocks(initialBlocks);
}
}, [template]);
const handleSave = async () => {
try {
await api.post(`/notifications/templates/${eventId}/${channelId}`, {
subject,
body: markdownContent, // Save markdown (source of truth)
recipient: recipientType,
});
queryClient.invalidateQueries({ queryKey: ['notification-templates'] });
queryClient.invalidateQueries({ queryKey: ['notification-template', eventId, channelId, recipientType] });
toast.success(__('Template saved successfully'));
} catch (error: any) {
toast.error(error?.message || __('Failed to save template'));
}
};
const handleReset = async () => {
if (!confirm(__('Are you sure you want to reset this template to default?'))) return;
try {
await api.del(`/notifications/templates/${eventId}/${channelId}?recipient=${recipientType}`);
queryClient.invalidateQueries({ queryKey: ['notification-templates'] });
queryClient.invalidateQueries({ queryKey: ['notification-template', eventId, channelId, recipientType] });
toast.success(__('Template reset to default'));
} catch (error: any) {
toast.error(error?.message || __('Failed to reset template'));
}
};
// Send test email mutation
const sendTestMutation = useMutation({
mutationFn: async (email: string) => {
return api.post(`/notifications/templates/${eventId}/${channelId}/send-test`, {
email,
recipient: recipientType,
});
},
onSuccess: (data: any) => {
toast.success(data.message || __('Test email sent successfully'));
setTestEmailDialogOpen(false);
setTestEmail('');
},
onError: (error: any) => {
toast.error(error?.message || __('Failed to send test email'));
},
});
const handleSendTest = () => {
if (!testEmail || !testEmail.includes('@')) {
toast.error(__('Please enter a valid email address'));
return;
}
sendTestMutation.mutate(testEmail);
};
// Visual mode: Update blocks → Markdown (source of truth)
const handleBlocksChange = (newBlocks: EmailBlock[]) => {
setBlocks(newBlocks);
const markdown = blocksToMarkdown(newBlocks);
setMarkdownContent(markdown); // Update markdown (source of truth)
};
// Markdown mode: Update markdown → Blocks (for visual sync)
const handleMarkdownChange = (newMarkdown: string) => {
setMarkdownContent(newMarkdown); // Update source of truth
const newBlocks = markdownToBlocks(newMarkdown);
setBlocks(newBlocks); // Keep blocks in sync
};
// Variable keys for the rich text editor dropdown - from API (contextual per event)
const variableKeys = template?.available_variables
? Object.keys(template.available_variables).map(k => k.replace(/^\{|}$/g, ''))
: [];
// Parse [card] tags and [button] shortcodes for preview
const parseCardsForPreview = (content: string) => {
// Parse card blocks - new [card:type] syntax
let parsed = content.replace(/\[card:(\w+)\](.*?)\[\/card\]/gs, (match, type, cardContent) => {
const cardClass = `card card-${type}`;
const htmlContent = markdownToHtml(cardContent.trim());
return `<div class="${cardClass}">${htmlContent}</div>`;
});
// Parse card blocks - old [card type="..."] syntax (backward compatibility)
parsed = parsed.replace(/\[card([^\]]*)\](.*?)\[\/card\]/gs, (match, attributes, cardContent) => {
let cardClass = 'card';
const typeMatch = attributes.match(/type=["']([^"']+)["']/);
if (typeMatch) {
cardClass += ` card-${typeMatch[1]}`;
}
const bgMatch = attributes.match(/bg=["']([^"']+)["']/);
const bgStyle = bgMatch ? `background-image: url(${bgMatch[1]}); background-size: cover; background-position: center;` : '';
// Convert markdown inside card to HTML
const htmlContent = markdownToHtml(cardContent.trim());
return `<div class="${cardClass}" style="${bgStyle}">${htmlContent}</div>`;
});
// Parse button shortcodes - new [button:style](url)Text[/button] syntax
parsed = parsed.replace(/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/g, (match, style, url, text) => {
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
});
// Parse button shortcodes - old [button url="..."]Text[/button] syntax (backward compatibility)
parsed = parsed.replace(/\[button\s+url=["']([^"']+)["'](?:\s+style=["'](solid|outline)["'])?\]([^\[]+)\[\/button\]/g, (match, url, style, text) => {
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
});
return parsed;
};
// Generate preview HTML
const generatePreviewHTML = () => {
// Convert markdown to HTML for preview
let previewBody = parseCardsForPreview(markdownContent);
// Replace store-identity variables with actual data
const storeVariables: { [key: string]: string } = {
store_name: 'My WordPress Store',
site_url: window.location.origin,
store_email: 'store@example.com',
};
Object.entries(storeVariables).forEach(([key, value]) => {
const regex = new RegExp(`\\{${key}\\}`, 'g');
previewBody = previewBody.replace(regex, value);
});
// Replace dynamic variables with sample data (not just highlighting)
const sampleData: { [key: string]: string } = {
order_number: '12345',
order_total: '$99.99',
order_status: 'Processing',
order_date: new Date().toLocaleDateString(),
order_url: '#',
completion_date: new Date().toLocaleDateString(),
order_items_list: `<ul style="list-style: none; padding: 0; margin: 16px 0;">
<li style="padding: 12px; background: #f9f9f9; border-radius: 6px; margin-bottom: 8px;">
<strong>Premium T-Shirt</strong> × 2<br>
<span style="color: #666;">Size: L, Color: Blue</span><br>
<span style="font-weight: 600;">$49.98</span>
</li>
<li style="padding: 12px; background: #f9f9f9; border-radius: 6px; margin-bottom: 8px;">
<strong>Classic Jeans</strong> × 1<br>
<span style="color: #666;">Size: 32, Color: Dark Blue</span><br>
<span style="font-weight: 600;">$79.99</span>
</li>
</ul>`,
order_items_table: `<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
<thead>
<tr style="background: #f5f5f5;">
<th style="padding: 12px; text-align: left; border-bottom: 2px solid #ddd;">Product</th>
<th style="padding: 12px; text-align: center; border-bottom: 2px solid #ddd;">Qty</th>
<th style="padding: 12px; text-align: right; border-bottom: 2px solid #ddd;">Price</th>
</tr>
</thead>
<tbody>
<tr>
<td style="padding: 12px; border-bottom: 1px solid #eee;">
<strong>Premium T-Shirt</strong><br>
<span style="color: #666; font-size: 13px;">Size: L, Color: Blue</span>
</td>
<td style="padding: 12px; text-align: center; border-bottom: 1px solid #eee;">2</td>
<td style="padding: 12px; text-align: right; border-bottom: 1px solid #eee;">$49.98</td>
</tr>
<tr>
<td style="padding: 12px; border-bottom: 1px solid #eee;">
<strong>Classic Jeans</strong><br>
<span style="color: #666; font-size: 13px;">Size: 32, Color: Dark Blue</span>
</td>
<td style="padding: 12px; text-align: center; border-bottom: 1px solid #eee;">1</td>
<td style="padding: 12px; text-align: right; border-bottom: 1px solid #eee;">$79.99</td>
</tr>
</tbody>
</table>`,
customer_name: 'John Doe',
customer_email: 'john@example.com',
customer_phone: '+1 234 567 8900',
payment_method: 'Credit Card',
payment_url: '#',
shipping_method: 'Standard Shipping',
tracking_number: 'TRACK123456',
tracking_url: '#',
shipping_carrier: 'Standard Shipping',
refund_amount: '$50.00',
billing_address: '123 Main St, City, State 12345',
shipping_address: '123 Main St, City, State 12345',
transaction_id: 'TXN123456789',
payment_date: new Date().toLocaleDateString(),
payment_status: 'Completed',
review_url: '#',
shop_url: '#',
my_account_url: '#',
payment_retry_url: '#',
vip_dashboard_url: '#',
vip_free_shipping_threshold: '$50',
current_year: new Date().getFullYear().toString(),
site_name: 'My WordPress Store',
store_name: 'My WordPress Store',
site_url: '#',
store_email: 'store@example.com',
support_email: 'support@example.com',
// Account-related URLs and variables
login_url: '#',
reset_link: '#',
reset_key: 'abc123xyz',
user_login: 'johndoe',
user_email: 'john@example.com',
user_temp_password: '••••••••',
customer_first_name: 'John',
customer_last_name: 'Doe',
// Campaign/Newsletter variables
content: '<p>This is sample content that would be replaced with your actual campaign content.</p>',
campaign_title: 'Newsletter Campaign',
};
Object.keys(sampleData).forEach((key) => {
const regex = new RegExp(`\\{${key}\\}`, 'g');
previewBody = previewBody.replace(regex, sampleData[key]);
});
// Highlight variables that don't have sample data
// Use plain text [variable] instead of HTML spans to avoid breaking href attributes
variableKeys.forEach((key: string) => {
if (!storeVariables[key] && !sampleData[key]) {
previewBody = previewBody.replace(new RegExp(`\\{${key}\\}`, 'g'), `[${key}]`);
}
});
// Get email settings for preview - use UNIFIED appearance settings for colors
const settings = emailSettings || {};
const appearColors = appearanceSettings?.data?.general?.colors || appearanceSettings?.general?.colors || {};
const primaryColor = appearColors.primary || '#7f54b3';
const secondaryColor = appearColors.secondary || '#7f54b3';
const heroGradientStart = appearColors.gradientStart || '#667eea';
const heroGradientEnd = appearColors.gradientEnd || '#764ba2';
const heroTextColor = '#ffffff'; // Always white on gradient
const buttonTextColor = '#ffffff'; // Always white on primary
const bodyBgColor = settings.body_bg_color || '#f8f8f8';
const socialIconColor = settings.social_icon_color || 'white';
const logoUrl = settings.logo_url || '';
const headerText = settings.header_text || 'My WordPress Store';
const footerText = settings.footer_text || `© ${new Date().getFullYear()} My WordPress Store. All rights reserved.`;
const socialLinks = settings.social_links || [];
// Replace {current_year} in footer
const processedFooter = footerText.replace('{current_year}', new Date().getFullYear().toString());
// Generate social icons HTML with PNG images
// Get plugin URL from config, with fallback
const pluginUrl =
(window as any).woonoowData?.pluginUrl ||
(window as any).WNW_CONFIG?.pluginUrl ||
'/wp-content/plugins/woonoow/';
const socialIconsHtml = socialLinks.length > 0 ? `
<div style="margin-top: 16px;">
${socialLinks.map((link: any) => `
<a href="${link.url}" style="display: inline-block; margin: 0 8px; text-decoration: none;">
<img src="${pluginUrl}assets/icons/mage--${link.platform}-${socialIconColor}.png" alt="${link.platform}" style="width: 24px; height: 24px;" />
</a>
`).join('')}
</div>
` : '';
return `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: 'Inter', Arial, sans-serif; background: ${bodyBgColor}; margin: 0; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; }
.header { padding: 32px; text-align: center; background: #f8f8f8; }
.card-gutter { padding: 0 16px; }
.card { background: #ffffff; border-radius: 8px; margin-bottom: 24px; padding: 32px 40px; }
/* Mobile responsive */
@media only screen and (max-width: 600px) {
body { padding: 8px; }
.card-gutter { padding: 0 8px; }
.card { padding: 20px 16px; }
.header { padding: 20px 16px; }
.footer { padding: 20px 16px; }
}
.card-success { background-color: #f0fdf4; }
.card-highlight { background: linear-gradient(135deg, ${heroGradientStart} 0%, ${heroGradientEnd} 100%); color: ${heroTextColor}; }
.card-highlight * { color: ${heroTextColor} !important; }
.card-hero { background: linear-gradient(135deg, ${heroGradientStart} 0%, ${heroGradientEnd} 100%); color: ${heroTextColor}; }
.card-hero * { color: ${heroTextColor} !important; }
.card-info { background-color: #f0f7ff; }
.card-warning { background-color: #fff8e1; }
.card-basic { background: none; border: none; padding: 0; margin: 16px 0; }
h1 { font-size: 26px; margin-top: 0; margin-bottom: 16px; color: #333; }
h2 { font-size: 18px; margin-top: 0; margin-bottom: 16px; color: #333; }
h3 { font-size: 16px; margin-top: 0; margin-bottom: 8px; color: #333; }
p { font-size: 16px; line-height: 1.6; color: #555; margin-bottom: 16px; }
.button { display: inline-block; background: ${primaryColor}; color: ${buttonTextColor} !important; padding: 14px 28px; border-radius: 6px; text-decoration: none; font-weight: 600; }
.button-outline { display: inline-block; background: transparent; color: ${secondaryColor} !important; padding: 12px 26px; border: 2px solid ${secondaryColor}; border-radius: 6px; text-decoration: none; font-weight: 600; }
.text-link { color: ${primaryColor}; text-decoration: underline; }
.info-box { background: #f6f6f6; border-radius: 6px; padding: 20px; margin: 16px 0; }
.footer { padding: 32px; text-align: center; color: #888; font-size: 13px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
${logoUrl ? `<img src="${logoUrl}" alt="${headerText}" style="max-width: 200px; max-height: 60px;">` : `<strong style="font-size: 24px; color: #333;">${headerText}</strong>`}
</div>
<div class="card-gutter">
${previewBody}
</div>
<div class="footer">
<p>${processedFooter}</p>
${socialIconsHtml}
</div>
</div>
</body>
</html>
`;
};
// Helper function to get social icon emoji
const getSocialIcon = (platform: string) => {
const icons: Record<string, string> = {
facebook: '📘',
twitter: '🐦',
instagram: '📷',
linkedin: '💼',
youtube: '📺',
website: '🌐',
};
return icons[platform] || '🔗';
};
if (!eventId || !channelId) {
return (
<SettingsLayout
title={__('Edit Template')}
description={__('Template editor')}
>
<div className="text-muted-foreground">{__('Invalid template parameters')}</div>
</SettingsLayout>
);
}
// Don't render form until template data is loaded
if (isLoading || !template) {
return (
<SettingsLayout
title={__('Edit Template')}
description={__('Loading template...')}
isLoading={true}
>
<div className="text-muted-foreground">{__('Loading template data...')}</div>
</SettingsLayout>
);
}
return (
<>
<SettingsLayout
title={template.event_label || __('Edit Template')}
description={`${template.channel_label || ''} - ${__('Customize the notification template. Use variables like {customer_name} to personalize messages.')}`}
onSave={handleSave}
saveLabel={__('Save Template')}
isLoading={false}
action={
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
// Determine if staff or customer based on event category
const isStaffEvent = template.event_category === 'staff' || eventId?.includes('admin') || eventId?.includes('staff');
const page = isStaffEvent ? 'staff' : 'customer';
navigate(`/settings/notifications/${page}?tab=events`);
}}
className="gap-2"
title={__('Back')}
>
<ArrowLeft className="h-4 w-4" />
<span className="hidden sm:inline">{__('Back')}</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleReset}
className="gap-2"
title={__('Reset to Default')}
>
<RotateCcw className="h-4 w-4" />
<span className="hidden sm:inline">{__('Reset to Default')}</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setTestEmailDialogOpen(true)}
className="gap-2"
title={__('Send Test')}
>
<Send className="h-4 w-4" />
<span className="hidden sm:inline">{__('Send Test')}</span>
</Button>
</div>
}
>
<Card>
<CardContent className="pt-6 space-y-6">
{/* Subject */}
<div className="space-y-2">
<Label htmlFor="subject">{__('Subject / Title')}</Label>
<Input
id="subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder={__('Enter notification subject')}
/>
<p className="text-xs text-muted-foreground">
{channelId === 'email'
? __('Email subject line')
: __('Push notification title')}
</p>
</div>
{/* Body */}
<div className="space-y-4">
{/* Three-tab system: Preview | Visual | Markdown */}
<div className="flex items-center justify-between">
<Label>{__('Message Body')}</Label>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-auto">
<TabsList className="grid grid-cols-3">
<TabsTrigger value="preview" className="flex items-center gap-1 text-xs">
<Eye className="h-3 w-3" />
{__('Preview')}
</TabsTrigger>
<TabsTrigger value="visual" className="flex items-center gap-1 text-xs">
<Edit className="h-3 w-3" />
{__('Visual')}
</TabsTrigger>
<TabsTrigger value="markdown" className="flex items-center gap-1 text-xs">
<FileText className="h-3 w-3" />
{__('Markdown')}
</TabsTrigger>
</TabsList>
</Tabs>
</div>
{/* Preview Tab */}
{activeTab === 'preview' && (
<div className="border rounded-md overflow-hidden">
<iframe
srcDoc={generatePreviewHTML()}
className="w-full min-h-[600px] overflow-hidden bg-white"
title={__('Email Preview')}
/>
</div>
)}
{/* Visual Tab */}
{activeTab === 'visual' && (
<div>
<EmailBuilder
blocks={blocks}
onChange={handleBlocksChange}
variables={variableKeys}
/>
<p className="text-xs text-muted-foreground mt-2">
{__('Build your email visually. Add blocks, edit content, and switch to Preview to see your branding.')}
</p>
</div>
)}
{/* Markdown Tab */}
{activeTab === 'markdown' && (
<div className="space-y-2">
<CodeEditor
value={markdownContent}
onChange={handleMarkdownChange}
placeholder={__('Write in Markdown... Easy and mobile-friendly!')}
supportMarkdown={true}
/>
<p className="text-xs text-muted-foreground">
{__('Write in Markdown - easy to type, even on mobile! Use **bold**, ## headings, [card]...[/card], etc.')}
</p>
<p className="text-xs text-muted-foreground">
{__('All changes are automatically synced between Visual and Markdown modes.')}
</p>
</div>
)}
</div>
</CardContent>
</Card>
</SettingsLayout>
{/* Send Test Email Dialog */}
<Dialog open={testEmailDialogOpen} onOpenChange={setTestEmailDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{__('Send Test Email')}</DialogTitle>
<DialogDescription>
{__('Send a test email with sample data to verify the template looks correct.')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 p-4">
<div className="space-y-2">
<Label htmlFor="test-email">{__('Email Address')}</Label>
<Input
id="test-email"
type="email"
value={testEmail}
onChange={(e) => setTestEmail(e.target.value)}
placeholder="you@example.com"
/>
<p className="text-xs text-muted-foreground">
{__('The subject will be prefixed with [TEST]')}
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setTestEmailDialogOpen(false)}>
{__('Cancel')}
</Button>
<Button onClick={handleSendTest} disabled={sendTestMutation.isPending}>
{sendTestMutation.isPending ? __('Sending...') : __('Send Test')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}