Marketing Hub: RBAC, i18n (AR/EN), tasks overhaul, team/user merge, tutorial
Features: - Full RBAC with 3 roles (superadmin/manager/contributor) - Ownership tracking on posts, tasks, campaigns, projects - Task system: assign to anyone, filter combobox, visibility scoping - Team members merged into users table (single source of truth) - Post thumbnails on kanban cards from attachments - Publication link validation before publishing - Interactive onboarding tutorial with Settings restart - Full Arabic/English i18n with RTL layout support - Language toggle in sidebar, IBM Plex Sans Arabic font - Brand-based visibility filtering for non-superadmins - Manager can only create contributors - Profile completion flow for new users - Cookie-based sessions (express-session + SQLite)
This commit is contained in:
637
client/src/pages/Campaigns.jsx
Normal file
637
client/src/pages/Campaigns.jsx
Normal file
@@ -0,0 +1,637 @@
|
||||
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 { api, PLATFORMS } from '../utils/api'
|
||||
import { PlatformIcons } from '../components/PlatformIcon'
|
||||
import CampaignCalendar from '../components/CampaignCalendar'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import BrandBadge from '../components/BrandBadge'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
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 BudgetBar({ budget, spent }) {
|
||||
if (!budget || budget <= 0) return null
|
||||
const pct = Math.min((spent / budget) * 100, 100)
|
||||
const color = pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-emerald-500'
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between text-[10px] text-text-tertiary mb-0.5">
|
||||
<span>{spent?.toLocaleString() || 0} SAR spent</span>
|
||||
<span>{budget?.toLocaleString()} SAR</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-surface-tertiary rounded-full overflow-hidden">
|
||||
<div className={`h-full ${color} rounded-full transition-all`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 } = useContext(AppContext)
|
||||
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">
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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}>{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>
|
||||
|
||||
{/* Calendar */}
|
||||
<CampaignCalendar campaigns={filtered} />
|
||||
|
||||
{/* 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={() => permissions?.canEditCampaigns ? navigate(`/campaigns/${campaign.id || campaign._id}`) : navigate(`/campaigns/${campaign.id || campaign._id}`)}
|
||||
className="flex items-center gap-4 px-5 py-4 hover:bg-surface-secondary cursor-pointer transition-colors"
|
||||
>
|
||||
<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.brandName && <BrandBadge brand={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">
|
||||
{campaign.platforms && campaign.platforms.length > 0 && (
|
||||
<PlatformIcons platforms={campaign.platforms} size={16} />
|
||||
)}
|
||||
{budget > 0 && (
|
||||
<div className="w-32">
|
||||
<BudgetBar budget={budget} spent={spent} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Quick metrics row */}
|
||||
{(campaign.impressions > 0 || campaign.clicks > 0) && (
|
||||
<div className="flex items-center gap-3 mt-1.5 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 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>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</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} {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)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.budget}
|
||||
onChange={e => setFormData(f => ({ ...f, budget: 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="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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user