spine-framework-cortex 0.1.12 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,8 +6,7 @@ import { Badge } from '@core/components/ui/badge'
6
6
  import { Skeleton } from '@core/components/ui/skeleton'
7
7
  import { Button } from '@core/components/ui/button'
8
8
  import { ScrollArea } from '@core/components/ui/scroll-area'
9
- import { Separator } from '@core/components/ui/separator'
10
- import { ArrowLeft, User, Ticket, Handshake, Activity, Heart, Filter, TrendingUp, Target, Clock } from 'lucide-react'
9
+ import { ArrowLeft, User, Ticket, Handshake, Activity, Heart, Filter, TrendingUp, Target, Clock, Eye, Download, FileText, CheckCircle, Zap, Link2 } from 'lucide-react'
11
10
 
12
11
  interface Account {
13
12
  id: string
@@ -140,182 +139,229 @@ function ActivityTab({ accountId }: { accountId: string }) {
140
139
  )
141
140
  }
142
141
 
143
- function FilterTab({ account }: { account: Account }) {
144
- const stage = account.data?.lifecycle_stage
145
- const score = account.data?.lead_score ?? 0
142
+ // ─── ACTION TYPE ICON ────────────────────────────────────────────────────────
143
+ function ActionIcon({ actionType }: { actionType?: string }) {
144
+ if (!actionType) return <Zap className="h-3.5 w-3.5 text-muted-foreground" />
145
+ if (actionType.includes('page_view') || actionType.includes('visit')) return <Eye className="h-3.5 w-3.5 text-muted-foreground" />
146
+ if (actionType.includes('install') || actionType.includes('npm')) return <Download className="h-3.5 w-3.5 text-blue-500" />
147
+ if (actionType.includes('claim')) return <CheckCircle className="h-3.5 w-3.5 text-green-500" />
148
+ if (actionType.includes('audit') || actionType.includes('self')) return <FileText className="h-3.5 w-3.5 text-purple-500" />
149
+ if (actionType.includes('signup') || actionType.includes('register')) return <User className="h-3.5 w-3.5 text-orange-500" />
150
+ return <Zap className="h-3.5 w-3.5 text-muted-foreground" />
151
+ }
152
+
153
+ // ─── SIGNAL TIMELINE ─────────────────────────────────────────────────────────
154
+ function SignalTimeline({ accountId }: { accountId: string }) {
155
+ const [signals, setSignals] = useState<any[]>([])
156
+ const [loading, setLoading] = useState(true)
157
+ const [showAll, setShowAll] = useState(false)
158
+
159
+ useEffect(() => {
160
+ apiFetch(`/api/admin-data?action=list&entity=items&type_slug=funnel_signal&account_id=${accountId}&limit=50`)
161
+ .then(r => r.json())
162
+ .then(j => {
163
+ const items = Array.isArray(j?.data) ? j.data : Array.isArray(j) ? j : []
164
+ items.sort((a: any, b: any) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
165
+ setSignals(items)
166
+ })
167
+ .catch(() => setSignals([]))
168
+ .finally(() => setLoading(false))
169
+ }, [accountId])
170
+
171
+ if (loading) return <div className="space-y-2">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}</div>
172
+ if (signals.length === 0) return (
173
+ <div className="text-center py-8 text-muted-foreground">
174
+ <Zap className="h-7 w-7 mx-auto mb-2 opacity-20" />
175
+ <p className="text-sm">No signals received yet.</p>
176
+ </div>
177
+ )
178
+
179
+ const visible = showAll ? signals : signals.slice(0, 8)
180
+
181
+ return (
182
+ <div className="space-y-1">
183
+ {visible.map(sig => {
184
+ const actionType = sig.data?.action?.action_type
185
+ const pipelineSlug = sig.data?.classification?.pipeline_slug
186
+ const rating = sig.data?.scoring_components?.raw_score?.rating
187
+ return (
188
+ <div key={sig.id} className="flex items-start gap-3 py-2 px-1 rounded hover:bg-muted/40 transition-colors">
189
+ <div className="mt-0.5 shrink-0"><ActionIcon actionType={actionType} /></div>
190
+ <div className="flex-1 min-w-0">
191
+ <div className="flex items-center gap-2 flex-wrap">
192
+ <span className="text-sm font-medium capitalize">{actionType?.replace(/_/g, ' ') || sig.title}</span>
193
+ {pipelineSlug && (
194
+ <Badge variant="outline" className="text-xs py-0 h-4 font-mono">{pipelineSlug.replace('funnel-', '')}</Badge>
195
+ )}
196
+ {rating && (
197
+ <span className={`text-xs font-medium ${
198
+ rating >= 4 ? 'text-red-600' : rating >= 3 ? 'text-yellow-600' : 'text-muted-foreground'
199
+ }`}>{rating}/5</span>
200
+ )}
201
+ </div>
202
+ <p className="text-xs text-muted-foreground">
203
+ {new Date(sig.created_at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
204
+ </p>
205
+ </div>
206
+ </div>
207
+ )
208
+ })}
209
+ {signals.length > 8 && !showAll && (
210
+ <button className="text-xs text-primary hover:underline pt-1" onClick={() => setShowAll(true)}>
211
+ View all {signals.length} signals
212
+ </button>
213
+ )}
214
+ </div>
215
+ )
216
+ }
217
+
218
+ // ─── SCORE BREAKDOWN ─────────────────────────────────────────────────────────
219
+ const PIPELINE_LABELS: Record<string, string> = {
220
+ 'funnel-ai-audit': 'Audit Intent',
221
+ 'funnel-ai-audit-portal-signup': 'Portal Signup',
222
+ 'funnel-ai-audit-hot': 'Audit Hot',
223
+ 'funnel-technical': 'Technical Intent',
224
+ 'funnel-technical-installed': 'Install Activity',
225
+ 'funnel-technical-claimed': 'Claim Intent',
226
+ 'funnel-general': 'General',
227
+ anonymous: 'Anonymous',
228
+ identified: 'Identified',
229
+ installed: 'Installed',
230
+ }
231
+
232
+ function ScoreBreakdown({ ratings }: { ratings?: Record<string, any> }) {
233
+ if (!ratings || Object.keys(ratings).length === 0) return (
234
+ <p className="text-xs text-muted-foreground">No scoring data yet.</p>
235
+ )
236
+
237
+ return (
238
+ <div className="space-y-2.5">
239
+ {Object.entries(ratings).map(([key, val]) => {
240
+ if (!val || typeof val.rating !== 'number') return null
241
+ const displayPct = Math.round((val.raw_score / 25) * 100)
242
+ const label = PIPELINE_LABELS[key] || key
243
+ const color = displayPct >= 80 ? 'bg-green-500' : displayPct >= 50 ? 'bg-yellow-500' : displayPct >= 25 ? 'bg-blue-400' : 'bg-gray-300'
244
+ return (
245
+ <div key={key} className="flex items-center gap-3">
246
+ <span className="text-sm text-muted-foreground w-36 shrink-0 truncate">{label}</span>
247
+ <div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
248
+ <div className={`h-full rounded-full ${color}`} style={{ width: `${displayPct}%` }} />
249
+ </div>
250
+ <span className="text-xs font-semibold w-8 text-right tabular-nums">{displayPct}</span>
251
+ </div>
252
+ )
253
+ })}
254
+ </div>
255
+ )
256
+ }
257
+
258
+ // ─── FUNNEL TAB (replaces old FilterTab) ─────────────────────────────────────
259
+ function FunnelTab({ account }: { account: Account }) {
146
260
  const temp = account.data?.temperature
261
+ const stage = account.data?.lifecycle_stage
147
262
  const ratings = account.data?.ratings
148
- const attr = account.data?.attribution
149
263
  const queue = account.data?.queue
264
+ const claimStatus = (account.data as any)?.claim_status
265
+ const instanceId = (account.data as any)?.instance_id
150
266
 
151
267
  return (
152
268
  <div className="p-6 space-y-6">
153
- {/* Header Stats */}
269
+ {/* Top stat row */}
154
270
  <div className="grid grid-cols-3 gap-4">
155
271
  <div className="border rounded-lg p-4">
156
- <div className="flex items-center gap-2 mb-2">
157
- <Target className="h-4 w-4 text-muted-foreground" />
158
- <span className="text-xs text-muted-foreground uppercase tracking-wider">Lifecycle Stage</span>
272
+ <div className="flex items-center gap-2 mb-1.5">
273
+ <Target className="h-3.5 w-3.5 text-muted-foreground" />
274
+ <span className="text-xs text-muted-foreground uppercase tracking-wider">Stage</span>
159
275
  </div>
160
- {stage ? (
161
- <Badge className="capitalize text-sm" variant={stage === 'customer' ? 'default' : 'secondary'}>
162
- {stage.replace(/_/g, ' ')}
163
- </Badge>
164
- ) : (
165
- <span className="text-muted-foreground text-sm">Not set</span>
166
- )}
276
+ {stage
277
+ ? <Badge className="capitalize" variant={stage === 'installed' ? 'default' : 'secondary'}>{stage}</Badge>
278
+ : <span className="text-sm text-muted-foreground">Not set</span>
279
+ }
167
280
  </div>
168
-
169
281
  <div className="border rounded-lg p-4">
170
- <div className="flex items-center gap-2 mb-2">
171
- <TrendingUp className="h-4 w-4 text-muted-foreground" />
172
- <span className="text-xs text-muted-foreground uppercase tracking-wider">Lead Score</span>
173
- </div>
174
- <div className="flex items-center gap-3">
175
- <div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
176
- <div
177
- className={`h-full rounded-full ${score >= 70 ? 'bg-green-500' : score >= 40 ? 'bg-yellow-500' : 'bg-gray-400'}`}
178
- style={{ width: `${Math.min(score, 100)}%` }}
179
- />
180
- </div>
181
- <span className="font-semibold">{score}</span>
282
+ <div className="flex items-center gap-2 mb-1.5">
283
+ <TrendingUp className="h-3.5 w-3.5 text-muted-foreground" />
284
+ <span className="text-xs text-muted-foreground uppercase tracking-wider">Temperature</span>
182
285
  </div>
286
+ {temp ? (
287
+ <Badge variant="outline" className={`capitalize ${
288
+ temp === 'hot' ? 'border-red-400 text-red-600 bg-red-50' :
289
+ temp === 'warm' ? 'border-yellow-400 text-yellow-600 bg-yellow-50' :
290
+ 'border-blue-400 text-blue-600 bg-blue-50'
291
+ }`}>{temp}</Badge>
292
+ ) : <span className="text-sm text-muted-foreground">—</span>}
183
293
  </div>
184
-
185
294
  <div className="border rounded-lg p-4">
186
- <div className="flex items-center gap-2 mb-2">
187
- <Clock className="h-4 w-4 text-muted-foreground" />
295
+ <div className="flex items-center gap-2 mb-1.5">
296
+ <Clock className="h-3.5 w-3.5 text-muted-foreground" />
188
297
  <span className="text-xs text-muted-foreground uppercase tracking-wider">Last Signal</span>
189
298
  </div>
190
299
  <span className="text-sm">
191
- {account.data?.last_signal_at
192
- ? new Date(account.data.last_signal_at).toLocaleDateString()
193
- : 'Never'
194
- }
300
+ {account.data?.last_signal_at ? new Date(account.data.last_signal_at).toLocaleDateString() : 'Never'}
195
301
  </span>
196
302
  </div>
197
303
  </div>
198
304
 
199
- {/* Temperature Badge */}
200
- {temp && (
201
- <div className="flex items-center gap-2">
202
- <span className="text-sm text-muted-foreground">Temperature:</span>
203
- <Badge
204
- variant="outline"
205
- className={`capitalize ${
206
- temp === 'hot' ? 'border-red-400 text-red-600 bg-red-50' :
207
- temp === 'warm' ? 'border-yellow-400 text-yellow-600 bg-yellow-50' :
208
- 'border-blue-400 text-blue-600 bg-blue-50'
209
- }`}
210
- >
211
- {temp}
212
- </Badge>
305
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
306
+ {/* Left: Signal Timeline */}
307
+ <div className="space-y-3">
308
+ <h3 className="font-medium text-sm flex items-center gap-2">
309
+ <Zap className="h-4 w-4 text-muted-foreground" /> Signal Timeline
310
+ </h3>
311
+ <SignalTimeline accountId={account.id} />
213
312
  </div>
214
- )}
215
313
 
216
- {/* Queue Status */}
217
- {queue?.pending_opportunity_id && (
218
- <div className="border rounded-lg p-4 bg-yellow-50 border-yellow-200">
219
- <div className="flex items-center gap-2">
220
- <Filter className="h-4 w-4 text-yellow-600" />
221
- <span className="font-medium text-sm">Opportunity in Queue</span>
314
+ {/* Right: Score Breakdown + Claim Status */}
315
+ <div className="space-y-4">
316
+ <div className="border rounded-lg p-4 space-y-3">
317
+ <h3 className="font-medium text-sm flex items-center gap-2">
318
+ <TrendingUp className="h-4 w-4 text-muted-foreground" /> Signal Score Breakdown
319
+ </h3>
320
+ <ScoreBreakdown ratings={ratings} />
222
321
  </div>
223
- <p className="text-xs text-muted-foreground mt-1">
224
- Type: {queue.primary_opportunity_type?.replace(/_/g, ' ') || 'Unknown'}
225
- </p>
226
- </div>
227
- )}
228
322
 
229
- {/* Stage Ratings */}
230
- {ratings && (
231
- <div className="border rounded-lg p-4">
232
- <h3 className="font-medium mb-4">Stage Ratings</h3>
233
- <div className="space-y-3">
234
- {ratings.anonymous && (
235
- <div className="flex items-center justify-between">
236
- <span className="text-sm text-muted-foreground">Anonymous</span>
237
- <div className="flex items-center gap-2">
238
- <div className="flex gap-0.5">
239
- {Array.from({ length: 5 }).map((_, i) => (
240
- <div
241
- key={i}
242
- className={`w-2 h-2 rounded-full ${i < ratings.anonymous!.rating ? 'bg-blue-500' : 'bg-gray-200'}`}
243
- />
244
- ))}
245
- </div>
246
- <span className="text-xs font-medium w-12 text-right">{ratings.anonymous.raw_score}</span>
247
- </div>
248
- </div>
249
- )}
250
- {ratings.identified && (
251
- <div className="flex items-center justify-between">
252
- <span className="text-sm text-muted-foreground">Identified</span>
253
- <div className="flex items-center gap-2">
254
- <div className="flex gap-0.5">
255
- {Array.from({ length: 5 }).map((_, i) => (
256
- <div
257
- key={i}
258
- className={`w-2 h-2 rounded-full ${i < ratings.identified!.rating ? 'bg-green-500' : 'bg-gray-200'}`}
259
- />
260
- ))}
261
- </div>
262
- <span className="text-xs font-medium w-12 text-right">{ratings.identified.raw_score}</span>
263
- </div>
323
+ {/* Opportunity queue alert */}
324
+ {queue?.pending_opportunity_id && (
325
+ <div className="border rounded-lg p-4 bg-yellow-50 border-yellow-200">
326
+ <div className="flex items-center gap-2">
327
+ <Filter className="h-4 w-4 text-yellow-600" />
328
+ <span className="font-medium text-sm">Opportunity in Queue</span>
264
329
  </div>
265
- )}
266
- {ratings.engaged && (
267
- <div className="flex items-center justify-between">
268
- <span className="text-sm text-muted-foreground">Engaged</span>
269
- <div className="flex items-center gap-2">
270
- <div className="flex gap-0.5">
271
- {Array.from({ length: 5 }).map((_, i) => (
272
- <div
273
- key={i}
274
- className={`w-2 h-2 rounded-full ${i < ratings.engaged!.rating ? 'bg-orange-500' : 'bg-gray-200'}`}
275
- />
276
- ))}
277
- </div>
278
- <span className="text-xs font-medium w-12 text-right">{ratings.engaged.raw_score}</span>
279
- </div>
280
- </div>
281
- )}
282
- </div>
283
- </div>
284
- )}
330
+ <p className="text-xs text-muted-foreground mt-1">
331
+ {queue.primary_opportunity_type?.replace(/_/g, ' ') || 'Pending review'}
332
+ </p>
333
+ </div>
334
+ )}
285
335
 
286
- {/* Attribution */}
287
- {(attr?.anonymous_first_touch || attr?.identified_first_touch) && (
288
- <div className="border rounded-lg p-4">
289
- <h3 className="font-medium mb-4">Attribution</h3>
290
- <div className="space-y-3 text-sm">
291
- {attr.anonymous_first_touch && (
336
+ {/* Identity / Claim Status */}
337
+ <div className="border rounded-lg p-4 space-y-3">
338
+ <h3 className="font-medium text-sm flex items-center gap-2">
339
+ <Link2 className="h-4 w-4 text-muted-foreground" /> Identity / Claim Status
340
+ </h3>
341
+ <div className="space-y-2 text-sm">
292
342
  <div className="flex justify-between">
293
- <span className="text-muted-foreground">Anonymous First Touch</span>
294
- <span className="text-right">
295
- <span className="font-medium">{attr.anonymous_first_touch.referrer || 'Direct'}</span>
296
- <span className="text-xs text-muted-foreground block">
297
- {attr.anonymous_first_touch.at ? new Date(attr.anonymous_first_touch.at).toLocaleDateString() : ''}
298
- </span>
299
- </span>
343
+ <span className="text-muted-foreground">Instance ID</span>
344
+ <span className="font-mono text-xs">{instanceId ? instanceId.slice(0, 14) + '…' : '—'}</span>
300
345
  </div>
301
- )}
302
- {attr.identified_first_touch && (
303
- <div className="flex justify-between">
304
- <span className="text-muted-foreground">Identified First Touch</span>
305
- <span className="text-right">
306
- <span className="font-medium">{attr.identified_first_touch.referrer || 'Direct'}</span>
307
- <span className="text-xs text-muted-foreground block">
308
- {attr.identified_first_touch.at ? new Date(attr.identified_first_touch.at).toLocaleDateString() : ''}
309
- </span>
310
- </span>
346
+ <div className="flex justify-between items-center">
347
+ <span className="text-muted-foreground">Claim Status</span>
348
+ {claimStatus === 'claimed'
349
+ ? <Badge className="text-xs bg-green-100 text-green-700 border-green-200">Claimed</Badge>
350
+ : <Badge variant="outline" className="text-xs">Unclaimed</Badge>
351
+ }
311
352
  </div>
312
- )}
353
+ {!claimStatus && (
354
+ <Button variant="outline" size="sm" className="w-full mt-1 text-xs gap-1.5">
355
+ <Link2 className="h-3.5 w-3.5" /> Create Claim Link
356
+ </Button>
357
+ )}
358
+ </div>
313
359
  </div>
314
360
  </div>
315
- )}
361
+ </div>
316
362
 
317
- {/* Empty State */}
318
- {!stage && !score && !temp && !ratings && !queue?.pending_opportunity_id && (
363
+ {/* Empty state */}
364
+ {!stage && !temp && !ratings && !queue?.pending_opportunity_id && (
319
365
  <div className="text-center py-12 text-muted-foreground">
320
366
  <Filter className="h-10 w-10 mx-auto mb-3 opacity-30" />
321
367
  <p className="text-sm">No funnel data yet.</p>
@@ -381,7 +427,7 @@ export default function AccountDetailPage() {
381
427
 
382
428
  <ScrollArea className="flex-1">
383
429
  <TabsContent value="people" className="mt-0"><PeopleTab accountId={id!} /></TabsContent>
384
- <TabsContent value="funnel" className="mt-0"><FilterTab account={account} /></TabsContent>
430
+ <TabsContent value="funnel" className="mt-0"><FunnelTab account={account} /></TabsContent>
385
431
  <TabsContent value="tickets" className="mt-0"><ItemsTab accountId={id!} typeSlug="support_ticket" emptyText="No tickets." /></TabsContent>
386
432
  <TabsContent value="deals" className="mt-0"><ItemsTab accountId={id!} typeSlug="deal" emptyText="No deals." /></TabsContent>
387
433
  <TabsContent value="health" className="mt-0"><ItemsTab accountId={id!} typeSlug="csm_health" emptyText="No health records." /></TabsContent>
@@ -0,0 +1,191 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { useNavigate } from 'react-router-dom'
3
+ import { apiFetch } from '@core/lib/api'
4
+ import { Badge } from '@core/components/ui/badge'
5
+ import { Button } from '@core/components/ui/button'
6
+ import { Input } from '@core/components/ui/input'
7
+ import { Skeleton } from '@core/components/ui/skeleton'
8
+ import { ScanSearch, ChevronRight, Search } from 'lucide-react'
9
+
10
+ interface AuditAccount {
11
+ id: string
12
+ slug: string
13
+ display_name?: string
14
+ created_at: string
15
+ data?: {
16
+ temperature?: 'cold' | 'warm' | 'hot'
17
+ lifecycle_stage?: string
18
+ lead_score?: number
19
+ last_signal_at?: string
20
+ ratings?: Record<string, { rating: number; raw_score: number; calculated_at?: string }>
21
+ claim_status?: string
22
+ }
23
+ }
24
+
25
+ function scoreBar(rating: number) {
26
+ const val = Math.round((rating / 5) * 100)
27
+ const color = val >= 80 ? 'bg-green-500' : val >= 50 ? 'bg-yellow-500' : 'bg-gray-300'
28
+ return (
29
+ <div className="flex items-center gap-2">
30
+ <div className="w-24 h-1.5 bg-muted rounded-full overflow-hidden">
31
+ <div className={`h-full rounded-full ${color}`} style={{ width: `${val}%` }} />
32
+ </div>
33
+ <span className="text-xs font-medium tabular-nums">{val}</span>
34
+ </div>
35
+ )
36
+ }
37
+
38
+ function recommendedOffer(rating: number): string {
39
+ if (rating >= 5) return 'Paid Audit $5k'
40
+ if (rating >= 4) return 'Discovery Call'
41
+ if (rating >= 3) return 'Self-Audit'
42
+ return 'Content Nurture'
43
+ }
44
+
45
+ function stageBadge(stage?: string) {
46
+ const map: Record<string, string> = {
47
+ anonymous: 'Anonymous',
48
+ identified: 'Identified',
49
+ installed: 'Installed',
50
+ }
51
+ return <Badge variant="secondary" className="text-xs capitalize">{map[stage || ''] || stage || '—'}</Badge>
52
+ }
53
+
54
+ function temperatureBadge(temp?: string) {
55
+ if (temp === 'hot') return <Badge className="bg-red-100 text-red-700 border-red-200 text-xs">Hot</Badge>
56
+ if (temp === 'warm') return <Badge className="bg-yellow-100 text-yellow-700 border-yellow-200 text-xs">Warm</Badge>
57
+ return <Badge variant="outline" className="text-xs">Cold</Badge>
58
+ }
59
+
60
+ export default function AuditFunnelPage() {
61
+ const navigate = useNavigate()
62
+ const [accounts, setAccounts] = useState<AuditAccount[]>([])
63
+ const [loading, setLoading] = useState(true)
64
+ const [search, setSearch] = useState('')
65
+
66
+ useEffect(() => {
67
+ apiFetch('/api/admin-data?action=list&entity=accounts&limit=500')
68
+ .then(r => r.json())
69
+ .then(json => {
70
+ const all: AuditAccount[] = Array.isArray(json?.data) ? json.data : Array.isArray(json) ? json : []
71
+ // Keep accounts that have any audit funnel signal rating
72
+ const auditAccounts = all.filter(a =>
73
+ a.data?.ratings?.['funnel-ai-audit'] ||
74
+ a.data?.ratings?.['funnel-ai-audit-portal-signup'] ||
75
+ a.data?.ratings?.['funnel-ai-audit-hot']
76
+ )
77
+ // Sort by audit rating desc
78
+ auditAccounts.sort((a, b) => {
79
+ const ra = a.data?.ratings?.['funnel-ai-audit']?.rating || 0
80
+ const rb = b.data?.ratings?.['funnel-ai-audit']?.rating || 0
81
+ return rb - ra
82
+ })
83
+ setAccounts(auditAccounts)
84
+ })
85
+ .catch(() => setAccounts([]))
86
+ .finally(() => setLoading(false))
87
+ }, [])
88
+
89
+ const filtered = accounts.filter(a => {
90
+ const q = search.toLowerCase()
91
+ return !q || (a.display_name || a.slug || '').toLowerCase().includes(q)
92
+ })
93
+
94
+ return (
95
+ <div className="p-6 space-y-6">
96
+ {/* Header */}
97
+ <div className="flex items-center justify-between">
98
+ <div>
99
+ <div className="flex items-center gap-2">
100
+ <ScanSearch className="h-5 w-5 text-muted-foreground" />
101
+ <h1 className="text-2xl font-bold">Audit Funnel</h1>
102
+ </div>
103
+ <p className="text-sm text-muted-foreground mt-0.5">
104
+ Accounts progressing through the AI readiness audit — from marketing visit to paid audit.
105
+ </p>
106
+ </div>
107
+ <Button size="sm" onClick={() => navigate('/cortex/ops/command-center')}>
108
+ Command Center <ChevronRight className="h-3.5 w-3.5 ml-1" />
109
+ </Button>
110
+ </div>
111
+
112
+ {/* Summary stats */}
113
+ <div className="grid grid-cols-4 gap-4">
114
+ {[
115
+ { label: 'In Funnel', value: loading ? '…' : accounts.length },
116
+ { label: 'Hot (score ≥4)', value: loading ? '…' : accounts.filter(a => (a.data?.ratings?.['funnel-ai-audit']?.rating || 0) >= 4).length },
117
+ { label: 'Warm (score 3)', value: loading ? '…' : accounts.filter(a => (a.data?.ratings?.['funnel-ai-audit']?.rating || 0) === 3).length },
118
+ { label: 'Identified', value: loading ? '…' : accounts.filter(a => a.data?.lifecycle_stage === 'identified').length },
119
+ ].map(s => (
120
+ <div key={s.label} className="border rounded-lg p-4">
121
+ <div className="text-xs text-muted-foreground mb-1">{s.label}</div>
122
+ {loading ? <Skeleton className="h-7 w-12" /> : <div className="text-2xl font-bold">{s.value}</div>}
123
+ </div>
124
+ ))}
125
+ </div>
126
+
127
+ {/* Table */}
128
+ <div className="border rounded-lg">
129
+ <div className="flex items-center justify-between px-4 py-3 border-b">
130
+ <span className="font-medium text-sm">Audit Prospects</span>
131
+ <div className="relative w-56">
132
+ <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
133
+ <Input
134
+ className="h-7 pl-8 text-xs"
135
+ placeholder="Search accounts…"
136
+ value={search}
137
+ onChange={e => setSearch(e.target.value)}
138
+ />
139
+ </div>
140
+ </div>
141
+
142
+ {loading ? (
143
+ <div className="p-4 space-y-3">
144
+ {Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
145
+ </div>
146
+ ) : filtered.length === 0 ? (
147
+ <div className="p-10 text-center text-sm text-muted-foreground">
148
+ <ScanSearch className="h-8 w-8 mx-auto mb-2 opacity-20" />
149
+ {search ? 'No accounts match your search.' : 'No audit funnel activity yet. Send signals with pipeline_slug: funnel-ai-audit to populate this view.'}
150
+ </div>
151
+ ) : (
152
+ <>
153
+ {/* Header row */}
154
+ <div className="grid grid-cols-[2fr_1fr_1fr_1fr_1fr_1fr] gap-4 px-4 py-2 text-xs text-muted-foreground font-medium border-b bg-muted/30">
155
+ <span>Account</span>
156
+ <span>Signal Score</span>
157
+ <span>Stage</span>
158
+ <span>Last Signal</span>
159
+ <span>Recommended Offer</span>
160
+ <span>Status</span>
161
+ </div>
162
+ <div className="divide-y">
163
+ {filtered.map(a => {
164
+ const rating = a.data?.ratings?.['funnel-ai-audit']?.rating || 0
165
+ return (
166
+ <div
167
+ key={a.id}
168
+ className="grid grid-cols-[2fr_1fr_1fr_1fr_1fr_1fr] gap-4 px-4 py-3 items-center hover:bg-accent/50 cursor-pointer transition-colors text-sm"
169
+ onClick={() => navigate(`/cortex/crm/accounts/${a.id}`)}
170
+ >
171
+ <div className="min-w-0">
172
+ <div className="font-medium truncate">{a.display_name || a.slug}</div>
173
+ <div className="text-xs text-muted-foreground truncate">{a.slug}</div>
174
+ </div>
175
+ <div>{scoreBar(rating)}</div>
176
+ <div>{stageBadge(a.data?.lifecycle_stage)}</div>
177
+ <div className="text-xs text-muted-foreground">
178
+ {a.data?.last_signal_at ? new Date(a.data.last_signal_at).toLocaleDateString() : '—'}
179
+ </div>
180
+ <div className="text-xs">{recommendedOffer(rating)}</div>
181
+ <div>{temperatureBadge(a.data?.temperature)}</div>
182
+ </div>
183
+ )
184
+ })}
185
+ </div>
186
+ </>
187
+ )}
188
+ </div>
189
+ </div>
190
+ )
191
+ }