// ============================================================= // AM client setup — full configuration workspace shown when an // AM clicks the gear icon in the AM ribbon. Three pass-1 tabs: // • Connections — link external accounts (paid, analytics, // commerce, CRM, notifications) // • Performance Targets — CRUD for the KPIs we report against // • AI Suggestions — triage every open ClickyAI suggestion // before it surfaces to the client // // All edits persist to localStorage under `clicky_am_setup_` // per client. First read seeds defaults from existing portal data // (ACCOUNTS, AGREED_KPIS) so the AM has something realistic to // work against. // ============================================================= const { useState: useStateSetup, useEffect: useEffectSetup } = React; // --- Supported platforms / integrations grouped by category -------- const PLATFORMS = [ { id: 'google-ads', name: 'Google Ads', category: 'Paid media', icon: 'google', keyField: 'Account ID', placeholder: 'XXX-XXX-XXXX' }, { id: 'microsoft-ads', name: 'Microsoft Ads', category: 'Paid media', icon: 'microsoft', keyField: 'Account ID', placeholder: 'Bing customer ID' }, { id: 'meta-ads', name: 'Meta Ads', category: 'Paid media', icon: 'meta', keyField: 'Ad Account', placeholder: 'act_xxxxxxxxxx' }, { id: 'tiktok-ads', name: 'TikTok Ads', category: 'Paid media', icon: 'tiktok', keyField: 'Advertiser ID',placeholder: '7012345678901234567' }, { id: 'linkedin-ads', name: 'LinkedIn Ads', category: 'Paid media', icon: 'linkedin', keyField: 'Account URN', placeholder: 'urn:li:sponsoredAccount:...' }, { id: 'reddit-ads', name: 'Reddit Ads', category: 'Paid media', icon: 'reddit', keyField: 'Account ID', placeholder: 'a2_xxxxxx' }, { id: 'ga4', name: 'Google Analytics 4', category: 'Analytics', icon: 'ga4', keyField: 'Property ID', placeholder: '123456789' }, { id: 'search-console', name: 'Search Console', category: 'Analytics', icon: 'gsc', keyField: 'Property', placeholder: 'sc-domain:example.com' }, { id: 'gtm', name: 'Tag Manager', category: 'Analytics', icon: 'gtm', keyField: 'Container', placeholder: 'GTM-XXXXXXX' }, { id: 'shopify', name: 'Shopify', category: 'Commerce / CMS', icon: 'shopify', keyField: 'Store domain', placeholder: 'shop.myshopify.com' }, { id: 'webflow', name: 'Webflow', category: 'Commerce / CMS', icon: 'webflow', keyField: 'Site ID', placeholder: 'wf_xxxx' }, { id: 'wordpress', name: 'WordPress', category: 'Commerce / CMS', icon: 'wordpress', keyField: 'REST endpoint',placeholder: 'https://site.com/wp-json' }, { id: 'hubspot', name: 'HubSpot CRM', category: 'CRM / Email', icon: 'hubspot', keyField: 'Portal ID', placeholder: '12345678' }, { id: 'klaviyo', name: 'Klaviyo', category: 'CRM / Email', icon: 'klaviyo', keyField: 'Public Key', placeholder: 'pk_xxxxxx' }, { id: 'mailchimp', name: 'Mailchimp', category: 'CRM / Email', icon: 'mailchimp', keyField: 'List ID', placeholder: 'us21-xxxxxx' }, { id: 'slack', name: 'Slack', category: 'Notifications', icon: 'slack', keyField: 'Channel', placeholder: '#evergreen-clicky' }, ]; // Tiny inline brand-mark — same simple two-letter chip we use elsewhere // when an icon isn't bundled. Letters stay readable in light + dark. const PlatformMark = ({ id, name, size = 28 }) => { const initials = name .replace(/Ads$|CRM$/, '') .trim() .split(/\s+/) .slice(0, 2) .map(w => w[0]?.toUpperCase()) .join(''); // Brand-ish hues per platform so the wall of integrations stays scannable. const hue = ({ 'google-ads': '#4285f4', 'microsoft-ads': '#00a4ef', 'meta-ads': '#0866ff', 'tiktok-ads': '#ff0050', 'linkedin-ads': '#0a66c2', 'reddit-ads': '#ff4500', 'ga4': '#f9ab00', 'search-console': '#4285f4', 'gtm': '#246fdb', 'shopify': '#95bf47', 'webflow': '#4353ff', 'wordpress': '#21759b', 'hubspot': '#ff7a59', 'klaviyo': '#000000', 'mailchimp': '#ffe01b', 'slack': '#611f69', })[id] || '#52525b'; return (
{initials}
); }; // --- Default seed suggestions (4-6 per client). Keep generic so we // don't have to hand-author per-client lists for the prototype. const DEFAULT_SUGGESTIONS = [ { id: 'sug-pmax-prune', title: 'Pause underperforming Performance Max asset group', impact: 'High', confidence: 'High', source: 'Google Ads', body: 'Asset Group "Spring Lodges" is spending 22% of Pmax budget at 0.8× ROAS. Suggest pausing and reallocating to top-performing group "Family Stays" (3.4× ROAS).', suggestedAction: 'Pause asset group, reallocate £180/day budget', status: 'open', }, { id: 'sug-meta-lookalike', title: 'Test Lookalike audiences from past-90-day buyers', impact: 'Medium', confidence: 'High', source: 'Meta Ads', body: 'No Lookalikes currently running. Past-90-day buyer list is 1,847 contacts — large enough to seed 1% / 3% / 5% LALs and run a structured test against current Interest stack.', suggestedAction: 'Create 3 LAL audiences, A/B against Interest baseline for 14 days', status: 'open', }, { id: 'sug-negs', title: 'Add 14 negative keywords to Branded campaign', impact: 'Low', confidence: 'High', source: 'Google Ads', body: 'Search-terms report shows 14 high-impression terms with 0 conversions in 30 days, eating 4.8% of Branded spend. Mostly unrelated brand-similar queries.', suggestedAction: 'Add 14 exact-match negatives to Branded shared list', status: 'open', }, { id: 'sug-ga4-conv', title: 'Migrate to GA4 enhanced conversions', impact: 'Medium', confidence: 'Medium', source: 'GA4', body: 'Currently sending hashed-email-only. Adding name + phone + address would lift match rate ~22-28% based on internal benchmarks across similar accounts.', suggestedAction: 'Update GTM data layer to include first/last name, phone and city', status: 'open', }, { id: 'sug-cro-cta', title: 'A/B test booking CTA above the fold', impact: 'Medium', confidence: 'Medium', source: 'CRO', body: 'Heatmap shows 68% of users never scroll to the current "Book a stay" CTA. Moving it above the fold on the homepage hero is a low-effort test.', suggestedAction: 'Build variant in Webflow, run for 21 days at 50/50 split', status: 'open', }, { id: 'sug-seo-internal-links', title: 'Add 6 internal links from blog to lodge pages', impact: 'Low', confidence: 'Medium', source: 'SEO', body: 'Top-3-traffic blog posts have no internal links to commercial pages. Adding contextual links to "Stay" / "Ownership" pages should improve assisted conversions.', suggestedAction: 'Edit 3 blog posts, add 2 internal links each', status: 'open', }, ]; // --- Per-client setup persistence ---------------------------------- const SETUP_KEY = (id) => 'clicky_am_setup_' + id; const loadSetup = (clientId) => { try { const raw = localStorage.getItem(SETUP_KEY(clientId)); if (raw) { const parsed = JSON.parse(raw); // Backfill any new sections added after the saved blob was written. return { connections: parsed.connections || seedConnections(), cpts: parsed.cpts || seedCpts(), suggestions: parsed.suggestions || seedSuggestions(), profile: parsed.profile || seedProfile(clientId), users: parsed.users || seedUsers(clientId), contracts: parsed.contracts || seedContracts(), invoices: parsed.invoices || seedInvoices(), }; } } catch (e) {} return { connections: seedConnections(), cpts: seedCpts(), suggestions: seedSuggestions(), profile: seedProfile(clientId), users: seedUsers(clientId), contracts: seedContracts(), invoices: seedInvoices(), }; }; // --- Seed helpers -------------------------------------------------- const seedConnections = () => { const connectedSeed = ['google-ads', 'meta-ads', 'ga4', 'search-console']; const connections = {}; PLATFORMS.forEach(p => { connections[p.id] = connectedSeed.includes(p.id) ? { connected: true, accountId: '—', lastSync: '2 min ago', primary: connectedSeed.indexOf(p.id) === 0 } : { connected: false, accountId: '', lastSync: null, primary: false }; }); if (window.ACCOUNTS && window.ACCOUNTS.length) { connections['google-ads'].accountId = window.ACCOUNTS[0].id; } return connections; }; const seedCpts = () => (window.AGREED_KPIS || []).slice(0, 6).map((k, i) => ({ id: 'cpt-' + (i + 1), name: k.label || k.name || 'Untitled target', source: k.source || 'Cross-channel', metric: k.metric || k.label || '—', target: k.target || '', direction: k.direction || 'higher', cadence: 'monthly', owner: 'Joe Marshall', definition: k.definition || '', baseline: k.baseline || '', })); const seedSuggestions = () => DEFAULT_SUGGESTIONS.map(s => ({ ...s, amNote: '', triage: null, decidedAt: null })); const seedProfile = (clientId) => { const c = (window.AM_CLIENTS || []).find(x => x.id === clientId) || {}; return { brand: { name: c.name || '', shortName: c.shortName || '', url: c.url || '', tagline: c.industry || '', primaryColor: '#2563eb', }, registered: { address1: '', address2: '', city: '', postcode: '', country: 'United Kingdom', companyNumber: '', vat: '', }, contacts: { primary: { name: c.contact || '', role: c.contactRole || 'Owner', email: c.contact ? c.contact.toLowerCase().split(' ').join('.') + '@' + (c.url || 'client.co.uk') : '', phone: '' }, billing: { name: '', role: 'Billing contact', email: 'accounts@' + (c.url || 'client.co.uk'), phone: '' }, technical: { name: '', role: 'Technical contact', email: '', phone: '' }, marketing: { name: '', role: 'Marketing contact', email: '', phone: '' }, }, }; }; const seedUsers = (clientId) => { const c = (window.AM_CLIENTS || []).find(x => x.id === clientId) || {}; const domain = (c.url || 'client.co.uk').replace(/\/.*$/, ''); const slug = (n) => n.toLowerCase().replace(/[^a-z]/g, '.').replace(/\.+/g, '.').replace(/^\.|\.$/g, ''); return [ { id: 'u-1', name: c.contact || 'Daniel Trevethan', email: (c.contact ? slug(c.contact) : 'daniel.trevethan') + '@' + domain, role: 'Owner', status: 'active', lastLogin: '2 hours ago', mfa: true, invitedBy: 'Joe Marshall', joined: '12 Jan 2025' }, { id: 'u-2', name: 'Emma Holloway', email: 'emma@' + domain, role: 'Marketing lead', status: 'active', lastLogin: 'Yesterday', mfa: true, invitedBy: c.contact || 'Daniel Trevethan', joined: '14 Feb 2025' }, { id: 'u-3', name: 'Tom Brennan', email: 'tom@' + domain, role: 'Viewer', status: 'invited', lastLogin: null, mfa: false, invitedBy: c.contact || 'Daniel Trevethan', joined: 'pending' }, ]; }; const seedContracts = () => { const existing = (window.CONTRACTS || []).map((c, i) => ({ ...c, pendingApproval: false, services: c.name, autoRenew: i % 2 === 0, signedBy: 'Daniel Trevethan', notes: '', })); // Add one synthetic pending contract so the AM has something to approve. existing.unshift({ id: 'C-PEND-001', name: 'Meta Ads — Q3 expansion', services: 'Meta Ads creative + media buying expansion', start: '01 Jul 2026', renews: '01 Jul 2027', term: '12 months', monthly: '£1,250.00', status: 'Pending approval', pendingApproval: true, autoRenew: true, signedBy: 'Awaiting client e-sign', notes: 'Drafted after April performance review. Waiting on Daniel to e-sign.', }); return existing; }; const seedInvoices = () => (window.INVOICES || []).map(inv => ({ ...inv, amNote: '', reminderSentAt: null, })); const saveSetup = (clientId, setup) => { try { localStorage.setItem(SETUP_KEY(clientId), JSON.stringify(setup)); } catch (e) {} }; // --- The setup screen ---------------------------------------------- const AmClientSetupScreen = ({ clientId, onClose }) => { const client = (window.AM_CLIENTS || []).find(c => c.id === clientId); const [tab, setTab] = useStateSetup('connections'); const [setup, setSetup] = useStateSetup(() => loadSetup(clientId)); // Persist on every change. Cheap and fine for a prototype. useEffectSetup(() => { saveSetup(clientId, setup); }, [clientId, setup]); // Re-seed when switching clients via the rail/ribbon while setup is open. useEffectSetup(() => { setSetup(loadSetup(clientId)); }, [clientId]); const updateConnection = (id, patch) => { setSetup(prev => ({ ...prev, connections: { ...prev.connections, [id]: { ...prev.connections[id], ...patch } }, })); }; const upsertCpt = (cpt) => { setSetup(prev => { const exists = prev.cpts.find(c => c.id === cpt.id); const cpts = exists ? prev.cpts.map(c => c.id === cpt.id ? cpt : c) : [...prev.cpts, cpt]; return { ...prev, cpts }; }); }; const deleteCpt = (id) => setSetup(prev => ({ ...prev, cpts: prev.cpts.filter(c => c.id !== id) })); const triageSuggestion = (id, triage, amNote) => { setSetup(prev => ({ ...prev, suggestions: prev.suggestions.map(s => s.id === id ? { ...s, triage, amNote: amNote ?? s.amNote, status: triage === 'approve' ? 'approved' : triage === 'decline' ? 'declined' : triage === 'hold' ? 'hold' : 'open', decidedAt: new Date().toLocaleString('en-GB') } : s), })); }; const updateProfile = (path, value) => { setSetup(prev => { const profile = JSON.parse(JSON.stringify(prev.profile)); // path like "brand.name" or "contacts.billing.email" const parts = path.split('.'); let target = profile; for (let i = 0; i < parts.length - 1; i++) { target[parts[i]] = target[parts[i]] || {}; target = target[parts[i]]; } target[parts[parts.length - 1]] = value; return { ...prev, profile }; }); }; const upsertUser = (user) => { setSetup(prev => { const exists = prev.users.find(u => u.id === user.id); const users = exists ? prev.users.map(u => u.id === user.id ? user : u) : [...prev.users, user]; return { ...prev, users }; }); }; const removeUser = (id) => setSetup(prev => ({ ...prev, users: prev.users.filter(u => u.id !== id) })); const decideContract = (id, decision, amNote) => { setSetup(prev => ({ ...prev, contracts: prev.contracts.map(c => c.id === id ? { ...c, status: decision === 'approve' ? 'Active' : decision === 'decline' ? 'Declined' : c.status, pendingApproval: false, decidedAt: new Date().toLocaleString('en-GB'), decidedBy: AM_USER.firstName, notes: amNote ?? c.notes, } : c), })); }; const updateInvoice = (id, patch) => { setSetup(prev => ({ ...prev, invoices: prev.invoices.map(i => i.id === id ? { ...i, ...patch } : i), })); }; const outstandingCount = setup.invoices.filter(i => /Outstanding|Overdue|Pending/i.test(i.status || '')).length; const pendingContracts = setup.contracts.filter(c => c.pendingApproval).length; const tabs = [ { id: 'profile', label: 'Profile' }, { id: 'users', label: 'Users', count: setup.users.length }, { id: 'connections', label: 'Connections', count: Object.values(setup.connections).filter(c => c.connected).length, total: PLATFORMS.length }, { id: 'cpts', label: 'Performance targets', count: setup.cpts.length }, { id: 'contracts', label: 'Contracts', count: pendingContracts > 0 ? pendingContracts : setup.contracts.length, badge: pendingContracts > 0 }, { id: 'invoices', label: 'Invoices', count: outstandingCount > 0 ? outstandingCount : setup.invoices.length, badge: outstandingCount > 0 }, { id: 'suggestions', label: 'AI suggestions', count: setup.suggestions.filter(s => s.status === 'open').length, badge: setup.suggestions.filter(s => s.status === 'open').length > 0 }, ]; return (
{/* Header */}
Client setup · AM only
{client?.name || 'Client'}
All changes save automatically. Internal-only — never visible to the client.
{/* Tab strip */}
{tabs.map(t => ( ))}
Saved · {client?.name}
{/* Tab body */}
{tab === 'profile' && } {tab === 'users' && } {tab === 'connections' && } {tab === 'cpts' && } {tab === 'contracts' && } {tab === 'invoices' && } {tab === 'suggestions' && }
); }; // ============================================================= // Connections tab // ============================================================= const ConnectionsTab = ({ connections, onUpdate }) => { const grouped = PLATFORMS.reduce((acc, p) => { (acc[p.category] = acc[p.category] || []).push(p); return acc; }, {}); const toggleConnect = (p) => { const cur = connections[p.id]; if (cur.connected) { onUpdate(p.id, { connected: false, lastSync: null, primary: false }); } else { onUpdate(p.id, { connected: true, lastSync: 'just now' }); } }; return ( <>
Account connections
Wire up every platform we report against. Status, account ID and last sync show inline.
{Object.entries(grouped).map(([cat, items]) => (
{cat}
{items.map((p, i) => { const c = connections[p.id] || {}; return (
{p.name}
{c.connected ? ( Connected{c.primary ? ' · primary' : ''} ) : ( Not connected )}
{p.keyField}: {c.connected ? ( onUpdate(p.id, { accountId: e.target.value })} placeholder={p.placeholder} style={{ border: '1px solid var(--border)', borderRadius: 4, padding: '2px 6px', fontSize: 11, fontFamily: 'JetBrains Mono', minWidth: 160, background: 'var(--surface)', color: 'var(--text)', }} /> ) : ( )} {c.connected && c.lastSync && ( · last sync {c.lastSync} )}
{/* Actions */}
{c.connected && ( )}
); })}
))} ); }; // ============================================================= // CPTs (Performance Targets) tab // ============================================================= const CptsTab = ({ cpts, onUpsert, onDelete }) => { const [editing, setEditing] = useStateSetup(null); // either a cpt object or 'new' const startNew = () => setEditing({ id: 'cpt-' + Date.now(), name: '', source: 'Cross-channel', metric: '', target: '', direction: 'higher', cadence: 'monthly', owner: 'Joe Marshall', definition: '', baseline: '', }); return ( <>
Performance targets
The KPIs Clicky and the client agreed to track. Edits flow through to the client portal's CPTs tab.
{cpts.length === 0 ? (
No performance targets yet. Click "+ Add target" to define the first one.
) : (
Target
Source
Metric
Target
Cadence
{cpts.map(c => (
{c.name || Untitled}
{c.definition &&
{c.definition}
}
{c.source}
{c.metric || '—'}
{c.target || '—'} {c.direction === 'higher' ? '↑' : c.direction === 'lower' ? '↓' : ''}
{c.cadence}
))}
)} {editing && ( setEditing(null)} onSave={(c) => { onUpsert(c); setEditing(null); }} /> )} ); }; const iconBtn = (color) => ({ width: 26, height: 26, display: 'grid', placeItems: 'center', background: 'transparent', border: '1px solid var(--border)', borderRadius: 4, cursor: 'pointer', color: color || 'var(--text-muted)', }); const CptEditModal = ({ cpt, onClose, onSave }) => { const [c, setC] = useStateSetup(cpt); const update = (patch) => setC(prev => ({ ...prev, ...patch })); return (
e.stopPropagation()} style={{ width: '100%', maxWidth: 560, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', boxShadow: '0 12px 40px rgba(0,0,0,0.18)', display: 'flex', flexDirection: 'column', maxHeight: '90vh', }}>
Performance target
{cpt.name ? 'Edit "' + cpt.name + '"' : 'New target'}
update({ name: e.target.value })} placeholder="e.g. Bookings · 30d" style={input()} />
update({ metric: e.target.value })} placeholder="e.g. Total bookings, ROAS, CPA" style={input()} />
update({ target: e.target.value })} placeholder="e.g. 100" style={input()} /> update({ baseline: e.target.value })} placeholder="last 30d" style={input()} />
update({ owner: e.target.value })} style={input()} />