@actuate-media/cms-admin 0.57.2 → 0.58.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/CHANGELOG.md +12 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/seo/SeoIssueFixDrawer.d.ts.map +1 -1
- package/dist/components/seo/SeoIssueFixDrawer.js +2 -0
- package/dist/components/seo/SeoIssueFixDrawer.js.map +1 -1
- package/dist/components/seo/SeoIssueFixPanel.d.ts +14 -0
- package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -0
- package/dist/components/seo/SeoIssueFixPanel.js +208 -0
- package/dist/components/seo/SeoIssueFixPanel.js.map +1 -0
- package/dist/lib/seo-service.d.ts +131 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +127 -0
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/seo/AuditTab.d.ts.map +1 -1
- package/dist/views/seo/AuditTab.js +87 -12
- package/dist/views/seo/AuditTab.js.map +1 -1
- package/package.json +2 -2
- package/src/components/seo/SeoIssueFixDrawer.tsx +2 -0
- package/src/components/seo/SeoIssueFixPanel.tsx +589 -0
- package/src/lib/seo-service.ts +263 -0
- package/src/views/seo/AuditTab.tsx +193 -70
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { Check, ChevronDown, Loader2, Sparkles, Wand2 } from 'lucide-react'
|
|
5
|
+
import { toast } from 'sonner'
|
|
6
|
+
import {
|
|
7
|
+
applySeoIssueFix,
|
|
8
|
+
fetchSeoIssueFixSuggestion,
|
|
9
|
+
fetchSeoContentBrief,
|
|
10
|
+
fetchSeoContentResearch,
|
|
11
|
+
generateSeoContentBrief,
|
|
12
|
+
ignoreSeoIssue,
|
|
13
|
+
isInlineSeoFixIssue,
|
|
14
|
+
researchAndBriefSeoIssue,
|
|
15
|
+
supportsSeoContentBrief,
|
|
16
|
+
supportsSeoContentResearch,
|
|
17
|
+
type SeoContentBrief,
|
|
18
|
+
type SeoContentResearch,
|
|
19
|
+
type SeoFixFieldChange,
|
|
20
|
+
type SeoIssue,
|
|
21
|
+
type SeoIssueFixSuggestion,
|
|
22
|
+
} from '../../lib/seo-service.js'
|
|
23
|
+
import { SeoErrorState, btnPrimary, btnSecondary } from './primitives.js'
|
|
24
|
+
|
|
25
|
+
function formatValue(value: string | boolean | null): string {
|
|
26
|
+
if (value === null || value === '') return '—'
|
|
27
|
+
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
|
|
28
|
+
if (value.length > 280) return `${value.slice(0, 277)}…`
|
|
29
|
+
return value
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function ChangePreview({ changes }: { changes: SeoFixFieldChange[] }) {
|
|
33
|
+
if (changes.length === 0) return null
|
|
34
|
+
return (
|
|
35
|
+
<div className="border-border overflow-hidden rounded-md border">
|
|
36
|
+
<table className="w-full text-sm" aria-label="Proposed SEO changes">
|
|
37
|
+
<thead>
|
|
38
|
+
<tr className="border-border bg-muted/40 text-muted-foreground border-b text-left">
|
|
39
|
+
<th scope="col" className="py-2 pr-3 pl-3 font-medium">
|
|
40
|
+
Field
|
|
41
|
+
</th>
|
|
42
|
+
<th scope="col" className="py-2 pr-3 font-medium">
|
|
43
|
+
Current
|
|
44
|
+
</th>
|
|
45
|
+
<th scope="col" className="py-2 pr-3 font-medium">
|
|
46
|
+
Proposed
|
|
47
|
+
</th>
|
|
48
|
+
</tr>
|
|
49
|
+
</thead>
|
|
50
|
+
<tbody className="divide-border divide-y">
|
|
51
|
+
{changes.map((c) => (
|
|
52
|
+
<tr key={c.field}>
|
|
53
|
+
<td className="text-foreground py-2 pr-3 pl-3 font-medium">{c.label}</td>
|
|
54
|
+
<td className="text-muted-foreground max-w-48 py-2 pr-3 wrap-break-word">
|
|
55
|
+
{formatValue(c.before)}
|
|
56
|
+
</td>
|
|
57
|
+
<td className="text-foreground max-w-48 py-2 pr-3 wrap-break-word">
|
|
58
|
+
{formatValue(c.after)}
|
|
59
|
+
</td>
|
|
60
|
+
</tr>
|
|
61
|
+
))}
|
|
62
|
+
</tbody>
|
|
63
|
+
</table>
|
|
64
|
+
</div>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function ContentResearchCard({ research }: { research: SeoContentResearch }) {
|
|
69
|
+
return (
|
|
70
|
+
<div className="border-border bg-card space-y-3 rounded-md border p-4">
|
|
71
|
+
<div className="flex items-center gap-2">
|
|
72
|
+
<span className="bg-accent text-foreground inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium">
|
|
73
|
+
<Sparkles className="h-3 w-3" aria-hidden />
|
|
74
|
+
Topic research
|
|
75
|
+
</span>
|
|
76
|
+
</div>
|
|
77
|
+
<p className="text-foreground text-sm">{research.summary}</p>
|
|
78
|
+
<ul className="text-foreground space-y-1.5 text-sm">
|
|
79
|
+
{research.focusKeyword ? (
|
|
80
|
+
<li>
|
|
81
|
+
<span className="text-muted-foreground">Focus keyword: </span>
|
|
82
|
+
{research.focusKeyword}
|
|
83
|
+
</li>
|
|
84
|
+
) : null}
|
|
85
|
+
{research.suggestedWordCount ? (
|
|
86
|
+
<li>
|
|
87
|
+
<span className="text-muted-foreground">Suggested length: </span>~
|
|
88
|
+
{research.suggestedWordCount} words
|
|
89
|
+
</li>
|
|
90
|
+
) : null}
|
|
91
|
+
</ul>
|
|
92
|
+
{research.outline.length > 0 ? (
|
|
93
|
+
<div>
|
|
94
|
+
<p className="text-muted-foreground mb-1.5 text-xs font-medium tracking-wide uppercase">
|
|
95
|
+
Suggested outline
|
|
96
|
+
</p>
|
|
97
|
+
<ul className="text-foreground list-disc space-y-1 pl-5 text-sm">
|
|
98
|
+
{research.outline.map((item) => (
|
|
99
|
+
<li key={item}>{item}</li>
|
|
100
|
+
))}
|
|
101
|
+
</ul>
|
|
102
|
+
</div>
|
|
103
|
+
) : null}
|
|
104
|
+
{research.faqCandidates.length > 0 ? (
|
|
105
|
+
<div>
|
|
106
|
+
<p className="text-muted-foreground mb-1.5 text-xs font-medium tracking-wide uppercase">
|
|
107
|
+
FAQ candidates
|
|
108
|
+
</p>
|
|
109
|
+
<ul className="text-foreground list-disc space-y-1 pl-5 text-sm">
|
|
110
|
+
{research.faqCandidates.map((q) => (
|
|
111
|
+
<li key={q}>{q}</li>
|
|
112
|
+
))}
|
|
113
|
+
</ul>
|
|
114
|
+
</div>
|
|
115
|
+
) : null}
|
|
116
|
+
</div>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function ContentBriefCard({ brief }: { brief: SeoContentBrief }) {
|
|
121
|
+
return (
|
|
122
|
+
<div className="border-border bg-card space-y-3 rounded-md border p-4">
|
|
123
|
+
<div className="flex items-center gap-2">
|
|
124
|
+
<span className="bg-primary/10 text-primary inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium">
|
|
125
|
+
<Sparkles className="h-3 w-3" aria-hidden />
|
|
126
|
+
AI content brief
|
|
127
|
+
</span>
|
|
128
|
+
</div>
|
|
129
|
+
<p className="text-foreground text-sm">{brief.summary}</p>
|
|
130
|
+
<ul className="text-foreground space-y-1.5 text-sm">
|
|
131
|
+
{brief.suggestedH1 ? (
|
|
132
|
+
<li>
|
|
133
|
+
<span className="text-muted-foreground">Suggested H1: </span>
|
|
134
|
+
{brief.suggestedH1}
|
|
135
|
+
</li>
|
|
136
|
+
) : null}
|
|
137
|
+
{brief.targetWordCount ? (
|
|
138
|
+
<li>
|
|
139
|
+
<span className="text-muted-foreground">Target length: </span>~{brief.targetWordCount}{' '}
|
|
140
|
+
words
|
|
141
|
+
</li>
|
|
142
|
+
) : null}
|
|
143
|
+
{brief.keyTakeawayDraft ? (
|
|
144
|
+
<li>
|
|
145
|
+
<span className="text-muted-foreground">GEO takeaway: </span>
|
|
146
|
+
{brief.keyTakeawayDraft}
|
|
147
|
+
</li>
|
|
148
|
+
) : null}
|
|
149
|
+
{brief.geoNote ? (
|
|
150
|
+
<li>
|
|
151
|
+
<span className="text-muted-foreground">GEO note: </span>
|
|
152
|
+
{brief.geoNote}
|
|
153
|
+
</li>
|
|
154
|
+
) : null}
|
|
155
|
+
</ul>
|
|
156
|
+
{brief.topics.length > 0 ? (
|
|
157
|
+
<div>
|
|
158
|
+
<p className="text-muted-foreground mb-1.5 text-xs font-medium tracking-wide uppercase">
|
|
159
|
+
Topics to cover
|
|
160
|
+
</p>
|
|
161
|
+
<ul className="text-foreground list-disc space-y-1 pl-5 text-sm">
|
|
162
|
+
{brief.topics.map((topic) => (
|
|
163
|
+
<li key={topic}>{topic}</li>
|
|
164
|
+
))}
|
|
165
|
+
</ul>
|
|
166
|
+
</div>
|
|
167
|
+
) : null}
|
|
168
|
+
{brief.sections.map((section) => (
|
|
169
|
+
<div key={section.heading}>
|
|
170
|
+
<p className="text-foreground text-sm font-medium">{section.heading}</p>
|
|
171
|
+
{section.bullets.length > 0 ? (
|
|
172
|
+
<ul className="text-muted-foreground mt-1 list-disc space-y-1 pl-5 text-sm">
|
|
173
|
+
{section.bullets.map((bullet) => (
|
|
174
|
+
<li key={bullet}>{bullet}</li>
|
|
175
|
+
))}
|
|
176
|
+
</ul>
|
|
177
|
+
) : null}
|
|
178
|
+
</div>
|
|
179
|
+
))}
|
|
180
|
+
</div>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function manualPath(issue: SeoIssue): string | null {
|
|
185
|
+
const ent = issue.entityType
|
|
186
|
+
const id = issue.entityId
|
|
187
|
+
switch (issue.fixActionType) {
|
|
188
|
+
case 'edit-metadata':
|
|
189
|
+
case 'edit-canonical':
|
|
190
|
+
case 'edit-og':
|
|
191
|
+
case 'edit-og-image':
|
|
192
|
+
case 'edit-schema':
|
|
193
|
+
if (ent && id) {
|
|
194
|
+
return `/seo?tab=content&entityType=${ent}&entityId=${encodeURIComponent(id)}`
|
|
195
|
+
}
|
|
196
|
+
return null
|
|
197
|
+
case 'edit-content':
|
|
198
|
+
if (ent === 'page' && id) return `/pages/${id}`
|
|
199
|
+
if (ent && id) {
|
|
200
|
+
return `/seo?tab=content&entityType=${ent}&entityId=${encodeURIComponent(id)}`
|
|
201
|
+
}
|
|
202
|
+
return null
|
|
203
|
+
case 'fix-broken-link':
|
|
204
|
+
case 'fix-link':
|
|
205
|
+
case 'create-redirect':
|
|
206
|
+
return '/seo?tab=redirects'
|
|
207
|
+
case 'optimize-media':
|
|
208
|
+
return '/media'
|
|
209
|
+
case 'improve-speed':
|
|
210
|
+
return '/seo?tab=technical'
|
|
211
|
+
default:
|
|
212
|
+
if (ent === 'page' && id) return `/pages/${id}`
|
|
213
|
+
return null
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function SeoIssueFixPanel({
|
|
218
|
+
issue,
|
|
219
|
+
expanded,
|
|
220
|
+
onChanged,
|
|
221
|
+
onNavigate,
|
|
222
|
+
onOpenDetails,
|
|
223
|
+
}: {
|
|
224
|
+
issue: SeoIssue
|
|
225
|
+
expanded: boolean
|
|
226
|
+
onChanged?: () => void
|
|
227
|
+
onNavigate?: (path: string) => void
|
|
228
|
+
onOpenDetails?: () => void
|
|
229
|
+
}) {
|
|
230
|
+
const inlineFix = isInlineSeoFixIssue(issue.type)
|
|
231
|
+
const contentBriefEligible = supportsSeoContentBrief(issue.type)
|
|
232
|
+
const researchEligible = supportsSeoContentResearch(issue.type)
|
|
233
|
+
|
|
234
|
+
const [suggestion, setSuggestion] = useState<SeoIssueFixSuggestion | null>(null)
|
|
235
|
+
const [contentBrief, setContentBrief] = useState<SeoContentBrief | null>(null)
|
|
236
|
+
const [contentResearch, setContentResearch] = useState<SeoContentResearch | null>(null)
|
|
237
|
+
const [competitorUrls, setCompetitorUrls] = useState(['', '', ''])
|
|
238
|
+
const [briefStale, setBriefStale] = useState(false)
|
|
239
|
+
const [researchStale, setResearchStale] = useState(false)
|
|
240
|
+
const [loading, setLoading] = useState(false)
|
|
241
|
+
const [error, setError] = useState<string | null>(null)
|
|
242
|
+
const [busy, setBusy] = useState(false)
|
|
243
|
+
const [generatingBrief, setGeneratingBrief] = useState(false)
|
|
244
|
+
const [researching, setResearching] = useState(false)
|
|
245
|
+
|
|
246
|
+
useEffect(() => {
|
|
247
|
+
if (!expanded) {
|
|
248
|
+
setSuggestion(null)
|
|
249
|
+
setContentBrief(null)
|
|
250
|
+
setContentResearch(null)
|
|
251
|
+
setCompetitorUrls(['', '', ''])
|
|
252
|
+
setBriefStale(false)
|
|
253
|
+
setResearchStale(false)
|
|
254
|
+
setError(null)
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let alive = true
|
|
259
|
+
setLoading(true)
|
|
260
|
+
setError(null)
|
|
261
|
+
|
|
262
|
+
async function load() {
|
|
263
|
+
if (inlineFix) {
|
|
264
|
+
const res = await fetchSeoIssueFixSuggestion(issue.id)
|
|
265
|
+
if (!alive) return
|
|
266
|
+
if (res.error) setError(res.error)
|
|
267
|
+
else setSuggestion(res.suggestion ?? null)
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (contentBriefEligible) {
|
|
272
|
+
const briefRes = await fetchSeoContentBrief(issue.id)
|
|
273
|
+
if (!alive) return
|
|
274
|
+
if (briefRes.error) setError(briefRes.error)
|
|
275
|
+
else {
|
|
276
|
+
setContentBrief(briefRes.brief ?? null)
|
|
277
|
+
setBriefStale(briefRes.stale ?? false)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (researchEligible) {
|
|
281
|
+
const researchRes = await fetchSeoContentResearch(issue.id)
|
|
282
|
+
if (!alive) return
|
|
283
|
+
if (researchRes.error && !briefRes.error) setError(researchRes.error)
|
|
284
|
+
else if (!researchRes.error) {
|
|
285
|
+
setContentResearch(researchRes.research ?? null)
|
|
286
|
+
setResearchStale(researchRes.stale ?? false)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
load()
|
|
293
|
+
.catch((e) => alive && setError(e instanceof Error ? e.message : 'Failed to load'))
|
|
294
|
+
.finally(() => alive && setLoading(false))
|
|
295
|
+
|
|
296
|
+
return () => {
|
|
297
|
+
alive = false
|
|
298
|
+
}
|
|
299
|
+
}, [expanded, issue.id, inlineFix, contentBriefEligible, researchEligible])
|
|
300
|
+
|
|
301
|
+
function parsedCompetitorUrls(): string[] {
|
|
302
|
+
return competitorUrls
|
|
303
|
+
.map((u) => u.trim())
|
|
304
|
+
.filter(Boolean)
|
|
305
|
+
.slice(0, 3)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function handleApprove() {
|
|
309
|
+
if (!suggestion?.autoFixable) return
|
|
310
|
+
setBusy(true)
|
|
311
|
+
const res = await applySeoIssueFix(issue.id, suggestion.fingerprint)
|
|
312
|
+
setBusy(false)
|
|
313
|
+
if (res.error) {
|
|
314
|
+
toast.error(res.error)
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
toast.success('Fix applied and issue resolved.')
|
|
318
|
+
onChanged?.()
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function handleResearchAndBrief() {
|
|
322
|
+
setResearching(true)
|
|
323
|
+
const res = await researchAndBriefSeoIssue(issue.id, parsedCompetitorUrls())
|
|
324
|
+
setResearching(false)
|
|
325
|
+
if (res.error) {
|
|
326
|
+
toast.error(res.error)
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
if (res.research) {
|
|
330
|
+
setContentResearch(res.research)
|
|
331
|
+
setResearchStale(false)
|
|
332
|
+
}
|
|
333
|
+
if (res.brief) {
|
|
334
|
+
setContentBrief(res.brief)
|
|
335
|
+
setBriefStale(false)
|
|
336
|
+
}
|
|
337
|
+
toast.success('Research and content brief generated — review before editing the page.')
|
|
338
|
+
}
|
|
339
|
+
async function handleGenerateBrief() {
|
|
340
|
+
setGeneratingBrief(true)
|
|
341
|
+
const res = await generateSeoContentBrief(issue.id)
|
|
342
|
+
setGeneratingBrief(false)
|
|
343
|
+
if (res.error) {
|
|
344
|
+
toast.error(res.error)
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
if (res.brief) {
|
|
348
|
+
setContentBrief(res.brief)
|
|
349
|
+
setBriefStale(false)
|
|
350
|
+
toast.success('Content brief generated — review before editing the page.')
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function handleIgnore() {
|
|
355
|
+
setBusy(true)
|
|
356
|
+
const res = await ignoreSeoIssue(issue.id)
|
|
357
|
+
setBusy(false)
|
|
358
|
+
if (res.error) return toast.error(res.error)
|
|
359
|
+
toast.success('Issue ignored.')
|
|
360
|
+
onChanged?.()
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const manualRoute = manualPath(issue)
|
|
364
|
+
|
|
365
|
+
if (!expanded) return null
|
|
366
|
+
|
|
367
|
+
return (
|
|
368
|
+
<tr>
|
|
369
|
+
<td colSpan={5} className="bg-muted/30 px-4 py-4">
|
|
370
|
+
<div className="space-y-4">
|
|
371
|
+
{loading ? (
|
|
372
|
+
<div
|
|
373
|
+
className="text-muted-foreground flex items-center gap-2 text-sm"
|
|
374
|
+
role="status"
|
|
375
|
+
aria-live="polite"
|
|
376
|
+
>
|
|
377
|
+
<Loader2 className="h-4 w-4 motion-safe:animate-spin" aria-hidden />
|
|
378
|
+
{inlineFix ? 'Loading suggested fix…' : 'Loading issue details…'}
|
|
379
|
+
</div>
|
|
380
|
+
) : error ? (
|
|
381
|
+
<SeoErrorState message={error} />
|
|
382
|
+
) : (
|
|
383
|
+
<>
|
|
384
|
+
{!inlineFix && (issue.description || issue.recommendation) ? (
|
|
385
|
+
<div className="space-y-2">
|
|
386
|
+
{issue.description ? (
|
|
387
|
+
<p className="text-foreground text-sm">{issue.description}</p>
|
|
388
|
+
) : null}
|
|
389
|
+
{issue.recommendation ? (
|
|
390
|
+
<p className="text-muted-foreground text-sm">
|
|
391
|
+
<span className="text-foreground font-medium">Recommended fix: </span>
|
|
392
|
+
{issue.recommendation}
|
|
393
|
+
</p>
|
|
394
|
+
) : null}
|
|
395
|
+
</div>
|
|
396
|
+
) : null}
|
|
397
|
+
|
|
398
|
+
{inlineFix && suggestion ? (
|
|
399
|
+
<>
|
|
400
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
401
|
+
{suggestion.source === 'ai' && !suggestion.aiUnavailable ? (
|
|
402
|
+
<span className="bg-primary/10 text-primary inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium">
|
|
403
|
+
<Sparkles className="h-3 w-3" aria-hidden />
|
|
404
|
+
AI suggestion
|
|
405
|
+
</span>
|
|
406
|
+
) : suggestion.source === 'deterministic' ? (
|
|
407
|
+
<span className="bg-muted text-muted-foreground rounded-full px-2.5 py-0.5 text-xs font-medium">
|
|
408
|
+
Rule-based fix
|
|
409
|
+
</span>
|
|
410
|
+
) : null}
|
|
411
|
+
</div>
|
|
412
|
+
<div className="flex items-start gap-2">
|
|
413
|
+
<Sparkles className="text-primary mt-0.5 h-4 w-4 shrink-0" aria-hidden />
|
|
414
|
+
<p className="text-foreground text-sm">{suggestion.justification}</p>
|
|
415
|
+
</div>
|
|
416
|
+
<ChangePreview changes={suggestion.changes} />
|
|
417
|
+
</>
|
|
418
|
+
) : null}
|
|
419
|
+
|
|
420
|
+
{contentBriefEligible ? (
|
|
421
|
+
<>
|
|
422
|
+
{researchEligible ? (
|
|
423
|
+
<div className="space-y-2">
|
|
424
|
+
<p className="text-muted-foreground text-sm">
|
|
425
|
+
Optional competitor URLs (SSRF-safe fetch, max 3) to enrich topic research
|
|
426
|
+
for thin content.
|
|
427
|
+
</p>
|
|
428
|
+
{competitorUrls.map((value, index) => (
|
|
429
|
+
<label key={index} className="block">
|
|
430
|
+
<span className="text-muted-foreground sr-only">
|
|
431
|
+
Competitor URL {index + 1}
|
|
432
|
+
</span>
|
|
433
|
+
<input
|
|
434
|
+
type="url"
|
|
435
|
+
value={value}
|
|
436
|
+
onChange={(e) => {
|
|
437
|
+
const next = [...competitorUrls]
|
|
438
|
+
next[index] = e.target.value
|
|
439
|
+
setCompetitorUrls(next)
|
|
440
|
+
}}
|
|
441
|
+
placeholder={`Competitor URL ${index + 1} (optional)`}
|
|
442
|
+
className="border-border bg-input-background text-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none"
|
|
443
|
+
/>
|
|
444
|
+
</label>
|
|
445
|
+
))}
|
|
446
|
+
</div>
|
|
447
|
+
) : null}
|
|
448
|
+
{researchStale && !contentResearch ? (
|
|
449
|
+
<p className="text-muted-foreground text-sm" role="status">
|
|
450
|
+
Previous topic research is outdated because the page changed.
|
|
451
|
+
</p>
|
|
452
|
+
) : null}
|
|
453
|
+
{contentResearch ? <ContentResearchCard research={contentResearch} /> : null}
|
|
454
|
+
{briefStale && !contentBrief ? (
|
|
455
|
+
<p className="text-muted-foreground text-sm" role="status">
|
|
456
|
+
A previous content brief is outdated because the page changed. Generate a new
|
|
457
|
+
one.
|
|
458
|
+
</p>
|
|
459
|
+
) : null}
|
|
460
|
+
{contentBrief ? <ContentBriefCard brief={contentBrief} /> : null}
|
|
461
|
+
{!contentBrief ? (
|
|
462
|
+
<p className="text-muted-foreground text-sm">
|
|
463
|
+
Generate an AI content brief with suggested headings, topics, and GEO guidance
|
|
464
|
+
for your dev team.
|
|
465
|
+
</p>
|
|
466
|
+
) : null}
|
|
467
|
+
</>
|
|
468
|
+
) : null}
|
|
469
|
+
|
|
470
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
471
|
+
{inlineFix && suggestion?.autoFixable ? (
|
|
472
|
+
<button
|
|
473
|
+
type="button"
|
|
474
|
+
className={btnPrimary}
|
|
475
|
+
onClick={handleApprove}
|
|
476
|
+
disabled={busy}
|
|
477
|
+
>
|
|
478
|
+
<Check className="h-4 w-4" aria-hidden />
|
|
479
|
+
{busy ? 'Applying…' : 'Approve fix'}
|
|
480
|
+
</button>
|
|
481
|
+
) : inlineFix && suggestion?.aiUnavailable ? (
|
|
482
|
+
<p className="text-muted-foreground text-sm">
|
|
483
|
+
Configure an AI provider in Settings → AI to enable generated fixes for this
|
|
484
|
+
issue.
|
|
485
|
+
</p>
|
|
486
|
+
) : null}
|
|
487
|
+
|
|
488
|
+
{contentBriefEligible ? (
|
|
489
|
+
<>
|
|
490
|
+
{researchEligible ? (
|
|
491
|
+
<button
|
|
492
|
+
type="button"
|
|
493
|
+
className={!contentBrief && !contentResearch ? btnPrimary : btnSecondary}
|
|
494
|
+
onClick={handleResearchAndBrief}
|
|
495
|
+
disabled={busy || researching || generatingBrief}
|
|
496
|
+
>
|
|
497
|
+
<Sparkles
|
|
498
|
+
className={`h-4 w-4 ${researching ? 'motion-safe:animate-pulse' : ''}`}
|
|
499
|
+
aria-hidden
|
|
500
|
+
/>
|
|
501
|
+
{researching ? 'Researching…' : 'Research & brief'}
|
|
502
|
+
</button>
|
|
503
|
+
) : null}
|
|
504
|
+
<button
|
|
505
|
+
type="button"
|
|
506
|
+
className={contentBrief ? btnSecondary : btnPrimary}
|
|
507
|
+
onClick={handleGenerateBrief}
|
|
508
|
+
disabled={busy || generatingBrief || researching}
|
|
509
|
+
>
|
|
510
|
+
<Wand2
|
|
511
|
+
className={`h-4 w-4 ${generatingBrief ? 'motion-safe:animate-pulse' : ''}`}
|
|
512
|
+
aria-hidden
|
|
513
|
+
/>
|
|
514
|
+
{generatingBrief
|
|
515
|
+
? 'Generating brief…'
|
|
516
|
+
: contentBrief
|
|
517
|
+
? 'Regenerate brief'
|
|
518
|
+
: 'Generate content brief'}
|
|
519
|
+
</button>
|
|
520
|
+
</>
|
|
521
|
+
) : null}
|
|
522
|
+
|
|
523
|
+
{!inlineFix && !contentBriefEligible ? (
|
|
524
|
+
<p className="text-muted-foreground text-sm">
|
|
525
|
+
This issue requires manual follow-up in the admin or codebase.
|
|
526
|
+
</p>
|
|
527
|
+
) : null}
|
|
528
|
+
|
|
529
|
+
{manualRoute && onNavigate ? (
|
|
530
|
+
<button
|
|
531
|
+
type="button"
|
|
532
|
+
className={btnSecondary}
|
|
533
|
+
onClick={() => onNavigate(manualRoute)}
|
|
534
|
+
disabled={busy}
|
|
535
|
+
>
|
|
536
|
+
Edit manually
|
|
537
|
+
</button>
|
|
538
|
+
) : null}
|
|
539
|
+
<button
|
|
540
|
+
type="button"
|
|
541
|
+
className={btnSecondary}
|
|
542
|
+
onClick={handleIgnore}
|
|
543
|
+
disabled={busy}
|
|
544
|
+
>
|
|
545
|
+
Ignore
|
|
546
|
+
</button>
|
|
547
|
+
{onOpenDetails ? (
|
|
548
|
+
<button
|
|
549
|
+
type="button"
|
|
550
|
+
className="text-primary hover:bg-accent focus-visible:ring-ring rounded-md px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none"
|
|
551
|
+
onClick={onOpenDetails}
|
|
552
|
+
disabled={busy}
|
|
553
|
+
>
|
|
554
|
+
More details
|
|
555
|
+
</button>
|
|
556
|
+
) : null}
|
|
557
|
+
</div>
|
|
558
|
+
</>
|
|
559
|
+
)}
|
|
560
|
+
</div>
|
|
561
|
+
</td>
|
|
562
|
+
</tr>
|
|
563
|
+
)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export function SeoIssueFixToggle({
|
|
567
|
+
expanded,
|
|
568
|
+
fixable,
|
|
569
|
+
onToggle,
|
|
570
|
+
}: {
|
|
571
|
+
expanded: boolean
|
|
572
|
+
fixable: boolean
|
|
573
|
+
onToggle: () => void
|
|
574
|
+
}) {
|
|
575
|
+
return (
|
|
576
|
+
<button
|
|
577
|
+
type="button"
|
|
578
|
+
onClick={onToggle}
|
|
579
|
+
aria-expanded={expanded}
|
|
580
|
+
className="text-primary hover:bg-accent focus-visible:ring-ring inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm focus-visible:ring-2 focus-visible:outline-none"
|
|
581
|
+
>
|
|
582
|
+
{fixable ? 'Fix' : 'View'}
|
|
583
|
+
<ChevronDown
|
|
584
|
+
className={`h-3.5 w-3.5 motion-safe:transition-transform ${expanded ? 'rotate-180' : ''}`}
|
|
585
|
+
aria-hidden
|
|
586
|
+
/>
|
|
587
|
+
</button>
|
|
588
|
+
)
|
|
589
|
+
}
|