// ============================================================= // 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 */}
Clicky
Enterprise
Client Portal

Sign in to continue

Choose how you'd like to enter the portal.

{/* Client tile */} {/* AM tile */}
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 ( ); }; return ( ); }; // ============================================================= // 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 (
} />
{/* 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 */}
setSearch(e.target.value)} placeholder="Search clients…" style={{ flex: 1, border: 'none', background: 'transparent', outline: 'none', fontSize: 13, color: 'var(--text)', fontFamily: 'inherit' }} />
{/* Grid */}
{filtered.map(c => onOpenClient(c.id)} />)} {filtered.length === 0 && (
No clients match those filters.
)}
); }; const SegmentedControl = ({ value, onChange, options }) => (
{options.map(o => ( ))}
); const PortfolioCard = ({ c, onOpen }) => ( ); // ============================================================= // 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) => ( ))}
); })} {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 (
AM view Viewing as {AM_USER.firstName}: {/* Client switcher */}
{open && ( <>
setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 31 }}/>
{AM_CLIENTS.map(other => ( ))}
)}
{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 */}
); }; // ============================================================= // 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 (
{screen}
); }; // ============================================================= // 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 }) => ( ); return ( ); }; // ============================================================= // 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({ workingHours: e.target.value })} placeholder="9:00 – 17:30" style={input()} />