@growth-labs/cms 0.5.8 → 0.5.10
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/README.md +26 -0
- package/dist/engine/content-insights.d.ts +11 -4
- package/dist/engine/content-insights.d.ts.map +1 -1
- package/dist/engine/content-insights.js +22 -7
- package/dist/engine/content-insights.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/routes/content-insights.d.ts +1 -1
- package/dist/routes/content-insights.d.ts.map +1 -1
- package/dist/routes/content-insights.js +20 -5
- package/dist/routes/content-insights.js.map +1 -1
- package/dist/schema/index.d.ts +1 -0
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +1 -0
- package/dist/schema/index.js.map +1 -1
- package/dist/schema/insights-ingest.d.ts +2386 -0
- package/dist/schema/insights-ingest.d.ts.map +1 -1
- package/dist/schema/insights-ingest.js +78 -0
- package/dist/schema/insights-ingest.js.map +1 -1
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +9 -0
- package/dist/schema/migrations.js.map +1 -1
- package/dist/schema/types.d.ts +67 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js +10 -0
- package/dist/schema/types.js.map +1 -1
- package/dist/surveys/schema.d.ts +48 -48
- package/dist/ui/screens/ContentInsightsScreen.d.ts +28 -0
- package/dist/ui/screens/ContentInsightsScreen.d.ts.map +1 -1
- package/dist/ui/screens/ContentInsightsScreen.js +111 -36
- package/dist/ui/screens/ContentInsightsScreen.js.map +1 -1
- package/dist/ui/screens/content-insights-data.d.ts +13 -1
- package/dist/ui/screens/content-insights-data.d.ts.map +1 -1
- package/dist/ui/screens/content-insights-data.js +29 -1
- package/dist/ui/screens/content-insights-data.js.map +1 -1
- package/migrations/0022_content_insight_dismissal_reasons.sql +8 -0
- package/package.json +1 -1
- package/src/engine/content-insights.ts +32 -7
- package/src/index.ts +2 -0
- package/src/routes/content-insights.ts +31 -5
- package/src/schema/index.ts +1 -0
- package/src/schema/insights-ingest.ts +94 -0
- package/src/schema/migrations.ts +9 -0
- package/src/schema/types.ts +92 -0
- package/src/ui/screens/ContentInsightsScreen.tsx +416 -195
- package/src/ui/screens/content-insights-data.ts +40 -2
|
@@ -1,28 +1,58 @@
|
|
|
1
1
|
// src/ui/screens/ContentInsightsScreen.tsx — Content Insights demand-intelligence board (bead-09).
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// beat is rendered as a secondary chip next to signal_type (Adaptation 3 — spec §desk-taxonomy).
|
|
6
|
-
// Broadsheet idiom only; honest loading/empty/error/schema-not-ready/last-good-board states.
|
|
2
|
+
// Lane-first editorial decision docket. Each card appears once in its Insight Engine action
|
|
3
|
+
// lane and expands into its bounded evidence plus the full editorial mini-brief. Legacy rows
|
|
4
|
+
// remain readable in an unclassified appendix; dismiss persists and filters subsequent loads.
|
|
7
5
|
import { useCallback, useEffect, useReducer, useState } from 'react'
|
|
6
|
+
import type { InsightDismissalReasonCode } from '../../schema/types.js'
|
|
8
7
|
import { Icon } from '../icons.js'
|
|
9
8
|
import {
|
|
10
|
-
audienceQuestionGroups,
|
|
11
|
-
bestBets,
|
|
12
|
-
competitorWatchGroups,
|
|
13
|
-
demandDeskGroups,
|
|
14
9
|
discoveryKindOf,
|
|
15
10
|
formatDemandEvidence,
|
|
16
11
|
type InsightCard,
|
|
17
12
|
initInsightsState,
|
|
18
13
|
insightsReducer,
|
|
19
|
-
|
|
14
|
+
laneGroups,
|
|
15
|
+
shouldRenderInsightLanes,
|
|
20
16
|
} from './content-insights-data.js'
|
|
21
17
|
|
|
18
|
+
type DismissReasonSelection = InsightDismissalReasonCode | ''
|
|
19
|
+
|
|
20
|
+
export interface DismissRequestBody {
|
|
21
|
+
intentKey: string
|
|
22
|
+
reasonCode: InsightDismissalReasonCode
|
|
23
|
+
note?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Build the exact bounded route body; route validation remains authoritative. */
|
|
27
|
+
export function buildDismissRequest(
|
|
28
|
+
intentKey: string,
|
|
29
|
+
reasonCode: DismissReasonSelection,
|
|
30
|
+
note: string,
|
|
31
|
+
): DismissRequestBody | null {
|
|
32
|
+
if (reasonCode === '') return null
|
|
33
|
+
const normalizedNote = note.trim()
|
|
34
|
+
if (normalizedNote.length > 500) return null
|
|
35
|
+
if (reasonCode === 'other' && normalizedNote.length < 3) return null
|
|
36
|
+
return {
|
|
37
|
+
intentKey,
|
|
38
|
+
reasonCode,
|
|
39
|
+
...(normalizedNote.length > 0 ? { note: normalizedNote } : {}),
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A non-2xx response must leave the card and its editor intact for correction. */
|
|
44
|
+
export function shouldRemoveDismissedCard(responseOk: boolean): boolean {
|
|
45
|
+
return responseOk
|
|
46
|
+
}
|
|
47
|
+
|
|
22
48
|
export function ContentInsightsScreen() {
|
|
23
49
|
const [s, dispatch] = useReducer(insightsReducer, undefined, initInsightsState)
|
|
24
50
|
const [expanded, setExpanded] = useState<string | null>(null)
|
|
25
51
|
const [dismissing, setDismissing] = useState<string | null>(null)
|
|
52
|
+
const [dismissEditorFor, setDismissEditorFor] = useState<string | null>(null)
|
|
53
|
+
const [dismissReasonCode, setDismissReasonCode] = useState<DismissReasonSelection>('')
|
|
54
|
+
const [dismissNote, setDismissNote] = useState('')
|
|
55
|
+
const [dismissError, setDismissError] = useState<string | null>(null)
|
|
26
56
|
|
|
27
57
|
const fetchBoard = useCallback(async () => {
|
|
28
58
|
dispatch({ type: 'fetch-start' })
|
|
@@ -47,27 +77,52 @@ export function ContentInsightsScreen() {
|
|
|
47
77
|
void fetchBoard()
|
|
48
78
|
}, [fetchBoard])
|
|
49
79
|
|
|
80
|
+
const openDismissEditor = (card: InsightCard) => {
|
|
81
|
+
setDismissEditorFor(card.intent_key)
|
|
82
|
+
setDismissReasonCode('')
|
|
83
|
+
setDismissNote('')
|
|
84
|
+
setDismissError(null)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const closeDismissEditor = () => {
|
|
88
|
+
setDismissEditorFor(null)
|
|
89
|
+
setDismissReasonCode('')
|
|
90
|
+
setDismissNote('')
|
|
91
|
+
setDismissError(null)
|
|
92
|
+
}
|
|
93
|
+
|
|
50
94
|
const dismiss = async (card: InsightCard) => {
|
|
95
|
+
const body = buildDismissRequest(card.intent_key, dismissReasonCode, dismissNote)
|
|
96
|
+
if (body == null) {
|
|
97
|
+
setDismissError(
|
|
98
|
+
dismissReasonCode === 'other'
|
|
99
|
+
? 'Other requires an editorial note of at least 3 characters.'
|
|
100
|
+
: 'Choose a dismissal reason before saving.',
|
|
101
|
+
)
|
|
102
|
+
return
|
|
103
|
+
}
|
|
51
104
|
setDismissing(card.intent_key)
|
|
105
|
+
setDismissError(null)
|
|
52
106
|
try {
|
|
53
107
|
const res = await fetch('/admin/api/content-insights/dismiss', {
|
|
54
108
|
method: 'POST',
|
|
55
109
|
headers: { 'content-type': 'application/json' },
|
|
56
|
-
body: JSON.stringify(
|
|
110
|
+
body: JSON.stringify(body),
|
|
57
111
|
})
|
|
58
|
-
if (res.ok)
|
|
112
|
+
if (shouldRemoveDismissedCard(res.ok)) {
|
|
113
|
+
dispatch({ type: 'dismiss-ok', intentKey: card.intent_key })
|
|
114
|
+
closeDismissEditor()
|
|
115
|
+
} else {
|
|
116
|
+
setDismissError(`Could not save dismissal — ${res.status} ${res.statusText}`)
|
|
117
|
+
}
|
|
118
|
+
} catch (error) {
|
|
119
|
+
setDismissError(`Could not save dismissal — ${String(error)}`)
|
|
59
120
|
} finally {
|
|
60
121
|
setDismissing(null)
|
|
61
122
|
}
|
|
62
123
|
}
|
|
63
124
|
|
|
64
|
-
|
|
65
|
-
// labelled sections (Competitor Watch + What Readers Are Asking) below.
|
|
66
|
-
const groups = demandDeskGroups(s.insights)
|
|
67
|
-
const competitorGroups = competitorWatchGroups(s.insights)
|
|
68
|
-
const audienceGroups = audienceQuestionGroups(s.insights)
|
|
69
|
-
const bets = bestBets(s.insights)
|
|
70
|
-
const trackerCards = trackers(s.insights)
|
|
125
|
+
const groups = laneGroups(s.insights)
|
|
71
126
|
|
|
72
127
|
return (
|
|
73
128
|
<div className="page page-wide content-insights-screen fade-in" style={{ maxWidth: 1340 }}>
|
|
@@ -106,181 +161,76 @@ export function ContentInsightsScreen() {
|
|
|
106
161
|
/>
|
|
107
162
|
)}
|
|
108
163
|
|
|
109
|
-
{s
|
|
110
|
-
<
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
title="Competitor Watch"
|
|
175
|
-
subtitle="What rivals are publishing that we're missing"
|
|
176
|
-
groups={competitorGroups}
|
|
177
|
-
expanded={expanded}
|
|
178
|
-
setExpanded={setExpanded}
|
|
179
|
-
dismiss={dismiss}
|
|
180
|
-
dismissing={dismissing}
|
|
181
|
-
/>
|
|
182
|
-
)}
|
|
183
|
-
|
|
184
|
-
{audienceGroups.length > 0 && (
|
|
185
|
-
<OpportunitySection
|
|
186
|
-
title="What Readers Are Asking"
|
|
187
|
-
subtitle="Real reader questions (Google People-Also-Ask) we don't yet answer"
|
|
188
|
-
groups={audienceGroups}
|
|
189
|
-
expanded={expanded}
|
|
190
|
-
setExpanded={setExpanded}
|
|
191
|
-
dismiss={dismiss}
|
|
192
|
-
dismissing={dismissing}
|
|
193
|
-
/>
|
|
194
|
-
)}
|
|
195
|
-
|
|
196
|
-
{trackerCards.length > 0 && (
|
|
197
|
-
<div className="panel" style={{ overflow: 'hidden' }}>
|
|
198
|
-
<div style={{ padding: '15px 18px 12px' }}>
|
|
199
|
-
<div className="card-title">Trackers</div>
|
|
200
|
-
</div>
|
|
201
|
-
<hr className="masthead-rule" style={{ margin: '0 18px' }} />
|
|
202
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: 18 }}>
|
|
203
|
-
{trackerCards.map((c) => (
|
|
204
|
-
<InsightCardRow
|
|
205
|
-
key={c.intent_key}
|
|
206
|
-
card={c}
|
|
207
|
-
expanded={expanded === c.intent_key}
|
|
208
|
-
onToggle={() => setExpanded(expanded === c.intent_key ? null : c.intent_key)}
|
|
209
|
-
onDismiss={() => void dismiss(c)}
|
|
210
|
-
dismissing={dismissing === c.intent_key}
|
|
211
|
-
/>
|
|
212
|
-
))}
|
|
213
|
-
</div>
|
|
214
|
-
</div>
|
|
164
|
+
{shouldRenderInsightLanes(s) && (
|
|
165
|
+
<section aria-label="Insight Engine decision lanes">
|
|
166
|
+
{groups.map((group) => (
|
|
167
|
+
<section
|
|
168
|
+
key={group.lane ?? 'legacy'}
|
|
169
|
+
className="panel"
|
|
170
|
+
aria-labelledby={`insight-lane-${group.lane ?? 'legacy'}`}
|
|
171
|
+
style={{
|
|
172
|
+
overflow: 'hidden',
|
|
173
|
+
marginBottom: 'var(--gap)',
|
|
174
|
+
borderLeft: `3px solid ${group.lane == null ? 'var(--hairline)' : 'var(--gold)'}`,
|
|
175
|
+
}}
|
|
176
|
+
>
|
|
177
|
+
<header
|
|
178
|
+
style={{
|
|
179
|
+
padding: '15px 18px 12px',
|
|
180
|
+
display: 'flex',
|
|
181
|
+
alignItems: 'baseline',
|
|
182
|
+
justifyContent: 'space-between',
|
|
183
|
+
gap: 12,
|
|
184
|
+
}}
|
|
185
|
+
>
|
|
186
|
+
<h2
|
|
187
|
+
id={`insight-lane-${group.lane ?? 'legacy'}`}
|
|
188
|
+
className="card-title serif"
|
|
189
|
+
style={{ margin: 0 }}
|
|
190
|
+
>
|
|
191
|
+
{group.label}
|
|
192
|
+
</h2>
|
|
193
|
+
<span className="kicker tnum">
|
|
194
|
+
{group.cards.length} {group.cards.length === 1 ? 'idea' : 'ideas'}
|
|
195
|
+
</span>
|
|
196
|
+
</header>
|
|
197
|
+
<hr className="masthead-rule" style={{ margin: '0 18px' }} />
|
|
198
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: 18 }}>
|
|
199
|
+
{group.cards.length === 0 ? (
|
|
200
|
+
<div className="kicker" style={{ padding: '10px 0', color: 'var(--ink-faint)' }}>
|
|
201
|
+
No ideas in this lane.
|
|
202
|
+
</div>
|
|
203
|
+
) : (
|
|
204
|
+
group.cards.map((card) => (
|
|
205
|
+
<InsightCardRow
|
|
206
|
+
key={card.intent_key}
|
|
207
|
+
card={card}
|
|
208
|
+
expanded={expanded === card.intent_key}
|
|
209
|
+
onToggle={() =>
|
|
210
|
+
setExpanded(expanded === card.intent_key ? null : card.intent_key)
|
|
211
|
+
}
|
|
212
|
+
dismissEditorOpen={dismissEditorFor === card.intent_key}
|
|
213
|
+
onOpenDismissEditor={() => openDismissEditor(card)}
|
|
214
|
+
onCancelDismissEditor={closeDismissEditor}
|
|
215
|
+
onSubmitDismiss={() => void dismiss(card)}
|
|
216
|
+
dismissReasonCode={dismissReasonCode}
|
|
217
|
+
dismissNote={dismissNote}
|
|
218
|
+
dismissError={dismissError}
|
|
219
|
+
onDismissReasonCodeChange={setDismissReasonCode}
|
|
220
|
+
onDismissNoteChange={setDismissNote}
|
|
221
|
+
dismissing={dismissing === card.intent_key}
|
|
222
|
+
/>
|
|
223
|
+
))
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
</section>
|
|
227
|
+
))}
|
|
228
|
+
</section>
|
|
215
229
|
)}
|
|
216
230
|
</div>
|
|
217
231
|
)
|
|
218
232
|
}
|
|
219
233
|
|
|
220
|
-
/** One labelled opportunity SECTION (Competitor Watch / What Readers Are Asking),
|
|
221
|
-
* grouped by desk inside. Reuses the desk-panel + InsightCardRow rendering so the
|
|
222
|
-
* two new sub-kinds match the rest of the board. */
|
|
223
|
-
function OpportunitySection({
|
|
224
|
-
title,
|
|
225
|
-
subtitle,
|
|
226
|
-
groups,
|
|
227
|
-
expanded,
|
|
228
|
-
setExpanded,
|
|
229
|
-
dismiss,
|
|
230
|
-
dismissing,
|
|
231
|
-
}: {
|
|
232
|
-
title: string
|
|
233
|
-
subtitle: string
|
|
234
|
-
groups: Array<{ desk: string; cards: InsightCard[] }>
|
|
235
|
-
expanded: string | null
|
|
236
|
-
setExpanded: (v: string | null) => void
|
|
237
|
-
dismiss: (c: InsightCard) => Promise<void>
|
|
238
|
-
dismissing: string | null
|
|
239
|
-
}) {
|
|
240
|
-
return (
|
|
241
|
-
<div className="panel" style={{ overflow: 'hidden', marginBottom: 'var(--gap)' }}>
|
|
242
|
-
<div style={{ padding: '15px 18px 6px' }}>
|
|
243
|
-
<div className="card-title">{title}</div>
|
|
244
|
-
<div className="kicker" style={{ marginTop: 2 }}>
|
|
245
|
-
{subtitle}
|
|
246
|
-
</div>
|
|
247
|
-
</div>
|
|
248
|
-
<hr className="masthead-rule" style={{ margin: '0 18px' }} />
|
|
249
|
-
{groups.map((g) => (
|
|
250
|
-
<div key={g.desk}>
|
|
251
|
-
<div
|
|
252
|
-
style={{
|
|
253
|
-
padding: '12px 18px 6px',
|
|
254
|
-
display: 'flex',
|
|
255
|
-
alignItems: 'baseline',
|
|
256
|
-
gap: 10,
|
|
257
|
-
}}
|
|
258
|
-
>
|
|
259
|
-
<div className="card-title" style={{ textTransform: 'capitalize', fontSize: '0.95em' }}>
|
|
260
|
-
{g.desk}
|
|
261
|
-
</div>
|
|
262
|
-
<span className="kicker">{g.cards.length} ideas</span>
|
|
263
|
-
</div>
|
|
264
|
-
<div
|
|
265
|
-
style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: '0 18px 14px' }}
|
|
266
|
-
>
|
|
267
|
-
{g.cards.map((c) => (
|
|
268
|
-
<InsightCardRow
|
|
269
|
-
key={c.intent_key}
|
|
270
|
-
card={c}
|
|
271
|
-
expanded={expanded === c.intent_key}
|
|
272
|
-
onToggle={() => setExpanded(expanded === c.intent_key ? null : c.intent_key)}
|
|
273
|
-
onDismiss={() => void dismiss(c)}
|
|
274
|
-
dismissing={dismissing === c.intent_key}
|
|
275
|
-
/>
|
|
276
|
-
))}
|
|
277
|
-
</div>
|
|
278
|
-
</div>
|
|
279
|
-
))}
|
|
280
|
-
</div>
|
|
281
|
-
)
|
|
282
|
-
}
|
|
283
|
-
|
|
284
234
|
function evidenceChip(card: InsightCard): string {
|
|
285
235
|
if ('query' in card.evidence) return formatDemandEvidence(card.evidence)
|
|
286
236
|
// Kind-aware opportunity chip: audience rows are grounded in reader questions,
|
|
@@ -292,19 +242,52 @@ function evidenceChip(card: InsightCard): string {
|
|
|
292
242
|
return `${card.evidence.basis} · ${card.evidence.source_count} sources`
|
|
293
243
|
}
|
|
294
244
|
|
|
295
|
-
function
|
|
245
|
+
function formatPercent(value: number): string {
|
|
246
|
+
return `${(value * 100).toLocaleString('en-US', { maximumFractionDigits: 2 })}%`
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function formatEvidenceNumber(value: number): string {
|
|
250
|
+
return value.toLocaleString('en-US', { maximumFractionDigits: 2 })
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function formatSignedEvidenceNumber(value: number): string {
|
|
254
|
+
const formatted = formatEvidenceNumber(value)
|
|
255
|
+
return value > 0 ? `+${formatted}` : formatted
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function InsightCardRow({
|
|
296
259
|
card,
|
|
297
260
|
expanded,
|
|
298
261
|
onToggle,
|
|
299
|
-
|
|
262
|
+
dismissEditorOpen,
|
|
263
|
+
onOpenDismissEditor,
|
|
264
|
+
onCancelDismissEditor,
|
|
265
|
+
onSubmitDismiss,
|
|
266
|
+
dismissReasonCode,
|
|
267
|
+
dismissNote,
|
|
268
|
+
dismissError,
|
|
269
|
+
onDismissReasonCodeChange,
|
|
270
|
+
onDismissNoteChange,
|
|
300
271
|
dismissing,
|
|
301
272
|
}: {
|
|
302
273
|
card: InsightCard
|
|
303
274
|
expanded: boolean
|
|
304
275
|
onToggle: () => void
|
|
305
|
-
|
|
276
|
+
dismissEditorOpen: boolean
|
|
277
|
+
onOpenDismissEditor: () => void
|
|
278
|
+
onCancelDismissEditor: () => void
|
|
279
|
+
onSubmitDismiss: () => void
|
|
280
|
+
dismissReasonCode: DismissReasonSelection
|
|
281
|
+
dismissNote: string
|
|
282
|
+
dismissError: string | null
|
|
283
|
+
onDismissReasonCodeChange: (reasonCode: DismissReasonSelection) => void
|
|
284
|
+
onDismissNoteChange: (note: string) => void
|
|
306
285
|
dismissing: boolean
|
|
307
286
|
}) {
|
|
287
|
+
const detailsId = `insight-details-${card.id}`
|
|
288
|
+
const dismissReasonId = `insight-dismiss-reason-${card.id}`
|
|
289
|
+
const dismissNoteId = `insight-dismiss-note-${card.id}`
|
|
290
|
+
|
|
308
291
|
return (
|
|
309
292
|
<div
|
|
310
293
|
style={{
|
|
@@ -325,6 +308,8 @@ function InsightCardRow({
|
|
|
325
308
|
<button
|
|
326
309
|
type="button"
|
|
327
310
|
onClick={onToggle}
|
|
311
|
+
aria-expanded={expanded}
|
|
312
|
+
aria-controls={detailsId}
|
|
328
313
|
style={{
|
|
329
314
|
textAlign: 'left',
|
|
330
315
|
background: 'none',
|
|
@@ -341,16 +326,120 @@ function InsightCardRow({
|
|
|
341
326
|
{evidenceChip(card)}
|
|
342
327
|
</div>
|
|
343
328
|
</button>
|
|
344
|
-
<div
|
|
329
|
+
<div
|
|
330
|
+
style={{
|
|
331
|
+
display: 'flex',
|
|
332
|
+
alignItems: 'center',
|
|
333
|
+
justifyContent: 'flex-end',
|
|
334
|
+
flexWrap: 'wrap',
|
|
335
|
+
gap: 8,
|
|
336
|
+
}}
|
|
337
|
+
>
|
|
338
|
+
<span className="pill pdot">{card.desk}</span>
|
|
345
339
|
<span className="pill pdot">{card.signal_type}</span>
|
|
340
|
+
{(card.signal_type === 'opportunity' || card.brief.discoveryKind != null) && (
|
|
341
|
+
<span className="pill pdot">{discoveryKindOf(card)}</span>
|
|
342
|
+
)}
|
|
343
|
+
{card.suggestion_type === 'tracker' && <span className="pill pdot">tracker</span>}
|
|
346
344
|
{card.beat != null && <span className="pill pdot">{card.beat}</span>}
|
|
347
|
-
|
|
348
|
-
<
|
|
345
|
+
{card.is_best_bet === 1 && (
|
|
346
|
+
<span className="pill pdot">
|
|
347
|
+
best bet{card.best_bet_rank != null ? ` #${card.best_bet_rank}` : ''}
|
|
348
|
+
</span>
|
|
349
|
+
)}
|
|
350
|
+
<button
|
|
351
|
+
type="button"
|
|
352
|
+
className="btn danger sm"
|
|
353
|
+
onClick={dismissEditorOpen ? onCancelDismissEditor : onOpenDismissEditor}
|
|
354
|
+
disabled={dismissing}
|
|
355
|
+
>
|
|
356
|
+
<Icon name="x" size={13} /> {dismissEditorOpen ? 'Cancel dismissal' : 'Dismiss'}
|
|
349
357
|
</button>
|
|
350
358
|
</div>
|
|
351
359
|
</div>
|
|
360
|
+
{dismissEditorOpen && (
|
|
361
|
+
<form
|
|
362
|
+
onSubmit={(event) => {
|
|
363
|
+
event.preventDefault()
|
|
364
|
+
onSubmitDismiss()
|
|
365
|
+
}}
|
|
366
|
+
style={{
|
|
367
|
+
marginTop: 12,
|
|
368
|
+
borderTop: '1px solid var(--border)',
|
|
369
|
+
paddingTop: 12,
|
|
370
|
+
display: 'grid',
|
|
371
|
+
gap: 10,
|
|
372
|
+
}}
|
|
373
|
+
>
|
|
374
|
+
<div>
|
|
375
|
+
<label htmlFor={dismissReasonId} className="kicker">
|
|
376
|
+
Dismiss reason <span aria-hidden="true">*</span>
|
|
377
|
+
</label>
|
|
378
|
+
<select
|
|
379
|
+
id={dismissReasonId}
|
|
380
|
+
className="selectbox"
|
|
381
|
+
value={dismissReasonCode}
|
|
382
|
+
onChange={(event) =>
|
|
383
|
+
onDismissReasonCodeChange(event.currentTarget.value as DismissReasonSelection)
|
|
384
|
+
}
|
|
385
|
+
required
|
|
386
|
+
aria-describedby={`${dismissReasonId}-help`}
|
|
387
|
+
>
|
|
388
|
+
<option value="" disabled>
|
|
389
|
+
Choose a reason
|
|
390
|
+
</option>
|
|
391
|
+
<option value="off_topic">Off topic</option>
|
|
392
|
+
<option value="already_covered">Already covered</option>
|
|
393
|
+
<option value="cant_win">Can’t win</option>
|
|
394
|
+
<option value="wrong_format">Wrong format</option>
|
|
395
|
+
<option value="not_now">Not now</option>
|
|
396
|
+
<option value="other">Other</option>
|
|
397
|
+
</select>
|
|
398
|
+
<div id={`${dismissReasonId}-help`} className="kicker" style={{ marginTop: 4 }}>
|
|
399
|
+
Reason is required.
|
|
400
|
+
</div>
|
|
401
|
+
</div>
|
|
402
|
+
<div>
|
|
403
|
+
<label htmlFor={dismissNoteId} className="kicker">
|
|
404
|
+
Editorial note {dismissReasonCode === 'other' && '(required)'}
|
|
405
|
+
</label>
|
|
406
|
+
<textarea
|
|
407
|
+
id={dismissNoteId}
|
|
408
|
+
className="textarea"
|
|
409
|
+
value={dismissNote}
|
|
410
|
+
onChange={(event) => onDismissNoteChange(event.currentTarget.value)}
|
|
411
|
+
maxLength={500}
|
|
412
|
+
required={dismissReasonCode === 'other'}
|
|
413
|
+
aria-describedby={`${dismissNoteId}-help`}
|
|
414
|
+
/>
|
|
415
|
+
<div id={`${dismissNoteId}-help`} className="kicker" style={{ marginTop: 4 }}>
|
|
416
|
+
500 characters maximum
|
|
417
|
+
{dismissReasonCode === 'other' && ' · Required for Other (at least 3 characters)'}
|
|
418
|
+
</div>
|
|
419
|
+
</div>
|
|
420
|
+
{dismissError != null && (
|
|
421
|
+
<div role="alert" style={{ color: 'var(--red)', fontSize: 13 }}>
|
|
422
|
+
{dismissError}
|
|
423
|
+
</div>
|
|
424
|
+
)}
|
|
425
|
+
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
|
426
|
+
<button
|
|
427
|
+
type="button"
|
|
428
|
+
className="btn sm"
|
|
429
|
+
onClick={onCancelDismissEditor}
|
|
430
|
+
disabled={dismissing}
|
|
431
|
+
>
|
|
432
|
+
Cancel
|
|
433
|
+
</button>
|
|
434
|
+
<button type="submit" className="btn danger sm" disabled={dismissing}>
|
|
435
|
+
<Icon name="x" size={13} /> {dismissing ? 'Saving…' : 'Save dismissal'}
|
|
436
|
+
</button>
|
|
437
|
+
</div>
|
|
438
|
+
</form>
|
|
439
|
+
)}
|
|
352
440
|
{expanded && (
|
|
353
441
|
<div
|
|
442
|
+
id={detailsId}
|
|
354
443
|
style={{
|
|
355
444
|
marginTop: 12,
|
|
356
445
|
borderTop: '1px solid var(--border)',
|
|
@@ -360,6 +449,114 @@ function InsightCardRow({
|
|
|
360
449
|
lineHeight: 1.6,
|
|
361
450
|
}}
|
|
362
451
|
>
|
|
452
|
+
{card.evidence.scoreBreakdown != null && (
|
|
453
|
+
<div
|
|
454
|
+
className="mono tnum"
|
|
455
|
+
style={{
|
|
456
|
+
display: 'flex',
|
|
457
|
+
flexWrap: 'wrap',
|
|
458
|
+
gap: '6px 18px',
|
|
459
|
+
padding: '9px 10px',
|
|
460
|
+
marginBottom: 12,
|
|
461
|
+
background: 'var(--accent-tint)',
|
|
462
|
+
borderLeft: '2px solid var(--accent)',
|
|
463
|
+
fontSize: 12,
|
|
464
|
+
}}
|
|
465
|
+
>
|
|
466
|
+
<span>
|
|
467
|
+
<strong>Actual CTR:</strong> {formatPercent(card.evidence.scoreBreakdown.actualCtr)}
|
|
468
|
+
</span>
|
|
469
|
+
<span>
|
|
470
|
+
<strong>Expected CTR:</strong>{' '}
|
|
471
|
+
{formatPercent(card.evidence.scoreBreakdown.expectedCtr)}
|
|
472
|
+
</span>
|
|
473
|
+
<span>
|
|
474
|
+
<strong>Expected click gain:</strong>{' '}
|
|
475
|
+
{formatEvidenceNumber(card.evidence.scoreBreakdown.expectedClicksGain)}
|
|
476
|
+
</span>
|
|
477
|
+
<span>
|
|
478
|
+
<strong>Lane weight:</strong>{' '}
|
|
479
|
+
{formatPercent(card.evidence.scoreBreakdown.laneWeight)}
|
|
480
|
+
</span>
|
|
481
|
+
<span>
|
|
482
|
+
<strong>Score:</strong> {formatEvidenceNumber(card.evidence.scoreBreakdown.score)}
|
|
483
|
+
</span>
|
|
484
|
+
<span>
|
|
485
|
+
<strong>Target position:</strong>{' '}
|
|
486
|
+
{formatEvidenceNumber(card.evidence.scoreBreakdown.targetPosition)}
|
|
487
|
+
</span>
|
|
488
|
+
<span>
|
|
489
|
+
<strong>Breakdown topical fit:</strong>{' '}
|
|
490
|
+
{formatPercent(card.evidence.scoreBreakdown.topicalFit)}
|
|
491
|
+
</span>
|
|
492
|
+
<span>
|
|
493
|
+
<strong>Winnability prior:</strong>{' '}
|
|
494
|
+
{formatPercent(card.evidence.scoreBreakdown.winnabilityPrior)}
|
|
495
|
+
</span>
|
|
496
|
+
</div>
|
|
497
|
+
)}
|
|
498
|
+
{'query' in card.evidence &&
|
|
499
|
+
(card.evidence.coveredBy != null || card.evidence.coverageSource != null) && (
|
|
500
|
+
<div style={{ marginBottom: 12 }}>
|
|
501
|
+
{card.evidence.coveredBy != null && (
|
|
502
|
+
<div>
|
|
503
|
+
<strong>Covered by:</strong>{' '}
|
|
504
|
+
<a href={card.evidence.coveredBy} target="_blank" rel="noreferrer">
|
|
505
|
+
{card.evidence.coveredBy}
|
|
506
|
+
</a>
|
|
507
|
+
</div>
|
|
508
|
+
)}
|
|
509
|
+
{card.evidence.coverageSource != null && (
|
|
510
|
+
<div>
|
|
511
|
+
<strong>Coverage source:</strong> {card.evidence.coverageSource}
|
|
512
|
+
</div>
|
|
513
|
+
)}
|
|
514
|
+
</div>
|
|
515
|
+
)}
|
|
516
|
+
{card.evidence.semantic != null && (
|
|
517
|
+
<div style={{ marginBottom: 12 }}>
|
|
518
|
+
<div className="kicker" style={{ marginBottom: 4 }}>
|
|
519
|
+
Semantic evidence
|
|
520
|
+
</div>
|
|
521
|
+
<div>
|
|
522
|
+
<strong>Topical fit:</strong> {formatPercent(card.evidence.semantic.topicalFit)} ·{' '}
|
|
523
|
+
<strong>Disposition:</strong> {card.evidence.semantic.disposition} ·{' '}
|
|
524
|
+
<strong>Coverage similarity:</strong>{' '}
|
|
525
|
+
{formatPercent(card.evidence.semantic.coverageSimilarity)} ·{' '}
|
|
526
|
+
{card.evidence.semantic.coverageAligned ? 'aligned' : 'not aligned'} ·{' '}
|
|
527
|
+
{card.evidence.semantic.sharedConcepts} shared concepts
|
|
528
|
+
</div>
|
|
529
|
+
{card.evidence.semantic.nearest.length > 0 && (
|
|
530
|
+
<ul style={{ margin: '4px 0 0', paddingLeft: 18 }}>
|
|
531
|
+
{card.evidence.semantic.nearest.map((entry) => (
|
|
532
|
+
<li key={`${entry.slug}-${entry.title}`}>
|
|
533
|
+
{entry.title} ({formatPercent(entry.similarity)})
|
|
534
|
+
</li>
|
|
535
|
+
))}
|
|
536
|
+
</ul>
|
|
537
|
+
)}
|
|
538
|
+
</div>
|
|
539
|
+
)}
|
|
540
|
+
{card.evidence.judge != null && (
|
|
541
|
+
<div style={{ marginBottom: 12 }}>
|
|
542
|
+
<div className="kicker" style={{ marginBottom: 4 }}>
|
|
543
|
+
Judge decision
|
|
544
|
+
</div>
|
|
545
|
+
<div>
|
|
546
|
+
<strong>Judge:</strong> {card.evidence.judge.verdict} ·{' '}
|
|
547
|
+
{card.evidence.judge.reason_code} · {formatPercent(card.evidence.judge.confidence)}{' '}
|
|
548
|
+
confidence
|
|
549
|
+
</div>
|
|
550
|
+
<div>{card.evidence.judge.intent_statement}</div>
|
|
551
|
+
{card.evidence.judge.winnability != null && (
|
|
552
|
+
<div style={{ marginTop: 4 }}>
|
|
553
|
+
<strong>Winnability:</strong> {card.evidence.judge.winnability.verdict} ·{' '}
|
|
554
|
+
{formatPercent(card.evidence.judge.winnability.confidence)} confidence —{' '}
|
|
555
|
+
{card.evidence.judge.winnability.serp_summary}
|
|
556
|
+
</div>
|
|
557
|
+
)}
|
|
558
|
+
</div>
|
|
559
|
+
)}
|
|
363
560
|
<div>
|
|
364
561
|
<strong>Angle:</strong> {card.brief.angle}
|
|
365
562
|
</div>
|
|
@@ -367,6 +564,30 @@ function InsightCardRow({
|
|
|
367
564
|
<strong>Cluster:</strong> {card.brief.cluster.canonical}
|
|
368
565
|
{card.brief.cluster.spokes.length > 0 && ` — ${card.brief.cluster.spokes.join(', ')}`}
|
|
369
566
|
</div>
|
|
567
|
+
{card.brief.format != null && (
|
|
568
|
+
<div style={{ marginTop: 8 }}>
|
|
569
|
+
<strong>Format:</strong> {card.brief.format}
|
|
570
|
+
</div>
|
|
571
|
+
)}
|
|
572
|
+
{card.brief.content_outline != null && card.brief.content_outline.length > 0 && (
|
|
573
|
+
<div style={{ marginTop: 8 }}>
|
|
574
|
+
<strong>Content outline:</strong>
|
|
575
|
+
<ol style={{ margin: '4px 0 0', paddingLeft: 20 }}>
|
|
576
|
+
{card.brief.content_outline.map((item) => (
|
|
577
|
+
<li key={item.question}>
|
|
578
|
+
{item.question}
|
|
579
|
+
{item.demand != null && (
|
|
580
|
+
<div className="mono" style={{ fontSize: 12, color: 'var(--ink-muted)' }}>
|
|
581
|
+
{item.demand.query} · pos {item.demand.position.toFixed(1)} ·{' '}
|
|
582
|
+
{item.demand.impressions.toLocaleString('en-US')} impr · trend{' '}
|
|
583
|
+
{formatSignedEvidenceNumber(item.demand.trend)}
|
|
584
|
+
</div>
|
|
585
|
+
)}
|
|
586
|
+
</li>
|
|
587
|
+
))}
|
|
588
|
+
</ol>
|
|
589
|
+
</div>
|
|
590
|
+
)}
|
|
370
591
|
{card.brief.sources.length > 0 && (
|
|
371
592
|
<div style={{ marginTop: 8 }}>
|
|
372
593
|
<strong>Sources:</strong>
|