first commit
This commit is contained in:
961
frontend/src/app/admin/emails/page.tsx
Normal file
961
frontend/src/app/admin/emails/page.tsx
Normal file
@@ -0,0 +1,961 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { emailsApi, EmailTemplate, EmailLog, EmailStats } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import {
|
||||
EnvelopeIcon,
|
||||
PencilIcon,
|
||||
DocumentDuplicateIcon,
|
||||
EyeIcon,
|
||||
PaperAirplaneIcon,
|
||||
ClockIcon,
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
|
||||
type TabType = 'templates' | 'logs' | 'compose';
|
||||
|
||||
const DRAFT_STORAGE_KEY = 'spanglish-email-draft';
|
||||
|
||||
interface EmailDraft {
|
||||
eventId: string;
|
||||
templateSlug: string;
|
||||
customSubject: string;
|
||||
customBody: string;
|
||||
recipientFilter: 'all' | 'confirmed' | 'pending' | 'checked_in';
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
export default function AdminEmailsPage() {
|
||||
const { t, locale } = useLanguage();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('templates');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Templates state
|
||||
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
||||
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
|
||||
const [showTemplateForm, setShowTemplateForm] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Logs state
|
||||
const [logs, setLogs] = useState<EmailLog[]>([]);
|
||||
const [logsOffset, setLogsOffset] = useState(0);
|
||||
const [logsTotal, setLogsTotal] = useState(0);
|
||||
const [selectedLog, setSelectedLog] = useState<EmailLog | null>(null);
|
||||
|
||||
// Stats state
|
||||
const [stats, setStats] = useState<EmailStats | null>(null);
|
||||
|
||||
// Preview state
|
||||
const [previewHtml, setPreviewHtml] = useState<string | null>(null);
|
||||
const [previewSubject, setPreviewSubject] = useState<string>('');
|
||||
|
||||
// Template form state
|
||||
const [templateForm, setTemplateForm] = useState({
|
||||
name: '',
|
||||
slug: '',
|
||||
subject: '',
|
||||
subjectEs: '',
|
||||
bodyHtml: '',
|
||||
bodyHtmlEs: '',
|
||||
bodyText: '',
|
||||
bodyTextEs: '',
|
||||
description: '',
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
// Compose/Draft state
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [composeForm, setComposeForm] = useState<EmailDraft>({
|
||||
eventId: '',
|
||||
templateSlug: '',
|
||||
customSubject: '',
|
||||
customBody: '',
|
||||
recipientFilter: 'confirmed',
|
||||
savedAt: '',
|
||||
});
|
||||
const [hasDraft, setHasDraft] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [showRecipientPreview, setShowRecipientPreview] = useState(false);
|
||||
const [previewRecipients, setPreviewRecipients] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
loadEvents();
|
||||
loadDraft();
|
||||
}, []);
|
||||
|
||||
const loadEvents = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/events', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
|
||||
},
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setEvents(data.events || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load events');
|
||||
}
|
||||
};
|
||||
|
||||
const loadDraft = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem(DRAFT_STORAGE_KEY);
|
||||
if (saved) {
|
||||
const draft = JSON.parse(saved) as EmailDraft;
|
||||
setComposeForm(draft);
|
||||
setHasDraft(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load draft');
|
||||
}
|
||||
};
|
||||
|
||||
const saveDraft = () => {
|
||||
try {
|
||||
const draft: EmailDraft = {
|
||||
...composeForm,
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draft));
|
||||
setHasDraft(true);
|
||||
toast.success('Draft saved');
|
||||
} catch (error) {
|
||||
toast.error('Failed to save draft');
|
||||
}
|
||||
};
|
||||
|
||||
const clearDraft = () => {
|
||||
localStorage.removeItem(DRAFT_STORAGE_KEY);
|
||||
setComposeForm({
|
||||
eventId: '',
|
||||
templateSlug: '',
|
||||
customSubject: '',
|
||||
customBody: '',
|
||||
recipientFilter: 'confirmed',
|
||||
savedAt: '',
|
||||
});
|
||||
setHasDraft(false);
|
||||
};
|
||||
|
||||
const loadRecipientPreview = async () => {
|
||||
if (!composeForm.eventId) {
|
||||
toast.error('Please select an event');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/events/${composeForm.eventId}/attendees`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
|
||||
},
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
let attendees = data.attendees || [];
|
||||
|
||||
// Apply filter
|
||||
if (composeForm.recipientFilter !== 'all') {
|
||||
attendees = attendees.filter((a: any) => a.status === composeForm.recipientFilter);
|
||||
}
|
||||
|
||||
setPreviewRecipients(attendees);
|
||||
setShowRecipientPreview(true);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to load recipients');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendEmail = async () => {
|
||||
if (!composeForm.eventId || !composeForm.templateSlug) {
|
||||
toast.error('Please select an event and template');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to send this email to ${previewRecipients.length} recipients?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
const res = await emailsApi.sendToEvent(composeForm.eventId, {
|
||||
templateSlug: composeForm.templateSlug,
|
||||
recipientFilter: composeForm.recipientFilter,
|
||||
customVariables: composeForm.customBody ? { customMessage: composeForm.customBody } : undefined,
|
||||
});
|
||||
|
||||
if (res.success || res.sentCount > 0) {
|
||||
toast.success(`Sent ${res.sentCount} emails successfully`);
|
||||
if (res.failedCount > 0) {
|
||||
toast.error(`${res.failedCount} emails failed`);
|
||||
}
|
||||
clearDraft();
|
||||
setShowRecipientPreview(false);
|
||||
} else {
|
||||
toast.error('Failed to send emails');
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to send emails');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'logs') {
|
||||
loadLogs();
|
||||
}
|
||||
}, [activeTab, logsOffset]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [templatesRes, statsRes] = await Promise.all([
|
||||
emailsApi.getTemplates(),
|
||||
emailsApi.getStats(),
|
||||
]);
|
||||
setTemplates(templatesRes.templates);
|
||||
setStats(statsRes.stats);
|
||||
} catch (error) {
|
||||
toast.error('Failed to load email data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadLogs = async () => {
|
||||
try {
|
||||
const res = await emailsApi.getLogs({ limit: 20, offset: logsOffset });
|
||||
setLogs(res.logs);
|
||||
setLogsTotal(res.pagination.total);
|
||||
} catch (error) {
|
||||
toast.error('Failed to load email logs');
|
||||
}
|
||||
};
|
||||
|
||||
const resetTemplateForm = () => {
|
||||
setTemplateForm({
|
||||
name: '',
|
||||
slug: '',
|
||||
subject: '',
|
||||
subjectEs: '',
|
||||
bodyHtml: '',
|
||||
bodyHtmlEs: '',
|
||||
bodyText: '',
|
||||
bodyTextEs: '',
|
||||
description: '',
|
||||
isActive: true,
|
||||
});
|
||||
setEditingTemplate(null);
|
||||
};
|
||||
|
||||
const handleEditTemplate = (template: EmailTemplate) => {
|
||||
setTemplateForm({
|
||||
name: template.name,
|
||||
slug: template.slug,
|
||||
subject: template.subject,
|
||||
subjectEs: template.subjectEs || '',
|
||||
bodyHtml: template.bodyHtml,
|
||||
bodyHtmlEs: template.bodyHtmlEs || '',
|
||||
bodyText: template.bodyText || '',
|
||||
bodyTextEs: template.bodyTextEs || '',
|
||||
description: template.description || '',
|
||||
isActive: template.isActive,
|
||||
});
|
||||
setEditingTemplate(template);
|
||||
setShowTemplateForm(true);
|
||||
};
|
||||
|
||||
const handleSaveTemplate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const data = {
|
||||
...templateForm,
|
||||
subjectEs: templateForm.subjectEs || undefined,
|
||||
bodyHtmlEs: templateForm.bodyHtmlEs || undefined,
|
||||
bodyText: templateForm.bodyText || undefined,
|
||||
bodyTextEs: templateForm.bodyTextEs || undefined,
|
||||
description: templateForm.description || undefined,
|
||||
};
|
||||
|
||||
if (editingTemplate) {
|
||||
await emailsApi.updateTemplate(editingTemplate.id, data);
|
||||
toast.success('Template updated');
|
||||
} else {
|
||||
await emailsApi.createTemplate(data);
|
||||
toast.success('Template created');
|
||||
}
|
||||
|
||||
setShowTemplateForm(false);
|
||||
resetTemplateForm();
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to save template');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewTemplate = async (template: EmailTemplate) => {
|
||||
try {
|
||||
const res = await emailsApi.preview({
|
||||
templateSlug: template.slug,
|
||||
variables: {
|
||||
attendeeName: 'John Doe',
|
||||
attendeeEmail: 'john@example.com',
|
||||
ticketId: 'TKT-ABC123',
|
||||
eventTitle: 'Spanglish Night - January Edition',
|
||||
eventDate: 'January 28, 2026',
|
||||
eventTime: '7:00 PM',
|
||||
eventLocation: 'Casa Cultural, Asunción',
|
||||
eventLocationUrl: 'https://maps.google.com',
|
||||
eventPrice: '50,000 PYG',
|
||||
paymentAmount: '50,000 PYG',
|
||||
paymentMethod: 'Lightning',
|
||||
paymentReference: 'PAY-XYZ789',
|
||||
paymentDate: 'January 28, 2026',
|
||||
customMessage: 'This is a preview message.',
|
||||
},
|
||||
locale,
|
||||
});
|
||||
setPreviewSubject(res.subject);
|
||||
setPreviewHtml(res.bodyHtml);
|
||||
} catch (error) {
|
||||
toast.error('Failed to preview template');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTemplate = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this template?')) return;
|
||||
|
||||
try {
|
||||
await emailsApi.deleteTemplate(id);
|
||||
toast.success('Template deleted');
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to delete template');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'sent':
|
||||
return <CheckCircleIcon className="w-5 h-5 text-green-500" />;
|
||||
case 'failed':
|
||||
return <XCircleIcon className="w-5 h-5 text-red-500" />;
|
||||
case 'pending':
|
||||
return <ClockIcon className="w-5 h-5 text-yellow-500" />;
|
||||
case 'bounced':
|
||||
return <ExclamationTriangleIcon className="w-5 h-5 text-orange-500" />;
|
||||
default:
|
||||
return <ClockIcon className="w-5 h-5 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-primary-dark">Email Center</h1>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<EnvelopeIcon className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{stats.total}</p>
|
||||
<p className="text-sm text-gray-500">Total Sent</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<CheckCircleIcon className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{stats.sent}</p>
|
||||
<p className="text-sm text-gray-500">Delivered</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center">
|
||||
<ClockIcon className="w-5 h-5 text-yellow-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{stats.pending}</p>
|
||||
<p className="text-sm text-gray-500">Pending</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<XCircleIcon className="w-5 h-5 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{stats.failed}</p>
|
||||
<p className="text-sm text-gray-500">Failed</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-secondary-light-gray mb-6">
|
||||
<nav className="flex gap-6">
|
||||
{(['templates', 'compose', 'logs'] as TabType[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={clsx(
|
||||
'py-3 px-1 border-b-2 font-medium text-sm transition-colors relative',
|
||||
{
|
||||
'border-primary-yellow text-primary-dark': activeTab === tab,
|
||||
'border-transparent text-gray-500 hover:text-gray-700': activeTab !== tab,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{tab === 'templates' ? 'Templates' : tab === 'compose' ? 'Compose' : 'Email Logs'}
|
||||
{tab === 'compose' && hasDraft && (
|
||||
<span className="absolute -top-1 -right-2 w-2 h-2 bg-primary-yellow rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Templates Tab */}
|
||||
{activeTab === 'templates' && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<p className="text-gray-600">Manage email templates for booking confirmations, receipts, and updates.</p>
|
||||
<Button onClick={() => { resetTemplateForm(); setShowTemplateForm(true); }}>
|
||||
Create Template
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{templates.map((template) => (
|
||||
<Card key={template.id} className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-lg">{template.name}</h3>
|
||||
{template.isSystem && (
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded">System</span>
|
||||
)}
|
||||
{!template.isActive && (
|
||||
<span className="text-xs bg-red-100 text-red-600 px-2 py-0.5 rounded">Inactive</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">{template.slug}</p>
|
||||
<p className="text-sm text-gray-600 mt-2">{template.description || 'No description'}</p>
|
||||
<p className="text-sm font-medium mt-2">Subject: {template.subject}</p>
|
||||
{template.variables && template.variables.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{template.variables.slice(0, 5).map((v: any) => (
|
||||
<span key={v.name} className="text-xs bg-primary-yellow/20 text-primary-dark px-2 py-0.5 rounded">
|
||||
{`{{${v.name}}}`}
|
||||
</span>
|
||||
))}
|
||||
{template.variables.length > 5 && (
|
||||
<span className="text-xs text-gray-500">+{template.variables.length - 5} more</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handlePreviewTemplate(template)}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn"
|
||||
title="Preview"
|
||||
>
|
||||
<EyeIcon className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEditTemplate(template)}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn"
|
||||
title="Edit"
|
||||
>
|
||||
<PencilIcon className="w-5 h-5" />
|
||||
</button>
|
||||
{!template.isSystem && (
|
||||
<button
|
||||
onClick={() => handleDeleteTemplate(template.id)}
|
||||
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
|
||||
title="Delete"
|
||||
>
|
||||
<XCircleIcon className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Compose Tab */}
|
||||
{activeTab === 'compose' && (
|
||||
<div>
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold">Compose Email to Event Attendees</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasDraft && (
|
||||
<span className="text-xs text-gray-500">
|
||||
Draft saved {composeForm.savedAt ? new Date(composeForm.savedAt).toLocaleString() : ''}
|
||||
</span>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={saveDraft}>
|
||||
Save Draft
|
||||
</Button>
|
||||
{hasDraft && (
|
||||
<Button variant="ghost" size="sm" onClick={clearDraft}>
|
||||
Clear Draft
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Event Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Select Event *</label>
|
||||
<select
|
||||
value={composeForm.eventId}
|
||||
onChange={(e) => setComposeForm({ ...composeForm, eventId: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||
>
|
||||
<option value="">Choose an event</option>
|
||||
{events.filter(e => e.status === 'published').map((event) => (
|
||||
<option key={event.id} value={event.id}>
|
||||
{event.title} - {new Date(event.startDatetime).toLocaleDateString()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Recipient Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Recipients</label>
|
||||
<select
|
||||
value={composeForm.recipientFilter}
|
||||
onChange={(e) => setComposeForm({ ...composeForm, recipientFilter: e.target.value as any })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||
>
|
||||
<option value="all">All attendees</option>
|
||||
<option value="confirmed">Confirmed only</option>
|
||||
<option value="pending">Pending only</option>
|
||||
<option value="checked_in">Checked in only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Template Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Email Template *</label>
|
||||
<select
|
||||
value={composeForm.templateSlug}
|
||||
onChange={(e) => setComposeForm({ ...composeForm, templateSlug: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||
>
|
||||
<option value="">Choose a template</option>
|
||||
{templates.filter(t => t.isActive).map((template) => (
|
||||
<option key={template.id} value={template.slug}>
|
||||
{template.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Custom Message */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Custom Message (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={composeForm.customBody}
|
||||
onChange={(e) => setComposeForm({ ...composeForm, customBody: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||
rows={4}
|
||||
placeholder="Add a custom message that will be included in the email..."
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
This will be available as {'{{customMessage}}'} in the template
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-4 border-t border-secondary-light-gray">
|
||||
<Button
|
||||
onClick={loadRecipientPreview}
|
||||
disabled={!composeForm.eventId}
|
||||
>
|
||||
Preview Recipients
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Recipient Preview Modal */}
|
||||
{showRecipientPreview && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<div className="p-4 border-b border-secondary-light-gray">
|
||||
<h2 className="text-lg font-bold">Recipient Preview</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
{previewRecipients.length} recipient(s) will receive this email
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{previewRecipients.length === 0 ? (
|
||||
<p className="text-center text-gray-500 py-8">
|
||||
No recipients match your filter criteria
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{previewRecipients.map((recipient: any) => (
|
||||
<div
|
||||
key={recipient.id}
|
||||
className="flex items-center justify-between p-3 bg-secondary-gray rounded-btn"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-sm">{recipient.attendeeFirstName} {recipient.attendeeLastName || ''}</p>
|
||||
<p className="text-xs text-gray-500">{recipient.attendeeEmail}</p>
|
||||
</div>
|
||||
<span className={clsx('badge text-xs', {
|
||||
'badge-success': recipient.status === 'confirmed',
|
||||
'badge-warning': recipient.status === 'pending',
|
||||
'badge-info': recipient.status === 'checked_in',
|
||||
'badge-gray': recipient.status === 'cancelled',
|
||||
})}>
|
||||
{recipient.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-secondary-light-gray flex gap-3">
|
||||
<Button
|
||||
onClick={handleSendEmail}
|
||||
isLoading={sending}
|
||||
disabled={previewRecipients.length === 0}
|
||||
>
|
||||
Send to {previewRecipients.length} Recipients
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowRecipientPreview(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logs Tab */}
|
||||
{activeTab === 'logs' && (
|
||||
<div>
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-secondary-gray">
|
||||
<tr>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Recipient</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Subject</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Sent</th>
|
||||
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-secondary-light-gray">
|
||||
{logs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
|
||||
No emails sent yet
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<tr key={log.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(log.status)}
|
||||
<span className="capitalize text-sm">{log.status}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<p className="font-medium text-sm">{log.recipientName || 'Unknown'}</p>
|
||||
<p className="text-sm text-gray-500">{log.recipientEmail}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 max-w-xs">
|
||||
<p className="text-sm truncate">{log.subject}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{formatDate(log.sentAt || log.createdAt)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedLog(log)}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn"
|
||||
title="View Email"
|
||||
>
|
||||
<EyeIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{logsTotal > 20 && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-secondary-light-gray">
|
||||
<p className="text-sm text-gray-600">
|
||||
Showing {logsOffset + 1}-{Math.min(logsOffset + 20, logsTotal)} of {logsTotal}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={logsOffset === 0}
|
||||
onClick={() => setLogsOffset(Math.max(0, logsOffset - 20))}
|
||||
>
|
||||
<ChevronLeftIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={logsOffset + 20 >= logsTotal}
|
||||
onClick={() => setLogsOffset(logsOffset + 20)}
|
||||
>
|
||||
<ChevronRightIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Template Form Modal */}
|
||||
{showTemplateForm && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-4xl max-h-[90vh] overflow-y-auto p-6">
|
||||
<h2 className="text-xl font-bold mb-6">
|
||||
{editingTemplate ? 'Edit Template' : 'Create Template'}
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSaveTemplate} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Template Name"
|
||||
value={templateForm.name}
|
||||
onChange={(e) => setTemplateForm({ ...templateForm, name: e.target.value })}
|
||||
required
|
||||
placeholder="e.g., Booking Confirmation"
|
||||
/>
|
||||
<Input
|
||||
label="Slug (unique identifier)"
|
||||
value={templateForm.slug}
|
||||
onChange={(e) => setTemplateForm({ ...templateForm, slug: e.target.value })}
|
||||
required
|
||||
disabled={editingTemplate?.isSystem}
|
||||
placeholder="e.g., booking-confirmation"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Subject (English)"
|
||||
value={templateForm.subject}
|
||||
onChange={(e) => setTemplateForm({ ...templateForm, subject: e.target.value })}
|
||||
required
|
||||
placeholder="e.g., Your Spanglish ticket is confirmed"
|
||||
/>
|
||||
<Input
|
||||
label="Subject (Spanish)"
|
||||
value={templateForm.subjectEs}
|
||||
onChange={(e) => setTemplateForm({ ...templateForm, subjectEs: e.target.value })}
|
||||
placeholder="e.g., Tu entrada de Spanglish está confirmada"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Body HTML (English)</label>
|
||||
<textarea
|
||||
value={templateForm.bodyHtml}
|
||||
onChange={(e) => setTemplateForm({ ...templateForm, bodyHtml: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow font-mono text-sm"
|
||||
rows={8}
|
||||
required
|
||||
placeholder="<h2>Your Booking is Confirmed!</h2>..."
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Use {`{{variableName}}`} for dynamic content. Common variables: attendeeName, eventTitle, eventDate, ticketId
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Body HTML (Spanish)</label>
|
||||
<textarea
|
||||
value={templateForm.bodyHtmlEs}
|
||||
onChange={(e) => setTemplateForm({ ...templateForm, bodyHtmlEs: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow font-mono text-sm"
|
||||
rows={6}
|
||||
placeholder="<h2>¡Tu Reserva está Confirmada!</h2>..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Description</label>
|
||||
<textarea
|
||||
value={templateForm.description}
|
||||
onChange={(e) => setTemplateForm({ ...templateForm, description: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
rows={2}
|
||||
placeholder="What is this template used for?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isActive"
|
||||
checked={templateForm.isActive}
|
||||
onChange={(e) => setTemplateForm({ ...templateForm, isActive: e.target.checked })}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm">Template is active</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button type="submit" isLoading={saving}>
|
||||
{editingTemplate ? 'Update Template' : 'Create Template'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => { setShowTemplateForm(false); resetTemplateForm(); }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview Modal */}
|
||||
{previewHtml && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold">Email Preview</h2>
|
||||
<p className="text-sm text-gray-500">Subject: {previewSubject}</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setPreviewHtml(null)}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<iframe
|
||||
srcDoc={previewHtml}
|
||||
className="w-full h-full min-h-[500px]"
|
||||
title="Email Preview"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log Detail Modal */}
|
||||
{selectedLog && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold">Email Details</h2>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{getStatusIcon(selectedLog.status)}
|
||||
<span className="capitalize text-sm">{selectedLog.status}</span>
|
||||
{selectedLog.errorMessage && (
|
||||
<span className="text-sm text-red-500">- {selectedLog.errorMessage}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setSelectedLog(null)}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-4 space-y-2 border-b border-secondary-light-gray bg-gray-50">
|
||||
<p><strong>To:</strong> {selectedLog.recipientName} <{selectedLog.recipientEmail}></p>
|
||||
<p><strong>Subject:</strong> {selectedLog.subject}</p>
|
||||
<p><strong>Sent:</strong> {formatDate(selectedLog.sentAt || selectedLog.createdAt)}</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{selectedLog.bodyHtml ? (
|
||||
<iframe
|
||||
srcDoc={selectedLog.bodyHtml}
|
||||
className="w-full h-full min-h-[400px]"
|
||||
title="Email Content"
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-gray-500">Email content not available</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user