Files
marketing-app/client/src/pages/Settings.jsx
T
fahed ce4d6025d7 feat: post composition redesign + budget allocation + brand identity (Rawaj)
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>
2026-03-15 18:02:29 +03:00

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>
</>
)
}