// =============================================================
// AM (Account Manager) portal — separate area for Clicky staff
// to manage their book of clients. Two-tier login (Client / AM)
// drops AMs into a portfolio dashboard from which they can drill
// into any individual client portal (with an AM ribbon overlay).
// =============================================================
const { useState } = React;
// AM's own profile — persisted to localStorage so edits survive
// reloads. Falls back to these defaults on first run.
const AM_USER_DEFAULTS = {
name: 'Joe Marshall',
firstName: 'Joe',
role: 'Head of Client Strategy',
email: 'joe@clicky.co.uk',
phone: '+44 161 808 1888',
bio: '12 years across paid and lifecycle, ex-AMV BBDO. Lead AM on Cornish hospitality + DTC pods at Clicky.',
avatar: 'assets/team/joe.png',
hue: 200,
calendarUrl: 'https://cal.com/joe-marshall/15min',
timezone: 'Europe/London',
workingHours: '9:00 – 17:30',
notifications: {
clientFlags: true, // email me when a client goes critical
contractApprovals: true,
weeklyDigest: true,
aiSuggestionDaily: false,
invoiceOverdue: true,
},
};
const loadAmProfile = () => {
try {
const raw = localStorage.getItem('clicky_am_profile');
if (raw) return { ...AM_USER_DEFAULTS, ...JSON.parse(raw) };
} catch (e) {}
return { ...AM_USER_DEFAULTS };
};
const saveAmProfile = (p) => {
try { localStorage.setItem('clicky_am_profile', JSON.stringify(p)); } catch(e) {}
};
// Live AM_USER object — mutated in place when profile is saved so
// every script tag's bare reference picks up the new values on
// the next render.
const AM_USER = loadAmProfile();
// 15 mock clients — diverse UK SMB mix. Health distribution roughly
// 9 good · 4 watch · 2 critical so the portfolio reads realistically.
const AM_CLIENTS = [
{
id: 'evergreen',
name: 'Evergreen Escapes',
shortName: 'Evergreen',
industry: 'Self-catering lodge holidays · Cornwall',
url: 'evergreen-escapes.co.uk',
tier: 'Premier',
mrr: 4250,
health: 'good',
services: ['Google Ads', 'Meta Ads', 'SEO', 'Maintenance Pro', 'GA4 Tagging'],
contact: 'Daniel Trevethan',
contactRole: 'Owner',
lastContact: '2 days ago',
nextRenewal: '01 Mar 2027',
isLive: true,
thisWeek: [
{ kind: 'report', label: 'April performance report due', when: 'Wed 6 May' },
{ kind: 'opp', label: 'Maintenance Pro upsell decision', when: 'This week' },
],
},
{
id: 'halcyon',
name: 'Halcyon Dental',
shortName: 'Halcyon',
industry: 'Private dental clinic · West London',
url: 'halcyondental.co.uk',
tier: 'Pro',
mrr: 2400,
health: 'watch',
services: ['Google Ads', 'SEO'],
contact: 'Dr Priya Kapoor',
contactRole: 'Practice Owner',
lastContact: '5 days ago',
nextRenewal: '12 Aug 2026',
thisWeek: [
{ kind: 'flag', label: 'Search spend pacing 12% over budget', when: 'Today' },
{ kind: 'ticket', label: 'GA4 conversion mismatch — needs reply', when: 'Yesterday' },
],
},
{
id: 'wickham',
name: 'Wickham & Co.',
shortName: 'Wickham',
industry: 'Family law firm · Bristol',
url: 'wickhamco.legal',
tier: 'Pro',
mrr: 3100,
health: 'good',
services: ['Google Ads', 'SEO', 'Local SEO'],
contact: 'Eleanor Wickham',
contactRole: 'Managing Partner',
lastContact: '1 week ago',
nextRenewal: '01 Oct 2026',
thisWeek: [
{ kind: 'report', label: 'Quarterly review deck', when: 'Thu 7 May' },
],
},
{
id: 'northwind',
name: 'Northwind Cycles',
shortName: 'Northwind',
industry: 'DTC ebike retailer · Glasgow → UK',
url: 'northwindcycles.co.uk',
tier: 'Premier',
mrr: 6200,
health: 'watch',
services: ['Google Ads', 'Meta Ads', 'TikTok Ads', 'SEO', 'CRO', 'Email'],
contact: 'Callum Reid',
contactRole: 'Head of Growth',
lastContact: '3 days ago',
nextRenewal: '15 Nov 2026',
thisWeek: [
{ kind: 'flag', label: 'ROAS dipping — Performance Max review', when: 'Today' },
{ kind: 'opp', label: 'TikTok creative test green-lit', when: 'This week' },
{ kind: 'report', label: 'April spend reconciliation', when: 'Fri 8 May' },
],
},
{
id: 'mariner',
name: 'Mariner Boats',
shortName: 'Mariner',
industry: 'Marine engineering · Plymouth',
url: 'marinerboats.co.uk',
tier: 'Standard',
mrr: 950,
health: 'good',
services: ['Google Ads'],
contact: 'Ian Petrie',
contactRole: 'Director',
lastContact: '2 weeks ago',
nextRenewal: '20 Jan 2027',
thisWeek: [],
},
{
id: 'saffron',
name: 'Saffron Collective',
shortName: 'Saffron',
industry: 'Indian restaurant group · Manchester',
url: 'saffroncollective.com',
tier: 'Pro',
mrr: 2750,
health: 'good',
services: ['Google Ads', 'Meta Ads', 'Local SEO', 'Reservations attribution'],
contact: 'Aditi Sharma',
contactRole: 'Marketing Director',
lastContact: 'Yesterday',
nextRenewal: '03 Sep 2026',
thisWeek: [
{ kind: 'opp', label: 'New Liverpool venue — onboarding kickoff', when: 'Tue 5 May' },
],
},
{
id: 'ridgeline',
name: 'Ridgeline Roofing',
shortName: 'Ridgeline',
industry: 'Roofing & insulation · North Yorkshire',
url: 'ridgelineroofing.co.uk',
tier: 'Standard',
mrr: 1150,
health: 'watch',
services: ['Google Ads', 'Local SEO'],
contact: 'Mark Holloway',
contactRole: 'Owner',
lastContact: '4 days ago',
nextRenewal: '01 Jul 2026',
thisWeek: [
{ kind: 'flag', label: 'Lead quality flagged — Form spam up 23%', when: 'Today' },
],
},
{
id: 'beacon',
name: 'Beacon Reads',
shortName: 'Beacon',
industry: "Children's literacy charity · National",
url: 'beaconreads.org.uk',
tier: 'Standard',
mrr: 0,
mrrNote: 'Google Ad Grants pro-bono',
health: 'good',
services: ['Google Ad Grants', 'SEO'],
contact: 'Sarah Whitfield',
contactRole: 'Director of Engagement',
lastContact: '1 week ago',
nextRenewal: 'Pro-bono · annual',
thisWeek: [
{ kind: 'report', label: 'Grants compliance review', when: 'Mon 4 May' },
],
},
{
id: 'lumen',
name: 'Lumen Skincare',
shortName: 'Lumen',
industry: 'DTC clean beauty · UK + DE',
url: 'lumenskincare.com',
tier: 'Premier',
mrr: 5400,
health: 'critical',
services: ['Google Ads', 'Meta Ads', 'TikTok Ads', 'Influencer attribution', 'Email/SMS'],
contact: 'Olivia Brennan',
contactRole: 'CMO',
lastContact: 'Today',
nextRenewal: '14 Dec 2026',
thisWeek: [
{ kind: 'flag', label: 'CAC up 38% — emergency review call', when: 'Today' },
{ kind: 'flag', label: 'Meta creative fatigue across 4 ad sets', when: 'Today' },
{ kind: 'report', label: 'CAC unwind report for Olivia', when: 'Tue 5 May' },
],
},
{
id: 'kestrel',
name: 'Kestrel Fitness',
shortName: 'Kestrel',
industry: 'Boutique gym chain · London (4 sites)',
url: 'kestrelfitness.co.uk',
tier: 'Pro',
mrr: 2200,
health: 'good',
services: ['Google Ads', 'Meta Ads', 'Local SEO'],
contact: 'Rob Davenport',
contactRole: 'Founder',
lastContact: '6 days ago',
nextRenewal: '01 Jun 2026',
thisWeek: [
{ kind: 'renewal', label: 'Renewal conversation — June', when: 'Fri 8 May' },
],
},
{
id: 'apex',
name: 'Apex Motors',
shortName: 'Apex',
industry: 'Used car dealership · Leeds',
url: 'apexmotorsleeds.co.uk',
tier: 'Standard',
mrr: 1300,
health: 'good',
services: ['Google Ads', 'Vehicle inventory feeds'],
contact: 'Gareth Morley',
contactRole: 'Sales Director',
lastContact: '10 days ago',
nextRenewal: '01 Feb 2027',
thisWeek: [],
},
{
id: 'threadbare',
name: 'Threadbare Vintage',
shortName: 'Threadbare',
industry: 'DTC vintage fashion · UK',
url: 'threadbarevintage.co.uk',
tier: 'Pro',
mrr: 2100,
health: 'critical',
services: ['Meta Ads', 'TikTok Ads', 'SEO', 'Email'],
contact: 'Nia Okafor',
contactRole: 'Founder',
lastContact: '2 days ago',
nextRenewal: '20 Sep 2026',
thisWeek: [
{ kind: 'flag', label: 'Conv. rate down 31% post site re-skin', when: 'Today' },
{ kind: 'ticket', label: 'Meta pixel events misfiring on PDP', when: 'Today' },
],
},
{
id: 'glasshouse',
name: 'Glasshouse Studios',
shortName: 'Glasshouse',
industry: 'Wedding photography · Cotswolds',
url: 'glasshousestudios.co.uk',
tier: 'Standard',
mrr: 850,
health: 'good',
services: ['Google Ads', 'SEO'],
contact: 'Henry Ashworth',
contactRole: 'Owner',
lastContact: '2 weeks ago',
nextRenewal: '01 May 2026',
thisWeek: [
{ kind: 'renewal', label: 'Renewal — overdue by 1 day', when: 'Today' },
],
},
{
id: 'tideway',
name: 'Tideway Logistics',
shortName: 'Tideway',
industry: 'B2B haulage · Felixstowe',
url: 'tidewaylogistics.co.uk',
tier: 'Pro',
mrr: 2900,
health: 'watch',
services: ['Google Ads', 'LinkedIn Ads', 'SEO'],
contact: 'Phil Walmsley',
contactRole: 'Commercial Director',
lastContact: '4 days ago',
nextRenewal: '15 Oct 2026',
thisWeek: [
{ kind: 'flag', label: 'Quality Score drop on freight terms', when: 'Today' },
],
},
{
id: 'hearth',
name: 'Hearth & Home',
shortName: 'Hearth & Home',
industry: 'Furniture retailer · 6 showrooms',
url: 'hearthandhomeuk.co.uk',
tier: 'Premier',
mrr: 4750,
health: 'good',
services: ['Google Ads', 'Meta Ads', 'SEO', 'Shopping feeds', 'CRO'],
contact: 'Vanessa Kim',
contactRole: 'Head of Digital',
lastContact: '3 days ago',
nextRenewal: '01 Apr 2027',
thisWeek: [
{ kind: 'report', label: 'Shopping feed health audit', when: 'Wed 6 May' },
{ kind: 'opp', label: 'CRO scope-of-work proposal due', when: 'Fri 8 May' },
],
},
];
// Stash on window so other scripts can read.
Object.assign(window, { AM_USER, AM_CLIENTS });
// Mutate window.CLIENT so the existing client portal screens render
// for whichever client the AM has drilled into. We keep a snapshot of
// the original Evergreen client object so we can restore on exit.
const __CLICKY_ORIGINAL_CLIENT__ = window.CLIENT ? { ...window.CLIENT } : null;
window.swapActiveClient = (clientId) => {
if (!window.CLIENT) return;
const c = AM_CLIENTS.find(x => x.id === clientId);
if (!c) {
// restore original
if (__CLICKY_ORIGINAL_CLIENT__) {
Object.assign(window.CLIENT, __CLICKY_ORIGINAL_CLIENT__);
}
return;
}
Object.assign(window.CLIENT, {
name: c.name,
shortName: c.shortName,
url: c.url,
contact: c.contact,
contactRole: c.contactRole,
tier: c.tier,
});
};
// =============================================================
// Login screen — two-tile route picker. Mock auth, persists choice.
// =============================================================
const LoginScreen = ({ onLogin }) => {
return (
{/* Brand */}
Sign in to continue
Choose how you'd like to enter the portal.
{/* Client tile */}
onLogin('client')}
style={{
padding: '20px 18px', textAlign: 'left',
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
cursor: 'pointer', fontFamily: 'inherit',
transition: 'border-color 120ms ease, box-shadow 120ms ease, transform 80ms ease',
display: 'flex', flexDirection: 'column', gap: 12,
}}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--primary)'; e.currentTarget.style.boxShadow = '0 4px 14px rgba(37,99,235,0.10)'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border)'; e.currentTarget.style.boxShadow = 'none'; }}
onMouseDown={e => { e.currentTarget.style.transform = 'translateY(1px)'; }}
onMouseUp={e => { e.currentTarget.style.transform = 'translateY(0)'; }}
>
I'm a Clicky client
See your performance, reports, contracts and your Clicky team.
{/* AM tile */}
onLogin('am')}
style={{
padding: '20px 18px', textAlign: 'left',
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
cursor: 'pointer', fontFamily: 'inherit',
transition: 'border-color 120ms ease, box-shadow 120ms ease, transform 80ms ease',
display: 'flex', flexDirection: 'column', gap: 12,
}}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#0f172a'; e.currentTarget.style.boxShadow = '0 4px 14px rgba(15,23,42,0.10)'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border)'; e.currentTarget.style.boxShadow = 'none'; }}
onMouseDown={e => { e.currentTarget.style.transform = 'translateY(1px)'; }}
onMouseUp={e => { e.currentTarget.style.transform = 'translateY(0)'; }}
>
Account manager view: portfolio of {AM_CLIENTS.length} clients, this week's queue, drill into any account.
Mock sign-in for prototype. Production would use Clicky SSO.
);
};
// =============================================================
// AM sidebar — distinct from client sidebar (dark internal-staff
// feel; no client account-manager card).
// =============================================================
const AmSidebar = ({ active, setActive, onSignOut, onOpenClient, currentClientId }) => {
const queueCount = AM_CLIENTS.reduce((n, c) => n + c.thisWeek.length, 0);
const NavRow = ({ id, icon, label, badge }) => {
const isActive = active === id;
return (
setActive(id)}
style={{
display: 'flex', alignItems: 'center', gap: 10,
width: '100%', padding: '8px 12px',
background: isActive ? 'rgba(255,255,255,0.08)' : 'transparent',
color: isActive ? '#fff' : 'rgba(255,255,255,0.7)',
border: 'none', borderRadius: 6,
fontSize: 13, fontWeight: 500, fontFamily: 'inherit',
textAlign: 'left', cursor: 'pointer',
transition: 'background 120ms ease, color 120ms ease',
}}
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent'; }}
>
{icon}
{label}
{badge != null && (
{badge}
)}
);
};
return (
{/* Brand */}
{/* AM profile — click to open profile settings */}
setActive('profile')}
title="My profile"
style={{
margin: 12, padding: 10,
background: active === 'profile' ? 'rgba(255,255,255,0.10)' : 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: 8, fontFamily: 'inherit',
display: 'flex', alignItems: 'center', gap: 10,
cursor: 'pointer', textAlign: 'left',
transition: 'background 120ms ease',
}}
onMouseEnter={e => { if (active !== 'profile') e.currentTarget.style.background = 'rgba(255,255,255,0.06)'; }}
onMouseLeave={e => { if (active !== 'profile') e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
>
{AM_USER.name}
{AM_USER.role}
{/* Nav */}
}
badge={AM_CLIENTS.length}
/>
}
badge={queueCount}
/>
}
/>
}
/>
}
/>
}
/>
{/* Quick switch — clients */}
Quick switch
{AM_CLIENTS.slice(0, 6).map(c => (
onOpenClient(c.id)}
style={{
display: 'flex', alignItems: 'center', gap: 8,
width: '100%', padding: '6px 8px',
background: currentClientId === c.id ? 'rgba(255,255,255,0.08)' : 'transparent',
color: currentClientId === c.id ? '#fff' : 'rgba(255,255,255,0.7)',
border: 'none', borderRadius: 6, cursor: 'pointer',
fontSize: 12, fontFamily: 'inherit', textAlign: 'left',
}}
onMouseEnter={e => { if (currentClientId !== c.id) e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
onMouseLeave={e => { if (currentClientId !== c.id) e.currentTarget.style.background = 'transparent'; }}
>
{c.shortName}
))}
+{AM_CLIENTS.length - 6} more in Portfolio
{/* Sign out */}
);
};
// =============================================================
// AM topbar — slim, no search, just title + AM utility actions.
// =============================================================
const AmTopbar = ({ title, subtitle, actions }) => (
{title}
{subtitle &&
{subtitle}
}
{actions}
);
// =============================================================
// Health pill — used everywhere a client appears.
// =============================================================
const HealthPill = ({ health }) => {
const map = {
good: { label: 'On track', color: '#16a34a', bg: 'color-mix(in oklab, #16a34a 12%, transparent)' },
watch: { label: 'Watch', color: '#d97706', bg: 'color-mix(in oklab, #d97706 14%, transparent)' },
critical: { label: 'Critical', color: '#dc2626', bg: 'color-mix(in oklab, #dc2626 14%, transparent)' },
};
const t = map[health] || map.good;
return (
{t.label}
);
};
const TierBadge = ({ tier }) => {
const map = {
Premier: { color: '#7c3aed', bg: 'color-mix(in oklab, #7c3aed 10%, transparent)' },
Pro: { color: '#2563eb', bg: 'color-mix(in oklab, #2563eb 10%, transparent)' },
Standard: { color: '#52525b', bg: 'var(--surface-2)' },
};
const t = map[tier] || map.Standard;
return (
{tier}
);
};
const KIND_ICONS = {
report: { icon: , color: '#2563eb', label: 'Report' },
flag: { icon: , color: '#dc2626', label: 'Flag' },
ticket: { icon: , color: '#d97706', label: 'Ticket' },
opp: { icon: , color: '#7c3aed', label: 'Opportunity' },
renewal: { icon: , color: '#16a34a', label: 'Renewal' },
};
// =============================================================
// Portfolio screen — grid of all 15 clients.
// =============================================================
const AmPortfolioScreen = ({ onOpenClient }) => {
const [healthFilter, setHealthFilter] = useState('all');
const [tierFilter, setTierFilter] = useState('all');
const [sortBy, setSortBy] = useState('attention');
const [search, setSearch] = useState('');
const totalMrr = AM_CLIENTS.reduce((n, c) => n + (c.mrr || 0), 0);
const flags = AM_CLIENTS.filter(c => c.health !== 'good').length;
const reportsDue = AM_CLIENTS.reduce((n, c) => n + c.thisWeek.filter(t => t.kind === 'report').length, 0);
const filtered = AM_CLIENTS
.filter(c => healthFilter === 'all' || c.health === healthFilter)
.filter(c => tierFilter === 'all' || c.tier === tierFilter)
.filter(c => !search.trim() || c.name.toLowerCase().includes(search.toLowerCase()) || c.industry.toLowerCase().includes(search.toLowerCase()))
.sort((a, b) => {
if (sortBy === 'attention') {
const score = c => (c.health === 'critical' ? 1000 : c.health === 'watch' ? 500 : 0) + c.thisWeek.length * 10;
return score(b) - score(a);
}
if (sortBy === 'mrr') return (b.mrr || 0) - (a.mrr || 0);
if (sortBy === 'name') return a.name.localeCompare(b.name);
return 0;
});
return (
Export CSV
+ Add client
>}
/>
{/* Summary strip */}
{[
{ label: 'Active clients', value: AM_CLIENTS.length, sub: `${AM_CLIENTS.filter(c => c.tier === 'Premier').length} Premier · ${AM_CLIENTS.filter(c => c.tier === 'Pro').length} Pro · ${AM_CLIENTS.filter(c => c.tier === 'Standard').length} Standard` },
{ label: 'Combined MRR', value: `£${(totalMrr/1000).toFixed(1)}k`, sub: `${AM_CLIENTS.filter(c => c.mrr === 0).length} pro-bono` },
{ label: 'Health flags', value: flags, sub: `${AM_CLIENTS.filter(c => c.health === 'critical').length} critical · ${AM_CLIENTS.filter(c => c.health === 'watch').length} watch`, tone: flags > 4 ? 'warning' : 'muted' },
{ label: 'Reports due / wk', value: reportsDue, sub: 'across the book' },
].map((s, i) => (
{s.label}
{s.value}
{s.sub}
))}
{/* Filters */}
{/* Grid */}
{filtered.map(c =>
onOpenClient(c.id)} />)}
{filtered.length === 0 && (
No clients match those filters.
)}
);
};
const SegmentedControl = ({ value, onChange, options }) => (
{options.map(o => (
onChange(o.value)} style={{
padding: '5px 10px', fontSize: 11.5, fontWeight: 500, fontFamily: 'inherit',
background: value === o.value ? 'var(--surface)' : 'transparent',
color: value === o.value ? 'var(--text)' : 'var(--text-muted)',
border: 'none', borderRadius: 4, cursor: 'pointer',
boxShadow: value === o.value ? 'var(--shadow-sm)' : 'none',
}}>{o.label}
))}
);
const PortfolioCard = ({ c, onOpen }) => (
{ e.currentTarget.style.borderColor = 'var(--primary)'; e.currentTarget.style.boxShadow = 'var(--shadow-md)'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border)'; e.currentTarget.style.boxShadow = 'none'; }}
>
{/* Header row */}
{/* Stats row */}
{[
{ label: 'MRR', value: c.mrr ? `£${c.mrr.toLocaleString()}` : '—', sub: c.mrrNote || 'monthly' },
{ label: 'Services', value: c.services.length, sub: 'active' },
{ label: 'Queue', value: c.thisWeek.length, sub: 'this week' },
].map((s, i) => (
{s.label}
{s.value}
{s.sub}
))}
{/* This-week list */}
{c.thisWeek.length === 0 ? (
Nothing on the queue this week.
) : (
{c.thisWeek.slice(0, 2).map((t, i) => {
const k = KIND_ICONS[t.kind] || KIND_ICONS.report;
return (
{k.icon}
{t.label}
{t.when}
);
})}
{c.thisWeek.length > 2 && (
+{c.thisWeek.length - 2} more
)}
)}
{/* Footer */}
{c.contact} · last contact {c.lastContact}
);
// =============================================================
// This Week — flat action queue across the portfolio.
// =============================================================
const AmThisWeekScreen = ({ onOpenClient }) => {
const all = AM_CLIENTS.flatMap(c => c.thisWeek.map(t => ({ ...t, client: c })));
const grouped = {
flag: all.filter(t => t.kind === 'flag'),
ticket: all.filter(t => t.kind === 'ticket'),
report: all.filter(t => t.kind === 'report'),
renewal: all.filter(t => t.kind === 'renewal'),
opp: all.filter(t => t.kind === 'opp'),
};
const order = ['flag', 'ticket', 'renewal', 'report', 'opp'];
return (
c.thisWeek.length).length} clients`}
/>
{order.map(kind => {
const items = grouped[kind];
if (!items.length) return null;
const k = KIND_ICONS[kind];
return (
{k.icon}
{k.label}s
{items.length} item{items.length === 1 ? '' : 's'}
{items.map((t, i) => (
onOpenClient(t.client.id)}
style={{
display: 'flex', alignItems: 'center', gap: 12,
width: '100%', padding: '11px 16px',
background: 'transparent', border: 'none',
borderTop: i === 0 ? 'none' : '1px solid var(--border)',
cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
{t.label}
{t.client.name} · {t.client.contact}
{t.when}
))}
);
})}
{all.length === 0 && (
Inbox zero. Nothing on the queue this week.
)}
);
};
// =============================================================
// Placeholder screens for nav items we haven't fully built.
// =============================================================
const AmStubScreen = ({ title, subtitle, icon }) => (
{icon}
{title} — coming soon
We'll wire this up next. For now, work the portfolio and this-week queue.
);
// =============================================================
// AM ribbon — slim bar shown above the client topbar when an AM
// has drilled into a specific client. Communicates "you are
// internal staff viewing this account" + offers fast switching.
// =============================================================
const AmRibbon = ({ clientId, onBack, onSwitchClient, onOpenSetup, setupOpen }) => {
const c = AM_CLIENTS.find(x => x.id === clientId);
const [open, setOpen] = useState(false);
if (!c) return null;
return (
Back to portfolio
AM view
Viewing as {AM_USER.firstName}:
{/* Client switcher */}
setOpen(v => !v)} style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '4px 10px', borderRadius: 6,
background: 'rgba(255,255,255,0.08)', color: '#fff',
border: '1px solid rgba(255,255,255,0.10)',
fontSize: 12, fontWeight: 600, fontFamily: 'inherit', cursor: 'pointer',
}}>
{c.name}
{open && (
<>
setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 31 }}/>
{AM_CLIENTS.map(other => (
{ setOpen(false); onSwitchClient(other.id); }}
style={{
display: 'flex', alignItems: 'center', gap: 8,
width: '100%', padding: '7px 10px',
background: other.id === clientId ? 'rgba(255,255,255,0.08)' : 'transparent',
color: '#fff', border: 'none', borderRadius: 6,
fontSize: 12, fontFamily: 'inherit', textAlign: 'left', cursor: 'pointer',
}}
onMouseEnter={e => { if (other.id !== clientId) e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
onMouseLeave={e => { if (other.id !== clientId) e.currentTarget.style.background = 'transparent'; }}
>
{other.name}
{other.tier}
))}
>
)}
{c.thisWeek.length > 0 && <>{c.thisWeek.length} item{c.thisWeek.length === 1 ? '' : 's'} in this week's queue · >}
{c.contact}
{/* Client setup gear — full AM control panel */}
{setupOpen ? 'Close setup' : 'Setup'}
);
};
// =============================================================
// Top-level AM portal shell — sidebar + selected screen.
// =============================================================
const AmPortal = ({ onSignOut, onOpenClient, initialActive = 'portfolio' }) => {
const [active, setActive] = useState(initialActive);
let screen;
if (active === 'portfolio') screen =
;
else if (active === 'thisweek') screen =
;
else if (active === 'reports') screen =
} />;
else if (active === 'performance') screen = } />;
else if (active === 'team') screen = } />;
else if (active === 'profile') screen = ;
else screen = ;
return (
);
};
// =============================================================
// AM rail — slim 64px dark rail shown on the far left when an
// AM has drilled into a client. Keeps AM workspace controls
// (portfolio, this-week, sign out) one click away without
// taking sidebar real-estate from the client portal underneath.
// =============================================================
const AmRail = ({ onBackToPortfolio, onGoThisWeek, onSignOut, currentClientId }) => {
const queueCount = AM_CLIENTS.reduce((n, c) => n + c.thisWeek.length, 0);
const RailBtn = ({ label, badge, onClick, children }) => (
{ e.currentTarget.style.background = 'rgba(255,255,255,0.06)'; e.currentTarget.style.color = '#fff'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'rgba(255,255,255,0.7)'; }}
>
{children}
{badge != null && badge > 0 && (
{badge}
)}
);
return (
{/* Brand mark */}
{/* AM avatar */}
{/* Portfolio */}
{/* This week */}
{/* Sign out */}
);
};
// =============================================================
// AM profile screen — manage the account manager's own identity,
// contact info, calendar, working hours, and notification prefs.
// Saves to localStorage and mutates the live AM_USER object so
// every other surface (rail, ribbon, sidebar card) updates.
// =============================================================
const AmProfileScreen = () => {
const [p, setP] = useState(() => loadAmProfile());
const [saved, setSaved] = useState(false);
const update = (patch) => {
const next = { ...p, ...patch };
setP(next);
saveAmProfile(next);
Object.assign(AM_USER, next); // mutate live object so other surfaces re-read
setSaved(true);
clearTimeout(window.__amProfileSavedTimer);
window.__amProfileSavedTimer = setTimeout(() => setSaved(false), 1500);
};
const updateNotif = (key, value) => update({ notifications: { ...p.notifications, [key]: value } });
// Avatar preview — accept an uploaded file and turn it into a data URL.
const onAvatarFile = (file) => {
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => update({ avatar: e.target.result });
reader.readAsDataURL(file);
};
return (
{saved ? 'Saved' : 'Auto-saves'}
}
/>
{/* Identity card */}
{p.name}
{p.role}
{p.email}
{
const name = e.target.value;
const firstName = name.split(' ')[0] || '';
update({ name, firstName });
}}
style={input()} />
update({ role: e.target.value })} style={input()} />
update({ email: e.target.value })} style={input()} />
update({ phone: e.target.value })} style={input()} />
update({ calendarUrl: e.target.value })}
placeholder="https://cal.com/you/15min" style={input()} />
update({ timezone: e.target.value })} style={input()}>
{['Europe/London','Europe/Dublin','Europe/Paris','America/New_York','America/Los_Angeles','Asia/Singapore','Australia/Sydney'].map(z => {z} )}
update({ workingHours: e.target.value })}
placeholder="9:00 – 17:30" style={input()} />
{/* Notifications */}
Notifications
When we email you. Everything goes to {p.email}.
{[
{ id: 'clientFlags', label: 'Client critical alerts', desc: 'Email me when one of my clients flips to critical.' },
{ id: 'contractApprovals', label: 'Contract approval needed', desc: 'Email me when a contract needs my sign-off.' },
{ id: 'invoiceOverdue', label: 'Invoice overdue', desc: 'Email me when one of my clients has an invoice 7+ days overdue.' },
{ id: 'aiSuggestionDaily', label: 'Daily AI suggestion digest', desc: 'Morning summary of new ClickyAI suggestions across my book.' },
{ id: 'weeklyDigest', label: 'Weekly portfolio digest', desc: 'Monday roundup — health, MRR moves, this-week\'s queue.' },
].map(n => (
updateNotif(n.id, e.target.checked)}
style={{ marginTop: 2 }} />
))}
{/* Qualifications & certifications */}
{/* Account / security (mock) */}
Account & security
Mock controls for the prototype.
);
};
// =============================================================
// Qualifications card — lives inside AmProfileScreen. Lists the
// AM's certifications and lets them connect Google / Meta / LinkedIn
// (mock — simulates an API pull) or add one manually. Edits flow
// straight to the client-facing TeamProfilePanel via the shared
// localStorage store.
// =============================================================
const QualificationsCard = ({ amName }) => {
const [list, setList] = useState(() => window.getTeamQualifications(amName) || []);
const [editing, setEditing] = useState(null);
const [connecting, setConnecting] = useState(null); // 'google' | 'meta' | 'linkedin' | null
const persist = (next) => {
setList(next);
window.setTeamQualifications(amName, next);
};
const upsert = (q) => {
const idx = list.findIndex(x => x.id === q.id);
persist(idx >= 0 ? list.map(x => x.id === q.id ? q : x) : [...list, q]);
setEditing(null);
};
const remove = (id) => {
if (confirm('Remove this qualification?')) persist(list.filter(q => q.id !== id));
};
const startManualAdd = () => setEditing({
id: 'q-' + Date.now(),
name: '', issuer: '', issuerSlug: 'manual',
issued: '', verified: false, source: 'manual',
isNew: true,
});
// Mock "Connect" flow — pretends to fetch certs from the platform's
// certification API. Surfaces a 1-second "connecting" spinner, then
// appends 1-3 new verified quals tagged with the appropriate source.
const connect = (provider) => {
setConnecting(provider);
const sample = ({
google: [
{ name: 'Google Ads Video Certification', issued: 'May 2025' },
{ name: 'Google Ads Apps Certification', issued: 'May 2025' },
{ name: 'Google Ads Measurement', issued: 'Apr 2025' },
],
meta: [
{ name: 'Meta Certified Media Planning Professional', issued: 'Apr 2025' },
{ name: 'Meta Certified Creative Strategy Professional', issued: 'Mar 2025' },
],
linkedin: [
{ name: 'LinkedIn Marketing Solutions Certified Marketing Insider', issued: 'Feb 2025' },
],
})[provider] || [];
setTimeout(() => {
const issuer = provider === 'google' ? 'Google' : provider === 'meta' ? 'Meta' : 'LinkedIn';
const additions = sample
.filter(s => !list.some(q => q.name === s.name))
.map((s, i) => ({
id: `q-${provider}-${Date.now()}-${i}`,
name: s.name, issuer, issuerSlug: provider,
issued: s.issued, verified: true, source: `${provider}-api`,
}));
persist([...list, ...additions]);
setConnecting(null);
}, 950);
};
const verifiedCount = list.filter(q => q.verified).length;
return (
Qualifications & certifications
{list.length} listed{verifiedCount > 0 && <> · {verifiedCount} verified >}
<> · shown on your profile in the client's "Your Team" page.>
+ Add manually
{/* Connect buttons — mock platform API pulls */}
{[
{ id: 'google', label: 'Connect Google', sub: 'Pull from Skillshop', hue: '#4285f4' },
{ id: 'meta', label: 'Connect Meta', sub: 'Pull from Meta Blueprint', hue: '#0866ff' },
{ id: 'linkedin', label: 'Connect LinkedIn', sub: 'Pull from LinkedIn Marketing', hue: '#0a66c2' },
].map(p => {
const isConnecting = connecting === p.id;
return (
connect(p.id)}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 12px', borderRadius: 8,
background: 'var(--surface)',
border: '1px solid color-mix(in oklab, ' + p.hue + ' 28%, var(--border))',
cursor: connecting ? 'wait' : 'pointer', fontFamily: 'inherit', textAlign: 'left',
opacity: connecting && !isConnecting ? 0.55 : 1,
transition: 'background 120ms ease, transform 80ms ease',
}}
onMouseEnter={e => { if (!connecting) e.currentTarget.style.background = 'color-mix(in oklab, ' + p.hue + ' 5%, var(--surface))'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'var(--surface)'; }}
>
{isConnecting ? 'Connecting…' : p.label}
{isConnecting ? 'Pulling certifications' : p.sub}
{isConnecting ? (
) : (
)}
);
})}
{/* List */}
setEditing(q)}
onRemove={remove}
/>
{editing && (
setEditing(null)}
onSave={upsert}
/>
)}
);
};
const QualificationEditModal = ({ qualification, onClose, onSave }) => {
const [q, setQ] = useState(qualification);
const update = (patch) => setQ(prev => ({ ...prev, ...patch }));
const inp = {
padding: '7px 10px', fontSize: 12.5,
background: 'var(--surface)', color: 'var(--text)',
border: '1px solid var(--border)', borderRadius: 6,
fontFamily: 'inherit', outline: 'none',
};
return (
e.stopPropagation()} style={{
width: '100%', maxWidth: 480,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 'var(--radius)', boxShadow: '0 24px 60px rgba(15,23,42,0.30)',
}}>
Qualification
{q.isNew ? 'Add a qualification' : 'Edit qualification'}
Cancel
onSave(q)}
disabled={!q.name.trim() || !q.issuer.trim()}
style={{
padding: '7px 14px', borderRadius: 6,
background: q.name.trim() && q.issuer.trim() ? '#0f172a' : 'var(--surface-2)',
color: q.name.trim() && q.issuer.trim() ? '#fff' : 'var(--text-faint)',
border: '1px solid ' + (q.name.trim() && q.issuer.trim() ? '#0f172a' : 'var(--border)'),
fontSize: 12, fontWeight: 500, fontFamily: 'inherit',
cursor: q.name.trim() && q.issuer.trim() ? 'pointer' : 'not-allowed',
}}
>{q.isNew ? 'Add qualification' : 'Save'}
);
};
const SettingRow = ({ label, value, cta, tone }) => (
);
// Tiny re-implementations of Field/input from am-setup.jsx so we don't
// depend on load order of script tags.
const Field = ({ label, children }) => (
{label}
{children}
);
const input = () => ({
padding: '7px 10px', fontSize: 12.5,
background: 'var(--surface)', color: 'var(--text)',
border: '1px solid var(--border)', borderRadius: 6,
fontFamily: 'inherit', outline: 'none',
});
Object.assign(window, {
LoginScreen, AmPortal, AmRibbon, AmRail, AmProfileScreen, HealthPill, TierBadge,
AM_USER, loadAmProfile, saveAmProfile,
});