spine-framework-cortex 0.1.13 → 0.1.15

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.
@@ -26,3 +26,4 @@ registerWebhookHandler({
26
26
  description: 'Funnel signal processing webhook handler',
27
27
  events: ['integration.webhook'],
28
28
  }).catch(console.error)
29
+
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-framework-cortex",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Cortex — AI-powered support, CRM, and knowledge base app for Spine Framework",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -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>
@@ -11,11 +11,11 @@ interface FunnelSignal {
11
11
  id: string
12
12
  title: string
13
13
  data: {
14
- signal_type: string
15
- score_delta: number
16
- account_id?: string
17
- person_id?: string
18
- occurred_at: string
14
+ action?: { action_type?: string; action_value?: number }
15
+ identity?: { account_id?: string; person_id?: string; anonymous_id?: string }
16
+ classification?: { stage?: string; source?: string }
17
+ scoring_components?: { raw_score?: { rating?: number; calculated?: number } }
18
+ processing?: { received_at?: string; scored_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 || acc.data?.lifecycle_stage))
99
99
  }
100
100
  }, [accountsData])
101
101
 
@@ -206,28 +206,35 @@ export default function IntelligencePage() {
206
206
  <CardContent>
207
207
  <div className="space-y-3">
208
208
  {signals
209
- .filter(signal => signal.data.account_id === selectedAccountId)
209
+ .filter(signal => signal.data?.identity?.account_id === selectedAccountId)
210
210
  .slice(0, 5)
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()}
211
+ .map((signal) => {
212
+ const actionType = signal.data?.action?.action_type
213
+ const rating = signal.data?.scoring_components?.raw_score?.rating
214
+ const receivedAt = signal.data?.processing?.received_at || signal.created_at
215
+ return (
216
+ <div key={signal.id} className="flex items-center justify-between p-3 border rounded">
217
+ <div className="flex items-center space-x-3">
218
+ <Badge className={getSignalTypeColor(actionType || '')}>
219
+ {actionType || signal.title}
220
+ </Badge>
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>
221
226
  </div>
222
227
  </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
+ )}
223
235
  </div>
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
- ))}
236
+ )
237
+ })}
231
238
  </div>
232
239
  </CardContent>
233
240
  </Card>