spine-framework-cortex 0.1.13 → 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/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
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
## [0.1.14] — 2026-06-09
|
|
8
|
+
- Added **Command Center** page (`/ops/command-center`) — KPI strip, Hot Opportunities table, Audit/Install funnel tab strip, funnel health panels
|
|
9
|
+
- Added **Audit Funnel** page (`/ops/audit-funnel`) — account list filtered to `funnel-ai-audit` pipeline, sorted by signal score, with recommended offer logic
|
|
10
|
+
- Added **Install Funnel** page (`/ops/install-funnel`) — account list filtered to `funnel-technical` pipeline, filter tabs (All / Unclaimed / Claimed / Hot)
|
|
11
|
+
- Added **Revenue** sidebar group to `CortexSidebar` with Command Center, Audit Funnel, and Install Funnel nav items
|
|
12
|
+
- Enhanced **Account Detail Funnel tab** — replaced static rating panel with live Signal Timeline, per-pipeline Signal Score Breakdown, and Identity/Claim Status panel
|
|
13
|
+
|
|
14
|
+
## [0.1.13] — 2026-06-08
|
|
15
|
+
- Updated `LICENSE.md` to full Spine Framework Internal Use License 1.0.0
|
|
16
|
+
|
|
17
|
+
## [0.1.12] — 2026-06-08
|
|
18
|
+
- Added `CHANGELOG.md`
|
|
19
|
+
|
|
7
20
|
## [0.1.11] — 2026-06-08
|
|
8
21
|
- Updated README to consistently use "Spine Framework" instead of "Spine"
|
|
9
22
|
- Added `publish:next` and `publish:promote` scripts
|
|
@@ -21,6 +21,9 @@ import {
|
|
|
21
21
|
Handshake,
|
|
22
22
|
Activity,
|
|
23
23
|
Heart,
|
|
24
|
+
Flame,
|
|
25
|
+
ScanSearch,
|
|
26
|
+
Download,
|
|
24
27
|
} from "lucide-react"
|
|
25
28
|
import {
|
|
26
29
|
Sidebar,
|
|
@@ -54,6 +57,12 @@ export function CortexSidebar({ ...props }: React.ComponentProps<typeof Sidebar>
|
|
|
54
57
|
{ title: "Activity", url: `${base}/crm/activity`, icon: Activity },
|
|
55
58
|
]
|
|
56
59
|
|
|
60
|
+
const revenueItems = [
|
|
61
|
+
{ title: "Command Center", url: `${base}/ops/command-center`, icon: Flame },
|
|
62
|
+
{ title: "Audit Funnel", url: `${base}/ops/audit-funnel`, icon: ScanSearch },
|
|
63
|
+
{ title: "Install Funnel", url: `${base}/ops/install-funnel`, icon: Download },
|
|
64
|
+
]
|
|
65
|
+
|
|
57
66
|
const opsItems = [
|
|
58
67
|
{ title: "Support", url: `${base}/support`, icon: Headphones },
|
|
59
68
|
{ title: "Community", url: `${base}/community`, icon: Users },
|
|
@@ -103,6 +112,24 @@ export function CortexSidebar({ ...props }: React.ComponentProps<typeof Sidebar>
|
|
|
103
112
|
</SidebarGroupContent>
|
|
104
113
|
</SidebarGroup>
|
|
105
114
|
|
|
115
|
+
<SidebarGroup>
|
|
116
|
+
<SidebarGroupLabel>Revenue</SidebarGroupLabel>
|
|
117
|
+
<SidebarGroupContent>
|
|
118
|
+
<SidebarMenu>
|
|
119
|
+
{revenueItems.map((item) => (
|
|
120
|
+
<SidebarMenuItem key={item.title}>
|
|
121
|
+
<SidebarMenuButton asChild isActive={isActive(item.url)}>
|
|
122
|
+
<Link to={item.url}>
|
|
123
|
+
<item.icon className="h-4 w-4" />
|
|
124
|
+
<span>{item.title}</span>
|
|
125
|
+
</Link>
|
|
126
|
+
</SidebarMenuButton>
|
|
127
|
+
</SidebarMenuItem>
|
|
128
|
+
))}
|
|
129
|
+
</SidebarMenu>
|
|
130
|
+
</SidebarGroupContent>
|
|
131
|
+
</SidebarGroup>
|
|
132
|
+
|
|
106
133
|
<SidebarGroup>
|
|
107
134
|
<SidebarGroupLabel>Operations</SidebarGroupLabel>
|
|
108
135
|
<SidebarGroupContent>
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from './custom_funnel-scoring'
|
|
21
21
|
import { adminDb } from './_shared/db'
|
|
22
22
|
import { resolveTypeIds, resolveLinkTypeIds, resolveAccountId } from './_shared/resolve-ids'
|
|
23
|
+
import { fireCreateTriggers, SYSTEM_PRINCIPAL } from './_shared/index'
|
|
23
24
|
|
|
24
25
|
// IDs resolved at request time — never hardcoded
|
|
25
26
|
async function resolveIds() {
|
|
@@ -82,7 +83,7 @@ export async function processSignal(
|
|
|
82
83
|
let sessionItem = null
|
|
83
84
|
|
|
84
85
|
if (payload.account_id) {
|
|
85
|
-
accountUpdate = await updateAccountFunnel(payload.account_id, payload.stage, scoring)
|
|
86
|
+
accountUpdate = await updateAccountFunnel(payload.account_id, payload.stage, scoring, payload.pipeline_slug)
|
|
86
87
|
|
|
87
88
|
// Create link between account and signal
|
|
88
89
|
await createAccountSignalLink(payload.account_id, signalItem.id, ids)
|
|
@@ -96,6 +97,15 @@ export async function processSignal(
|
|
|
96
97
|
queueEntry = await evaluateQueueEntry(payload, scoring, signalItem.id, ids)
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
// 7. FIRE TRIGGERS — lets core pipeline/trigger engine handle downstream automation
|
|
101
|
+
const triggerCtx = {
|
|
102
|
+
principal: SYSTEM_PRINCIPAL,
|
|
103
|
+
accountId: payload.account_id || ids.UNIDENTIFIED_VISITORS_ACCOUNT_ID,
|
|
104
|
+
db: adminDb,
|
|
105
|
+
requestId: scriptContext?.requestId || crypto.randomUUID(),
|
|
106
|
+
}
|
|
107
|
+
await fireCreateTriggers('item', signalItem.id, signalItem, triggerCtx)
|
|
108
|
+
|
|
99
109
|
return {
|
|
100
110
|
status: 'success',
|
|
101
111
|
signal_id: signalItem.id,
|
|
@@ -166,6 +176,7 @@ interface SignalPayload {
|
|
|
166
176
|
utm_campaign?: string
|
|
167
177
|
instance_id?: string
|
|
168
178
|
environment?: 'dev' | 'staging' | 'production'
|
|
179
|
+
pipeline_slug?: string
|
|
169
180
|
}
|
|
170
181
|
|
|
171
182
|
// ============================================
|
|
@@ -273,7 +284,8 @@ async function createSignalItem(
|
|
|
273
284
|
},
|
|
274
285
|
classification: {
|
|
275
286
|
stage: payload.stage,
|
|
276
|
-
source: payload.source
|
|
287
|
+
source: payload.source,
|
|
288
|
+
pipeline_slug: payload.pipeline_slug || null
|
|
277
289
|
},
|
|
278
290
|
action: {
|
|
279
291
|
action_type: payload.action_type,
|
|
@@ -343,7 +355,8 @@ async function createSignalItem(
|
|
|
343
355
|
async function updateAccountFunnel(
|
|
344
356
|
accountId: string,
|
|
345
357
|
stage: string,
|
|
346
|
-
scoring: RawScoreResult
|
|
358
|
+
scoring: RawScoreResult,
|
|
359
|
+
pipelineSlug?: string
|
|
347
360
|
): Promise<boolean> {
|
|
348
361
|
// Get current account data
|
|
349
362
|
const { data: account, error: fetchError } = await adminDb
|
|
@@ -377,12 +390,16 @@ async function updateAccountFunnel(
|
|
|
377
390
|
|
|
378
391
|
// Update rating and temperature — write flat to data so UI can read directly
|
|
379
392
|
const temperature = ratingToTemperature(scoring.rating)
|
|
380
|
-
const
|
|
393
|
+
const ratingEntry = { rating: scoring.rating, raw_score: scoring.calculated, calculated_at: now }
|
|
394
|
+
const updatedRatings: Record<string, any> = {
|
|
381
395
|
...(account.data?.ratings || {}),
|
|
382
|
-
[stage]:
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
396
|
+
[stage]: ratingEntry,
|
|
397
|
+
}
|
|
398
|
+
// Also store per-pipeline_slug so UI can show per-funnel score breakdown
|
|
399
|
+
if (pipelineSlug) {
|
|
400
|
+
const currentPipelineRating = updatedRatings[pipelineSlug]?.rating || 0
|
|
401
|
+
if (scoring.rating >= currentPipelineRating) {
|
|
402
|
+
updatedRatings[pipelineSlug] = ratingEntry
|
|
386
403
|
}
|
|
387
404
|
}
|
|
388
405
|
|
package/index.tsx
CHANGED
|
@@ -37,6 +37,11 @@ const CoursesPage = lazy(() => import('./pages/courses/CoursesPage'))
|
|
|
37
37
|
// Intelligence
|
|
38
38
|
const IntelligencePage = lazy(() => import('./pages/intelligence/IntelligencePage'))
|
|
39
39
|
|
|
40
|
+
// Ops / Revenue
|
|
41
|
+
const CommandCenterPage = lazy(() => import('./pages/ops/CommandCenterPage'))
|
|
42
|
+
const AuditFunnelPage = lazy(() => import('./pages/ops/AuditFunnelPage'))
|
|
43
|
+
const InstallFunnelPage = lazy(() => import('./pages/ops/InstallFunnelPage'))
|
|
44
|
+
|
|
40
45
|
const Fallback = <div className="min-h-[400px] flex items-center justify-center"><LoadingSpinner /></div>
|
|
41
46
|
|
|
42
47
|
function CortexLayout() {
|
|
@@ -96,6 +101,11 @@ function CortexLayout() {
|
|
|
96
101
|
{/* Intelligence */}
|
|
97
102
|
<Route path="intelligence" element={<IntelligencePage />} />
|
|
98
103
|
|
|
104
|
+
{/* Ops / Revenue */}
|
|
105
|
+
<Route path="ops/command-center" element={<CommandCenterPage />} />
|
|
106
|
+
<Route path="ops/audit-funnel" element={<AuditFunnelPage />} />
|
|
107
|
+
<Route path="ops/install-funnel" element={<InstallFunnelPage />} />
|
|
108
|
+
|
|
99
109
|
<Route path="*" element={<Navigate to="dashboard" replace />} />
|
|
100
110
|
</Routes>
|
|
101
111
|
</Suspense>
|
package/package.json
CHANGED
|
@@ -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>
|