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.
- package/CHANGELOG.md +13 -0
- package/LICENSE.md +216 -6
- package/components/CortexSidebar.tsx +27 -0
- package/functions/custom_funnel-signal.ts +25 -8
- package/index.tsx +10 -0
- package/package.json +1 -1
- package/pages/crm/AccountDetailPage.tsx +192 -146
- package/pages/ops/AuditFunnelPage.tsx +191 -0
- package/pages/ops/CommandCenterPage.tsx +377 -0
- package/pages/ops/InstallFunnelPage.tsx +226 -0
- package/seed/pipelines.json +34 -5
- package/seed/triggers.json +44 -4
|
@@ -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 {
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
{/*
|
|
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-
|
|
157
|
-
<Target className="h-
|
|
158
|
-
<span className="text-xs text-muted-foreground uppercase tracking-wider">
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
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-
|
|
171
|
-
<TrendingUp className="h-
|
|
172
|
-
<span className="text-xs text-muted-foreground uppercase tracking-wider">
|
|
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-
|
|
187
|
-
<Clock className="h-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
<div className="
|
|
202
|
-
<
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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">
|
|
294
|
-
<span className="text-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
|
318
|
-
{!stage && !
|
|
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"><
|
|
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
|
+
}
|