diff --git a/app/api/brand-deals/draft/route.ts b/app/api/brand-deals/draft/route.ts new file mode 100644 index 00000000..bcc08ac0 --- /dev/null +++ b/app/api/brand-deals/draft/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { getBrandDeals, invalidateBrandDealsCache } from '@/lib/brand-deals'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const ID = z.string().regex(/^r-?\d+$/); + +async function gsendBin(): Promise { + const os = await import('node:os'); + return process.env.GSEND_BIN ?? `${os.homedir()}/bin/gsend`; +} + +/** Read a pending draft's current body (for the GladOS edit flow). */ +export async function GET(req: Request) { + const id = new URL(req.url).searchParams.get('id') ?? ''; + if (!ID.safeParse(id).success) { + return NextResponse.json({ ok: false, error: 'bad id' }, { status: 400 }); + } + const { execFile } = await import('node:child_process'); + const bin = await gsendBin(); + const result = await new Promise<{ ok: boolean; out: string }>((resolve) => { + execFile(bin, ['draft-get', id], { timeout: 30_000, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => { + resolve({ ok: !err, out: (err ? stderr || String(err) : stdout).trim() }); + }); + }); + // Soft-fail like the connectors: a missing binary or vanished draft is an + // honest {ok:false}, not a 5xx. + if (!result.ok) return NextResponse.json({ ok: false, error: result.out }); + try { + return NextResponse.json({ ok: true, draft: JSON.parse(result.out) }); + } catch { + return NextResponse.json({ ok: false, error: 'unparseable draft' }); + } +} + +/** Update a pending draft's body. gsend locks recipient/subject/thread to + * the existing draft in code — an edit can never redirect the email. */ +export async function POST(req: Request) { + let body: { dealId: string; text: string }; + try { + body = z.object({ dealId: ID, text: z.string().min(1).max(20_000) }).parse(await req.json()); + } catch (e) { + return NextResponse.json({ ok: false, error: String(e) }, { status: 400 }); + } + const { execFile } = await import('node:child_process'); + const bin = await gsendBin(); + const result = await new Promise<{ ok: boolean; out: string }>((resolve) => { + const child = execFile(bin, ['draft-update', body.dealId], { timeout: 60_000 }, (err, stdout, stderr) => { + resolve({ ok: !err, out: (err ? stderr || String(err) : stdout).trim() }); + }); + child.stdin?.write(body.text); + child.stdin?.end(); + }); + invalidateBrandDealsCache(); + if (!result.ok) return NextResponse.json({ ok: false, error: result.out }, { status: 502 }); + return NextResponse.json({ ok: true, snapshot: await getBrandDeals() }); +} diff --git a/app/api/brand-deals/override/route.ts b/app/api/brand-deals/override/route.ts new file mode 100644 index 00000000..c1dd9a3f --- /dev/null +++ b/app/api/brand-deals/override/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { getDb } from '@/lib/data'; +import { BrandDealOutcomeSchema } from '@/lib/schemas'; +import { getBrandDeals, invalidateBrandDealsCache } from '@/lib/brand-deals'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const Body = z.object({ + dealId: z.string().min(1), + outcome: BrandDealOutcomeSchema.optional(), + amountUsd: z.number().int().positive().nullable().optional(), +}); + +/** Set a deal's outcome (won/lost/open) and/or its quoted amount. GladOS + * tracking overlay only — never touches bdpilot state, never emails. */ +export async function POST(req: Request) { + let body: z.infer; + try { + body = Body.parse(await req.json()); + } catch (e) { + return NextResponse.json({ ok: false, error: String(e) }, { status: 400 }); + } + if (body.outcome === undefined && body.amountUsd === undefined) { + return NextResponse.json({ ok: false, error: 'nothing to set' }, { status: 400 }); + } + const db = getDb(); + const existing = db.brandDeals.get(body.dealId); + db.brandDeals.upsert({ + dealId: body.dealId, + outcome: body.outcome ?? existing?.outcome ?? 'open', + amountUsd: body.amountUsd !== undefined ? body.amountUsd : (existing?.amountUsd ?? null), + updatedAt: new Date().toISOString(), + }); + invalidateBrandDealsCache(); + return NextResponse.json({ ok: true, snapshot: await getBrandDeals() }); +} diff --git a/app/api/brand-deals/route.ts b/app/api/brand-deals/route.ts new file mode 100644 index 00000000..bceb3ff2 --- /dev/null +++ b/app/api/brand-deals/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from 'next/server'; +import { getBrandDeals } from '@/lib/brand-deals'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET() { + return NextResponse.json(await getBrandDeals()); +} diff --git a/app/api/brand-deals/send/route.ts b/app/api/brand-deals/send/route.ts new file mode 100644 index 00000000..b623322a --- /dev/null +++ b/app/api/brand-deals/send/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { getBrandDeals, invalidateBrandDealsCache } from '@/lib/brand-deals'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** Approve a pending draft from GladOS: same verified gsend path as the + * Telegram tap. Gmail deletes the draft on send, so a send is atomic — + * a later Telegram tap on the same deal fails harmlessly. */ +export async function POST(req: Request) { + let dealId: string; + try { + ({ dealId } = z.object({ dealId: z.string().regex(/^r-?\d+$/) }).parse(await req.json())); + } catch (e) { + return NextResponse.json({ ok: false, error: String(e) }, { status: 400 }); + } + const { execFile } = await import('node:child_process'); + const os = await import('node:os'); + const gsend = process.env.GSEND_BIN ?? `${os.homedir()}/bin/gsend`; + const result = await new Promise<{ ok: boolean; out: string }>((resolve) => { + execFile(gsend, ['send', dealId], { timeout: 60_000 }, (err, stdout, stderr) => { + resolve({ ok: !err, out: (err ? stderr || String(err) : stdout).trim() }); + }); + }); + invalidateBrandDealsCache(); + if (!result.ok) return NextResponse.json({ ok: false, error: result.out }, { status: 502 }); + return NextResponse.json({ ok: true, detail: result.out, snapshot: await getBrandDeals() }); +} diff --git a/app/brand-deals/page.tsx b/app/brand-deals/page.tsx new file mode 100644 index 00000000..299c322c --- /dev/null +++ b/app/brand-deals/page.tsx @@ -0,0 +1,12 @@ +import { getBrandDeals } from '@/lib/brand-deals'; +import { DealBoard } from '@/components/brand-deals/DealBoard'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +// GladOS Futurism (Zentra) layout: the title row lives inside the floating +// slab, so this page skips the shared PageHeader deliberately. +export default async function BrandDealsPage() { + const snapshot = await getBrandDeals(); + return ; +} diff --git a/components/PipelineChart.tsx b/components/PipelineChart.tsx new file mode 100644 index 00000000..0569f53b --- /dev/null +++ b/components/PipelineChart.tsx @@ -0,0 +1,289 @@ +'use client'; + +/** + * PipelineChart — the "Pipeline" widget's design lifted out of + * components/brand-deals/DealBoard.tsx (`Funnel`, ~2026-08-19) so it can be + * reused wherever a set of stages/categories deserves the same hero + * treatment: column headers (42px numeral + label + mono $ line, each a + * working filter button) above an SVG of hatched, gradient-filled step + * columns that fade out at the bottom via a mask, with an optional AI-style + * search bar melting out of the chart base. DealBoard now renders its + * Pipeline card through this component — see the `Funnel`-shaped usage + * there for the reference call. + */ + +import { useEffect, useState } from 'react'; + +export type PipelineStage = { + label: string; + value: number; + /** Optional dollar figure for the mono line under the numeral. */ + usd?: number; + /** Text shown next to the value in the tooltip pill over the active column. */ + note?: string; + /** Dim this column's header (e.g. it doesn't match an active search) without removing it. */ + dim?: boolean; +}; + +const EASE = 'cubic-bezier(.2,.7,.2,1)'; + +/** Same ease-out cubic count-up DealBoard's stat numerals use. */ +export function useCountUp(target: number, dur = 1400): number { + const [v, setV] = useState(0); + useEffect(() => { + let raf = 0; + const t0 = performance.now(); + const tick = (t: number) => { + const p = Math.min(1, (t - t0) / dur); + const e = 1 - Math.pow(1 - p, 3); + setV(Math.round(target * e)); + if (p < 1) raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [target, dur]); + return v; +} + +/** The exact caret-blink animation shorthand for a search-bar slot's cursor span. */ +export const CARET_ANIMATION = 'pc-caret 1.1s step-end infinite'; + +function AnimatedNumeral({ + value, + format, + masked, +}: { + value: number; + format: (v: number) => string; + masked?: boolean; +}) { + const v = useCountUp(value); + return {format(v)}; +} + +function MaybeMasked({ text, masked }: { text: string; masked?: boolean }) { + return {text}; +} + +export interface PipelineChartProps { + stages: T[]; + /** Index of the stage whose column renders solid/filled and drives the tooltip. */ + active: number; + onSelect?: (stage: T, index: number) => void; + /** Formats the big numeral (defaults to a plain rounded integer, like a count). */ + formatValue?: (value: number) => string; + /** Formats the mono `usd` line (defaults to `$12,345`). */ + formatUsd?: (value: number) => string; + /** Blur every rendered figure (numeral, mono line, tooltip) — a hide-values mode. */ + maskValues?: boolean; + /** Renders inside the gradient backdrop-blur wrapper melting out of the chart base. */ + searchSlot?: React.ReactNode; + numeralSize?: number; + svgHeight?: number; + showAxis?: boolean; + showTooltip?: boolean; + contentClassName?: string; + className?: string; +} + +export function PipelineChart({ + stages, + active, + onSelect, + formatValue = (v) => String(Math.round(v)), + formatUsd = (v) => `$${Math.round(v).toLocaleString('en-US')}`, + maskValues = false, + searchSlot, + numeralSize = 42, + svgHeight = 220, + showAxis = true, + showTooltip = true, + contentClassName = 'px-6 pt-4', + className = '', +}: PipelineChartProps) { + const max = Math.max(...stages.map((s) => s.value), 1); + const W = 1000; + const SVG_H = svgHeight; + const H = SVG_H + 40; + const AXIS = showAxis ? 44 : 0; + const colW = (W - AXIS) / Math.max(stages.length, 1); + const barW = colW * 0.62; + const h = (v: number) => (v === 0 ? 10 : 34 + (v / max) * (H - 54)); + const x0 = (i: number) => AXIS + i * colW + 4; + const ticks = showAxis ? [0, 0.25, 0.5, 0.75, 1].map((f) => Math.round(max * f)) : []; + const activeStage: T | undefined = stages[active]; + const activeTopPx = activeStage ? ((H - h(activeStage.value)) / H) * SVG_H : 0; + + return ( +
+ +
+ {/* column headers: numerals rule the card; every header is a working filter button */} +
+ {stages.map((s, i) => { + const isActive = i === active; + return ( + + ); + })} +
+ +
+ + {/* tooltip pill anchored just above the active stage's top edge */} + {showTooltip && activeStage?.note && ( +
{ + // clamp near the edges — a centered pill over the first or + // last column half-clips outside the card + const centerPct = ((AXIS + active * colW + 4 + barW / 2) / W) * 100; + const tx = centerPct < 15 ? '0%' : centerPct > 85 ? '-100%' : '-50%'; + return { + left: `${centerPct}%`, + top: Math.max(6, activeTopPx - 40), + transform: `translateX(${tx})`, + background: 'color-mix(in oklab, var(--bg) 72%, transparent)', + boxShadow: '0 8px 24px -10px rgba(0,0,0,.6)', + animation: `pc-fadein .6s ${EASE} 1100ms both`, + }; + })()} + > + + + + {activeStage.note} +
+ )} +
+
+ + {/* AI prompt bar melting out of the chart base — no top edge */} + {searchSlot && ( +
+
+ {searchSlot} +
+
+ )} +
+ ); +} diff --git a/components/brand-deals/DealBoard.tsx b/components/brand-deals/DealBoard.tsx new file mode 100644 index 00000000..4df56dfa --- /dev/null +++ b/components/brand-deals/DealBoard.tsx @@ -0,0 +1,951 @@ +'use client'; + +/** + * Brand Deals — GladOS Futurism (Zentra / UI Example 1) translated to dark + * glados tokens per the operator, 2026-08-19: the page floats as one slab, numerals + * rule, data owns all chroma (one hue per domain), hatched stepped funnel as + * the hero, an AI prompt bar melting out of it (wired as a live filter), + * barber-pole meters, step-line + dot-matrix minis, exactly ONE gradient + * insight card. Read-only: the send gate stays the operator's Telegram tap. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { X, Search, Sparkles, MessagesSquare, Send, RefreshCw, Lightbulb, ExternalLink } from 'lucide-react'; +import { Badge, Label } from '@/components/terminal'; +import { PipelineChart, useCountUp, CARET_ANIMATION, type PipelineStage } from '@/components/PipelineChart'; +import type { BrandDealsSnapshot, Deal, DealEvent, DealOutcome } from '@/lib/brand-deals'; + +const BOT_URL = ''; + +function fmtUsd(n: number): string { + return `$${Math.round(n).toLocaleString('en-US')}`; +} + +const BTN_SOLID = + 'inline-flex items-center gap-1.5 rounded-[9px] bg-os-accent px-3.5 py-1.5 text-[12.5px] font-medium text-white transition-colors hover:bg-[var(--accent-2)] disabled:opacity-50'; +const BTN_GHOST = + 'inline-flex items-center gap-1.5 rounded-[9px] border border-os-border-strong bg-os-surface2 px-3.5 py-1.5 text-[12.5px] text-os-muted transition-colors hover:bg-os-surface3 hover:text-os-text disabled:opacity-50'; + +// One hue per data domain (anti-drift rule). +const HUE = { + accent: 'var(--accent)', // pipeline / hero (Console violet) + amber: 'var(--warn)', // needs approval + cobalt: 'var(--ramp-1)', // awaiting brand (ramp blue) + violet: 'var(--ramp-4)', // unique (ramp orchid) + send: 'var(--send-activity)', // send activity (light ramp blue under console) +}; + +const GRAIN = + "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.55'/%3E%3C/svg%3E\")"; + +const EASE = 'cubic-bezier(.2,.7,.2,1)'; + +function timeAgo(iso: string | null): string { + if (!iso) return ''; + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return ''; + const mins = Math.max(0, Math.round((Date.now() - then) / 60_000)); + if (mins < 60) return `${mins}m ago`; + const hrs = Math.round(mins / 60); + if (hrs < 48) return `${hrs}h ago`; + return `${Math.round(hrs / 24)}d ago`; +} + +/** Card shell: near-bg surface, radius 20, soft dual shadow, stagger-in, + * 2px hover lift per the Zentra motion spec. */ +function Card({ + children, + delay = 0, + className = '', + style, +}: { + children: React.ReactNode; + delay?: number; + className?: string; + style?: React.CSSProperties; +}) { + return ( +
+ {children} +
+ ); +} + +/** Card header with a REAL kebab: refresh the data or open the approval bot. */ +function CardHead({ title, onRefresh }: { title: string; onRefresh: () => void }) { + const [open, setOpen] = useState(false); + return ( +
+

{title}

+ + {open && ( + <> +
setOpen(false)} aria-hidden="true" /> +
+ + setOpen(false)} + className="flex w-full items-center gap-2.5 px-3.5 py-2 text-left text-[12.5px] text-os-muted transition-colors hover:bg-[color-mix(in_oklab,var(--text)_6%,transparent)] hover:text-os-text" + > + Open approval bot + +
+ + )} +
+ ); +} + +/** Status meter: label/display row over a static hatch fill with a hue glow. */ +function Meter({ label, frac: rawFrac, display, hue, delay }: { label: string; frac: number; display: string; hue: string; delay: number }) { + const frac = Number.isFinite(rawFrac) && rawFrac > 0 ? Math.max(0.02, Math.min(1, rawFrac)) : 0.02; + return ( +
+
+ {label} + {display} +
+
+
+
+
+ ); +} + +type FunnelStage = PipelineStage & { usd: number; note: string; filter: string }; + +function StatNumber({ value }: { value: number }) { + const v = useCountUp(value); + return <>{v}; +} + +function DollarStat({ value }: { value: number }) { + const v = useCountUp(value); + return <>{fmtUsd(v)}; +} + +/** Stepped line over vertical pinstripes — sends binned to 15 min with the + * quiet slots kept, so the line has real steps. */ +function StepLine({ events }: { events: DealEvent[] }) { + const buckets = useMemo(() => { + const stamps = events + .filter((e) => e.kind === 'sent') + .map((e) => new Date(e.at).getTime()) + .filter((t) => !Number.isNaN(t)) + .sort((a, b) => a - b); + if (stamps.length === 0) return [] as Array<{ label: string; count: number }>; + const SLOT = 15 * 60_000; + const first = Math.floor(stamps[0] / SLOT) * SLOT - SLOT; + const last = Math.floor(stamps[stamps.length - 1] / SLOT) * SLOT + SLOT; + const out: Array<{ label: string; count: number }> = []; + for (let t = first; t <= last; t += SLOT) { + const d = new Date(t); + out.push({ + label: `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`, + count: stamps.filter((s) => s >= t && s < t + SLOT).length, + }); + } + return out; + }, [events]); + if (buckets.length === 0) return
No sends recorded yet.
; + const W = 600; + const H = 150; + const max = Math.max(...buckets.map((b) => b.count), 1); + const stepW = W / buckets.length; + const y = (c: number) => H - 14 - (c / max) * (H - 60); + let path = `M 0 ${y(buckets[0].count)}`; + buckets.forEach((b, i) => { + path += ` H ${(i + 1) * stepW}`; + if (i < buckets.length - 1) path += ` V ${y(buckets[i + 1].count)}`; + }); + const peakIdx = buckets.reduce((bi, b, i) => (b.count > buckets[bi].count ? i : bi), 0); + const area = `${path} V ${H} H 0 Z`; + return ( +
+ + {/* peak pill anchored on the peak step (reference grammar) */} +
+ + {buckets[peakIdx].count} + {' '} + at {buckets[peakIdx].label} +
+
+ {buckets[0].label} + {buckets[buckets.length - 1].label} +
+
+ ); +} + +/** Waffle dot-matrix mini — chain-depth distribution. */ +function DotMatrix({ deals, hue }: { deals: Deal[]; hue: string }) { + const cols = useMemo(() => { + const defs: Array<{ label: string; test: (n: number) => boolean }> = [ + { label: '1', test: (n) => n === 1 }, + { label: '2-3', test: (n) => n >= 2 && n <= 3 }, + { label: '4-5', test: (n) => n >= 4 && n <= 5 }, + { label: '6-9', test: (n) => n >= 6 && n <= 9 }, + { label: '10+', test: (n) => n >= 10 }, + ]; + return defs.map((d) => ({ label: d.label, count: deals.filter((x) => x.chainLen > 0 && d.test(x.chainLen)).length })); + }, [deals]); + const max = Math.max(...cols.map((c) => c.count), 1); + return ( +
+ {cols.map((c, ci) => { + const dots = Math.max(c.count === 0 ? 0 : 1, Math.round((c.count / max) * 6)); + const strength = c.count === max ? 1 : c.count >= max * 0.6 ? 0.55 : 0.25; + return ( +
+
+ {Array.from({ length: dots }, (_, i) => ( + + ))} + {dots === 0 && } +
+ {c.label} +
+ ); + })} +
+ ); +} + +type Filter = 'all' | 'approval' | 'sent' | 'unique' | 'won' | 'lost' | 'closed'; + +const FILTER_TO_STAGE_IDX: Record = { + all: 2, + unique: 2, + approval: 2, + sent: 1, + won: 3, + lost: 3, + closed: 3, +}; + +export function DealBoard({ initial }: { initial: BrandDealsSnapshot }) { + const [snap, setSnap] = useState(initial); + const [selectedId, setSelectedId] = useState(null); + const [query, setQuery] = useState(''); + const [stageFilter, setStageFilter] = useState('all'); + const [busy, setBusy] = useState(null); + const [armedSend, setArmedSend] = useState(false); + const [notice, setNotice] = useState(null); + const [draftText, setDraftText] = useState(null); + const [quoteInput, setQuoteInput] = useState(''); + const pending = useRef(false); + + const refresh = useCallback(async () => { + if (pending.current) return; + pending.current = true; + try { + const r = await fetch('/api/brand-deals'); + if (r.ok) setSnap(await r.json()); + } catch { + /* keep last good */ + } finally { + pending.current = false; + } + }, []); + + /** POST an action; adopt the fresh snapshot the API returns. */ + const act = useCallback(async (key: string, url: string, payload: unknown): Promise => { + setBusy(key); + setNotice(null); + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + const body = await r.json(); + if (body.snapshot) setSnap(body.snapshot); + if (!body.ok) setNotice(body.error ?? 'action failed'); + return Boolean(body.ok); + } catch (e) { + setNotice(String(e)); + return false; + } finally { + setBusy(null); + } + }, []); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setSelectedId(null); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); + + useEffect(() => { + const t = setInterval(refresh, 30_000); + return () => clearInterval(t); + }, [refresh]); + + // Reset the per-deal editors when the drawer target changes. + useEffect(() => { + setArmedSend(false); + setDraftText(null); + setNotice(null); + const d = snap.deals.find((x) => x.id === selectedId); + setQuoteInput(d?.quotedUsd != null ? String(d.quotedUsd) : ''); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedId]); + + const { counts, volume } = snap; + const approval = snap.deals.filter((d) => d.stage === 'approval'); + const selected = snap.deals.find((d) => d.id === selectedId) ?? null; + const uniqueDeals = snap.deals.filter((d) => d.tag === 'UNIQUE'); + const totalMsgs = snap.deals.reduce((n, d) => n + d.chainLen, 0); + const sentToday = snap.events.filter((e) => e.kind === 'sent').length; + + const slashToken = query.trim().startsWith('/') ? query.trim().split(/\s+/)[0] : null; + const textQuery = (slashToken ? query.trim().slice(slashToken.length) : query).trim().toLowerCase(); + const matchesFilter = (d: Deal, f: Filter): boolean => { + if (f === 'all') return true; + if (f === 'unique') return d.tag === 'UNIQUE'; + if (f === 'won' || f === 'lost') return d.outcome === f; + if (f === 'closed') return d.stage === 'closed'; + return d.stage === f; + }; + const tokenFilter: Filter | null = + slashToken === '/unique' + ? 'unique' + : slashToken === '/approval' + ? 'approval' + : slashToken === '/won' + ? 'won' + : slashToken === '/lost' + ? 'lost' + : null; + const filtered = snap.deals.filter((d) => { + if (!matchesFilter(d, stageFilter)) return false; + if (tokenFilter && !matchesFilter(d, tokenFilter)) return false; + if (textQuery) { + const hay = `${d.brand} ${d.email} ${d.subject} ${d.tagReason} ${d.summary ?? ''}`.toLowerCase(); + if (!hay.includes(textQuery)) return false; + } + return true; + }); + + const quotedUsdAll = snap.deals.reduce((n, d) => n + (d.quotedUsd ?? 0), 0); + const funnelStages: FunnelStage[] = [ + { label: 'Announced', value: snap.deals.length, usd: quotedUsdAll, note: 'every tracked deal thread', filter: 'all' }, + { label: 'Sent', value: counts.sent, usd: volume.awaitingUsd, note: 'awaiting the brand', filter: 'sent' }, + { label: 'Needs approval', value: counts.approval, usd: volume.approvalUsd, note: 'approve here or in Telegram', filter: 'approval' }, + { label: 'Closed', value: counts.closed, usd: volume.wonUsd + volume.lostUsd, note: `${counts.won} won · ${counts.lost} lost`, filter: 'closed' }, + ]; + const activeStageIdx = FILTER_TO_STAGE_IDX[stageFilter]; + + return ( +
+ + + {/* The slab: the whole view floats as one surface. */} +
+ {/* Title row */} +
+
+
// verified sender@ · autopilot
+

