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,86 @@
import { format } from 'date-fns'
import { Image, FileText, Film, Music, File, Download } from 'lucide-react'
const typeIcons = {
image: Image,
document: FileText,
video: Film,
audio: Music,
}
const formatFileSize = (bytes) => {
if (!bytes) return '—'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export default function AssetCard({ asset, onClick }) {
const TypeIcon = typeIcons[asset.type] || File
const isImage = asset.type === 'image'
return (
<div
onClick={() => onClick?.(asset)}
className="bg-white rounded-xl border border-border overflow-hidden card-hover cursor-pointer group"
>
{/* Thumbnail */}
<div className="aspect-square bg-surface-tertiary flex items-center justify-center overflow-hidden relative">
{isImage && asset.url ? (
<img
src={asset.url}
alt={asset.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
onError={(e) => {
e.target.style.display = 'none'
e.target.nextSibling.style.display = 'flex'
}}
/>
) : null}
<div
className={`flex flex-col items-center justify-center gap-2 ${isImage && asset.url ? 'hidden' : ''}`}
style={{ display: isImage && asset.url ? 'none' : 'flex' }}
>
<TypeIcon className="w-10 h-10 text-text-tertiary" />
<span className="text-xs text-text-tertiary uppercase font-medium">
{asset.fileType || asset.type}
</span>
</div>
{/* Hover overlay */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors flex items-center justify-center">
<button className="opacity-0 group-hover:opacity-100 bg-white/90 backdrop-blur-sm rounded-full p-2 shadow-lg transition-opacity">
<Download className="w-4 h-4 text-text-primary" />
</button>
</div>
</div>
{/* Info */}
<div className="p-3">
<h5 className="text-sm font-medium text-text-primary truncate">{asset.name}</h5>
<div className="flex items-center justify-between mt-1.5">
<span className="text-xs text-text-tertiary">{formatFileSize(asset.size)}</span>
{asset.createdAt && (
<span className="text-xs text-text-tertiary">
{format(new Date(asset.createdAt), 'MMM d')}
</span>
)}
</div>
{asset.tags && asset.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{asset.tags.slice(0, 3).map((tag) => (
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-tertiary">
{tag}
</span>
))}
{asset.tags.length > 3 && (
<span className="text-[10px] px-1.5 py-0.5 text-text-tertiary">
+{asset.tags.length - 3}
</span>
)}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,12 @@
import { getBrandColor } from '../utils/api'
export default function BrandBadge({ brand }) {
const color = getBrandColor(brand)
return (
<span className={`inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded-full ${color.bg} ${color.text}`}>
<span className={`w-1.5 h-1.5 rounded-full ${color.dot}`}></span>
{brand}
</span>
)
}

View File

@@ -0,0 +1,132 @@
import { useState, useMemo } from 'react'
import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
eachDayOfInterval, format, isSameMonth, isToday,
addMonths, subMonths, isBefore, isAfter, isSameDay
} from 'date-fns'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { getBrandColor } from '../utils/api'
const CAMPAIGN_COLORS = [
'bg-indigo-400', 'bg-pink-400', 'bg-emerald-400', 'bg-amber-400',
'bg-purple-400', 'bg-cyan-400', 'bg-rose-400', 'bg-teal-400',
]
export default function CampaignCalendar({ campaigns = [] }) {
const [currentMonth, setCurrentMonth] = useState(new Date())
const days = useMemo(() => {
const monthStart = startOfMonth(currentMonth)
const monthEnd = endOfMonth(currentMonth)
const calStart = startOfWeek(monthStart, { weekStartsOn: 0 })
const calEnd = endOfWeek(monthEnd, { weekStartsOn: 0 })
return eachDayOfInterval({ start: calStart, end: calEnd })
}, [currentMonth])
const getCampaignsForDay = (day) => {
return campaigns.filter((c) => {
const start = new Date(c.startDate)
const end = new Date(c.endDate)
return (isSameDay(day, start) || isAfter(day, start)) &&
(isSameDay(day, end) || isBefore(day, end))
})
}
const isStartOfCampaign = (day, campaign) => {
return isSameDay(day, new Date(campaign.startDate))
}
const isEndOfCampaign = (day, campaign) => {
return isSameDay(day, new Date(campaign.endDate))
}
return (
<div className="bg-white rounded-xl border border-border overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h3 className="text-lg font-semibold text-text-primary">
{format(currentMonth, 'MMMM yyyy')}
</h3>
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-secondary"
>
<ChevronLeft className="w-5 h-5" />
</button>
<button
onClick={() => setCurrentMonth(new Date())}
className="px-3 py-1 text-sm font-medium rounded-lg hover:bg-surface-tertiary text-text-secondary"
>
Today
</button>
<button
onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-secondary"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
{/* Day names */}
<div className="calendar-grid border-b border-border">
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => (
<div key={d} className="px-2 py-2 text-center text-xs font-semibold text-text-tertiary uppercase tracking-wider">
{d}
</div>
))}
</div>
{/* Calendar grid */}
<div className="calendar-grid">
{days.map((day, i) => {
const dayCampaigns = getCampaignsForDay(day)
const inMonth = isSameMonth(day, currentMonth)
const today = isToday(day)
return (
<div
key={i}
className={`min-h-[80px] p-1 border-b border-r border-border-light relative ${
!inMonth ? 'bg-surface-secondary/50' : ''
}`}
>
<span className={`text-xs font-medium inline-flex items-center justify-center w-6 h-6 rounded-full ${
today ? 'bg-brand-primary text-white' :
inMonth ? 'text-text-primary' : 'text-text-tertiary'
}`}>
{format(day, 'd')}
</span>
<div className="space-y-0.5 mt-0.5">
{dayCampaigns.slice(0, 3).map((campaign, ci) => {
const colorIndex = campaigns.indexOf(campaign) % CAMPAIGN_COLORS.length
const isStart = isStartOfCampaign(day, campaign)
const isEnd = isEndOfCampaign(day, campaign)
return (
<div
key={campaign._id || ci}
className={`h-5 text-[10px] font-medium text-white flex items-center px-1 truncate ${CAMPAIGN_COLORS[colorIndex]} ${
isStart ? 'rounded-l-full ml-0' : '-ml-1'
} ${isEnd ? 'rounded-r-full mr-0' : '-mr-1'}`}
title={campaign.name}
>
{isStart ? campaign.name : ''}
</div>
)
})}
{dayCampaigns.length > 3 && (
<div className="text-[10px] text-text-tertiary px-1">
+{dayCampaigns.length - 3} more
</div>
)}
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,134 @@
import { useState, useRef, useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { Bell, ChevronDown, LogOut, Settings, User, Shield } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
const pageTitles = {
'/': 'Dashboard',
'/posts': 'Post Production',
'/assets': 'Assets',
'/campaigns': 'Campaigns',
'/finance': 'Finance',
'/projects': 'Projects',
'/tasks': 'My Tasks',
'/team': 'Team',
'/users': 'User Management',
}
const ROLE_INFO = {
superadmin: { label: 'Superadmin', color: 'bg-purple-100 text-purple-700', icon: '👑' },
manager: { label: 'Manager', color: 'bg-blue-100 text-blue-700', icon: '📊' },
contributor: { label: 'Contributor', color: 'bg-green-100 text-green-700', icon: '✏️' },
}
export default function Header() {
const { user, logout } = useAuth()
const [showDropdown, setShowDropdown] = useState(false)
const dropdownRef = useRef(null)
const location = useLocation()
const pageTitle = pageTitles[location.pathname] ||
(location.pathname.startsWith('/projects/') ? 'Project Details' :
location.pathname.startsWith('/campaigns/') ? 'Campaign Details' : 'Page')
useEffect(() => {
const handleClickOutside = (e) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
setShowDropdown(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const getInitials = (name) => {
if (!name) return '?'
return name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
}
const roleInfo = ROLE_INFO[user?.role] || ROLE_INFO.contributor
return (
<header className="h-16 bg-white border-b border-border flex items-center justify-between px-6 shrink-0 sticky top-0 z-20">
{/* Page title */}
<div>
<h2 className="text-xl font-bold text-text-primary">{pageTitle}</h2>
</div>
{/* Right side */}
<div className="flex items-center gap-4">
{/* Notifications */}
<button className="relative p-2 rounded-lg hover:bg-surface-tertiary text-text-secondary hover:text-text-primary transition-colors">
<Bell className="w-5 h-5" />
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-red-500 rounded-full"></span>
</button>
{/* User menu */}
<div ref={dropdownRef} className="relative">
<button
onClick={() => setShowDropdown(!showDropdown)}
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-surface-tertiary transition-colors"
>
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-semibold ${
user?.role === 'superadmin'
? 'bg-gradient-to-br from-purple-500 to-pink-500'
: 'bg-gradient-to-br from-blue-500 to-indigo-500'
}`}>
{getInitials(user?.name)}
</div>
<div className="text-left hidden sm:block">
<p className="text-sm font-medium text-text-primary">
{user?.name || 'User'}
</p>
<p className={`text-[10px] font-medium ${roleInfo.color.split(' ')[1]}`}>
{roleInfo.icon} {roleInfo.label}
</p>
</div>
<ChevronDown className={`w-4 h-4 text-text-tertiary transition-transform ${showDropdown ? 'rotate-180' : ''}`} />
</button>
{showDropdown && (
<div className="absolute right-0 top-full mt-2 w-64 bg-white rounded-xl shadow-lg border border-border overflow-hidden animate-scale-in">
{/* User info */}
<div className="px-4 py-3 border-b border-border-light bg-surface-secondary">
<p className="text-sm font-semibold text-text-primary">{user?.name}</p>
<p className="text-xs text-text-tertiary">{user?.email}</p>
<div className={`inline-flex items-center gap-1 text-[10px] font-medium px-2 py-0.5 rounded-full mt-2 ${roleInfo.color}`}>
<span>{roleInfo.icon}</span>
{roleInfo.label}
</div>
</div>
{/* Menu items */}
<div className="py-2">
{user?.role === 'superadmin' && (
<button
onClick={() => {
setShowDropdown(false)
window.location.href = '/users'
}}
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-surface-secondary transition-colors text-left"
>
<Shield className="w-4 h-4 text-text-tertiary" />
<span className="text-sm text-text-primary">User Management</span>
</button>
)}
<button
onClick={() => {
setShowDropdown(false)
logout()
}}
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-red-50 transition-colors text-left group"
>
<LogOut className="w-4 h-4 text-text-tertiary group-hover:text-red-500" />
<span className="text-sm text-text-primary group-hover:text-red-500">Sign Out</span>
</button>
</div>
</div>
)}
</div>
</div>
</header>
)
}

View File

@@ -0,0 +1,111 @@
import { useState } from 'react'
import PostCard from './PostCard'
import { useLanguage } from '../i18n/LanguageContext'
const COLUMNS = [
{ id: 'draft', labelKey: 'posts.status.draft', color: 'bg-gray-400' },
{ id: 'in_review', labelKey: 'posts.status.in_review', color: 'bg-amber-400' },
{ id: 'approved', labelKey: 'posts.status.approved', color: 'bg-blue-400' },
{ id: 'scheduled', labelKey: 'posts.status.scheduled', color: 'bg-purple-400' },
{ id: 'published', labelKey: 'posts.status.published', color: 'bg-emerald-400' },
]
export default function KanbanBoard({ posts, onPostClick, onMovePost }) {
const { t } = useLanguage()
const [draggedPost, setDraggedPost] = useState(null)
const [dragOverCol, setDragOverCol] = useState(null)
const handleDragStart = (e, post) => {
setDraggedPost(post)
e.dataTransfer.effectAllowed = 'move'
// Make the drag image slightly transparent
if (e.target) {
setTimeout(() => e.target.style.opacity = '0.4', 0)
}
}
const handleDragEnd = (e) => {
e.target.style.opacity = '1'
setDraggedPost(null)
setDragOverCol(null)
}
const handleDragOver = (e, colId) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
setDragOverCol(colId)
}
const handleDragLeave = (e, colId) => {
// Only clear if we're actually leaving the column (not entering a child)
if (!e.currentTarget.contains(e.relatedTarget)) {
setDragOverCol(null)
}
}
const handleDrop = (e, colId) => {
e.preventDefault()
setDragOverCol(null)
if (draggedPost && draggedPost.status !== colId) {
onMovePost(draggedPost._id, colId)
}
setDraggedPost(null)
}
return (
<div className="flex gap-4 overflow-x-auto pb-4">
{COLUMNS.map((col) => {
const colPosts = posts.filter((p) => p.status === col.id)
const isOver = dragOverCol === col.id && draggedPost?.status !== col.id
return (
<div key={col.id} className="flex-shrink-0 w-72">
{/* Column header */}
<div className="flex items-center gap-2 mb-3 px-1">
<div className={`w-2.5 h-2.5 rounded-full ${col.color}`} />
<h4 className="text-sm font-semibold text-text-primary">{t(col.labelKey)}</h4>
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-2 py-0.5 rounded-full ml-auto">
{colPosts.length}
</span>
</div>
{/* Column body — drop zone */}
<div
className={`kanban-column rounded-xl p-2 space-y-2 border-2 transition-colors min-h-[120px] ${
isOver
? 'bg-brand-primary/5 border-brand-primary/40 border-dashed'
: 'bg-surface-secondary border-border-light border-solid'
}`}
onDragOver={(e) => handleDragOver(e, col.id)}
onDragLeave={(e) => handleDragLeave(e, col.id)}
onDrop={(e) => handleDrop(e, col.id)}
>
{colPosts.length === 0 ? (
<div className={`py-8 text-center text-xs ${isOver ? 'text-brand-primary font-medium' : 'text-text-tertiary'}`}>
{isOver ? t('posts.dropHere') : t('posts.noPosts')}
</div>
) : (
colPosts.map((post) => (
<div
key={post._id}
draggable
onDragStart={(e) => handleDragStart(e, post)}
onDragEnd={handleDragEnd}
className="cursor-grab active:cursor-grabbing"
>
<PostCard
post={post}
onClick={() => onPostClick(post)}
onMove={onMovePost}
compact
/>
</div>
))
)}
</div>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,24 @@
import { useState } from 'react'
import { Outlet } from 'react-router-dom'
import Sidebar from './Sidebar'
import Header from './Header'
export default function Layout() {
const [collapsed, setCollapsed] = useState(false)
return (
<div className="min-h-screen bg-surface-secondary">
<Sidebar collapsed={collapsed} setCollapsed={setCollapsed} />
<div
className={`transition-all duration-300 ${
collapsed ? 'main-content-margin-collapsed' : 'main-content-margin'
}`}
>
<Header />
<main className="p-6">
<Outlet />
</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,68 @@
import BrandBadge from './BrandBadge'
const ROLE_BADGES = {
manager: { bg: 'bg-indigo-50', text: 'text-indigo-700', label: 'Manager' },
approver: { bg: 'bg-emerald-50', text: 'text-emerald-700', label: 'Approver' },
publisher: { bg: 'bg-blue-50', text: 'text-blue-700', label: 'Publisher' },
content_creator: { bg: 'bg-amber-50', text: 'text-amber-700', label: 'Content Creator' },
producer: { bg: 'bg-purple-50', text: 'text-purple-700', label: 'Producer' },
designer: { bg: 'bg-pink-50', text: 'text-pink-700', label: 'Designer' },
content_writer: { bg: 'bg-orange-50', text: 'text-orange-700', label: 'Content Writer' },
social_media_manager: { bg: 'bg-teal-50', text: 'text-teal-700', label: 'Social Media Manager' },
photographer: { bg: 'bg-cyan-50', text: 'text-cyan-700', label: 'Photographer' },
videographer: { bg: 'bg-sky-50', text: 'text-sky-700', label: 'Videographer' },
strategist: { bg: 'bg-rose-50', text: 'text-rose-700', label: 'Strategist' },
default: { bg: 'bg-gray-50', text: 'text-gray-700', label: 'Team Member' },
}
export default function MemberCard({ member, onClick }) {
const getInitials = (name) => {
if (!name) return '?'
return name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
}
const role = ROLE_BADGES[member.team_role || member.role] || ROLE_BADGES.default
const avatarColors = [
'from-indigo-400 to-purple-500',
'from-pink-400 to-rose-500',
'from-emerald-400 to-teal-500',
'from-amber-400 to-orange-500',
'from-cyan-400 to-blue-500',
]
const colorIndex = (member.name?.charCodeAt(0) || 0) % avatarColors.length
return (
<div
onClick={() => onClick?.(member)}
className="bg-white rounded-xl border border-border p-5 card-hover cursor-pointer text-center"
>
{/* Avatar */}
<div className={`w-16 h-16 rounded-full bg-gradient-to-br ${avatarColors[colorIndex]} flex items-center justify-center text-white text-xl font-bold mx-auto mb-3`}>
{getInitials(member.name)}
</div>
{/* Name */}
<h4 className="text-base font-semibold text-text-primary">{member.name}</h4>
{/* Role badge */}
<span className={`inline-block text-xs font-medium px-2 py-0.5 rounded-full mt-1 ${role.bg} ${role.text}`}>
{role.label}
</span>
{/* Email */}
{member.email && (
<p className="text-xs text-text-tertiary mt-2">{member.email}</p>
)}
{/* Brands */}
{member.brands && member.brands.length > 0 && (
<div className="flex flex-wrap gap-1 justify-center mt-3 pt-3 border-t border-border-light">
{member.brands.map((brand) => (
<BrandBadge key={brand} brand={brand} />
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,122 @@
import { useEffect } from 'react'
import { createPortal } from 'react-dom'
import { X, AlertTriangle } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
export default function Modal({
isOpen,
onClose,
title,
children,
size = 'md',
// Confirmation mode props
isConfirm = false,
confirmText,
cancelText,
onConfirm,
danger = false,
}) {
const { t } = useLanguage()
// Default translations
const finalConfirmText = confirmText || (danger ? t('common.delete') : t('common.save'))
const finalCancelText = cancelText || t('common.cancel')
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => { document.body.style.overflow = '' }
}, [isOpen])
if (!isOpen) return null
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
}
// Confirmation dialog
if (isConfirm) {
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center px-4">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/40 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal content */}
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-md animate-scale-in">
<div className="p-6">
{danger && (
<div className="w-12 h-12 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
<AlertTriangle className="w-6 h-6 text-red-600" />
</div>
)}
<h3 className="text-lg font-semibold text-text-primary text-center mb-2">{title}</h3>
<div className="text-sm text-text-secondary text-center mb-6">
{children}
</div>
<div className="flex items-center gap-3">
<button
onClick={onClose}
className="flex-1 px-4 py-2.5 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg transition-colors"
>
{finalCancelText}
</button>
<button
onClick={() => {
onConfirm?.();
onClose();
}}
className={`flex-1 px-4 py-2.5 text-sm font-medium text-white rounded-lg shadow-sm transition-colors ${
danger
? 'bg-red-600 hover:bg-red-700'
: 'bg-brand-primary hover:bg-brand-primary-light'
}`}
>
{finalConfirmText}
</button>
</div>
</div>
</div>
</div>,
document.body
)
}
// Regular modal
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[10vh] px-4">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/40 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal content */}
<div className={`relative bg-white rounded-2xl shadow-2xl w-full ${sizeClasses[size]} max-h-[80vh] flex flex-col animate-scale-in`}>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Body */}
<div className="px-6 py-4 overflow-y-auto flex-1">
{children}
</div>
</div>
</div>,
document.body
)
}

View File

@@ -0,0 +1,30 @@
import { PLATFORMS } from '../utils/api'
export default function PlatformIcon({ platform, size = 16, showLabel = false, className = '' }) {
const p = PLATFORMS[platform]
if (!p) return <span className="text-[10px] text-text-tertiary capitalize">{platform}</span>
return (
<span className={`inline-flex items-center gap-1 ${className}`} title={p.label}>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill={p.color}
className="shrink-0"
>
<path d={p.icon} />
</svg>
{showLabel && <span className="text-xs text-text-secondary">{p.label}</span>}
</span>
)
}
export function PlatformIcons({ platforms = [], size = 14, gap = 'gap-1', className = '' }) {
if (!platforms || platforms.length === 0) return null
return (
<span className={`inline-flex items-center ${gap} ${className}`}>
{platforms.map(p => <PlatformIcon key={p} platform={p} size={size} />)}
</span>
)
}

View File

@@ -0,0 +1,121 @@
import { format } from 'date-fns'
import { ArrowRight } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import BrandBadge from './BrandBadge'
import StatusBadge from './StatusBadge'
import PlatformIcon, { PlatformIcons } from './PlatformIcon'
export default function PostCard({ post, onClick, onMove, compact = false }) {
const { t } = useLanguage()
// Support both single platform and platforms array
const platforms = post.platforms?.length > 0
? post.platforms
: (post.platform ? [post.platform] : [])
const getInitials = (name) => {
if (!name) return '?'
return name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
}
const assigneeName = post.assignedToName || post.assignedName || post.assigned_name || (typeof post.assignedTo === 'object' ? post.assignedTo?.name : null)
if (compact) {
return (
<div
onClick={onClick}
className="bg-white rounded-lg p-3 border border-border hover:border-brand-primary/30 hover:shadow-md cursor-pointer transition-all group"
>
{post.thumbnail_url && (
<div className="w-full h-32 -mx-3 -mt-3 mb-2 rounded-t-lg overflow-hidden">
<img src={`http://localhost:3001${post.thumbnail_url}`} alt="" className="w-full h-full object-cover" />
</div>
)}
<div className="flex items-start justify-between gap-2 mb-2">
<h5 className="text-sm font-medium text-text-primary line-clamp-2 leading-snug">{post.title}</h5>
<div className="shrink-0 mt-0.5">
<PlatformIcons platforms={platforms} size={14} />
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
{post.brand && <BrandBadge brand={post.brand} />}
</div>
<div className="flex items-center justify-between mt-3 pt-2 border-t border-border-light">
{assigneeName ? (
<div className="flex items-center gap-1.5">
<div className="w-5 h-5 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-semibold">
{getInitials(assigneeName)}
</div>
<span className="text-xs text-text-tertiary">{assigneeName}</span>
</div>
) : (
<span className="text-xs text-text-tertiary">{t('common.unassigned')}</span>
)}
{post.scheduledDate && (
<span className="text-[10px] text-text-tertiary">
{format(new Date(post.scheduledDate), 'MMM d')}
</span>
)}
</div>
{/* Quick move buttons */}
{onMove && (
<div className="hidden group-hover:flex items-center gap-1 mt-2 pt-2 border-t border-border-light">
{post.status === 'draft' && (
<button onClick={(e) => { e.stopPropagation(); onMove(post._id, 'in_review') }}
className="text-[10px] text-amber-600 hover:bg-amber-50 px-2 py-0.5 rounded-full flex items-center gap-1">
{t('posts.sendToReview')} <ArrowRight className="w-3 h-3" />
</button>
)}
{post.status === 'in_review' && (
<button onClick={(e) => { e.stopPropagation(); onMove(post._id, 'approved') }}
className="text-[10px] text-blue-600 hover:bg-blue-50 px-2 py-0.5 rounded-full flex items-center gap-1">
{t('posts.approve')} <ArrowRight className="w-3 h-3" />
</button>
)}
{post.status === 'approved' && (
<button onClick={(e) => { e.stopPropagation(); onMove(post._id, 'scheduled') }}
className="text-[10px] text-purple-600 hover:bg-purple-50 px-2 py-0.5 rounded-full flex items-center gap-1">
{t('posts.schedule')} <ArrowRight className="w-3 h-3" />
</button>
)}
{post.status === 'scheduled' && (
<button onClick={(e) => { e.stopPropagation(); onMove(post._id, 'published') }}
className="text-[10px] text-emerald-600 hover:bg-emerald-50 px-2 py-0.5 rounded-full flex items-center gap-1">
{t('posts.publish')} <ArrowRight className="w-3 h-3" />
</button>
)}
</div>
)}
</div>
)
}
// Table row view
return (
<tr onClick={onClick} className="hover:bg-surface-secondary cursor-pointer group">
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="shrink-0">
<PlatformIcons platforms={platforms} size={16} />
</div>
<span className="text-sm font-medium text-text-primary">{post.title}</span>
</div>
</td>
<td className="px-4 py-3">{post.brand && <BrandBadge brand={post.brand} />}</td>
<td className="px-4 py-3"><StatusBadge status={post.status} /></td>
<td className="px-4 py-3">
<PlatformIcons platforms={platforms} size={16} gap="gap-1.5" />
</td>
<td className="px-4 py-3">
<span className="text-sm text-text-secondary">{assigneeName || '—'}</span>
</td>
<td className="px-4 py-3 text-sm text-text-tertiary">
{post.scheduledDate ? format(new Date(post.scheduledDate), 'MMM d, yyyy') : '—'}
</td>
</tr>
)
}

View File

@@ -0,0 +1,68 @@
import { format } from 'date-fns'
import { useNavigate } from 'react-router-dom'
import StatusBadge from './StatusBadge'
import BrandBadge from './BrandBadge'
export default function ProjectCard({ project }) {
const navigate = useNavigate()
const completedTasks = project.tasks?.filter(t => t.status === 'done').length || 0
const totalTasks = project.tasks?.length || 0
const progress = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
const ownerName = typeof project.owner === 'object' ? project.owner?.name : project.owner
return (
<div
onClick={() => navigate(`/projects/${project._id}`)}
className="bg-white rounded-xl border border-border p-5 card-hover cursor-pointer"
>
<div className="flex items-start justify-between gap-3 mb-3">
<h4 className="text-base font-semibold text-text-primary line-clamp-1">{project.name}</h4>
<StatusBadge status={project.status} size="xs" />
</div>
{project.brand && (
<div className="mb-3">
<BrandBadge brand={project.brand} />
</div>
)}
{project.description && (
<p className="text-sm text-text-secondary line-clamp-2 mb-4">{project.description}</p>
)}
{/* Progress bar */}
<div className="mb-3">
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-text-tertiary">Progress</span>
<span className="font-medium text-text-secondary">{progress}%</span>
</div>
<div className="h-1.5 bg-surface-tertiary rounded-full overflow-hidden">
<div
className="h-full bg-brand-primary rounded-full transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
<p className="text-[10px] text-text-tertiary mt-1">{completedTasks}/{totalTasks} tasks</p>
</div>
{/* Footer */}
<div className="flex items-center justify-between pt-3 border-t border-border-light">
{ownerName && (
<div className="flex items-center gap-1.5">
<div className="w-5 h-5 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-semibold">
{ownerName.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()}
</div>
<span className="text-xs text-text-tertiary">{ownerName}</span>
</div>
)}
{project.dueDate && (
<span className="text-xs text-text-tertiary">
Due {format(new Date(project.dueDate), 'MMM d')}
</span>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,169 @@
import { useContext } from 'react'
import { NavLink } from 'react-router-dom'
import {
LayoutDashboard, FileEdit, Image, Calendar, Wallet,
FolderKanban, CheckSquare, Users, ChevronLeft, ChevronRight, Sparkles, Shield, LogOut, User, Settings, Languages
} from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
const navItems = [
{ to: '/', icon: LayoutDashboard, labelKey: 'nav.dashboard', end: true, tutorial: 'dashboard' },
{ to: '/campaigns', icon: Calendar, labelKey: 'nav.campaigns', tutorial: 'campaigns' },
{ to: '/finance', icon: Wallet, labelKey: 'nav.finance', minRole: 'manager' },
{ to: '/posts', icon: FileEdit, labelKey: 'nav.posts', tutorial: 'posts' },
{ to: '/assets', icon: Image, labelKey: 'nav.assets', tutorial: 'assets' },
{ to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
{ to: '/tasks', icon: CheckSquare, labelKey: 'nav.tasks', tutorial: 'tasks' },
{ to: '/team', icon: Users, labelKey: 'nav.team', tutorial: 'team' },
]
const ROLE_LEVEL = { contributor: 0, manager: 1, superadmin: 2 }
export default function Sidebar({ collapsed, setCollapsed }) {
const { user: currentUser, logout } = useAuth()
const { t, lang, setLang } = useLanguage()
const userLevel = ROLE_LEVEL[currentUser?.role] ?? 0
const visibleItems = navItems.filter(item => {
if (!item.minRole) return true
return userLevel >= (ROLE_LEVEL[item.minRole] ?? 0)
})
return (
<aside
className={`sidebar fixed top-0 h-screen bg-sidebar flex flex-col z-30 transition-all duration-300 ${
collapsed ? 'w-[68px]' : 'w-[260px]'
}`}
>
{/* Logo */}
<div className="flex items-center gap-3 px-4 h-16 border-b border-white/10 shrink-0">
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-indigo-400 to-purple-500 flex items-center justify-center shrink-0">
<Sparkles className="w-5 h-5 text-white" />
</div>
{!collapsed && (
<div className="animate-fade-in overflow-hidden">
<h1 className="text-white font-bold text-sm leading-tight whitespace-nowrap">{t('app.name')}</h1>
<p className="text-text-on-dark-muted text-xs whitespace-nowrap">{t('app.subtitle')}</p>
</div>
)}
</div>
{/* Navigation */}
<nav className="flex-1 py-4 px-3 space-y-1 overflow-y-auto">
{visibleItems.map(({ to, icon: Icon, labelKey, end, tutorial }) => (
<NavLink
key={to}
to={to}
end={end}
data-tutorial={tutorial}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 group ${
isActive
? 'bg-white/15 text-white shadow-sm'
: 'text-text-on-dark-muted hover:bg-white/8 hover:text-white'
}`
}
>
<Icon className="w-5 h-5 shrink-0" />
{!collapsed && <span className="animate-fade-in whitespace-nowrap">{t(labelKey)}</span>}
</NavLink>
))}
{/* Superadmin Only: Users Management */}
{currentUser?.role === 'superadmin' && (
<NavLink
to="/users"
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 group ${
isActive
? 'bg-white/15 text-white shadow-sm'
: 'text-text-on-dark-muted hover:bg-white/8 hover:text-white'
}`
}
>
<Shield className="w-5 h-5 shrink-0" />
{!collapsed && <span className="animate-fade-in whitespace-nowrap">{t('nav.users')}</span>}
</NavLink>
)}
{/* Settings (visible to all) */}
<NavLink
to="/settings"
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 group ${
isActive
? 'bg-white/15 text-white shadow-sm'
: 'text-text-on-dark-muted hover:bg-white/8 hover:text-white'
}`
}
>
<Settings className="w-5 h-5 shrink-0" />
{!collapsed && <span className="animate-fade-in whitespace-nowrap">{t('nav.settings')}</span>}
</NavLink>
</nav>
{/* Current User & Logout */}
<div className="border-t border-white/10 shrink-0">
{currentUser && !collapsed && (
<div className="p-3 border-b border-white/10">
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-white/5">
<div className="w-8 h-8 rounded-full bg-brand-primary flex items-center justify-center shrink-0">
{currentUser.avatar ? (
<img src={currentUser.avatar} alt={currentUser.name} className="w-full h-full rounded-full object-cover" />
) : (
<User className="w-4 h-4 text-white" />
)}
</div>
<div className="flex-1 min-w-0 animate-fade-in">
<p className="text-white text-sm font-medium truncate">{currentUser.name}</p>
<p className="text-text-on-dark-muted text-xs truncate capitalize">{currentUser.role}</p>
</div>
</div>
</div>
)}
{!collapsed && (
<>
{/* Language Toggle */}
<div className="px-3 pb-3">
<button
onClick={() => setLang(lang === 'en' ? 'ar' : 'en')}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-text-on-dark-muted hover:bg-white/8 hover:text-white transition-colors text-sm"
>
<Languages className="w-4 h-4" />
<span className="animate-fade-in">{lang === 'en' ? 'عربي' : 'English'}</span>
</button>
</div>
{/* Logout */}
<div className="px-3 pb-3">
<button
onClick={logout}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-text-on-dark-muted hover:bg-white/8 hover:text-white transition-colors text-sm"
>
<LogOut className="w-4 h-4" />
<span className="animate-fade-in">{t('nav.logout')}</span>
</button>
</div>
</>
)}
{/* Collapse toggle */}
<div className="p-3 border-t border-white/10">
<button
onClick={() => setCollapsed(!collapsed)}
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-text-on-dark-muted hover:bg-white/8 hover:text-white transition-colors text-sm"
>
{collapsed ? <ChevronRight className="w-4 h-4" /> : (
<>
<ChevronLeft className="w-4 h-4" />
<span className="animate-fade-in">{t('nav.collapse')}</span>
</>
)}
</button>
</div>
</div>
</aside>
)
}

View File

@@ -0,0 +1,42 @@
export default function StatCard({ icon: Icon, label, value, subtitle, color = 'brand-primary', trend }) {
const colorMap = {
'brand-primary': 'from-indigo-500 to-indigo-600',
'brand-secondary': 'from-pink-500 to-pink-600',
'brand-tertiary': 'from-amber-500 to-amber-600',
'brand-quaternary': 'from-emerald-500 to-emerald-600',
}
const iconBgMap = {
'brand-primary': 'bg-indigo-50 text-indigo-600',
'brand-secondary': 'bg-pink-50 text-pink-600',
'brand-tertiary': 'bg-amber-50 text-amber-600',
'brand-quaternary': 'bg-emerald-50 text-emerald-600',
}
return (
<div className="bg-white rounded-xl border border-border p-5 card-hover">
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-medium text-text-tertiary">{label}</p>
<p className="text-3xl font-bold text-text-primary mt-1">{value}</p>
{subtitle && (
<p className="text-xs text-text-tertiary mt-1">{subtitle}</p>
)}
</div>
<div className={`w-11 h-11 rounded-xl flex items-center justify-center ${iconBgMap[color] || iconBgMap['brand-primary']}`}>
<Icon className="w-5 h-5" />
</div>
</div>
{trend && (
<div className="mt-3 pt-3 border-t border-border-light">
<div className="flex items-center gap-1">
<span className={`text-xs font-medium ${trend > 0 ? 'text-emerald-600' : 'text-red-500'}`}>
{trend > 0 ? '+' : ''}{trend}%
</span>
<span className="text-xs text-text-tertiary">vs last month</span>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,34 @@
import { getStatusConfig } from '../utils/api'
import { useLanguage } from '../i18n/LanguageContext'
const STATUS_LABEL_MAP = {
'draft': 'posts.status.draft',
'in_review': 'posts.status.in_review',
'approved': 'posts.status.approved',
'scheduled': 'posts.status.scheduled',
'published': 'posts.status.published',
'todo': 'tasks.todo',
'in_progress': 'tasks.in_progress',
'done': 'tasks.done',
}
export default function StatusBadge({ status, size = 'sm' }) {
const { t } = useLanguage()
const config = getStatusConfig(status)
const sizeClasses = {
xs: 'text-[10px] px-1.5 py-0.5',
sm: 'text-xs px-2 py-1',
md: 'text-sm px-2.5 py-1',
}
const labelKey = STATUS_LABEL_MAP[status]
const label = labelKey ? t(labelKey) : config.label
return (
<span className={`inline-flex items-center gap-1.5 rounded-full font-medium ${config.bg} ${config.text} ${sizeClasses[size]}`}>
<span className={`w-1.5 h-1.5 rounded-full ${config.dot}`}></span>
{label}
</span>
)
}

View File

@@ -0,0 +1,93 @@
import { format } from 'date-fns'
import { ArrowRight, Clock, User, UserCheck } from 'lucide-react'
import { PRIORITY_CONFIG } from '../utils/api'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
export default function TaskCard({ task, onMove, showProject = true }) {
const { t } = useLanguage()
const { user: authUser } = useAuth()
const priority = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
const projectName = typeof task.project === 'object' ? task.project?.name : task.projectName
const nextStatus = {
todo: 'in_progress',
in_progress: 'done',
}
const nextLabel = {
todo: t('tasks.start'),
in_progress: t('tasks.complete'),
}
const dueDate = task.due_date || task.dueDate
const isOverdue = dueDate && new Date(dueDate) < new Date() && task.status !== 'done'
const creatorName = task.creator_user_name || task.creatorUserName
// Determine if this task was assigned by someone else
const createdByUserId = task.created_by_user_id || task.createdByUserId
const isExternallyAssigned = authUser && createdByUserId && createdByUserId !== authUser.id
const assignedName = task.assigned_name || task.assignedName
return (
<div className={`bg-white rounded-lg border border-border p-3 card-hover group ${isExternallyAssigned ? 'border-l-[3px] border-l-blue-400' : ''}`}>
<div className="flex items-start gap-2.5">
{/* Priority dot */}
<div className={`w-2.5 h-2.5 rounded-full ${priority.color} mt-1.5 shrink-0`} title={priority.label} />
<div className="flex-1 min-w-0">
<h5 className={`text-sm font-medium leading-snug ${task.status === 'done' ? 'text-text-tertiary line-through' : 'text-text-primary'}`}>
{task.title}
</h5>
{/* Assigned by label for externally-assigned tasks */}
{isExternallyAssigned && creatorName && (
<div className="flex items-center gap-1 mt-1">
<UserCheck className="w-3 h-3 text-blue-400" />
<span className="text-[10px] text-blue-500 font-medium">{t('tasks.from')} {creatorName}</span>
</div>
)}
{/* Assigned to label for tasks you delegated */}
{!isExternallyAssigned && assignedName && (
<div className="flex items-center gap-1 mt-1">
<User className="w-3 h-3 text-emerald-400" />
<span className="text-[10px] text-emerald-500 font-medium">{t('tasks.assignedTo')} {assignedName}</span>
</div>
)}
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
{showProject && projectName && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-tertiary">
{projectName}
</span>
)}
{dueDate && (
<span className={`text-[10px] flex items-center gap-1 ${isOverdue ? 'text-red-500 font-medium' : 'text-text-tertiary'}`}>
<Clock className="w-3 h-3" />
{format(new Date(dueDate), 'MMM d')}
</span>
)}
{!isExternallyAssigned && creatorName && (
<span className="text-[10px] flex items-center gap-1 text-text-tertiary">
<User className="w-3 h-3" />
{creatorName}
</span>
)}
</div>
</div>
</div>
{/* Quick action */}
{onMove && nextStatus[task.status] && (
<div className="mt-2 pt-2 border-t border-border-light opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => onMove(task._id || task.id, nextStatus[task.status])}
className="text-[11px] text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1"
>
{nextLabel[task.status]} <ArrowRight className="w-3 h-3" />
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,248 @@
import { useState, useEffect } from 'react'
import { X, ArrowLeft, ArrowRight } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
const getTutorialSteps = (t) => [
{
target: '[data-tutorial="dashboard"]',
title: t('tutorial.dashboard.title'),
description: t('tutorial.dashboard.desc'),
position: 'right',
},
{
target: '[data-tutorial="campaigns"]',
title: t('tutorial.campaigns.title'),
description: t('tutorial.campaigns.desc'),
position: 'right',
},
{
target: '[data-tutorial="posts"]',
title: t('tutorial.posts.title'),
description: t('tutorial.posts.desc'),
position: 'right',
},
{
target: '[data-tutorial="tasks"]',
title: t('tutorial.tasks.title'),
description: t('tutorial.tasks.desc'),
position: 'right',
},
{
target: '[data-tutorial="team"]',
title: t('tutorial.team.title'),
description: t('tutorial.team.desc'),
position: 'right',
},
{
target: '[data-tutorial="assets"]',
title: t('tutorial.assets.title'),
description: t('tutorial.assets.desc'),
position: 'right',
},
{
target: '[data-tutorial="new-post"]',
title: t('tutorial.newPost.title'),
description: t('tutorial.newPost.desc'),
position: 'bottom',
},
{
target: '[data-tutorial="filters"]',
title: t('tutorial.filters.title'),
description: t('tutorial.filters.desc'),
position: 'bottom',
},
]
export default function Tutorial({ onComplete }) {
const { t } = useLanguage()
const [currentStep, setCurrentStep] = useState(0)
const [targetRect, setTargetRect] = useState(null)
const [tooltipPosition, setTooltipPosition] = useState({ top: 0, left: 0 })
const TUTORIAL_STEPS = getTutorialSteps(t)
const step = TUTORIAL_STEPS[currentStep]
useEffect(() => {
updatePosition()
window.addEventListener('resize', updatePosition)
return () => window.removeEventListener('resize', updatePosition)
}, [currentStep])
const updatePosition = () => {
const target = document.querySelector(step.target)
if (!target) {
console.warn(`Tutorial target not found: ${step.target}`)
return
}
const rect = target.getBoundingClientRect()
setTargetRect(rect)
// Calculate tooltip position
const tooltipWidth = 360
const tooltipHeight = 200
const padding = 20
let top = rect.top
let left = rect.right + padding
// Adjust based on position hint
if (step.position === 'bottom') {
top = rect.bottom + padding
left = rect.left
} else if (step.position === 'left') {
top = rect.top
left = rect.left - tooltipWidth - padding
} else if (step.position === 'top') {
top = rect.top - tooltipHeight - padding
left = rect.left
}
// Keep tooltip on screen
if (left + tooltipWidth > window.innerWidth) {
left = rect.left - tooltipWidth - padding
}
if (left < 0) {
left = padding
}
if (top + tooltipHeight > window.innerHeight) {
top = window.innerHeight - tooltipHeight - padding
}
if (top < 0) {
top = padding
}
setTooltipPosition({ top, left })
}
const handleNext = () => {
if (currentStep < TUTORIAL_STEPS.length - 1) {
setCurrentStep(currentStep + 1)
} else {
onComplete()
}
}
const handlePrev = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1)
}
}
const handleSkip = () => {
onComplete()
}
if (!targetRect) return null
return (
<div className="fixed inset-0 z-[9999] pointer-events-none">
{/* Dark overlay with spotlight cutout */}
<svg className="absolute inset-0 w-full h-full pointer-events-auto" style={{ pointerEvents: 'auto' }}>
<defs>
<mask id="tutorial-mask">
<rect x="0" y="0" width="100%" height="100%" fill="white" />
<rect
x={targetRect.left - 4}
y={targetRect.top - 4}
width={targetRect.width + 8}
height={targetRect.height + 8}
rx="8"
fill="black"
/>
</mask>
</defs>
<rect
x="0"
y="0"
width="100%"
height="100%"
fill="rgba(0, 0, 0, 0.7)"
mask="url(#tutorial-mask)"
onClick={handleSkip}
style={{ cursor: 'pointer' }}
/>
</svg>
{/* Highlight ring around target */}
<div
className="absolute border-4 border-brand-primary rounded-lg shadow-lg transition-all duration-300 pointer-events-none"
style={{
top: targetRect.top - 4,
left: targetRect.left - 4,
width: targetRect.width + 8,
height: targetRect.height + 8,
}}
/>
{/* Tooltip card */}
<div
className="absolute bg-white rounded-xl shadow-2xl border border-border p-6 animate-fade-in pointer-events-auto"
style={{
top: tooltipPosition.top,
left: tooltipPosition.left,
width: 360,
maxWidth: 'calc(100vw - 40px)',
}}
>
{/* Close button */}
<button
onClick={handleSkip}
className="absolute top-4 right-4 text-text-tertiary hover:text-text-primary transition-colors"
>
<X className="w-5 h-5" />
</button>
{/* Content */}
<div className="space-y-4">
<div>
<div className="flex items-center gap-2 text-xs font-medium text-brand-primary mb-2">
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary text-xs font-bold">
{currentStep + 1}
</span>
<span>{t('tutorial.step')} {currentStep + 1} {t('tutorial.of')} {TUTORIAL_STEPS.length}</span>
</div>
<h3 className="text-xl font-bold text-text-primary mb-2">{step.title}</h3>
<p className="text-sm text-text-secondary leading-relaxed">{step.description}</p>
</div>
{/* Progress bar */}
<div className="w-full h-1.5 bg-surface-tertiary rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-brand-primary to-purple-500 transition-all duration-300"
style={{ width: `${((currentStep + 1) / TUTORIAL_STEPS.length) * 100}%` }}
/>
</div>
{/* Navigation */}
<div className="flex items-center justify-between gap-3 pt-2">
<button
onClick={handleSkip}
className="text-sm font-medium text-text-tertiary hover:text-text-primary transition-colors"
>
{t('tutorial.skip')}
</button>
<div className="flex items-center gap-2">
{currentStep > 0 && (
<button
onClick={handlePrev}
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg transition-colors"
>
<ArrowLeft className="w-4 h-4" />
{t('tutorial.prev')}
</button>
)}
<button
onClick={handleNext}
className="flex items-center gap-1.5 px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm transition-colors"
>
{currentStep < TUTORIAL_STEPS.length - 1 ? t('tutorial.next') : t('tutorial.finish')}
{currentStep < TUTORIAL_STEPS.length - 1 && <ArrowRight className="w-4 h-4" />}
</button>
</div>
</div>
</div>
</div>
</div>
)
}