spine-framework-cortex 0.1.19 → 0.2.0
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/{functions/custom_cortex-handler.ts → api/cortex-handler.ts} +9 -9
- package/components/CliInstancesCard.tsx +144 -0
- package/components/CortexSidebar.tsx +27 -53
- package/hooks/useTypeRegistry.ts +74 -0
- package/index.tsx +13 -24
- package/manifest.json +1 -13
- package/package.json +11 -20
- package/pages/courses/CoursesPage.tsx +14 -4
- package/pages/crm/AccountDetailPage.tsx +149 -194
- package/pages/crm/ContactsPage.tsx +7 -7
- package/pages/intelligence/IntelligencePage.tsx +24 -31
- package/pages/kb/KBEditorPage.tsx +9 -2
- package/pages/operations/AuditFunnelPage.tsx +378 -0
- package/pages/operations/InstallFunnelPage.tsx +410 -0
- package/pages/operations/OperationsDashboard.tsx +275 -0
- package/pages/support/RedactionReview.tsx +11 -2
- package/seed/link-types.json +8 -42
- package/seed/package.json +27 -0
- package/seed/roles.json +1 -1
- package/seed/types.json +2711 -596
- package/CHANGELOG.md +0 -46
- package/LICENSE.md +0 -223
- package/README.md +0 -69
- package/functions/custom_anonymous-sessions.ts +0 -356
- package/functions/custom_case_analysis.ts +0 -507
- package/functions/custom_community-escalation.ts +0 -234
- package/functions/custom_cortex-chunks.ts +0 -52
- package/functions/custom_funnel-scoring.ts +0 -256
- package/functions/custom_funnel-signal.ts +0 -446
- package/functions/custom_funnel-timers.ts +0 -449
- package/functions/custom_kb-chunker-test.ts +0 -364
- package/functions/custom_kb-chunker.ts +0 -576
- package/functions/custom_kb-embeddings.ts +0 -481
- package/functions/custom_kb-ingestion.ts +0 -448
- package/functions/custom_support-triage.ts +0 -649
- package/functions/custom_tag_management.ts +0 -314
- package/functions/webhook-handlers.ts +0 -29
- package/lib/resolveTypeId.ts +0 -16
- package/pages/crm/ContactDetailPage.tsx +0 -184
- package/pages/ops/AuditFunnelPage.tsx +0 -191
- package/pages/ops/CommandCenterPage.tsx +0 -377
- package/pages/ops/InstallFunnelPage.tsx +0 -226
- package/seed/accounts.json +0 -9
- package/seed/integrations.json +0 -24
- package/seed/pipelines.json +0 -59
- package/seed/triggers.json +0 -125
|
@@ -6,7 +6,8 @@ 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 {
|
|
9
|
+
import { Separator } from '@core/components/ui/separator'
|
|
10
|
+
import { ArrowLeft, User, Ticket, Handshake, Activity, Heart, Funnel, TrendingUp, Target, Clock } from 'lucide-react'
|
|
10
11
|
|
|
11
12
|
interface Account {
|
|
12
13
|
id: string
|
|
@@ -38,7 +39,8 @@ interface Account {
|
|
|
38
39
|
interface Person {
|
|
39
40
|
id: string
|
|
40
41
|
email: string
|
|
41
|
-
|
|
42
|
+
first_name?: string
|
|
43
|
+
last_name?: string
|
|
42
44
|
created_at: string
|
|
43
45
|
}
|
|
44
46
|
|
|
@@ -67,7 +69,7 @@ function PeopleTab({ accountId }: { accountId: string }) {
|
|
|
67
69
|
<User className="h-4 w-4 text-muted-foreground" />
|
|
68
70
|
</div>
|
|
69
71
|
<div>
|
|
70
|
-
<p className="text-sm font-medium">{p.
|
|
72
|
+
<p className="text-sm font-medium">{[p.first_name, p.last_name].filter(Boolean).join(' ') || '—'}</p>
|
|
71
73
|
<p className="text-xs text-muted-foreground">{p.email}</p>
|
|
72
74
|
</div>
|
|
73
75
|
<p className="ml-auto text-xs text-muted-foreground">{new Date(p.created_at).toLocaleDateString()}</p>
|
|
@@ -139,231 +141,184 @@ function ActivityTab({ accountId }: { accountId: string }) {
|
|
|
139
141
|
)
|
|
140
142
|
}
|
|
141
143
|
|
|
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
144
|
function FunnelTab({ account }: { account: Account }) {
|
|
260
|
-
const temp = account.data?.temperature
|
|
261
145
|
const stage = account.data?.lifecycle_stage
|
|
146
|
+
const score = account.data?.lead_score ?? 0
|
|
147
|
+
const temp = account.data?.temperature
|
|
262
148
|
const ratings = account.data?.ratings
|
|
149
|
+
const attr = account.data?.attribution
|
|
263
150
|
const queue = account.data?.queue
|
|
264
|
-
const claimStatus = (account.data as any)?.claim_status
|
|
265
|
-
const instanceId = (account.data as any)?.instance_id
|
|
266
151
|
|
|
267
152
|
return (
|
|
268
153
|
<div className="p-6 space-y-6">
|
|
269
|
-
{/*
|
|
154
|
+
{/* Header Stats */}
|
|
270
155
|
<div className="grid grid-cols-3 gap-4">
|
|
271
156
|
<div className="border rounded-lg p-4">
|
|
272
|
-
<div className="flex items-center gap-2 mb-
|
|
273
|
-
<Target className="h-
|
|
274
|
-
<span className="text-xs text-muted-foreground uppercase tracking-wider">Stage</span>
|
|
157
|
+
<div className="flex items-center gap-2 mb-2">
|
|
158
|
+
<Target className="h-4 w-4 text-muted-foreground" />
|
|
159
|
+
<span className="text-xs text-muted-foreground uppercase tracking-wider">Lifecycle Stage</span>
|
|
275
160
|
</div>
|
|
276
|
-
{stage
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
161
|
+
{stage ? (
|
|
162
|
+
<Badge className="capitalize text-sm" variant={stage === 'customer' ? 'default' : 'secondary'}>
|
|
163
|
+
{stage.replace(/_/g, ' ')}
|
|
164
|
+
</Badge>
|
|
165
|
+
) : (
|
|
166
|
+
<span className="text-muted-foreground text-sm">Not set</span>
|
|
167
|
+
)}
|
|
280
168
|
</div>
|
|
169
|
+
|
|
281
170
|
<div className="border rounded-lg p-4">
|
|
282
|
-
<div className="flex items-center gap-2 mb-
|
|
283
|
-
<TrendingUp className="h-
|
|
284
|
-
<span className="text-xs text-muted-foreground uppercase tracking-wider">
|
|
171
|
+
<div className="flex items-center gap-2 mb-2">
|
|
172
|
+
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
|
173
|
+
<span className="text-xs text-muted-foreground uppercase tracking-wider">Lead Score</span>
|
|
174
|
+
</div>
|
|
175
|
+
<div className="flex items-center gap-3">
|
|
176
|
+
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
|
177
|
+
<div
|
|
178
|
+
className={`h-full rounded-full ${score >= 70 ? 'bg-green-500' : score >= 40 ? 'bg-yellow-500' : 'bg-gray-400'}`}
|
|
179
|
+
style={{ width: `${Math.min(score, 100)}%` }}
|
|
180
|
+
/>
|
|
181
|
+
</div>
|
|
182
|
+
<span className="font-semibold">{score}</span>
|
|
285
183
|
</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>}
|
|
293
184
|
</div>
|
|
185
|
+
|
|
294
186
|
<div className="border rounded-lg p-4">
|
|
295
|
-
<div className="flex items-center gap-2 mb-
|
|
296
|
-
<Clock className="h-
|
|
187
|
+
<div className="flex items-center gap-2 mb-2">
|
|
188
|
+
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
297
189
|
<span className="text-xs text-muted-foreground uppercase tracking-wider">Last Signal</span>
|
|
298
190
|
</div>
|
|
299
191
|
<span className="text-sm">
|
|
300
|
-
{account.data?.last_signal_at
|
|
192
|
+
{account.data?.last_signal_at
|
|
193
|
+
? new Date(account.data.last_signal_at).toLocaleDateString()
|
|
194
|
+
: 'Never'
|
|
195
|
+
}
|
|
301
196
|
</span>
|
|
302
197
|
</div>
|
|
303
198
|
</div>
|
|
304
199
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
<div className="
|
|
308
|
-
<
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
200
|
+
{/* Temperature Badge */}
|
|
201
|
+
{temp && (
|
|
202
|
+
<div className="flex items-center gap-2">
|
|
203
|
+
<span className="text-sm text-muted-foreground">Temperature:</span>
|
|
204
|
+
<Badge
|
|
205
|
+
variant="outline"
|
|
206
|
+
className={`capitalize ${
|
|
207
|
+
temp === 'hot' ? 'border-red-400 text-red-600 bg-red-50' :
|
|
208
|
+
temp === 'warm' ? 'border-yellow-400 text-yellow-600 bg-yellow-50' :
|
|
209
|
+
'border-blue-400 text-blue-600 bg-blue-50'
|
|
210
|
+
}`}
|
|
211
|
+
>
|
|
212
|
+
{temp}
|
|
213
|
+
</Badge>
|
|
312
214
|
</div>
|
|
215
|
+
)}
|
|
313
216
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
</
|
|
320
|
-
<ScoreBreakdown ratings={ratings} />
|
|
217
|
+
{/* Queue Status */}
|
|
218
|
+
{queue?.pending_opportunity_id && (
|
|
219
|
+
<div className="border rounded-lg p-4 bg-yellow-50 border-yellow-200">
|
|
220
|
+
<div className="flex items-center gap-2">
|
|
221
|
+
<Funnel className="h-4 w-4 text-yellow-600" />
|
|
222
|
+
<span className="font-medium text-sm">Opportunity in Queue</span>
|
|
321
223
|
</div>
|
|
224
|
+
<p className="text-xs text-muted-foreground mt-1">
|
|
225
|
+
Type: {queue.primary_opportunity_type?.replace(/_/g, ' ') || 'Unknown'}
|
|
226
|
+
</p>
|
|
227
|
+
</div>
|
|
228
|
+
)}
|
|
322
229
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
230
|
+
{/* Stage Ratings */}
|
|
231
|
+
{ratings && (
|
|
232
|
+
<div className="border rounded-lg p-4">
|
|
233
|
+
<h3 className="font-medium mb-4">Stage Ratings</h3>
|
|
234
|
+
<div className="space-y-3">
|
|
235
|
+
{ratings.anonymous && (
|
|
236
|
+
<div className="flex items-center justify-between">
|
|
237
|
+
<span className="text-sm text-muted-foreground">Anonymous</span>
|
|
238
|
+
<div className="flex items-center gap-2">
|
|
239
|
+
<div className="flex gap-0.5">
|
|
240
|
+
{Array.from({ length: 5 }).map((_, i) => (
|
|
241
|
+
<div
|
|
242
|
+
key={i}
|
|
243
|
+
className={`w-2 h-2 rounded-full ${i < ratings.anonymous!.rating ? 'bg-blue-500' : 'bg-gray-200'}`}
|
|
244
|
+
/>
|
|
245
|
+
))}
|
|
246
|
+
</div>
|
|
247
|
+
<span className="text-xs font-medium w-12 text-right">{ratings.anonymous.raw_score}</span>
|
|
248
|
+
</div>
|
|
329
249
|
</div>
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
250
|
+
)}
|
|
251
|
+
{ratings.identified && (
|
|
252
|
+
<div className="flex items-center justify-between">
|
|
253
|
+
<span className="text-sm text-muted-foreground">Identified</span>
|
|
254
|
+
<div className="flex items-center gap-2">
|
|
255
|
+
<div className="flex gap-0.5">
|
|
256
|
+
{Array.from({ length: 5 }).map((_, i) => (
|
|
257
|
+
<div
|
|
258
|
+
key={i}
|
|
259
|
+
className={`w-2 h-2 rounded-full ${i < ratings.identified!.rating ? 'bg-green-500' : 'bg-gray-200'}`}
|
|
260
|
+
/>
|
|
261
|
+
))}
|
|
262
|
+
</div>
|
|
263
|
+
<span className="text-xs font-medium w-12 text-right">{ratings.identified.raw_score}</span>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
)}
|
|
267
|
+
{ratings.engaged && (
|
|
268
|
+
<div className="flex items-center justify-between">
|
|
269
|
+
<span className="text-sm text-muted-foreground">Engaged</span>
|
|
270
|
+
<div className="flex items-center gap-2">
|
|
271
|
+
<div className="flex gap-0.5">
|
|
272
|
+
{Array.from({ length: 5 }).map((_, i) => (
|
|
273
|
+
<div
|
|
274
|
+
key={i}
|
|
275
|
+
className={`w-2 h-2 rounded-full ${i < ratings.engaged!.rating ? 'bg-orange-500' : 'bg-gray-200'}`}
|
|
276
|
+
/>
|
|
277
|
+
))}
|
|
278
|
+
</div>
|
|
279
|
+
<span className="text-xs font-medium w-12 text-right">{ratings.engaged.raw_score}</span>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
)}
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
)}
|
|
335
286
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
287
|
+
{/* Attribution */}
|
|
288
|
+
{(attr?.anonymous_first_touch || attr?.identified_first_touch) && (
|
|
289
|
+
<div className="border rounded-lg p-4">
|
|
290
|
+
<h3 className="font-medium mb-4">Attribution</h3>
|
|
291
|
+
<div className="space-y-3 text-sm">
|
|
292
|
+
{attr.anonymous_first_touch && (
|
|
342
293
|
<div className="flex justify-between">
|
|
343
|
-
<span className="text-muted-foreground">
|
|
344
|
-
<span className="
|
|
294
|
+
<span className="text-muted-foreground">Anonymous First Touch</span>
|
|
295
|
+
<span className="text-right">
|
|
296
|
+
<span className="font-medium">{attr.anonymous_first_touch.referrer || 'Direct'}</span>
|
|
297
|
+
<span className="text-xs text-muted-foreground block">
|
|
298
|
+
{attr.anonymous_first_touch.at ? new Date(attr.anonymous_first_touch.at).toLocaleDateString() : ''}
|
|
299
|
+
</span>
|
|
300
|
+
</span>
|
|
345
301
|
</div>
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
302
|
+
)}
|
|
303
|
+
{attr.identified_first_touch && (
|
|
304
|
+
<div className="flex justify-between">
|
|
305
|
+
<span className="text-muted-foreground">Identified First Touch</span>
|
|
306
|
+
<span className="text-right">
|
|
307
|
+
<span className="font-medium">{attr.identified_first_touch.referrer || 'Direct'}</span>
|
|
308
|
+
<span className="text-xs text-muted-foreground block">
|
|
309
|
+
{attr.identified_first_touch.at ? new Date(attr.identified_first_touch.at).toLocaleDateString() : ''}
|
|
310
|
+
</span>
|
|
311
|
+
</span>
|
|
352
312
|
</div>
|
|
353
|
-
|
|
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
314
|
</div>
|
|
360
315
|
</div>
|
|
361
|
-
|
|
316
|
+
)}
|
|
362
317
|
|
|
363
|
-
{/* Empty
|
|
364
|
-
{!stage && !temp && !ratings && !queue?.pending_opportunity_id && (
|
|
318
|
+
{/* Empty State */}
|
|
319
|
+
{!stage && !score && !temp && !ratings && !queue?.pending_opportunity_id && (
|
|
365
320
|
<div className="text-center py-12 text-muted-foreground">
|
|
366
|
-
<
|
|
321
|
+
<Funnel className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
|
367
322
|
<p className="text-sm">No funnel data yet.</p>
|
|
368
323
|
<p className="text-xs mt-1">Signals will appear here as they are processed.</p>
|
|
369
324
|
</div>
|
|
@@ -410,7 +365,7 @@ export default function AccountDetailPage() {
|
|
|
410
365
|
<TabsList className="h-9 bg-transparent p-0 gap-4">
|
|
411
366
|
{[
|
|
412
367
|
{ value: 'people', label: 'People', icon: User },
|
|
413
|
-
{ value: 'funnel', label: 'Funnel', icon:
|
|
368
|
+
{ value: 'funnel', label: 'Funnel', icon: Funnel },
|
|
414
369
|
{ value: 'tickets', label: 'Tickets', icon: Ticket },
|
|
415
370
|
{ value: 'deals', label: 'Deals', icon: Handshake },
|
|
416
371
|
{ value: 'health', label: 'Health', icon: Heart },
|
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react'
|
|
2
|
-
import { useNavigate } from 'react-router-dom'
|
|
3
2
|
import { apiFetch } from '@core/lib/api'
|
|
4
3
|
|
|
5
4
|
interface Person {
|
|
6
5
|
id: string
|
|
7
6
|
email: string
|
|
8
|
-
|
|
7
|
+
first_name?: string
|
|
8
|
+
last_name?: string
|
|
9
9
|
account_id?: string
|
|
10
10
|
created_at: string
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export default function ContactsPage() {
|
|
14
|
-
const navigate = useNavigate()
|
|
15
14
|
const [people, setPeople] = useState<Person[]>([])
|
|
16
15
|
const [loading, setLoading] = useState(true)
|
|
17
16
|
const [search, setSearch] = useState('')
|
|
@@ -26,8 +25,9 @@ export default function ContactsPage() {
|
|
|
26
25
|
|
|
27
26
|
const filtered = people.filter(p => {
|
|
28
27
|
const q = search.toLowerCase()
|
|
29
|
-
return !q ||
|
|
30
|
-
(p.
|
|
28
|
+
return !q || p.email.toLowerCase().includes(q) ||
|
|
29
|
+
(p.first_name || '').toLowerCase().includes(q) ||
|
|
30
|
+
(p.last_name || '').toLowerCase().includes(q)
|
|
31
31
|
})
|
|
32
32
|
|
|
33
33
|
return (
|
|
@@ -68,9 +68,9 @@ export default function ContactsPage() {
|
|
|
68
68
|
</td>
|
|
69
69
|
</tr>
|
|
70
70
|
) : filtered.map(person => (
|
|
71
|
-
<tr key={person.id} className="hover:bg-slate-50
|
|
71
|
+
<tr key={person.id} className="hover:bg-slate-50">
|
|
72
72
|
<td className="px-5 py-3 font-medium text-slate-900">
|
|
73
|
-
{person.
|
|
73
|
+
{[person.first_name, person.last_name].filter(Boolean).join(' ') || '—'}
|
|
74
74
|
</td>
|
|
75
75
|
<td className="px-5 py-3 text-slate-600">{person.email}</td>
|
|
76
76
|
<td className="px-5 py-3 text-right text-slate-400 text-xs">
|
|
@@ -11,11 +11,11 @@ interface FunnelSignal {
|
|
|
11
11
|
id: string
|
|
12
12
|
title: string
|
|
13
13
|
data: {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
signal_type: string
|
|
15
|
+
score_delta: number
|
|
16
|
+
account_id?: string
|
|
17
|
+
person_id?: string
|
|
18
|
+
occurred_at: string
|
|
19
19
|
}
|
|
20
20
|
created_at: string
|
|
21
21
|
}
|
|
@@ -95,7 +95,7 @@ export default function IntelligencePage() {
|
|
|
95
95
|
|
|
96
96
|
useEffect(() => {
|
|
97
97
|
if (accountsData) {
|
|
98
|
-
setAccounts(accountsData.filter((acc: any) => acc.data?.lead_score !== undefined
|
|
98
|
+
setAccounts(accountsData.filter((acc: any) => acc.data?.lead_score !== undefined))
|
|
99
99
|
}
|
|
100
100
|
}, [accountsData])
|
|
101
101
|
|
|
@@ -206,35 +206,28 @@ export default function IntelligencePage() {
|
|
|
206
206
|
<CardContent>
|
|
207
207
|
<div className="space-y-3">
|
|
208
208
|
{signals
|
|
209
|
-
.filter(signal => signal.data
|
|
209
|
+
.filter(signal => signal.data.account_id === selectedAccountId)
|
|
210
210
|
.slice(0, 5)
|
|
211
|
-
.map((signal) =>
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
<div
|
|
218
|
-
<
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
<div>
|
|
222
|
-
<div className="font-medium">{signal.title}</div>
|
|
223
|
-
<div className="text-sm text-gray-500">
|
|
224
|
-
{new Date(receivedAt).toLocaleDateString()}
|
|
225
|
-
</div>
|
|
211
|
+
.map((signal) => (
|
|
212
|
+
<div key={signal.id} className="flex items-center justify-between p-3 border rounded">
|
|
213
|
+
<div className="flex items-center space-x-3">
|
|
214
|
+
<Badge className={getSignalTypeColor(signal.data.signal_type)}>
|
|
215
|
+
{signal.data.signal_type}
|
|
216
|
+
</Badge>
|
|
217
|
+
<div>
|
|
218
|
+
<div className="font-medium">{signal.title}</div>
|
|
219
|
+
<div className="text-sm text-gray-500">
|
|
220
|
+
{new Date(signal.data.occurred_at).toLocaleDateString()}
|
|
226
221
|
</div>
|
|
227
222
|
</div>
|
|
228
|
-
{rating !== undefined && (
|
|
229
|
-
<div className={`font-bold ${
|
|
230
|
-
rating >= 4 ? 'text-green-600' : rating >= 3 ? 'text-yellow-600' : 'text-gray-500'
|
|
231
|
-
}`}>
|
|
232
|
-
{rating}/5
|
|
233
|
-
</div>
|
|
234
|
-
)}
|
|
235
223
|
</div>
|
|
236
|
-
|
|
237
|
-
|
|
224
|
+
<div className={`font-bold ${
|
|
225
|
+
signal.data.score_delta > 0 ? 'text-green-600' : 'text-red-600'
|
|
226
|
+
}`}>
|
|
227
|
+
{signal.data.score_delta > 0 ? '+' : ''}{signal.data.score_delta}
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
))}
|
|
238
231
|
</div>
|
|
239
232
|
</CardContent>
|
|
240
233
|
</Card>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState, useEffect } from 'react'
|
|
2
|
-
import { resolveTypeId } from '../../lib/resolveTypeId'
|
|
3
2
|
import { useParams, useNavigate } from 'react-router-dom'
|
|
4
3
|
import { apiFetch } from '@core/lib/api'
|
|
4
|
+
import { getTypeIdAsync } from '../hooks/useTypeRegistry'
|
|
5
5
|
import { RichTextEditor } from '@core/components/ui/RichTextEditor'
|
|
6
6
|
import { Button } from '@core/components/ui/button'
|
|
7
7
|
import { Input } from '@core/components/ui/input'
|
|
@@ -71,6 +71,7 @@ export default function KBEditorPage() {
|
|
|
71
71
|
const [loading, setLoading] = useState(!isNew)
|
|
72
72
|
const [saving, setSaving] = useState(false)
|
|
73
73
|
const [error, setError] = useState<string | null>(null)
|
|
74
|
+
const [kbArticleTypeId, setKbArticleTypeId] = useState<string>('')
|
|
74
75
|
|
|
75
76
|
useEffect(() => {
|
|
76
77
|
if (isNew) return
|
|
@@ -94,13 +95,19 @@ export default function KBEditorPage() {
|
|
|
94
95
|
.finally(() => setLoading(false))
|
|
95
96
|
}, [id, isNew])
|
|
96
97
|
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
getTypeIdAsync('kb_article').then(id => {
|
|
100
|
+
if (id) setKbArticleTypeId(id)
|
|
101
|
+
})
|
|
102
|
+
}, [])
|
|
103
|
+
|
|
97
104
|
const handleSave = async (publish?: boolean) => {
|
|
98
105
|
if (!form.title.trim()) { setError('Title is required'); return }
|
|
99
106
|
if (!form.kb_type) { setError('KB Type is required'); return }
|
|
107
|
+
if (!kbArticleTypeId) { setError('Type not loaded'); return }
|
|
100
108
|
setSaving(true)
|
|
101
109
|
setError(null)
|
|
102
110
|
try {
|
|
103
|
-
const kbArticleTypeId = await resolveTypeId('kb_article')
|
|
104
111
|
const payload = {
|
|
105
112
|
title: form.title.trim(),
|
|
106
113
|
description: form.description,
|