962 lines
39 KiB
TypeScript
962 lines
39 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
Mail,
|
|
Plus,
|
|
BarChart3,
|
|
FileText,
|
|
X,
|
|
Layers,
|
|
Target
|
|
} from 'lucide-react';
|
|
import { emailCampaignService, Campaign, CampaignSegment, EmailTemplate, DripSequence, CampaignAnalytics } from '../../services/api/emailCampaignService';
|
|
import { toast } from 'react-toastify';
|
|
import Loading from '../../components/common/Loading';
|
|
import Pagination from '../../components/common/Pagination';
|
|
import { formatDate } from '../../utils/format';
|
|
|
|
type CampaignTab = 'campaigns' | 'segments' | 'templates' | 'drip-sequences' | 'analytics';
|
|
|
|
const EmailCampaignManagementPage: React.FC = () => {
|
|
const [activeTab, setActiveTab] = useState<CampaignTab>('campaigns');
|
|
const [loading, setLoading] = useState(false);
|
|
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
|
const [segments, setSegments] = useState<CampaignSegment[]>([]);
|
|
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
|
const [dripSequences, setDripSequences] = useState<DripSequence[]>([]);
|
|
useState<Campaign | null>(null);
|
|
const [analytics, setAnalytics] = useState<CampaignAnalytics | null>(null);
|
|
const [showCampaignModal, setShowCampaignModal] = useState(false);
|
|
const [showSegmentModal, setShowSegmentModal] = useState(false);
|
|
const [showTemplateModal, setShowTemplateModal] = useState(false);
|
|
const [showDripModal, setShowDripModal] = useState(false);
|
|
const [editingItem, setEditingItem] = useState<any>(null);
|
|
const [dripForm, setDripForm] = useState({
|
|
name: '',
|
|
description: '',
|
|
trigger_event: ''
|
|
});
|
|
const [filters, setFilters] = useState({
|
|
status: '',
|
|
campaign_type: ''
|
|
});
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
|
|
const [campaignForm, setCampaignForm] = useState({
|
|
name: '',
|
|
subject: '',
|
|
html_content: '',
|
|
text_content: '',
|
|
campaign_type: 'newsletter',
|
|
segment_id: undefined as number | undefined,
|
|
scheduled_at: '',
|
|
template_id: undefined as number | undefined,
|
|
from_name: '',
|
|
from_email: '',
|
|
track_opens: true,
|
|
track_clicks: true
|
|
});
|
|
|
|
const [segmentForm, setSegmentForm] = useState({
|
|
name: '',
|
|
description: '',
|
|
criteria: {
|
|
role: '',
|
|
has_bookings: undefined as boolean | undefined,
|
|
is_vip: undefined as boolean | undefined,
|
|
last_booking_days: undefined as number | undefined
|
|
}
|
|
});
|
|
|
|
const [templateForm, setTemplateForm] = useState({
|
|
name: '',
|
|
subject: '',
|
|
html_content: '',
|
|
text_content: '',
|
|
category: ''
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (activeTab === 'campaigns') {
|
|
fetchCampaigns();
|
|
} else if (activeTab === 'segments') {
|
|
fetchSegments();
|
|
} else if (activeTab === 'templates') {
|
|
fetchTemplates();
|
|
} else if (activeTab === 'drip-sequences') {
|
|
fetchDripSequences();
|
|
}
|
|
}, [activeTab, filters, currentPage]);
|
|
|
|
const fetchCampaigns = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await emailCampaignService.getCampaigns({
|
|
status: filters.status || undefined,
|
|
campaign_type: filters.campaign_type || undefined,
|
|
limit: 20,
|
|
offset: (currentPage - 1) * 20
|
|
});
|
|
setCampaigns(data);
|
|
setTotalPages(Math.ceil(data.length / 20));
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.message || 'Failed to fetch campaigns');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchSegments = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await emailCampaignService.getSegments();
|
|
setSegments(data);
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.message || 'Failed to fetch segments');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchTemplates = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await emailCampaignService.getTemplates();
|
|
setTemplates(data);
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.message || 'Failed to fetch templates');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchDripSequences = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await emailCampaignService.getDripSequences();
|
|
setDripSequences(data);
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.message || 'Failed to fetch drip sequences');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleCreateCampaign = async () => {
|
|
try {
|
|
if (editingItem) {
|
|
await emailCampaignService.updateCampaign(editingItem.id, campaignForm);
|
|
toast.success('Campaign updated');
|
|
} else {
|
|
await emailCampaignService.createCampaign(campaignForm);
|
|
toast.success('Campaign created');
|
|
}
|
|
setShowCampaignModal(false);
|
|
resetCampaignForm();
|
|
fetchCampaigns();
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.message || 'Failed to save campaign');
|
|
}
|
|
};
|
|
|
|
const handleSendCampaign = async (campaignId: number) => {
|
|
if (!window.confirm('Are you sure you want to send this campaign?')) return;
|
|
try {
|
|
const result = await emailCampaignService.sendCampaign(campaignId);
|
|
toast.success(`Campaign sent! ${result.sent} emails sent, ${result.failed} failed`);
|
|
fetchCampaigns();
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.message || 'Failed to send campaign');
|
|
}
|
|
};
|
|
|
|
const handleViewAnalytics = async (campaignId: number) => {
|
|
try {
|
|
const data = await emailCampaignService.getCampaignAnalytics(campaignId);
|
|
setAnalytics(data);
|
|
setActiveTab('analytics');
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.message || 'Failed to fetch analytics');
|
|
}
|
|
};
|
|
|
|
const handleCreateSegment = async () => {
|
|
try {
|
|
// Build criteria object
|
|
const criteria: any = {};
|
|
if (segmentForm.criteria.role) criteria.role = segmentForm.criteria.role;
|
|
if (segmentForm.criteria.has_bookings !== undefined) criteria.has_bookings = segmentForm.criteria.has_bookings;
|
|
if (segmentForm.criteria.is_vip !== undefined) criteria.is_vip = segmentForm.criteria.is_vip;
|
|
if (segmentForm.criteria.last_booking_days) criteria.last_booking_days = segmentForm.criteria.last_booking_days;
|
|
|
|
await emailCampaignService.createSegment({
|
|
name: segmentForm.name,
|
|
description: segmentForm.description,
|
|
criteria
|
|
});
|
|
toast.success('Segment created');
|
|
setShowSegmentModal(false);
|
|
resetSegmentForm();
|
|
fetchSegments();
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.message || 'Failed to create segment');
|
|
}
|
|
};
|
|
|
|
const handleCreateTemplate = async () => {
|
|
try {
|
|
await emailCampaignService.createTemplate(templateForm);
|
|
toast.success('Template created');
|
|
setShowTemplateModal(false);
|
|
resetTemplateForm();
|
|
fetchTemplates();
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.message || 'Failed to create template');
|
|
}
|
|
};
|
|
|
|
const handleCreateDripSequence = async () => {
|
|
try {
|
|
await emailCampaignService.createDripSequence(dripForm);
|
|
toast.success('Drip sequence created');
|
|
setShowDripModal(false);
|
|
resetDripForm();
|
|
fetchDripSequences();
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.message || 'Failed to create drip sequence');
|
|
}
|
|
};
|
|
|
|
const resetCampaignForm = () => {
|
|
setCampaignForm({
|
|
name: '',
|
|
subject: '',
|
|
html_content: '',
|
|
text_content: '',
|
|
campaign_type: 'newsletter',
|
|
segment_id: undefined,
|
|
scheduled_at: '',
|
|
template_id: undefined,
|
|
from_name: '',
|
|
from_email: '',
|
|
track_opens: true,
|
|
track_clicks: true
|
|
});
|
|
setEditingItem(null);
|
|
};
|
|
|
|
const resetSegmentForm = () => {
|
|
setSegmentForm({
|
|
name: '',
|
|
description: '',
|
|
criteria: {
|
|
role: '',
|
|
has_bookings: undefined,
|
|
is_vip: undefined,
|
|
last_booking_days: undefined
|
|
}
|
|
});
|
|
};
|
|
|
|
const resetTemplateForm = () => {
|
|
setTemplateForm({
|
|
name: '',
|
|
subject: '',
|
|
html_content: '',
|
|
text_content: '',
|
|
category: ''
|
|
});
|
|
};
|
|
|
|
const resetDripForm = () => {
|
|
setDripForm({
|
|
name: '',
|
|
description: '',
|
|
trigger_event: ''
|
|
});
|
|
};
|
|
|
|
const getStatusColor = (status: string) => {
|
|
switch (status) {
|
|
case 'sent': return 'bg-green-100 text-green-800';
|
|
case 'sending': return 'bg-blue-100 text-blue-800';
|
|
case 'scheduled': return 'bg-yellow-100 text-yellow-800';
|
|
case 'draft': return 'bg-gray-100 text-gray-800';
|
|
case 'paused': return 'bg-orange-100 text-orange-800';
|
|
default: return 'bg-gray-100 text-gray-800';
|
|
}
|
|
};
|
|
|
|
if (loading && !campaigns.length && !segments.length && !templates.length) {
|
|
return <Loading />;
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
|
|
{/* Header */}
|
|
<div className="relative">
|
|
<div className="absolute inset-0 bg-gradient-to-r from-blue-400/5 via-transparent to-purple-600/5 rounded-3xl blur-3xl"></div>
|
|
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-2xl border border-blue-200/30 p-8">
|
|
<div className="flex items-center gap-5">
|
|
<div className="relative">
|
|
<div className="absolute inset-0 bg-gradient-to-br from-blue-400 to-purple-600 rounded-2xl blur-lg opacity-50"></div>
|
|
<div className="relative p-4 rounded-2xl bg-gradient-to-br from-blue-500 via-blue-500 to-purple-600 shadow-xl border border-blue-400/50">
|
|
<Mail className="w-8 h-8 text-white" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h1 className="text-4xl font-extrabold bg-gradient-to-r from-slate-900 via-blue-700 to-slate-900 bg-clip-text text-transparent">
|
|
Email Marketing & Campaigns
|
|
</h1>
|
|
<p className="text-gray-600 mt-2">Create, manage, and track email campaigns</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="bg-white rounded-2xl shadow-lg border border-gray-200 p-2">
|
|
<div className="flex flex-wrap gap-2">
|
|
{[
|
|
{ id: 'campaigns', label: 'Campaigns', icon: Mail },
|
|
{ id: 'segments', label: 'Segments', icon: Target },
|
|
{ id: 'templates', label: 'Templates', icon: FileText },
|
|
{ id: 'drip-sequences', label: 'Drip Campaigns', icon: Layers },
|
|
{ id: 'analytics', label: 'Analytics', icon: BarChart3 }
|
|
].map(tab => {
|
|
const Icon = tab.icon;
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setActiveTab(tab.id as CampaignTab)}
|
|
className={`flex items-center gap-2 px-4 py-2 rounded-xl font-medium transition-all ${
|
|
activeTab === tab.id
|
|
? 'bg-gradient-to-r from-blue-500 to-purple-500 text-white shadow-lg'
|
|
: 'text-gray-600 hover:bg-gray-100'
|
|
}`}
|
|
>
|
|
<Icon className="w-4 h-4" />
|
|
{tab.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="bg-white rounded-2xl shadow-lg border border-gray-200 p-6">
|
|
{activeTab === 'campaigns' && (
|
|
<div className="space-y-6">
|
|
<div className="flex justify-between items-center">
|
|
<h3 className="text-xl font-semibold">Email Campaigns</h3>
|
|
<button
|
|
onClick={() => {
|
|
resetCampaignForm();
|
|
setShowCampaignModal(true);
|
|
}}
|
|
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors flex items-center gap-2"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Create Campaign
|
|
</button>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<select
|
|
value={filters.status}
|
|
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
|
className="px-4 py-2 border border-gray-300 rounded-lg"
|
|
>
|
|
<option value="">All Statuses</option>
|
|
<option value="draft">Draft</option>
|
|
<option value="scheduled">Scheduled</option>
|
|
<option value="sending">Sending</option>
|
|
<option value="sent">Sent</option>
|
|
<option value="paused">Paused</option>
|
|
</select>
|
|
<select
|
|
value={filters.campaign_type}
|
|
onChange={(e) => setFilters({ ...filters, campaign_type: e.target.value })}
|
|
className="px-4 py-2 border border-gray-300 rounded-lg"
|
|
>
|
|
<option value="">All Types</option>
|
|
<option value="newsletter">Newsletter</option>
|
|
<option value="promotional">Promotional</option>
|
|
<option value="transactional">Transactional</option>
|
|
<option value="abandoned_booking">Abandoned Booking</option>
|
|
<option value="welcome">Welcome</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Campaigns Table */}
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b border-gray-200">
|
|
<th className="text-left py-3 px-4 font-semibold">Name</th>
|
|
<th className="text-left py-3 px-4 font-semibold">Type</th>
|
|
<th className="text-left py-3 px-4 font-semibold">Status</th>
|
|
<th className="text-left py-3 px-4 font-semibold">Recipients</th>
|
|
<th className="text-left py-3 px-4 font-semibold">Open Rate</th>
|
|
<th className="text-left py-3 px-4 font-semibold">Click Rate</th>
|
|
<th className="text-left py-3 px-4 font-semibold">Date</th>
|
|
<th className="text-left py-3 px-4 font-semibold">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{campaigns.map((campaign) => (
|
|
<tr key={campaign.id} className="border-b border-gray-100 hover:bg-gray-50">
|
|
<td className="py-3 px-4 font-medium">{campaign.name}</td>
|
|
<td className="py-3 px-4 text-sm text-gray-600">{campaign.campaign_type}</td>
|
|
<td className="py-3 px-4">
|
|
<span className={`px-2 py-1 rounded-full text-xs ${getStatusColor(campaign.status)}`}>
|
|
{campaign.status}
|
|
</span>
|
|
</td>
|
|
<td className="py-3 px-4">{campaign.total_recipients}</td>
|
|
<td className="py-3 px-4">
|
|
{campaign.open_rate !== null && campaign.open_rate !== undefined
|
|
? `${campaign.open_rate.toFixed(2)}%`
|
|
: '-'}
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
{campaign.click_rate !== null && campaign.click_rate !== undefined
|
|
? `${campaign.click_rate.toFixed(2)}%`
|
|
: '-'}
|
|
</td>
|
|
<td className="py-3 px-4 text-sm text-gray-600">
|
|
{campaign.sent_at ? formatDate(campaign.sent_at) : formatDate(campaign.created_at)}
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => handleViewAnalytics(campaign.id)}
|
|
className="px-3 py-1 bg-blue-500 text-white rounded-lg hover:bg-blue-600 text-sm"
|
|
>
|
|
Analytics
|
|
</button>
|
|
{campaign.status === 'draft' && (
|
|
<button
|
|
onClick={() => handleSendCampaign(campaign.id)}
|
|
className="px-3 py-1 bg-green-500 text-white rounded-lg hover:bg-green-600 text-sm"
|
|
>
|
|
Send
|
|
</button>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<Pagination
|
|
currentPage={currentPage}
|
|
totalPages={totalPages}
|
|
onPageChange={setCurrentPage}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'segments' && (
|
|
<SegmentsTab
|
|
segments={segments}
|
|
onRefresh={fetchSegments}
|
|
onCreate={() => setShowSegmentModal(true)}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === 'templates' && (
|
|
<TemplatesTab
|
|
templates={templates}
|
|
onRefresh={fetchTemplates}
|
|
onCreate={() => setShowTemplateModal(true)}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === 'drip-sequences' && (
|
|
<DripSequencesTab
|
|
sequences={dripSequences}
|
|
onRefresh={fetchDripSequences}
|
|
onCreate={() => {
|
|
resetDripForm();
|
|
setShowDripModal(true);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === 'analytics' && analytics && (
|
|
<AnalyticsTab analytics={analytics} />
|
|
)}
|
|
</div>
|
|
|
|
{/* Campaign Modal */}
|
|
{showCampaignModal && (
|
|
<CampaignModal
|
|
form={campaignForm}
|
|
setForm={setCampaignForm}
|
|
segments={segments}
|
|
templates={templates}
|
|
onSave={handleCreateCampaign}
|
|
onClose={() => {
|
|
setShowCampaignModal(false);
|
|
resetCampaignForm();
|
|
}}
|
|
editing={!!editingItem}
|
|
/>
|
|
)}
|
|
|
|
{/* Segment Modal */}
|
|
{showSegmentModal && (
|
|
<SegmentModal
|
|
form={segmentForm}
|
|
setForm={setSegmentForm}
|
|
onSave={handleCreateSegment}
|
|
onClose={() => {
|
|
setShowSegmentModal(false);
|
|
resetSegmentForm();
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Template Modal */}
|
|
{showTemplateModal && (
|
|
<TemplateModal
|
|
form={templateForm}
|
|
setForm={setTemplateForm}
|
|
onSave={handleCreateTemplate}
|
|
onClose={() => {
|
|
setShowTemplateModal(false);
|
|
resetTemplateForm();
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Drip Sequence Modal */}
|
|
{showDripModal && (
|
|
<DripSequenceModal
|
|
form={dripForm}
|
|
setForm={setDripForm}
|
|
onSave={handleCreateDripSequence}
|
|
onClose={() => {
|
|
setShowDripModal(false);
|
|
resetDripForm();
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Sub-components
|
|
const SegmentsTab: React.FC<{
|
|
segments: CampaignSegment[];
|
|
onRefresh: () => void;
|
|
onCreate: () => void;
|
|
}> = ({ segments, onCreate }) => (
|
|
<div className="space-y-4">
|
|
<div className="flex justify-between items-center">
|
|
<h3 className="text-xl font-semibold">Segments</h3>
|
|
<button
|
|
onClick={onCreate}
|
|
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
|
|
>
|
|
Create Segment
|
|
</button>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{segments.map((segment) => (
|
|
<div key={segment.id} className="border rounded-xl p-4">
|
|
<h4 className="font-semibold">{segment.name}</h4>
|
|
<p className="text-sm text-gray-600 mt-1">{segment.description}</p>
|
|
<p className="text-sm text-blue-600 mt-2">
|
|
Estimated: {segment.estimated_count || 0} users
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const TemplatesTab: React.FC<{
|
|
templates: EmailTemplate[];
|
|
onRefresh: () => void;
|
|
onCreate: () => void;
|
|
}> = ({ templates, onCreate }) => (
|
|
<div className="space-y-4">
|
|
<div className="flex justify-between items-center">
|
|
<h3 className="text-xl font-semibold">Email Templates</h3>
|
|
<button
|
|
onClick={onCreate}
|
|
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
|
|
>
|
|
Create Template
|
|
</button>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
{templates.map((template) => (
|
|
<div key={template.id} className="border rounded-xl p-4">
|
|
<h4 className="font-semibold">{template.name}</h4>
|
|
<p className="text-sm text-gray-600 mt-1">{template.subject}</p>
|
|
{template.category && (
|
|
<span className="inline-block mt-2 px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs">
|
|
{template.category}
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const DripSequencesTab: React.FC<{
|
|
sequences: DripSequence[];
|
|
onRefresh: () => void;
|
|
onCreate: () => void;
|
|
}> = ({ sequences, onCreate }) => (
|
|
<div className="space-y-4">
|
|
<div className="flex justify-between items-center">
|
|
<h3 className="text-xl font-semibold">Drip Sequences</h3>
|
|
<button
|
|
onClick={onCreate}
|
|
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors flex items-center gap-2"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Create Sequence
|
|
</button>
|
|
</div>
|
|
<div className="space-y-4">
|
|
{sequences.map((sequence) => (
|
|
<div key={sequence.id} className="border rounded-xl p-4">
|
|
<div className="flex justify-between items-start">
|
|
<div>
|
|
<h4 className="font-semibold">{sequence.name}</h4>
|
|
<p className="text-sm text-gray-600 mt-1">{sequence.description}</p>
|
|
<p className="text-sm text-blue-600 mt-2">
|
|
{sequence.step_count} steps
|
|
{sequence.trigger_event && ` • Trigger: ${sequence.trigger_event}`}
|
|
</p>
|
|
</div>
|
|
<button className="px-3 py-1 bg-blue-500 text-white rounded-lg text-sm">
|
|
Edit
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const AnalyticsTab: React.FC<{ analytics: CampaignAnalytics }> = ({ analytics }) => (
|
|
<div className="space-y-6">
|
|
<h3 className="text-xl font-semibold">Campaign Analytics</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
|
<div className="bg-blue-50 rounded-xl p-6 border border-blue-100">
|
|
<p className="text-sm text-blue-600">Open Rate</p>
|
|
<p className="text-3xl font-bold text-blue-800 mt-2">{analytics.open_rate.toFixed(2)}%</p>
|
|
</div>
|
|
<div className="bg-green-50 rounded-xl p-6 border border-green-100">
|
|
<p className="text-sm text-green-600">Click Rate</p>
|
|
<p className="text-3xl font-bold text-green-800 mt-2">{analytics.click_rate.toFixed(2)}%</p>
|
|
</div>
|
|
<div className="bg-purple-50 rounded-xl p-6 border border-purple-100">
|
|
<p className="text-sm text-purple-600">Total Opened</p>
|
|
<p className="text-3xl font-bold text-purple-800 mt-2">{analytics.total_opened}</p>
|
|
</div>
|
|
<div className="bg-orange-50 rounded-xl p-6 border border-orange-100">
|
|
<p className="text-sm text-orange-600">Total Clicked</p>
|
|
<p className="text-3xl font-bold text-orange-800 mt-2">{analytics.total_clicked}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const CampaignModal: React.FC<{
|
|
form: any;
|
|
setForm: (form: any) => void;
|
|
segments: CampaignSegment[];
|
|
templates: EmailTemplate[];
|
|
onSave: () => void;
|
|
onClose: () => void;
|
|
editing: boolean;
|
|
}> = ({ form, setForm, segments, onSave, onClose, editing }) => (
|
|
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-3 sm:p-4 animate-fade-in">
|
|
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-4xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden border border-slate-200/50 animate-scale-in">
|
|
<div className="sticky top-0 bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-4 sm:px-6 py-4 sm:py-5 border-b border-slate-700 z-10">
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<h4 className="text-lg sm:text-xl md:text-2xl font-bold text-amber-100">{editing ? 'Edit Campaign' : 'Create Campaign'}</h4>
|
|
<p className="text-amber-200/80 text-xs sm:text-sm font-light mt-1">
|
|
{editing ? 'Modify campaign details' : 'Create a new email campaign'}
|
|
</p>
|
|
</div>
|
|
<button onClick={onClose} className="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center rounded-xl text-amber-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-amber-400">
|
|
<X className="w-5 h-5 sm:w-6 sm:h-6" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
|
|
<div className="p-4 sm:p-6 space-y-4 sm:space-y-5">
|
|
<input
|
|
type="text"
|
|
placeholder="Campaign Name"
|
|
value={form.name}
|
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="Subject"
|
|
value={form.subject}
|
|
onChange={(e) => setForm({ ...form, subject: e.target.value })}
|
|
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
|
/>
|
|
<select
|
|
value={form.campaign_type}
|
|
onChange={(e) => setForm({ ...form, campaign_type: e.target.value })}
|
|
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm cursor-pointer"
|
|
>
|
|
<option value="newsletter">Newsletter</option>
|
|
<option value="promotional">Promotional</option>
|
|
<option value="transactional">Transactional</option>
|
|
<option value="abandoned_booking">Abandoned Booking</option>
|
|
<option value="welcome">Welcome</option>
|
|
</select>
|
|
<select
|
|
value={form.segment_id || ''}
|
|
onChange={(e) => setForm({ ...form, segment_id: e.target.value ? parseInt(e.target.value) : undefined })}
|
|
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm cursor-pointer"
|
|
>
|
|
<option value="">No Segment (All Users)</option>
|
|
{segments.map(s => (
|
|
<option key={s.id} value={s.id}>{s.name}</option>
|
|
))}
|
|
</select>
|
|
<textarea
|
|
placeholder="HTML Content"
|
|
value={form.html_content}
|
|
onChange={(e) => setForm({ ...form, html_content: e.target.value })}
|
|
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm resize-y"
|
|
rows={10}
|
|
/>
|
|
<div className="flex flex-col sm:flex-row gap-3 pt-4 border-t border-slate-200">
|
|
<button
|
|
onClick={onSave}
|
|
className="w-full sm:flex-1 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
|
|
>
|
|
{editing ? 'Update' : 'Create'}
|
|
</button>
|
|
<button
|
|
onClick={onClose}
|
|
className="w-full sm:flex-1 px-6 py-3 border-2 border-slate-300 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition-all duration-200 shadow-sm hover:shadow-md"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const SegmentModal: React.FC<{
|
|
form: any;
|
|
setForm: (form: any) => void;
|
|
onSave: () => void;
|
|
onClose: () => void;
|
|
}> = ({ form, setForm, onSave, onClose }) => (
|
|
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-3 sm:p-4 animate-fade-in">
|
|
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-2xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden border border-slate-200/50 animate-scale-in">
|
|
<div className="sticky top-0 bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-4 sm:px-6 py-4 sm:py-5 border-b border-slate-700 z-10">
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<h4 className="text-lg sm:text-xl md:text-2xl font-bold text-amber-100">Create Segment</h4>
|
|
<p className="text-amber-200/80 text-xs sm:text-sm font-light mt-1">Create a new email segment</p>
|
|
</div>
|
|
<button onClick={onClose} className="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center rounded-xl text-amber-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-amber-400">
|
|
<X className="w-5 h-5 sm:w-6 sm:h-6" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
|
|
<div className="p-4 sm:p-6 space-y-4 sm:space-y-5">
|
|
<input
|
|
type="text"
|
|
placeholder="Segment Name"
|
|
value={form.name}
|
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
|
/>
|
|
<textarea
|
|
placeholder="Description"
|
|
value={form.description}
|
|
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
|
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm resize-y"
|
|
rows={3}
|
|
/>
|
|
<select
|
|
value={form.criteria.role}
|
|
onChange={(e) => setForm({ ...form, criteria: { ...form.criteria, role: e.target.value } })}
|
|
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm cursor-pointer"
|
|
>
|
|
<option value="">All Roles</option>
|
|
<option value="customer">Customer</option>
|
|
<option value="admin">Admin</option>
|
|
<option value="staff">Staff</option>
|
|
</select>
|
|
<div className="flex flex-col sm:flex-row gap-3 pt-4 border-t border-slate-200">
|
|
<button
|
|
onClick={onSave}
|
|
className="w-full sm:flex-1 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
|
|
>
|
|
Create
|
|
</button>
|
|
<button
|
|
onClick={onClose}
|
|
className="w-full sm:flex-1 px-6 py-3 border-2 border-slate-300 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition-all duration-200 shadow-sm hover:shadow-md"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const TemplateModal: React.FC<{
|
|
form: any;
|
|
setForm: (form: any) => void;
|
|
onSave: () => void;
|
|
onClose: () => void;
|
|
}> = ({ form, setForm, onSave, onClose }) => (
|
|
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-3 sm:p-4 animate-fade-in">
|
|
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-4xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden border border-slate-200/50 animate-scale-in">
|
|
<div className="sticky top-0 bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-4 sm:px-6 py-4 sm:py-5 border-b border-slate-700 z-10">
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<h4 className="text-lg sm:text-xl md:text-2xl font-bold text-amber-100">Create Template</h4>
|
|
<p className="text-amber-200/80 text-xs sm:text-sm font-light mt-1">Create a new email template</p>
|
|
</div>
|
|
<button onClick={onClose} className="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center rounded-xl text-amber-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-amber-400">
|
|
<X className="w-5 h-5 sm:w-6 sm:h-6" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
|
|
<div className="p-4 sm:p-6 space-y-4 sm:space-y-5">
|
|
<input
|
|
type="text"
|
|
placeholder="Template Name"
|
|
value={form.name}
|
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="Subject"
|
|
value={form.subject}
|
|
onChange={(e) => setForm({ ...form, subject: e.target.value })}
|
|
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
|
/>
|
|
<textarea
|
|
placeholder="HTML Content"
|
|
value={form.html_content}
|
|
onChange={(e) => setForm({ ...form, html_content: e.target.value })}
|
|
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm resize-y"
|
|
rows={15}
|
|
/>
|
|
<div className="flex flex-col sm:flex-row gap-3 pt-4 border-t border-slate-200">
|
|
<button
|
|
onClick={onSave}
|
|
className="w-full sm:flex-1 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
|
|
>
|
|
Create
|
|
</button>
|
|
<button
|
|
onClick={onClose}
|
|
className="w-full sm:flex-1 px-6 py-3 border-2 border-slate-300 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition-all duration-200 shadow-sm hover:shadow-md"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const DripSequenceModal: React.FC<{
|
|
form: any;
|
|
setForm: (form: any) => void;
|
|
onSave: () => void;
|
|
onClose: () => void;
|
|
}> = ({ form, setForm, onSave, onClose }) => (
|
|
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-3 sm:p-4 animate-fade-in">
|
|
<div className="bg-white rounded-2xl sm:rounded-3xl shadow-2xl max-w-2xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden border border-slate-200/50 animate-scale-in">
|
|
<div className="sticky top-0 bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-4 sm:px-6 py-4 sm:py-5 border-b border-slate-700 z-10">
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<h4 className="text-lg sm:text-xl md:text-2xl font-bold text-amber-100">Create Drip Sequence</h4>
|
|
<p className="text-amber-200/80 text-xs sm:text-sm font-light mt-1">Create a new email drip sequence</p>
|
|
</div>
|
|
<button onClick={onClose} className="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center rounded-xl text-amber-100 hover:text-white hover:bg-slate-700/50 transition-all duration-200 border border-slate-600 hover:border-amber-400">
|
|
<X className="w-5 h-5 sm:w-6 sm:h-6" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="overflow-y-auto max-h-[calc(95vh-120px)] sm:max-h-[calc(90vh-120px)]">
|
|
<div className="p-4 sm:p-6 space-y-4 sm:space-y-5">
|
|
<input
|
|
type="text"
|
|
placeholder="Sequence Name"
|
|
value={form.name}
|
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm"
|
|
/>
|
|
<textarea
|
|
placeholder="Description"
|
|
value={form.description}
|
|
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
|
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm resize-y"
|
|
rows={3}
|
|
/>
|
|
<select
|
|
value={form.trigger_event}
|
|
onChange={(e) => setForm({ ...form, trigger_event: e.target.value })}
|
|
className="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:border-amber-400 focus:ring-4 focus:ring-amber-100 transition-all duration-200 text-slate-700 font-medium shadow-sm cursor-pointer"
|
|
>
|
|
<option value="">No Trigger (Manual)</option>
|
|
<option value="user_signup">User Signup</option>
|
|
<option value="booking_created">Booking Created</option>
|
|
<option value="booking_cancelled">Booking Cancelled</option>
|
|
<option value="check_in">Check In</option>
|
|
<option value="check_out">Check Out</option>
|
|
</select>
|
|
<div className="flex flex-col sm:flex-row gap-3 pt-4 border-t border-slate-200">
|
|
<button
|
|
onClick={onSave}
|
|
className="w-full sm:flex-1 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
|
|
>
|
|
Create
|
|
</button>
|
|
<button
|
|
onClick={onClose}
|
|
className="w-full sm:flex-1 px-6 py-3 border-2 border-slate-300 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition-all duration-200 shadow-sm hover:shadow-md"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
export default EmailCampaignManagementPage;
|
|
|