ce4d6025d7
Post Workflow: - PostDetail full page (/posts/:id) replaces slide panel approach - Post = 1 Caption Copy + 1 Body Copy + 1 Design + 0-1 Video - copy_type field on Translations (caption/body) - Composition endpoint returns rich data (content preview, languages, thumbnails) - Stage auto-advances on translation/artefact changes (both link and unlink) - "Translations" renamed to "Copy" in navigation - GET /api/posts/:id, /api/translations/:id, /api/artefacts/:id routes added - PostProduction: "New Post" creates → navigates to full page - CampaignDetail: click post → navigates to full page - Inline link picker (no modals) with search + rich item display - PostComposition sub-components for caption, copy, designs, video, formats, readiness Budget Allocation: - Single source of truth: BudgetEntries (Campaign.budget deprecated) - Budget mutex for race conditions - Validation at all levels (main → campaign → track, expenses) - CEO approval workflow: BudgetRequests table, public approval page - Finance page: request budget UI, budget requests section - Settings: CEO email field - All emails branded with "Rawaj —" prefix Brand Identity: - Name: Rawaj (رواج) — trending/virality - Deep teal palette (#0d9488), forest-tinted dark mode - DM Sans font, custom SVG logo - Consistent across login, sidebar, emails, public pages Approval Workflow: - Single reviewer per artefact (not multi-select) - Reviewer redirect on public review page - Server blocks submit-review without reviewer - Review URLs use APP_URL (not server URL) UI/UX: - Scroll clipping fix: Modal, TabbedModal, SlidePanel restructured to avoid overflow-y-auto clipping native select dropdowns - section-card overflow-hidden → overflow-clip - All page titles via Header.jsx (removed duplicate h1s) - CampaignDetail redesigned: prominent budget card, compact team Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
380 lines
17 KiB
React
380 lines
17 KiB
React
import { useState, useEffect, useContext } from 'react'
|
|
import { Settings as SettingsIcon, Play, CheckCircle, Languages, Coins, Upload, Tag, Plus, Pencil, Trash2, X, Mail } from 'lucide-react'
|
|
import { api } from '../utils/api'
|
|
import { useLanguage } from '../i18n/LanguageContext'
|
|
import { useToast } from '../components/ToastContainer'
|
|
import { CURRENCIES } from '../i18n/LanguageContext'
|
|
import { AppContext } from '../App'
|
|
import { useAuth } from '../contexts/AuthContext'
|
|
import Modal from '../components/Modal'
|
|
|
|
const ROLE_COLORS = [
|
|
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6',
|
|
'#EC4899', '#06B6D4', '#F97316', '#6366F1', '#14B8A6',
|
|
]
|
|
|
|
export default function Settings() {
|
|
const { t, lang, setLang, currency, setCurrency } = useLanguage()
|
|
const toast = useToast()
|
|
const { user } = useAuth()
|
|
const { roles, loadRoles } = useContext(AppContext)
|
|
const [restarting, setRestarting] = useState(false)
|
|
const [success, setSuccess] = useState(false)
|
|
const [maxSizeMB, setMaxSizeMB] = useState(50)
|
|
const [sizeSaving, setSizeSaving] = useState(false)
|
|
const [sizeSaved, setSizeSaved] = useState(false)
|
|
const [ceoEmail, setCeoEmail] = useState('')
|
|
const [ceoSaving, setCeoSaving] = useState(false)
|
|
const [ceoSaved, setCeoSaved] = useState(false)
|
|
|
|
useEffect(() => {
|
|
api.get('/settings/app').then(s => {
|
|
setMaxSizeMB(s.uploadMaxSizeMB || 50)
|
|
if (s.ceoEmail) setCeoEmail(s.ceoEmail)
|
|
}).catch(() => {})
|
|
}, [])
|
|
|
|
const handleSaveMaxSize = async () => {
|
|
setSizeSaving(true)
|
|
setSizeSaved(false)
|
|
try {
|
|
const res = await api.patch('/settings/app', { uploadMaxSizeMB: maxSizeMB })
|
|
setMaxSizeMB(res.uploadMaxSizeMB)
|
|
setSizeSaved(true)
|
|
setTimeout(() => setSizeSaved(false), 2000)
|
|
} catch (err) {
|
|
toast.error(err.message || t('settings.saveFailed'))
|
|
} finally {
|
|
setSizeSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleRestartTutorial = async () => {
|
|
setRestarting(true)
|
|
setSuccess(false)
|
|
try {
|
|
await api.patch('/users/me/tutorial', { completed: false })
|
|
setSuccess(true)
|
|
setTimeout(() => {
|
|
window.location.reload() // Reload to trigger tutorial
|
|
}, 1500)
|
|
} catch (err) {
|
|
console.error('Failed to restart tutorial:', err)
|
|
toast.error(t('settings.restartTutorialFailed'))
|
|
} finally {
|
|
setRestarting(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6 animate-fade-in max-w-3xl">
|
|
<p className="text-sm text-text-tertiary">{t('settings.preferences')}</p>
|
|
|
|
{/* General Settings */}
|
|
<div className="bg-surface rounded-xl border border-border overflow-clip">
|
|
<div className="px-6 py-4 border-b border-border">
|
|
<h3 className="font-semibold text-text-primary">{t('settings.general')}</h3>
|
|
</div>
|
|
<div className="p-6 space-y-4">
|
|
{/* Language Selector */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
|
|
<Languages className="w-4 h-4" />
|
|
{t('settings.language')}
|
|
</label>
|
|
<select
|
|
value={lang}
|
|
onChange={(e) => setLang(e.target.value)}
|
|
className="w-full max-w-xs px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
|
|
>
|
|
<option value="en">{t('settings.english')}</option>
|
|
<option value="ar">{t('settings.arabic')}</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Currency Selector */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
|
|
<Coins className="w-4 h-4" />
|
|
{t('settings.currency')}
|
|
</label>
|
|
<select
|
|
value={currency}
|
|
onChange={(e) => setCurrency(e.target.value)}
|
|
className="w-full max-w-xs px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
|
|
>
|
|
{CURRENCIES.map(c => (
|
|
<option key={c.code} value={c.code}>
|
|
{c.symbol} — {lang === 'ar' ? c.labelAr : c.labelEn}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<p className="text-xs text-text-tertiary mt-1.5">{t('settings.currencyHint')}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Uploads Section */}
|
|
<div className="bg-surface rounded-xl border border-border overflow-clip">
|
|
<div className="px-6 py-4 border-b border-border">
|
|
<h3 className="font-semibold text-text-primary flex items-center gap-2">
|
|
<Upload className="w-5 h-5 text-brand-primary" />
|
|
{t('settings.uploads')}
|
|
</h3>
|
|
</div>
|
|
<div className="p-6 space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-primary mb-2">
|
|
{t('settings.maxFileSize')}
|
|
</label>
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
max="500"
|
|
value={maxSizeMB}
|
|
onChange={(e) => setMaxSizeMB(Number(e.target.value))}
|
|
className="w-24 px-3 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
|
|
/>
|
|
<span className="text-sm text-text-secondary">{t('settings.mb')}</span>
|
|
<button
|
|
onClick={handleSaveMaxSize}
|
|
disabled={sizeSaving}
|
|
className="px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 transition-colors"
|
|
>
|
|
{sizeSaved ? (
|
|
<span className="flex items-center gap-1.5"><CheckCircle className="w-4 h-4" />{t('settings.saved')}</span>
|
|
) : sizeSaving ? '...' : t('common.save')}
|
|
</button>
|
|
</div>
|
|
<p className="text-xs text-text-tertiary mt-1.5">{t('settings.maxFileSizeHint')}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tutorial Section */}
|
|
<div className="bg-surface rounded-xl border border-border overflow-clip">
|
|
<div className="px-6 py-4 border-b border-border">
|
|
<h3 className="font-semibold text-text-primary">{t('settings.onboardingTutorial')}</h3>
|
|
</div>
|
|
<div className="p-6 space-y-4">
|
|
<p className="text-sm text-text-secondary">
|
|
{t('settings.tutorialDesc')}
|
|
</p>
|
|
<button
|
|
onClick={handleRestartTutorial}
|
|
disabled={restarting || success}
|
|
className="flex items-center gap-2 px-4 py-2.5 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 transition-colors"
|
|
>
|
|
{success ? (
|
|
<>
|
|
<CheckCircle className="w-4 h-4" />
|
|
{t('settings.tutorialRestarted')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Play className="w-4 h-4" />
|
|
{restarting ? t('settings.restarting') : t('settings.restartTutorial')}
|
|
</>
|
|
)}
|
|
</button>
|
|
{success && (
|
|
<p className="text-xs text-emerald-600 font-medium">
|
|
{t('settings.reloadingPage')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Budget Approval (Superadmin only) */}
|
|
{user?.role === 'superadmin' && (
|
|
<div className="bg-surface rounded-xl border border-border overflow-clip">
|
|
<div className="px-6 py-4 border-b border-border">
|
|
<h3 className="font-semibold text-text-primary flex items-center gap-2">
|
|
<Mail className="w-5 h-5 text-brand-primary" />
|
|
{t('settings.budgetApproval') || 'Budget Approval'}
|
|
</h3>
|
|
</div>
|
|
<div className="p-6 space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-primary mb-2">
|
|
{t('settings.ceoEmail')}
|
|
</label>
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
type="email"
|
|
value={ceoEmail}
|
|
onChange={(e) => setCeoEmail(e.target.value)}
|
|
placeholder="ceo@company.com"
|
|
className="flex-1 max-w-sm px-4 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface"
|
|
/>
|
|
<button
|
|
onClick={async () => {
|
|
setCeoSaving(true)
|
|
setCeoSaved(false)
|
|
try {
|
|
await api.patch('/settings/app', { ceoEmail })
|
|
setCeoSaved(true)
|
|
setTimeout(() => setCeoSaved(false), 2000)
|
|
} catch (err) {
|
|
toast.error(err.message || t('settings.saveFailed'))
|
|
} finally {
|
|
setCeoSaving(false)
|
|
}
|
|
}}
|
|
disabled={ceoSaving}
|
|
className="px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 transition-colors"
|
|
>
|
|
{ceoSaved ? (
|
|
<span className="flex items-center gap-1.5"><CheckCircle className="w-4 h-4" />{t('settings.saved')}</span>
|
|
) : ceoSaving ? '...' : t('common.save')}
|
|
</button>
|
|
</div>
|
|
<p className="text-xs text-text-tertiary mt-1.5">{t('settings.ceoEmailHint')}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Roles Management (Superadmin only) */}
|
|
{user?.role === 'superadmin' && <RolesSection roles={roles} loadRoles={loadRoles} t={t} toast={toast} />}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function RolesSection({ roles, loadRoles, t, toast }) {
|
|
const [editingRole, setEditingRole] = useState(null)
|
|
const [showAddModal, setShowAddModal] = useState(false)
|
|
const [modalForm, setModalForm] = useState({ name: '', color: ROLE_COLORS[0] })
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
const openAddModal = () => {
|
|
setModalForm({ name: '', color: ROLE_COLORS[roles.length % ROLE_COLORS.length] })
|
|
setShowAddModal(true)
|
|
}
|
|
|
|
const handleCreate = async () => {
|
|
setSaving(true)
|
|
try {
|
|
await api.post('/roles', { name: modalForm.name, color: modalForm.color })
|
|
await loadRoles()
|
|
setShowAddModal(false)
|
|
} catch (err) {
|
|
toast.error(err.message || t('common.error'))
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleSave = async (role) => {
|
|
setSaving(true)
|
|
try {
|
|
await api.patch(`/roles/${role.Id || role.id}`, { name: role.name, color: role.color })
|
|
await loadRoles()
|
|
setEditingRole(null)
|
|
} catch (err) {
|
|
toast.error(err.message || t('common.error'))
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleDelete = async (role) => {
|
|
if (!confirm(t('settings.deleteRoleConfirm'))) return
|
|
try {
|
|
await api.delete(`/roles/${role.Id || role.id}`)
|
|
await loadRoles()
|
|
} catch (err) {
|
|
toast.error(err.message || t('common.error'))
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="bg-surface dark:bg-surface-primary rounded-xl border border-border overflow-clip">
|
|
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
|
|
<h3 className="font-semibold text-text-primary flex items-center gap-2">
|
|
<Tag className="w-5 h-5 text-brand-primary" />
|
|
{t('settings.roles')}
|
|
</h3>
|
|
<button
|
|
onClick={openAddModal}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
{t('settings.addRole')}
|
|
</button>
|
|
</div>
|
|
<div className="p-6">
|
|
<p className="text-sm text-text-tertiary mb-4">{t('settings.rolesDesc')}</p>
|
|
<div className="space-y-2">
|
|
{roles.map(role => (
|
|
<div key={role.Id || role.id} className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-surface-secondary transition-colors">
|
|
{editingRole && (editingRole.Id || editingRole.id) === (role.Id || role.id) ? (
|
|
<div className="flex items-center gap-3 flex-1">
|
|
<input type="color" value={editingRole.color || '#94A3B8'} onChange={e => setEditingRole({ ...editingRole, color: e.target.value })}
|
|
className="w-8 h-8 rounded-lg border border-border cursor-pointer" />
|
|
<input type="text" value={editingRole.name} onChange={e => setEditingRole({ ...editingRole, name: e.target.value })}
|
|
placeholder={t('settings.roleName')} autoFocus
|
|
className="flex-1 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" />
|
|
<button onClick={() => handleSave(editingRole)} disabled={!editingRole.name || saving}
|
|
className="px-3 py-1.5 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light disabled:opacity-50 transition-colors">
|
|
{saving ? '...' : t('common.save')}
|
|
</button>
|
|
<button onClick={() => setEditingRole(null)} className="p-1.5 text-text-tertiary hover:text-text-primary rounded-lg hover:bg-surface-tertiary transition-colors">
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="w-4 h-4 rounded-full shrink-0" style={{ backgroundColor: role.color || '#94A3B8' }} />
|
|
<span className="flex-1 text-sm font-medium text-text-primary">{role.name}</span>
|
|
<button onClick={() => setEditingRole({ ...role })} className="p-1.5 text-text-tertiary hover:text-brand-primary rounded-lg hover:bg-surface-tertiary transition-colors">
|
|
<Pencil className="w-4 h-4" />
|
|
</button>
|
|
<button onClick={() => handleDelete(role)} className="p-1.5 text-text-tertiary hover:text-red-500 rounded-lg hover:bg-red-50 transition-colors">
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
))}
|
|
{roles.length === 0 && (
|
|
<p className="text-sm text-text-tertiary text-center py-6">{t('settings.noRoles')}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Modal isOpen={showAddModal} onClose={() => setShowAddModal(false)} title={t('settings.addRole')} size="sm">
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('settings.roleName')}</label>
|
|
<input type="text" value={modalForm.name} onChange={e => setModalForm(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={t('settings.roleName')} autoFocus />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('settings.roleColor') || 'Color'}</label>
|
|
<div className="flex items-center gap-3">
|
|
<input type="color" value={modalForm.color} onChange={e => setModalForm(f => ({ ...f, color: e.target.value }))}
|
|
className="w-10 h-10 rounded-lg border border-border cursor-pointer" />
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{ROLE_COLORS.map(c => (
|
|
<button key={c} type="button" onClick={() => setModalForm(f => ({ ...f, color: c }))}
|
|
className={`w-6 h-6 rounded-full border-2 transition-colors ${modalForm.color === c ? 'border-text-primary scale-110' : 'border-transparent'}`}
|
|
style={{ backgroundColor: c }} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button onClick={handleCreate} disabled={!modalForm.name || saving}
|
|
className={`w-full px-4 py-2.5 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 ${saving ? 'btn-loading' : ''}`}>
|
|
{t('settings.addRole')}
|
|
</button>
|
|
</div>
|
|
</Modal>
|
|
</>
|
|
)
|
|
}
|