/* Mandera Portal — Client app shell + sections (live on MData). */
function StatusPill({ status }) {
const { Badge } = window.ManderaDesignSystem_8327ce;
const map = {
Confirmed: ['positive', 'soft'], Scheduled: ['positive', 'soft'], Pending: ['caution', 'outline'],
Declined: ['critical', 'outline'], Posted: ['neutral', 'soft'],
Draft: ['neutral', 'outline'], 'In progress': ['accent', 'soft'], Planning: ['caution', 'outline'],
done: ['positive', 'soft'], 'in-progress': ['accent', 'soft'], open: ['caution', 'outline'],
};
const [tone, variant] = map[status] || ['neutral', 'outline'];
const label = status === 'in-progress' ? 'In progress' : status.charAt(0).toUpperCase() + status.slice(1);
return {label};
}
function SectionWrap({ children, pad = 32 }) {
return
{children}
;
}
function Empty({ icon, text }) {
return (
{text}
);
}
const fmtDate = (iso) => { try { return new Date(iso + 'T00:00').toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); } catch (e) { return iso; } };
const _todayISO = () => new Date().toISOString().slice(0, 10);
function dayLabel(iso) {
const t = new Date(_todayISO() + 'T00:00'); const d = new Date(iso + 'T00:00');
const diff = Math.round((d - t) / 86400000);
if (diff === 0) return 'Today'; if (diff === 1) return 'Tomorrow';
if (diff > 1 && diff < 7) return new Date(iso + 'T00:00').toLocaleDateString('en-US', { weekday: 'long' });
return new Date(iso + 'T00:00').toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
}
/* Build an upcoming agenda (today onward) from calendar posts + shoots.
Pass clientId to scope to one client, or null for every client (owner view). */
function buildAgenda(clientId) {
const today = _todayISO();
const cal = (clientId ? window.MData.forClient('calendar', clientId) : window.MData.get('calendar'))
.filter((c) => c.date && c.date >= today)
.map((c) => ({ id: c.id, kind: 'post', date: c.date, time: c.time || '', title: c.title, meta: c.platform, status: c.status, clientId: c.clientId, media: c.media }));
const shoots = (clientId ? window.MData.forClient('shoots', clientId) : window.MData.get('shoots'))
.filter((s) => s.date && s.date >= today)
.map((s) => ({ id: s.id, kind: 'shoot', date: s.date, time: s.time || '', title: s.title, meta: s.location, status: s.status, clientId: s.clientId }));
const all = cal.concat(shoots).sort((a, b) => (a.date + (a.time || '')).localeCompare(b.date + (b.time || '')));
const byDate = {};
all.forEach((it) => { (byDate[it.date] = byDate[it.date] || []).push(it); });
return Object.keys(byDate).sort().map((d) => ({ date: d, items: byDate[d] }));
}
function AgendaRow({ it, showClient }) {
const client = showClient && it.clientId ? window.MData.client(it.clientId) : null;
const isImg = it.media && it.media.type && it.media.type.startsWith('image');
return (
{isImg
?

:
}
{it.title}
{client ? client.brand + ' · ' : ''}{it.kind === 'shoot' ? 'Shoot' : it.meta}{it.time ? ' · ' + it.time : ''}
);
}
function Agenda({ clientId, showClient, emptyText }) {
const days = buildAgenda(clientId);
if (!days.length) return ;
return (
{days.map((g) => (
{dayLabel(g.date)}
{fmtDate(g.date)}
{g.items.length} item{g.items.length > 1 ? 's' : ''}
{g.items.map((it) =>
)}
))}
);
}
/* Items scheduled for TODAY (shoots + posts), across one client or all. */
function todayItems(clientId) {
const today = _todayISO();
const cal = (clientId ? window.MData.forClient('calendar', clientId) : window.MData.get('calendar'))
.filter((c) => c.date === today)
.map((c) => ({ id: c.id, kind: 'post', date: c.date, time: c.time || '', title: c.title, meta: c.platform, status: c.status, clientId: c.clientId, media: c.media }));
const shoots = (clientId ? window.MData.forClient('shoots', clientId) : window.MData.get('shoots'))
.filter((s) => s.date === today)
.map((s) => ({ id: s.id, kind: 'shoot', date: s.date, time: s.time || '', title: s.title, meta: s.location, status: s.status, clientId: s.clientId }));
return cal.concat(shoots).sort((a, b) => (a.time || '').localeCompare(b.time || ''));
}
/* A "Today" card — the current date + everything happening on it. Used on
both the client dashboard and the owner overview so they match. */
function TodayPanel({ clientId, showClient }) {
const now = new Date();
const weekday = now.toLocaleDateString('en-US', { weekday: 'long' });
const rest = now.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
const items = todayItems(clientId || null);
return (
Today
{weekday}, {rest}
{items.length} scheduled
{items.length
?
:
Nothing scheduled for today.
}
);
}
/* ---------------- Dashboard ---------------- */
function ClientDashboard({ clientId, client, acc, go }) {
const { Button, Card } = window.ManderaDesignSystem_8327ce;
const shoots = window.MData.forClient('shoots', clientId).slice().sort((a, b) => a.date.localeCompare(b.date));
const calendar = window.MData.forClient('calendar', clientId).slice().sort((a, b) => a.date.localeCompare(b.date));
const projects = window.MData.forClient('projects', clientId);
const timeline = window.MData.forClient('timeline', clientId);
const nextShoot = shoots[0];
const nextPost = calendar[0];
return (
Welcome back · {client.brand}
Good to see you, {(acc.name || '').split(' ')[0]}.
Your account lead is {client.manager}. Here’s where things stand across your projects.
{[
{ ic: 'camera', lbl: 'Next shoot', val: nextShoot ? fmtDate(nextShoot.date) : '—', sub: nextShoot ? nextShoot.title : 'Nothing booked', color: 'var(--azure-400)' },
{ ic: 'send', lbl: 'Next post', val: nextPost ? fmtDate(nextPost.date) : '—', sub: nextPost ? nextPost.title : 'Nothing scheduled', color: 'var(--positive)' },
{ ic: 'folder-kanban', lbl: 'Active projects', val: projects.length + '', sub: projects.map((p) => p.name).join(' · ') || '—', color: 'var(--accent-text)' },
].map((s) => (
{s.lbl}
{s.val}
{s.sub}
))}
Upcoming shoots
{shoots.slice(0, 3).map((s) => (
{s.title}
{fmtDate(s.date)} · {s.time}
))}
{!shoots.length &&
}
Project timeline
{timeline.slice(0, 5).map((t, i) => (
{i < Math.min(timeline.length, 5) - 1 && }
{t.task}
{fmtDate(t.date)}
))}
{!timeline.length &&
}
);
}
/* ---------------- Shoots ---------------- */
function ClientShoots({ clientId, client, acc, focus, bump }) {
const { Button } = window.ManderaDesignSystem_8327ce;
const shoots = window.MData.forClient('shoots', clientId).slice().sort((a, b) => a.date.localeCompare(b.date));
const [openForm, setOpenForm] = React.useState(null);
const [note, setNote] = React.useState('');
const brand = client ? client.brand : 'A client';
const teamMail = (subject, intro, rows) => { try { fetch('../api/portal-mail.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'notify_team', subject, intro, rows }) }).catch(() => {}); } catch (e) {} };
const respond = (s, ok) => {
window.MData.update('shoots', s.id, { status: ok ? 'Confirmed' : 'Declined', respondedBy: acc.name, respondedAt: Date.now() });
window.MData.notify({ audience: 'team', type: 'shoot', text: brand + (ok ? ' approved' : ' declined') + ' the shoot “' + s.title + '” · ' + fmtDate(s.date), link: 'clients', refId: clientId });
teamMail((ok ? 'Shoot approved' : 'Shoot declined') + ' — ' + brand, brand + (ok ? ' approved' : ' declined') + ' a scheduled shoot.', [['Shoot', s.title], ['Date', fmtDate(s.date)], ['Time', s.time || '—'], ['Location', s.location || '—'], ['Response', ok ? 'Approved' : 'Declined']]);
bump();
};
const requestChange = (s) => {
const t = note.trim(); if (!t) return;
window.MData.update('shoots', s.id, { changeRequest: { note: t, by: acc.name, ts: Date.now() } });
window.MData.notify({ audience: 'team', type: 'shootchange', text: brand + ' requested a change to the shoot “' + s.title + '”: “' + t + '”', link: 'clients', refId: clientId });
teamMail('Shoot change request — ' + brand, brand + ' asked to change a scheduled shoot.', [['Shoot', s.title], ['Currently', fmtDate(s.date) + (s.time ? ' · ' + s.time : '')], ['Location', s.location || '—'], ['Request', t]]);
setOpenForm(null); setNote(''); bump();
};
const chipBtn = (color, bg, border) => ({ appearance: 'none', display: 'inline-flex', alignItems: 'center', gap: 7, cursor: 'pointer', fontFamily: 'var(--font-sans)', fontSize: 12.5, fontWeight: 600, color, background: bg, border: '1px solid ' + border, borderRadius: 'var(--radius-pill)', padding: '7px 14px' });
return (
{shoots.map((s) => {
const hot = focus && focus === s.id;
return (
{s.title}
{[['calendar', fmtDate(s.date)], ['clock', s.time], ['map-pin', s.location]].map(([ic, v]) => (
{v}
))}
{s.notes &&
{s.notes}
}
{s.changeRequest && (
Change requested: “{s.changeRequest.note}” — your Mandera team has been emailed and will confirm the new details.
)}
{s.status !== 'Declined' && (
{s.status === 'Pending' && (
)}
{!s.changeRequest && (
)}
)}
{openForm === s.id && (
)}
); })}
{!shoots.length && }
);
}
/* ---------------- Content calendar ---------------- */
/* Approve / request changes on a post. “Request changes” asks for a note,
which lands in the item's note thread so it shows under the content on
both sides. Works for Draft AND Scheduled items. */
function ApprovalRow({ it, clientId, acc, bump }) {
const [open, setOpen] = React.useState(false);
const [note, setNote] = React.useState('');
const cl = window.MData.client(clientId);
const brand = cl ? cl.brand : 'A client';
const approve = () => {
window.MData.update('calendar', it.id, { approval: 'approved' });
window.MData.notify({ audience: 'team', type: 'approval', text: brand + ' approved “' + it.title + '”', link: 'clients', refId: clientId });
bump && bump();
};
const sendChanges = (e) => {
e.preventDefault();
const t = note.trim(); if (!t) return;
const notes = (Array.isArray(it.notes) ? it.notes : []).concat([{ id: 'n' + Date.now().toString(36), who: acc ? acc.name : 'Client', role: 'client', text: t, ts: Date.now() }]);
window.MData.update('calendar', it.id, { approval: 'changes', approvalNote: t, notes });
window.MData.notify({ audience: 'team', type: 'approval', text: brand + ' requested changes on “' + it.title + '”: “' + t + '”', link: 'clients', refId: clientId });
setOpen(false); setNote(''); bump && bump();
};
const chip = (icon, color, bg, border, label) => (
{label}
);
if (it.approval === 'approved') return {chip('check', 'var(--positive)', 'rgba(47,170,110,0.1)', 'rgba(47,170,110,0.4)', 'Approved by you')}
;
return (
{it.approval === 'changes' && chip('pencil', '#e0a33e', 'rgba(224,163,62,0.1)', 'rgba(224,163,62,0.4)', 'Changes requested')}
{it.status !== 'Posted' && (
)}
{it.status !== 'Posted' && it.approval !== 'changes' && (
)}
{open && (
)}
);
}
function ClientCalendar({ clientId, acc, bump }) {
const items = window.MData.forClient('calendar', clientId).slice().sort((a, b) => (a.date + a.time).localeCompare(b.date + b.time));
const platIcon = { Instagram: 'instagram', TikTok: 'music', Email: 'mail', Facebook: 'facebook', YouTube: 'youtube' };
const isImg = (m) => m && m.type && m.type.startsWith('image');
const isVid = (m) => m && m.type && m.type.startsWith('video');
const byDate = {};
items.forEach((it) => { (byDate[it.date] = byDate[it.date] || []).push(it); });
return (
{Object.keys(byDate).sort().map((d) => (
{fmtDate(d)}
{byDate[d].map((it) => (
{it.time}
{it.media ? (
isImg(it.media)
?

:
) : (
)}
{it.title}
{it.platform}{it.media ? ' · ' + it.media.name : ''}
{it.media &&
}
))}
))}
{!items.length && }
);
}
/* ---------------- Timeline ---------------- */
function ClientTimeline({ clientId, acc, bump }) {
const tl = window.MData.forClient('timeline', clientId);
const groups = [['current', 'In progress'], ['upcoming', 'Upcoming'], ['completed', 'Completed']];
return (
{groups.map(([key, label]) => {
const items = tl.filter((t) => t.status === key);
if (!items.length) return null;
return (
{label}
{items.map((t) => (
{t.task}
{fmtDate(t.date)}
))}
);
})}
{!tl.length && }
);
}
/* ---------------- Files ---------------- */
function ClientFiles({ clientId, client, acc, bump }) {
const { Button } = window.ManderaDesignSystem_8327ce;
const files = window.MData.forClient('files', clientId);
const fileRef = React.useRef(null);
const [busy, setBusy] = React.useState(false);
const kindIcon = { pdf: 'file-text', doc: 'file-text', zip: 'file-archive', img: 'image', video: 'file-video' };
const onUpload = async (e) => {
const fl = e.target.files && e.target.files[0]; if (!fl) return;
e.target.value = '';
const ext = (fl.name.split('.').pop() || '').toLowerCase();
const kind = ['pdf'].includes(ext) ? 'pdf' : ['doc', 'docx', 'txt'].includes(ext) ? 'doc' : ['zip', 'rar'].includes(ext) ? 'zip' : ['png', 'jpg', 'jpeg', 'gif', 'webp'].includes(ext) ? 'img' : ['mp4', 'mov', 'webm'].includes(ext) ? 'video' : 'doc';
setBusy(true);
const up = await window.mUploadFile(fl);
const dataUrl = up ? null : await window.mReadSmall(fl, 2.5);
setBusy(false);
if (!up && !dataUrl) { alert('Could not upload “' + fl.name + '” — the server is unreachable right now and the file is too large to keep locally. Please try again.'); return; }
window.MData.add('files', { clientId, name: fl.name, kind, size: (fl.size / 1048576).toFixed(1) + ' MB', by: acc.name, date: new Date().toISOString().slice(0, 10), url: up ? up.url : null, dataUrl });
window.MData.notify({ audience: 'team', type: 'file', text: (client ? client.brand : 'A client') + ' uploaded a file: ' + fl.name, link: 'clients', refId: clientId });
bump();
};
return (
{files.map((fl, i) => (
{fl.name}
{fl.size} · {fl.by} · {fmtDate(fl.date)}
{(fl.url || fl.dataUrl)
?
:
}
))}
{!files.length &&
}
);
}
/* ---------------- Revisions ---------------- */
function ClientRevisions({ clientId, bump }) {
const { Button, Input } = window.ManderaDesignSystem_8327ce;
const revs = window.MData.forClient('revisions', clientId);
const [title, setTitle] = React.useState('');
const [detail, setDetail] = React.useState('');
const submit = (e) => { e.preventDefault(); if (!title.trim()) return; window.MData.add('revisions', { clientId, title: title.trim(), detail: detail.trim(), status: 'open', date: new Date().toISOString().slice(0, 10) }); const cl = window.MData.client(clientId); window.MData.notify({ audience: 'team', type: 'revision', text: (cl ? cl.brand : 'A client') + ' requested a change: ' + title.trim(), link: 'clients', refId: clientId }); setTitle(''); setDetail(''); bump(); };
return (
{revs.map((r) => (
{r.title}
{r.detail &&
{r.detail}
}
{fmtDate(r.date)}
))}
{!revs.length &&
}
);
}
/* ---------------- Messages ---------------- */
function ClientMessages({ clientId, acc, bump }) {
const { Button } = window.ManderaDesignSystem_8327ce;
const msgs = window.MData.forClient('messages', clientId).slice().sort((a, b) => a.ts - b.ts);
const [text, setText] = React.useState('');
const endRef = React.useRef(null);
React.useEffect(() => { if (endRef.current) endRef.current.scrollTop = endRef.current.scrollHeight; });
const send = (e) => { e.preventDefault(); if (!text.trim()) return; window.MData.add('messages', { clientId, fromRole: 'client', fromId: acc.id, fromName: (acc.name || 'You').split(' ')[0], text: text.trim(), ts: Date.now(), read: true }); const cl = window.MData.client(clientId); window.MData.notify({ audience: 'team', type: 'message', text: (cl ? cl.brand : 'A client') + ' sent you a message', link: 'messages', refId: clientId }); setText(''); bump(); };
return (
{msgs.map((m) => {
const mine = m.fromId ? m.fromId === acc.id : m.fromRole === 'client';
const av = !mine ? window.mAvatarOf(m.fromId) : null;
return (
{!mine &&
}
{m.text}
{m.fromName} · {window.mTimeAgo(m.ts)}
);
})}
{!msgs.length &&
}
);
}
/* ---------------- Notifications ---------------- */
function ClientNotifications({ clientId, bump, go }) {
const { Button } = window.ManderaDesignSystem_8327ce;
const notes = window.MData.forClient('notifications', clientId).slice().sort((a, b) => b.ts - a.ts);
const icon = { shoot: 'camera', file: 'file-text', message: 'message-square', schedule: 'calendar-days', invoice: 'receipt', note: 'message-circle', shootchange: 'calendar-clock', approval: 'check-check' };
const markAll = () => { notes.forEach((n) => window.MData.update('notifications', n.id, { read: true })); bump(); };
const open = (n) => { window.MData.update('notifications', n.id, { read: true }); if (n.link) go(n.link, n.refId); else bump(); };
return (
}>Mark all read
{notes.map((n) => (
open(n)} style={{ display: 'flex', gap: 14, alignItems: 'center', padding: '14px 16px', background: n.read ? 'var(--surface-card)' : 'var(--accent-soft)', border: '1px solid', borderColor: n.read ? 'var(--border-subtle)' : 'var(--border-accent)', borderRadius: 'var(--radius-md)', cursor: 'pointer' }}>
{n.text}
{n.link && }
{window.mTimeAgo(n.ts)}
))}
{!notes.length &&
}
);
}
/* ---------------- Invoices ---------------- */
function ClientInvoices({ clientId, client }) {
const { Badge } = window.ManderaDesignSystem_8327ce;
const rows = window.MData.forClient('invoices', clientId).slice().sort((a, b) => (b.date || '').localeCompare(a.date || ''));
return (
Billing history for {client.brand}. Tap an invoice to download.
{rows.map((r, i) => (
{r.no || r.name}
{r.date ? fmtDate(r.date) : ''}{r.amount ? ' · ' + r.amount : ''}
{r.status || 'Due'}
{(r.url || r.dataUrl)
?
:
No file}
))}
{!rows.length &&
}
);
}
/* ---------------- Settings ---------------- */
function ClientSettings({ clientId, client, acc, bump }) {
const { Button, Input } = window.ManderaDesignSystem_8327ce;
const [cur, setCur] = React.useState('');
const [n1, setN1] = React.useState('');
const [n2, setN2] = React.useState('');
const [err, setErr] = React.useState('');
const [ok, setOk] = React.useState(false);
const avRef = React.useRef(null);
const onb = window.MData.onboardingOf(clientId);
const submit = (e) => {
e.preventDefault(); setOk(false);
if (n1 !== n2) { setErr('The two new passwords don’t match.'); return; }
const res = window.MAuth.changePassword(acc.id, cur, n1);
if (res.error) { setErr(res.error); return; }
setErr(''); setOk(true); setCur(''); setN1(''); setN2(''); bump();
};
return (
Change password
);
}
/* ---------------- Client shell ---------------- */
const CLIENT_NAV = [
{ id: 'dashboard', label: 'Dashboard', icon: 'layout-dashboard' },
{ id: 'shoots', label: 'Upcoming Shoots', icon: 'camera' },
{ id: 'calendar', label: 'Content Calendar', icon: 'calendar-days' },
{ id: 'timeline', label: 'Project Timeline', icon: 'git-commit' },
{ id: 'files', label: 'File Center', icon: 'folder' },
{ id: 'revisions', label: 'Revisions', icon: 'git-pull-request' },
{ id: 'messages', label: 'Messages', icon: 'message-square' },
{ id: 'notifications', label: 'Notifications', icon: 'bell' },
{ id: 'invoices', label: 'Invoices', icon: 'receipt' },
{ id: 'settings', label: 'Settings', icon: 'settings' },
];
const CLIENT_TITLES = {
dashboard: ['Dashboard', 'Your partnership at a glance'],
shoots: ['Upcoming Shoots', 'Dates, times, locations & notes'],
calendar: ['Content Calendar', 'Scheduled posts & publishing dates'],
timeline: ['Project Timeline', 'What’s done, current and next'],
files: ['File Center', 'Download assets & upload files'],
revisions: ['Revisions', 'Request changes & track status'],
messages: ['Messages', 'Talk directly to your Mandera team'],
notifications: ['Notifications', 'Reminders & updates'],
invoices: ['Invoices', 'Billing history'],
settings: ['Settings', 'Profile & password'],
};
function AppShell({ initial }) {
const [nav, setNav] = React.useState(initial || 'dashboard');
const [focus, setFocus] = React.useState(null);
const [menuOpen, setMenuOpen] = React.useState(false);
const [, setTick] = React.useState(0);
const bump = () => setTick((t) => t + 1);
const acc = window.MAuth.current();
const clientId = acc && acc.clientId;
const client = clientId ? window.MData.client(clientId) : null;
if (!acc || !client) return ;
const go = (id, refId) => { setNav(id); setFocus(refId || null); setMenuOpen(false); };
React.useEffect(() => { window.__mpView = nav; }, [nav]);
const [title, sub] = CLIENT_TITLES[nav];
const unread = window.MData.forClient('notifications', clientId).filter((n) => !n.read).length;
const unreadMsg = window.MData.forClient('messages', clientId).filter((m) => m.fromRole !== 'client' && !m.read).length;
const nav2 = CLIENT_NAV.map((n) => n.id === 'notifications' && unread ? { ...n, badge: unread } : n.id === 'messages' && unreadMsg ? { ...n, badge: unreadMsg } : n);
const footer = { window.MData.update('accounts', acc.id, { avatar: d }); bump(); }} />;
const sectionProps = { clientId, client, acc, go, bump, focus };
return (
{menuOpen &&
setMenuOpen(false)} />}
{ window.MAuth.signOut(); go('dashboard'); window.__portalGo && window.__portalGo('login'); }} />
go('notifications')} onMenu={() => setMenuOpen(true)} />
{nav === 'dashboard' && }
{nav === 'shoots' && }
{nav === 'calendar' && }
{nav === 'timeline' && }
{nav === 'files' && }
{nav === 'revisions' && }
{nav === 'messages' && }
{nav === 'notifications' && }
{nav === 'invoices' && }
{nav === 'settings' && }
);
}
Object.assign(window, { AppShell, StatusPill, mFmtDate: fmtDate, Agenda, TodayPanel });