655 lines
31 KiB
JavaScript
655 lines
31 KiB
JavaScript
import { useState, useEffect, useContext } from 'react'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import { Plus, Search, TrendingUp, DollarSign, Eye, MousePointer, Target, BarChart3 } from 'lucide-react'
|
||
import { format } from 'date-fns'
|
||
import { AppContext } from '../App'
|
||
import { useAuth } from '../contexts/AuthContext'
|
||
import { useLanguage } from '../i18n/LanguageContext'
|
||
import { api, PLATFORMS } from '../utils/api'
|
||
import { PlatformIcons } from '../components/PlatformIcon'
|
||
import StatusBadge from '../components/StatusBadge'
|
||
import BrandBadge from '../components/BrandBadge'
|
||
import Modal from '../components/Modal'
|
||
import BudgetBar from '../components/BudgetBar'
|
||
import InteractiveTimeline from '../components/InteractiveTimeline'
|
||
|
||
const EMPTY_CAMPAIGN = {
|
||
name: '', description: '', brand_id: '', status: 'planning',
|
||
start_date: '', end_date: '', budget: '', goals: '', platforms: [],
|
||
budget_spent: '', revenue: '', impressions: '', clicks: '', conversions: '', cost_per_click: '', notes: '',
|
||
}
|
||
|
||
function ROIBadge({ revenue, spent }) {
|
||
if (!spent || spent <= 0) return null
|
||
const roi = ((revenue - spent) / spent * 100).toFixed(0)
|
||
const color = roi >= 0 ? 'text-emerald-600 bg-emerald-50' : 'text-red-600 bg-red-50'
|
||
return (
|
||
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded ${color}`}>
|
||
ROI {roi}%
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function MetricCard({ icon: Icon, label, value, color = 'text-text-primary' }) {
|
||
return (
|
||
<div className="bg-surface-secondary rounded-lg p-3 text-center">
|
||
<Icon className={`w-4 h-4 mx-auto mb-1 ${color}`} />
|
||
<div className={`text-sm font-bold ${color}`}>{value || '—'}</div>
|
||
<div className="text-[10px] text-text-tertiary">{label}</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function Campaigns() {
|
||
const { brands, getBrandName } = useContext(AppContext)
|
||
const { lang } = useLanguage()
|
||
const { permissions } = useAuth()
|
||
const navigate = useNavigate()
|
||
const [campaigns, setCampaigns] = useState([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [showModal, setShowModal] = useState(false)
|
||
const [editingCampaign, setEditingCampaign] = useState(null)
|
||
const [formData, setFormData] = useState(EMPTY_CAMPAIGN)
|
||
const [filters, setFilters] = useState({ brand: '', status: '' })
|
||
const [activeTab, setActiveTab] = useState('details') // details | performance
|
||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||
|
||
useEffect(() => { loadCampaigns() }, [])
|
||
|
||
const loadCampaigns = async () => {
|
||
try {
|
||
const res = await api.get('/campaigns')
|
||
setCampaigns(res.data || res || [])
|
||
} catch (err) {
|
||
console.error('Failed to load campaigns:', err)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleSave = async () => {
|
||
try {
|
||
const data = {
|
||
name: formData.name,
|
||
description: formData.description,
|
||
brand_id: formData.brand_id ? Number(formData.brand_id) : null,
|
||
status: formData.status,
|
||
start_date: formData.start_date,
|
||
end_date: formData.end_date,
|
||
budget: formData.budget ? Number(formData.budget) : null,
|
||
goals: formData.goals,
|
||
platforms: formData.platforms || [],
|
||
budget_spent: formData.budget_spent ? Number(formData.budget_spent) : 0,
|
||
revenue: formData.revenue ? Number(formData.revenue) : 0,
|
||
impressions: formData.impressions ? Number(formData.impressions) : 0,
|
||
clicks: formData.clicks ? Number(formData.clicks) : 0,
|
||
conversions: formData.conversions ? Number(formData.conversions) : 0,
|
||
cost_per_click: formData.cost_per_click ? Number(formData.cost_per_click) : 0,
|
||
notes: formData.notes || '',
|
||
}
|
||
if (editingCampaign) {
|
||
await api.patch(`/campaigns/${editingCampaign.id || editingCampaign._id}`, data)
|
||
} else {
|
||
await api.post('/campaigns', data)
|
||
}
|
||
setShowModal(false)
|
||
setEditingCampaign(null)
|
||
setFormData(EMPTY_CAMPAIGN)
|
||
loadCampaigns()
|
||
} catch (err) {
|
||
console.error('Save failed:', err)
|
||
}
|
||
}
|
||
|
||
const openEdit = (campaign) => {
|
||
setEditingCampaign(campaign)
|
||
setFormData({
|
||
name: campaign.name || '',
|
||
description: campaign.description || '',
|
||
brand_id: campaign.brandId || campaign.brand_id || '',
|
||
status: campaign.status || 'planning',
|
||
start_date: campaign.startDate ? new Date(campaign.startDate).toISOString().slice(0, 10) : '',
|
||
end_date: campaign.endDate ? new Date(campaign.endDate).toISOString().slice(0, 10) : '',
|
||
budget: campaign.budget || '',
|
||
goals: campaign.goals || '',
|
||
platforms: campaign.platforms || [],
|
||
budget_spent: campaign.budgetSpent || campaign.budget_spent || '',
|
||
revenue: campaign.revenue || '',
|
||
impressions: campaign.impressions || '',
|
||
clicks: campaign.clicks || '',
|
||
conversions: campaign.conversions || '',
|
||
cost_per_click: campaign.costPerClick || campaign.cost_per_click || '',
|
||
notes: campaign.notes || '',
|
||
})
|
||
setActiveTab('details')
|
||
setShowModal(true)
|
||
}
|
||
|
||
const openNew = () => {
|
||
setEditingCampaign(null)
|
||
setFormData(EMPTY_CAMPAIGN)
|
||
setActiveTab('details')
|
||
setShowModal(true)
|
||
}
|
||
|
||
const filtered = campaigns.filter(c => {
|
||
if (filters.brand && String(c.brandId || c.brand_id) !== filters.brand) return false
|
||
if (filters.status && c.status !== filters.status) return false
|
||
return true
|
||
})
|
||
|
||
// Aggregate stats
|
||
const totalBudget = filtered.reduce((sum, c) => sum + (c.budget || 0), 0)
|
||
const totalSpent = filtered.reduce((sum, c) => sum + (c.budgetSpent || c.budget_spent || 0), 0)
|
||
const totalImpressions = filtered.reduce((sum, c) => sum + (c.impressions || 0), 0)
|
||
const totalClicks = filtered.reduce((sum, c) => sum + (c.clicks || 0), 0)
|
||
const totalConversions = filtered.reduce((sum, c) => sum + (c.conversions || 0), 0)
|
||
const totalRevenue = filtered.reduce((sum, c) => sum + (c.revenue || 0), 0)
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="space-y-4 animate-pulse">
|
||
<div className="h-12 bg-surface-tertiary rounded-xl"></div>
|
||
<div className="h-[400px] bg-surface-tertiary rounded-xl"></div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6 animate-fade-in">
|
||
{/* Toolbar */}
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<select
|
||
value={filters.brand}
|
||
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
|
||
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
|
||
>
|
||
<option value="">All Brands</option>
|
||
{brands.map(b => <option key={b.id || b._id} value={b.id || b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
|
||
</select>
|
||
|
||
<select
|
||
value={filters.status}
|
||
onChange={e => setFilters(f => ({ ...f, status: e.target.value }))}
|
||
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none"
|
||
>
|
||
<option value="">All Statuses</option>
|
||
<option value="planning">Planning</option>
|
||
<option value="active">Active</option>
|
||
<option value="paused">Paused</option>
|
||
<option value="completed">Completed</option>
|
||
<option value="cancelled">Cancelled</option>
|
||
</select>
|
||
|
||
{permissions?.canCreateCampaigns && (
|
||
<button
|
||
onClick={openNew}
|
||
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm ml-auto"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
New Campaign
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Summary Cards */}
|
||
{(totalBudget > 0 || totalSpent > 0) && (
|
||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||
<div className="bg-white rounded-xl border border-border p-4">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<DollarSign className="w-4 h-4 text-blue-500" />
|
||
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Budget</span>
|
||
</div>
|
||
<div className="text-lg font-bold text-text-primary">{totalBudget.toLocaleString()}</div>
|
||
<div className="text-[10px] text-text-tertiary">SAR total</div>
|
||
</div>
|
||
<div className="bg-white rounded-xl border border-border p-4">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<TrendingUp className="w-4 h-4 text-amber-500" />
|
||
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Spent</span>
|
||
</div>
|
||
<div className="text-lg font-bold text-text-primary">{totalSpent.toLocaleString()}</div>
|
||
<div className="text-[10px] text-text-tertiary">SAR spent</div>
|
||
</div>
|
||
<div className="bg-white rounded-xl border border-border p-4">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<Eye className="w-4 h-4 text-purple-500" />
|
||
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Impressions</span>
|
||
</div>
|
||
<div className="text-lg font-bold text-text-primary">{totalImpressions.toLocaleString()}</div>
|
||
</div>
|
||
<div className="bg-white rounded-xl border border-border p-4">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<MousePointer className="w-4 h-4 text-green-500" />
|
||
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Clicks</span>
|
||
</div>
|
||
<div className="text-lg font-bold text-text-primary">{totalClicks.toLocaleString()}</div>
|
||
</div>
|
||
<div className="bg-white rounded-xl border border-border p-4">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<Target className="w-4 h-4 text-red-500" />
|
||
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Conversions</span>
|
||
</div>
|
||
<div className="text-lg font-bold text-text-primary">{totalConversions.toLocaleString()}</div>
|
||
</div>
|
||
<div className="bg-white rounded-xl border border-border p-4">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<BarChart3 className="w-4 h-4 text-emerald-500" />
|
||
<span className="text-[10px] uppercase tracking-wider text-text-tertiary font-medium">Revenue</span>
|
||
</div>
|
||
<div className="text-lg font-bold text-text-primary">{totalRevenue.toLocaleString()}</div>
|
||
<div className="text-[10px] text-text-tertiary">SAR</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Timeline */}
|
||
<InteractiveTimeline
|
||
items={filtered}
|
||
mapItem={(campaign) => ({
|
||
id: campaign._id || campaign.id,
|
||
label: campaign.name,
|
||
description: campaign.description,
|
||
startDate: campaign.startDate || campaign.start_date || campaign.createdAt,
|
||
endDate: campaign.endDate || campaign.end_date,
|
||
status: campaign.status,
|
||
assigneeName: campaign.brandName || campaign.brand_name,
|
||
tags: campaign.platforms || [],
|
||
})}
|
||
onDateChange={async (campaignId, { startDate, endDate }) => {
|
||
try {
|
||
await api.patch(`/campaigns/${campaignId}`, { start_date: startDate, end_date: endDate })
|
||
} catch (err) {
|
||
console.error('Timeline date update failed:', err)
|
||
} finally {
|
||
loadCampaigns()
|
||
}
|
||
}}
|
||
onItemClick={(campaign) => {
|
||
navigate(`/campaigns/${campaign._id || campaign.id}`)
|
||
}}
|
||
/>
|
||
|
||
{/* Campaign list */}
|
||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||
<div className="px-5 py-4 border-b border-border">
|
||
<h3 className="font-semibold text-text-primary">All Campaigns</h3>
|
||
</div>
|
||
<div className="divide-y divide-border-light">
|
||
{filtered.length === 0 ? (
|
||
<div className="py-12 text-center text-sm text-text-tertiary">
|
||
No campaigns found
|
||
</div>
|
||
) : (
|
||
filtered.map(campaign => {
|
||
const spent = campaign.budgetSpent || campaign.budget_spent || 0
|
||
const budget = campaign.budget || 0
|
||
return (
|
||
<div
|
||
key={campaign.id || campaign._id}
|
||
onClick={() => navigate(`/campaigns/${campaign.id || campaign._id}`)}
|
||
className="relative px-5 py-4 hover:bg-surface-secondary cursor-pointer transition-colors"
|
||
>
|
||
<div className="flex items-center gap-4">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<h4 className="text-sm font-semibold text-text-primary">{campaign.name}</h4>
|
||
{(campaign.brand_id || campaign.brandName) && <BrandBadge brand={getBrandName(campaign.brand_id) || campaign.brandName} />}
|
||
<ROIBadge revenue={campaign.revenue || 0} spent={spent} />
|
||
</div>
|
||
{campaign.description && (
|
||
<p className="text-xs text-text-secondary line-clamp-1">{campaign.description}</p>
|
||
)}
|
||
<div className="flex items-center gap-3 mt-1.5">
|
||
{budget > 0 && (
|
||
<div className="w-32">
|
||
<BudgetBar budget={budget} spent={spent} />
|
||
</div>
|
||
)}
|
||
{(campaign.impressions > 0 || campaign.clicks > 0) && (
|
||
<div className="flex items-center gap-3 text-[10px] text-text-tertiary">
|
||
{campaign.impressions > 0 && <span>👁 {campaign.impressions.toLocaleString()}</span>}
|
||
{campaign.clicks > 0 && <span>🖱 {campaign.clicks.toLocaleString()}</span>}
|
||
{campaign.conversions > 0 && <span>🎯 {campaign.conversions.toLocaleString()}</span>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="text-right shrink-0">
|
||
<StatusBadge status={campaign.status} size="xs" />
|
||
<div className="text-xs text-text-tertiary mt-1">
|
||
{campaign.startDate && campaign.endDate ? (
|
||
<>
|
||
{format(new Date(campaign.startDate), 'MMM d')} – {format(new Date(campaign.endDate), 'MMM d, yyyy')}
|
||
</>
|
||
) : '—'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{campaign.platforms && campaign.platforms.length > 0 && (
|
||
<div className="flex justify-end mt-2">
|
||
<PlatformIcons platforms={campaign.platforms} size={16} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Create/Edit Modal */}
|
||
<Modal
|
||
isOpen={showModal}
|
||
onClose={() => { setShowModal(false); setEditingCampaign(null) }}
|
||
title={editingCampaign ? 'Edit Campaign' : 'Create Campaign'}
|
||
size="lg"
|
||
>
|
||
<div className="space-y-4">
|
||
{/* Tabs */}
|
||
{editingCampaign && (
|
||
<div className="flex gap-1 p-1 bg-surface-tertiary rounded-lg">
|
||
<button
|
||
onClick={() => setActiveTab('details')}
|
||
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||
activeTab === 'details' ? 'bg-white text-text-primary shadow-sm' : 'text-text-tertiary hover:text-text-secondary'
|
||
}`}
|
||
>
|
||
Details
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('performance')}
|
||
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||
activeTab === 'performance' ? 'bg-white text-text-primary shadow-sm' : 'text-text-tertiary hover:text-text-secondary'
|
||
}`}
|
||
>
|
||
Performance & ROI
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'details' ? (
|
||
<>
|
||
<div>
|
||
<label className="block text-sm font-medium text-text-primary mb-1">Name *</label>
|
||
<input
|
||
type="text"
|
||
value={formData.name}
|
||
onChange={e => setFormData(f => ({ ...f, name: e.target.value }))}
|
||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||
placeholder="Campaign name"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-text-primary mb-1">Description</label>
|
||
<textarea
|
||
value={formData.description}
|
||
onChange={e => setFormData(f => ({ ...f, description: e.target.value }))}
|
||
rows={3}
|
||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
||
placeholder="Campaign description..."
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-text-primary mb-1">Brand</label>
|
||
<select
|
||
value={formData.brand_id}
|
||
onChange={e => setFormData(f => ({ ...f, brand_id: e.target.value }))}
|
||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||
>
|
||
<option value="">Select brand</option>
|
||
{brands.map(b => <option key={b.id || b._id} value={b.id || b._id}>{b.icon} {lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-text-primary mb-1">Status</label>
|
||
<select
|
||
value={formData.status}
|
||
onChange={e => setFormData(f => ({ ...f, status: e.target.value }))}
|
||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||
>
|
||
<option value="planning">Planning</option>
|
||
<option value="active">Active</option>
|
||
<option value="paused">Paused</option>
|
||
<option value="completed">Completed</option>
|
||
<option value="cancelled">Cancelled</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Platforms multi-select */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-text-primary mb-1">Platforms</label>
|
||
<div className="flex flex-wrap gap-2 p-2 border border-border rounded-lg bg-white min-h-[42px]">
|
||
{Object.entries(PLATFORMS).map(([k, v]) => {
|
||
const checked = (formData.platforms || []).includes(k)
|
||
return (
|
||
<label
|
||
key={k}
|
||
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-full cursor-pointer border transition-colors ${
|
||
checked
|
||
? 'bg-brand-primary/10 border-brand-primary/30 text-brand-primary font-medium'
|
||
: 'bg-surface-tertiary border-transparent text-text-secondary hover:bg-surface-tertiary/80'
|
||
}`}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={checked}
|
||
onChange={() => {
|
||
setFormData(f => ({
|
||
...f,
|
||
platforms: checked
|
||
? f.platforms.filter(p => p !== k)
|
||
: [...(f.platforms || []), k]
|
||
}))
|
||
}}
|
||
className="sr-only"
|
||
/>
|
||
{v.label}
|
||
</label>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-text-primary mb-1">Start Date *</label>
|
||
<input
|
||
type="date"
|
||
value={formData.start_date}
|
||
onChange={e => setFormData(f => ({ ...f, start_date: e.target.value }))}
|
||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-text-primary mb-1">End Date *</label>
|
||
<input
|
||
type="date"
|
||
value={formData.end_date}
|
||
onChange={e => setFormData(f => ({ ...f, end_date: e.target.value }))}
|
||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-text-primary mb-1">
|
||
Budget (SAR)
|
||
{!permissions?.canSetBudget && <span className="text-[10px] text-text-tertiary ml-1">(Superadmin only)</span>}
|
||
</label>
|
||
<input
|
||
type="number"
|
||
value={formData.budget}
|
||
onChange={e => setFormData(f => ({ ...f, budget: e.target.value }))}
|
||
disabled={!permissions?.canSetBudget}
|
||
className={`w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary ${!permissions?.canSetBudget ? 'bg-surface-tertiary text-text-tertiary cursor-not-allowed' : ''}`}
|
||
placeholder="e.g., 50000"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-text-primary mb-1">Goals</label>
|
||
<input
|
||
type="text"
|
||
value={formData.goals}
|
||
onChange={e => setFormData(f => ({ ...f, goals: e.target.value }))}
|
||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||
placeholder="Campaign goals"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : (
|
||
/* Performance & ROI Tab */
|
||
<>
|
||
{/* Live metrics summary */}
|
||
{(formData.budget_spent || formData.impressions || formData.clicks) && (
|
||
<div className="grid grid-cols-4 gap-2 mb-2">
|
||
<MetricCard icon={DollarSign} label="Spent" value={formData.budget_spent ? `${Number(formData.budget_spent).toLocaleString()} SAR` : null} color="text-amber-600" />
|
||
<MetricCard icon={Eye} label="Impressions" value={formData.impressions ? Number(formData.impressions).toLocaleString() : null} color="text-purple-600" />
|
||
<MetricCard icon={MousePointer} label="Clicks" value={formData.clicks ? Number(formData.clicks).toLocaleString() : null} color="text-blue-600" />
|
||
<MetricCard icon={Target} label="Conversions" value={formData.conversions ? Number(formData.conversions).toLocaleString() : null} color="text-emerald-600" />
|
||
</div>
|
||
)}
|
||
|
||
{formData.budget && formData.budget_spent && (
|
||
<div className="p-3 bg-surface-secondary rounded-lg">
|
||
<BudgetBar budget={Number(formData.budget)} spent={Number(formData.budget_spent)} />
|
||
<div className="flex items-center gap-2 mt-2">
|
||
<ROIBadge revenue={Number(formData.revenue) || 0} spent={Number(formData.budget_spent) || 0} />
|
||
{formData.clicks > 0 && formData.budget_spent > 0 && (
|
||
<span className="text-[10px] text-text-tertiary">
|
||
CPC: {(Number(formData.budget_spent) / Number(formData.clicks)).toFixed(2)} SAR
|
||
</span>
|
||
)}
|
||
{formData.impressions > 0 && formData.clicks > 0 && (
|
||
<span className="text-[10px] text-text-tertiary">
|
||
CTR: {(Number(formData.clicks) / Number(formData.impressions) * 100).toFixed(2)}%
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-text-primary mb-1">Budget Spent (SAR)</label>
|
||
<input
|
||
type="number"
|
||
value={formData.budget_spent}
|
||
onChange={e => setFormData(f => ({ ...f, budget_spent: e.target.value }))}
|
||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||
placeholder="Amount spent so far"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-text-primary mb-1">Revenue (SAR)</label>
|
||
<input
|
||
type="number"
|
||
value={formData.revenue}
|
||
onChange={e => setFormData(f => ({ ...f, revenue: e.target.value }))}
|
||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||
placeholder="Revenue generated"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-3 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-text-primary mb-1">Impressions</label>
|
||
<input
|
||
type="number"
|
||
value={formData.impressions}
|
||
onChange={e => setFormData(f => ({ ...f, impressions: e.target.value }))}
|
||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||
placeholder="Total impressions"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-text-primary mb-1">Clicks</label>
|
||
<input
|
||
type="number"
|
||
value={formData.clicks}
|
||
onChange={e => setFormData(f => ({ ...f, clicks: e.target.value }))}
|
||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||
placeholder="Total clicks"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-text-primary mb-1">Conversions</label>
|
||
<input
|
||
type="number"
|
||
value={formData.conversions}
|
||
onChange={e => setFormData(f => ({ ...f, conversions: e.target.value }))}
|
||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||
placeholder="Conversions (visits, tickets...)"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-text-primary mb-1">Notes</label>
|
||
<textarea
|
||
value={formData.notes}
|
||
onChange={e => setFormData(f => ({ ...f, notes: e.target.value }))}
|
||
rows={3}
|
||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
||
placeholder="Performance notes, observations, what's working..."
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||
{editingCampaign && permissions?.canDeleteCampaigns && (
|
||
<button
|
||
onClick={() => setShowDeleteConfirm(true)}
|
||
className="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg mr-auto"
|
||
>
|
||
Delete
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={() => { setShowModal(false); setEditingCampaign(null) }}
|
||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={handleSave}
|
||
disabled={!formData.name || !formData.start_date || !formData.end_date}
|
||
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
||
>
|
||
{editingCampaign ? 'Save Changes' : 'Create Campaign'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
|
||
{/* Delete Confirmation */}
|
||
<Modal
|
||
isOpen={showDeleteConfirm}
|
||
onClose={() => setShowDeleteConfirm(false)}
|
||
title="Delete Campaign?"
|
||
isConfirm
|
||
danger
|
||
confirmText="Delete Campaign"
|
||
onConfirm={async () => {
|
||
if (editingCampaign) {
|
||
await api.delete(`/campaigns/${editingCampaign.id || editingCampaign._id}`)
|
||
setShowModal(false)
|
||
setEditingCampaign(null)
|
||
loadCampaigns()
|
||
}
|
||
}}
|
||
>
|
||
Are you sure you want to delete this campaign? All associated posts and tracks will also be deleted. This action cannot be undone.
|
||
</Modal>
|
||
</div>
|
||
)
|
||
}
|