diff --git a/client/src/App.jsx b/client/src/App.jsx index 41a63f2..7b9f9e9 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -16,6 +16,7 @@ import Tasks from './pages/Tasks' import Team from './pages/Team' import Users from './pages/Users' import Settings from './pages/Settings' +import Brands from './pages/Brands' import Login from './pages/Login' import Tutorial from './components/Tutorial' import Modal from './components/Modal' @@ -40,7 +41,7 @@ export const AppContext = createContext() function AppContent() { const { user, loading: authLoading, checkAuth } = useAuth() - const { t } = useLanguage() + const { t, lang } = useLanguage() const [teamMembers, setTeamMembers] = useState([]) const [brands, setBrands] = useState([]) const [loading, setLoading] = useState(true) @@ -68,6 +69,13 @@ function AppContent() { } }, [user, authLoading]) + const getBrandName = (brandId) => { + if (!brandId) return null + const brand = brands.find(b => String(b._id || b.id) === String(brandId)) + if (!brand) return null + return lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name + } + const loadTeam = async () => { try { const data = await api.get('/users/team') @@ -115,7 +123,7 @@ function AppContent() { } return ( - + {/* Profile completion prompt */} {showProfilePrompt && (
@@ -261,6 +269,7 @@ function AppContent() { } /> } /> } /> + } /> } /> {user?.role === 'superadmin' && ( } /> diff --git a/client/src/components/PostCard.jsx b/client/src/components/PostCard.jsx index f26dfd2..792dd32 100644 --- a/client/src/components/PostCard.jsx +++ b/client/src/components/PostCard.jsx @@ -1,13 +1,17 @@ +import { useContext } from 'react' import { format } from 'date-fns' import { ArrowRight } from 'lucide-react' import { getInitials } from '../utils/api' import { useLanguage } from '../i18n/LanguageContext' +import { AppContext } from '../App' import BrandBadge from './BrandBadge' import StatusBadge from './StatusBadge' import { PlatformIcons } from './PlatformIcon' export default function PostCard({ post, onClick, onMove, compact = false }) { const { t } = useLanguage() + const { getBrandName } = useContext(AppContext) + const brandName = getBrandName(post.brand_id || post.brandId) || post.brand_name || post.brand // Support both single platform and platforms array const platforms = post.platforms?.length > 0 ? post.platforms @@ -35,7 +39,7 @@ export default function PostCard({ post, onClick, onMove, compact = false }) {
- {post.brand && } + {brandName && }
@@ -101,7 +105,7 @@ export default function PostCard({ post, onClick, onMove, compact = false }) { {post.title}
- {post.brand && } + {brandName && } diff --git a/client/src/components/ProjectCard.jsx b/client/src/components/ProjectCard.jsx index 6bc3862..48bf13b 100644 --- a/client/src/components/ProjectCard.jsx +++ b/client/src/components/ProjectCard.jsx @@ -1,10 +1,14 @@ +import { useContext } from 'react' import { format } from 'date-fns' import { useNavigate } from 'react-router-dom' +import { AppContext } from '../App' import StatusBadge from './StatusBadge' import BrandBadge from './BrandBadge' export default function ProjectCard({ project }) { const navigate = useNavigate() + const { getBrandName } = useContext(AppContext) + const brandLabel = getBrandName(project.brand_id) || project.brand const completedTasks = project.tasks?.filter(t => t.status === 'done').length || 0 const totalTasks = project.tasks?.length || 0 @@ -22,9 +26,9 @@ export default function ProjectCard({ project }) { - {project.brand && ( + {brandLabel && (
- +
)} diff --git a/client/src/components/Sidebar.jsx b/client/src/components/Sidebar.jsx index 3225993..5b85b78 100644 --- a/client/src/components/Sidebar.jsx +++ b/client/src/components/Sidebar.jsx @@ -2,7 +2,7 @@ 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 + FolderKanban, CheckSquare, Users, ChevronLeft, ChevronRight, Sparkles, Shield, LogOut, User, Settings, Languages, Tag } from 'lucide-react' import { useAuth } from '../contexts/AuthContext' import { useLanguage } from '../i18n/LanguageContext' @@ -16,6 +16,7 @@ const navItems = [ { 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' }, + { to: '/brands', icon: Tag, labelKey: 'nav.brands' }, ] const ROLE_LEVEL = { contributor: 0, manager: 1, superadmin: 2 } diff --git a/client/src/i18n/ar.json b/client/src/i18n/ar.json index 047e03d..9d37ea9 100644 --- a/client/src/i18n/ar.json +++ b/client/src/i18n/ar.json @@ -12,6 +12,7 @@ "nav.settings": "الإعدادات", "nav.users": "المستخدمين", "nav.logout": "تسجيل الخروج", + "nav.brands": "العلامات التجارية", "nav.collapse": "طي", "common.save": "حفظ", @@ -119,6 +120,8 @@ "posts.statusUpdated": "تم تحديث حالة المنشور!", "posts.attachmentDeleted": "تم حذف المرفق!", "posts.createFirstPost": "أنشئ أول منشور لك للبدء بإنتاج المحتوى.", + "posts.periodFrom": "من", + "posts.periodTo": "إلى", "posts.tryDifferentFilter": "جرب تعديل الفلاتر لرؤية المزيد من المنشورات.", "posts.status.draft": "مسودة", @@ -208,6 +211,21 @@ "assets.upload": "رفع", "assets.noAssets": "لا توجد أصول", + "brands.title": "العلامات التجارية", + "brands.addBrand": "إضافة علامة", + "brands.editBrand": "تعديل العلامة", + "brands.deleteBrand": "حذف العلامة؟", + "brands.deleteBrandConfirm": "هل أنت متأكد من حذف هذه العلامة؟ ستفقد المنشورات والحملات المرتبطة بها ارتباطها بالعلامة.", + "brands.noBrands": "لا توجد علامات تجارية", + "brands.brandName": "الاسم (إنجليزي)", + "brands.brandNameAr": "الاسم (عربي)", + "brands.brandPriority": "الأولوية", + "brands.brandIcon": "الأيقونة", + "brands.logo": "الشعار", + "brands.uploadLogo": "رفع الشعار", + "brands.changeLogo": "تغيير الشعار", + "brands.manageBrands": "إدارة العلامات التجارية لمؤسستك", + "settings.title": "الإعدادات", "settings.language": "اللغة", "settings.english": "English", @@ -219,6 +237,17 @@ "settings.tutorialRestarted": "تم إعادة تشغيل الدليل!", "settings.restarting": "جاري إعادة التشغيل...", "settings.reloadingPage": "جاري إعادة تحميل الصفحة لبدء الدليل...", + "settings.brands": "العلامات التجارية", + "settings.manageBrands": "إدارة العلامات التجارية وأسماء العرض", + "settings.addBrand": "إضافة علامة", + "settings.editBrand": "تعديل العلامة", + "settings.brandName": "الاسم (إنجليزي)", + "settings.brandNameAr": "الاسم (عربي)", + "settings.brandPriority": "الأولوية", + "settings.brandIcon": "الأيقونة", + "settings.deleteBrand": "حذف العلامة؟", + "settings.deleteBrandConfirm": "هل أنت متأكد من حذف هذه العلامة؟ ستفقد المنشورات والحملات المرتبطة بها ارتباطها بالعلامة.", + "settings.noBrands": "لا توجد علامات بعد. أضف أول علامة تجارية.", "settings.moreComingSoon": "المزيد من الإعدادات قريباً", "settings.additionalSettings": "سيتم إضافة إعدادات إضافية للإشعارات وتفضيلات العرض والمزيد هنا.", "settings.preferences": "إدارة تفضيلاتك وإعدادات التطبيق", diff --git a/client/src/i18n/en.json b/client/src/i18n/en.json index ba6f22a..d4fd35e 100644 --- a/client/src/i18n/en.json +++ b/client/src/i18n/en.json @@ -12,6 +12,7 @@ "nav.settings": "Settings", "nav.users": "Users", "nav.logout": "Logout", + "nav.brands": "Brands", "nav.collapse": "Collapse", "common.save": "Save", @@ -119,6 +120,8 @@ "posts.statusUpdated": "Post status updated!", "posts.attachmentDeleted": "Attachment deleted!", "posts.createFirstPost": "Create your first post to get started with content production.", + "posts.periodFrom": "From", + "posts.periodTo": "To", "posts.tryDifferentFilter": "Try adjusting your filters to see more posts.", "posts.status.draft": "Draft", @@ -208,6 +211,21 @@ "assets.upload": "Upload", "assets.noAssets": "No assets", + "brands.title": "Brands", + "brands.addBrand": "Add Brand", + "brands.editBrand": "Edit Brand", + "brands.deleteBrand": "Delete Brand?", + "brands.deleteBrandConfirm": "Are you sure you want to delete this brand? Posts and campaigns linked to it will lose their brand association.", + "brands.noBrands": "No brands yet", + "brands.brandName": "Name (English)", + "brands.brandNameAr": "Name (Arabic)", + "brands.brandPriority": "Priority", + "brands.brandIcon": "Icon", + "brands.logo": "Logo", + "brands.uploadLogo": "Upload Logo", + "brands.changeLogo": "Change Logo", + "brands.manageBrands": "Manage your organization's brands", + "settings.title": "Settings", "settings.language": "Language", "settings.english": "English", @@ -219,6 +237,17 @@ "settings.tutorialRestarted": "Tutorial Restarted!", "settings.restarting": "Restarting...", "settings.reloadingPage": "Reloading page to start tutorial...", + "settings.brands": "Brands", + "settings.manageBrands": "Manage your brands and their display names", + "settings.addBrand": "Add Brand", + "settings.editBrand": "Edit Brand", + "settings.brandName": "Name (English)", + "settings.brandNameAr": "Name (Arabic)", + "settings.brandPriority": "Priority", + "settings.brandIcon": "Icon", + "settings.deleteBrand": "Delete Brand?", + "settings.deleteBrandConfirm": "Are you sure you want to delete this brand? Posts and campaigns linked to it will lose their brand association.", + "settings.noBrands": "No brands yet. Add your first brand.", "settings.moreComingSoon": "More Settings Coming Soon", "settings.additionalSettings": "Additional settings for notifications, display preferences, and more will be added here.", "settings.preferences": "Manage your preferences and app settings", diff --git a/client/src/pages/Brands.jsx b/client/src/pages/Brands.jsx new file mode 100644 index 0000000..9151a80 --- /dev/null +++ b/client/src/pages/Brands.jsx @@ -0,0 +1,336 @@ +import { useState, useEffect, useContext, useRef } from 'react' +import { Tag, Plus, Edit2, Trash2, Upload, Image } from 'lucide-react' +import { api } from '../utils/api' +import { useLanguage } from '../i18n/LanguageContext' +import { useAuth } from '../contexts/AuthContext' +import { AppContext } from '../App' +import Modal from '../components/Modal' + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001/api' + +const EMPTY_BRAND = { name: '', name_ar: '', priority: 2, icon: '' } + +export default function Brands() { + const { t, lang } = useLanguage() + const { user } = useAuth() + const { getBrandName } = useContext(AppContext) + const isSuperadminOrManager = user?.role === 'superadmin' || user?.role === 'manager' + + const [brands, setBrands] = useState([]) + const [loading, setLoading] = useState(true) + const [showModal, setShowModal] = useState(false) + const [editingBrand, setEditingBrand] = useState(null) + const [brandForm, setBrandForm] = useState(EMPTY_BRAND) + const [showDeleteModal, setShowDeleteModal] = useState(false) + const [brandToDelete, setBrandToDelete] = useState(null) + const [uploading, setUploading] = useState(false) + const fileInputRef = useRef(null) + + useEffect(() => { + loadBrands() + }, []) + + const loadBrands = async () => { + try { + const data = await api.get('/brands') + setBrands(Array.isArray(data) ? data : (data.data || [])) + } catch (err) { + console.error('Failed to load brands:', err) + } finally { + setLoading(false) + } + } + + const openNewBrand = () => { + setEditingBrand(null) + setBrandForm(EMPTY_BRAND) + setShowModal(true) + } + + const openEditBrand = (brand) => { + setEditingBrand(brand) + setBrandForm({ + name: brand.name || '', + name_ar: brand.name_ar || '', + priority: brand.priority ?? 2, + icon: brand.icon || '', + }) + setShowModal(true) + } + + const saveBrand = async () => { + try { + const data = { + name: brandForm.name, + name_ar: brandForm.name_ar || null, + priority: brandForm.priority ? Number(brandForm.priority) : 2, + icon: brandForm.icon || null, + } + if (editingBrand) { + await api.patch(`/brands/${editingBrand.Id || editingBrand._id || editingBrand.id}`, data) + } else { + await api.post('/brands', data) + } + setShowModal(false) + setEditingBrand(null) + loadBrands() + } catch (err) { + console.error('Failed to save brand:', err) + } + } + + const confirmDelete = async () => { + if (!brandToDelete) return + try { + await api.delete(`/brands/${brandToDelete.Id || brandToDelete._id || brandToDelete.id}`) + setBrandToDelete(null) + setShowDeleteModal(false) + loadBrands() + } catch (err) { + console.error('Failed to delete brand:', err) + } + } + + const handleLogoUpload = async (brand, file) => { + const brandId = brand.Id || brand._id || brand.id + setUploading(true) + try { + const formData = new FormData() + formData.append('file', file) + const res = await fetch(`${API_BASE}/brands/${brandId}/logo`, { + method: 'POST', + body: formData, + credentials: 'include', + }) + if (!res.ok) throw new Error('Upload failed') + loadBrands() + } catch (err) { + console.error('Failed to upload logo:', err) + } finally { + setUploading(false) + } + } + + const getBrandId = (brand) => brand.Id || brand._id || brand.id + + if (loading) { + return ( +
+
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+

+ + {t('brands.title')} +

+

{t('brands.manageBrands')}

+
+ {isSuperadminOrManager && ( + + )} +
+ + {/* Brand Cards Grid */} + {brands.length === 0 ? ( +
+ +

{t('brands.noBrands')}

+
+ ) : ( +
+ {brands.map(brand => { + const displayName = lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name + return ( +
isSuperadminOrManager && openEditBrand(brand)} + > + {/* Logo area */} +
+ {brand.logo ? ( + {displayName} + ) : ( +
+ {brand.icon || } +
+ )} + {isSuperadminOrManager && ( +
e.stopPropagation()}> + + +
+ )} +
+ + {/* Card body */} +
+
+ {brand.icon && {brand.icon}} +

{displayName}

+
+
+ {brand.name && EN: {brand.name}} + {brand.name_ar && AR: {brand.name_ar}} + Priority: {brand.priority ?? '—'} +
+
+
+ ) + })} +
+ )} + + {/* Create/Edit Modal */} + { setShowModal(false); setEditingBrand(null) }} + title={editingBrand ? t('brands.editBrand') : t('brands.addBrand')} + > +
+
+ + setBrandForm(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="Brand name in English" + dir="ltr" + /> +
+
+ + setBrandForm(f => ({ ...f, name_ar: 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="اسم العلامة بالعربي" + dir="rtl" + /> +
+
+
+ + setBrandForm(f => ({ ...f, priority: e.target.value }))} + min="1" + max="10" + 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" + /> +
+
+ + setBrandForm(f => ({ ...f, icon: 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="emoji" + /> +
+
+ + {/* Logo upload — only for existing brands */} + {editingBrand && ( +
+ + {editingBrand.logo && ( +
+ Logo +
+ )} +
+ { + const file = e.target.files?.[0] + if (file) handleLogoUpload(editingBrand, file) + e.target.value = '' + }} + /> + +
+
+ )} + +
+ + +
+
+
+ + {/* Delete Confirmation */} + { setShowDeleteModal(false); setBrandToDelete(null) }} + title={t('brands.deleteBrand')} + isConfirm + danger + confirmText={t('common.delete')} + onConfirm={confirmDelete} + > + {t('brands.deleteBrandConfirm')} + +
+ ) +} diff --git a/client/src/pages/CampaignDetail.jsx b/client/src/pages/CampaignDetail.jsx index 0beb3ab..8532fe7 100644 --- a/client/src/pages/CampaignDetail.jsx +++ b/client/src/pages/CampaignDetail.jsx @@ -4,6 +4,7 @@ import { ArrowLeft, Plus, Edit2, Trash2, DollarSign, Eye, MousePointer, Target, import { format } from 'date-fns' import { AppContext } from '../App' import { useAuth } from '../contexts/AuthContext' +import { useLanguage } from '../i18n/LanguageContext' import { api, PLATFORMS, getInitials } from '../utils/api' import PlatformIcon, { PlatformIcons } from '../components/PlatformIcon' import StatusBadge from '../components/StatusBadge' @@ -43,7 +44,8 @@ function MetricBox({ label, value, icon: Icon, color = 'text-text-primary' }) { export default function CampaignDetail() { const { id } = useParams() const navigate = useNavigate() - const { brands } = useContext(AppContext) + const { brands, getBrandName } = useContext(AppContext) + const { lang } = useLanguage() const { permissions, user } = useAuth() const isSuperadmin = user?.role === 'superadmin' const [campaign, setCampaign] = useState(null) @@ -69,6 +71,7 @@ export default function CampaignDetail() { const [showDiscussion, setShowDiscussion] = useState(false) const [showEditModal, setShowEditModal] = useState(false) const [editForm, setEditForm] = useState({}) + const [showDeleteCampaignConfirm, setShowDeleteCampaignConfirm] = useState(false) const isCreator = campaign?.createdByUserId === user?.id || campaign?.created_by_user_id === user?.id const canManage = isSuperadmin || (permissions?.canEditCampaigns && isCreator) @@ -215,6 +218,8 @@ export default function CampaignDetail() { goals: campaign.goals || '', platforms: campaign.platforms || [], notes: campaign.notes || '', + brand_id: campaign.brand_id || '', + budget: campaign.budget || '', }) setShowEditModal(true) } @@ -230,6 +235,8 @@ export default function CampaignDetail() { goals: editForm.goals, platforms: editForm.platforms, notes: editForm.notes, + brand_id: editForm.brand_id || null, + budget: editForm.budget ? Number(editForm.budget) : null, }) setShowEditModal(false) loadAll() @@ -284,7 +291,7 @@ export default function CampaignDetail() {

{campaign.name}

- {campaign.brand_name && } + {(campaign.brand_id || campaign.brand_name) && }
{campaign.description &&

{campaign.description}

}
@@ -852,6 +859,19 @@ export default function CampaignDetail() { 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" />
+
+ + +