social ads engine module screens
This commit is contained in:
+10
@@ -47,6 +47,8 @@ import UserDetailsPage from './pages/UserDetailsPage';
|
||||
import LeadVerificationPage from './pages/LeadVerification/LeadVerificationPage';
|
||||
import CustomerEstimateWizard from './modules/estimateWizard/CustomerEstimateWizard';
|
||||
import EstimateWizardAdmin from './modules/estimateWizard/EstimateWizardAdmin';
|
||||
import SocialAdsEngine from './modules/socialAdsEngine/SocialAdsEngine';
|
||||
import PublicAdLanding from './modules/socialAdsEngine/PublicAdLanding';
|
||||
|
||||
// ... (existing imports)
|
||||
const ProtectedRoute = ({ children, allowedRoles }) => {
|
||||
@@ -78,11 +80,19 @@ function App() {
|
||||
<Route path="/login" element={<Login />} />
|
||||
{/* Public DIY Roofing Estimate Wizard — homeowner-facing, no auth, chrome-less */}
|
||||
<Route path="/estimate" element={<CustomerEstimateWizard />} />
|
||||
{/* Public ad lead-capture landing page — homeowner-facing, no auth, chrome-less */}
|
||||
<Route path="/ads/lp" element={<PublicAdLanding />} />
|
||||
<Route path="/estimate-wizard/admin" element={
|
||||
<ProtectedRoute allowedRoles={['OWNER', 'ADMIN']}>
|
||||
<EstimateWizardAdmin />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
{/* Social Media + Advertisement Engine — self-contained admin module (Owner/Admin) */}
|
||||
<Route path="/marketing" element={
|
||||
<ProtectedRoute allowedRoles={['OWNER', 'ADMIN']}>
|
||||
<SocialAdsEngine />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route
|
||||
path="/chat-assistant"
|
||||
element={
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
LayoutDashboard, Map, Calendar, LogOut, User, Home, MessageSquare,
|
||||
ChevronLeft, ChevronRight, Sun, Moon, Trophy, Users, Briefcase,
|
||||
FileText, Menu, X, Calculator, PlusCircle, ClipboardList, Zap, LayoutGrid, Settings, CloudLightning,
|
||||
HardHat, ShieldCheck, Sparkles
|
||||
HardHat, ShieldCheck, Sparkles, Megaphone
|
||||
} from 'lucide-react';
|
||||
|
||||
import PageTransition from './PageTransition';
|
||||
@@ -112,7 +112,8 @@ const Layout = () => {
|
||||
}, []);
|
||||
|
||||
// Determine standard layout vs full screen for Public pages
|
||||
const isPublic = ['/', '/login', '/estimate'].includes(location.pathname);
|
||||
const isPublic = ['/', '/login', '/estimate'].includes(location.pathname)
|
||||
|| location.pathname.startsWith('/ads/');
|
||||
|
||||
if (isPublic) {
|
||||
return (
|
||||
@@ -158,6 +159,7 @@ const Layout = () => {
|
||||
{ to: "/owner/settings", icon: Settings, label: "Org Settings" },
|
||||
...commonItems,
|
||||
{ to: "/estimate-wizard/admin", icon: Settings, label: "Wizard Admin" },
|
||||
{ to: "/marketing", icon: Megaphone, label: "Ad Engine" },
|
||||
];
|
||||
case 'ADMIN':
|
||||
return [
|
||||
@@ -180,6 +182,7 @@ const Layout = () => {
|
||||
{ to: "/admin/settings", icon: Settings, label: "Org Settings" },
|
||||
...commonItems,
|
||||
{ to: "/estimate-wizard/admin", icon: Settings, label: "Wizard Admin" },
|
||||
{ to: "/marketing", icon: Megaphone, label: "Ad Engine" },
|
||||
];
|
||||
case 'CONTRACTOR':
|
||||
case 'SUBCONTRACTOR':
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* PublicAdLanding — the homeowner-facing ad lead-capture page (spec §4 Lead Form
|
||||
* Builder, §9.2 example campaign). Rendered chrome-less (no CRM sidebar) at the
|
||||
* public /ads/lp route, simulating where a Meta/Google/Nextdoor ad click lands.
|
||||
*
|
||||
* On submit it builds a fully-shaped ad_lead (with consent proof + a fresh
|
||||
* speed-to-lead SLA clock) and hands it to the session store; opening
|
||||
* /marketing#inbox afterwards shows it live at the top of the Unified Lead Inbox,
|
||||
* closing the spec's loop (§5). Front-end demo only — no real platform or backend.
|
||||
*/
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
ShieldCheck, CheckCircle2, ArrowRight, Loader2, Megaphone, Lock, Phone,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { BLUEPRINT_BY_ID, BLUEPRINTS } from './data/blueprints.js';
|
||||
import { getPlatform } from './data/platforms.js';
|
||||
import { addPublicLead } from './data/sessionStore.js';
|
||||
|
||||
const CONSENT_TEXT =
|
||||
'By submitting, I agree LynkedUp Roofing may contact me by call, text, and email about my request. Msg/data rates may apply. Reply STOP to opt out.';
|
||||
const PRIVACY_URL = 'https://lynkeduppro.com/privacy';
|
||||
|
||||
const ROLES = [
|
||||
{ id: 'homeowner', label: 'Homeowner' },
|
||||
{ id: 'tenant', label: 'Tenant' },
|
||||
{ id: 'property_manager', label: 'Property manager' },
|
||||
];
|
||||
|
||||
const SERVICE_OPTIONS = [
|
||||
{ id: 'storm', label: 'Storm / hail damage' },
|
||||
{ id: 'emergency', label: 'Active leak (emergency)' },
|
||||
{ id: 'replacement', label: 'Roof replacement' },
|
||||
{ id: 'commercial', label: 'Commercial / property' },
|
||||
];
|
||||
|
||||
const WINDOWS = ['Morning (8–11am)', 'Midday (11am–2pm)', 'Afternoon (2–5pm)', 'Evening (after 5pm)'];
|
||||
|
||||
// Blueprint copy can carry merge tokens (e.g. "{{City}}"). Fill from the ad's ?city=
|
||||
// param, fall back to a natural phrase, and strip any unknown token so a homeowner
|
||||
// never sees a raw {{…}} placeholder.
|
||||
const MERGE_FALLBACKS = { city: 'your area' };
|
||||
function fillTokens(text, vars = {}) {
|
||||
return text.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key) => {
|
||||
const k = key.toLowerCase();
|
||||
return (vars[k] && String(vars[k]).trim()) || MERGE_FALLBACKS[k] || '';
|
||||
});
|
||||
}
|
||||
|
||||
// text-base (16px) on mobile prevents iOS Safari's zoom-on-focus; tightens to 14px ≥sm.
|
||||
const inputCls =
|
||||
'w-full rounded-xl border border-zinc-300 dark:border-white/10 bg-white dark:bg-zinc-900 px-3.5 py-2.5 text-base sm:text-sm text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500';
|
||||
|
||||
export default function PublicAdLanding() {
|
||||
const [params] = useSearchParams();
|
||||
|
||||
const blueprint = BLUEPRINT_BY_ID[params.get('c')] || BLUEPRINTS[0];
|
||||
const platformId = getPlatform(params.get('src')) ? params.get('src') : 'meta';
|
||||
const platform = getPlatform(platformId);
|
||||
|
||||
// Resolve merge tokens in the ad copy (e.g. {{City}}) for display.
|
||||
const mergeVars = { city: params.get('city') || '' };
|
||||
// A creative preview can pass its own headline (?hl) and media image (?img).
|
||||
const headline = params.get('hl') || fillTokens(blueprint.adCopy, mergeVars).split('.')[0];
|
||||
const offerText = fillTokens(blueprint.offer, mergeVars);
|
||||
const heroImg = params.get('img') || '';
|
||||
const [imgErr, setImgErr] = useState(false);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
platform: platformId,
|
||||
serviceType: blueprint.serviceType === 'referral' || blueprint.serviceType === 'maintenance' ? 'storm' : blueprint.serviceType,
|
||||
name: '', phone: '', email: '', address: '',
|
||||
role: 'homeowner', activeLeak: false, window: WINDOWS[0], consent: false,
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(null);
|
||||
|
||||
const set = (patch) => setForm(f => ({ ...f, ...patch }));
|
||||
|
||||
const isEmergencyLike = form.serviceType === 'emergency' || form.serviceType === 'storm';
|
||||
const valid = useMemo(() =>
|
||||
form.name.trim() && form.phone.trim() && form.email.trim() && form.address.trim() && form.consent,
|
||||
[form]);
|
||||
|
||||
const submit = (e) => {
|
||||
e.preventDefault();
|
||||
if (!valid) { toast.error('Please complete the required fields and consent.'); return; }
|
||||
setSubmitting(true);
|
||||
// Simulate the platform → webhook → CRM ingest latency.
|
||||
setTimeout(() => {
|
||||
const lead = addPublicLead(form);
|
||||
setSubmitting(false);
|
||||
setSubmitted(lead);
|
||||
}, 900);
|
||||
};
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="min-h-[100dvh] w-full bg-zinc-50 dark:bg-[#09090b] flex items-center justify-center px-4 py-10">
|
||||
<div className="max-w-md text-center">
|
||||
<CheckCircle2 size={56} className="text-emerald-500 mx-auto mb-4" />
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white">
|
||||
Thanks{submitted.name ? `, ${submitted.name.split(' ')[0]}` : ''}!
|
||||
</h1>
|
||||
<p className="text-zinc-500 dark:text-zinc-400 mt-2">
|
||||
We received your request and our team has been notified. A rep will reach out shortly to confirm your free inspection window.
|
||||
</p>
|
||||
<a href="tel:+19725550100"
|
||||
className="mt-6 w-full sm:w-auto inline-flex items-center justify-center gap-1.5 rounded-xl bg-gradient-to-r from-amber-400 to-orange-500 text-white font-semibold px-5 py-3 sm:py-2.5 shadow-lg shadow-amber-500/25">
|
||||
<Phone size={16} /> Need it sooner? Call (972) 555-0100
|
||||
</a>
|
||||
<p className="text-[11px] text-zinc-400 mt-4 flex items-center justify-center gap-1">
|
||||
<ShieldCheck size={12} /> Your information is kept private and used only to contact you about this request.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-[100dvh] w-full bg-zinc-50 dark:bg-[#09090b]">
|
||||
{/* Sponsored banner — simulates the ad context */}
|
||||
<div className="w-full bg-zinc-900 text-white/80 text-[11px] py-1.5 px-4 flex items-center justify-center gap-1.5">
|
||||
<Megaphone size={12} />
|
||||
<span>Sponsored · {platform?.name} ad</span>
|
||||
</div>
|
||||
|
||||
<div className="max-w-2xl mx-auto px-4 py-6 sm:py-12">
|
||||
{/* Hero */}
|
||||
<div className="text-center mb-6 sm:mb-7">
|
||||
<div className="inline-flex items-center gap-2 mb-3">
|
||||
<div className="p-2 rounded-xl bg-gradient-to-tr from-amber-400 to-orange-600 text-white shadow-lg shadow-amber-500/25">
|
||||
<Megaphone size={20} />
|
||||
</div>
|
||||
<span className="font-bold text-lg text-zinc-900 dark:text-white">LynkedUp<span className="text-amber-500">Pro</span></span>
|
||||
</div>
|
||||
<h1 className="text-balance text-2xl leading-snug sm:text-3xl font-bold text-zinc-900 dark:text-white">{headline}{/[.?!]$/.test(headline) ? '' : '.'}</h1>
|
||||
<p className="text-zinc-500 dark:text-zinc-400 mt-2 text-sm text-balance">{offerText} · Free, no-obligation.</p>
|
||||
</div>
|
||||
|
||||
{/* Creative media (when previewing a creative with an attached image) */}
|
||||
{heroImg && !imgErr && (
|
||||
<img src={heroImg} alt="" onError={() => setImgErr(true)}
|
||||
className="w-full h-44 sm:h-56 object-cover rounded-2xl mb-6 shadow-lg" />
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={submit} className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4 sm:p-6 space-y-4 shadow-xl">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Field label="Full name" required>
|
||||
<input className={inputCls} value={form.name} onChange={e => set({ name: e.target.value })} placeholder="Jane Homeowner" />
|
||||
</Field>
|
||||
<Field label="Phone" required>
|
||||
<input className={inputCls} value={form.phone} onChange={e => set({ phone: e.target.value })} placeholder="(972) 555-0100" />
|
||||
</Field>
|
||||
<Field label="Email" required>
|
||||
<input className={inputCls} type="email" value={form.email} onChange={e => set({ email: e.target.value })} placeholder="you@example.com" />
|
||||
</Field>
|
||||
<Field label="Property address" required>
|
||||
<input className={inputCls} value={form.address} onChange={e => set({ address: e.target.value })} placeholder="123 Main St, Plano, TX" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="What do you need?">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{SERVICE_OPTIONS.map(s => (
|
||||
<button type="button" key={s.id} onClick={() => set({ serviceType: s.id })}
|
||||
className={`flex items-center justify-center text-center leading-tight min-h-[3rem] px-3 py-2 rounded-xl border-2 text-sm sm:text-xs font-semibold transition-all ${form.serviceType === s.id ? 'border-amber-500 bg-amber-500/5 text-amber-700 dark:text-amber-400' : 'border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300'}`}>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Field label="You are the…">
|
||||
<select className={inputCls} value={form.role} onChange={e => set({ role: e.target.value })}>
|
||||
{ROLES.map(r => <option key={r.id} value={r.id}>{r.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Preferred contact window">
|
||||
<select className={inputCls} value={form.window} onChange={e => set({ window: e.target.value })}>
|
||||
{WINDOWS.map(w => <option key={w} value={w}>{w}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{isEmergencyLike && (
|
||||
<label className="flex items-center gap-3 rounded-xl bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 px-3.5 py-3 cursor-pointer">
|
||||
<input type="checkbox" checked={form.activeLeak} onChange={e => set({ activeLeak: e.target.checked })} className="w-5 h-5 shrink-0 accent-red-500" />
|
||||
<span className="text-sm text-zinc-700 dark:text-zinc-200">I have an <b>active leak</b> right now (we'll prioritize this)</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Consent */}
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input type="checkbox" checked={form.consent} onChange={e => set({ consent: e.target.checked })} className="w-5 h-5 mt-0.5 shrink-0 accent-amber-500" />
|
||||
<span className="text-[11px] text-zinc-500 dark:text-zinc-400 leading-relaxed">
|
||||
{CONSENT_TEXT} See our <a href={PRIVACY_URL} className="text-amber-600 underline">privacy policy</a>.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={submitting}
|
||||
className="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-amber-400 to-orange-500 text-white font-semibold px-5 py-3 shadow-lg shadow-amber-500/25 disabled:opacity-60">
|
||||
{submitting ? <><Loader2 size={18} className="animate-spin" /> Sending…</> : <>Request my free inspection <ArrowRight size={16} /></>}
|
||||
</button>
|
||||
|
||||
<p className="text-[10px] text-zinc-400 flex items-center justify-center gap-1">
|
||||
<Lock size={11} /> Your info is used only to contact you about this request.
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-[11px] text-zinc-400 mt-4 flex items-center justify-center gap-1">
|
||||
<ShieldCheck size={12} /> Consent & privacy compliant · TCPA / CAN-SPAM aware
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, required, children }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-xs font-semibold text-zinc-600 dark:text-zinc-300 mb-1.5 block">
|
||||
{label}{required && <span className="text-red-500"> *</span>}
|
||||
</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* SocialAdsContext — self-contained client state for the Social Ads Engine module.
|
||||
*
|
||||
* This is the front-end stand-in for the spec's backend entities (§7). All data
|
||||
* lives in React memory (seeded from data/seed.js); actions mutate local state and
|
||||
* fire sonner toasts so the demo feels live without any backend, OAuth, or webhooks.
|
||||
* Kept fully separate from the global MockStore — this is an isolated new module,
|
||||
* exactly like the Estimate Wizard. A reload resets to the seed.
|
||||
*/
|
||||
import React, { createContext, useContext, useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
accounts as seedAccounts,
|
||||
campaigns as seedCampaigns,
|
||||
creatives as seedCreatives,
|
||||
leadForms as seedLeadForms,
|
||||
mediaAssets as seedMedia,
|
||||
leads as seedLeads,
|
||||
nurtureRules as seedRules,
|
||||
audiences as seedAudiences,
|
||||
conversionEvents as seedConversions,
|
||||
approvals as seedApprovals,
|
||||
analyticsDaily as seedAnalytics,
|
||||
} from './data/seed.js';
|
||||
import { getPublicLeads, subscribeToPublicLeads } from './data/sessionStore.js';
|
||||
|
||||
const SocialAdsContext = createContext(null);
|
||||
|
||||
const nextId = (prefix) => `${prefix}_${Math.floor(performance.now()).toString(36)}`;
|
||||
const nowIso = () => new Date().toISOString();
|
||||
|
||||
// Seed leads + any leads captured on the public landing page this session. The SLA
|
||||
// clock start (slaStartTs) is materialized here from each seed lead's slaSecondsAgo so
|
||||
// the speed-to-lead timers show a realistic mix on load and tick in real time.
|
||||
function initLeads() {
|
||||
const now = Date.now();
|
||||
const seeded = seedLeads.map(l => ({
|
||||
...l,
|
||||
slaStartTs: l.slaStartTs ?? now - (l.slaSecondsAgo || 0) * 1000,
|
||||
}));
|
||||
return [...getPublicLeads(), ...seeded];
|
||||
}
|
||||
|
||||
// Conversion-event mapping for the closed loop (spec §5, §10.1). Module-scoped so the
|
||||
// advanceLead callback stays stable.
|
||||
const STAGE_EVENT = {
|
||||
appointment: { eventName: 'AppointmentScheduled', category: 'Schedule / QualifiedLead' },
|
||||
showed: { eventName: 'AppointmentCompleted', category: 'Show / QualifiedAppointment' },
|
||||
estimate: { eventName: 'EstimateSent', category: 'SubmitApplication' },
|
||||
won: { eventName: 'JobWon', category: 'Purchase / ClosedWon' },
|
||||
};
|
||||
|
||||
export function SocialAdsProvider({ children }) {
|
||||
const [accounts, setAccounts] = useState(seedAccounts);
|
||||
// Backfill default spend guardrails (spec §3/§4) on seed campaigns that predate them.
|
||||
const [campaigns, setCampaigns] = useState(() => seedCampaigns.map(c => ({
|
||||
...c,
|
||||
guardrails: c.guardrails || {
|
||||
weeklyCap: c.budgetDaily * 7, monthlyCap: c.budgetDaily * 30,
|
||||
cplThreshold: 120, alertPct: 80,
|
||||
pauseOnDailyCap: true, pauseOnCplBreach: false, anomalyDetection: true,
|
||||
},
|
||||
})));
|
||||
const [creatives, setCreatives] = useState(seedCreatives);
|
||||
const [mediaAssets, setMediaAssets] = useState(seedMedia);
|
||||
const [leadForms, setLeadForms] = useState(seedLeadForms);
|
||||
const [leads, setLeads] = useState(initLeads);
|
||||
const [nurtureRules, setNurtureRules] = useState(seedRules);
|
||||
const [audiences, setAudiences] = useState(seedAudiences);
|
||||
const [conversionEvents, setConversionEvents] = useState(seedConversions);
|
||||
const [approvals, setApprovals] = useState(seedApprovals);
|
||||
const [analyticsDaily] = useState(seedAnalytics);
|
||||
|
||||
// Live-ingest public leads submitted on /ads/lp (same tab or another, via the
|
||||
// session store's broadcast) — they appear at the top of the inbox immediately,
|
||||
// no CRM reload. Dedup by id guards against re-mount + broadcast double-adds.
|
||||
useEffect(() => subscribeToPublicLeads((lead) => {
|
||||
setLeads(prev => (prev.some(l => l.id === lead.id) ? prev : [lead, ...prev]));
|
||||
if (lead.triage?.urgency === 'high') {
|
||||
toast.error(`New urgent lead: ${lead.name} — speed-to-lead SLA running`);
|
||||
} else {
|
||||
toast.success(`New ad lead: ${lead.name}`);
|
||||
}
|
||||
}), []);
|
||||
|
||||
// ── Connected Accounts ────────────────────────────────────────────────────
|
||||
const toggleAccount = useCallback((id) => {
|
||||
setAccounts(prev => prev.map(a => {
|
||||
if (a.id !== id) return a;
|
||||
const connecting = a.status !== 'connected';
|
||||
toast[connecting ? 'success' : 'message'](
|
||||
connecting ? `Connected ${a.name}` : `Disconnected ${a.name}`,
|
||||
);
|
||||
return connecting
|
||||
? { ...a, status: 'connected', tokenStatus: 'healthy', webhookStatus: 'verified', lastSyncAt: nowIso() }
|
||||
: { ...a, status: 'disconnected', tokenStatus: 'none', webhookStatus: 'not_configured' };
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const testWebhook = useCallback((id) => {
|
||||
const acc = accounts.find(a => a.id === id);
|
||||
if (!acc) return;
|
||||
if (acc.status !== 'connected') { toast.error('Connect the account before testing its webhook.'); return; }
|
||||
toast.promise(new Promise(res => setTimeout(res, 900)), {
|
||||
loading: `Sending test lead to ${acc.short || acc.name}…`,
|
||||
success: 'Test lead received and mapped to CRM fields ✓',
|
||||
error: 'Webhook test failed',
|
||||
});
|
||||
}, [accounts]);
|
||||
|
||||
// ── Campaigns ─────────────────────────────────────────────────────────────
|
||||
const createCampaign = useCallback((draft) => {
|
||||
const camp = { id: nextId('camp'), status: 'draft', approvalStatus: 'pending', ...draft };
|
||||
setCampaigns(prev => [camp, ...prev]);
|
||||
// Route the draft into the Compliance approval queue — it cannot publish until a
|
||||
// reviewer approves this record, which flips campaign.approvalStatus (spec §6/§8).
|
||||
setApprovals(prev => [{
|
||||
id: nextId('apr'), type: 'campaign', refId: camp.id,
|
||||
title: `${camp.name} — publish approval`, status: 'pending',
|
||||
flags: [], submittedAt: nowIso(), reviewer: null,
|
||||
}, ...prev]);
|
||||
toast.success(`Campaign draft "${camp.name}" created — sent to Compliance for approval`);
|
||||
return camp;
|
||||
}, []);
|
||||
|
||||
const setCampaignStatus = useCallback((id, status) => {
|
||||
setCampaigns(prev => prev.map(c => c.id === id ? { ...c, status } : c));
|
||||
toast.message(`Campaign ${status}`);
|
||||
}, []);
|
||||
|
||||
const publishCampaign = useCallback((id) => {
|
||||
const camp = campaigns.find(c => c.id === id);
|
||||
if (!camp) return;
|
||||
if (camp.approvalStatus !== 'approved') {
|
||||
toast.error('Cannot publish — campaign and creatives must be approved first.');
|
||||
return;
|
||||
}
|
||||
setCampaigns(prev => prev.map(c => c.id === id ? { ...c, status: 'active' } : c));
|
||||
toast.success(`"${camp.name}" published`);
|
||||
}, [campaigns]);
|
||||
|
||||
// Manual-publish fallback (spec §5/§11): platforms without a full-publish API are set
|
||||
// up by hand from a generated checklist, then marked live here. Same approval gate.
|
||||
const completeManualSetup = useCallback((id) => {
|
||||
const camp = campaigns.find(c => c.id === id);
|
||||
if (!camp) return;
|
||||
if (camp.approvalStatus !== 'approved') {
|
||||
toast.error('Approve the campaign in Compliance before marking setup complete.');
|
||||
return;
|
||||
}
|
||||
setCampaigns(prev => prev.map(c => c.id === id ? { ...c, status: 'active', manualSetup: true } : c));
|
||||
toast.success(`"${camp.name}" marked live — manual setup complete`);
|
||||
}, [campaigns]);
|
||||
|
||||
// ── Creatives ─────────────────────────────────────────────────────────────
|
||||
const addCreative = useCallback((creative) => {
|
||||
const c = { id: nextId('cre'), approvalStatus: 'pending', versions: [], ...creative };
|
||||
setCreatives(prev => [c, ...prev]);
|
||||
return c;
|
||||
}, []);
|
||||
|
||||
// Snapshot the current copy as a version before applying an edit (spec §7 copy versions).
|
||||
const snapshotVersion = (c) => ({
|
||||
v: (c.versions?.length || 0) + 1,
|
||||
headline: c.headline, body: c.body, description: c.description || '',
|
||||
mediaAssetId: c.mediaAssetId || null,
|
||||
policyStatus: c.policyStatus, policyFlags: c.policyFlags || [],
|
||||
savedAt: nowIso(),
|
||||
});
|
||||
|
||||
const setCreativeApproval = useCallback((id, approvalStatus) => {
|
||||
setCreatives(prev => prev.map(c => c.id === id ? { ...c, approvalStatus } : c));
|
||||
toast[approvalStatus === 'approved' ? 'success' : 'message'](`Creative ${approvalStatus}`);
|
||||
}, []);
|
||||
|
||||
// Manual create/edit (spec §8/B2). Editing copy re-runs the guardrail and resets the
|
||||
// creative to pending approval, since the reviewed text has changed.
|
||||
const updateCreative = useCallback((id, patch) => {
|
||||
setCreatives(prev => prev.map(c => c.id === id
|
||||
? { ...c, ...patch, versions: [...(c.versions || []), snapshotVersion(c)], approvalStatus: 'pending' }
|
||||
: c));
|
||||
toast.success('Creative updated — previous version saved to history');
|
||||
}, []);
|
||||
|
||||
// Revert to a prior version: snapshot the current copy first, then restore the chosen
|
||||
// version. Re-enters approval since the live copy changed (spec §7/§8).
|
||||
const revertCreative = useCallback((id, v) => {
|
||||
setCreatives(prev => prev.map(c => {
|
||||
if (c.id !== id) return c;
|
||||
const target = (c.versions || []).find(x => x.v === v);
|
||||
if (!target) return c;
|
||||
return {
|
||||
...c,
|
||||
headline: target.headline, body: target.body, description: target.description,
|
||||
mediaAssetId: target.mediaAssetId, policyStatus: target.policyStatus, policyFlags: target.policyFlags,
|
||||
versions: [...(c.versions || []), snapshotVersion(c)],
|
||||
approvalStatus: 'pending',
|
||||
};
|
||||
}));
|
||||
toast.success(`Reverted to v${v} — sent back for approval`);
|
||||
}, []);
|
||||
|
||||
// Media library (spec §7 media_asset_id). Uploads add an in-memory object-URL preview.
|
||||
const addMediaAsset = useCallback((asset) => {
|
||||
const a = { id: nextId('med'), type: 'image', ...asset };
|
||||
setMediaAssets(prev => [a, ...prev]);
|
||||
toast.success(`Added “${a.name}” to the media library`);
|
||||
return a;
|
||||
}, []);
|
||||
|
||||
// ── Lead Form Mapper (editable builder, spec §7 lead_forms + field_mappings) ──
|
||||
const updateLeadForm = useCallback((id, patch) => {
|
||||
setLeadForms(prev => prev.map(f => f.id === id ? { ...f, ...patch } : f));
|
||||
}, []);
|
||||
|
||||
const updateFormField = useCallback((formId, source, patch) => {
|
||||
setLeadForms(prev => prev.map(f => f.id === formId
|
||||
? { ...f, fields: f.fields.map(fl => fl.source === source ? { ...fl, ...patch } : fl) }
|
||||
: f));
|
||||
}, []);
|
||||
|
||||
const addFormField = useCallback((formId, field) => {
|
||||
const newField = field
|
||||
? { transform: 'none', required: false, validation: '', ...field }
|
||||
: { source: nextId('field'), target: '', required: false, transform: 'none', validation: '' };
|
||||
setLeadForms(prev => prev.map(f => f.id === formId
|
||||
? { ...f, fields: [...f.fields, newField] }
|
||||
: f));
|
||||
}, []);
|
||||
|
||||
const removeFormField = useCallback((formId, source) => {
|
||||
setLeadForms(prev => prev.map(f => f.id === formId
|
||||
? { ...f, fields: f.fields.filter(fl => fl.source !== source) }
|
||||
: f));
|
||||
}, []);
|
||||
|
||||
const toggleFormStatus = useCallback((id) => {
|
||||
setLeadForms(prev => prev.map(f => {
|
||||
if (f.id !== id) return f;
|
||||
const status = f.status === 'live' ? 'draft' : 'live';
|
||||
toast[status === 'live' ? 'success' : 'message'](`Form set to ${status}`);
|
||||
return { ...f, status };
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const addLeadForm = useCallback((form = {}) => {
|
||||
const f = {
|
||||
id: nextId('form'), campaignId: null, platform: form.platform || 'meta',
|
||||
platformFormId: `manual_${nextId('lf')}`, title: form.title || 'New lead form', status: 'draft',
|
||||
consentText: 'By submitting, I agree LynkedUp Roofing may contact me by call, text, and email about my request. Msg/data rates may apply. Reply STOP to opt out.',
|
||||
privacyPolicyUrl: 'https://lynkeduppro.com/privacy',
|
||||
thankYou: 'We received your request. A rep will reach out shortly to confirm your appointment window.',
|
||||
fields: [
|
||||
{ source: 'full_name', target: 'contact.name', required: true, transform: 'titlecase', validation: '' },
|
||||
{ source: 'phone_number', target: 'contact.phone', required: true, transform: 'e164', validation: 'phone' },
|
||||
{ source: 'email', target: 'contact.email', required: true, transform: 'lowercase', validation: 'email' },
|
||||
],
|
||||
};
|
||||
setLeadForms(prev => [f, ...prev]);
|
||||
toast.success(`Created “${f.title}” (draft)`);
|
||||
return f;
|
||||
}, []);
|
||||
|
||||
// ── Leads (Unified Inbox) ─────────────────────────────────────────────────
|
||||
const assignLead = useCallback((id, repId) => {
|
||||
// Assigning is first permitted contact → record speed-to-lead (spec §10).
|
||||
const speed = 120 + Math.floor(performance.now() % 180); // demo: ~2–5 min
|
||||
setLeads(prev => prev.map(l => l.id === id
|
||||
? { ...l, assignedTo: repId, status: 'assigned', firstContactSec: l.firstContactSec ?? speed }
|
||||
: l));
|
||||
toast.success('Lead assigned — nurture playbook started');
|
||||
}, []);
|
||||
|
||||
// Advance a lead one step through the lifecycle, emitting a deduped conversion
|
||||
// event back to the source platform. This is what makes the dashboard KPIs live.
|
||||
const advanceLead = useCallback((id, toStage, { value = 0, premium = false } = {}) => {
|
||||
const lead = leads.find(l => l.id === id);
|
||||
if (!lead) return;
|
||||
const map = STAGE_EVENT[toStage];
|
||||
setLeads(prev => prev.map(l => l.id === id
|
||||
? { ...l, stage: toStage, dealValue: value || l.dealValue, premium: premium || l.premium }
|
||||
: l));
|
||||
if (map) {
|
||||
const ev = {
|
||||
id: nextId('ce'),
|
||||
platform: lead.platform,
|
||||
eventName: map.eventName,
|
||||
entityType: toStage === 'won' || toStage === 'estimate' ? 'estimate' : 'opportunity',
|
||||
entityId: lead.id,
|
||||
value: toStage === 'won' ? value : 0,
|
||||
currency: 'USD',
|
||||
dedupKey: `${lead.platform}:${lead.externalLeadId}:${toStage}`,
|
||||
syncStatus: 'synced',
|
||||
syncedAt: nowIso(),
|
||||
};
|
||||
setConversionEvents(prev => [ev, ...prev]);
|
||||
}
|
||||
const labels = { appointment: 'Appointment scheduled', showed: 'Appointment completed (showed)', estimate: 'Estimate sent', won: `Job won — $${value.toLocaleString()}`, lost: 'Marked lost' };
|
||||
toast.success(`${labels[toStage] || toStage} · synced to ${lead.platform}`);
|
||||
}, [leads]);
|
||||
|
||||
const mergeLead = useCallback((id) => {
|
||||
setLeads(prev => prev.map(l => l.id === id ? { ...l, status: 'merged' } : l));
|
||||
toast.message('Duplicate merged into primary contact');
|
||||
}, []);
|
||||
|
||||
const setLeadStatus = useCallback((id, status) => {
|
||||
setLeads(prev => prev.map(l => l.id === id ? { ...l, status } : l));
|
||||
}, []);
|
||||
|
||||
// ── Ad-to-Nurture rules ───────────────────────────────────────────────────
|
||||
const toggleRule = useCallback((id) => {
|
||||
setNurtureRules(prev => prev.map(r => r.id === id ? { ...r, enabled: !r.enabled } : r));
|
||||
}, []);
|
||||
|
||||
// Editable routing rules (spec §8). Merges nested when/then patches.
|
||||
const updateRule = useCallback((id, patch) => {
|
||||
setNurtureRules(prev => prev.map(r => r.id === id ? {
|
||||
...r, ...patch,
|
||||
when: patch.when ? { ...r.when, ...patch.when } : r.when,
|
||||
then: patch.then ? { ...r.then, ...patch.then } : r.then,
|
||||
} : r));
|
||||
}, []);
|
||||
|
||||
const addRule = useCallback(() => {
|
||||
setNurtureRules(prev => {
|
||||
const maxP = Math.max(0, ...prev.filter(r => r.priority !== 99).map(r => r.priority));
|
||||
const rule = {
|
||||
id: nextId('rule'), priority: maxP + 1, enabled: true,
|
||||
when: { platform: 'any', serviceType: 'storm', campaignId: 'any', formId: 'any' },
|
||||
then: { playbook: 'New Nurture', rep: 'Auto-assign', speedToLead: 'Call < 5 min', education: 'None', scheduling: 'Send scheduling link' },
|
||||
};
|
||||
return [...prev, rule];
|
||||
});
|
||||
toast.success('Routing rule added');
|
||||
}, []);
|
||||
|
||||
const removeRule = useCallback((id) => {
|
||||
setNurtureRules(prev => prev.filter(r => r.id !== id));
|
||||
toast.message('Routing rule removed');
|
||||
}, []);
|
||||
|
||||
// Add a rule from an AI nurture suggestion (spec §6 — admin accepts the recommendation).
|
||||
const addRuleFromSuggestion = useCallback((when, then) => {
|
||||
setNurtureRules(prev => {
|
||||
const maxP = Math.max(0, ...prev.filter(r => r.priority !== 99).map(r => r.priority));
|
||||
const rule = {
|
||||
id: nextId('rule'), priority: maxP + 1, enabled: true,
|
||||
when: { platform: 'any', serviceType: 'any', campaignId: 'any', formId: 'any', ...when },
|
||||
then: { ...then },
|
||||
};
|
||||
return [...prev, rule];
|
||||
});
|
||||
toast.success('AI-suggested rule added');
|
||||
}, []);
|
||||
|
||||
// Reorder priority by swapping with the adjacent non-default rule.
|
||||
const moveRule = useCallback((id, dir) => {
|
||||
setNurtureRules(prev => {
|
||||
const movable = [...prev].filter(r => r.priority !== 99).sort((a, b) => a.priority - b.priority);
|
||||
const i = movable.findIndex(r => r.id === id);
|
||||
const j = i + dir;
|
||||
if (i < 0 || j < 0 || j >= movable.length) return prev;
|
||||
const a = movable[i], b = movable[j];
|
||||
return prev.map(r => r.id === a.id ? { ...r, priority: b.priority }
|
||||
: r.id === b.id ? { ...r, priority: a.priority } : r);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── Audiences ─────────────────────────────────────────────────────────────
|
||||
const refreshAudience = useCallback((id) => {
|
||||
toast.promise(new Promise(res => setTimeout(res, 700)), {
|
||||
loading: 'Recomputing audience with consent + suppression filters…',
|
||||
success: 'Audience count refreshed ✓',
|
||||
error: 'Refresh failed',
|
||||
});
|
||||
setAudiences(prev => prev.map(a => a.id === id ? { ...a, lastCount: nowIso().slice(0, 10) } : a));
|
||||
}, []);
|
||||
|
||||
// ── Compliance / approvals ────────────────────────────────────────────────
|
||||
// Resolving a Compliance item also flips the underlying entity's approval state, so
|
||||
// an approved 'campaign' record unblocks publish and a 'creative' record stays in
|
||||
// sync with Creative Studio (spec §6/§8 — an unapproved campaign cannot publish).
|
||||
const resolveApproval = useCallback((id, decision) => {
|
||||
const appr = approvals.find(a => a.id === id);
|
||||
setApprovals(prev => prev.map(a => a.id === id ? { ...a, status: decision, reviewer: 'fe@lynkeduppro.com' } : a));
|
||||
if (appr?.refId && appr.type === 'campaign') {
|
||||
setCampaigns(prev => prev.map(c => c.id === appr.refId ? { ...c, approvalStatus: decision } : c));
|
||||
}
|
||||
if (appr?.refId && appr.type === 'creative') {
|
||||
setCreatives(prev => prev.map(c => c.id === appr.refId ? { ...c, approvalStatus: decision } : c));
|
||||
}
|
||||
toast[decision === 'approved' ? 'success' : 'message'](
|
||||
appr?.type === 'campaign'
|
||||
? (decision === 'approved' ? 'Campaign approved — ready to publish' : `Campaign approval ${decision}`)
|
||||
: `Item ${decision}`,
|
||||
);
|
||||
}, [approvals]);
|
||||
|
||||
// ── Conversion events ─────────────────────────────────────────────────────
|
||||
const retryConversion = useCallback((id) => {
|
||||
setConversionEvents(prev => prev.map(e => e.id === id ? { ...e, syncStatus: 'synced', syncedAt: nowIso() } : e));
|
||||
toast.success('Conversion event re-synced ✓');
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
accounts, campaigns, creatives, mediaAssets, leadForms, leads, nurtureRules,
|
||||
audiences, conversionEvents, approvals, analyticsDaily,
|
||||
toggleAccount, testWebhook,
|
||||
createCampaign, setCampaignStatus, publishCampaign, completeManualSetup,
|
||||
addCreative, setCreativeApproval, updateCreative, revertCreative, addMediaAsset,
|
||||
updateLeadForm, updateFormField, addFormField, removeFormField, toggleFormStatus, addLeadForm,
|
||||
assignLead, mergeLead, setLeadStatus, advanceLead,
|
||||
toggleRule, updateRule, addRule, removeRule, moveRule, addRuleFromSuggestion,
|
||||
refreshAudience, resolveApproval, retryConversion,
|
||||
}), [
|
||||
accounts, campaigns, creatives, mediaAssets, leadForms, leads, nurtureRules,
|
||||
audiences, conversionEvents, approvals, analyticsDaily,
|
||||
toggleAccount, testWebhook, createCampaign, setCampaignStatus, publishCampaign, completeManualSetup,
|
||||
addCreative, setCreativeApproval, updateCreative, revertCreative, addMediaAsset,
|
||||
updateLeadForm, updateFormField, addFormField, removeFormField, toggleFormStatus, addLeadForm,
|
||||
assignLead, mergeLead, setLeadStatus, advanceLead,
|
||||
toggleRule, updateRule, addRule, removeRule, moveRule, addRuleFromSuggestion,
|
||||
refreshAudience, resolveApproval, retryConversion,
|
||||
]);
|
||||
|
||||
return <SocialAdsContext.Provider value={value}>{children}</SocialAdsContext.Provider>;
|
||||
}
|
||||
|
||||
export function useSocialAds() {
|
||||
const ctx = useContext(SocialAdsContext);
|
||||
if (!ctx) throw new Error('useSocialAds must be used within SocialAdsProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* SocialAdsEngine — entry shell for the Social Media + Advertisement Engine module.
|
||||
*
|
||||
* Renders inside the CRM Layout (sidebar + chrome) at /marketing. A single horizontal
|
||||
* tab bar switches between the spec's 10 admin screens (§8); each screen is a
|
||||
* self-contained component reading from SocialAdsProvider. URL hash keeps the active
|
||||
* tab deep-linkable and refresh-stable without adding ten router entries.
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
LayoutDashboard, Plug, Megaphone, Wand2, ClipboardList,
|
||||
GitBranch, Inbox, Users, BarChart3, ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { SocialAdsProvider } from './SocialAdsContext.jsx';
|
||||
|
||||
import MarketingHubHome from './screens/MarketingHubHome.jsx';
|
||||
import ConnectedAccounts from './screens/ConnectedAccounts.jsx';
|
||||
import CampaignBuilder from './screens/CampaignBuilder.jsx';
|
||||
import CreativeStudio from './screens/CreativeStudio.jsx';
|
||||
import LeadFormMapper from './screens/LeadFormMapper.jsx';
|
||||
import AdToNurtureRules from './screens/AdToNurtureRules.jsx';
|
||||
import UnifiedLeadInbox from './screens/UnifiedLeadInbox.jsx';
|
||||
import AudienceSegments from './screens/AudienceSegments.jsx';
|
||||
import AttributionDashboard from './screens/AttributionDashboard.jsx';
|
||||
import ComplianceCenter from './screens/ComplianceCenter.jsx';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'home', label: 'Marketing Hub', icon: LayoutDashboard, Component: MarketingHubHome },
|
||||
{ id: 'accounts', label: 'Connected Accounts', icon: Plug, Component: ConnectedAccounts },
|
||||
{ id: 'campaigns', label: 'Campaign Builder', icon: Megaphone, Component: CampaignBuilder },
|
||||
{ id: 'creative', label: 'Creative Studio', icon: Wand2, Component: CreativeStudio },
|
||||
{ id: 'forms', label: 'Lead Form Mapper', icon: ClipboardList, Component: LeadFormMapper },
|
||||
{ id: 'rules', label: 'Ad-to-Nurture', icon: GitBranch, Component: AdToNurtureRules },
|
||||
{ id: 'inbox', label: 'Lead Inbox', icon: Inbox, Component: UnifiedLeadInbox },
|
||||
{ id: 'audiences', label: 'Audiences', icon: Users, Component: AudienceSegments },
|
||||
{ id: 'attribution', label: 'Attribution', icon: BarChart3, Component: AttributionDashboard },
|
||||
{ id: 'compliance', label: 'Compliance', icon: ShieldCheck, Component: ComplianceCenter },
|
||||
];
|
||||
|
||||
export default function SocialAdsEngine() {
|
||||
const [active, setActive] = useState(() => {
|
||||
const hash = window.location.hash.replace('#', '');
|
||||
return TABS.some(t => t.id === hash) ? hash : 'home';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
window.history.replaceState(null, '', `#${active}`);
|
||||
}, [active]);
|
||||
|
||||
const ActiveComponent = TABS.find(t => t.id === active)?.Component || MarketingHubHome;
|
||||
|
||||
return (
|
||||
<SocialAdsProvider>
|
||||
<div className="min-h-full bg-zinc-50 dark:bg-[#09090b]">
|
||||
{/* Header */}
|
||||
<div className="border-b border-zinc-200 dark:border-white/5 bg-white/60 dark:bg-zinc-900/40 backdrop-blur-md sticky top-0 z-30">
|
||||
<div className="max-w-content mx-auto px-4 sm:px-6 pt-5 pb-0">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-xl bg-gradient-to-tr from-amber-400 to-orange-600 text-white shadow-lg shadow-amber-500/25">
|
||||
<Megaphone size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-zinc-900 dark:text-white">
|
||||
Social Ads <span className="text-amber-500">Engine</span>
|
||||
</h1>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Closed-loop acquisition — campaign → lead → nurture → job → attribution
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 overflow-x-auto custom-scrollbar -mb-px">
|
||||
{TABS.map(t => {
|
||||
const Icon = t.icon;
|
||||
const isActive = t.id === active;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setActive(t.id)}
|
||||
className={`relative flex items-center gap-1.5 px-3 py-2.5 text-xs sm:text-sm font-semibold whitespace-nowrap transition-colors ${isActive
|
||||
? 'text-amber-600 dark:text-amber-400'
|
||||
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white'}`}
|
||||
>
|
||||
<Icon size={15} />
|
||||
{t.label}
|
||||
{isActive && (
|
||||
<motion.div layoutId="adEngineTab" className="absolute left-0 right-0 -bottom-px h-0.5 bg-amber-500 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active screen — `isolate` creates a stacking context so the cards'
|
||||
internal z-indexes stay contained below the sticky header above. */}
|
||||
<div className="max-w-content mx-auto px-4 sm:px-6 py-6 isolate">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={active}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<ActiveComponent onNavigate={setActive} />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</SocialAdsProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* bannedClaims.js — compliance guardrail for AI/admin ad copy (spec §6, §11).
|
||||
*
|
||||
* The Compliance reviewer "flags missing opt-out, risky guarantee, undisclosed
|
||||
* incentive, deceptive scarcity, or unsupported insurance claim" and blocks
|
||||
* publish/send until resolved. This is a lightweight client-side stand-in: a set
|
||||
* of regex rules that scan copy and return policy flags. Demo only — not legal advice.
|
||||
*/
|
||||
|
||||
const RULES = [
|
||||
{ id: 'guarantee', test: /\b(guarantee[d]?|100%|risk[-\s]?free|always|never fail)\b/i, severity: 'block', message: 'Unsupported guarantee / absolute claim.' },
|
||||
{ id: 'scarcity', test: /\b(limited (slots|time|spots)|act now|last chance|only \d+ (left|spots)|hurry)\b/i, severity: 'block', message: 'Deceptive scarcity / pressure language.' },
|
||||
{ id: 'price_claim', test: /\b(prices? (jump|rising|going up)|\d+% (off|discount)|cheapest|lowest price)\b/i, severity: 'block', message: 'Unverified discount / price claim.' },
|
||||
{ id: 'insurance', test: /\b(insurance will (pay|cover)|free roof|we handle your claim|guaranteed approval)\b/i, severity: 'block', message: 'Unsupported insurance claim.' },
|
||||
{ id: 'fear', test: /\b(disaster|catastrophe|your family.s safety|before it.s too late|danger)\b/i, severity: 'warn', message: 'Fear/panic language — soften per platform tone rules.' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Scan ad copy. Returns { status: 'clear'|'review'|'blocked', flags: [{id,severity,message}] }.
|
||||
*/
|
||||
export function scanCopy(...parts) {
|
||||
const text = parts.filter(Boolean).join(' ');
|
||||
const flags = RULES.filter(r => r.test.test(text)).map(({ id, severity, message }) => ({ id, severity, message }));
|
||||
const status = flags.some(f => f.severity === 'block')
|
||||
? 'blocked'
|
||||
: flags.length > 0 ? 'review' : 'clear';
|
||||
return { status, flags };
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* briefGenerator.js — mock "AI Campaign Brief Generator" (spec §6).
|
||||
*
|
||||
* The admin types a natural-language brief ("storm inspection campaign for hail-hit
|
||||
* ZIPs in Plano") and the assistant drafts objective, audience notes, CTA, ad copy,
|
||||
* recommended form fields, and the nurture path. Everything is composed from the
|
||||
* tenant-approved blueprint library and run through the bannedClaims guardrail (via
|
||||
* generateVariants), and nothing is created until the admin approves it (spec §6).
|
||||
*
|
||||
* Front-end demo: deterministic keyword matching, no real LLM call.
|
||||
*/
|
||||
import { BLUEPRINT_BY_ID } from '../data/blueprints.js';
|
||||
import { generateVariants } from './creativeGenerator.js';
|
||||
|
||||
export const OBJECTIVE_LABEL = {
|
||||
lead_generation: 'Lead generation',
|
||||
retargeting: 'Retargeting',
|
||||
engagement: 'Engagement',
|
||||
reactivation: 'Reactivation',
|
||||
};
|
||||
|
||||
// Keyword → blueprint intent. First match wins, so order encodes priority.
|
||||
const INTENT_RULES = [
|
||||
{ id: 'bp_emergency', re: /\b(emergency|leak|leaking|tarp|urgent|flood)\b/i },
|
||||
{ id: 'bp_commercial', re: /\b(commercial|property manager|facilit|building|b2b|portfolio|hoa)\b/i },
|
||||
{ id: 'bp_storm', re: /\b(storm|hail|wind|tornado|hurricane|damage)\b/i },
|
||||
{ id: 'bp_unsold', re: /\b(unsold|retarget|re-?target|follow.?up|warm lead|stalled|estimate)\b/i },
|
||||
{ id: 'bp_review', re: /\b(review|referral|testimonial|word of mouth)\b/i },
|
||||
{ id: 'bp_seasonal', re: /\b(seasonal|maintenance|reactivat|annual|check.?up|past customer)\b/i },
|
||||
{ id: 'bp_retail', re: /\b(replace|replacement|new roof|aging|old roof|upgrade)\b/i },
|
||||
];
|
||||
|
||||
const CTA = {
|
||||
storm: 'Request a free inspection',
|
||||
replacement: 'Get a free quote',
|
||||
emergency: 'Get urgent help now',
|
||||
commercial: 'Request a site review',
|
||||
referral: 'Share your experience',
|
||||
maintenance: 'Book a seasonal check-up',
|
||||
};
|
||||
|
||||
const SERVICE_LABEL = {
|
||||
storm: 'recent storm or hail activity',
|
||||
replacement: 'an aging or failing roof',
|
||||
emergency: 'an active roof leak',
|
||||
commercial: 'commercial roofing needs',
|
||||
referral: 'a recently completed job',
|
||||
maintenance: 'overdue roof maintenance',
|
||||
};
|
||||
|
||||
function detectBlueprint(text) {
|
||||
for (const rule of INTENT_RULES) if (rule.re.test(text)) return BLUEPRINT_BY_ID[rule.id];
|
||||
return BLUEPRINT_BY_ID.bp_storm;
|
||||
}
|
||||
|
||||
// Pull a 5-digit ZIP or a capitalized place name ("... in Plano ...") from the prompt.
|
||||
function detectGeo(text) {
|
||||
const zip = text.match(/\b\d{5}\b/);
|
||||
if (zip) return zip[0];
|
||||
const city = text.match(/\bin ([A-Z][a-zA-Z.-]+(?: [A-Z][a-zA-Z.-]+)?)/);
|
||||
return city ? city[1].trim() : '';
|
||||
}
|
||||
|
||||
/** Draft a full campaign brief from a free-text prompt. */
|
||||
export function generateBrief(prompt) {
|
||||
const text = (prompt || '').trim();
|
||||
const blueprint = detectBlueprint(text);
|
||||
const geo = detectGeo(text);
|
||||
const serviceType = blueprint.serviceType;
|
||||
const platform = blueprint.bestChannels[0];
|
||||
const city = geo || 'your area';
|
||||
|
||||
const variants = generateVariants({ platformId: platform, serviceType, city, count: 3 });
|
||||
// Prefer a policy-clear variant; fall back to the first draft.
|
||||
const adCopy = variants.find(v => v.policy.status === 'clear') || variants[0];
|
||||
|
||||
const audienceNotes =
|
||||
`Households with ${SERVICE_LABEL[serviceType] || 'roofing needs'}${geo ? ` in/around ${geo}` : ''}. ` +
|
||||
`Exclude existing customers and anyone on the suppression list; only consented contacts sync.`;
|
||||
|
||||
return {
|
||||
prompt: text,
|
||||
blueprintId: blueprint.id,
|
||||
blueprintName: blueprint.name,
|
||||
serviceType,
|
||||
platform,
|
||||
geo,
|
||||
objective: blueprint.objective,
|
||||
objectiveLabel: OBJECTIVE_LABEL[blueprint.objective] || blueprint.objective,
|
||||
offer: blueprint.offer,
|
||||
cta: CTA[serviceType] || 'Request a free inspection',
|
||||
audienceNotes,
|
||||
adCopy, // { headline, body, description, charCounts, policy, ... }
|
||||
formFields: blueprint.formQuestions,
|
||||
nurturePath: blueprint.nurture,
|
||||
};
|
||||
}
|
||||
|
||||
export const BRIEF_EXAMPLES = [
|
||||
'Storm inspection campaign for hail-hit ZIPs in Plano',
|
||||
'Same-day emergency leak response in Dallas',
|
||||
'Commercial roof bids for property managers',
|
||||
'Retarget unsold estimates with financing',
|
||||
];
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* creativeGenerator.js — mock "AI Creative Assistant" (spec §4, §6).
|
||||
*
|
||||
* The real engine drafts headlines/body/descriptions from approved tenant knowledge
|
||||
* with platform tone + character limits. For the front-end demo this returns
|
||||
* deterministic, platform-aware variants from a template bank — every output is then
|
||||
* run through bannedClaims.scanCopy() so the compliance guardrail is exercised.
|
||||
*
|
||||
* AI cannot invent offers or guarantee outcomes (spec §2 non-negotiables); all copy
|
||||
* here is generic and approval-gated downstream.
|
||||
*/
|
||||
import { getPlatform } from '../data/platforms.js';
|
||||
import { scanCopy } from './bannedClaims.js';
|
||||
|
||||
// Platform character ceilings (approximate, demo values).
|
||||
export const CHAR_LIMITS = {
|
||||
meta: { headline: 40, body: 125, description: 30 },
|
||||
google: { headline: 30, body: 90, description: 90 },
|
||||
linkedin: { headline: 70, body: 150, description: 70 },
|
||||
reddit: { headline: 60, body: 140, description: 0 },
|
||||
whatsapp: { headline: 0, body: 1024, description: 0 },
|
||||
nextdoor: { headline: 50, body: 130, description: 0 },
|
||||
};
|
||||
|
||||
// Tone-shaped template bank per service type. {{City}} is a tenant merge field.
|
||||
const BANK = {
|
||||
storm: [
|
||||
{ headline: 'Hail in {{City}}? Document the damage.', body: 'A free roof inspection documents visible hail damage, ventilation concerns, and your repair or replacement options. Request an inspection window.', description: 'Free inspection • Local crew' },
|
||||
{ headline: 'Storm rolled through {{City}}', body: 'Our local team checks for hail bruising, lifted shingles, and ventilation issues, then walks you through the options. Same-week appointments.', description: 'Same-week appointments' },
|
||||
{ headline: 'Free post-storm roof check', body: 'Not sure if the storm hurt your roof? We will document what we find and explain your choices clearly. No obligation.', description: 'Clear, honest options' },
|
||||
{ headline: 'Hail check for {{City}} homes', body: 'A quick, no-obligation inspection tells you whether the recent storm caused damage worth addressing. We document everything with photos.', description: 'Photo-documented' },
|
||||
{ headline: 'Worried about storm damage?', body: 'Our local crew inspects, photographs, and explains your repair or replacement options in plain language. Book a convenient window.', description: 'Plain-language options' },
|
||||
],
|
||||
replacement: [
|
||||
{ headline: 'Roof replacement quotes — {{City}}', body: 'Compare Good / Better / Best options with transparent pricing and financing. Free, no-obligation quote.', description: 'Financing available' },
|
||||
{ headline: 'Time for a new roof?', body: 'See how Class 4 impact-rated options protect your home and how financing changes the monthly number.', description: 'Good / Better / Best' },
|
||||
{ headline: 'Transparent roof pricing', body: 'Know your options before you decide. Request a free quote and a side-by-side comparison.', description: 'No-obligation quote' },
|
||||
{ headline: 'Planning a new roof?', body: 'Compare materials, warranties, and financing side by side. We help you choose what fits your home and budget.', description: 'Side-by-side options' },
|
||||
{ headline: 'Roof nearing the end?', body: 'Get a clear replacement quote with Good / Better / Best choices and financing details so you can plan ahead.', description: 'Plan-ahead quote' },
|
||||
],
|
||||
emergency: [
|
||||
{ headline: 'Roof leaking right now?', body: 'Our emergency crew can tarp and stop the damage fast. Request urgent help and keep your phone handy.', description: 'Same-day response' },
|
||||
{ headline: 'Active leak? Get help fast', body: 'Send a safe photo and your address — our dispatcher will call to coordinate emergency tarping.', description: 'Emergency dispatch' },
|
||||
{ headline: 'Stop the leak today', body: 'Our emergency team responds quickly to tarp and protect your home. Tell us where the leak is and we will coordinate the next step.', description: 'Quick response' },
|
||||
{ headline: 'Roof emergency? We can help', body: 'Send your address and a safe photo — a dispatcher will call to arrange emergency tarping and protect your interior.', description: 'Dispatcher will call' },
|
||||
{ headline: 'Water coming in?', body: 'Do not wait for more damage. Our crew can tarp the area quickly and assess what needs to happen next.', description: 'Limit the damage' },
|
||||
],
|
||||
commercial: [
|
||||
{ headline: 'Commercial roof assessment + bid', body: 'Request a site review and receive a decision-maker checklist before your meeting. Capability statement on request.', description: 'For property managers' },
|
||||
{ headline: 'Need a commercial roof bid?', body: 'Professional site assessment, scope documentation, and a clear bid package for owners and facilities teams.', description: 'Owners & facilities' },
|
||||
{ headline: 'Commercial roof, clear bid', body: 'We document the scope, photograph problem areas, and deliver a bid package your team can act on. Schedule a site review.', description: 'Scope + bid package' },
|
||||
{ headline: 'Property managers: roof help', body: 'Site assessments, capability statements, and clear timelines for owners and facilities teams. Request a review.', description: 'Owners & facilities' },
|
||||
{ headline: 'Bid-ready roof assessments', body: 'Get a documented assessment and a decision-maker checklist before your next meeting.', description: 'Decision-maker checklist' },
|
||||
],
|
||||
referral: [
|
||||
{ headline: 'Loved your new roof?', body: 'Share your experience with neighbors and pass along a referral if you know someone who needs us.', description: 'Neighborly referrals' },
|
||||
{ headline: 'Happy with your roof?', body: 'A quick review helps your neighbors find a contractor they can trust — and we appreciate it.', description: 'Help your neighbors' },
|
||||
{ headline: 'Know someone who needs us?', body: 'Pass along a referral. We will take great care of them, just like we did for you.', description: 'Refer a neighbor' },
|
||||
{ headline: 'Share your experience', body: 'Tell other homeowners how your project went. Your honest review means a lot.', description: 'Your honest review' },
|
||||
{ headline: 'Spread the word', body: 'If we did right by you, a quick review or referral helps your community find reliable roofing.', description: 'Community trust' },
|
||||
],
|
||||
maintenance: [
|
||||
{ headline: 'Roof check-up due in {{City}}', body: 'It has been a while since your last inspection. Book a seasonal check-up and keep your warranty on track.', description: 'Annual inspection' },
|
||||
{ headline: 'Time for a roof check-up?', body: 'A seasonal inspection catches small issues early and helps keep your warranty in good standing.', description: 'Catch issues early' },
|
||||
{ headline: 'Keep your roof on track', body: 'Book a maintenance visit to check for wear, sealant, and ventilation before the next season.', description: 'Seasonal visit' },
|
||||
{ headline: 'Roof maintenance reminder', body: 'It has been a while — a quick inspection now can prevent bigger repairs later.', description: 'Prevent bigger repairs' },
|
||||
{ headline: 'Protect your investment', body: 'Regular check-ups extend roof life and keep warranties valid. Schedule yours today.', description: 'Extend roof life' },
|
||||
],
|
||||
};
|
||||
|
||||
function fit(text, limit) {
|
||||
if (!limit || text.length <= limit) return text;
|
||||
return text.slice(0, Math.max(0, limit - 1)).trimEnd() + '…';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate N platform-aware creative variants for a service type.
|
||||
* Returns [{ headline, body, description, charCounts, policy }].
|
||||
*/
|
||||
export function generateVariants({ platformId, serviceType, city = 'your area', count = 3 }) {
|
||||
const platform = getPlatform(platformId);
|
||||
const limits = CHAR_LIMITS[platformId] || CHAR_LIMITS.meta;
|
||||
const bank = BANK[serviceType] || BANK.storm;
|
||||
|
||||
return bank.slice(0, count).map((tpl) => {
|
||||
const merge = (s) => s.replaceAll('{{City}}', city);
|
||||
const headline = fit(merge(tpl.headline), limits.headline);
|
||||
const body = fit(merge(tpl.body), limits.body);
|
||||
const description = fit(merge(tpl.description), limits.description);
|
||||
return {
|
||||
headline,
|
||||
body,
|
||||
description,
|
||||
aiGenerated: true,
|
||||
platform: platformId,
|
||||
platformTone: platform?.copyGuidance || '',
|
||||
charCounts: {
|
||||
headline: { used: headline.length, limit: limits.headline },
|
||||
body: { used: body.length, limit: limits.body },
|
||||
description: { used: description.length, limit: limits.description },
|
||||
},
|
||||
policy: scanCopy(headline, body, description),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* nurtureAdvisor.js — mock "Next best nurture selection" (spec §6).
|
||||
*
|
||||
* Given a sample lead (platform + service), it first checks the deterministic routing
|
||||
* rules — those override AI (spec §6). If only the catch-all default would catch the
|
||||
* lead, it recommends a tailored nurture path the admin can add as a rule.
|
||||
* Front-end demo: deterministic mapping, no real model.
|
||||
*/
|
||||
|
||||
const PATHS = {
|
||||
storm: { playbook: 'Storm Inspection Nurture', rep: 'Field agent (territory)', speedToLead: 'Text < 5 min', education: 'Storm checklist + insurance', scheduling: 'Auto-offer next available slot' },
|
||||
emergency: { playbook: 'Emergency Dispatch Nurture', rep: 'On-call dispatcher', speedToLead: 'WhatsApp + call < 10 min', education: 'Leak mitigation tips', scheduling: 'Human callback within SLA' },
|
||||
commercial: { playbook: 'Commercial Bid Nurture', rep: 'Commercial estimator', speedToLead: 'Email < 1 hr', education: 'Capability statement', scheduling: 'Manual booking by rep' },
|
||||
replacement: { playbook: 'Retail Replacement Nurture', rep: 'Inside sales', speedToLead: 'Call < 5 min', education: 'Good/Better/Best + financing', scheduling: 'Send scheduling link' },
|
||||
referral: { playbook: 'Completion Promoter Path', rep: 'Customer success', speedToLead: 'Email/SMS same day', education: 'Review + referral ask', scheduling: 'No scheduling (info only)' },
|
||||
maintenance: { playbook: 'Reactivation Nurture', rep: 'Inside sales', speedToLead: 'Email < 1 day', education: 'Seasonal maintenance value', scheduling: 'Send scheduling link' },
|
||||
};
|
||||
|
||||
const RATIONALE = {
|
||||
storm: 'Storm / hail intent → fast field inspection with insurance education.',
|
||||
emergency: 'Leak / emergency intent → human dispatch within the 10-minute SLA.',
|
||||
commercial: 'Commercial / facilities intent → estimator with a decision-maker track.',
|
||||
replacement: 'Replacement intent → inside sales with financing education.',
|
||||
referral: 'Referral / review intent → promoter path, no hard scheduling.',
|
||||
maintenance: 'Maintenance / reactivation intent → seasonal value nurture.',
|
||||
};
|
||||
|
||||
/** First enabled rule (by priority) whose conditions match the lead; may be the default. */
|
||||
export function matchRule(rules, { platform, serviceType }) {
|
||||
const enabled = [...rules].filter(r => r.enabled).sort((a, b) => a.priority - b.priority);
|
||||
for (const r of enabled) {
|
||||
const pOk = !r.when.platform || r.when.platform === 'any' || r.when.platform === platform;
|
||||
const sOk = !r.when.serviceType || r.when.serviceType === 'any' || r.when.serviceType === serviceType;
|
||||
if (pOk && sOk) return { rule: r, isDefault: r.priority === 99 };
|
||||
}
|
||||
return { rule: null, isDefault: false };
|
||||
}
|
||||
|
||||
/** Recommend a nurture path for a service type. Returns { then, rationale }. */
|
||||
export function recommendPath(serviceType) {
|
||||
const t = PATHS[serviceType] ? serviceType : 'storm';
|
||||
return { then: PATHS[t], rationale: RATIONALE[t] };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* questionOptimizer.js — mock "AI Lead form question optimizer" (spec §6).
|
||||
*
|
||||
* Suggests 2–4 qualifying questions for a lead form based on its service type
|
||||
* (owner/tenant, urgency, address, insurance status, etc). Guardrails (spec §6):
|
||||
* - never request sensitive data (SSN, income, DOB, financial details);
|
||||
* - don't over-friction the form — capped at 4 suggestions, and the UI warns when a
|
||||
* form already has many fields.
|
||||
* Front-end demo: deterministic, derived from the blueprint service type.
|
||||
*/
|
||||
|
||||
// Sensitive fields the optimizer will never suggest (documented for the guardrail note).
|
||||
export const SENSITIVE_EXCLUDED = ['SSN', 'income', 'date of birth', 'credit score', 'bank details'];
|
||||
|
||||
const GENERAL = [
|
||||
{ source: 'owner_or_tenant', target: 'lead.role', label: 'Are you the homeowner or tenant?', transform: 'none', validation: '', rationale: 'Routes decision-makers vs. renters to the right rep.' },
|
||||
{ source: 'service_urgency', target: 'lead.urgency', label: 'How urgent is this?', transform: 'none', validation: '', rationale: 'Drives the speed-to-lead SLA and triage priority.' },
|
||||
{ source: 'property_address', target: 'property.address', label: 'Property address', transform: 'none', validation: 'required', rationale: 'Confirms the service area and enables routing.' },
|
||||
];
|
||||
|
||||
const BY_SERVICE = {
|
||||
storm: [
|
||||
{ source: 'active_leak', target: 'lead.urgencyFlag', label: 'Do you have an active leak right now?', transform: 'bool', validation: '', rationale: 'Flags emergencies for faster dispatch.' },
|
||||
{ source: 'storm_date', target: 'lead.stormDate', label: 'Storm date (if known)', transform: 'date', validation: '', rationale: 'Supports insurance documentation windows.' },
|
||||
{ source: 'insurance_status', target: 'lead.insuranceStatus', label: 'Have you filed an insurance claim yet?', transform: 'none', validation: '', rationale: 'Qualifies insurance-track leads (claim status only — not financial data).' },
|
||||
],
|
||||
replacement: [
|
||||
{ source: 'roof_age', target: 'lead.roofAge', label: 'Approximate roof age?', transform: 'int', validation: '', rationale: 'Separates replacement-ready from early research.' },
|
||||
{ source: 'financing_interest', target: 'lead.financingInterest', label: 'Interested in financing options?', transform: 'bool', validation: '', rationale: 'Surfaces financing intent without asking for financial details.' },
|
||||
{ source: 'timeline', target: 'lead.timeline', label: 'What is your project timeline?', transform: 'none', validation: '', rationale: 'Prioritizes near-term buyers.' },
|
||||
],
|
||||
emergency: [
|
||||
{ source: 'active_leak', target: 'lead.urgencyFlag', label: 'Is water coming in right now?', transform: 'bool', validation: '', rationale: 'Triggers emergency dispatch within the SLA.' },
|
||||
{ source: 'best_callback', target: 'contact.phone', label: 'Best callback number?', transform: 'e164', validation: 'phone', rationale: 'Enables immediate human escalation.' },
|
||||
],
|
||||
commercial: [
|
||||
{ source: 'property_type', target: 'lead.propertyType', label: 'What type of property is it?', transform: 'none', validation: '', rationale: 'Qualifies the commercial scope.' },
|
||||
{ source: 'square_footage', target: 'lead.squareFootage', label: 'Approximate square footage?', transform: 'int', validation: '', rationale: 'Sizes the bid and assigns the right estimator.' },
|
||||
{ source: 'decision_timeline', target: 'lead.timeline', label: 'Decision timeline?', transform: 'none', validation: '', rationale: 'Prioritizes active buyers.' },
|
||||
],
|
||||
referral: [],
|
||||
maintenance: [
|
||||
{ source: 'past_customer', target: 'lead.pastCustomer', label: 'Are you a past customer?', transform: 'bool', validation: '', rationale: 'Routes reactivation vs. new prospect.' },
|
||||
{ source: 'warranty_status', target: 'lead.warrantyStatus', label: 'Is your warranty still active?', transform: 'none', validation: '', rationale: 'Targets warranty-driven check-ups.' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Suggest up to 4 qualifying questions not already on the form.
|
||||
* Returns [{ source, target, label, transform, validation, rationale }].
|
||||
*/
|
||||
export function suggestQuestions(form, serviceType = 'storm') {
|
||||
const existing = new Set((form.fields || []).flatMap(f => [f.source, f.target]));
|
||||
const pool = [...(BY_SERVICE[serviceType] || []), ...GENERAL];
|
||||
const picks = [];
|
||||
for (const q of pool) {
|
||||
if (existing.has(q.source) || existing.has(q.target)) continue;
|
||||
if (picks.some(p => p.target === q.target || p.source === q.source)) continue;
|
||||
picks.push(q);
|
||||
if (picks.length >= 4) break;
|
||||
}
|
||||
return picks;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* ui.jsx — shared presentational primitives for the Social Ads Engine screens.
|
||||
*
|
||||
* Reuses the CRM's SpotlightCard look and Tailwind tokens (zinc surfaces, amber/blue
|
||||
* accents) so the module feels native to the rest of the app. Pure UI, no state.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { SpotlightCard } from '../../../components/SpotlightCard';
|
||||
import { getPlatform, CAPABILITY_LABEL } from '../data/platforms.js';
|
||||
|
||||
// ── Card ───────────────────────────────────────────────────────────────────
|
||||
export function Card({ title, subtitle, icon: Icon, action, children, className = '', pad = 'p-5' }) {
|
||||
return (
|
||||
<SpotlightCard className={`h-full ${className}`}>
|
||||
<div className={`${pad} flex-1 flex flex-col`}>
|
||||
{(title || action) && (
|
||||
<div className="flex items-start justify-between mb-4 gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{Icon && <Icon size={18} className="text-amber-500 shrink-0" />}
|
||||
<div className="min-w-0">
|
||||
{title && <h3 className="text-base font-bold text-zinc-900 dark:text-white truncate">{title}</h3>}
|
||||
{subtitle && <p className="text-xs text-zinc-500 dark:text-zinc-400">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
);
|
||||
}
|
||||
|
||||
// ── KPI tile ─────────────────────────────────────────────────────────────────
|
||||
const KPI_COLORS = {
|
||||
blue: 'text-blue-500 bg-blue-500/10',
|
||||
emerald: 'text-emerald-500 bg-emerald-500/10',
|
||||
amber: 'text-amber-500 bg-amber-500/10',
|
||||
red: 'text-red-500 bg-red-500/10',
|
||||
purple: 'text-purple-500 bg-purple-500/10',
|
||||
cyan: 'text-cyan-500 bg-cyan-500/10',
|
||||
};
|
||||
|
||||
export function KpiTile({ label, value, sub, icon: Icon, color = 'blue', trend }) {
|
||||
const cls = KPI_COLORS[color] || KPI_COLORS.blue;
|
||||
const [text, bg] = cls.split(' ');
|
||||
return (
|
||||
<SpotlightCard className="h-full">
|
||||
<div className="p-4 sm:p-5 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 truncate">{label}</p>
|
||||
<p className={`mt-1.5 text-2xl font-mono font-bold ${text} tracking-tight`}>{value}</p>
|
||||
{sub && <p className="text-[11px] text-zinc-400 mt-1">{sub}</p>}
|
||||
{typeof trend === 'number' && (
|
||||
<span className={`text-[11px] font-semibold ${trend >= 0 ? 'text-emerald-500' : 'text-red-500'}`}>
|
||||
{trend >= 0 ? '▲' : '▼'} {Math.abs(trend)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{Icon && <div className={`p-2.5 rounded-xl ${bg} ${text} shrink-0`}><Icon size={20} /></div>}
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Platform badge ───────────────────────────────────────────────────────────
|
||||
export function PlatformBadge({ platformId, size = 'md', showName = true }) {
|
||||
const p = getPlatform(platformId);
|
||||
if (!p) return null;
|
||||
const Icon = p.icon;
|
||||
const dim = size === 'sm' ? 14 : 16;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 font-semibold text-zinc-700 dark:text-zinc-200">
|
||||
<span className="inline-flex items-center justify-center rounded-md p-1" style={{ backgroundColor: `${p.color}1A`, color: p.color }}>
|
||||
<Icon size={dim} />
|
||||
</span>
|
||||
{showName && <span className={size === 'sm' ? 'text-xs' : 'text-sm'}>{p.short}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Generic status chip ──────────────────────────────────────────────────────
|
||||
const CHIP = {
|
||||
// account / generic
|
||||
connected: 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
|
||||
disconnected: 'bg-zinc-100 dark:bg-zinc-700/40 text-zinc-500 dark:text-zinc-400',
|
||||
action_required: 'bg-amber-100 dark:bg-amber-500/15 text-amber-700 dark:text-amber-400',
|
||||
// campaign
|
||||
active: 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
|
||||
paused: 'bg-amber-100 dark:bg-amber-500/15 text-amber-700 dark:text-amber-400',
|
||||
draft: 'bg-zinc-100 dark:bg-zinc-700/40 text-zinc-500 dark:text-zinc-300',
|
||||
// approval / policy
|
||||
approved: 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
|
||||
pending: 'bg-amber-100 dark:bg-amber-500/15 text-amber-700 dark:text-amber-400',
|
||||
review: 'bg-amber-100 dark:bg-amber-500/15 text-amber-700 dark:text-amber-400',
|
||||
blocked: 'bg-red-100 dark:bg-red-500/15 text-red-700 dark:text-red-400',
|
||||
clear: 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
|
||||
// lead / sync
|
||||
new: 'bg-blue-100 dark:bg-blue-500/15 text-blue-700 dark:text-blue-400',
|
||||
assigned: 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
|
||||
merged: 'bg-zinc-100 dark:bg-zinc-700/40 text-zinc-500 dark:text-zinc-400',
|
||||
synced: 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
|
||||
queued: 'bg-amber-100 dark:bg-amber-500/15 text-amber-700 dark:text-amber-400',
|
||||
failed: 'bg-red-100 dark:bg-red-500/15 text-red-700 dark:text-red-400',
|
||||
};
|
||||
|
||||
export function StatusChip({ status, label }) {
|
||||
const cls = CHIP[status] || CHIP.draft;
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider ${cls}`}>
|
||||
{label || String(status).replace(/_/g, ' ')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Capability badge (spec §11 — honest automation level) ──────────────────────
|
||||
export function CapabilityBadge({ capability, active }) {
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-md text-[10px] font-semibold border ${active
|
||||
? 'border-emerald-300 dark:border-emerald-500/30 text-emerald-700 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10'
|
||||
: 'border-zinc-200 dark:border-zinc-700 text-zinc-400 dark:text-zinc-600 line-through'}`}>
|
||||
{CAPABILITY_LABEL[capability] || capability}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Section header for screens ─────────────────────────────────────────────────
|
||||
export function SectionHeader({ icon: Icon, title, description, children }) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
{Icon && (
|
||||
<div className="p-2 rounded-xl bg-amber-500/10 text-amber-500 shrink-0">
|
||||
<Icon size={22} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h2 className="text-lg sm:text-xl font-bold text-zinc-900 dark:text-white">{title}</h2>
|
||||
{description && <p className="text-xs sm:text-sm text-zinc-500 dark:text-zinc-400">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{children && <div className="flex items-center gap-2 shrink-0">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Small button ───────────────────────────────────────────────────────────────
|
||||
export function Btn({ children, onClick, variant = 'primary', size = 'md', icon: Icon, disabled, type = 'button', className = '', title }) {
|
||||
const base = 'inline-flex items-center justify-center gap-1.5 font-semibold rounded-xl transition-all focus:outline-none focus:ring-2 focus:ring-amber-500 disabled:opacity-40 disabled:cursor-not-allowed';
|
||||
const sizes = { sm: 'px-2.5 py-1.5 text-xs', md: 'px-4 py-2 text-sm' };
|
||||
const variants = {
|
||||
primary: 'bg-gradient-to-r from-amber-400 to-orange-500 text-white shadow-lg shadow-amber-500/25 hover:shadow-amber-500/40',
|
||||
ghost: 'text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-white/10',
|
||||
outline: 'border border-zinc-200 dark:border-white/15 text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-white/5',
|
||||
danger: 'bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20',
|
||||
success: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/20',
|
||||
};
|
||||
return (
|
||||
<button type={type} title={title} onClick={onClick} disabled={disabled} className={`${base} ${sizes[size]} ${variants[variant]} ${className}`}>
|
||||
{Icon && <Icon size={size === 'sm' ? 14 : 16} />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* blueprints.js — prebuilt roofing/construction campaign recipes (spec §4, §9).
|
||||
*
|
||||
* These are the tenant-cloneable templates surfaced in the Campaign Builder.
|
||||
* Lead-form questions and mapped nurture paths come straight from spec §9.1.
|
||||
* Front-end demo data only.
|
||||
*/
|
||||
|
||||
export const BLUEPRINTS = [
|
||||
{
|
||||
id: 'bp_storm',
|
||||
name: 'Storm inspection response',
|
||||
objective: 'lead_generation',
|
||||
serviceType: 'storm',
|
||||
bestChannels: ['meta', 'google', 'nextdoor'],
|
||||
offer: 'Free post-storm roof inspection',
|
||||
formQuestions: [
|
||||
'Property address', 'Storm date (if known)', 'Active leak?',
|
||||
'Homeowner / tenant', 'Preferred contact time',
|
||||
],
|
||||
nurture: 'Social Lead → Appointment → Storm Inspection Nurture → Insurance Education',
|
||||
adCopy: 'Recent hail or wind in {{City}}? A roof inspection can document visible damage, ventilation concerns, and repair/replacement options. Request an inspection window.',
|
||||
icon: 'CloudLightning',
|
||||
accent: 'amber',
|
||||
},
|
||||
{
|
||||
id: 'bp_retail',
|
||||
name: 'Retail roof replacement',
|
||||
objective: 'lead_generation',
|
||||
serviceType: 'replacement',
|
||||
bestChannels: ['google', 'meta', 'nextdoor'],
|
||||
offer: 'Free replacement quote + financing options',
|
||||
formQuestions: [
|
||||
'Roof age', 'Leak / aging / planned upgrade', 'Timeline', 'Financing interest',
|
||||
],
|
||||
nurture: 'Retail Replacement Nurture → Upgrade Education → Appointment Reminder',
|
||||
adCopy: 'Roof showing its age? Compare Good / Better / Best replacement options with transparent pricing and financing. Get a free quote.',
|
||||
icon: 'Home',
|
||||
accent: 'blue',
|
||||
},
|
||||
{
|
||||
id: 'bp_emergency',
|
||||
name: 'Emergency leak / tarping',
|
||||
objective: 'lead_generation',
|
||||
serviceType: 'emergency',
|
||||
bestChannels: ['google', 'meta', 'whatsapp'],
|
||||
offer: 'Same-day emergency leak response',
|
||||
formQuestions: [
|
||||
'Active leak?', 'Safe photo upload', 'Address', 'Urgency', 'Best callback number',
|
||||
],
|
||||
nurture: 'Emergency Dispatch Nurture → Human escalation within 10 minutes',
|
||||
adCopy: 'Roof leaking right now? Our emergency crew can tarp and stop the damage fast. Request urgent help.',
|
||||
icon: 'Siren',
|
||||
accent: 'red',
|
||||
},
|
||||
{
|
||||
id: 'bp_commercial',
|
||||
name: 'Commercial / property manager bid',
|
||||
objective: 'lead_generation',
|
||||
serviceType: 'commercial',
|
||||
bestChannels: ['linkedin', 'google', 'reddit'],
|
||||
offer: 'Commercial roof assessment + decision-maker checklist',
|
||||
formQuestions: [
|
||||
'Company', 'Role', 'Property type', 'Square footage (if known)',
|
||||
'Site access', 'Decision timeline',
|
||||
],
|
||||
nurture: 'Commercial Bid Nurture → Decision-maker Confirmation → Capability Statement',
|
||||
adCopy: 'Need a commercial roof assessment or bid package? Request a site review and receive a decision-maker checklist before the meeting.',
|
||||
icon: 'Building2',
|
||||
accent: 'purple',
|
||||
},
|
||||
{
|
||||
id: 'bp_unsold',
|
||||
name: 'Unsold estimate retargeting',
|
||||
objective: 'retargeting',
|
||||
serviceType: 'replacement',
|
||||
bestChannels: ['meta', 'google', 'reddit', 'linkedin'],
|
||||
offer: 'No new form — uses CRM audience',
|
||||
formQuestions: ['No new form required; uses CRM audience'],
|
||||
nurture: 'Unsold Estimate Warm/Cold Nurture → Financing or Options Compare',
|
||||
adCopy: 'Still weighing your roof options? See how financing changes the monthly number — and what a Class 4 upgrade protects.',
|
||||
icon: 'RefreshCw',
|
||||
accent: 'cyan',
|
||||
},
|
||||
{
|
||||
id: 'bp_review',
|
||||
name: 'Review / referral flywheel',
|
||||
objective: 'engagement',
|
||||
serviceType: 'referral',
|
||||
bestChannels: ['meta', 'nextdoor'],
|
||||
offer: 'Referral landing page / review CTA',
|
||||
formQuestions: ['Referral landing page or review CTA'],
|
||||
nurture: 'Completion Promoter Path → Review → Referral Offer (if allowed)',
|
||||
adCopy: 'Loved your new roof? Share your experience with neighbors — and pass along a referral if you know someone who needs us.',
|
||||
icon: 'Star',
|
||||
accent: 'emerald',
|
||||
},
|
||||
{
|
||||
id: 'bp_seasonal',
|
||||
name: 'Seasonal maintenance / reactivation',
|
||||
objective: 'reactivation',
|
||||
serviceType: 'maintenance',
|
||||
bestChannels: ['nextdoor', 'meta', 'google', 'whatsapp'],
|
||||
offer: 'Annual inspection check-in',
|
||||
formQuestions: ['Past customer check-in', 'Inspection request', 'Warranty check'],
|
||||
nurture: 'Reactivation → Annual Inspection → Storm Trigger Retarget',
|
||||
adCopy: 'It has been a while since your last roof check-up. Book a seasonal inspection and keep your warranty on track.',
|
||||
icon: 'CalendarClock',
|
||||
accent: 'amber',
|
||||
},
|
||||
];
|
||||
|
||||
export const BLUEPRINT_BY_ID = Object.fromEntries(BLUEPRINTS.map(b => [b.id, b]));
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* platforms.js — the six ad platforms the Social Ads Engine targets (spec §3).
|
||||
*
|
||||
* Per spec §11, the UI must never pretend full automation exists where a platform
|
||||
* only supports a subset. Each platform therefore declares an explicit set of
|
||||
* CAPABILITIES, and every screen reads these flags to honestly show what is
|
||||
* automated vs. manual/import-only. Front-end demo data only — no real OAuth or APIs.
|
||||
*
|
||||
* Manufacturer/brand colors are used purely for recognizability in the demo.
|
||||
*/
|
||||
import { Facebook, Search, Linkedin, MessageCircle, Phone, Home } from 'lucide-react';
|
||||
|
||||
// Capability vocabulary (spec §11 feature flags by platform capability).
|
||||
export const CAPABILITY = {
|
||||
INGEST: 'ingest', // can receive leads (webhook / lead sync)
|
||||
REPORT: 'report', // can pull spend/impressions/clicks/leads
|
||||
CONVERSION_SYNC: 'conversion-sync', // can push offline/conversion events back
|
||||
DRAFT_BUILDER: 'draft-builder', // can assemble a platform-ready draft in-app
|
||||
FULL_PUBLISH: 'full-publish', // can publish a campaign directly via API
|
||||
};
|
||||
|
||||
export const CAPABILITY_LABEL = {
|
||||
[CAPABILITY.INGEST]: 'Lead ingest',
|
||||
[CAPABILITY.REPORT]: 'Reporting',
|
||||
[CAPABILITY.CONVERSION_SYNC]: 'Conversion sync',
|
||||
[CAPABILITY.DRAFT_BUILDER]: 'Draft builder',
|
||||
[CAPABILITY.FULL_PUBLISH]: 'Full publish',
|
||||
};
|
||||
|
||||
export const PLATFORMS = [
|
||||
{
|
||||
id: 'meta',
|
||||
name: 'Facebook + Instagram',
|
||||
short: 'Meta',
|
||||
icon: Facebook,
|
||||
color: '#1877F2',
|
||||
role: 'Local awareness, storm response, instant-form leads, retargeting, social proof.',
|
||||
integrationPath: 'Meta Lead Ads / Instant Forms + webhook ingestion; Marketing API; Conversions API.',
|
||||
cautions: 'App review, business verification, token lifecycle, lead retention windows.',
|
||||
capabilities: [
|
||||
CAPABILITY.INGEST, CAPABILITY.REPORT, CAPABILITY.CONVERSION_SYNC,
|
||||
CAPABILITY.DRAFT_BUILDER, CAPABILITY.FULL_PUBLISH,
|
||||
],
|
||||
copyGuidance: 'Local, visual, simple offer, fast callback. Avoid panic/fear language after storms.',
|
||||
},
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google Ads',
|
||||
short: 'Google',
|
||||
icon: Search,
|
||||
color: '#4285F4',
|
||||
role: 'High-intent search, local services, lead-form assets, retargeting.',
|
||||
integrationPath: 'Google Ads Lead Form Webhook; Ads API for reporting; offline conversion upload.',
|
||||
cautions: 'Webhook key validation, OAuth/developer token, call tracking, conversion dedup.',
|
||||
capabilities: [
|
||||
CAPABILITY.INGEST, CAPABILITY.REPORT, CAPABILITY.CONVERSION_SYNC, CAPABILITY.DRAFT_BUILDER,
|
||||
],
|
||||
copyGuidance: 'Intent matching. Align ad text and landing/form to the exact search need.',
|
||||
},
|
||||
{
|
||||
id: 'linkedin',
|
||||
name: 'LinkedIn',
|
||||
short: 'LinkedIn',
|
||||
icon: Linkedin,
|
||||
color: '#0A66C2',
|
||||
role: 'Commercial roofing, property managers, builders, facilities, HOA and B2B.',
|
||||
integrationPath: 'Lead Gen Forms + Lead Sync APIs; campaign/reporting via Marketing API program.',
|
||||
cautions: 'Partner/API access, B2B field mapping, leadSync permissions, lower volume.',
|
||||
capabilities: [CAPABILITY.INGEST, CAPABILITY.REPORT, CAPABILITY.DRAFT_BUILDER],
|
||||
copyGuidance: 'Professional and proof-driven. Focus owners, facilities, associations, builders.',
|
||||
},
|
||||
{
|
||||
id: 'reddit',
|
||||
name: 'Reddit',
|
||||
short: 'Reddit',
|
||||
icon: MessageCircle,
|
||||
color: '#FF4500',
|
||||
role: 'Niche local research, homeowner communities, storm/insurance discussion, B2B education.',
|
||||
integrationPath: 'Reddit Lead Generation Ads where available; Zapier/partner path; Conversions API.',
|
||||
cautions: 'Community tone differs; avoid promotional copy; beta limitations.',
|
||||
capabilities: [CAPABILITY.INGEST, CAPABILITY.REPORT],
|
||||
copyGuidance: 'Value-first, specific, less polished. Offer checklist/calculator. Avoid corporate hype.',
|
||||
},
|
||||
{
|
||||
id: 'whatsapp',
|
||||
name: 'WhatsApp',
|
||||
short: 'WhatsApp',
|
||||
icon: Phone,
|
||||
color: '#25D366',
|
||||
role: 'Conversational follow-up in markets where customers prefer WhatsApp.',
|
||||
integrationPath: 'WhatsApp Business Platform / Cloud API, approved templates, session messaging.',
|
||||
cautions: 'Template approval, opt-in, conversation windows, category rules, pricing.',
|
||||
capabilities: [CAPABILITY.INGEST],
|
||||
copyGuidance: 'Conversational, short, service-oriented. Use approved templates; shift to human for complex issues.',
|
||||
},
|
||||
{
|
||||
id: 'nextdoor',
|
||||
name: 'Nextdoor',
|
||||
short: 'Nextdoor',
|
||||
icon: Home,
|
||||
color: '#8B6F47',
|
||||
role: 'Hyperlocal neighborhood credibility, storm response, referral/recommendation demand.',
|
||||
integrationPath: 'Nextdoor developer/Ads API where eligible; conversion API; manual/import fallback.',
|
||||
cautions: 'API access may require approval; do not scrape neighborhood content; community etiquette.',
|
||||
capabilities: [CAPABILITY.REPORT],
|
||||
copyGuidance: 'Neighborly, hyperlocal, trust- and reputation-focused. Avoid spammy automation or scraping.',
|
||||
},
|
||||
];
|
||||
|
||||
export const PLATFORM_BY_ID = Object.fromEntries(PLATFORMS.map(p => [p.id, p]));
|
||||
|
||||
export const getPlatform = (id) => PLATFORM_BY_ID[id] ?? null;
|
||||
|
||||
export const hasCapability = (platformId, capability) =>
|
||||
!!PLATFORM_BY_ID[platformId]?.capabilities.includes(capability);
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* publishChecklist.js — generates a platform-ready manual setup checklist for
|
||||
* campaigns on channels without a full-publish API (spec §5 fallback, §11 capability
|
||||
* honesty). The steps are filled with the campaign's real objective, budget caps,
|
||||
* schedule, approved creative, form fields, tracking link, and webhook so an admin can
|
||||
* reproduce the campaign in the platform's native ads manager, then mark it live.
|
||||
*/
|
||||
import { getPlatform } from './platforms.js';
|
||||
import { BLUEPRINT_BY_ID } from './blueprints.js';
|
||||
import { buildUtm } from './utm.js';
|
||||
|
||||
const PLATFORM_TIP = {
|
||||
reddit: 'Reddit lead APIs are limited — drive clicks to the tracked LynkedUp landing page and ingest submissions via webhook/CSV rather than native lead forms.',
|
||||
whatsapp: 'Use Click-to-WhatsApp ads or the WhatsApp Business app; route inbound conversations into LynkedUp through the WhatsApp Business webhook.',
|
||||
nextdoor: 'Nextdoor Ads are managed in Nextdoor Business Center; use a tracked landing-page link and confirm neighborhood-level targeting.',
|
||||
default: 'This platform has no full-publish API — complete setup in its native ads manager, then return to mark the campaign live.',
|
||||
};
|
||||
|
||||
const money = (n) => `$${Number(n || 0).toLocaleString('en-US')}`;
|
||||
|
||||
/** Build a structured, value-filled checklist for a manual-publish campaign. */
|
||||
export function buildChecklist(campaign, { creative, form } = {}) {
|
||||
const p = getPlatform(campaign.platform);
|
||||
const g = campaign.guardrails || {};
|
||||
const pauseRules = [
|
||||
g.pauseOnDailyCap && 'daily cap',
|
||||
g.pauseOnCplBreach && 'CPL breach',
|
||||
g.anomalyDetection && 'spend anomaly',
|
||||
].filter(Boolean).join(', ') || 'none';
|
||||
|
||||
const formFields = form?.fields?.map(f => f.target || f.source)
|
||||
|| BLUEPRINT_BY_ID[campaign.blueprintId]?.formQuestions
|
||||
|| ['name', 'phone', 'email', 'address', 'consent'];
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: `Open ${p?.name || 'the platform'} Ads Manager`,
|
||||
detail: `Create a new ${p?.short || ''} campaign. Account capability: ${p?.role || 'manual setup only'}.`,
|
||||
},
|
||||
{
|
||||
title: 'Set objective & campaign name',
|
||||
detail: `Objective: ${(campaign.objective || 'lead_generation').replace(/_/g, ' ')}. Name: “${campaign.name}”.`,
|
||||
},
|
||||
{
|
||||
title: 'Set geography / targeting',
|
||||
detail: campaign.serviceArea || 'Define the service-area ZIPs, city, or radius.',
|
||||
},
|
||||
{
|
||||
title: 'Set budget & spend caps',
|
||||
detail: `Daily ${money(campaign.budgetDaily)} (total ${money(campaign.budgetTotal)}). Enforce caps — weekly ${money(g.weeklyCap)}, monthly ${money(g.monthlyCap)}. Alert at ${g.alertPct || 80}% of cap or CPL > ${money(g.cplThreshold)}. Auto-pause on: ${pauseRules}.`,
|
||||
},
|
||||
{
|
||||
title: 'Set flight dates',
|
||||
detail: `${campaign.startAt} → ${campaign.endAt}.`,
|
||||
},
|
||||
{
|
||||
title: 'Add the approved creative',
|
||||
detail: creative
|
||||
? `Headline: “${creative.headline}”\nBody: “${creative.body}”${creative.description ? `\nDescription: “${creative.description}”` : ''}`
|
||||
: 'Paste a policy-clear, approved creative from Creative Studio.',
|
||||
},
|
||||
{
|
||||
title: 'Configure the lead form & consent',
|
||||
detail: `Capture fields: ${formFields.join(', ')}. ${form ? 'Apply the locked consent text + privacy URL from the Lead Form Mapper.' : 'Use the default capture form’s consent text + privacy URL.'}`,
|
||||
},
|
||||
{
|
||||
title: 'Add the tracking link',
|
||||
detail: `Point the ad to the tracked landing page (UTM parameters pre-filled for attribution):\n${buildUtm(campaign, { creative }).url}`,
|
||||
},
|
||||
{
|
||||
title: 'Connect lead ingestion',
|
||||
detail: `In Connected Accounts → ${p?.short || 'this platform'}, verify the webhook so submissions ingest into the Unified Lead Inbox.`,
|
||||
},
|
||||
{
|
||||
title: 'Submit & launch in-platform',
|
||||
detail: `Submit for ${p?.short || 'platform'} review, then set the campaign live. Return here to mark setup complete.`,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
platform: campaign.platform,
|
||||
platformName: p?.name || campaign.platform,
|
||||
tip: PLATFORM_TIP[campaign.platform] || PLATFORM_TIP.default,
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
/** Flatten a checklist to copyable plain text (for "Copy instructions"). */
|
||||
export function checklistToText(campaign, checklist) {
|
||||
const lines = [
|
||||
`Manual setup checklist — ${campaign.name}`,
|
||||
`Platform: ${checklist.platformName}`,
|
||||
`Note: ${checklist.tip}`,
|
||||
'',
|
||||
...checklist.steps.map((s, i) => `${i + 1}. ${s.title}\n ${s.detail.replace(/\n/g, '\n ')}`),
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* seed.js — initial mock data for the Social Ads Engine demo (spec §7 data model).
|
||||
*
|
||||
* Each export maps to a spec entity:
|
||||
* accounts → ad_accounts
|
||||
* campaigns → ad_campaigns
|
||||
* creatives → ad_creatives
|
||||
* leadForms → lead_forms (+ field_mappings inline)
|
||||
* leads → ad_leads
|
||||
* nurtureRules → ad-to-nurture rules (spec §5, §8)
|
||||
* audiences → audience_segments
|
||||
* conversionEvents → conversion_events
|
||||
* approvals → creative_reviews / compliance queue
|
||||
* analyticsDaily → campaign_analytics_daily
|
||||
*
|
||||
* Dates are fixed strings so the demo never drifts. Front-end only; no backend.
|
||||
*/
|
||||
|
||||
// ── ad_accounts ──────────────────────────────────────────────────────────────
|
||||
export const accounts = [
|
||||
{
|
||||
id: 'acc_meta', platform: 'meta', name: 'LynkedUp Roofing — Meta Business',
|
||||
externalAccountId: 'act_109_445_882', status: 'connected',
|
||||
tokenStatus: 'healthy', tokenExpiresAt: '2026-09-02', webhookStatus: 'verified',
|
||||
lastSyncAt: '2026-06-10T07:40:00Z', scopes: ['leads_retrieval', 'ads_read', 'ads_management'],
|
||||
},
|
||||
{
|
||||
id: 'acc_google', platform: 'google', name: 'LynkedUp Roofing — Google Ads',
|
||||
externalAccountId: '742-553-9981', status: 'connected',
|
||||
tokenStatus: 'healthy', tokenExpiresAt: '2026-08-19', webhookStatus: 'verified',
|
||||
lastSyncAt: '2026-06-10T07:38:00Z', scopes: ['adwords', 'lead_form_webhook'],
|
||||
},
|
||||
{
|
||||
id: 'acc_linkedin', platform: 'linkedin', name: 'LynkedUp Commercial — LinkedIn',
|
||||
externalAccountId: '510882', status: 'connected',
|
||||
tokenStatus: 'expiring', tokenExpiresAt: '2026-06-15', webhookStatus: 'verified',
|
||||
lastSyncAt: '2026-06-09T22:10:00Z', scopes: ['r_ads', 'r_leadgen', 'rw_ads'],
|
||||
},
|
||||
{
|
||||
id: 'acc_reddit', platform: 'reddit', name: 'LynkedUp — Reddit Ads',
|
||||
externalAccountId: 't2_8h3k1', status: 'action_required',
|
||||
tokenStatus: 'revoked', tokenExpiresAt: null, webhookStatus: 'failing',
|
||||
lastSyncAt: '2026-06-07T14:02:00Z', scopes: [],
|
||||
},
|
||||
{
|
||||
id: 'acc_whatsapp', platform: 'whatsapp', name: 'WhatsApp Business — (972) 555-0100',
|
||||
externalAccountId: 'waba_338201', status: 'disconnected',
|
||||
tokenStatus: 'none', tokenExpiresAt: null, webhookStatus: 'not_configured',
|
||||
lastSyncAt: null, scopes: [],
|
||||
},
|
||||
{
|
||||
id: 'acc_nextdoor', platform: 'nextdoor', name: 'LynkedUp Roofing — Nextdoor',
|
||||
externalAccountId: 'nd_ads_44120', status: 'connected',
|
||||
tokenStatus: 'healthy', tokenExpiresAt: '2026-12-01', webhookStatus: 'manual',
|
||||
lastSyncAt: '2026-06-10T06:00:00Z', scopes: ['ads_read'],
|
||||
},
|
||||
];
|
||||
|
||||
// ── ad_campaigns ─────────────────────────────────────────────────────────────
|
||||
export const campaigns = [
|
||||
{
|
||||
id: 'camp_1', blueprintId: 'bp_storm', platform: 'meta', name: 'Plano Hail Response — June',
|
||||
objective: 'lead_generation', serviceType: 'storm', status: 'active',
|
||||
budgetDaily: 120, budgetTotal: 3600, startAt: '2026-06-01', endAt: '2026-06-30',
|
||||
playbook: 'Storm Inspection Nurture', approvalStatus: 'approved',
|
||||
serviceArea: 'Plano, TX 75023 / 75025 (hail-hit ZIPs)',
|
||||
},
|
||||
{
|
||||
id: 'camp_2', blueprintId: 'bp_retail', platform: 'google', name: 'Roof Replacement — North Dallas Search',
|
||||
objective: 'lead_generation', serviceType: 'replacement', status: 'active',
|
||||
budgetDaily: 95, budgetTotal: 2850, startAt: '2026-05-20', endAt: '2026-06-30',
|
||||
playbook: 'Retail Replacement Nurture', approvalStatus: 'approved',
|
||||
serviceArea: 'Plano / Allen / Frisco, TX',
|
||||
},
|
||||
{
|
||||
id: 'camp_3', blueprintId: 'bp_commercial', platform: 'linkedin', name: 'Commercial Bid — Property Managers',
|
||||
objective: 'lead_generation', serviceType: 'commercial', status: 'active',
|
||||
budgetDaily: 70, budgetTotal: 2100, startAt: '2026-05-25', endAt: '2026-07-15',
|
||||
playbook: 'Commercial Bid Nurture', approvalStatus: 'approved',
|
||||
serviceArea: 'DFW Metro commercial corridors',
|
||||
},
|
||||
{
|
||||
id: 'camp_4', blueprintId: 'bp_emergency', platform: 'google', name: 'Emergency Leak — Always On',
|
||||
objective: 'lead_generation', serviceType: 'emergency', status: 'active',
|
||||
budgetDaily: 45, budgetTotal: 1350, startAt: '2026-05-01', endAt: '2026-07-31',
|
||||
playbook: 'Emergency Dispatch Nurture', approvalStatus: 'approved',
|
||||
serviceArea: 'Plano + 15mi radius',
|
||||
},
|
||||
{
|
||||
id: 'camp_5', blueprintId: 'bp_review', platform: 'nextdoor', name: 'Neighborhood Review Flywheel',
|
||||
objective: 'engagement', serviceType: 'referral', status: 'paused',
|
||||
budgetDaily: 25, budgetTotal: 750, startAt: '2026-05-10', endAt: '2026-06-30',
|
||||
playbook: 'Completion Promoter Path', approvalStatus: 'approved',
|
||||
serviceArea: 'Completed-job neighborhoods',
|
||||
},
|
||||
{
|
||||
id: 'camp_6', blueprintId: 'bp_unsold', platform: 'meta', name: 'Unsold Estimate Retarget — Q2',
|
||||
objective: 'retargeting', serviceType: 'replacement', status: 'draft',
|
||||
budgetDaily: 40, budgetTotal: 1200, startAt: '2026-06-15', endAt: '2026-07-15',
|
||||
playbook: 'Unsold Estimate Warm Nurture', approvalStatus: 'pending',
|
||||
serviceArea: 'CRM audience: unsold estimates < 90 days',
|
||||
},
|
||||
{
|
||||
// LinkedIn has no full-publish API (ingest/report only), so this draft surfaces
|
||||
// the "Manual setup" checklist instead of "Publish". Approved → can go live.
|
||||
id: 'camp_7', blueprintId: 'bp_commercial', platform: 'linkedin', name: 'Commercial Roofing — Property Managers (Draft)',
|
||||
objective: 'lead_generation', serviceType: 'commercial', status: 'draft',
|
||||
budgetDaily: 60, budgetTotal: 1800, startAt: '2026-06-20', endAt: '2026-07-20',
|
||||
playbook: 'Commercial Bid Nurture', approvalStatus: 'approved',
|
||||
serviceArea: 'DFW commercial corridors — property managers',
|
||||
creativeId: 'cre_4', formId: 'form_2',
|
||||
},
|
||||
];
|
||||
|
||||
// ── ad_creatives ─────────────────────────────────────────────────────────────
|
||||
export const creatives = [
|
||||
{
|
||||
id: 'cre_1', campaignId: 'camp_1', platform: 'meta', aiGenerated: true,
|
||||
headline: 'Hail in Plano? Document the damage.',
|
||||
body: 'Recent hail or wind? A free roof inspection documents visible damage, ventilation concerns, and your repair/replacement options. Request an inspection window today.',
|
||||
description: 'Free inspection • Local crew • Insurance-savvy',
|
||||
approvalStatus: 'approved', policyStatus: 'clear', policyFlags: [],
|
||||
},
|
||||
{
|
||||
id: 'cre_2', campaignId: 'camp_1', platform: 'meta', aiGenerated: true,
|
||||
headline: 'Storm rolled through. Is your roof okay?',
|
||||
body: 'Our local team checks for hail bruising, lifted shingles, and ventilation issues — then walks you through options. No pressure, just facts.',
|
||||
description: 'Same-week appointments',
|
||||
approvalStatus: 'pending', policyStatus: 'review',
|
||||
policyFlags: ['Verify "no pressure" claim against approved messaging'],
|
||||
},
|
||||
{
|
||||
id: 'cre_3', campaignId: 'camp_2', platform: 'google', aiGenerated: true,
|
||||
headline: 'Roof Replacement Quotes — Plano',
|
||||
body: 'Compare Good / Better / Best options with transparent pricing and financing. Free, no-obligation quote.',
|
||||
description: 'Financing available',
|
||||
approvalStatus: 'approved', policyStatus: 'clear', policyFlags: [],
|
||||
},
|
||||
{
|
||||
id: 'cre_4', campaignId: 'camp_3', platform: 'linkedin', aiGenerated: false,
|
||||
headline: 'Commercial Roof Assessment + Bid Package',
|
||||
body: 'Request a site review and receive a decision-maker checklist before your meeting. Capability statement and COI on request.',
|
||||
description: 'For property managers & facilities',
|
||||
approvalStatus: 'approved', policyStatus: 'clear', policyFlags: [],
|
||||
},
|
||||
{
|
||||
id: 'cre_5', campaignId: 'camp_6', platform: 'meta', aiGenerated: true,
|
||||
headline: 'Your roof quote is about to expire',
|
||||
body: 'Lock in this season\'s pricing before material costs jump 18%. Limited inspection slots left this week!',
|
||||
description: 'Act now — prices rising',
|
||||
approvalStatus: 'blocked', policyStatus: 'blocked',
|
||||
policyFlags: ['Unverified discount / price-jump claim', 'Deceptive scarcity ("limited slots")'],
|
||||
},
|
||||
];
|
||||
|
||||
// ── lead_forms (+ inline field_mappings) ──────────────────────────────────────
|
||||
export const leadForms = [
|
||||
{
|
||||
id: 'form_1', campaignId: 'camp_1', platform: 'meta', platformFormId: 'fb_form_88231',
|
||||
title: 'Storm Inspection Request', status: 'live',
|
||||
consentText: 'By submitting, I agree LynkedUp Roofing may contact me by call, text, and email about my inspection. Msg/data rates may apply. Reply STOP to opt out.',
|
||||
privacyPolicyUrl: 'https://lynkeduppro.com/privacy',
|
||||
thankYou: 'We received your request. Choose an appointment window or watch for a scheduling text.',
|
||||
fields: [
|
||||
{ source: 'full_name', target: 'contact.name', required: true, transform: 'titlecase' },
|
||||
{ source: 'phone_number', target: 'contact.phone', required: true, transform: 'e164' },
|
||||
{ source: 'email', target: 'contact.email', required: true, transform: 'lowercase' },
|
||||
{ source: 'street_address', target: 'property.address', required: true, transform: 'none' },
|
||||
{ source: 'active_leak', target: 'lead.urgencyFlag', required: false, transform: 'bool' },
|
||||
{ source: 'owner_or_tenant', target: 'lead.role', required: true, transform: 'none' },
|
||||
{ source: 'preferred_window', target: 'appointment.preferredWindow', required: false, transform: 'none' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'form_2', campaignId: 'camp_3', platform: 'linkedin', platformFormId: 'li_form_5521',
|
||||
title: 'Commercial Bid Request', status: 'live',
|
||||
consentText: 'I agree to be contacted regarding a commercial roofing assessment. LynkedUp will store my details per its privacy policy.',
|
||||
privacyPolicyUrl: 'https://lynkeduppro.com/privacy',
|
||||
thankYou: 'Thanks — a commercial estimator will reach out with a decision-maker checklist.',
|
||||
fields: [
|
||||
{ source: 'company', target: 'contact.company', required: true, transform: 'none' },
|
||||
{ source: 'job_title', target: 'lead.role', required: true, transform: 'none' },
|
||||
{ source: 'work_email', target: 'contact.email', required: true, transform: 'lowercase' },
|
||||
{ source: 'property_type', target: 'lead.propertyType', required: true, transform: 'none' },
|
||||
{ source: 'building_count', target: 'lead.buildingCount', required: false, transform: 'int' },
|
||||
{ source: 'decision_timeline', target: 'lead.timeline', required: false, transform: 'none' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'form_3', campaignId: 'camp_4', platform: 'google', platformFormId: 'ggl_lf_70012',
|
||||
title: 'Emergency Leak — Get Help Now', status: 'live',
|
||||
consentText: 'I consent to urgent contact by phone and text about my roof emergency. Reply STOP to opt out.',
|
||||
privacyPolicyUrl: 'https://lynkeduppro.com/privacy',
|
||||
thankYou: 'Help is on the way — keep your phone handy, our dispatcher will call shortly.',
|
||||
fields: [
|
||||
{ source: 'name', target: 'contact.name', required: true, transform: 'titlecase' },
|
||||
{ source: 'phone', target: 'contact.phone', required: true, transform: 'e164' },
|
||||
{ source: 'address', target: 'property.address', required: true, transform: 'none' },
|
||||
{ source: 'active_leak', target: 'lead.urgencyFlag', required: true, transform: 'bool' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ── ad_leads ──────────────────────────────────────────────────────────────────
|
||||
// triage: { serviceType, urgency, role, intent, quality (0-100), confidence (0-1) }
|
||||
// stage : lifecycle position (spec §5/§10.1) — lead → appointment → showed →
|
||||
// estimate → won | lost. Advancing a stage emits a conversion_event and
|
||||
// live-updates the dashboard KPIs. dealValue = estimate/won $; premium =
|
||||
// won with a premium package; firstContactSec = speed-to-lead.
|
||||
export const leads = [
|
||||
{
|
||||
id: 'lead_1', platform: 'meta', campaignId: 'camp_1', formId: 'form_1',
|
||||
externalLeadId: 'fbl_99120', receivedAt: '2026-06-10T13:42:00Z', status: 'new', stage: 'lead',
|
||||
name: 'Marcus Webb', phone: '(972) 555-0188', email: 'm.webb@example.com',
|
||||
address: '2612 Dunwick Dr, Plano, TX 75023',
|
||||
consent: true, role: 'homeowner', activeLeak: false,
|
||||
triage: { serviceType: 'storm', urgency: 'medium', role: 'homeowner', intent: 'inspection', quality: 82, confidence: 0.91 },
|
||||
duplicateOf: null, assignedTo: null, dealValue: 0, premium: false, firstContactSec: null,
|
||||
slaSecondsAgo: 220, // storm target 300s → ~80s left (ticking toward breach)
|
||||
},
|
||||
{
|
||||
id: 'lead_2', platform: 'google', campaignId: 'camp_4', formId: 'form_3',
|
||||
externalLeadId: 'ggl_44021', receivedAt: '2026-06-10T13:05:00Z', status: 'new', stage: 'lead',
|
||||
name: 'Priya Nair', phone: '(469) 555-0143', email: 'priya.nair@example.com',
|
||||
address: '6609 Phoenix Pl, Plano, TX 75023',
|
||||
consent: true, role: 'homeowner', activeLeak: true,
|
||||
triage: { serviceType: 'emergency', urgency: 'high', role: 'homeowner', intent: 'emergency', quality: 95, confidence: 0.97 },
|
||||
duplicateOf: null, assignedTo: null, dealValue: 0, premium: false, firstContactSec: null,
|
||||
slaSecondsAgo: 700, // emergency target 600s → already breached (+100s)
|
||||
},
|
||||
{
|
||||
id: 'lead_3', platform: 'linkedin', campaignId: 'camp_3', formId: 'form_2',
|
||||
externalLeadId: 'lil_30221', receivedAt: '2026-06-10T11:20:00Z', status: 'assigned', stage: 'appointment',
|
||||
name: 'Dana Cole', phone: '(214) 555-0177', email: 'dcole@summitproperties.com',
|
||||
address: 'Summit Properties, 1200 Legacy Dr, Plano, TX',
|
||||
consent: true, role: 'property_manager', activeLeak: false,
|
||||
triage: { serviceType: 'commercial', urgency: 'low', role: 'property_manager', intent: 'bid', quality: 88, confidence: 0.89 },
|
||||
duplicateOf: null, assignedTo: 'rep_jordan', dealValue: 0, premium: false, firstContactSec: 240,
|
||||
},
|
||||
{
|
||||
id: 'lead_4', platform: 'meta', campaignId: 'camp_1', formId: 'form_1',
|
||||
externalLeadId: 'fbl_99131', receivedAt: '2026-06-10T10:58:00Z', status: 'review', stage: 'lead',
|
||||
name: 'Marcus W.', phone: '(972) 555-0188', email: 'm.webb@example.com',
|
||||
address: '2612 Dunwick Dr, Plano TX',
|
||||
consent: true, role: 'homeowner', activeLeak: false,
|
||||
triage: { serviceType: 'storm', urgency: 'medium', role: 'homeowner', intent: 'inspection', quality: 60, confidence: 0.42 },
|
||||
duplicateOf: 'lead_1', assignedTo: null, dealValue: 0, premium: false, firstContactSec: null,
|
||||
},
|
||||
{
|
||||
id: 'lead_5', platform: 'reddit', campaignId: null, formId: null,
|
||||
externalLeadId: 'rdl_1102', receivedAt: '2026-06-09T18:30:00Z', status: 'new', stage: 'lead',
|
||||
name: 'Anon User', phone: '', email: 'roofcurious@example.com',
|
||||
address: 'ZIP 75025',
|
||||
consent: false, role: 'homeowner', activeLeak: false,
|
||||
triage: { serviceType: 'replacement', urgency: 'low', role: 'homeowner', intent: 'research', quality: 35, confidence: 0.55 },
|
||||
duplicateOf: null, assignedTo: null, dealValue: 0, premium: false, firstContactSec: null,
|
||||
},
|
||||
{
|
||||
id: 'lead_6', platform: 'google', campaignId: 'camp_2', formId: null,
|
||||
externalLeadId: 'ggl_44090', receivedAt: '2026-06-09T16:12:00Z', status: 'assigned', stage: 'won',
|
||||
name: 'Helen Ortiz', phone: '(972) 555-0210', email: 'helen.ortiz@example.com',
|
||||
address: '3913 Arizona Pl, Plano, TX 75023',
|
||||
consent: true, role: 'homeowner', activeLeak: false,
|
||||
triage: { serviceType: 'replacement', urgency: 'medium', role: 'homeowner', intent: 'quote', quality: 78, confidence: 0.84 },
|
||||
duplicateOf: null, assignedTo: 'rep_sam', dealValue: 28100, premium: true, firstContactSec: 180,
|
||||
},
|
||||
{
|
||||
id: 'lead_7', platform: 'meta', campaignId: 'camp_1', formId: 'form_1',
|
||||
externalLeadId: 'fbl_99140', receivedAt: '2026-06-08T15:20:00Z', status: 'assigned', stage: 'showed',
|
||||
name: 'Greg Tanaka', phone: '(972) 555-0233', email: 'greg.tanaka@example.com',
|
||||
address: '2608 Dunwick Dr, Plano, TX 75023',
|
||||
consent: true, role: 'homeowner', activeLeak: false,
|
||||
triage: { serviceType: 'storm', urgency: 'medium', role: 'homeowner', intent: 'inspection', quality: 80, confidence: 0.9 },
|
||||
duplicateOf: null, assignedTo: 'rep_jordan', dealValue: 0, premium: false, firstContactSec: 300,
|
||||
},
|
||||
{
|
||||
id: 'lead_8', platform: 'google', campaignId: 'camp_2', formId: null,
|
||||
externalLeadId: 'ggl_44120', receivedAt: '2026-06-07T12:10:00Z', status: 'assigned', stage: 'estimate',
|
||||
name: 'Lena Brooks', phone: '(469) 555-0299', email: 'lena.brooks@example.com',
|
||||
address: '3917 Arizona Pl, Plano, TX 75023',
|
||||
consent: true, role: 'homeowner', activeLeak: false,
|
||||
triage: { serviceType: 'replacement', urgency: 'medium', role: 'homeowner', intent: 'quote', quality: 85, confidence: 0.88 },
|
||||
duplicateOf: null, assignedTo: 'rep_sam', dealValue: 31200, premium: false, firstContactSec: 220,
|
||||
},
|
||||
];
|
||||
|
||||
// ── ad-to-nurture rules (spec §5, §8) ──────────────────────────────────────────
|
||||
export const nurtureRules = [
|
||||
{
|
||||
id: 'rule_1', priority: 1,
|
||||
when: { platform: 'any', serviceType: 'emergency' },
|
||||
then: { playbook: 'Emergency Dispatch Nurture', rep: 'On-call dispatcher', speedToLead: 'WhatsApp + call < 10 min', education: 'Leak mitigation tips' },
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'rule_2', priority: 2,
|
||||
when: { platform: 'linkedin', serviceType: 'commercial' },
|
||||
then: { playbook: 'Commercial Bid Nurture', rep: 'Commercial estimator', speedToLead: 'Email < 1 hr', education: 'Capability statement' },
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'rule_3', priority: 3,
|
||||
when: { platform: 'meta', serviceType: 'storm' },
|
||||
then: { playbook: 'Storm Inspection Nurture', rep: 'Field agent (territory)', speedToLead: 'Text < 5 min', education: 'Storm checklist + insurance' },
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'rule_4', priority: 4,
|
||||
when: { platform: 'google', serviceType: 'replacement' },
|
||||
then: { playbook: 'Retail Replacement Nurture', rep: 'Inside sales', speedToLead: 'Call < 5 min', education: 'Good/Better/Best + financing' },
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'rule_5', priority: 99,
|
||||
when: { platform: 'any', serviceType: 'any' },
|
||||
then: { playbook: 'Default Inbound Lead Nurture', rep: 'Round-robin', speedToLead: 'Text < 15 min', education: 'Company intro' },
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
// ── audience_segments ──────────────────────────────────────────────────────────
|
||||
export const audiences = [
|
||||
{
|
||||
id: 'aud_1', name: 'Plano hail-hit neighborhoods', segmentType: 'storm_geo',
|
||||
totalCount: 4820, consentCount: 4120, suppressionCount: 700, lastCount: '2026-06-10',
|
||||
filters: 'ZIP 75023/75025 • storm event < 30 days • residential',
|
||||
},
|
||||
{
|
||||
id: 'aud_2', name: 'Unsold estimates < 90 days', segmentType: 'crm_stage',
|
||||
totalCount: 142, consentCount: 138, suppressionCount: 4, lastCount: '2026-06-10',
|
||||
filters: 'Estimate sent • not won • age < 90d • has consent',
|
||||
},
|
||||
{
|
||||
id: 'aud_3', name: 'Past customers — maintenance due', segmentType: 'reactivation',
|
||||
totalCount: 980, consentCount: 910, suppressionCount: 70, lastCount: '2026-06-09',
|
||||
filters: 'Job completed 10-14 mo ago • warranty active • opted in',
|
||||
},
|
||||
{
|
||||
id: 'aud_4', name: 'Commercial property managers (DFW)', segmentType: 'b2b',
|
||||
totalCount: 310, consentCount: 295, suppressionCount: 15, lastCount: '2026-06-08',
|
||||
filters: 'Role = property/facilities manager • DFW metro • consented',
|
||||
},
|
||||
{
|
||||
id: 'aud_5', name: 'Review promoters (NPS 9-10)', segmentType: 'referral',
|
||||
totalCount: 268, consentCount: 268, suppressionCount: 0, lastCount: '2026-06-10',
|
||||
filters: 'Completed job • NPS ≥ 9 • review-permission on file',
|
||||
},
|
||||
];
|
||||
|
||||
// ── media_assets (spec §7 media_asset_id) ──────────────────────────────────────
|
||||
// Front-end demo: no real binaries — each asset renders as a labeled gradient tile.
|
||||
// Uploaded assets add an in-memory object-URL preview at runtime.
|
||||
export const mediaAssets = [
|
||||
{ id: 'med_1', name: 'Hail-damaged shingles (closeup)', type: 'image', kind: 'storm', w: 1200, h: 1200, gradient: ['#f59e0b', '#ea580c'], photo: 'https://loremflickr.com/600/400/roof,shingles?lock=21' },
|
||||
{ id: 'med_2', name: 'Aerial roof inspection', type: 'image', kind: 'storm', w: 1200, h: 628, gradient: ['#3b82f6', '#1d4ed8'], photo: 'https://loremflickr.com/600/400/roof,aerial?lock=22' },
|
||||
{ id: 'med_3', name: 'New architectural shingle roof', type: 'image', kind: 'replacement', w: 1080, h: 1080, gradient: ['#10b981', '#047857'], photo: 'https://loremflickr.com/600/400/roof,house?lock=23' },
|
||||
{ id: 'med_4', name: 'Crew tarping an active leak', type: 'image', kind: 'emergency', w: 1200, h: 628, gradient: ['#ef4444', '#b91c1c'], photo: 'https://loremflickr.com/600/400/roof,rain?lock=24' },
|
||||
{ id: 'med_5', name: 'Commercial flat-roof survey', type: 'image', kind: 'commercial', w: 1200, h: 800, gradient: ['#8b5cf6', '#6d28d9'], photo: 'https://loremflickr.com/600/400/commercial,building?lock=25' },
|
||||
{ id: 'med_6', name: 'Branded crew + truck', type: 'image', kind: 'referral', w: 1080, h: 1080, gradient: ['#06b6d4', '#0e7490'], photo: 'https://loremflickr.com/600/400/construction,truck?lock=26' },
|
||||
];
|
||||
|
||||
// ── conversion_events (spec §10.1) ─────────────────────────────────────────────
|
||||
export const conversionEvents = [
|
||||
{ id: 'ce_1', platform: 'meta', eventName: 'LeadCreated', entityType: 'lead', entityId: 'lead_1', value: 0, currency: 'USD', dedupKey: 'meta:fbl_99120', syncStatus: 'synced', syncedAt: '2026-06-10T13:43:00Z' },
|
||||
{ id: 'ce_2', platform: 'google', eventName: 'AppointmentScheduled', entityType: 'opportunity', entityId: 'opp_2210', value: 0, currency: 'USD', dedupKey: 'opp_2210+appt_55', syncStatus: 'synced', syncedAt: '2026-06-10T09:15:00Z' },
|
||||
{ id: 'ce_3', platform: 'meta', eventName: 'EstimateSent', entityType: 'opportunity', entityId: 'opp_2188', value: 0, currency: 'USD', dedupKey: 'appt_51+2026-06-08', syncStatus: 'synced', syncedAt: '2026-06-09T17:02:00Z' },
|
||||
{ id: 'ce_4', platform: 'google', eventName: 'JobWon', entityType: 'estimate', entityId: 'est_990', value: 24850, currency: 'USD', dedupKey: 'est_990', syncStatus: 'synced', syncedAt: '2026-06-08T20:30:00Z' },
|
||||
{ id: 'ce_5', platform: 'linkedin', eventName: 'AppointmentScheduled', entityType: 'opportunity', entityId: 'opp_2241', value: 0, currency: 'USD', dedupKey: 'opp_2241+appt_60', syncStatus: 'queued', syncedAt: null },
|
||||
{ id: 'ce_6', platform: 'reddit', eventName: 'LeadCreated', entityType: 'lead', entityId: 'lead_5', value: 0, currency: 'USD', dedupKey: 'reddit:rdl_1102', syncStatus: 'failed', syncedAt: null },
|
||||
];
|
||||
|
||||
// ── compliance queue / creative_reviews (spec §8 Compliance Center) ─────────────
|
||||
export const approvals = [
|
||||
{ id: 'apr_1', type: 'creative', refId: 'cre_2', title: 'Storm creative — "no pressure"', status: 'pending', flags: ['Verify "no pressure" against approved messaging'], submittedAt: '2026-06-10T08:30:00Z', reviewer: null },
|
||||
{ id: 'apr_2', type: 'creative', refId: 'cre_5', title: 'Unsold retarget — price-jump + scarcity', status: 'blocked', flags: ['Unverified discount claim', 'Deceptive scarcity'], submittedAt: '2026-06-09T15:40:00Z', reviewer: null },
|
||||
{ id: 'apr_3', type: 'consent_text', refId: 'form_1', title: 'Storm form consent language v2', status: 'approved', flags: [], submittedAt: '2026-06-05T10:00:00Z', reviewer: 'fe@lynkeduppro.com' },
|
||||
{ id: 'apr_4', type: 'review_disclosure', refId: 'camp_5', title: 'Neighbor review creative — disclosure check', status: 'pending', flags: ['Confirm reviewer permission + FTC disclosure on file'], submittedAt: '2026-06-10T07:00:00Z', reviewer: null },
|
||||
{ id: 'apr_5', type: 'campaign', refId: 'camp_6', title: 'Unsold Estimate Retarget — publish approval', status: 'pending', flags: ['Blocked creative cre_5 must be resolved before publish'], submittedAt: '2026-06-10T09:00:00Z', reviewer: null },
|
||||
];
|
||||
|
||||
// ── campaign_analytics_daily (materialized reporting) ───────────────────────────
|
||||
// Compact 7-day series per active campaign for the Attribution Dashboard.
|
||||
export const analyticsDaily = [
|
||||
// camp_1 (Meta storm)
|
||||
{ date: '2026-06-04', campaignId: 'camp_1', platform: 'meta', spend: 118, impressions: 9200, clicks: 240, leads: 9, appointments: 4, shows: 3, wins: 1, revenue: 0 },
|
||||
{ date: '2026-06-05', campaignId: 'camp_1', platform: 'meta', spend: 121, impressions: 9800, clicks: 261, leads: 11, appointments: 5, shows: 4, wins: 1, revenue: 22400 },
|
||||
{ date: '2026-06-06', campaignId: 'camp_1', platform: 'meta', spend: 120, impressions: 8900, clicks: 233, leads: 8, appointments: 3, shows: 3, wins: 0, revenue: 0 },
|
||||
{ date: '2026-06-07', campaignId: 'camp_1', platform: 'meta', spend: 119, impressions: 9100, clicks: 248, leads: 10, appointments: 5, shows: 4, wins: 2, revenue: 46200 },
|
||||
{ date: '2026-06-08', campaignId: 'camp_1', platform: 'meta', spend: 122, impressions: 10100, clicks: 272, leads: 12, appointments: 6, shows: 5, wins: 1, revenue: 24850 },
|
||||
{ date: '2026-06-09', campaignId: 'camp_1', platform: 'meta', spend: 120, impressions: 9600, clicks: 255, leads: 10, appointments: 4, shows: 3, wins: 1, revenue: 21900 },
|
||||
{ date: '2026-06-10', campaignId: 'camp_1', platform: 'meta', spend: 60, impressions: 4700, clicks: 130, leads: 6, appointments: 2, shows: 1, wins: 0, revenue: 0 },
|
||||
// camp_2 (Google replacement)
|
||||
{ date: '2026-06-04', campaignId: 'camp_2', platform: 'google', spend: 94, impressions: 3100, clicks: 180, leads: 6, appointments: 3, shows: 2, wins: 1, revenue: 26700 },
|
||||
{ date: '2026-06-05', campaignId: 'camp_2', platform: 'google', spend: 96, impressions: 3250, clicks: 192, leads: 7, appointments: 4, shows: 3, wins: 1, revenue: 28100 },
|
||||
{ date: '2026-06-06', campaignId: 'camp_2', platform: 'google', spend: 95, impressions: 3000, clicks: 175, leads: 5, appointments: 2, shows: 2, wins: 0, revenue: 0 },
|
||||
{ date: '2026-06-07', campaignId: 'camp_2', platform: 'google', spend: 95, impressions: 3300, clicks: 200, leads: 8, appointments: 4, shows: 3, wins: 2, revenue: 51200 },
|
||||
{ date: '2026-06-08', campaignId: 'camp_2', platform: 'google', spend: 97, impressions: 3400, clicks: 210, leads: 7, appointments: 3, shows: 3, wins: 1, revenue: 25400 },
|
||||
{ date: '2026-06-09', campaignId: 'camp_2', platform: 'google', spend: 95, impressions: 3150, clicks: 188, leads: 6, appointments: 3, shows: 2, wins: 1, revenue: 23900 },
|
||||
{ date: '2026-06-10', campaignId: 'camp_2', platform: 'google', spend: 48, impressions: 1600, clicks: 95, leads: 4, appointments: 2, shows: 1, wins: 0, revenue: 0 },
|
||||
// camp_3 (LinkedIn commercial)
|
||||
{ date: '2026-06-04', campaignId: 'camp_3', platform: 'linkedin', spend: 70, impressions: 1400, clicks: 42, leads: 2, appointments: 1, shows: 1, wins: 0, revenue: 0 },
|
||||
{ date: '2026-06-05', campaignId: 'camp_3', platform: 'linkedin', spend: 68, impressions: 1350, clicks: 38, leads: 1, appointments: 1, shows: 0, wins: 0, revenue: 0 },
|
||||
{ date: '2026-06-06', campaignId: 'camp_3', platform: 'linkedin', spend: 71, impressions: 1500, clicks: 45, leads: 2, appointments: 1, shows: 1, wins: 1, revenue: 84000 },
|
||||
{ date: '2026-06-07', campaignId: 'camp_3', platform: 'linkedin', spend: 70, impressions: 1420, clicks: 40, leads: 1, appointments: 0, shows: 0, wins: 0, revenue: 0 },
|
||||
{ date: '2026-06-08', campaignId: 'camp_3', platform: 'linkedin', spend: 72, impressions: 1480, clicks: 44, leads: 3, appointments: 2, shows: 1, wins: 0, revenue: 0 },
|
||||
{ date: '2026-06-09', campaignId: 'camp_3', platform: 'linkedin', spend: 69, impressions: 1390, clicks: 39, leads: 2, appointments: 1, shows: 1, wins: 0, revenue: 0 },
|
||||
{ date: '2026-06-10', campaignId: 'camp_3', platform: 'linkedin', spend: 35, impressions: 700, clicks: 20, leads: 1, appointments: 1, shows: 0, wins: 0, revenue: 0 },
|
||||
// camp_4 (Google emergency)
|
||||
{ date: '2026-06-04', campaignId: 'camp_4', platform: 'google', spend: 44, impressions: 1100, clicks: 88, leads: 4, appointments: 3, shows: 3, wins: 2, revenue: 18400 },
|
||||
{ date: '2026-06-05', campaignId: 'camp_4', platform: 'google', spend: 45, impressions: 1180, clicks: 95, leads: 5, appointments: 4, shows: 3, wins: 2, revenue: 21200 },
|
||||
{ date: '2026-06-06', campaignId: 'camp_4', platform: 'google', spend: 46, impressions: 1050, clicks: 82, leads: 3, appointments: 2, shows: 2, wins: 1, revenue: 9800 },
|
||||
{ date: '2026-06-07', campaignId: 'camp_4', platform: 'google', spend: 45, impressions: 1200, clicks: 99, leads: 5, appointments: 4, shows: 4, wins: 3, revenue: 27600 },
|
||||
{ date: '2026-06-08', campaignId: 'camp_4', platform: 'google', spend: 45, impressions: 1150, clicks: 90, leads: 4, appointments: 3, shows: 2, wins: 1, revenue: 10500 },
|
||||
{ date: '2026-06-09', campaignId: 'camp_4', platform: 'google', spend: 44, impressions: 1090, clicks: 85, leads: 4, appointments: 3, shows: 3, wins: 2, revenue: 19100 },
|
||||
{ date: '2026-06-10', campaignId: 'camp_4', platform: 'google', spend: 22, impressions: 540, clicks: 44, leads: 2, appointments: 2, shows: 1, wins: 0, revenue: 0 },
|
||||
];
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* sessionStore.js — a tiny module-level singleton that lets the PUBLIC ad
|
||||
* lead-capture page (a separate, chrome-less route with its own React tree) hand a
|
||||
* lead to the admin Social Ads Engine inbox.
|
||||
*
|
||||
* Why a singleton and not localStorage: the CRM demo deliberately avoids localStorage
|
||||
* (state is in-memory and resets on reload). This singleton lives for the SPA session,
|
||||
* so a lead submitted on /ads/lp and then viewed via /marketing#inbox (same tab) shows
|
||||
* up live — closing the spec's loop (§5) — without persisting anything to disk.
|
||||
*
|
||||
* The admin SocialAdsProvider reads getPublicLeads() when it mounts AND subscribes to
|
||||
* live updates, so a lead submitted on /ads/lp surfaces in the inbox immediately —
|
||||
* no CRM reload. Updates also bridge across browser tabs via BroadcastChannel (still
|
||||
* no localStorage), so submitting in one tab updates an Ad Engine open in another.
|
||||
*/
|
||||
|
||||
const publicLeads = [];
|
||||
|
||||
let seq = 0;
|
||||
const newId = () => `lead_pub_${Date.now().toString(36)}_${seq++}`;
|
||||
|
||||
// ── Live update bus ──────────────────────────────────────────────────────────
|
||||
const listeners = new Set();
|
||||
|
||||
/** Subscribe to new public leads; returns an unsubscribe fn. */
|
||||
export function subscribeToPublicLeads(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
function emit(lead) {
|
||||
listeners.forEach(fn => { try { fn(lead); } catch { /* listener errors must not break ingest */ } });
|
||||
}
|
||||
|
||||
// Cross-tab bridge: structured-clones the lead to other tabs. No disk persistence.
|
||||
const channel = typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel('lynkedup_ads_leads') : null;
|
||||
if (channel) {
|
||||
channel.onmessage = (e) => {
|
||||
const lead = e.data?.type === 'new_lead' ? e.data.lead : null;
|
||||
if (!lead || publicLeads.some(l => l.id === lead.id)) return;
|
||||
publicLeads.unshift(lead); // keep this tab's store in sync for later remounts
|
||||
emit(lead);
|
||||
};
|
||||
}
|
||||
|
||||
// Map a service type to a seed campaign/form so the lead has plausible attribution.
|
||||
const SERVICE_CAMPAIGN = {
|
||||
storm: { campaignId: 'camp_1', formId: 'form_1' },
|
||||
emergency: { campaignId: 'camp_4', formId: 'form_3' },
|
||||
commercial: { campaignId: 'camp_3', formId: 'form_2' },
|
||||
replacement: { campaignId: 'camp_2', formId: null },
|
||||
maintenance: { campaignId: null, formId: null },
|
||||
referral: { campaignId: 'camp_5', formId: null },
|
||||
};
|
||||
|
||||
/** Lightweight AI triage stand-in from the captured form answers (spec §6 triage). */
|
||||
function triageFromCapture({ serviceType, activeLeak, role }) {
|
||||
const urgency = activeLeak || serviceType === 'emergency' ? 'high' : 'medium';
|
||||
const intent = { storm: 'inspection', emergency: 'emergency', commercial: 'bid', replacement: 'quote' }[serviceType] || 'inquiry';
|
||||
let quality = 70;
|
||||
if (activeLeak) quality += 15;
|
||||
if (role === 'property_manager') quality += 10;
|
||||
if (serviceType === 'commercial') quality += 5;
|
||||
return { serviceType, urgency, role, intent, quality: Math.min(98, quality), confidence: 0.86 };
|
||||
}
|
||||
|
||||
/** Build a fully-shaped ad_lead from the public capture form. */
|
||||
export function buildLeadFromCapture(form) {
|
||||
const { campaignId, formId } = SERVICE_CAMPAIGN[form.serviceType] || {};
|
||||
return {
|
||||
id: newId(),
|
||||
platform: form.platform || 'meta',
|
||||
campaignId: campaignId ?? null,
|
||||
formId: formId ?? null,
|
||||
externalLeadId: `pub_${Date.now().toString(36)}`,
|
||||
receivedAt: new Date().toISOString(),
|
||||
slaStartTs: Date.now(), // SLA clock starts now (live countdown)
|
||||
status: 'new',
|
||||
stage: 'lead',
|
||||
name: form.name,
|
||||
phone: form.phone,
|
||||
email: form.email,
|
||||
address: form.address,
|
||||
consent: !!form.consent,
|
||||
role: form.role,
|
||||
activeLeak: !!form.activeLeak,
|
||||
triage: triageFromCapture(form),
|
||||
duplicateOf: null,
|
||||
assignedTo: null,
|
||||
dealValue: 0,
|
||||
premium: false,
|
||||
firstContactSec: null,
|
||||
source: 'public_landing',
|
||||
};
|
||||
}
|
||||
|
||||
export function addPublicLead(form) {
|
||||
const lead = buildLeadFromCapture(form);
|
||||
publicLeads.unshift(lead);
|
||||
emit(lead); // live update to a mounted provider
|
||||
channel?.postMessage({ type: 'new_lead', lead }); // and to the Ad Engine in other tabs
|
||||
return lead;
|
||||
}
|
||||
|
||||
/** Newest-first copy for the provider to merge on mount. */
|
||||
export function getPublicLeads() {
|
||||
return [...publicLeads];
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* sla.js — speed-to-lead SLA targets and live state (spec §10 "Speed-to-lead",
|
||||
* §9 recipe response times, §11 "Lost leads" mitigation).
|
||||
*
|
||||
* Speed-to-lead = seconds from a lead being received to the first permitted outbound
|
||||
* contact. A new, consented, non-duplicate lead is "on the clock" until it is
|
||||
* assigned (first contact). Breaching the target should create a task/alert.
|
||||
*
|
||||
* Targets come from the campaign recipes (§9):
|
||||
* emergency → human escalation within 10 min
|
||||
* high-urgency / storm / replacement → text/call within 5 min
|
||||
* commercial → email within 1 hr
|
||||
* everything else → default 15 min
|
||||
*/
|
||||
|
||||
export const SLA_TARGETS = {
|
||||
emergency: 600,
|
||||
commercial: 3600,
|
||||
storm: 300,
|
||||
replacement: 300,
|
||||
maintenance: 900,
|
||||
referral: 900,
|
||||
default: 900,
|
||||
};
|
||||
|
||||
/** Target seconds for a given lead (urgency can tighten the window). */
|
||||
export function slaTargetSec(lead) {
|
||||
const s = lead?.triage?.serviceType;
|
||||
if (s === 'emergency') return SLA_TARGETS.emergency;
|
||||
if (s === 'commercial') return SLA_TARGETS.commercial;
|
||||
if (lead?.triage?.urgency === 'high') return SLA_TARGETS.storm;
|
||||
return SLA_TARGETS[s] ?? SLA_TARGETS.default;
|
||||
}
|
||||
|
||||
/** Resolve the clock start (ms). Public leads carry slaStartTs; seed leads derive it. */
|
||||
function startMs(lead) {
|
||||
if (lead?.slaStartTs) return lead.slaStartTs;
|
||||
if (lead?.receivedAt) return Date.parse(lead.receivedAt);
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Live SLA state for a lead at a given clock time.
|
||||
* eligible=false → not on the clock (no consent, duplicate, or already contacted)
|
||||
* eligible=true → { elapsed, target, remaining, breached }
|
||||
*/
|
||||
export function slaState(lead, nowMs = Date.now()) {
|
||||
if (!lead) return { eligible: false, reason: 'unknown' };
|
||||
if (lead.status !== 'new' || lead.stage !== 'lead') return { eligible: false, reason: 'contacted' };
|
||||
if (lead.duplicateOf) return { eligible: false, reason: 'duplicate' };
|
||||
if (!lead.consent) return { eligible: false, reason: 'no_consent' };
|
||||
|
||||
const target = slaTargetSec(lead);
|
||||
const elapsed = Math.max(0, Math.round((nowMs - startMs(lead)) / 1000));
|
||||
const remaining = target - elapsed;
|
||||
return { eligible: true, elapsed, target, remaining, breached: remaining < 0 };
|
||||
}
|
||||
|
||||
/** Count leads currently breaching SLA (for banners / recommended actions). */
|
||||
export function countBreaches(leads, nowMs = Date.now()) {
|
||||
return leads.filter(l => slaState(l, nowMs).breached).length;
|
||||
}
|
||||
|
||||
/** mm:ss formatter that prefixes a sign for breach overshoot. */
|
||||
export function fmtClock(sec) {
|
||||
const sign = sec < 0 ? '+' : '';
|
||||
const a = Math.abs(sec);
|
||||
return `${sign}${Math.floor(a / 60)}:${String(a % 60).padStart(2, '0')}`;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* testLead.js — simulate a platform lead payload flowing through a form's field
|
||||
* mapping into CRM fields (spec phased C: "Test lead maps correctly into CRM fields").
|
||||
*
|
||||
* For each mapped field it generates a plausible raw value, applies the transform, runs
|
||||
* the validation rule, and reports source → transformed value → CRM target with a
|
||||
* pass/fail. Pure + deterministic — no network, no real webhook.
|
||||
*/
|
||||
|
||||
// Pick a believable raw value from the field's source/target keywords.
|
||||
function sampleRaw(field) {
|
||||
const s = `${field.source} ${field.target}`.toLowerCase();
|
||||
if (/email/.test(s)) return 'Jane.Homeowner@Example.com';
|
||||
if (/phone|callback/.test(s)) return '(972) 555-0142';
|
||||
if (/name/.test(s)) return 'jane homeowner';
|
||||
if (/address/.test(s)) return '123 Main St, Plano, TX 75023';
|
||||
if (/leak|active/.test(s)) return 'yes';
|
||||
if (/owner|tenant|role/.test(s)) return 'homeowner';
|
||||
if (/company/.test(s)) return 'Pinnacle Property Group';
|
||||
if (/title/.test(s)) return 'Facilities Manager';
|
||||
if (/property.?type|\btype\b/.test(s)) return 'Office building';
|
||||
if (/square|footage/.test(s)) return '12,000';
|
||||
if (/count|age/.test(s)) return '18';
|
||||
if (/timeline|window|when/.test(s)) return '30-60 days';
|
||||
if (/date/.test(s)) return '2026-06-08';
|
||||
if (/urgency/.test(s)) return 'high';
|
||||
if (/insurance/.test(s)) return 'Filed';
|
||||
if (/financing/.test(s)) return 'yes';
|
||||
if (/warranty/.test(s)) return 'active';
|
||||
if (/past.?customer/.test(s)) return 'yes';
|
||||
return 'Sample value';
|
||||
}
|
||||
|
||||
function applyTransform(value, t) {
|
||||
switch (t) {
|
||||
case 'titlecase': return value.replace(/\w\S*/g, w => w[0].toUpperCase() + w.slice(1).toLowerCase());
|
||||
case 'lowercase': return value.toLowerCase();
|
||||
case 'e164': { const d = value.replace(/\D/g, ''); return d ? `+1${d.slice(-10)}` : ''; }
|
||||
case 'bool': return /^(yes|true|1|y)$/i.test(value.trim()) ? 'true' : 'false';
|
||||
case 'int': { const n = parseInt(value.replace(/[^\d]/g, ''), 10); return Number.isNaN(n) ? '' : String(n); }
|
||||
case 'date': return value;
|
||||
default: return value;
|
||||
}
|
||||
}
|
||||
|
||||
function validate(field, value) {
|
||||
const rule = (field.validation || '').toLowerCase();
|
||||
if ((field.required || /required/.test(rule)) && !String(value).trim()) return 'Required value missing';
|
||||
if (/email/.test(rule) && value && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) return 'Invalid email format';
|
||||
if (/phone/.test(rule) && value && value.replace(/\D/g, '').length < 10) return 'Invalid phone number';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Run a sample lead through the form mapping. Returns { rows, ok, issues }. */
|
||||
export function runTestLead(form) {
|
||||
const rows = (form.fields || []).map(f => {
|
||||
const raw = sampleRaw(f);
|
||||
const value = applyTransform(raw, f.transform || 'none');
|
||||
const issue = validate(f, value);
|
||||
return {
|
||||
source: f.source, raw, transform: f.transform || 'none',
|
||||
value, target: f.target || '(unmapped)', required: !!f.required, valid: !issue, issue,
|
||||
};
|
||||
});
|
||||
// A row with no target is a mapping gap.
|
||||
rows.forEach(r => { if (!r.target || r.target === '(unmapped)') { r.valid = false; r.issue = r.issue || 'No CRM target set'; } });
|
||||
return { rows, ok: rows.length > 0 && rows.every(r => r.valid), issues: rows.filter(r => !r.valid).length };
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* utm.js — trackable campaign links (spec §7 `utm_links` entity).
|
||||
*
|
||||
* Mints a UTM-tagged URL to the public landing page so every ad click is attributable
|
||||
* end-to-end (source / medium / campaign / content / term → destination). Front-end
|
||||
* demo: pure string building, no shortener or backend.
|
||||
*/
|
||||
|
||||
// Use the running app's origin so the copied link opens the demo landing page; in a
|
||||
// real deployment this resolves to your public site (swap to your domain when hosting).
|
||||
const ORIGIN = (typeof window !== 'undefined' && window.location?.origin) || 'https://lynkeduppro.com';
|
||||
const BASE = `${ORIGIN}/ads/lp`;
|
||||
|
||||
// Ad medium by platform (search vs paid social vs messaging).
|
||||
const MEDIUM = {
|
||||
google: 'paid_search',
|
||||
meta: 'paid_social',
|
||||
linkedin: 'paid_social',
|
||||
reddit: 'paid_social',
|
||||
nextdoor: 'paid_social',
|
||||
whatsapp: 'messaging',
|
||||
};
|
||||
|
||||
const slug = (s) => (s || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 60);
|
||||
|
||||
/**
|
||||
* Build the tracked link + its utm_links field breakdown for a campaign.
|
||||
* Returns { url, fields }.
|
||||
*/
|
||||
export function buildUtm(campaign, { creative } = {}) {
|
||||
const source = campaign.platform;
|
||||
const medium = MEDIUM[source] || 'paid';
|
||||
const name = slug(campaign.name) || campaign.blueprintId || 'campaign';
|
||||
const content = creative ? (slug(creative.headline) || creative.id) : (campaign.creativeId || 'default');
|
||||
const term = campaign.serviceType || '';
|
||||
|
||||
const params = {
|
||||
c: campaign.blueprintId,
|
||||
src: source,
|
||||
utm_source: source,
|
||||
utm_medium: medium,
|
||||
utm_campaign: name,
|
||||
utm_content: content,
|
||||
utm_term: term,
|
||||
};
|
||||
const qs = Object.entries(params)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
|
||||
.join('&');
|
||||
|
||||
return {
|
||||
url: `${BASE}?${qs}`,
|
||||
fields: {
|
||||
campaignId: campaign.id || null,
|
||||
source,
|
||||
medium,
|
||||
campaign: name,
|
||||
content,
|
||||
term,
|
||||
destinationUrl: BASE,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* attribution.js — pure derivations for the Attribution Dashboard (spec §10).
|
||||
*
|
||||
* No state, no side effects: feed it the analyticsDaily rows and it returns the
|
||||
* source-to-close metrics that matter to a roofing tenant. Keeping this pure means
|
||||
* the dashboard and the hub home can both reuse it without duplicating math.
|
||||
*/
|
||||
|
||||
const safeDiv = (a, b) => (b > 0 ? a / b : 0);
|
||||
|
||||
/** Sum the raw daily counters across a set of rows. */
|
||||
export function totals(rows) {
|
||||
return rows.reduce(
|
||||
(acc, r) => ({
|
||||
spend: acc.spend + r.spend,
|
||||
impressions: acc.impressions + r.impressions,
|
||||
clicks: acc.clicks + r.clicks,
|
||||
leads: acc.leads + r.leads,
|
||||
appointments: acc.appointments + r.appointments,
|
||||
shows: acc.shows + r.shows,
|
||||
wins: acc.wins + r.wins,
|
||||
revenue: acc.revenue + r.revenue,
|
||||
}),
|
||||
{ spend: 0, impressions: 0, clicks: 0, leads: 0, appointments: 0, shows: 0, wins: 0, revenue: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
/** Derived ratios from a totals() object (spec §10 metric definitions). */
|
||||
export function metrics(t) {
|
||||
return {
|
||||
...t,
|
||||
cpl: safeDiv(t.spend, t.leads), // cost per lead
|
||||
costPerAppt: safeDiv(t.spend, t.appointments),
|
||||
apptRate: safeDiv(t.appointments, t.leads), // appt scheduled / valid leads
|
||||
showRate: safeDiv(t.shows, t.appointments),
|
||||
closeRate: safeDiv(t.wins, t.appointments),
|
||||
roas: safeDiv(t.revenue, t.spend), // return on ad spend
|
||||
ctr: safeDiv(t.clicks, t.impressions),
|
||||
};
|
||||
}
|
||||
|
||||
/** Group rows by a key field and return metrics per group. */
|
||||
export function metricsBy(rows, key) {
|
||||
const buckets = {};
|
||||
for (const r of rows) {
|
||||
const k = r[key];
|
||||
(buckets[k] ||= []).push(r);
|
||||
}
|
||||
return Object.entries(buckets).map(([k, rs]) => ({ key: k, ...metrics(totals(rs)) }));
|
||||
}
|
||||
|
||||
/** Time series of one numeric field, sorted by date (for line/area charts). */
|
||||
export function timeSeries(rows, field) {
|
||||
const byDate = {};
|
||||
for (const r of rows) byDate[r.date] = (byDate[r.date] || 0) + r[field];
|
||||
return Object.entries(byDate)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, value]) => ({ date: date.slice(5), value }));
|
||||
}
|
||||
|
||||
/** Source-to-close funnel stages (spec §10 / §8 hub funnel). */
|
||||
export function funnel(rows) {
|
||||
const t = totals(rows);
|
||||
return [
|
||||
{ stage: 'Leads', value: t.leads },
|
||||
{ stage: 'Appointments', value: t.appointments },
|
||||
{ stage: 'Showed', value: t.shows },
|
||||
{ stage: 'Won', value: t.wins },
|
||||
];
|
||||
}
|
||||
|
||||
// ── Live, CRM-event-driven derivations (spec §5 closed loop, §10) ──────────────
|
||||
// These read the live `leads` array (not the historical analyticsDaily table) so the
|
||||
// dashboard reflects actions taken in the Lead Inbox in real time. Analytics are
|
||||
// based on CRM events, not impression-level assumptions (spec §6).
|
||||
|
||||
// Funnel order; index lets us ask "has the lead reached at least this stage?".
|
||||
export const LIFECYCLE = ['lead', 'appointment', 'showed', 'estimate', 'won'];
|
||||
const stageRank = (s) => LIFECYCLE.indexOf(s);
|
||||
|
||||
/** A lead "counts" for the funnel unless it was merged away or had no consent path. */
|
||||
const isActive = (l) => l.status !== 'merged';
|
||||
/** Reached at least `stage` (won counts toward estimate, showed, appointment, etc.). */
|
||||
const reached = (l, stage) => l.stage !== 'lost' && stageRank(l.stage) >= stageRank(stage);
|
||||
|
||||
/** Live source-to-close counters from the leads array. */
|
||||
export function liveCounts(leads) {
|
||||
const active = leads.filter(isActive);
|
||||
const contacted = active.filter(l => l.firstContactSec != null);
|
||||
return {
|
||||
leads: active.length,
|
||||
qualified: active.filter(l => l.triage.quality >= 50).length,
|
||||
spam: active.filter(l => l.triage.quality < 40).length,
|
||||
duplicates: leads.filter(l => l.duplicateOf).length,
|
||||
appointments: active.filter(l => reached(l, 'appointment')).length,
|
||||
shows: active.filter(l => reached(l, 'showed')).length,
|
||||
estimates: active.filter(l => reached(l, 'estimate')).length,
|
||||
wins: active.filter(l => l.stage === 'won').length,
|
||||
revenue: active.reduce((s, l) => s + (l.stage === 'won' ? l.dealValue : 0), 0),
|
||||
premiumWins: active.filter(l => l.stage === 'won' && l.premium).length,
|
||||
avgSpeedToLead: contacted.length
|
||||
? Math.round(contacted.reduce((s, l) => s + l.firstContactSec, 0) / contacted.length)
|
||||
: null,
|
||||
contacted: contacted.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Live ratios (spec §10 metric definitions) derived from liveCounts. */
|
||||
export function liveMetrics(leads) {
|
||||
const c = liveCounts(leads);
|
||||
return {
|
||||
...c,
|
||||
apptRate: safeDiv(c.appointments, c.leads),
|
||||
showRate: safeDiv(c.shows, c.appointments),
|
||||
estimateRate: safeDiv(c.estimates, c.shows),
|
||||
closeRate: safeDiv(c.wins, c.estimates),
|
||||
premiumMix: safeDiv(c.premiumWins, c.wins),
|
||||
spamRate: safeDiv(c.spam, c.leads),
|
||||
};
|
||||
}
|
||||
|
||||
/** Live funnel array for the bar chart. */
|
||||
export function liveFunnel(leads) {
|
||||
const c = liveCounts(leads);
|
||||
return [
|
||||
{ stage: 'Leads', value: c.leads },
|
||||
{ stage: 'Appointments', value: c.appointments },
|
||||
{ stage: 'Showed', value: c.shows },
|
||||
{ stage: 'Estimate', value: c.estimates },
|
||||
{ stage: 'Won', value: c.wins },
|
||||
];
|
||||
}
|
||||
|
||||
/** Live metrics grouped by platform (for source-to-close by channel). */
|
||||
export function liveMetricsByPlatform(leads) {
|
||||
const buckets = {};
|
||||
for (const l of leads.filter(isActive)) (buckets[l.platform] ||= []).push(l);
|
||||
return Object.entries(buckets).map(([platform, ls]) => ({ key: platform, ...liveMetrics(ls) }));
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* AdToNurtureRules — editable "if platform/service then playbook/rep/speed-to-lead/
|
||||
* education" routing rules (spec §5, §8). Rules evaluate by priority (reorderable); a
|
||||
* catch-all default guarantees no lead is left unmapped. Deterministic rules override
|
||||
* AI triage.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
GitBranch, ArrowRight, Workflow, UserCheck, Zap, BookOpen, Power,
|
||||
Plus, Trash2, ChevronUp, ChevronDown, CalendarClock, Sparkles, CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
import { useSocialAds } from '../SocialAdsContext.jsx';
|
||||
import { PLATFORMS } from '../data/platforms.js';
|
||||
import { matchRule, recommendPath } from '../ai/nurtureAdvisor.js';
|
||||
import { Card, SectionHeader, Btn } from '../components/ui.jsx';
|
||||
|
||||
const SERVICE_TYPES = ['storm', 'replacement', 'emergency', 'commercial', 'referral', 'maintenance'];
|
||||
const SCHEDULING = ['Send scheduling link', 'Auto-offer next available slot', 'Manual booking by rep', 'Human callback within SLA', 'No scheduling (info only)'];
|
||||
const selCls = 'w-full rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-2 py-1.5 text-base sm:text-xs text-zinc-800 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-amber-500';
|
||||
const inCls = 'w-full rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-2 py-1.5 text-base sm:text-xs text-zinc-800 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-amber-500';
|
||||
|
||||
export default function AdToNurtureRules() {
|
||||
const { nurtureRules, campaigns, leadForms, toggleRule, updateRule, addRule, removeRule, moveRule, addRuleFromSuggestion } = useSocialAds();
|
||||
const sorted = [...nurtureRules].sort((a, b) => a.priority - b.priority);
|
||||
const movable = sorted.filter(r => r.priority !== 99);
|
||||
|
||||
// AI "Next best nurture selection" (spec §6).
|
||||
const [advPlatform, setAdvPlatform] = useState('google');
|
||||
const [advService, setAdvService] = useState('emergency');
|
||||
const [advice, setAdvice] = useState(null);
|
||||
|
||||
const suggest = () => {
|
||||
const m = matchRule(nurtureRules, { platform: advPlatform, serviceType: advService });
|
||||
if (m.rule && !m.isDefault) setAdvice({ covered: true, rule: m.rule });
|
||||
else setAdvice({ covered: false, ...recommendPath(advService) });
|
||||
};
|
||||
const acceptAdvice = () => {
|
||||
addRuleFromSuggestion({ platform: advPlatform, serviceType: advService }, advice.then);
|
||||
setAdvice(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader icon={GitBranch} title="Ad-to-Nurture Rules" description="Route each inbound lead to the right playbook, rep, speed-to-lead channel, and education track. Deterministic rules override AI triage.">
|
||||
<Btn icon={Plus} onClick={addRule}>Add rule</Btn>
|
||||
</SectionHeader>
|
||||
|
||||
{/* AI next-best-nurture (spec §6) */}
|
||||
<div className="rounded-xl border border-purple-200 dark:border-purple-500/20 bg-purple-50/60 dark:bg-purple-500/5 p-3 mb-4">
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex items-center gap-2 mr-auto">
|
||||
<Sparkles size={15} className="text-purple-500 shrink-0" />
|
||||
<p className="text-xs text-zinc-600 dark:text-zinc-300">Next best nurture — pick a sample lead; AI recommends a route. Deterministic rules override AI.</p>
|
||||
</div>
|
||||
<select value={advPlatform} onChange={e => setAdvPlatform(e.target.value)} className={selCls + ' !w-auto'}>
|
||||
{PLATFORMS.map(p => <option key={p.id} value={p.id}>{p.short}</option>)}
|
||||
</select>
|
||||
<select value={advService} onChange={e => setAdvService(e.target.value)} className={selCls + ' !w-auto'}>
|
||||
{SERVICE_TYPES.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<Btn size="sm" variant="outline" icon={Sparkles} onClick={suggest}>Suggest route</Btn>
|
||||
</div>
|
||||
|
||||
{advice && (advice.covered ? (
|
||||
<div className="mt-3 flex items-start gap-2 text-xs text-emerald-700 dark:text-emerald-400">
|
||||
<CheckCircle2 size={14} className="mt-0.5 shrink-0" />
|
||||
<span>Rule #{advice.rule.priority} (<b>{advice.rule.then.playbook}</b>) already routes this lead — AI deferred to your deterministic rule.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 rounded-lg bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mb-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-purple-500">AI recommended path</span>
|
||||
<Btn size="sm" variant="primary" icon={Plus} onClick={acceptAdvice}>Add as rule</Btn>
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-500 mb-2">{advice.rationale}</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-1.5 text-[11px]">
|
||||
<PathBit label="Playbook" value={advice.then.playbook} />
|
||||
<PathBit label="Rep" value={advice.then.rep} />
|
||||
<PathBit label="Speed-to-lead" value={advice.then.speedToLead} />
|
||||
<PathBit label="Education" value={advice.then.education} />
|
||||
<PathBit label="Scheduling" value={advice.then.scheduling} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{sorted.map(rule => {
|
||||
const isDefault = rule.priority === 99;
|
||||
const idx = movable.findIndex(r => r.id === rule.id);
|
||||
return (
|
||||
<Card key={rule.id} className={`!h-auto ${!rule.enabled ? 'opacity-60' : ''}`} pad="p-4">
|
||||
<div className="flex flex-col lg:flex-row lg:items-start gap-3">
|
||||
{/* Priority + reorder */}
|
||||
<div className="flex lg:flex-col items-center gap-1.5 shrink-0">
|
||||
<span className={`flex items-center justify-center w-7 h-7 rounded-lg text-xs font-bold ${isDefault ? 'bg-zinc-200 dark:bg-zinc-700 text-zinc-500' : 'bg-amber-500/15 text-amber-600 dark:text-amber-400'}`}>
|
||||
{isDefault ? '∗' : rule.priority}
|
||||
</span>
|
||||
{!isDefault && (
|
||||
<div className="flex lg:flex-col">
|
||||
<button onClick={() => moveRule(rule.id, -1)} disabled={idx === 0} className="p-0.5 text-zinc-400 hover:text-amber-500 disabled:opacity-30" title="Higher priority"><ChevronUp size={15} /></button>
|
||||
<button onClick={() => moveRule(rule.id, 1)} disabled={idx === movable.length - 1} className="p-0.5 text-zinc-400 hover:text-amber-500 disabled:opacity-30" title="Lower priority"><ChevronDown size={15} /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* WHEN */}
|
||||
<div className="lg:w-52 shrink-0">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-400 block mb-1">When</span>
|
||||
{isDefault ? (
|
||||
<p className="text-xs text-zinc-500">Any platform · any service <span className="block text-[10px] text-zinc-400">(catch-all default)</span></p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<select value={rule.when.platform} onChange={e => updateRule(rule.id, { when: { platform: e.target.value } })} className={selCls}>
|
||||
<option value="any">Any platform</option>
|
||||
{PLATFORMS.map(p => <option key={p.id} value={p.id}>{p.short}</option>)}
|
||||
</select>
|
||||
<select value={rule.when.serviceType} onChange={e => updateRule(rule.id, { when: { serviceType: e.target.value } })} className={selCls}>
|
||||
<option value="any">Any service</option>
|
||||
{SERVICE_TYPES.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<select value={rule.when.campaignId || 'any'} onChange={e => updateRule(rule.id, { when: { campaignId: e.target.value } })} className={selCls}>
|
||||
<option value="any">Any campaign</option>
|
||||
{campaigns.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
<select value={rule.when.formId || 'any'} onChange={e => updateRule(rule.id, { when: { formId: e.target.value } })} className={selCls}>
|
||||
<option value="any">Any form</option>
|
||||
{leadForms.map(f => <option key={f.id} value={f.id}>{f.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ArrowRight size={16} className="text-amber-500 shrink-0 hidden lg:block mt-6" />
|
||||
|
||||
{/* THEN */}
|
||||
<div className="flex-1 grid grid-cols-1 sm:grid-cols-2 gap-2 min-w-0">
|
||||
<ThenField icon={Workflow} label="Playbook" value={rule.then.playbook} onChange={v => updateRule(rule.id, { then: { playbook: v } })} />
|
||||
<ThenField icon={UserCheck} label="Rep" value={rule.then.rep} onChange={v => updateRule(rule.id, { then: { rep: v } })} />
|
||||
<ThenField icon={Zap} label="Speed-to-lead" value={rule.then.speedToLead} onChange={v => updateRule(rule.id, { then: { speedToLead: v } })} />
|
||||
<ThenField icon={BookOpen} label="Education" value={rule.then.education} onChange={v => updateRule(rule.id, { then: { education: v } })} />
|
||||
<ThenField icon={CalendarClock} label="Scheduling" value={rule.then.scheduling || ''} options={SCHEDULING} onChange={v => updateRule(rule.id, { then: { scheduling: v } })} />
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex lg:flex-col items-center gap-2 shrink-0">
|
||||
<button onClick={() => toggleRule(rule.id)} disabled={isDefault}
|
||||
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold transition-colors ${rule.enabled ? 'text-emerald-600 dark:text-emerald-400 bg-emerald-500/10' : 'text-zinc-400 bg-zinc-100 dark:bg-white/5'} ${isDefault ? 'cursor-not-allowed opacity-60' : 'hover:opacity-80'}`}>
|
||||
<Power size={13} /> {rule.enabled ? 'On' : 'Off'}
|
||||
</button>
|
||||
{!isDefault && (
|
||||
<button onClick={() => removeRule(rule.id)} title="Delete rule" className="p-1.5 text-zinc-400 hover:text-red-500"><Trash2 size={15} /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-zinc-400 mt-4 flex items-center gap-1">
|
||||
<GitBranch size={12} /> Rules evaluate top-down by priority. If no specific rule matches, the default inbound playbook runs and an admin is alerted.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PathBit({ label, value }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider text-zinc-400 block">{label}</span>
|
||||
<span className="text-zinc-700 dark:text-zinc-200">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThenField({ icon: Icon, label, value, onChange, options }) {
|
||||
return (
|
||||
<label className="min-w-0 block">
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider text-zinc-400 flex items-center gap-1 mb-1">{Icon && <Icon size={10} />}{label}</span>
|
||||
{options
|
||||
? (
|
||||
<select value={value} onChange={e => onChange(e.target.value)} className={inCls}>
|
||||
<option value="">—</option>
|
||||
{options.map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
)
|
||||
: <input value={value} onChange={e => onChange(e.target.value)} className={inCls} />}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* AttributionDashboard — spend, CPL, cost-per-appointment, show rate, close rate,
|
||||
* revenue, and ROAS by source/campaign (spec §8, §10). Analytics are built from CRM
|
||||
* events (the analyticsDaily series), not impression-level assumptions.
|
||||
*/
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
BarChart3, DollarSign, Users, CalendarCheck, TrendingUp, Percent, Eye, Trophy,
|
||||
Zap, FileText, Crown, Activity,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell,
|
||||
LineChart, Line, CartesianGrid, Legend,
|
||||
} from 'recharts';
|
||||
import { useSocialAds } from '../SocialAdsContext.jsx';
|
||||
import { totals, metrics, metricsBy, timeSeries, liveMetrics } from '../engine/attribution.js';
|
||||
import { getPlatform } from '../data/platforms.js';
|
||||
import { Card, KpiTile, SectionHeader, PlatformBadge } from '../components/ui.jsx';
|
||||
|
||||
const money = (n) => `$${Math.round(n).toLocaleString('en-US')}`;
|
||||
const pct = (n) => `${Math.round(n * 100)}%`;
|
||||
const mmss = (s) => s == null ? '—' : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`;
|
||||
|
||||
export default function AttributionDashboard() {
|
||||
const { analyticsDaily, campaigns, leads } = useSocialAds();
|
||||
const [view, setView] = useState('platform'); // platform | campaign
|
||||
|
||||
// Spend/ROAS = platform-reported; lead→won + rates = live CRM events (spec §6, §10).
|
||||
const reported = useMemo(() => metrics(totals(analyticsDaily)), [analyticsDaily]);
|
||||
const live = useMemo(() => liveMetrics(leads), [leads]);
|
||||
const m = useMemo(() => ({
|
||||
...live,
|
||||
spend: reported.spend,
|
||||
cpl: live.leads ? reported.spend / live.leads : 0,
|
||||
costPerAppt: live.appointments ? reported.spend / live.appointments : 0,
|
||||
roas: reported.spend ? live.revenue / reported.spend : 0,
|
||||
}), [reported, live]);
|
||||
const bySource = useMemo(() => metricsBy(analyticsDaily, view === 'platform' ? 'platform' : 'campaignId'), [analyticsDaily, view]);
|
||||
|
||||
const revenueSeries = useMemo(() => {
|
||||
// Merge revenue + spend per date for a dual-line chart.
|
||||
const rev = timeSeries(analyticsDaily, 'revenue');
|
||||
const spend = timeSeries(analyticsDaily, 'spend');
|
||||
return rev.map((r, i) => ({ date: r.date, revenue: r.value, spend: spend[i]?.value || 0 }));
|
||||
}, [analyticsDaily]);
|
||||
|
||||
const campName = (id) => campaigns.find(c => c.id === id)?.name || id;
|
||||
|
||||
// ROAS leaderboard
|
||||
const ranked = [...bySource].sort((a, b) => b.roas - a.roas);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader icon={BarChart3} title="Attribution Dashboard" description="Source-to-close economics built from CRM events — leads alone are not enough.">
|
||||
<div className="flex rounded-lg overflow-hidden border border-zinc-200 dark:border-white/10">
|
||||
{['platform', 'campaign'].map(v => (
|
||||
<button key={v} onClick={() => setView(v)}
|
||||
className={`px-3 py-1.5 text-xs font-semibold capitalize ${view === v ? 'bg-amber-500 text-white' : 'bg-white dark:bg-zinc-900 text-zinc-500'}`}>
|
||||
By {v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SectionHeader>
|
||||
|
||||
{/* Live banner */}
|
||||
<div className="flex items-center gap-2 text-[11px] text-emerald-600 dark:text-emerald-400 -mb-2">
|
||||
<Activity size={13} className="animate-pulse" />
|
||||
<span className="font-semibold">Live</span>
|
||||
<span className="text-zinc-400">— lead-to-won metrics update from CRM events as you act in the Lead Inbox. Spend is platform-reported.</span>
|
||||
</div>
|
||||
|
||||
{/* KPI row */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<KpiTile label="Spend (reported)" value={money(m.spend)} icon={DollarSign} color="amber" />
|
||||
<KpiTile label="CPL" value={money(m.cpl)} icon={Users} color="blue" sub={`${m.leads} leads`} />
|
||||
<KpiTile label="Cost / appt" value={money(m.costPerAppt)} icon={CalendarCheck} color="purple" sub={`${m.appointments} appts`} />
|
||||
<KpiTile label="ROAS" value={`${m.roas.toFixed(1)}×`} icon={TrendingUp} color="emerald" sub={money(m.revenue)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<KpiTile label="Appt rate" value={pct(m.apptRate)} icon={CalendarCheck} color="blue" />
|
||||
<KpiTile label="Show rate" value={pct(m.showRate)} icon={Eye} color="cyan" />
|
||||
<KpiTile label="Estimate rate" value={pct(m.estimateRate)} icon={FileText} color="blue" sub={`${m.estimates} sent`} />
|
||||
<KpiTile label="Close rate" value={pct(m.closeRate)} icon={Percent} color="emerald" sub={`${m.wins} won`} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<KpiTile label="Speed-to-lead" value={mmss(m.avgSpeedToLead)} icon={Zap} color="amber" sub="avg to first contact" />
|
||||
<KpiTile label="Premium mix" value={pct(m.premiumMix)} icon={Crown} color="purple" sub={`${m.premiumWins} premium`} />
|
||||
<KpiTile label="Qualified leads" value={m.qualified} icon={Users} color="emerald" sub={`spam ${pct(m.spamRate)}`} />
|
||||
<KpiTile label="Revenue (won)" value={money(m.revenue)} icon={Trophy} color="amber" />
|
||||
</div>
|
||||
|
||||
{/* Revenue vs spend */}
|
||||
<Card title="Revenue vs. spend" subtitle="Daily, all sources" icon={TrendingUp}>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<LineChart data={revenueSeries} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#71717a22" vertical={false} />
|
||||
<XAxis dataKey="date" tick={{ fill: '#71717a', fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fill: '#71717a', fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<Tooltip contentStyle={{ background: 'rgba(24,24,27,0.95)', border: '1px solid #3f3f46', borderRadius: 12, fontSize: 12 }} labelStyle={{ color: '#fff' }} formatter={(v, n) => [money(v), n === 'revenue' ? 'Revenue' : 'Spend']} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Line type="monotone" dataKey="revenue" stroke="#10b981" strokeWidth={2.5} dot={false} name="Revenue" />
|
||||
<Line type="monotone" dataKey="spend" stroke="#f59e0b" strokeWidth={2} dot={false} name="Spend" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* CPL by source */}
|
||||
<Card title={`Cost per lead by ${view}`} icon={Users}>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={bySource} margin={{ top: 5, right: 20, left: -10, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#71717a22" vertical={false} />
|
||||
<XAxis dataKey="key" tick={{ fill: '#71717a', fontSize: 10 }} axisLine={false} tickLine={false}
|
||||
tickFormatter={(k) => view === 'platform' ? (getPlatform(k)?.short || k) : campName(k).slice(0, 8)} />
|
||||
<YAxis tick={{ fill: '#71717a', fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<Tooltip cursor={{ fill: 'transparent' }} contentStyle={{ background: 'rgba(24,24,27,0.95)', border: '1px solid #3f3f46', borderRadius: 12, fontSize: 12 }}
|
||||
formatter={(v) => [money(v), 'CPL']} labelFormatter={(k) => view === 'platform' ? getPlatform(k)?.name : campName(k)} />
|
||||
<Bar dataKey="cpl" radius={[6, 6, 0, 0]} barSize={36}>
|
||||
{bySource.map((s, i) => <Cell key={i} fill={view === 'platform' ? (getPlatform(s.key)?.color || '#3b82f6') : '#3b82f6'} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
|
||||
{/* ROAS leaderboard */}
|
||||
<Card title="ROAS leaderboard" subtitle="Ranked by return — not lead count" icon={Trophy}>
|
||||
<div className="space-y-2">
|
||||
{ranked.map((s, i) => (
|
||||
<div key={s.key} className="flex items-center gap-3 px-3 py-2 rounded-lg bg-zinc-50 dark:bg-white/5">
|
||||
<span className="text-xs font-bold text-zinc-400 w-4">{i + 1}</span>
|
||||
{view === 'platform'
|
||||
? <PlatformBadge platformId={s.key} />
|
||||
: <span className="text-sm font-semibold text-zinc-700 dark:text-zinc-200 truncate flex-1">{campName(s.key)}</span>}
|
||||
<div className="flex items-center gap-3 ml-auto text-xs">
|
||||
<span className="text-zinc-400">{money(s.revenue)}</span>
|
||||
<span className={`font-mono font-bold w-12 text-right ${s.roas >= 1 ? 'text-emerald-500' : 'text-red-500'}`}>{s.roas.toFixed(1)}×</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Detail table */}
|
||||
<Card title={`Performance by ${view}`} icon={BarChart3}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left text-[10px] uppercase tracking-wider text-zinc-400 border-b border-zinc-100 dark:border-white/5">
|
||||
<th className="py-2 pr-3 font-bold">{view}</th>
|
||||
<th className="py-2 px-2 font-bold text-right">Spend</th>
|
||||
<th className="py-2 px-2 font-bold text-right">Leads</th>
|
||||
<th className="py-2 px-2 font-bold text-right">CPL</th>
|
||||
<th className="py-2 px-2 font-bold text-right">Appts</th>
|
||||
<th className="py-2 px-2 font-bold text-right">Show</th>
|
||||
<th className="py-2 px-2 font-bold text-right">Close</th>
|
||||
<th className="py-2 px-2 font-bold text-right">Revenue</th>
|
||||
<th className="py-2 pl-2 font-bold text-right">ROAS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bySource.map(s => (
|
||||
<tr key={s.key} className="border-b border-zinc-50 dark:border-white/5">
|
||||
<td className="py-2 pr-3">
|
||||
{view === 'platform' ? <PlatformBadge platformId={s.key} size="sm" /> : <span className="font-semibold text-zinc-700 dark:text-zinc-200">{campName(s.key)}</span>}
|
||||
</td>
|
||||
<td className="py-2 px-2 text-right font-mono">{money(s.spend)}</td>
|
||||
<td className="py-2 px-2 text-right font-mono">{s.leads}</td>
|
||||
<td className="py-2 px-2 text-right font-mono">{money(s.cpl)}</td>
|
||||
<td className="py-2 px-2 text-right font-mono">{s.appointments}</td>
|
||||
<td className="py-2 px-2 text-right font-mono">{pct(s.showRate)}</td>
|
||||
<td className="py-2 px-2 text-right font-mono">{pct(s.closeRate)}</td>
|
||||
<td className="py-2 px-2 text-right font-mono text-emerald-600 dark:text-emerald-400">{money(s.revenue)}</td>
|
||||
<td className={`py-2 pl-2 text-right font-mono font-bold ${s.roas >= 1 ? 'text-emerald-500' : 'text-red-500'}`}>{s.roas.toFixed(1)}×</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* AudienceSegments — reusable audiences with consent count, suppression count,
|
||||
* export/sync status, and refresh (spec §4, §8). Exports/syncs only permitted,
|
||||
* consented, suppression-filtered contacts — the count math makes that visible.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Users, RefreshCw, Download, Upload, ShieldCheck, ShieldX, UserCheck } from 'lucide-react';
|
||||
import { useSocialAds } from '../SocialAdsContext.jsx';
|
||||
import { Card, SectionHeader, Btn } from '../components/ui.jsx';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const SEGMENT_LABEL = {
|
||||
storm_geo: 'Storm geo', crm_stage: 'CRM stage', reactivation: 'Reactivation',
|
||||
b2b: 'B2B', referral: 'Referral',
|
||||
};
|
||||
|
||||
export default function AudienceSegments() {
|
||||
const { audiences, refreshAudience } = useSocialAds();
|
||||
|
||||
const exportCsv = (a) => toast.success(`Exported ${a.consentCount.toLocaleString()} consented contacts from "${a.name}" to CSV`);
|
||||
const syncAudience = (a) => toast.promise(new Promise(r => setTimeout(r, 800)), {
|
||||
loading: `Syncing "${a.name}" to platform…`,
|
||||
success: `Synced ${a.consentCount.toLocaleString()} contacts (suppression list applied)`,
|
||||
error: 'Sync failed',
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader icon={Users} title="Audience Segments" description="Reusable CRM audiences. Only consented contacts sync, and suppression lists are always applied — including for retargeting." />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{audiences.map(a => {
|
||||
const eligiblePct = Math.round((a.consentCount / a.totalCount) * 100);
|
||||
return (
|
||||
<Card key={a.id} className="!h-auto" pad="p-4">
|
||||
<div className="flex items-start justify-between mb-2 gap-2">
|
||||
<div className="min-w-0">
|
||||
<h4 className="font-bold text-sm text-zinc-900 dark:text-white">{a.name}</h4>
|
||||
<p className="text-[11px] text-zinc-400">{a.filters}</p>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-white/10 text-zinc-500 shrink-0">
|
||||
{SEGMENT_LABEL[a.segmentType] || a.segmentType}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Count breakdown */}
|
||||
<div className="grid grid-cols-3 gap-2 my-3">
|
||||
<Stat icon={Users} label="Total" value={a.totalCount} color="text-zinc-500" />
|
||||
<Stat icon={ShieldCheck} label="Consented" value={a.consentCount} color="text-emerald-500" />
|
||||
<Stat icon={ShieldX} label="Suppressed" value={a.suppressionCount} color="text-red-500" />
|
||||
</div>
|
||||
|
||||
{/* Eligible bar */}
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between text-[10px] text-zinc-400 mb-1">
|
||||
<span className="flex items-center gap-1"><UserCheck size={11} /> Eligible to sync</span>
|
||||
<span className="font-mono font-semibold">{eligiblePct}%</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-zinc-200 dark:bg-zinc-700 overflow-hidden">
|
||||
<div className="h-full bg-gradient-to-r from-emerald-400 to-emerald-500" style={{ width: `${eligiblePct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 pt-3 border-t border-zinc-100 dark:border-white/5">
|
||||
<span className="text-[10px] text-zinc-400">Updated {a.lastCount}</span>
|
||||
<div className="flex items-center gap-1.5 ml-auto">
|
||||
<Btn size="sm" variant="ghost" icon={RefreshCw} onClick={() => refreshAudience(a.id)}>Refresh</Btn>
|
||||
<Btn size="sm" variant="outline" icon={Download} onClick={() => exportCsv(a)}>CSV</Btn>
|
||||
<Btn size="sm" variant="primary" icon={Upload} onClick={() => syncAudience(a)}>Sync</Btn>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ icon: Icon, label, value, color }) {
|
||||
return (
|
||||
<div className="rounded-lg bg-zinc-50 dark:bg-white/5 p-2 text-center">
|
||||
<Icon size={14} className={`${color} mx-auto mb-0.5`} />
|
||||
<p className="text-sm font-mono font-bold text-zinc-800 dark:text-zinc-100">{value.toLocaleString()}</p>
|
||||
<p className="text-[9px] uppercase tracking-wider text-zinc-400">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,810 @@
|
||||
/**
|
||||
* CampaignBuilder — blueprint library + a guided builder that clones a recipe into a
|
||||
* campaign draft: objective, platform, service type, geography, budget, schedule,
|
||||
* offer, creative, lead form, playbook mapping, and approval workflow (spec §8).
|
||||
* Honors platform capability flags: where full-publish is unavailable, it offers a
|
||||
* manual checklist. An AI Campaign Brief Generator (spec §6) drafts a whole campaign
|
||||
* from a natural-language prompt and hands it to the builder for human approval.
|
||||
*/
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Megaphone, Plus, X, Check, ChevronRight, MapPin, DollarSign,
|
||||
Calendar, Target, Workflow, FileCheck2, ListChecks, ExternalLink,
|
||||
Sparkles, Wand2, ClipboardList, ShieldCheck, AlertTriangle, ArrowRight, Image,
|
||||
Gauge, Bell, ShieldAlert, Power, ArrowLeft, Copy, CheckCircle2, Circle, Rocket, Link2,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useSocialAds } from '../SocialAdsContext.jsx';
|
||||
import { BLUEPRINTS, BLUEPRINT_BY_ID } from '../data/blueprints.js';
|
||||
import { PLATFORMS, getPlatform, hasCapability, CAPABILITY } from '../data/platforms.js';
|
||||
import { generateVariants } from '../ai/creativeGenerator.js';
|
||||
import { generateBrief, BRIEF_EXAMPLES } from '../ai/briefGenerator.js';
|
||||
import { buildChecklist, checklistToText } from '../data/publishChecklist.js';
|
||||
import { buildUtm } from '../data/utm.js';
|
||||
import { Card, StatusChip, SectionHeader, Btn, PlatformBadge } from '../components/ui.jsx';
|
||||
|
||||
const STEPS = ['Blueprint', 'Targeting', 'Budget', 'Creative', 'Lead form', 'Mapping', 'Review'];
|
||||
|
||||
// Build the exact public landing-page URL a click on this campaign's ad would open:
|
||||
// blueprint drives the headline/offer (?c), platform sets the "Sponsored" source (?src).
|
||||
const publicFormUrl = (blueprintId, platform) =>
|
||||
`/ads/lp?c=${blueprintId}&src=${platform}`;
|
||||
|
||||
const POLICY_META = {
|
||||
clear: { color: 'text-emerald-500', label: 'Policy clear' },
|
||||
review: { color: 'text-amber-500', label: 'Needs review' },
|
||||
blocked: { color: 'text-red-500', label: 'Blocked' },
|
||||
};
|
||||
|
||||
export default function CampaignBuilder() {
|
||||
const { campaigns, createCampaign, publishCampaign, setCampaignStatus, addCreative, completeManualSetup } = useSocialAds();
|
||||
const [mode, setMode] = useState('list'); // 'list' | 'building' | 'briefing'
|
||||
const [seedDraft, setSeedDraft] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const close = () => { setMode('list'); setSeedDraft(null); };
|
||||
|
||||
// Brief → builder: persist the AI-drafted creative, then seed the wizard with the brief.
|
||||
const openFromBrief = (brief) => {
|
||||
const creative = addCreative({
|
||||
platform: brief.platform,
|
||||
serviceType: brief.serviceType,
|
||||
headline: brief.adCopy.headline,
|
||||
body: brief.adCopy.body,
|
||||
description: brief.adCopy.description,
|
||||
aiGenerated: true,
|
||||
policyStatus: brief.adCopy.policy.status,
|
||||
policyFlags: brief.adCopy.policy.flags.map(f => f.message),
|
||||
});
|
||||
setSeedDraft({
|
||||
blueprintId: brief.blueprintId,
|
||||
name: brief.prompt.slice(0, 60) || `${brief.blueprintName} — New`,
|
||||
platform: brief.platform,
|
||||
objective: brief.objective,
|
||||
serviceType: brief.serviceType,
|
||||
serviceArea: brief.geo || '',
|
||||
offer: brief.offer,
|
||||
playbook: brief.nurturePath,
|
||||
creativeId: creative.id,
|
||||
});
|
||||
setMode('building');
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader icon={Megaphone} title="Campaign Builder" description="Clone a roofing blueprint into a campaign draft, attach creative and a lead form, map it to a nurture playbook, and route it through approval.">
|
||||
{mode === 'list' && (
|
||||
<>
|
||||
<Btn variant="outline" icon={Wand2} onClick={() => setMode('briefing')}>AI brief</Btn>
|
||||
<Btn icon={Plus} onClick={() => { setSeedDraft(null); setMode('building'); }}>New campaign</Btn>
|
||||
</>
|
||||
)}
|
||||
</SectionHeader>
|
||||
|
||||
{mode === 'briefing' && <BriefPanel onClose={close} onUse={openFromBrief} />}
|
||||
{mode === 'building' && <BuilderWizard onClose={close} onCreate={createCampaign} initialDraft={seedDraft} />}
|
||||
{mode === 'list' && <CampaignList campaigns={campaigns} onPublish={publishCampaign} onStatus={setCampaignStatus} onManualComplete={completeManualSetup} />}
|
||||
|
||||
{mode === 'list' && (
|
||||
<>
|
||||
<h3 className="text-sm font-bold text-zinc-700 dark:text-zinc-300 mt-8 mb-3 flex items-center gap-2">
|
||||
<Target size={16} className="text-amber-500" /> Blueprint library
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
{BLUEPRINTS.map(bp => (
|
||||
<Card key={bp.id} className="!h-auto" pad="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-bold text-sm text-zinc-900 dark:text-white">{bp.name}</h4>
|
||||
<span className={`text-[10px] font-bold uppercase tracking-wide text-${bp.accent}-500`}>{bp.objective.replace(/_/g, ' ')}</span>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-3 line-clamp-2">{bp.adCopy}</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5 mb-2">
|
||||
{bp.bestChannels.map(c => <PlatformBadge key={c} platformId={c} size="sm" showName={false} />)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[11px] text-zinc-400 flex items-center gap-1 min-w-0 truncate"><Workflow size={11} className="shrink-0" /> {bp.nurture}</p>
|
||||
<button
|
||||
onClick={() => navigate(publicFormUrl(bp.id, bp.bestChannels[0]))}
|
||||
title="Preview the public lead-capture page for this blueprint"
|
||||
className="shrink-0 inline-flex items-center gap-1 text-[11px] font-semibold text-amber-600 dark:text-amber-400 hover:underline">
|
||||
<ExternalLink size={11} /> Preview form
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── AI Campaign Brief Generator (spec §6) ──────────────────────────────────────
|
||||
function BriefPanel({ onClose, onUse }) {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [brief, setBrief] = useState(null);
|
||||
const [thinking, setThinking] = useState(false);
|
||||
|
||||
const run = (text) => {
|
||||
const p = (text ?? prompt).trim();
|
||||
if (!p) return;
|
||||
if (text != null) setPrompt(text);
|
||||
setThinking(true);
|
||||
setBrief(null);
|
||||
setTimeout(() => { setBrief(generateBrief(p)); setThinking(false); }, 700);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="!h-auto">
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 rounded-xl bg-purple-500/10 text-purple-500"><Wand2 size={18} /></div>
|
||||
<div>
|
||||
<h3 className="font-bold text-zinc-900 dark:text-white">AI Campaign Brief</h3>
|
||||
<p className="text-[11px] text-zinc-500">Describe the campaign in plain language — AI drafts it from approved blueprints. You approve before anything is created.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 rounded-lg text-zinc-400 hover:bg-zinc-100 dark:hover:bg-white/10"><X size={18} /></button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={e => setPrompt(e.target.value)}
|
||||
rows={2}
|
||||
placeholder='e.g. "storm inspection campaign for hail-hit ZIPs in Plano"'
|
||||
className="w-full rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-3.5 py-2.5 text-base sm:text-sm text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-purple-500 resize-none"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-2">
|
||||
{BRIEF_EXAMPLES.map(ex => (
|
||||
<button key={ex} onClick={() => run(ex)}
|
||||
className="text-[11px] px-2 py-1 rounded-lg bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-purple-500/10 hover:text-purple-600 dark:hover:text-purple-400 transition-colors">
|
||||
{ex}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-3">
|
||||
<Btn icon={Sparkles} onClick={() => run()} disabled={thinking || !prompt.trim()}>
|
||||
{thinking ? 'Drafting…' : 'Generate brief'}
|
||||
</Btn>
|
||||
</div>
|
||||
|
||||
{brief && <BriefSummary brief={brief} onUse={onUse} onRegenerate={() => run()} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function BriefSummary({ brief, onUse, onRegenerate }) {
|
||||
const policy = POLICY_META[brief.adCopy.policy.status] || POLICY_META.review;
|
||||
return (
|
||||
<div className="mt-4 pt-4 border-t border-zinc-100 dark:border-white/5 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide text-zinc-400">Drafted brief</span>
|
||||
<StatusChip status="draft" label={brief.blueprintName} />
|
||||
<PlatformBadge platformId={brief.platform} size="sm" />
|
||||
<span className="text-[11px] font-semibold text-zinc-500">{brief.objectiveLabel}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<BriefBlock icon={Target} title="Audience notes">{brief.audienceNotes}</BriefBlock>
|
||||
<BriefBlock icon={Megaphone} title="Call to action">{brief.cta} · <span className="text-zinc-400">{brief.offer}</span></BriefBlock>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-zinc-200 dark:border-white/10 p-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide text-zinc-400 flex items-center gap-1"><Sparkles size={11} className="text-purple-500" /> Ad copy draft</span>
|
||||
<span className={`text-[10px] font-bold flex items-center gap-1 ${policy.color}`}><ShieldCheck size={11} /> {policy.label}</span>
|
||||
</div>
|
||||
<p className="font-bold text-sm text-zinc-900 dark:text-white">{brief.adCopy.headline}</p>
|
||||
<p className="text-xs text-zinc-600 dark:text-zinc-300 mt-0.5">{brief.adCopy.body}</p>
|
||||
{brief.adCopy.policy.flags.length > 0 && (
|
||||
<ul className="mt-1.5 space-y-0.5">
|
||||
{brief.adCopy.policy.flags.map(f => (
|
||||
<li key={f.id} className="text-[10px] text-amber-600 dark:text-amber-400/80 flex items-start gap-1"><AlertTriangle size={10} className="mt-0.5 shrink-0" /> {f.message}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<BriefBlock icon={ClipboardList} title="Suggested form fields">
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
{brief.formFields.map(q => (
|
||||
<span key={q} className="text-[11px] px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-white/10 text-zinc-600 dark:text-zinc-300">{q}</span>
|
||||
))}
|
||||
</div>
|
||||
</BriefBlock>
|
||||
<BriefBlock icon={Workflow} title="Nurture path">{brief.nurturePath}</BriefBlock>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 pt-1">
|
||||
<Btn variant="ghost" icon={Sparkles} onClick={onRegenerate}>Regenerate</Btn>
|
||||
<Btn icon={ArrowRight} onClick={() => onUse(brief)}>Open in builder</Btn>
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-400 flex items-center gap-1">
|
||||
<ShieldCheck size={12} /> AI only uses tenant-approved blueprints and offers. The draft requires your approval before it can publish.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BriefBlock({ icon: Icon, title, children }) {
|
||||
return (
|
||||
<div className="rounded-xl bg-zinc-50 dark:bg-white/5 p-3">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide text-zinc-400 flex items-center gap-1 mb-1">{Icon && <Icon size={11} className="text-amber-500" />} {title}</span>
|
||||
<div className="text-xs text-zinc-600 dark:text-zinc-300">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
['all', 'All'],
|
||||
['active', 'Active'],
|
||||
['pending', 'Pending approval'],
|
||||
['draft', 'Drafts'],
|
||||
['paused', 'Paused'],
|
||||
];
|
||||
|
||||
function CampaignList({ campaigns, onPublish, onStatus, onManualComplete }) {
|
||||
const navigate = useNavigate();
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [checklistId, setChecklistId] = useState(null);
|
||||
|
||||
const checklistCampaign = campaigns.find(c => c.id === checklistId);
|
||||
if (checklistCampaign) {
|
||||
return (
|
||||
<ManualPublishChecklist
|
||||
campaign={checklistCampaign}
|
||||
onClose={() => setChecklistId(null)}
|
||||
onComplete={() => { onManualComplete(checklistCampaign.id); setChecklistId(null); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const matches = (c) => {
|
||||
if (statusFilter === 'all') return true;
|
||||
if (statusFilter === 'pending') return c.status === 'draft' && c.approvalStatus === 'pending';
|
||||
if (statusFilter === 'draft') return c.status === 'draft' && c.approvalStatus !== 'pending';
|
||||
return c.status === statusFilter;
|
||||
};
|
||||
const counts = Object.fromEntries(STATUS_FILTERS.map(([id]) => [
|
||||
id,
|
||||
campaigns.filter(c => {
|
||||
if (id === 'all') return true;
|
||||
if (id === 'pending') return c.status === 'draft' && c.approvalStatus === 'pending';
|
||||
if (id === 'draft') return c.status === 'draft' && c.approvalStatus !== 'pending';
|
||||
return c.status === id;
|
||||
}).length,
|
||||
]));
|
||||
const visible = campaigns.filter(matches);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Status filter */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
{STATUS_FILTERS.map(([id, label]) => (
|
||||
<button key={id} onClick={() => setStatusFilter(id)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors ${statusFilter === id ? 'bg-amber-500 text-white' : 'bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10'}`}>
|
||||
{label} <span className="opacity-60">{counts[id]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{visible.length === 0 && <p className="text-sm text-zinc-500 text-center py-8">No campaigns match this filter.</p>}
|
||||
{visible.map(c => {
|
||||
const canPublish = hasCapability(c.platform, CAPABILITY.FULL_PUBLISH);
|
||||
return (
|
||||
<Card key={c.id} className="!h-auto" pad="p-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<PlatformBadge platformId={c.platform} showName={false} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="font-bold text-sm text-zinc-900 dark:text-white truncate">{c.name}</h4>
|
||||
<StatusChip status={c.status} />
|
||||
<StatusChip status={c.approvalStatus} label={`${c.approvalStatus} approval`} />
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 mt-0.5">
|
||||
{c.serviceArea} · ${c.budgetDaily}/day · {c.startAt} → {c.endAt} · {c.playbook}
|
||||
</p>
|
||||
{c.guardrails && (
|
||||
<p className="text-[11px] text-zinc-400 mt-0.5 flex items-center gap-1">
|
||||
<Gauge size={11} className="text-amber-500 shrink-0" />
|
||||
Caps ${c.guardrails.weeklyCap.toLocaleString()}/wk · ${c.guardrails.monthlyCap.toLocaleString()}/mo · CPL alert > ${c.guardrails.cplThreshold}
|
||||
{(c.guardrails.pauseOnDailyCap || c.guardrails.pauseOnCplBreach || c.guardrails.anomalyDetection) && ' · auto-pause on'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
||||
<Btn size="sm" variant="ghost" icon={ExternalLink}
|
||||
title="Open the public lead-capture page this campaign's ad links to"
|
||||
onClick={() => navigate(publicFormUrl(c.blueprintId, c.platform))}>
|
||||
Preview form
|
||||
</Btn>
|
||||
<Btn size="sm" variant="ghost" icon={Link2}
|
||||
title="Copy this campaign's tracked UTM link"
|
||||
onClick={() => copyToClipboard(buildUtm(c).url, 'Tracked link copied')}>
|
||||
Copy link
|
||||
</Btn>
|
||||
{c.status === 'active'
|
||||
? <Btn size="sm" variant="outline" onClick={() => onStatus(c.id, 'paused')}>Pause</Btn>
|
||||
: c.status === 'paused'
|
||||
? <Btn size="sm" variant="success" onClick={() => onStatus(c.id, 'active')}>Resume</Btn>
|
||||
: canPublish
|
||||
? <Btn size="sm" variant="primary" icon={FileCheck2} onClick={() => onPublish(c.id)}>Publish</Btn>
|
||||
: <Btn size="sm" variant="outline" icon={ListChecks} onClick={() => setChecklistId(c.id)}>Manual setup</Btn>}
|
||||
</div>
|
||||
</div>
|
||||
{c.status === 'draft' && !canPublish && (
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400/80 mt-2 flex items-center gap-1">
|
||||
<ListChecks size={12} /> {getPlatform(c.platform)?.short} has no full-publish API — generate a platform-ready setup checklist instead.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Manual-publish setup checklist (spec §5/§11) ───────────────────────────────
|
||||
function ManualPublishChecklist({ campaign, onClose, onComplete }) {
|
||||
const { creatives, leadForms } = useSocialAds();
|
||||
const creative = creatives.find(c => c.id === campaign.creativeId);
|
||||
const form = leadForms.find(f => f.id === campaign.formId);
|
||||
const checklist = useMemo(() => buildChecklist(campaign, { creative, form }), [campaign, creative, form]);
|
||||
const [done, setDone] = useState({});
|
||||
|
||||
const completedCount = checklist.steps.filter((_, i) => done[i]).length;
|
||||
const allDone = completedCount === checklist.steps.length;
|
||||
const approved = campaign.approvalStatus === 'approved';
|
||||
|
||||
const copyInstructions = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(checklistToText(campaign, checklist));
|
||||
toast.success('Setup instructions copied to clipboard');
|
||||
} catch {
|
||||
toast.error('Could not copy — select and copy manually');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="!h-auto">
|
||||
<div className="flex items-start justify-between gap-3 mb-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Btn size="sm" variant="ghost" icon={ArrowLeft} onClick={onClose}>Back</Btn>
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-bold text-zinc-900 dark:text-white truncate flex items-center gap-2">
|
||||
<ListChecks size={18} className="text-amber-500 shrink-0" /> Manual setup · {campaign.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<PlatformBadge platformId={campaign.platform} size="sm" />
|
||||
<span className="text-[11px] text-zinc-400">No full-publish API — set up by hand, then mark live.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Btn size="sm" variant="outline" icon={Copy} onClick={copyInstructions}>Copy</Btn>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20 p-3 mb-4">
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300/90 flex items-start gap-1.5">
|
||||
<AlertTriangle size={13} className="mt-0.5 shrink-0" /> {checklist.tip}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{checklist.steps.map((s, i) => {
|
||||
const checked = !!done[i];
|
||||
return (
|
||||
<button key={i} type="button" onClick={() => setDone(d => ({ ...d, [i]: !d[i] }))}
|
||||
className={`w-full text-left flex items-start gap-3 p-3 rounded-xl border transition-colors ${checked ? 'border-emerald-300 dark:border-emerald-500/30 bg-emerald-50 dark:bg-emerald-500/10' : 'border-zinc-200 dark:border-white/10 hover:bg-zinc-50 dark:hover:bg-white/5'}`}>
|
||||
{checked
|
||||
? <CheckCircle2 size={18} className="text-emerald-500 shrink-0 mt-0.5" />
|
||||
: <Circle size={18} className="text-zinc-300 dark:text-zinc-600 shrink-0 mt-0.5" />}
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">{i + 1}. {s.title}</p>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5 whitespace-pre-line">{s.detail}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mt-5 pt-4 border-t border-zinc-100 dark:border-white/5">
|
||||
<span className="text-xs text-zinc-500">{completedCount}/{checklist.steps.length} steps done{!approved && ' · approval required to go live'}</span>
|
||||
<Btn icon={Rocket} disabled={!approved} title={approved ? 'Mark the campaign live' : 'Approve in Compliance first'} onClick={onComplete}>
|
||||
{allDone ? 'Mark setup complete & go live' : 'Mark setup complete'}
|
||||
</Btn>
|
||||
</div>
|
||||
{!approved && (
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400/80 mt-2 flex items-center gap-1">
|
||||
<ShieldAlert size={12} /> This campaign is awaiting approval — approve it in the Compliance Center before marking setup complete.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function BuilderWizard({ onClose, onCreate, initialDraft }) {
|
||||
const { creatives, leadForms, addCreative } = useSocialAds();
|
||||
const [step, setStep] = useState(0);
|
||||
const [draft, setDraft] = useState(() => ({
|
||||
blueprintId: '', name: '', platform: '', objective: '', serviceType: '',
|
||||
serviceArea: '', budgetDaily: 75, budgetTotal: 2250,
|
||||
startAt: '2026-06-15', endAt: '2026-07-15', playbook: '',
|
||||
creativeId: '', formId: '',
|
||||
// Spend guardrails — hard caps + alert thresholds set before activation (spec §3/§4).
|
||||
guardrails: {
|
||||
weeklyCap: 525, monthlyCap: 2250, cplThreshold: 120, alertPct: 80,
|
||||
pauseOnDailyCap: true, pauseOnCplBreach: false, anomalyDetection: true,
|
||||
},
|
||||
...(initialDraft || {}),
|
||||
}));
|
||||
const [variants, setVariants] = useState([]);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
const set = (patch) => setDraft(d => ({ ...d, ...patch }));
|
||||
const setG = (patch) => setDraft(d => ({ ...d, guardrails: { ...d.guardrails, ...patch } }));
|
||||
const bp = BLUEPRINT_BY_ID[draft.blueprintId];
|
||||
const g = draft.guardrails;
|
||||
|
||||
const pickBlueprint = (b) => set({
|
||||
blueprintId: b.id, objective: b.objective, serviceType: b.serviceType,
|
||||
platform: b.bestChannels[0], playbook: b.nurture, offer: b.offer,
|
||||
name: draft.name || `${b.name} — New`,
|
||||
creativeId: '', formId: '',
|
||||
});
|
||||
|
||||
const creativesForPlatform = creatives.filter(c => c.platform === draft.platform);
|
||||
const formsForPlatform = leadForms.filter(f => f.platform === draft.platform);
|
||||
const selectedCreative = creatives.find(c => c.id === draft.creativeId);
|
||||
const selectedForm = leadForms.find(f => f.id === draft.formId);
|
||||
|
||||
const genVariants = () => {
|
||||
setGenerating(true);
|
||||
setTimeout(() => {
|
||||
setVariants(generateVariants({ platformId: draft.platform, serviceType: draft.serviceType || 'storm', city: draft.serviceArea || 'your area', count: 3 }));
|
||||
setGenerating(false);
|
||||
}, 600);
|
||||
};
|
||||
|
||||
const applyVariant = (v) => {
|
||||
const c = addCreative({
|
||||
platform: draft.platform, serviceType: draft.serviceType,
|
||||
headline: v.headline, body: v.body, description: v.description,
|
||||
aiGenerated: true, policyStatus: v.policy.status,
|
||||
policyFlags: v.policy.flags.map(f => f.message),
|
||||
});
|
||||
set({ creativeId: c.id });
|
||||
setVariants([]);
|
||||
};
|
||||
|
||||
const canNext = [
|
||||
() => !!draft.blueprintId,
|
||||
() => !!draft.platform && !!draft.serviceArea,
|
||||
() => draft.budgetDaily > 0 && !!draft.startAt && !!draft.endAt,
|
||||
() => !!draft.creativeId,
|
||||
() => true, // lead form is optional (some blueprints reuse a CRM audience)
|
||||
() => !!draft.playbook,
|
||||
() => true,
|
||||
][step]();
|
||||
|
||||
const finish = () => { onCreate(draft); onClose(); };
|
||||
|
||||
return (
|
||||
<Card className="!h-auto">
|
||||
{/* Stepper */}
|
||||
<div className="flex items-center justify-between mb-5 gap-1">
|
||||
{STEPS.map((s, i) => (
|
||||
<React.Fragment key={s}>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className={`flex items-center justify-center w-6 h-6 rounded-full text-[11px] font-bold shrink-0 ${i < step ? 'bg-emerald-500 text-white' : i === step ? 'bg-amber-500 text-white' : 'bg-zinc-200 dark:bg-zinc-700 text-zinc-500'}`}>
|
||||
{i < step ? <Check size={13} /> : i + 1}
|
||||
</span>
|
||||
<span className={`text-[11px] font-semibold truncate hidden md:block ${i === step ? 'text-zinc-900 dark:text-white' : 'text-zinc-400'}`}>{s}</span>
|
||||
</div>
|
||||
{i < STEPS.length - 1 && <div className="flex-1 h-px bg-zinc-200 dark:bg-zinc-700" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<button onClick={onClose} className="ml-2 p-1 rounded-lg text-zinc-400 hover:bg-zinc-100 dark:hover:bg-white/10"><X size={18} /></button>
|
||||
</div>
|
||||
|
||||
{/* Step body */}
|
||||
<div className="min-h-[220px]">
|
||||
{step === 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{BLUEPRINTS.map(b => (
|
||||
<button key={b.id} onClick={() => pickBlueprint(b)}
|
||||
className={`text-left p-3 rounded-xl border-2 transition-all ${draft.blueprintId === b.id ? 'border-amber-500 bg-amber-500/5' : 'border-zinc-200 dark:border-white/10 hover:border-amber-300'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-bold text-sm text-zinc-900 dark:text-white">{b.name}</span>
|
||||
{draft.blueprintId === b.id && <Check size={16} className="text-amber-500" />}
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-500 mt-1 line-clamp-2">{b.adCopy}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<Field label="Campaign name">
|
||||
<input value={draft.name} onChange={e => set({ name: e.target.value })} className={inputCls} placeholder="e.g. Plano Hail Response — July" />
|
||||
</Field>
|
||||
<Field label="Objective">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['lead_generation', 'retargeting', 'engagement', 'reactivation'].map(o => (
|
||||
<button key={o} onClick={() => set({ objective: o })}
|
||||
className={`px-3 py-1.5 rounded-lg border-2 text-xs font-semibold capitalize transition-all ${draft.objective === o ? 'border-amber-500 bg-amber-500/5' : 'border-zinc-200 dark:border-white/10'}`}>
|
||||
{o.replace(/_/g, ' ')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Platform" hint={bp ? `Recommended: ${bp.bestChannels.map(c => getPlatform(c).short).join(', ')}` : ''}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PLATFORMS.map(p => (
|
||||
<button key={p.id} onClick={() => set({ platform: p.id, creativeId: '', formId: '' })}
|
||||
className={`px-3 py-1.5 rounded-lg border-2 text-xs font-semibold transition-all ${draft.platform === p.id ? 'border-amber-500 bg-amber-500/5' : 'border-zinc-200 dark:border-white/10'}`}>
|
||||
{p.short}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Service area / geography" icon={MapPin}>
|
||||
<input value={draft.serviceArea} onChange={e => set({ serviceArea: e.target.value })} className={inputCls} placeholder="ZIP codes, city, or radius" />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Field label="Daily budget" icon={DollarSign}>
|
||||
<input type="number" value={draft.budgetDaily}
|
||||
onChange={e => {
|
||||
const v = +e.target.value;
|
||||
setDraft(d => ({ ...d, budgetDaily: v, budgetTotal: v * 30, guardrails: { ...d.guardrails, weeklyCap: v * 7, monthlyCap: v * 30 } }));
|
||||
}}
|
||||
className={inputCls} />
|
||||
</Field>
|
||||
<Field label="Total budget (≈30d)" icon={DollarSign}>
|
||||
<input type="number" value={draft.budgetTotal} onChange={e => set({ budgetTotal: +e.target.value })} className={inputCls} />
|
||||
</Field>
|
||||
<Field label="Start date" icon={Calendar}>
|
||||
<input type="date" value={draft.startAt} onChange={e => set({ startAt: e.target.value })} className={inputCls} />
|
||||
</Field>
|
||||
<Field label="End date" icon={Calendar}>
|
||||
<input type="date" value={draft.endAt} onChange={e => set({ endAt: e.target.value })} className={inputCls} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{/* Spend guardrails (spec §3/§4) — hard caps + alert thresholds before activation */}
|
||||
<div className="pt-4 border-t border-zinc-100 dark:border-white/5">
|
||||
<div className="flex items-center gap-1.5 mb-3">
|
||||
<ShieldAlert size={14} className="text-amber-500" />
|
||||
<h4 className="text-xs font-bold text-zinc-700 dark:text-zinc-300">Spend guardrails</h4>
|
||||
<span className="text-[11px] text-zinc-400">— hard caps & alerts enforced before activation</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<Field label="Weekly cap" icon={Gauge}>
|
||||
<input type="number" value={g.weeklyCap} onChange={e => setG({ weeklyCap: +e.target.value })} className={inputCls} />
|
||||
</Field>
|
||||
<Field label="Monthly cap" icon={Gauge}>
|
||||
<input type="number" value={g.monthlyCap} onChange={e => setG({ monthlyCap: +e.target.value })} className={inputCls} />
|
||||
</Field>
|
||||
<Field label="CPL threshold" icon={DollarSign}>
|
||||
<input type="number" value={g.cplThreshold} onChange={e => setG({ cplThreshold: +e.target.value })} className={inputCls} />
|
||||
</Field>
|
||||
<Field label="Alert at (% of cap)" icon={Bell}>
|
||||
<input type="number" min="1" max="100" value={g.alertPct} onChange={e => setG({ alertPct: +e.target.value })} className={inputCls} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
<ToggleRow label="Auto-pause when daily cap is reached" on={g.pauseOnDailyCap} onToggle={() => setG({ pauseOnDailyCap: !g.pauseOnDailyCap })} />
|
||||
<ToggleRow label={`Auto-pause when cost-per-lead exceeds $${g.cplThreshold}`} on={g.pauseOnCplBreach} onToggle={() => setG({ pauseOnCplBreach: !g.pauseOnCplBreach })} />
|
||||
<ToggleRow label="Anomaly detection — pause on a sudden spend spike" on={g.anomalyDetection} onToggle={() => setG({ anomalyDetection: !g.anomalyDetection })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-xs text-zinc-500 flex items-center gap-1">
|
||||
<Image size={13} className="text-amber-500" /> Attach an approved creative for {getPlatform(draft.platform)?.short || 'this platform'} — AI drafts are approval-gated.
|
||||
</p>
|
||||
<Btn size="sm" variant="outline" icon={Sparkles} onClick={genVariants} disabled={generating}>
|
||||
{generating ? 'Drafting…' : 'Generate with AI'}
|
||||
</Btn>
|
||||
</div>
|
||||
|
||||
{variants.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
{variants.map((v, i) => {
|
||||
const pm = POLICY_META[v.policy.status] || POLICY_META.review;
|
||||
return (
|
||||
<div key={i} className="rounded-xl border border-zinc-200 dark:border-white/10 p-3 flex flex-col">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide text-zinc-400">Variant {i + 1}</span>
|
||||
<span className={`text-[10px] font-bold ${pm.color}`}>{pm.label}</span>
|
||||
</div>
|
||||
<p className="font-bold text-sm text-zinc-900 dark:text-white">{v.headline}</p>
|
||||
<p className="text-xs text-zinc-500 mt-1 flex-1">{v.body}</p>
|
||||
<Btn size="sm" variant="outline" icon={Plus} className="mt-2" disabled={v.policy.status === 'blocked'} onClick={() => applyVariant(v)}>Use this</Btn>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{creativesForPlatform.length === 0 && variants.length === 0 && (
|
||||
<p className="text-xs text-zinc-400 sm:col-span-2 py-4 text-center">No creatives yet for this platform — generate one with AI above.</p>
|
||||
)}
|
||||
{creativesForPlatform.map(c => {
|
||||
const pm = POLICY_META[c.policyStatus] || POLICY_META.review;
|
||||
const selected = draft.creativeId === c.id;
|
||||
return (
|
||||
<button key={c.id} type="button" onClick={() => set({ creativeId: c.id })}
|
||||
className={`text-left p-3 rounded-xl border-2 transition-all ${selected ? 'border-amber-500 bg-amber-500/5' : 'border-zinc-200 dark:border-white/10 hover:border-amber-300'}`}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold">
|
||||
{c.aiGenerated && <Sparkles size={10} className="text-purple-500" />}
|
||||
<span className={pm.color}>{pm.label}</span>
|
||||
</span>
|
||||
{selected && <Check size={15} className="text-amber-500" />}
|
||||
</div>
|
||||
<p className="font-bold text-sm text-zinc-900 dark:text-white">{c.headline}</p>
|
||||
<p className="text-xs text-zinc-500 mt-0.5 line-clamp-2">{c.body}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-zinc-500 flex items-center gap-1">
|
||||
<ClipboardList size={13} className="text-amber-500" /> Choose the lead form this campaign sends to. Optional — leads can use the default capture form.
|
||||
</p>
|
||||
|
||||
{bp?.formQuestions && (
|
||||
<div className="rounded-xl bg-zinc-50 dark:bg-white/5 p-3">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wide text-zinc-400 mb-2">Recommended questions · {bp.name}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{bp.formQuestions.map(q => (
|
||||
<span key={q} className="text-[11px] px-2 py-0.5 rounded-full bg-white dark:bg-white/10 border border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300">{q}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button type="button" onClick={() => set({ formId: '' })}
|
||||
className={`text-left p-3 rounded-xl border-2 transition-all ${!draft.formId ? 'border-amber-500 bg-amber-500/5' : 'border-zinc-200 dark:border-white/10 hover:border-amber-300'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-bold text-sm text-zinc-900 dark:text-white">Default capture form</span>
|
||||
{!draft.formId && <Check size={15} className="text-amber-500" />}
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-400 mt-0.5">Public landing-page form with consent capture</p>
|
||||
</button>
|
||||
{formsForPlatform.map(f => {
|
||||
const selected = draft.formId === f.id;
|
||||
return (
|
||||
<button key={f.id} type="button" onClick={() => set({ formId: f.id })}
|
||||
className={`text-left p-3 rounded-xl border-2 transition-all ${selected ? 'border-amber-500 bg-amber-500/5' : 'border-zinc-200 dark:border-white/10 hover:border-amber-300'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-bold text-sm text-zinc-900 dark:text-white truncate">{f.title}</span>
|
||||
{selected && <Check size={15} className="text-amber-500 shrink-0" />}
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-400 mt-0.5">{f.fields.length} fields mapped · {f.status}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 5 && (
|
||||
<div className="space-y-4">
|
||||
<Field label="Offer">
|
||||
<input value={draft.offer || ''} onChange={e => set({ offer: e.target.value })} className={inputCls} />
|
||||
</Field>
|
||||
<Field label="Mapped nurture playbook" icon={Workflow}>
|
||||
<input value={draft.playbook} onChange={e => set({ playbook: e.target.value })} className={inputCls} />
|
||||
</Field>
|
||||
<p className="text-[11px] text-zinc-500 flex items-center gap-1">
|
||||
<Workflow size={12} /> New leads from this campaign will auto-start the mapped playbook within the speed-to-lead SLA.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 6 && (
|
||||
<div className="space-y-2 text-sm">
|
||||
{[
|
||||
['Blueprint', bp?.name], ['Campaign', draft.name],
|
||||
['Platform', getPlatform(draft.platform)?.name], ['Objective', draft.objective?.replace(/_/g, ' ')],
|
||||
['Service area', draft.serviceArea], ['Budget', `$${draft.budgetDaily}/day · $${draft.budgetTotal} total`],
|
||||
['Schedule', `${draft.startAt} → ${draft.endAt}`],
|
||||
['Spend caps', `$${g.weeklyCap.toLocaleString()}/wk · $${g.monthlyCap.toLocaleString()}/mo`],
|
||||
['CPL guardrail', `alert > $${g.cplThreshold} · notify at ${g.alertPct}% of cap`],
|
||||
['Auto-pause rules', [g.pauseOnDailyCap && 'daily cap', g.pauseOnCplBreach && 'CPL breach', g.anomalyDetection && 'anomaly'].filter(Boolean).join(', ') || 'none'],
|
||||
['Creative', selectedCreative?.headline], ['Lead form', selectedForm?.title || 'Default capture form'],
|
||||
['Playbook', draft.playbook],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between gap-3 py-1.5 border-b border-zinc-100 dark:border-white/5">
|
||||
<span className="text-zinc-500">{k}</span>
|
||||
<span className="font-semibold text-zinc-800 dark:text-zinc-100 text-right">{v || '—'}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="pt-2">
|
||||
<CopyableLink url={buildUtm(draft, { creative: selectedCreative }).url} />
|
||||
</div>
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400/80 pt-2 flex items-center gap-1">
|
||||
<FileCheck2 size={12} /> Saved as a draft with <b>pending</b> approval — it cannot publish until creatives and consent text are approved.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between mt-5 pt-4 border-t border-zinc-100 dark:border-white/5">
|
||||
<Btn variant="ghost" onClick={() => (step === 0 ? onClose() : setStep(step - 1))}>
|
||||
{step === 0 ? 'Cancel' : 'Back'}
|
||||
</Btn>
|
||||
{step < STEPS.length - 1
|
||||
? <Btn icon={ChevronRight} disabled={!canNext} onClick={() => setStep(step + 1)}>Next</Btn>
|
||||
: <Btn icon={Check} onClick={finish}>Create draft</Btn>}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const inputCls = 'w-full rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-3 py-2 text-base sm:text-sm text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-amber-500';
|
||||
|
||||
function Field({ label, hint, icon: Icon, children }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-xs font-semibold text-zinc-600 dark:text-zinc-300 mb-1.5 flex items-center gap-1.5">
|
||||
{Icon && <Icon size={13} className="text-amber-500" />} {label}
|
||||
{hint && <span className="font-normal text-zinc-400">· {hint}</span>}
|
||||
</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text, label = 'Copied') => {
|
||||
try { await navigator.clipboard.writeText(text); toast.success(label); }
|
||||
catch { toast.error('Could not copy — select and copy manually'); }
|
||||
};
|
||||
|
||||
function CopyableLink({ url }) {
|
||||
return (
|
||||
<div className="rounded-xl bg-zinc-50 dark:bg-white/5 p-3">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide text-zinc-400 flex items-center gap-1"><Link2 size={11} className="text-amber-500" /> Tracked link (UTM)</span>
|
||||
<Btn size="sm" variant="ghost" icon={Copy} onClick={() => copyToClipboard(url, 'Tracked link copied')}>Copy</Btn>
|
||||
</div>
|
||||
<code className="block text-[11px] font-mono text-blue-600 dark:text-blue-400 break-all">{url}</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({ label, on, onToggle }) {
|
||||
return (
|
||||
<button type="button" onClick={onToggle}
|
||||
className="w-full flex items-center justify-between gap-3 px-3 py-2 rounded-xl bg-zinc-50 dark:bg-white/5 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors text-left">
|
||||
<span className="text-xs text-zinc-700 dark:text-zinc-200">{label}</span>
|
||||
<span className={`shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-lg text-[11px] font-semibold ${on ? 'text-emerald-600 dark:text-emerald-400 bg-emerald-500/10' : 'text-zinc-400 bg-zinc-200/60 dark:bg-white/5'}`}>
|
||||
<Power size={12} /> {on ? 'On' : 'Off'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* ComplianceCenter — pending creative/consent/campaign approvals, blocked claims,
|
||||
* review-disclosure checks, and an AI audit + conversion-sync log (spec §8, §10.1).
|
||||
* An unapproved campaign cannot publish and a risky message cannot send.
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
ShieldCheck, AlertTriangle, Ban, CheckCircle2, Clock, RefreshCw,
|
||||
FileWarning, Wand2, ArrowUpRight,
|
||||
} from 'lucide-react';
|
||||
import { useSocialAds } from '../SocialAdsContext.jsx';
|
||||
import { PlatformBadge, Card, SectionHeader, StatusChip, Btn } from '../components/ui.jsx';
|
||||
|
||||
const TYPE_LABEL = {
|
||||
creative: 'Creative', consent_text: 'Consent text',
|
||||
review_disclosure: 'Review disclosure', campaign: 'Campaign publish',
|
||||
};
|
||||
|
||||
const SYNC_ICON = {
|
||||
synced: { icon: CheckCircle2, color: 'text-emerald-500' },
|
||||
queued: { icon: Clock, color: 'text-amber-500' },
|
||||
failed: { icon: AlertTriangle, color: 'text-red-500' },
|
||||
};
|
||||
|
||||
export default function ComplianceCenter() {
|
||||
const { approvals, resolveApproval, conversionEvents, retryConversion } = useSocialAds();
|
||||
|
||||
const pending = approvals.filter(a => a.status === 'pending');
|
||||
const blocked = approvals.filter(a => a.status === 'blocked');
|
||||
const resolved = approvals.filter(a => a.status === 'approved');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader icon={ShieldCheck} title="Compliance Center" description="Approval workflow, blocked claims, consent versions, and the AI / conversion audit log. Nothing risky publishes or sends until resolved.">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusChip status="pending" label={`${pending.length} pending`} />
|
||||
{blocked.length > 0 && <StatusChip status="blocked" label={`${blocked.length} blocked`} />}
|
||||
</div>
|
||||
</SectionHeader>
|
||||
|
||||
{/* Blocked first — must resolve */}
|
||||
{blocked.length > 0 && (
|
||||
<Card title="Blocked — resolve before publish" icon={Ban} subtitle="Risky claims detected by the compliance reviewer.">
|
||||
<div className="space-y-2">
|
||||
{blocked.map(a => <ApprovalRow key={a.id} item={a} onResolve={resolveApproval} />)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Pending queue */}
|
||||
<Card title="Approval queue" icon={Clock} subtitle="Creative, consent text, review disclosure, and campaign publish approvals.">
|
||||
{pending.length === 0
|
||||
? <p className="text-sm text-zinc-500 py-4 text-center">Nothing pending — all clear.</p>
|
||||
: <div className="space-y-2">{pending.map(a => <ApprovalRow key={a.id} item={a} onResolve={resolveApproval} />)}</div>}
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Conversion sync log */}
|
||||
<Card title="Conversion sync log" icon={ArrowUpRight} subtitle="Events sent back to ad platforms (deduped).">
|
||||
<div className="space-y-1.5">
|
||||
{conversionEvents.map(e => {
|
||||
const meta = SYNC_ICON[e.syncStatus];
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<div key={e.id} className="flex items-center gap-2 px-2.5 py-2 rounded-lg bg-zinc-50 dark:bg-white/5">
|
||||
<Icon size={14} className={meta.color} />
|
||||
<PlatformBadge platformId={e.platform} size="sm" showName={false} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-semibold text-zinc-700 dark:text-zinc-200 truncate">{e.eventName}</p>
|
||||
<p className="text-[10px] text-zinc-400 font-mono truncate">dedup: {e.dedupKey}</p>
|
||||
</div>
|
||||
{e.value > 0 && <span className="text-[11px] font-mono text-emerald-600 dark:text-emerald-400">${e.value.toLocaleString()}</span>}
|
||||
{e.syncStatus === 'failed'
|
||||
? <Btn size="sm" variant="danger" icon={RefreshCw} onClick={() => retryConversion(e.id)}>Retry</Btn>
|
||||
: <StatusChip status={e.syncStatus} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Recently resolved + AI audit */}
|
||||
<Card title="AI audit & recent approvals" icon={Wand2} subtitle="Human corrections and resolved items.">
|
||||
<div className="space-y-1.5">
|
||||
{resolved.map(a => (
|
||||
<div key={a.id} className="flex items-center gap-2 px-2.5 py-2 rounded-lg bg-zinc-50 dark:bg-white/5">
|
||||
<CheckCircle2 size={14} className="text-emerald-500" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-semibold text-zinc-700 dark:text-zinc-200 truncate">{a.title}</p>
|
||||
<p className="text-[10px] text-zinc-400">{TYPE_LABEL[a.type]} · approved by {a.reviewer}</p>
|
||||
</div>
|
||||
<StatusChip status="approved" />
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-start gap-2 px-2.5 py-2 rounded-lg bg-purple-50 dark:bg-purple-500/10 mt-2">
|
||||
<Wand2 size={14} className="text-purple-500 mt-0.5" />
|
||||
<p className="text-[11px] text-zinc-600 dark:text-zinc-300">
|
||||
AI guardrail summary: <b>2</b> creatives auto-flagged this week (1 scarcity, 1 price claim), <b>0</b> auto-published. All AI copy required human approval.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApprovalRow({ item, onResolve }) {
|
||||
const isBlocked = item.status === 'blocked';
|
||||
return (
|
||||
<div className={`flex flex-col sm:flex-row sm:items-center gap-3 p-3 rounded-xl ${isBlocked ? 'bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20' : 'bg-zinc-50 dark:bg-white/5'}`}>
|
||||
<div className="flex items-start gap-2 min-w-0 flex-1">
|
||||
<FileWarning size={16} className={isBlocked ? 'text-red-500 mt-0.5' : 'text-amber-500 mt-0.5'} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide px-1.5 py-0.5 rounded bg-zinc-200 dark:bg-white/10 text-zinc-500">{TYPE_LABEL[item.type]}</span>
|
||||
<h4 className="font-semibold text-sm text-zinc-800 dark:text-zinc-100">{item.title}</h4>
|
||||
</div>
|
||||
{item.flags?.length > 0 && (
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{item.flags.map((f, i) => <li key={i} className="text-[11px] text-amber-600 dark:text-amber-400/80 flex items-start gap-1"><AlertTriangle size={11} className="mt-0.5 shrink-0" />{f}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Btn size="sm" variant="success" icon={CheckCircle2} disabled={isBlocked} onClick={() => onResolve(item.id, 'approved')}>Approve</Btn>
|
||||
<Btn size="sm" variant="danger" icon={Ban} onClick={() => onResolve(item.id, 'blocked')}>{isBlocked ? 'Keep blocked' : 'Block'}</Btn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* ConnectedAccounts — platform cards with connect/disconnect, token + webhook health,
|
||||
* permission scopes, last sync, and a webhook test button (spec §4, §8).
|
||||
*
|
||||
* Capability badges make the spec §11 non-negotiable visible: the UI honestly shows
|
||||
* which platforms are ingest-only / report-only / full-publish, so nobody assumes
|
||||
* automation that doesn't exist.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Plug, RefreshCw, Webhook, KeyRound, Clock, AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
import { useSocialAds } from '../SocialAdsContext.jsx';
|
||||
import { PLATFORMS, CAPABILITY } from '../data/platforms.js';
|
||||
import { Card, StatusChip, CapabilityBadge, SectionHeader, Btn } from '../components/ui.jsx';
|
||||
|
||||
const ALL_CAPS = [
|
||||
CAPABILITY.INGEST, CAPABILITY.REPORT, CAPABILITY.CONVERSION_SYNC,
|
||||
CAPABILITY.DRAFT_BUILDER, CAPABILITY.FULL_PUBLISH,
|
||||
];
|
||||
|
||||
const TOKEN_META = {
|
||||
healthy: { icon: CheckCircle2, color: 'text-emerald-500', label: 'Healthy' },
|
||||
expiring: { icon: AlertTriangle, color: 'text-amber-500', label: 'Expiring soon' },
|
||||
revoked: { icon: AlertTriangle, color: 'text-red-500', label: 'Revoked' },
|
||||
none: { icon: KeyRound, color: 'text-zinc-400', label: 'No token' },
|
||||
};
|
||||
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
export default function ConnectedAccounts() {
|
||||
const { accounts, toggleAccount, testWebhook } = useSocialAds();
|
||||
const accountByPlatform = Object.fromEntries(accounts.map(a => [a.platform, a]));
|
||||
|
||||
const connectedCount = accounts.filter(a => a.status === 'connected').length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader
|
||||
icon={Plug}
|
||||
title="Connected Accounts"
|
||||
description="Manage platform connections, token health, and webhooks. Secrets are stored outside the app DB."
|
||||
>
|
||||
<StatusChip status="connected" label={`${connectedCount}/${PLATFORMS.length} connected`} />
|
||||
</SectionHeader>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{PLATFORMS.map(p => {
|
||||
const acc = accountByPlatform[p.id];
|
||||
const connected = acc?.status === 'connected';
|
||||
const tokenMeta = TOKEN_META[acc?.tokenStatus] || TOKEN_META.none;
|
||||
const TokenIcon = tokenMeta.icon;
|
||||
|
||||
return (
|
||||
<Card key={p.id} className="!h-auto">
|
||||
<div className="flex items-start justify-between gap-2 mb-3">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<span className="inline-flex items-center justify-center rounded-xl p-2.5 shrink-0" style={{ backgroundColor: `${p.color}1A`, color: p.color }}>
|
||||
<p.icon size={22} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-bold text-zinc-900 dark:text-white truncate">{p.name}</h3>
|
||||
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 truncate">{acc?.externalAccountId || 'Not connected'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusChip status={acc?.status || 'disconnected'} />
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-3 line-clamp-2">{p.role}</p>
|
||||
|
||||
{/* Capability matrix */}
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{ALL_CAPS.map(cap => (
|
||||
<CapabilityBadge key={cap} capability={cap} active={p.capabilities.includes(cap)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Health rows */}
|
||||
<div className="space-y-1.5 text-xs border-t border-zinc-100 dark:border-white/5 pt-3 mb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 text-zinc-500"><TokenIcon size={13} className={tokenMeta.color} /> Token</span>
|
||||
<span className={`font-semibold ${tokenMeta.color}`}>{tokenMeta.label}{acc?.tokenExpiresAt ? ` · ${acc.tokenExpiresAt}` : ''}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 text-zinc-500"><Webhook size={13} /> Webhook</span>
|
||||
<span className="font-semibold text-zinc-600 dark:text-zinc-300 capitalize">{(acc?.webhookStatus || 'not configured').replace(/_/g, ' ')}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 text-zinc-500"><Clock size={13} /> Last sync</span>
|
||||
<span className="font-semibold text-zinc-600 dark:text-zinc-300">{fmtDate(acc?.lastSyncAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{acc?.cautions && (
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400/80 mb-3 flex items-start gap-1">
|
||||
<AlertTriangle size={12} className="mt-0.5 shrink-0" /> {p.cautions}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Btn
|
||||
size="sm"
|
||||
variant={connected ? 'outline' : 'primary'}
|
||||
onClick={() => toggleAccount(acc?.id)}
|
||||
className="flex-1"
|
||||
>
|
||||
{connected ? 'Disconnect' : 'Connect'}
|
||||
</Btn>
|
||||
<Btn size="sm" variant="ghost" icon={RefreshCw} onClick={() => testWebhook(acc?.id)} disabled={!connected}>
|
||||
Test lead
|
||||
</Btn>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* CreativeStudio — AI draft variants by platform, character-limit + compliance flags,
|
||||
* version history, and approve / block / publish (spec §4, §6, §8).
|
||||
*
|
||||
* Generation is the mock AI assistant (ai/creativeGenerator). Every variant — AI or
|
||||
* hand-typed — is scanned by the bannedClaims guardrail; blocked copy cannot be approved.
|
||||
*/
|
||||
import React, { useState, useMemo, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Wand2, Sparkles, CheckCircle2, AlertTriangle, Ban, ShieldCheck, Plus, ExternalLink, PenLine, X, Image, Upload, ImageOff, History, ChevronDown, Undo2 } from 'lucide-react';
|
||||
import { useSocialAds } from '../SocialAdsContext.jsx';
|
||||
import { PLATFORMS, getPlatform } from '../data/platforms.js';
|
||||
import { BLUEPRINTS } from '../data/blueprints.js';
|
||||
import { generateVariants, CHAR_LIMITS } from '../ai/creativeGenerator.js';
|
||||
import { scanCopy } from '../ai/bannedClaims.js';
|
||||
import { Card, StatusChip, SectionHeader, Btn, PlatformBadge } from '../components/ui.jsx';
|
||||
|
||||
const SERVICE_TYPES = [
|
||||
{ id: 'storm', label: 'Storm inspection' },
|
||||
{ id: 'replacement', label: 'Roof replacement' },
|
||||
{ id: 'emergency', label: 'Emergency leak' },
|
||||
{ id: 'commercial', label: 'Commercial bid' },
|
||||
{ id: 'referral', label: 'Review / referral' },
|
||||
{ id: 'maintenance', label: 'Maintenance' },
|
||||
];
|
||||
|
||||
const POLICY_META = {
|
||||
clear: { icon: CheckCircle2, color: 'text-emerald-500', label: 'Policy clear' },
|
||||
review: { icon: AlertTriangle, color: 'text-amber-500', label: 'Needs review' },
|
||||
blocked: { icon: Ban, color: 'text-red-500', label: 'Blocked' },
|
||||
};
|
||||
|
||||
export default function CreativeStudio() {
|
||||
const { creatives, campaigns, mediaAssets, setCreativeApproval, addCreative, updateCreative, revertCreative, addMediaAsset } = useSocialAds();
|
||||
const navigate = useNavigate();
|
||||
const fileRef = useRef(null);
|
||||
const [platform, setPlatform] = useState('meta');
|
||||
const [serviceType, setServiceType] = useState('storm');
|
||||
const [city, setCity] = useState('Plano');
|
||||
const [variants, setVariants] = useState([]);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [editor, setEditor] = useState(null); // null | { id, platform, headline, body, description }
|
||||
|
||||
const generate = () => {
|
||||
setGenerating(true);
|
||||
setTimeout(() => {
|
||||
setVariants(generateVariants({ platformId: platform, serviceType, city, count: 5 }));
|
||||
setGenerating(false);
|
||||
}, 650);
|
||||
};
|
||||
|
||||
const openNew = () => setEditor({ id: null, platform, headline: '', body: '', description: '', mediaAssetId: '' });
|
||||
const openEdit = (c) => setEditor({ id: c.id, platform: c.platform, headline: c.headline, body: c.body, description: c.description || '', mediaAssetId: c.mediaAssetId || '' });
|
||||
|
||||
const saveCreative = ({ id, platform: p, headline, body, description, mediaAssetId, policy }) => {
|
||||
const payload = { platform: p, headline, body, description, mediaAssetId: mediaAssetId || null, policyStatus: policy.status, policyFlags: policy.flags.map(f => f.message) };
|
||||
if (id) updateCreative(id, payload);
|
||||
else addCreative({ ...payload, aiGenerated: false });
|
||||
setEditor(null);
|
||||
};
|
||||
|
||||
const onUploadMedia = (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
// In-memory preview only — object URLs reset on reload (no localStorage/backend).
|
||||
const url = URL.createObjectURL(file);
|
||||
addMediaAsset({ name: file.name, kind: 'uploaded', url, gradient: ['#71717a', '#3f3f46'] });
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
// Resolve the landing page a creative's ad would link to: prefer its campaign's
|
||||
// blueprint, then the service type it was drafted for, then any blueprint that
|
||||
// runs on the creative's platform.
|
||||
const previewUrl = (c) => {
|
||||
const camp = c.campaignId && campaigns.find(x => x.id === c.campaignId);
|
||||
const bp = (camp && BLUEPRINTS.find(b => b.id === camp.blueprintId))
|
||||
|| (c.serviceType && BLUEPRINTS.find(b => b.serviceType === c.serviceType))
|
||||
|| BLUEPRINTS.find(b => b.bestChannels.includes(c.platform))
|
||||
|| BLUEPRINTS[0];
|
||||
// Pass the attached media + headline so the landing page previews the actual creative.
|
||||
const media = mediaAssets.find(m => m.id === c.mediaAssetId);
|
||||
const img = media?.url || media?.photo;
|
||||
const extra = (img ? `&img=${encodeURIComponent(img)}` : '') + (c.headline ? `&hl=${encodeURIComponent(c.headline)}` : '');
|
||||
return `/ads/lp?c=${bp.id}&src=${c.platform}${extra}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader icon={Wand2} title="Creative Studio" description="AI drafts platform-aware ad copy from approved tenant knowledge. All output requires human approval before publish.">
|
||||
<Btn variant="outline" icon={PenLine} onClick={openNew}>Write your own</Btn>
|
||||
<StatusChip status="review" label={`${creatives.filter(c => c.approvalStatus === 'pending').length} pending`} />
|
||||
</SectionHeader>
|
||||
|
||||
{editor && (
|
||||
<CreativeEditor key={editor.id || 'new'} initial={editor} mediaAssets={mediaAssets} onCancel={() => setEditor(null)} onSave={saveCreative} />
|
||||
)}
|
||||
|
||||
{/* Media library (spec §8 / §7 media_asset_id) */}
|
||||
<Card title="Media library" icon={Image} subtitle="Reusable image assets to attach to creatives."
|
||||
action={<><input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={onUploadMedia} /><Btn size="sm" variant="outline" icon={Upload} onClick={() => fileRef.current?.click()}>Upload</Btn></>}>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
{mediaAssets.map(a => <MediaThumb key={a.id} asset={a} />)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Generator panel */}
|
||||
<Card title="AI Creative Assistant" icon={Sparkles} subtitle="Generate 5 variants with platform tone + character limits.">
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3 mb-4">
|
||||
<label className="block sm:col-span-1">
|
||||
<span className="text-xs font-semibold text-zinc-600 dark:text-zinc-300 mb-1.5 block">Platform</span>
|
||||
<select value={platform} onChange={e => setPlatform(e.target.value)} className={inputCls}>
|
||||
{PLATFORMS.map(p => <option key={p.id} value={p.id}>{p.short}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="text-xs font-semibold text-zinc-600 dark:text-zinc-300 mb-1.5 block">Campaign type</span>
|
||||
<select value={serviceType} onChange={e => setServiceType(e.target.value)} className={inputCls}>
|
||||
{SERVICE_TYPES.map(s => <option key={s.id} value={s.id}>{s.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-semibold text-zinc-600 dark:text-zinc-300 mb-1.5 block">City merge</span>
|
||||
<input value={city} onChange={e => setCity(e.target.value)} className={inputCls} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] text-zinc-400 flex items-center gap-1">
|
||||
<ShieldCheck size={12} /> {getPlatform(platform)?.copyGuidance}
|
||||
</p>
|
||||
<Btn icon={Sparkles} onClick={generate} disabled={generating}>{generating ? 'Drafting…' : 'Generate variants'}</Btn>
|
||||
</div>
|
||||
|
||||
{variants.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mt-4">
|
||||
{variants.map((v, i) => {
|
||||
const pm = POLICY_META[v.policy.status];
|
||||
const PIcon = pm.icon;
|
||||
return (
|
||||
<div key={i} className="rounded-xl border border-zinc-200 dark:border-white/10 p-3 flex flex-col">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide text-zinc-400">Variant {i + 1}</span>
|
||||
<span className={`inline-flex items-center gap-1 text-[10px] font-bold ${pm.color}`}><PIcon size={12} /> {pm.label}</span>
|
||||
</div>
|
||||
<p className="font-bold text-sm text-zinc-900 dark:text-white">{v.headline}</p>
|
||||
<p className="text-xs text-zinc-600 dark:text-zinc-300 mt-1 flex-1">{v.body}</p>
|
||||
{v.description && <p className="text-[11px] text-zinc-400 mt-1 italic">{v.description}</p>}
|
||||
<div className="text-[10px] text-zinc-400 mt-2 flex flex-wrap gap-x-3">
|
||||
<span>H {v.charCounts.headline.used}/{v.charCounts.headline.limit}</span>
|
||||
<span>B {v.charCounts.body.used}/{v.charCounts.body.limit}</span>
|
||||
</div>
|
||||
{v.policy.flags.length > 0 && (
|
||||
<ul className="mt-2 space-y-0.5">
|
||||
{v.policy.flags.map(f => (
|
||||
<li key={f.id} className="text-[10px] text-amber-600 dark:text-amber-400/80 flex items-start gap-1"><AlertTriangle size={10} className="mt-0.5 shrink-0" /> {f.message}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<Btn size="sm" variant="outline" icon={Plus} className="mt-3"
|
||||
disabled={v.policy.status === 'blocked'}
|
||||
onClick={() => addCreative({ platform, serviceType, headline: v.headline, body: v.body, description: v.description, aiGenerated: true, policyStatus: v.policy.status, policyFlags: v.policy.flags.map(f => f.message) })}>
|
||||
Save to library
|
||||
</Btn>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Creative library */}
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-zinc-700 dark:text-zinc-300 mb-3">Creative library</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{creatives.map(c => {
|
||||
const pm = POLICY_META[c.policyStatus] || POLICY_META.review;
|
||||
const PIcon = pm.icon;
|
||||
const media = mediaAssets.find(m => m.id === c.mediaAssetId);
|
||||
return (
|
||||
<Card key={c.id} className="!h-auto" pad="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<PlatformBadge platformId={c.platform} size="sm" />
|
||||
{c.aiGenerated && <span className="inline-flex items-center gap-0.5 text-[10px] font-bold text-purple-500"><Sparkles size={10} /> AI</span>}
|
||||
</div>
|
||||
<StatusChip status={c.approvalStatus} />
|
||||
</div>
|
||||
{media && (
|
||||
<div className="mb-2">
|
||||
<MediaThumb asset={media} />
|
||||
</div>
|
||||
)}
|
||||
<p className="font-bold text-sm text-zinc-900 dark:text-white">{c.headline}</p>
|
||||
<p className="text-xs text-zinc-600 dark:text-zinc-300 mt-1">{c.body}</p>
|
||||
{c.description && <p className="text-[11px] text-zinc-400 mt-1 italic">{c.description}</p>}
|
||||
|
||||
<div className={`mt-2 inline-flex items-center gap-1 text-[11px] font-semibold ${pm.color}`}><PIcon size={13} /> {pm.label}</div>
|
||||
{c.policyFlags?.length > 0 && (
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{c.policyFlags.map((f, i) => <li key={i} className="text-[10px] text-amber-600 dark:text-amber-400/80 flex items-start gap-1"><AlertTriangle size={10} className="mt-0.5 shrink-0" />{f}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 mt-3 pt-3 border-t border-zinc-100 dark:border-white/5">
|
||||
<Btn size="sm" variant="success" icon={CheckCircle2} disabled={c.policyStatus === 'blocked' || c.approvalStatus === 'approved'} onClick={() => setCreativeApproval(c.id, 'approved')}>Approve</Btn>
|
||||
<Btn size="sm" variant="danger" icon={Ban} onClick={() => setCreativeApproval(c.id, 'blocked')}>Block</Btn>
|
||||
<Btn size="sm" variant="ghost" icon={PenLine} onClick={() => openEdit(c)}>Edit</Btn>
|
||||
<Btn size="sm" variant="ghost" icon={ExternalLink} className="ml-auto"
|
||||
title="Open the public lead-capture page this creative's ad links to"
|
||||
onClick={() => navigate(previewUrl(c))}>
|
||||
Preview form
|
||||
</Btn>
|
||||
{c.policyStatus === 'blocked' && <span className="text-[10px] text-red-500 font-semibold">Cannot publish until resolved</span>}
|
||||
</div>
|
||||
|
||||
<VersionHistory creative={c} mediaAssets={mediaAssets} onRevert={revertCreative} />
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputCls = 'w-full rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-3 py-2 text-base sm:text-sm text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-amber-500';
|
||||
|
||||
function CharCount({ used, limit }) {
|
||||
if (!limit) return null;
|
||||
const over = used > limit;
|
||||
const near = !over && used > limit * 0.85;
|
||||
return <span className={`text-[10px] font-mono ${over ? 'text-red-500 font-bold' : near ? 'text-amber-500' : 'text-zinc-400'}`}>{used}/{limit}</span>;
|
||||
}
|
||||
|
||||
// Media tile — shows the asset's photo (uploaded object-URL or a topic-matched image)
|
||||
// over its gradient, which stays as a graceful fallback if the image can't load.
|
||||
function MediaThumb({ asset, selected, onClick }) {
|
||||
const [err, setErr] = useState(false);
|
||||
const src = asset.url || asset.photo;
|
||||
const showImg = src && !err;
|
||||
const tile = (
|
||||
<>
|
||||
<div className={`relative w-full h-20 rounded-lg overflow-hidden border ${selected ? 'border-amber-500 ring-2 ring-amber-500/40' : 'border-zinc-200 dark:border-white/10'}`}
|
||||
style={{ background: `linear-gradient(135deg, ${asset.gradient?.[0] || '#71717a'}, ${asset.gradient?.[1] || '#3f3f46'})` }}>
|
||||
{showImg
|
||||
? <img src={src} alt={asset.name} loading="lazy" onError={() => setErr(true)} className="w-full h-full object-cover" />
|
||||
: <div className="w-full h-full flex items-center justify-center"><Image size={20} className="text-white/80" /></div>}
|
||||
{selected && <span className="absolute top-1 right-1 bg-amber-500 text-white rounded-full p-0.5"><CheckCircle2 size={12} /></span>}
|
||||
</div>
|
||||
<p className="text-[10px] text-zinc-500 dark:text-zinc-400 truncate mt-1">{asset.name}</p>
|
||||
</>
|
||||
);
|
||||
return onClick
|
||||
? <button type="button" onClick={onClick} className="block text-left w-full">{tile}</button>
|
||||
: <div className="w-full">{tile}</div>;
|
||||
}
|
||||
|
||||
const fmtWhen = (iso) => {
|
||||
if (!iso) return '';
|
||||
try { return new Date(iso).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); }
|
||||
catch { return iso; }
|
||||
};
|
||||
|
||||
// Version history (spec §7/§8) — prior copy versions with one-click revert.
|
||||
function VersionHistory({ creative, mediaAssets = [], onRevert }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const versions = creative.versions || [];
|
||||
if (versions.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-zinc-100 dark:border-white/5">
|
||||
<button onClick={() => setOpen(o => !o)}
|
||||
className="flex items-center gap-1 text-[11px] font-semibold text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-200 transition-colors">
|
||||
<History size={12} /> Version history ({versions.length})
|
||||
<ChevronDown size={13} className={`transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{open && (
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{versions.slice().reverse().map(ver => {
|
||||
const pm = POLICY_META[ver.policyStatus] || POLICY_META.review;
|
||||
const media = mediaAssets.find(m => m.id === ver.mediaAssetId);
|
||||
return (
|
||||
<li key={ver.v} className="flex items-start justify-between gap-2 rounded-lg bg-zinc-50 dark:bg-white/5 px-2.5 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold text-zinc-700 dark:text-zinc-200 truncate">v{ver.v} · {ver.headline}</p>
|
||||
<p className="text-[10px] text-zinc-400 truncate">{ver.body}</p>
|
||||
<p className="text-[10px] text-zinc-400">{fmtWhen(ver.savedAt)} · <span className={pm.color}>{pm.label}</span>{media ? ` · ${media.name}` : ''}</p>
|
||||
</div>
|
||||
<Btn size="sm" variant="ghost" icon={Undo2} onClick={() => onRevert(creative.id, ver.v)}>Revert</Btn>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Manual create/edit (spec §8/B2 "manual edit") — platform-aware char limits + a live
|
||||
// compliance scan so hand-written copy is held to the same guardrail as AI drafts.
|
||||
function CreativeEditor({ initial, mediaAssets = [], onCancel, onSave }) {
|
||||
const [platform, setPlatform] = useState(initial.platform);
|
||||
const [headline, setHeadline] = useState(initial.headline);
|
||||
const [body, setBody] = useState(initial.body);
|
||||
const [description, setDescription] = useState(initial.description);
|
||||
const [mediaAssetId, setMediaAssetId] = useState(initial.mediaAssetId || '');
|
||||
|
||||
const limits = CHAR_LIMITS[platform] || CHAR_LIMITS.meta;
|
||||
const policy = useMemo(() => scanCopy(headline, body, description), [headline, body, description]);
|
||||
const pm = POLICY_META[policy.status];
|
||||
const PIcon = pm.icon;
|
||||
const over = (limits.headline && headline.length > limits.headline)
|
||||
|| (limits.body && body.length > limits.body)
|
||||
|| (limits.description && description.length > limits.description);
|
||||
const valid = headline.trim() && body.trim() && policy.status !== 'blocked' && !over;
|
||||
|
||||
const labelCls = 'text-xs font-semibold text-zinc-600 dark:text-zinc-300 mb-1.5 flex items-center justify-between gap-2';
|
||||
|
||||
return (
|
||||
<Card title={initial.id ? 'Edit creative' : 'Write your own creative'} icon={PenLine}
|
||||
subtitle="Platform-aware character limits + live compliance scan. Editing resets approval to pending."
|
||||
action={<button onClick={onCancel} className="p-1 rounded-lg text-zinc-400 hover:bg-zinc-100 dark:hover:bg-white/10"><X size={18} /></button>}>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[160px_1fr] gap-3">
|
||||
<label className="block">
|
||||
<span className="text-xs font-semibold text-zinc-600 dark:text-zinc-300 mb-1.5 block">Platform</span>
|
||||
<select value={platform} onChange={e => setPlatform(e.target.value)} className={inputCls}>
|
||||
{PLATFORMS.map(p => <option key={p.id} value={p.id}>{p.short}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className={labelCls}>Headline <CharCount used={headline.length} limit={limits.headline} /></span>
|
||||
<input value={headline} onChange={e => setHeadline(e.target.value)} className={inputCls} placeholder="Short, benefit-led headline" />
|
||||
</label>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className={labelCls}>Body <CharCount used={body.length} limit={limits.body} /></span>
|
||||
<textarea rows={3} value={body} onChange={e => setBody(e.target.value)} className={`${inputCls} resize-none`} placeholder="Explain the offer clearly — no exaggerated or unverified claims." />
|
||||
</label>
|
||||
{limits.description > 0 && (
|
||||
<label className="block">
|
||||
<span className={labelCls}>Description <CharCount used={description.length} limit={limits.description} /></span>
|
||||
<input value={description} onChange={e => setDescription(e.target.value)} className={inputCls} placeholder="Optional supporting line" />
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Media attach (spec §7 media_asset_id) */}
|
||||
<div>
|
||||
<span className="text-xs font-semibold text-zinc-600 dark:text-zinc-300 mb-1.5 block">Media <span className="font-normal text-zinc-400">· optional</span></span>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-5 gap-2">
|
||||
<button type="button" onClick={() => setMediaAssetId('')} className="block text-left w-full">
|
||||
<div className={`h-20 rounded-lg border flex items-center justify-center ${!mediaAssetId ? 'border-amber-500 ring-2 ring-amber-500/40 text-amber-500' : 'border-zinc-200 dark:border-white/10 text-zinc-400'}`}>
|
||||
<ImageOff size={18} />
|
||||
</div>
|
||||
<p className="text-[10px] text-zinc-500 dark:text-zinc-400 truncate mt-1">No media</p>
|
||||
</button>
|
||||
{mediaAssets.map(a => (
|
||||
<MediaThumb key={a.id} asset={a} selected={mediaAssetId === a.id} onClick={() => setMediaAssetId(a.id)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-start gap-1.5 text-xs font-semibold ${pm.color}`}>
|
||||
<PIcon size={14} className="mt-0.5 shrink-0" /> {pm.label}
|
||||
</div>
|
||||
{policy.flags.length > 0 && (
|
||||
<ul className="space-y-0.5 -mt-1">
|
||||
{policy.flags.map(f => <li key={f.id} className="text-[11px] text-amber-600 dark:text-amber-400/80 flex items-start gap-1"><AlertTriangle size={11} className="mt-0.5 shrink-0" /> {f.message}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 pt-3 border-t border-zinc-100 dark:border-white/5">
|
||||
{policy.status === 'blocked' && <span className="text-[11px] text-red-500 font-semibold mr-auto">Resolve blocked claims before saving</span>}
|
||||
{over && policy.status !== 'blocked' && <span className="text-[11px] text-red-500 font-semibold mr-auto">Trim copy under the platform limit</span>}
|
||||
<Btn variant="ghost" onClick={onCancel}>Cancel</Btn>
|
||||
<Btn icon={CheckCircle2} disabled={!valid}
|
||||
onClick={() => onSave({ id: initial.id, platform, headline: headline.trim(), body: body.trim(), description: description.trim(), mediaAssetId, policy })}>
|
||||
{initial.id ? 'Save changes' : 'Save to library'}
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* LeadFormMapper — editable builder for platform field → CRM field mapping, custom
|
||||
* fields, required flags, transform + validation rules, consent text, privacy URL, the
|
||||
* thank-you CTA, and live/draft status (spec §4, §7 lead_forms + field_mappings, §8).
|
||||
*
|
||||
* Edits write straight to the module state; consent/thank-you/mapping are no longer a
|
||||
* static display. A test lead would land in the CRM fields shown in the target column.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ClipboardList, ArrowRight, Lock, ShieldCheck, FileText, CheckCircle2,
|
||||
ExternalLink, Plus, Trash2, Power, Sparkles, AlertTriangle, X, FlaskConical,
|
||||
} from 'lucide-react';
|
||||
import { useSocialAds } from '../SocialAdsContext.jsx';
|
||||
import { BLUEPRINTS, BLUEPRINT_BY_ID } from '../data/blueprints.js';
|
||||
import { suggestQuestions, SENSITIVE_EXCLUDED } from '../ai/questionOptimizer.js';
|
||||
import { runTestLead } from '../data/testLead.js';
|
||||
import { Card, SectionHeader, PlatformBadge, StatusChip, Btn } from '../components/ui.jsx';
|
||||
|
||||
const TRANSFORMS = ['none', 'titlecase', 'lowercase', 'e164', 'bool', 'int', 'date'];
|
||||
const inputCls = 'w-full rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-2.5 py-1.5 text-base sm:text-sm text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-amber-500';
|
||||
|
||||
export default function LeadFormMapper() {
|
||||
const {
|
||||
leadForms, campaigns, updateLeadForm, updateFormField,
|
||||
addFormField, removeFormField, toggleFormStatus, addLeadForm,
|
||||
} = useSocialAds();
|
||||
const navigate = useNavigate();
|
||||
const [activeId, setActiveId] = useState(leadForms[0]?.id);
|
||||
const [suggestions, setSuggestions] = useState(null);
|
||||
const [test, setTest] = useState(null);
|
||||
|
||||
const form = leadForms.find(f => f.id === activeId) || leadForms[0];
|
||||
|
||||
// The public preview mirrors what a lead sees: the form's campaign decides the
|
||||
// blueprint headline/offer (?c); the form's platform sets the ad source (?src).
|
||||
const campaign = campaigns.find(c => c.id === form?.campaignId);
|
||||
const blueprintId = campaign?.blueprintId || BLUEPRINTS[0].id;
|
||||
const previewUrl = `/ads/lp?c=${blueprintId}&src=${form?.platform}`;
|
||||
const serviceType = campaign?.serviceType || BLUEPRINT_BY_ID[blueprintId]?.serviceType || 'storm';
|
||||
|
||||
const selectForm = (id) => { setActiveId(id); setSuggestions(null); setTest(null); };
|
||||
const runTest = () => setTest(runTestLead(form));
|
||||
const createForm = () => { const f = addLeadForm(); selectForm(f.id); };
|
||||
const optimize = () => setSuggestions(suggestQuestions(form, serviceType));
|
||||
const addSuggestion = (q) => {
|
||||
addFormField(form.id, { source: q.source, target: q.target, transform: q.transform, required: false, validation: q.validation });
|
||||
setSuggestions(prev => prev.filter(s => s.source !== q.source));
|
||||
};
|
||||
const addAllSuggestions = () => { suggestions.forEach(addSuggestion); setSuggestions([]); };
|
||||
|
||||
if (!form) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader icon={ClipboardList} title="Lead Form Mapper" description="Map platform fields to CRM fields, edit consent + thank-you, and set live/draft status.">
|
||||
<Btn variant="outline" icon={Plus} onClick={createForm}>New form</Btn>
|
||||
<Btn variant="outline" icon={ExternalLink} onClick={() => navigate(previewUrl)} title="Open the public lead-capture page for the selected form">
|
||||
Preview
|
||||
</Btn>
|
||||
</SectionHeader>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
|
||||
{/* Form list */}
|
||||
<div className="lg:col-span-1 space-y-2">
|
||||
{leadForms.map(f => (
|
||||
<button key={f.id} onClick={() => selectForm(f.id)}
|
||||
className={`w-full text-left p-3 rounded-xl border-2 transition-all ${f.id === activeId ? 'border-amber-500 bg-amber-500/5' : 'border-zinc-200 dark:border-white/10 hover:border-amber-300'}`}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<PlatformBadge platformId={f.platform} size="sm" showName={false} />
|
||||
<StatusChip status={f.status === 'live' ? 'active' : 'draft'} label={f.status} />
|
||||
</div>
|
||||
<p className="font-bold text-sm text-zinc-900 dark:text-white truncate">{f.title}</p>
|
||||
<p className="text-[11px] text-zinc-400">{f.fields.length} fields mapped</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Editable detail */}
|
||||
<div className="lg:col-span-3 space-y-4">
|
||||
<Card icon={ClipboardList} title={form.title}
|
||||
subtitle={`Platform form ID: ${form.platformFormId}`}
|
||||
action={
|
||||
<button onClick={() => toggleFormStatus(form.id)} title="Toggle live / draft"
|
||||
className={`shrink-0 inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold transition-colors ${form.status === 'live' ? 'text-emerald-600 dark:text-emerald-400 bg-emerald-500/10' : 'text-zinc-500 bg-zinc-100 dark:bg-white/5'}`}>
|
||||
<Power size={13} /> {form.status === 'live' ? 'Live' : 'Draft'}
|
||||
</button>
|
||||
}>
|
||||
{/* Title + platform */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-2 items-end mb-4">
|
||||
<label className="block">
|
||||
<span className="text-[11px] font-semibold text-zinc-500 mb-1 block">Form title</span>
|
||||
<input value={form.title} onChange={e => updateLeadForm(form.id, { title: e.target.value })} className={inputCls} />
|
||||
</label>
|
||||
<span className="text-[11px] text-zinc-400 pb-2">Maps to: name, phone, email, address, role, urgency, consent</span>
|
||||
</div>
|
||||
|
||||
{/* AI question optimizer (spec §6) */}
|
||||
<div className="rounded-xl border border-purple-200 dark:border-purple-500/20 bg-purple-50/60 dark:bg-purple-500/5 p-3 mb-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Sparkles size={15} className="text-purple-500 shrink-0" />
|
||||
<p className="text-xs text-zinc-600 dark:text-zinc-300">Suggest 2–4 qualifying questions to raise lead quality.</p>
|
||||
</div>
|
||||
<Btn size="sm" variant="outline" icon={Sparkles} onClick={optimize}>Optimize questions</Btn>
|
||||
</div>
|
||||
|
||||
{form.fields.length >= 6 && (
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400/80 mt-2 flex items-start gap-1">
|
||||
<AlertTriangle size={12} className="mt-0.5 shrink-0" /> This form already has {form.fields.length} fields — keep it low-friction; add only what improves routing.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{suggestions && (
|
||||
<div className="mt-3">
|
||||
{suggestions.length === 0 ? (
|
||||
<p className="text-[11px] text-zinc-500">No new qualifying questions — this form is well-covered.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-purple-500">{suggestions.length} suggested</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Btn size="sm" variant="ghost" icon={Plus} onClick={addAllSuggestions}>Add all</Btn>
|
||||
<button onClick={() => setSuggestions(null)} className="p-1 rounded-lg text-zinc-400 hover:bg-zinc-100 dark:hover:bg-white/10"><X size={15} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{suggestions.map(q => (
|
||||
<li key={q.source} className="flex items-start justify-between gap-2 rounded-lg bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 px-2.5 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold text-zinc-800 dark:text-zinc-100">{q.label}</p>
|
||||
<p className="text-[10px] text-zinc-400 truncate">→ <span className="font-mono text-blue-600 dark:text-blue-400">{q.target}</span> · {q.rationale}</p>
|
||||
</div>
|
||||
<Btn size="sm" variant="outline" icon={Plus} onClick={() => addSuggestion(q)}>Add</Btn>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
<p className="text-[10px] text-zinc-400 mt-2 flex items-start gap-1">
|
||||
<ShieldCheck size={11} className="mt-0.5 shrink-0 text-emerald-500" /> Guardrail: never requests sensitive data ({SENSITIVE_EXCLUDED.join(', ')}); capped at 4 to avoid over-friction.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Field mappings */}
|
||||
<div className="space-y-2">
|
||||
{form.fields.map(f => (
|
||||
<FieldEditor key={f.source} field={f}
|
||||
onChange={patch => updateFormField(form.id, f.source, patch)}
|
||||
onRemove={() => removeFormField(form.id, f.source)} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 mt-3">
|
||||
<Btn size="sm" variant="ghost" icon={Plus} onClick={() => addFormField(form.id)}>Add field</Btn>
|
||||
<Btn size="sm" variant="outline" icon={FlaskConical} onClick={runTest}>Send test lead</Btn>
|
||||
</div>
|
||||
|
||||
{test && <TestLeadResult result={test} onClose={() => setTest(null)} onRerun={runTest} />}
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card title="Consent text" icon={Lock} subtitle="Edited consent should be re-reviewed by Compliance">
|
||||
<textarea rows={4} value={form.consentText} onChange={e => updateLeadForm(form.id, { consentText: e.target.value })}
|
||||
className={`${inputCls} resize-none`} />
|
||||
<label className="block mt-2">
|
||||
<span className="text-[11px] font-semibold text-zinc-500 mb-1 flex items-center gap-1"><ShieldCheck size={12} /> Privacy policy URL</span>
|
||||
<input value={form.privacyPolicyUrl} onChange={e => updateLeadForm(form.id, { privacyPolicyUrl: e.target.value })} className={inputCls} />
|
||||
</label>
|
||||
</Card>
|
||||
|
||||
<Card title="Thank-you screen" icon={FileText} subtitle="Shown after submission">
|
||||
<textarea rows={4} value={form.thankYou} onChange={e => updateLeadForm(form.id, { thankYou: e.target.value })}
|
||||
className={`${inputCls} resize-none`} />
|
||||
<p className="text-[11px] text-zinc-400 mt-2">Consent proof (text, URL, timestamp, channel) is stored with every submission before any marketing automation runs.</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldEditor({ field, onChange, onRemove }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-zinc-200 dark:border-white/10 p-3">
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-400">Field mapping</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button onClick={() => onChange({ required: !field.required })}
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-bold ${field.required ? 'text-emerald-600 dark:text-emerald-400 bg-emerald-500/10' : 'text-zinc-400 bg-zinc-100 dark:bg-white/5'}`}>
|
||||
<CheckCircle2 size={11} /> {field.required ? 'Required' : 'Optional'}
|
||||
</button>
|
||||
<button onClick={onRemove} title="Remove field" className="text-zinc-400 hover:text-red-500 p-1"><Trash2 size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<Labeled label="Platform field">
|
||||
<input value={field.source} onChange={e => onChange({ source: e.target.value })} className={`${inputCls} font-mono`} placeholder="e.g. full_name" />
|
||||
</Labeled>
|
||||
<Labeled label="CRM target" icon={ArrowRight}>
|
||||
<input value={field.target} onChange={e => onChange({ target: e.target.value })} className={`${inputCls} font-mono`} placeholder="e.g. contact.name" />
|
||||
</Labeled>
|
||||
<Labeled label="Transform">
|
||||
<select value={field.transform || 'none'} onChange={e => onChange({ transform: e.target.value })} className={inputCls}>
|
||||
{TRANSFORMS.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</Labeled>
|
||||
<Labeled label="Validation rule">
|
||||
<input value={field.validation || ''} onChange={e => onChange({ validation: e.target.value })} className={inputCls} placeholder="e.g. email, phone, required" />
|
||||
</Labeled>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Labeled({ label, icon: Icon, children }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-[11px] font-semibold text-zinc-500 mb-1 flex items-center gap-1">
|
||||
{Icon && <Icon size={11} className="text-amber-500" />} {label}
|
||||
</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// Test-lead → CRM mapping preview (spec phased C "Test lead maps correctly into CRM fields").
|
||||
function TestLeadResult({ result, onClose, onRerun }) {
|
||||
return (
|
||||
<div className="mt-4 rounded-xl border border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FlaskConical size={15} className="text-amber-500" />
|
||||
<span className="text-xs font-bold text-zinc-700 dark:text-zinc-200">Test lead → CRM mapping</span>
|
||||
{result.ok
|
||||
? <StatusChip status="active" label="Maps correctly" />
|
||||
: <StatusChip status="blocked" label={`${result.issues} issue${result.issues > 1 ? 's' : ''}`} />}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Btn size="sm" variant="ghost" icon={FlaskConical} onClick={onRerun}>Re-run</Btn>
|
||||
<button onClick={onClose} className="p-1 rounded-lg text-zinc-400 hover:bg-zinc-100 dark:hover:bg-white/10"><X size={15} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{result.rows.map(r => (
|
||||
<div key={r.source} className={`rounded-lg px-2.5 py-2 border ${r.valid ? 'border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900' : 'border-red-200 dark:border-red-500/30 bg-red-50 dark:bg-red-500/10'}`}>
|
||||
<div className="flex items-start gap-2">
|
||||
{r.valid
|
||||
? <CheckCircle2 size={14} className="text-emerald-500 shrink-0 mt-0.5" />
|
||||
: <AlertTriangle size={14} className="text-red-500 shrink-0 mt-0.5" />}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||
<code className="font-mono text-zinc-500 dark:text-zinc-400">{r.source}</code>
|
||||
<span className="text-zinc-400">“{r.raw}”</span>
|
||||
{r.transform !== 'none' && <span className="text-[10px] px-1 py-0.5 rounded bg-zinc-100 dark:bg-white/10 text-zinc-500">{r.transform}</span>}
|
||||
<ArrowRight size={11} className="text-amber-500" />
|
||||
<code className="font-mono text-blue-600 dark:text-blue-400">{r.target}</code>
|
||||
<span className="font-semibold text-zinc-700 dark:text-zinc-200">= {r.value || '∅'}</span>
|
||||
{r.required && <span className="text-[9px] uppercase font-bold text-zinc-400">req</span>}
|
||||
</div>
|
||||
{!r.valid && <p className="text-[10px] text-red-500 mt-0.5">{r.issue}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-zinc-400 mt-2">Simulated payload — a real webhook lead would resolve into these CRM fields before consent + triage.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* MarketingHubHome — KPIs, campaign status, leads by source, SLA breaches,
|
||||
* recommended actions, and the source-to-close funnel (spec §8).
|
||||
*/
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell,
|
||||
AreaChart, Area, CartesianGrid,
|
||||
} from 'recharts';
|
||||
import {
|
||||
DollarSign, Users, CalendarCheck, TrendingUp, AlertTriangle,
|
||||
Megaphone, Zap, ArrowRight, Inbox, ClipboardCheck,
|
||||
} from 'lucide-react';
|
||||
import { useSocialAds } from '../SocialAdsContext.jsx';
|
||||
import { totals, metrics, metricsBy, timeSeries, liveMetrics, liveFunnel } from '../engine/attribution.js';
|
||||
import { countBreaches } from '../data/sla.js';
|
||||
import { Card, KpiTile, PlatformBadge, StatusChip, SectionHeader, Btn } from '../components/ui.jsx';
|
||||
|
||||
const money = (n) => `$${Math.round(n).toLocaleString('en-US')}`;
|
||||
const pct = (n) => `${Math.round(n * 100)}%`;
|
||||
|
||||
const FUNNEL_COLORS = ['#3b82f6', '#8b5cf6', '#06b6d4', '#f59e0b', '#10b981'];
|
||||
|
||||
export default function MarketingHubHome({ onNavigate }) {
|
||||
const { analyticsDaily, campaigns, leads, approvals, accounts } = useSocialAds();
|
||||
|
||||
// Spend/ROAS reporting comes from the platform-reported series; lead→won metrics
|
||||
// come live from CRM events (spec §6: analytics based on events, not impressions).
|
||||
const reported = useMemo(() => metrics(totals(analyticsDaily)), [analyticsDaily]);
|
||||
const live = useMemo(() => liveMetrics(leads), [leads]);
|
||||
const m = useMemo(() => ({
|
||||
spend: reported.spend,
|
||||
leads: live.leads,
|
||||
appointments: live.appointments,
|
||||
revenue: live.revenue,
|
||||
cpl: live.leads ? reported.spend / live.leads : 0,
|
||||
roas: reported.spend ? live.revenue / reported.spend : 0,
|
||||
showRate: live.showRate,
|
||||
closeRate: live.closeRate,
|
||||
}), [reported, live]);
|
||||
const bySource = useMemo(() => metricsBy(analyticsDaily, 'platform'), [analyticsDaily]);
|
||||
const funnelData = useMemo(() => liveFunnel(leads), [leads]);
|
||||
const spendSeries = useMemo(() => timeSeries(analyticsDaily, 'spend'), [analyticsDaily]);
|
||||
|
||||
const newLeads = leads.filter(l => l.status === 'new');
|
||||
const slaBreaches = countBreaches(leads);
|
||||
const pendingApprovals = approvals.filter(a => a.status === 'pending').length;
|
||||
const blockedItems = approvals.filter(a => a.status === 'blocked').length;
|
||||
const tokenAlerts = accounts.filter(a => a.tokenStatus === 'expiring' || a.tokenStatus === 'revoked');
|
||||
|
||||
const recommendations = [
|
||||
slaBreaches > 0 && { icon: Zap, color: 'text-red-500', text: `${slaBreaches} lead(s) past the speed-to-lead SLA — contact now before they go cold.`, to: 'inbox' },
|
||||
blockedItems > 0 && { icon: AlertTriangle, color: 'text-red-500', text: `${blockedItems} creative blocked by compliance — resolve before publish.`, to: 'compliance' },
|
||||
tokenAlerts.length > 0 && { icon: AlertTriangle, color: 'text-amber-500', text: `${tokenAlerts.length} account token(s) expiring or revoked — reconnect to avoid lost leads.`, to: 'accounts' },
|
||||
pendingApprovals > 0 && { icon: ClipboardCheck, color: 'text-amber-500', text: `${pendingApprovals} item(s) pending approval in the Compliance Center.`, to: 'compliance' },
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* KPI row */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
||||
<KpiTile label="Ad Spend (7d)" value={money(m.spend)} icon={DollarSign} color="amber" sub={`ROAS ${m.roas.toFixed(1)}×`} />
|
||||
<KpiTile label="Leads" value={m.leads} icon={Users} color="blue" sub={`CPL ${money(m.cpl)}`} />
|
||||
<KpiTile label="Appointments" value={m.appointments} icon={CalendarCheck} color="purple" sub={`Show rate ${pct(m.showRate)}`} />
|
||||
<KpiTile label="Revenue (won)" value={money(m.revenue)} icon={TrendingUp} color="emerald" sub={`Close rate ${pct(m.closeRate)}`} />
|
||||
</div>
|
||||
|
||||
{/* Recommended actions */}
|
||||
{recommendations.length > 0 && (
|
||||
<Card title="Recommended actions" icon={Zap} subtitle="AI surfaces what needs attention — every action routes to a human review.">
|
||||
<div className="space-y-2">
|
||||
{recommendations.map((r, i) => {
|
||||
const Icon = r.icon;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onNavigate?.(r.to)}
|
||||
className="w-full flex items-center gap-3 text-left px-3 py-2.5 rounded-xl bg-zinc-50 dark:bg-white/5 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors group"
|
||||
>
|
||||
<Icon size={18} className={`${r.color} shrink-0`} />
|
||||
<span className="text-sm text-zinc-700 dark:text-zinc-200 flex-1">{r.text}</span>
|
||||
<ArrowRight size={16} className="text-zinc-400 group-hover:translate-x-0.5 transition-transform" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* Spend trend */}
|
||||
<Card title="Spend trend" subtitle="Daily ad spend across sources" icon={DollarSign} className="lg:col-span-2">
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<AreaChart data={spendSeries} margin={{ top: 5, right: 10, left: -10, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="spendGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#f59e0b" stopOpacity={0.4} />
|
||||
<stop offset="100%" stopColor="#f59e0b" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#71717a22" vertical={false} />
|
||||
<XAxis dataKey="date" tick={{ fill: '#71717a', fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fill: '#71717a', fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<Tooltip contentStyle={{ background: 'rgba(24,24,27,0.95)', border: '1px solid #3f3f46', borderRadius: 12, fontSize: 12 }} labelStyle={{ color: '#fff' }} formatter={(v) => [money(v), 'Spend']} />
|
||||
<Area type="monotone" dataKey="value" stroke="#f59e0b" strokeWidth={2} fill="url(#spendGrad)" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
|
||||
{/* Source-to-close funnel */}
|
||||
<Card title="Source-to-close funnel" subtitle="Leads → won jobs" icon={TrendingUp}>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart layout="vertical" data={funnelData} margin={{ top: 0, right: 24, left: 10, bottom: 0 }}>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis type="category" dataKey="stage" width={90} tick={{ fill: '#71717a', fontSize: 11, fontWeight: 600 }} axisLine={false} tickLine={false} />
|
||||
<Tooltip cursor={{ fill: 'transparent' }} contentStyle={{ background: 'rgba(24,24,27,0.95)', border: '1px solid #3f3f46', borderRadius: 12, fontSize: 12 }} />
|
||||
<Bar dataKey="value" radius={[0, 6, 6, 0]} barSize={26} label={{ position: 'right', fill: '#a1a1aa', fontSize: 11 }}>
|
||||
{funnelData.map((_, i) => <Cell key={i} fill={FUNNEL_COLORS[i]} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Leads by source */}
|
||||
<Card title="Leads by source" subtitle="Volume & cost per lead" icon={Users}>
|
||||
<div className="space-y-2">
|
||||
{bySource.sort((a, b) => b.leads - a.leads).map(s => (
|
||||
<div key={s.key} className="flex items-center justify-between px-3 py-2 rounded-lg bg-zinc-50 dark:bg-white/5">
|
||||
<PlatformBadge platformId={s.key} />
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<span className="text-zinc-500 dark:text-zinc-400">{s.leads} leads</span>
|
||||
<span className="font-mono font-semibold text-zinc-700 dark:text-zinc-200 w-16 text-right">{money(s.cpl)} CPL</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Active campaigns */}
|
||||
<Card title="Campaign status" subtitle={`${campaigns.filter(c => c.status === 'active').length} active`} icon={Megaphone}
|
||||
action={<Btn size="sm" variant="ghost" icon={ArrowRight} onClick={() => onNavigate?.('campaigns')}>All</Btn>}>
|
||||
<div className="space-y-2">
|
||||
{campaigns.slice(0, 5).map(c => (
|
||||
<div key={c.id} className="flex items-center justify-between px-3 py-2 rounded-lg bg-zinc-50 dark:bg-white/5 gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<PlatformBadge platformId={c.platform} showName={false} />
|
||||
<span className="text-sm text-zinc-700 dark:text-zinc-200 truncate">{c.name}</span>
|
||||
</div>
|
||||
<StatusChip status={c.status} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* New leads quick peek */}
|
||||
<Card title="New leads awaiting action" icon={Inbox} subtitle={`${newLeads.length} unassigned`}
|
||||
action={<Btn size="sm" variant="primary" icon={ArrowRight} onClick={() => onNavigate?.('inbox')}>Open inbox</Btn>}>
|
||||
{newLeads.length === 0 ? (
|
||||
<p className="text-sm text-zinc-500 py-4 text-center">No new leads — you're all caught up.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{newLeads.map(l => (
|
||||
<div key={l.id} className="flex items-center justify-between px-3 py-2 rounded-lg bg-zinc-50 dark:bg-white/5 gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<PlatformBadge platformId={l.platform} showName={false} />
|
||||
<span className="text-sm font-medium text-zinc-800 dark:text-zinc-100 truncate">{l.name}</span>
|
||||
{l.triage.urgency === 'high' && <StatusChip status="blocked" label="Urgent" />}
|
||||
</div>
|
||||
<span className="text-xs text-zinc-400">{l.triage.serviceType}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* UnifiedLeadInbox — all inbound ad leads by source with AI triage, duplicate
|
||||
* warnings, consent proof, appointment status, and one-click assign/schedule
|
||||
* (spec §4, §8). No lead remains unassigned; SLA breaches surface as urgent.
|
||||
*/
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Inbox, Sparkles, AlertTriangle, Copy, ShieldCheck, ShieldAlert,
|
||||
UserPlus, Merge, Phone, Mail, MapPin, Filter, CalendarCheck, Eye,
|
||||
FileText, Trophy, CheckCircle2, Timer, AlarmClock, ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { useSocialAds } from '../SocialAdsContext.jsx';
|
||||
import { PLATFORMS } from '../data/platforms.js';
|
||||
import { LIFECYCLE } from '../engine/attribution.js';
|
||||
import { slaState, fmtClock, countBreaches } from '../data/sla.js';
|
||||
import { Card, SectionHeader, PlatformBadge, StatusChip, Btn } from '../components/ui.jsx';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const REPS = [
|
||||
{ id: 'rep_jordan', name: 'Jordan (Field)' },
|
||||
{ id: 'rep_sam', name: 'Sam (Inside sales)' },
|
||||
{ id: 'rep_dispatch', name: 'On-call dispatch' },
|
||||
];
|
||||
|
||||
// Next lifecycle step → button label + icon (spec §5 closed loop).
|
||||
const NEXT_STEP = {
|
||||
lead: { to: 'appointment', label: 'Schedule appt', icon: CalendarCheck },
|
||||
appointment: { to: 'showed', label: 'Mark showed', icon: Eye },
|
||||
showed: { to: 'estimate', label: 'Send estimate', icon: FileText },
|
||||
estimate: { to: 'won', label: 'Mark won', icon: Trophy },
|
||||
};
|
||||
|
||||
// Demo default deal size by service type (used when sending an estimate / winning).
|
||||
const DEFAULT_VALUE = { commercial: 85000, replacement: 28000, storm: 24000, emergency: 12000, maintenance: 6500, referral: 0 };
|
||||
|
||||
const STAGE_LABEL = { lead: 'Lead', appointment: 'Appointment', showed: 'Showed', estimate: 'Estimate', won: 'Won', lost: 'Lost' };
|
||||
|
||||
const URGENCY = {
|
||||
high: 'text-red-500 bg-red-500/10',
|
||||
medium: 'text-amber-500 bg-amber-500/10',
|
||||
low: 'text-zinc-500 bg-zinc-500/10',
|
||||
};
|
||||
|
||||
function QualityBar({ value }) {
|
||||
const color = value >= 75 ? 'bg-emerald-500' : value >= 50 ? 'bg-amber-500' : 'bg-red-500';
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-14 h-1.5 rounded-full bg-zinc-200 dark:bg-zinc-700 overflow-hidden">
|
||||
<div className={`h-full ${color}`} style={{ width: `${value}%` }} />
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-zinc-500">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UnifiedLeadInbox() {
|
||||
const { leads, assignLead, mergeLead, advanceLead } = useSocialAds();
|
||||
const navigate = useNavigate();
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [platformFilter, setPlatformFilter] = useState('all');
|
||||
|
||||
// Ticking clock drives the live speed-to-lead SLA countdowns (spec §10).
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNowMs(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const visible = useMemo(() => leads.filter(l => {
|
||||
if (filter === 'new' && l.status !== 'new') return false;
|
||||
if (filter === 'duplicates' && !l.duplicateOf) return false;
|
||||
if (filter === 'urgent' && l.triage.urgency !== 'high') return false;
|
||||
if (filter === 'breached' && !slaState(l, nowMs).breached) return false;
|
||||
if (platformFilter !== 'all' && l.platform !== platformFilter) return false;
|
||||
return true;
|
||||
}), [leads, filter, platformFilter, nowMs]);
|
||||
|
||||
const breaches = countBreaches(leads, nowMs);
|
||||
|
||||
const counts = {
|
||||
all: leads.length,
|
||||
new: leads.filter(l => l.status === 'new').length,
|
||||
urgent: leads.filter(l => l.triage.urgency === 'high').length,
|
||||
duplicates: leads.filter(l => l.duplicateOf).length,
|
||||
breached: breaches,
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader icon={Inbox} title="Unified Lead Inbox" description="Every ad lead across sources, with AI triage, consent proof, and one-click routing into a nurture playbook.">
|
||||
<Btn size="sm" variant="outline" icon={ExternalLink} onClick={() => navigate('/ads/lp')}>Preview public form</Btn>
|
||||
</SectionHeader>
|
||||
|
||||
{/* Speed-to-lead SLA breach banner (spec §11 "Lost leads": breach creates task/alert) */}
|
||||
{breaches > 0 && (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-4 rounded-xl border border-red-200 dark:border-red-500/30 bg-red-50 dark:bg-red-500/10 px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-sm text-red-700 dark:text-red-400">
|
||||
<AlarmClock size={18} className="animate-pulse shrink-0" />
|
||||
<span><b>{breaches}</b> lead{breaches > 1 ? 's' : ''} past the speed-to-lead SLA — contact now before they go cold.</span>
|
||||
</div>
|
||||
<Btn size="sm" variant="danger" icon={AlertTriangle}
|
||||
onClick={() => { setFilter('breached'); toast.error(`${breaches} SLA-breach task${breaches > 1 ? 's' : ''} created for managers`); }}>
|
||||
Create {breaches} task{breaches > 1 ? 's' : ''}
|
||||
</Btn>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||
{[['all', 'All'], ['new', 'New'], ['urgent', 'Urgent'], ['breached', 'SLA breach'], ['duplicates', 'Duplicates']].map(([id, label]) => (
|
||||
<button key={id} onClick={() => setFilter(id)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors ${filter === id ? 'bg-amber-500 text-white' : 'bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10'}`}>
|
||||
{label} <span className="opacity-60">{counts[id]}</span>
|
||||
</button>
|
||||
))}
|
||||
<span className="w-px h-5 bg-zinc-200 dark:bg-white/10 mx-1" />
|
||||
<Filter size={14} className="text-zinc-400" />
|
||||
<select value={platformFilter} onChange={e => setPlatformFilter(e.target.value)}
|
||||
className="rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-2 py-1.5 text-xs text-zinc-700 dark:text-zinc-200 focus:outline-none focus:ring-2 focus:ring-amber-500">
|
||||
<option value="all">All sources</option>
|
||||
{PLATFORMS.map(p => <option key={p.id} value={p.id}>{p.short}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{visible.length === 0 && <p className="text-sm text-zinc-500 text-center py-8">No leads match this filter.</p>}
|
||||
{visible.map(l => {
|
||||
const assignedRep = REPS.find(r => r.id === l.assignedTo);
|
||||
const sla = slaState(l, nowMs);
|
||||
return (
|
||||
<Card key={l.id} className={`!h-auto ${l.status === 'merged' ? 'opacity-50' : ''} ${sla.breached ? 'ring-1 ring-red-400/50' : ''}`} pad="p-4">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-4">
|
||||
{/* Identity */}
|
||||
<div className="flex items-start gap-3 min-w-0 lg:w-64 shrink-0">
|
||||
<PlatformBadge platformId={l.platform} showName={false} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<h4 className="font-bold text-sm text-zinc-900 dark:text-white truncate">{l.name}</h4>
|
||||
{l.duplicateOf && <span className="inline-flex items-center gap-0.5 text-[10px] font-bold text-amber-500"><Copy size={10} /> Dup</span>}
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-500 space-y-0.5 mt-0.5">
|
||||
{l.phone && <p className="flex items-center gap-1 truncate"><Phone size={10} /> {l.phone}</p>}
|
||||
{l.email && <p className="flex items-center gap-1 truncate"><Mail size={10} /> {l.email}</p>}
|
||||
<p className="flex items-center gap-1 truncate"><MapPin size={10} /> {l.address}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI triage */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Sparkles size={12} className="text-purple-500" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-purple-500">AI triage</span>
|
||||
<span className="text-[10px] text-zinc-400">({Math.round(l.triage.confidence * 100)}% conf.)</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-mono px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-white/10 text-zinc-700 dark:text-zinc-200">{l.triage.serviceType}</span>
|
||||
<span className={`text-[10px] font-bold uppercase px-1.5 py-0.5 rounded ${URGENCY[l.triage.urgency]}`}>{l.triage.urgency}</span>
|
||||
<span className="text-xs text-zinc-500">{l.triage.role.replace(/_/g, ' ')}</span>
|
||||
<QualityBar value={l.triage.quality} />
|
||||
</div>
|
||||
{l.triage.confidence < 0.6 && (
|
||||
<p className="text-[10px] text-amber-600 dark:text-amber-400/80 mt-1 flex items-center gap-1"><AlertTriangle size={10} /> Low confidence — review before auto-nurture.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Consent + lifecycle */}
|
||||
<div className="flex flex-col items-start gap-1.5 lg:w-44 shrink-0">
|
||||
{l.consent
|
||||
? <span className="inline-flex items-center gap-1 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400"><ShieldCheck size={13} /> Consent on file</span>
|
||||
: <span className="inline-flex items-center gap-1 text-[11px] font-semibold text-red-500"><ShieldAlert size={13} /> No consent</span>}
|
||||
{sla.eligible && <SlaTimer sla={sla} />}
|
||||
<StageStepper stage={l.stage} />
|
||||
{assignedRep && <span className="text-[11px] text-zinc-500">→ {assignedRep.name}{l.firstContactSec != null && ` · ${fmtClock(l.firstContactSec)} to contact`}</span>}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{l.duplicateOf && l.status !== 'merged' && (
|
||||
<Btn size="sm" variant="outline" icon={Merge} onClick={() => mergeLead(l.id)}>Merge</Btn>
|
||||
)}
|
||||
{l.status === 'new' && !l.duplicateOf && (
|
||||
<AssignMenu onAssign={(rep) => assignLead(l.id, rep)} disabled={!l.consent} />
|
||||
)}
|
||||
{l.status !== 'new' && l.status !== 'merged' && !l.duplicateOf && (
|
||||
l.stage === 'won'
|
||||
? <span className="inline-flex items-center gap-1 text-xs font-bold text-emerald-600 dark:text-emerald-400"><CheckCircle2 size={14} /> ${l.dealValue.toLocaleString()}</span>
|
||||
: NEXT_STEP[l.stage] && (() => {
|
||||
const step = NEXT_STEP[l.stage];
|
||||
const value = step.to === 'won' || step.to === 'estimate'
|
||||
? (l.dealValue || DEFAULT_VALUE[l.triage.serviceType] || 20000) : 0;
|
||||
const premium = step.to === 'won' && l.triage.quality >= 80;
|
||||
return <Btn size="sm" variant="primary" icon={step.icon} onClick={() => advanceLead(l.id, step.to, { value, premium })}>{step.label}</Btn>;
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!l.consent && l.status === 'new' && (
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400/80 mt-2 flex items-center gap-1">
|
||||
<ShieldAlert size={12} /> Missing consent proof — restricted to manual/transactional follow-up only.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SlaTimer({ sla }) {
|
||||
const ratio = Math.min(1, sla.elapsed / sla.target);
|
||||
const danger = sla.breached || sla.remaining < sla.target * 0.25;
|
||||
const color = sla.breached ? 'text-red-500' : danger ? 'text-amber-500' : 'text-emerald-500';
|
||||
const bar = sla.breached ? 'bg-red-500' : danger ? 'bg-amber-500' : 'bg-emerald-500';
|
||||
const Icon = sla.breached ? AlarmClock : Timer;
|
||||
return (
|
||||
<div className="w-full" title="Speed-to-lead SLA — time to first contact">
|
||||
<div className={`flex items-center gap-1 text-[11px] font-semibold ${color}`}>
|
||||
<Icon size={13} className={sla.breached ? 'animate-pulse' : ''} />
|
||||
{sla.breached
|
||||
? <span>SLA breach {fmtClock(sla.remaining)}</span>
|
||||
: <span>{fmtClock(sla.remaining)} to SLA</span>}
|
||||
</div>
|
||||
<div className="w-full h-1 rounded-full bg-zinc-200 dark:bg-zinc-700 overflow-hidden mt-0.5">
|
||||
<div className={`h-full ${bar} transition-all duration-1000`} style={{ width: `${ratio * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StageStepper({ stage }) {
|
||||
if (stage === 'lost') return <StatusChip status="merged" label="Lost" />;
|
||||
const rank = LIFECYCLE.indexOf(stage);
|
||||
return (
|
||||
<div className="flex items-center gap-1" title={`Stage: ${STAGE_LABEL[stage]}`}>
|
||||
{LIFECYCLE.map((s, i) => (
|
||||
<React.Fragment key={s}>
|
||||
<span className={`w-2 h-2 rounded-full ${i <= rank ? 'bg-emerald-500' : 'bg-zinc-300 dark:bg-zinc-700'}`} />
|
||||
{i < LIFECYCLE.length - 1 && <span className={`w-2.5 h-px ${i < rank ? 'bg-emerald-500' : 'bg-zinc-300 dark:bg-zinc-700'}`} />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<span className="text-[10px] font-semibold text-zinc-500 ml-1">{STAGE_LABEL[stage]}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssignMenu({ onAssign, disabled }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="relative">
|
||||
<Btn size="sm" variant="primary" icon={UserPlus} disabled={disabled} onClick={() => setOpen(o => !o)}>Assign</Btn>
|
||||
{open && !disabled && (
|
||||
<div className="absolute right-0 mt-1 w-44 rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 shadow-xl z-20 p-1">
|
||||
{REPS.map(r => (
|
||||
<button key={r.id} onClick={() => { onAssign(r.id); setOpen(false); }}
|
||||
className="w-full text-left px-3 py-2 rounded-lg text-xs text-zinc-700 dark:text-zinc-200 hover:bg-amber-500/10 hover:text-amber-600 dark:hover:text-amber-400 transition-colors">
|
||||
{r.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user