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:
fahed
2026-02-08 20:46:58 +03:00
commit 35d84b6bff
2240 changed files with 846749 additions and 0 deletions

View File

@@ -0,0 +1,565 @@
import { useState, useEffect, useContext } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft, Plus, Edit2, Trash2, DollarSign, Eye, MousePointer, Target, TrendingUp, FileText, Megaphone, Search, Globe, Pencil } from 'lucide-react'
import { format } from 'date-fns'
import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
import { api, PLATFORMS } from '../utils/api'
import PlatformIcon, { PlatformIcons } from '../components/PlatformIcon'
import StatusBadge from '../components/StatusBadge'
import BrandBadge from '../components/BrandBadge'
import Modal from '../components/Modal'
const TRACK_TYPES = {
organic_social: { label: 'Organic Social', icon: Megaphone, color: 'text-green-600 bg-green-50', hasBudget: false },
paid_social: { label: 'Paid Social', icon: DollarSign, color: 'text-blue-600 bg-blue-50', hasBudget: true },
paid_search: { label: 'Paid Search (PPC)', icon: Search, color: 'text-amber-600 bg-amber-50', hasBudget: true },
seo_content: { label: 'SEO / Content', icon: Globe, color: 'text-purple-600 bg-purple-50', hasBudget: false },
production: { label: 'Production', icon: FileText, color: 'text-red-600 bg-red-50', hasBudget: true },
}
const TRACK_STATUSES = ['planned', 'active', 'paused', 'completed']
const EMPTY_TRACK = {
name: '', type: 'organic_social', platform: '', budget_allocated: '', status: 'planned', notes: '',
}
const EMPTY_METRICS = {
budget_spent: '', revenue: '', impressions: '', clicks: '', conversions: '', 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 || 0).toLocaleString()} spent</span>
<span>{budget.toLocaleString()} SAR</span>
</div>
<div className="h-2 bg-surface-tertiary rounded-full overflow-hidden">
<div className={`h-full ${color} rounded-full transition-all`} style={{ width: `${pct}%` }} />
</div>
</div>
)
}
function MetricBox({ label, value, icon: Icon, color = 'text-text-primary' }) {
return (
<div className="text-center">
<Icon className={`w-4 h-4 mx-auto mb-0.5 ${color}`} />
<div className={`text-base font-bold ${color}`}>{value ?? '—'}</div>
<div className="text-[10px] text-text-tertiary">{label}</div>
</div>
)
}
export default function CampaignDetail() {
const { id } = useParams()
const navigate = useNavigate()
const { brands } = useContext(AppContext)
const { permissions } = useAuth()
const canManage = permissions?.canEditCampaigns
const [campaign, setCampaign] = useState(null)
const [tracks, setTracks] = useState([])
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
const [showTrackModal, setShowTrackModal] = useState(false)
const [editingTrack, setEditingTrack] = useState(null)
const [trackForm, setTrackForm] = useState(EMPTY_TRACK)
const [showMetricsModal, setShowMetricsModal] = useState(false)
const [metricsTrack, setMetricsTrack] = useState(null)
const [metricsForm, setMetricsForm] = useState(EMPTY_METRICS)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [trackToDelete, setTrackToDelete] = useState(null)
useEffect(() => { loadAll() }, [id])
const loadAll = async () => {
try {
const [campRes, tracksRes, postsRes] = await Promise.all([
api.get(`/campaigns`),
api.get(`/campaigns/${id}/tracks`),
api.get(`/campaigns/${id}/posts`),
])
const allCampaigns = campRes.data || campRes || []
const found = allCampaigns.find(c => String(c.id) === String(id) || String(c._id) === String(id))
setCampaign(found || null)
setTracks(tracksRes.data || tracksRes || [])
setPosts(postsRes.data || postsRes || [])
} catch (err) {
console.error('Failed to load campaign:', err)
} finally {
setLoading(false)
}
}
const saveTrack = async () => {
try {
const data = {
name: trackForm.name,
type: trackForm.type,
platform: trackForm.platform || null,
budget_allocated: trackForm.budget_allocated ? Number(trackForm.budget_allocated) : 0,
status: trackForm.status,
notes: trackForm.notes,
}
if (editingTrack) {
await api.patch(`/tracks/${editingTrack.id}`, data)
} else {
await api.post(`/campaigns/${id}/tracks`, data)
}
setShowTrackModal(false)
setEditingTrack(null)
setTrackForm(EMPTY_TRACK)
loadAll()
} catch (err) {
console.error('Save track failed:', err)
}
}
const deleteTrack = async (trackId) => {
setTrackToDelete(trackId)
setShowDeleteConfirm(true)
}
const confirmDeleteTrack = async () => {
if (!trackToDelete) return
await api.delete(`/tracks/${trackToDelete}`)
setTrackToDelete(null)
loadAll()
}
const saveMetrics = async () => {
try {
await api.patch(`/tracks/${metricsTrack.id}`, {
budget_spent: metricsForm.budget_spent ? Number(metricsForm.budget_spent) : 0,
revenue: metricsForm.revenue ? Number(metricsForm.revenue) : 0,
impressions: metricsForm.impressions ? Number(metricsForm.impressions) : 0,
clicks: metricsForm.clicks ? Number(metricsForm.clicks) : 0,
conversions: metricsForm.conversions ? Number(metricsForm.conversions) : 0,
notes: metricsForm.notes || '',
})
setShowMetricsModal(false)
setMetricsTrack(null)
loadAll()
} catch (err) {
console.error('Save metrics failed:', err)
}
}
const openEditTrack = (track) => {
setEditingTrack(track)
setTrackForm({
name: track.name || '',
type: track.type || 'organic_social',
platform: track.platform || '',
budget_allocated: track.budget_allocated || '',
status: track.status || 'planned',
notes: track.notes || '',
})
setShowTrackModal(true)
}
const openMetrics = (track) => {
setMetricsTrack(track)
setMetricsForm({
budget_spent: track.budget_spent || '',
revenue: track.revenue || '',
impressions: track.impressions || '',
clicks: track.clicks || '',
conversions: track.conversions || '',
notes: track.notes || '',
})
setShowMetricsModal(true)
}
if (loading) {
return <div className="animate-pulse"><div className="h-64 bg-surface-tertiary rounded-xl"></div></div>
}
if (!campaign) {
return (
<div className="text-center py-12 text-text-tertiary">
Campaign not found. <button onClick={() => navigate('/campaigns')} className="text-brand-primary underline">Go back</button>
</div>
)
}
// Aggregates from tracks
const totalAllocated = tracks.reduce((s, t) => s + (t.budget_allocated || 0), 0)
const totalSpent = tracks.reduce((s, t) => s + (t.budget_spent || 0), 0)
const totalImpressions = tracks.reduce((s, t) => s + (t.impressions || 0), 0)
const totalClicks = tracks.reduce((s, t) => s + (t.clicks || 0), 0)
const totalConversions = tracks.reduce((s, t) => s + (t.conversions || 0), 0)
const totalRevenue = tracks.reduce((s, t) => s + (t.revenue || 0), 0)
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div className="flex items-start gap-4">
<button onClick={() => navigate('/campaigns')} className="mt-1 p-1.5 hover:bg-surface-tertiary rounded-lg">
<ArrowLeft className="w-5 h-5 text-text-secondary" />
</button>
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<h1 className="text-xl font-bold text-text-primary">{campaign.name}</h1>
<StatusBadge status={campaign.status} />
{campaign.brand_name && <BrandBadge brand={campaign.brand_name} />}
</div>
{campaign.description && <p className="text-sm text-text-secondary">{campaign.description}</p>}
<div className="flex items-center gap-4 mt-2 text-xs text-text-tertiary">
{campaign.start_date && campaign.end_date && (
<span>{format(new Date(campaign.start_date), 'MMM d')} {format(new Date(campaign.end_date), 'MMM d, yyyy')}</span>
)}
{campaign.budget > 0 && <span>Budget: {campaign.budget.toLocaleString()} SAR</span>}
</div>
</div>
</div>
{/* Aggregate Metrics */}
{tracks.length > 0 && (
<div className="bg-white rounded-xl border border-border p-5">
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-4">Campaign Totals (from tracks)</h3>
<div className="grid grid-cols-3 md:grid-cols-6 gap-4">
<MetricBox icon={DollarSign} label="Allocated" value={`${totalAllocated.toLocaleString()}`} color="text-blue-600" />
<MetricBox icon={TrendingUp} label="Spent" value={`${totalSpent.toLocaleString()}`} color="text-amber-600" />
<MetricBox icon={Eye} label="Impressions" value={totalImpressions.toLocaleString()} color="text-purple-600" />
<MetricBox icon={MousePointer} label="Clicks" value={totalClicks.toLocaleString()} color="text-green-600" />
<MetricBox icon={Target} label="Conversions" value={totalConversions.toLocaleString()} color="text-red-600" />
<MetricBox icon={DollarSign} label="Revenue" value={`${totalRevenue.toLocaleString()}`} color="text-emerald-600" />
</div>
{totalAllocated > 0 && (
<div className="mt-4">
<BudgetBar budget={totalAllocated} spent={totalSpent} />
</div>
)}
</div>
)}
{/* Tracks */}
<div className="bg-white rounded-xl border border-border overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">Tracks</h3>
{canManage && (
<button
onClick={() => { setEditingTrack(null); setTrackForm(EMPTY_TRACK); setShowTrackModal(true) }}
className="flex items-center gap-1.5 px-3 py-1.5 bg-brand-primary text-white rounded-lg text-xs font-medium hover:bg-brand-primary-light"
>
<Plus className="w-3.5 h-3.5" /> Add Track
</button>
)}
</div>
{tracks.length === 0 ? (
<div className="py-12 text-center text-sm text-text-tertiary">
No tracks yet. Add organic, paid, or SEO tracks to organize this campaign.
</div>
) : (
<div className="divide-y divide-border-light">
{tracks.map(track => {
const typeInfo = TRACK_TYPES[track.type] || TRACK_TYPES.organic_social
const TypeIcon = typeInfo.icon
const trackPosts = posts.filter(p => p.track_id === track.id)
return (
<div key={track.id} className="px-5 py-4">
<div className="flex items-start gap-3">
<div className={`p-2 rounded-lg ${typeInfo.color}`}>
<TypeIcon className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<h4 className="text-sm font-semibold text-text-primary">
{track.name || typeInfo.label}
</h4>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-tertiary">
{typeInfo.label}
</span>
{track.platform && (
<PlatformIcon platform={track.platform} size={16} />
)}
<StatusBadge status={track.status} size="xs" />
</div>
{/* Budget bar for paid tracks */}
{track.budget_allocated > 0 && (
<div className="w-48 mt-1.5">
<BudgetBar budget={track.budget_allocated} spent={track.budget_spent || 0} />
</div>
)}
{/* Quick metrics */}
{(track.impressions > 0 || track.clicks > 0 || track.conversions > 0) && (
<div className="flex items-center gap-3 mt-1.5 text-[10px] text-text-tertiary">
{track.impressions > 0 && <span>👁 {track.impressions.toLocaleString()}</span>}
{track.clicks > 0 && <span>🖱 {track.clicks.toLocaleString()}</span>}
{track.conversions > 0 && <span>🎯 {track.conversions.toLocaleString()}</span>}
{track.clicks > 0 && track.budget_spent > 0 && (
<span>CPC: {(track.budget_spent / track.clicks).toFixed(2)} SAR</span>
)}
{track.impressions > 0 && track.clicks > 0 && (
<span>CTR: {(track.clicks / track.impressions * 100).toFixed(2)}%</span>
)}
</div>
)}
{/* Linked posts count */}
{trackPosts.length > 0 && (
<div className="text-[10px] text-text-tertiary mt-1">
📝 {trackPosts.length} post{trackPosts.length !== 1 ? 's' : ''} linked
</div>
)}
{track.notes && (
<p className="text-xs text-text-secondary mt-1 line-clamp-1">{track.notes}</p>
)}
</div>
{/* Actions */}
{canManage && (
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => openMetrics(track)}
title="Update metrics"
className="p-1.5 hover:bg-surface-tertiary rounded-lg text-text-tertiary hover:text-brand-primary"
>
<TrendingUp className="w-4 h-4" />
</button>
<button
onClick={() => openEditTrack(track)}
title="Edit track"
className="p-1.5 hover:bg-surface-tertiary rounded-lg text-text-tertiary hover:text-text-primary"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => deleteTrack(track.id)}
title="Delete track"
className="p-1.5 hover:bg-red-50 rounded-lg text-text-tertiary hover:text-red-500"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
</div>
</div>
)
})}
</div>
)}
</div>
{/* Linked Posts */}
{posts.length > 0 && (
<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">Linked Posts ({posts.length})</h3>
</div>
<div className="divide-y divide-border-light">
{posts.map(post => (
<div key={post.id} className="flex items-center gap-3 px-5 py-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-text-primary">{post.title}</h4>
<StatusBadge status={post.status} size="xs" />
</div>
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-text-tertiary">
{post.track_name && <span className="px-1.5 py-0.5 rounded bg-surface-tertiary">{post.track_name}</span>}
{post.assigned_name && <span> {post.assigned_name}</span>}
{post.platforms && post.platforms.length > 0 && (
<PlatformIcons platforms={post.platforms} size={14} />
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Add/Edit Track Modal */}
<Modal
isOpen={showTrackModal}
onClose={() => { setShowTrackModal(false); setEditingTrack(null) }}
title={editingTrack ? 'Edit Track' : 'Add Track'}
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Track Name</label>
<input
type="text"
value={trackForm.name}
onChange={e => setTrackForm(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="e.g., Instagram Paid Ads, Organic Wave, Google Search..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Type</label>
<select
value={trackForm.type}
onChange={e => setTrackForm(f => ({ ...f, type: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
>
{Object.entries(TRACK_TYPES).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Platform</label>
<select
value={trackForm.platform}
onChange={e => setTrackForm(f => ({ ...f, platform: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
>
<option value="">All / Multiple</option>
{Object.entries(PLATFORMS).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
<option value="google_ads">Google Ads</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Budget Allocated (SAR)</label>
<input
type="number"
value={trackForm.budget_allocated}
onChange={e => setTrackForm(f => ({ ...f, budget_allocated: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
placeholder="0 for free/organic"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Status</label>
<select
value={trackForm.status}
onChange={e => setTrackForm(f => ({ ...f, status: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
>
{TRACK_STATUSES.map(s => (
<option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Notes</label>
<textarea
value={trackForm.notes}
onChange={e => setTrackForm(f => ({ ...f, notes: e.target.value }))}
rows={2}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none resize-none"
placeholder="Keywords, targeting details, content plan..."
/>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-border">
<button onClick={() => setShowTrackModal(false)} className="px-4 py-2 text-sm text-text-secondary hover:bg-surface-tertiary rounded-lg">Cancel</button>
<button onClick={saveTrack} className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm">
{editingTrack ? 'Save' : 'Add Track'}
</button>
</div>
</div>
</Modal>
{/* Update Metrics Modal */}
<Modal
isOpen={showMetricsModal}
onClose={() => { setShowMetricsModal(false); setMetricsTrack(null) }}
title={`Update Metrics — ${metricsTrack?.name || ''}`}
>
<div className="space-y-4">
<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={metricsForm.budget_spent}
onChange={e => setMetricsForm(f => ({ ...f, budget_spent: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Revenue (SAR)</label>
<input
type="number"
value={metricsForm.revenue}
onChange={e => setMetricsForm(f => ({ ...f, revenue: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
/>
</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={metricsForm.impressions}
onChange={e => setMetricsForm(f => ({ ...f, impressions: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Clicks</label>
<input
type="number"
value={metricsForm.clicks}
onChange={e => setMetricsForm(f => ({ ...f, clicks: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Conversions</label>
<input
type="number"
value={metricsForm.conversions}
onChange={e => setMetricsForm(f => ({ ...f, conversions: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Notes</label>
<textarea
value={metricsForm.notes}
onChange={e => setMetricsForm(f => ({ ...f, notes: e.target.value }))}
rows={2}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none resize-none"
placeholder="What's working, what to adjust..."
/>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-border">
<button onClick={() => setShowMetricsModal(false)} className="px-4 py-2 text-sm text-text-secondary hover:bg-surface-tertiary rounded-lg">Cancel</button>
<button onClick={saveMetrics} className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm">
Save Metrics
</button>
</div>
</div>
</Modal>
{/* Delete Track Confirmation */}
<Modal
isOpen={showDeleteConfirm}
onClose={() => { setShowDeleteConfirm(false); setTrackToDelete(null) }}
title="Delete Track?"
isConfirm
danger
confirmText="Delete Track"
onConfirm={confirmDeleteTrack}
>
Are you sure you want to delete this campaign track? This action cannot be undone.
</Modal>
</div>
)
}