Deal Journeys

+
+
+ + live · refreshes 30s + + approve here or in + + Telegram + + +
+
+ + {/* Hero row: funnel + volume card */} +
+ + + setStageFilter(stage.filter as Filter)} + searchSlot={ + <> +
+ + What are you looking for? +
+ + + } + /> +
+ + + +
+
+ + + + {volume.wonUsd > 0 && ( + + ▲ {fmtUsd(volume.wonUsd)} won + + )} + {volume.lostUsd > 0 && ( + + ▼ {fmtUsd(volume.lostUsd)} lost + + )} +
+
+ proposed revenue across open deals · {volume.quotedDeals} of {snap.deals.length} threads quoted +
+
+ 0 ? volume.approvalUsd / volume.openUsd : 0} + display={fmtUsd(volume.approvalUsd)} + hue={HUE.amber} + delay={500} + /> + 0 ? volume.awaitingUsd / volume.openUsd : 0} + display={fmtUsd(volume.awaitingUsd)} + hue={HUE.cobalt} + delay={650} + /> + 0 ? volume.wonUsd / (volume.wonUsd + volume.lostUsd) : 0} + display={`${fmtUsd(volume.wonUsd)} / ${fmtUsd(volume.lostUsd)}`} + hue={HUE.accent} + delay={800} + /> +
+
+ open = approval + awaiting · {sentToday} sends in the activity window +
+
+
+
+ + {/* Second row: activity line + chain stats + THE gradient card */} +
+ + +
+ + e.kind === 'sent').length} /> + + sends, recent activity window +
+ +
+ + + +
+
+
+ +
+
messages across cached chains
+
+ Deepest: {Math.max(...snap.deals.map((d) => d.chainLen), 0)} msgs +
+
+ +
+
+ + {/* the ONE gradient insight card */} + +
+
+
+ + Insights + +
+ +
+
+ unique deals sit outside the standard reel playbook. +
+
+ {uniqueDeals + .slice(0, 2) + .map((d) => d.tagReason || d.brand) + .join(' · ') || 'None flagged right now.'} +
+
+ {Array.from({ length: 8 }, (_, i) => ( + + ))} +
+
+ +
+ + {/* Deal list */} + +
+

+ Deals{' '} + + {filtered.length} of {snap.deals.length} + +

+
+ {( + [ + ['all', `All ${snap.deals.length}`], + ['approval', `Needs approval ${counts.approval}`], + ['sent', `Awaiting ${counts.sent}`], + ['unique', `Unique ${counts.unique}`], + ['won', `Won ${counts.won}`], + ['lost', `Lost ${counts.lost}`], + ] as const + ).map(([key, label]) => ( + + ))} +
+
+
+ {filtered.map((d) => ( + + ))} + {filtered.length === 0 &&
Nothing matches that filter.
} +
+
+
+ + {/* Detail drawer (unchanged behavior) */} + {selected && ( +
setSelectedId(null)} aria-hidden="true" /> + )} + {selected && ( +