@actuate-media/cms-admin 0.57.3 → 0.58.1

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.
@@ -356,6 +356,269 @@ export async function reopenSeoIssue(issueId: string): Promise<{ error?: string
356
356
  return res.error ? { error: res.error } : {}
357
357
  }
358
358
 
359
+ // ─── Inline audit fixes (Phase 1 — deterministic, approval-gated) ───
360
+
361
+ export interface SeoFixFieldChange {
362
+ field: string
363
+ label: string
364
+ before: string | boolean | null
365
+ after: string | boolean | null
366
+ }
367
+
368
+ export interface SeoIssueFixSuggestion {
369
+ autoFixable: boolean
370
+ source?: 'deterministic' | 'ai'
371
+ aiUnavailable?: boolean
372
+ fingerprint: string
373
+ patch: Record<string, unknown>
374
+ changes: SeoFixFieldChange[]
375
+ justification: string
376
+ }
377
+
378
+ export async function fetchSeoIssueFixSuggestion(issueId: string): Promise<{
379
+ suggestion?: SeoIssueFixSuggestion
380
+ error?: string
381
+ }> {
382
+ const res = await cmsApi<{ suggestion: SeoIssueFixSuggestion }>(
383
+ `/seo/issues/${encodeURIComponent(issueId)}/suggest-fix`,
384
+ { method: 'POST' },
385
+ )
386
+ if (res.error) return { error: res.error }
387
+ return { suggestion: res.data?.suggestion }
388
+ }
389
+
390
+ export async function applySeoIssueFix(
391
+ issueId: string,
392
+ fingerprint: string,
393
+ ): Promise<{ issue?: SeoIssue; error?: string }> {
394
+ const res = await cmsApi<{ issue: SeoIssue }>(
395
+ `/seo/issues/${encodeURIComponent(issueId)}/apply-fix`,
396
+ {
397
+ method: 'POST',
398
+ body: JSON.stringify({ fingerprint }),
399
+ },
400
+ )
401
+ if (res.error) return { error: res.error }
402
+ return { issue: res.data?.issue }
403
+ }
404
+
405
+ export interface SeoFixBriefExport {
406
+ markdown: string
407
+ filename: string
408
+ issueCount: number
409
+ }
410
+
411
+ export interface SeoFixBriefExportQuery {
412
+ severity?: SeoSeverity | 'all'
413
+ status?: SeoIssueStatus | 'all'
414
+ auditRunId?: string
415
+ /** Default true — only issues needing dev/content handoff. */
416
+ manualOnly?: boolean
417
+ /** Default true — embed cached AI content briefs when available. */
418
+ includeContentBriefs?: boolean
419
+ /** Default true — embed cached topic research when available. */
420
+ includeContentResearch?: boolean
421
+ }
422
+
423
+ export async function exportSeoFixBrief(
424
+ query: SeoFixBriefExportQuery = {},
425
+ ): Promise<{ data?: SeoFixBriefExport; error?: string }> {
426
+ const params = new URLSearchParams()
427
+ if (query.severity && query.severity !== 'all') params.set('severity', query.severity)
428
+ if (query.status && query.status !== 'all') params.set('status', query.status)
429
+ if (query.auditRunId) params.set('auditRunId', query.auditRunId)
430
+ if (query.manualOnly === false) params.set('manualOnly', 'false')
431
+ if (query.includeContentBriefs === false) params.set('includeContentBriefs', 'false')
432
+ if (query.includeContentResearch === false) params.set('includeContentResearch', 'false')
433
+ const qs = params.toString()
434
+ const res = await cmsApi<SeoFixBriefExport>(`/seo/issues/export-brief${qs ? `?${qs}` : ''}`)
435
+ if (res.error) return { error: res.error }
436
+ return { data: res.data }
437
+ }
438
+
439
+ /** Issue types with inline Approve fix in the Audit tab (keep in sync with cms-core). */
440
+ export const SEO_INLINE_FIX_ISSUE_TYPES = new Set([
441
+ 'meta-title-length',
442
+ 'meta-description-length',
443
+ 'accidental-noindex',
444
+ 'missing-meta-title',
445
+ 'missing-meta-description',
446
+ 'missing-canonical',
447
+ 'duplicate-meta-title',
448
+ 'duplicate-meta-description',
449
+ 'missing-structured-data',
450
+ ])
451
+
452
+ export function isInlineSeoFixIssue(type: string): boolean {
453
+ return SEO_INLINE_FIX_ISSUE_TYPES.has(type)
454
+ }
455
+
456
+ export const SEO_CONTENT_BRIEF_ISSUE_TYPES = [
457
+ 'thin-content',
458
+ 'missing-h1',
459
+ 'multiple-h1',
460
+ 'missing-alt-text',
461
+ ] as const
462
+
463
+ export function supportsSeoContentBrief(type: string): boolean {
464
+ return (SEO_CONTENT_BRIEF_ISSUE_TYPES as readonly string[]).includes(type)
465
+ }
466
+
467
+ export interface SeoContentBriefSection {
468
+ heading: string
469
+ bullets: string[]
470
+ }
471
+
472
+ export interface SeoContentBrief {
473
+ issueType: string
474
+ entityId: string
475
+ fingerprint: string
476
+ generatedAt: string
477
+ summary: string
478
+ suggestedH1: string | null
479
+ targetWordCount: number | null
480
+ topics: string[]
481
+ sections: SeoContentBriefSection[]
482
+ keyTakeawayDraft: string | null
483
+ geoNote: string | null
484
+ }
485
+
486
+ export async function fetchSeoContentBrief(issueId: string): Promise<{
487
+ supported?: boolean
488
+ brief?: SeoContentBrief | null
489
+ stale?: boolean
490
+ error?: string
491
+ }> {
492
+ const res = await cmsApi<{ supported: boolean; brief: SeoContentBrief | null; stale: boolean }>(
493
+ `/seo/issues/${encodeURIComponent(issueId)}/content-brief`,
494
+ )
495
+ if (res.error) return { error: res.error }
496
+ return {
497
+ supported: res.data?.supported,
498
+ brief: res.data?.brief,
499
+ stale: res.data?.stale,
500
+ }
501
+ }
502
+
503
+ export async function generateSeoContentBrief(
504
+ issueId: string,
505
+ ): Promise<{ brief?: SeoContentBrief; error?: string }> {
506
+ const res = await cmsApi<{ brief: SeoContentBrief }>(
507
+ `/seo/issues/${encodeURIComponent(issueId)}/content-brief`,
508
+ { method: 'POST' },
509
+ )
510
+ if (res.error) return { error: res.error }
511
+ return { brief: res.data?.brief }
512
+ }
513
+
514
+ export const SEO_RESEARCH_ISSUE_TYPES = ['thin-content'] as const
515
+
516
+ export function supportsSeoContentResearch(type: string): boolean {
517
+ return (SEO_RESEARCH_ISSUE_TYPES as readonly string[]).includes(type)
518
+ }
519
+
520
+ export interface SeoContentResearchCompetitor {
521
+ url: string
522
+ title: string
523
+ note: string
524
+ }
525
+
526
+ export interface SeoContentResearch {
527
+ issueType: string
528
+ entityId: string
529
+ fingerprint: string
530
+ generatedAt: string
531
+ focusKeyword: string | null
532
+ suggestedWordCount: number | null
533
+ outline: string[]
534
+ faqCandidates: string[]
535
+ entitiesToMention: string[]
536
+ competitorInsights: SeoContentResearchCompetitor[]
537
+ summary: string
538
+ }
539
+
540
+ export async function fetchSeoContentResearch(issueId: string): Promise<{
541
+ supported?: boolean
542
+ research?: SeoContentResearch | null
543
+ stale?: boolean
544
+ error?: string
545
+ }> {
546
+ const res = await cmsApi<{
547
+ supported: boolean
548
+ research: SeoContentResearch | null
549
+ stale: boolean
550
+ }>(`/seo/issues/${encodeURIComponent(issueId)}/content-research`)
551
+ if (res.error) return { error: res.error }
552
+ return {
553
+ supported: res.data?.supported,
554
+ research: res.data?.research,
555
+ stale: res.data?.stale,
556
+ }
557
+ }
558
+
559
+ export async function generateSeoContentResearch(
560
+ issueId: string,
561
+ competitorUrls?: string[],
562
+ ): Promise<{ research?: SeoContentResearch; error?: string }> {
563
+ const res = await cmsApi<{ research: SeoContentResearch }>(
564
+ `/seo/issues/${encodeURIComponent(issueId)}/content-research`,
565
+ {
566
+ method: 'POST',
567
+ body: JSON.stringify({ competitorUrls: competitorUrls?.filter(Boolean) }),
568
+ },
569
+ )
570
+ if (res.error) return { error: res.error }
571
+ return { research: res.data?.research }
572
+ }
573
+
574
+ export async function researchAndBriefSeoIssue(
575
+ issueId: string,
576
+ competitorUrls?: string[],
577
+ ): Promise<{ research?: SeoContentResearch | null; brief?: SeoContentBrief; error?: string }> {
578
+ const res = await cmsApi<{ research: SeoContentResearch | null; brief: SeoContentBrief }>(
579
+ `/seo/issues/${encodeURIComponent(issueId)}/research-and-brief`,
580
+ {
581
+ method: 'POST',
582
+ body: JSON.stringify({ competitorUrls: competitorUrls?.filter(Boolean) }),
583
+ },
584
+ )
585
+ if (res.error) return { error: res.error }
586
+ return { research: res.data?.research, brief: res.data?.brief }
587
+ }
588
+
589
+ export interface BatchContentBriefResult {
590
+ generated: number
591
+ skipped: number
592
+ failed: number
593
+ results: Array<{ issueId: string; status: 'generated' | 'skipped' | 'failed'; error?: string }>
594
+ }
595
+
596
+ export interface BatchContentBriefQuery {
597
+ severity?: SeoSeverity | 'all'
598
+ status?: SeoIssueStatus | 'all'
599
+ issueIds?: string[]
600
+ skipExisting?: boolean
601
+ includeResearch?: boolean
602
+ }
603
+
604
+ export async function batchGenerateSeoContentBriefs(
605
+ query: BatchContentBriefQuery = {},
606
+ ): Promise<{ data?: BatchContentBriefResult; error?: string }> {
607
+ const body: Record<string, unknown> = {}
608
+ if (query.severity && query.severity !== 'all') body.severity = query.severity
609
+ if (query.status && query.status !== 'all') body.status = query.status
610
+ if (query.issueIds?.length) body.issueIds = query.issueIds
611
+ if (query.skipExisting === false) body.skipExisting = false
612
+ if (query.includeResearch) body.includeResearch = true
613
+
614
+ const res = await cmsApi<BatchContentBriefResult>('/seo/issues/batch-content-briefs', {
615
+ method: 'POST',
616
+ body: JSON.stringify(body),
617
+ })
618
+ if (res.error) return { error: res.error }
619
+ return { data: res.data }
620
+ }
621
+
359
622
  // ─── Technical (sitemap / robots / crawl) ────────────────────────────
360
623
 
361
624
  export interface SitemapStatus {
@@ -1,10 +1,15 @@
1
1
  'use client'
2
2
 
3
- import { useMemo, useState } from 'react'
4
- import { ClipboardCheck, Clock, CheckCircle2 } from 'lucide-react'
3
+ import { Fragment, useMemo, useState } from 'react'
4
+ import { ClipboardCheck, Clock, CheckCircle2, FileDown, Sparkles } from 'lucide-react'
5
+ import { toast } from 'sonner'
5
6
  import {
6
7
  fetchSeoIssues,
7
8
  fetchSeoAuditRuns,
9
+ batchGenerateSeoContentBriefs,
10
+ exportSeoFixBrief,
11
+ isInlineSeoFixIssue,
12
+ supportsSeoContentBrief,
8
13
  type SeoIssue,
9
14
  type SeoIssueQuery,
10
15
  type SeoSeverity,
@@ -17,8 +22,10 @@ import {
17
22
  SeoErrorState,
18
23
  severityMeta,
19
24
  SeoStatusBadge,
25
+ btnSecondary,
20
26
  } from '../../components/seo/primitives.js'
21
27
  import { SeoIssueFixDrawer } from '../../components/seo/SeoIssueFixDrawer.js'
28
+ import { SeoIssueFixPanel, SeoIssueFixToggle } from '../../components/seo/SeoIssueFixPanel.js'
22
29
  import { useSeoResource } from './useSeoResource.js'
23
30
 
24
31
  const SEVERITY_FILTERS: { id: SeoSeverity | 'all'; label: string }[] = [
@@ -95,7 +102,10 @@ export function AuditTab({
95
102
  }) {
96
103
  const [severity, setSeverity] = useState<SeoSeverity | 'all'>('all')
97
104
  const [status, setStatus] = useState<SeoIssueStatus | 'all'>('open')
98
- const [openIssue, setOpenIssue] = useState<string | null>(initialIssueId ?? null)
105
+ const [expandedIssueId, setExpandedIssueId] = useState<string | null>(initialIssueId ?? null)
106
+ const [detailIssueId, setDetailIssueId] = useState<string | null>(null)
107
+ const [exportingBrief, setExportingBrief] = useState(false)
108
+ const [batchGenerating, setBatchGenerating] = useState(false)
99
109
 
100
110
  const query: SeoIssueQuery = useMemo(() => ({ severity, status }), [severity, status])
101
111
  const { data, loading, error, refetch } = useSeoResource(
@@ -105,45 +115,142 @@ export function AuditTab({
105
115
 
106
116
  const issues = data ?? []
107
117
 
118
+ const briefEligibleCount = useMemo(
119
+ () => issues.filter((i) => supportsSeoContentBrief(i.type)).length,
120
+ [issues],
121
+ )
122
+
123
+ function toggleExpanded(issueId: string) {
124
+ setExpandedIssueId((current) => (current === issueId ? null : issueId))
125
+ }
126
+
127
+ function handleChanged() {
128
+ setExpandedIssueId(null)
129
+ refetch()
130
+ }
131
+
132
+ async function handleBatchGenerateBriefs() {
133
+ const issueIds = issues.filter((i) => supportsSeoContentBrief(i.type)).map((i) => i.id)
134
+ if (issueIds.length === 0) {
135
+ toast.message('No content-brief issues in the current list.')
136
+ return
137
+ }
138
+ setBatchGenerating(true)
139
+ try {
140
+ const res = await batchGenerateSeoContentBriefs({
141
+ severity,
142
+ status,
143
+ issueIds,
144
+ skipExisting: true,
145
+ })
146
+ if (res.error || !res.data) {
147
+ toast.error(res.error ?? 'Batch generation failed.')
148
+ return
149
+ }
150
+ const { generated, skipped, failed } = res.data
151
+ if (generated === 0 && failed === 0) {
152
+ toast.message(`All ${skipped} brief(s) already cached for the current filters.`)
153
+ } else {
154
+ toast.success(
155
+ `Generated ${generated} brief(s)${skipped ? `, skipped ${skipped} cached` : ''}${failed ? `, ${failed} failed` : ''}.`,
156
+ )
157
+ }
158
+ refetch()
159
+ } finally {
160
+ setBatchGenerating(false)
161
+ }
162
+ }
163
+
164
+ async function handleExportBrief() {
165
+ setExportingBrief(true)
166
+ try {
167
+ const res = await exportSeoFixBrief({ severity, status, manualOnly: true })
168
+ if (res.error || !res.data?.markdown) {
169
+ toast.error(res.error ?? 'Export failed.')
170
+ return
171
+ }
172
+ const blob = new Blob([res.data.markdown], { type: 'text/markdown;charset=utf-8' })
173
+ const url = URL.createObjectURL(blob)
174
+ const link = document.createElement('a')
175
+ link.href = url
176
+ link.download = res.data.filename || 'seo-fix-brief.md'
177
+ document.body.appendChild(link)
178
+ link.click()
179
+ document.body.removeChild(link)
180
+ setTimeout(() => URL.revokeObjectURL(url), 0)
181
+ if (res.data.issueCount === 0) {
182
+ toast.message('Brief downloaded — no manual fixes matched the current filters.')
183
+ } else {
184
+ toast.success(`Exported ${res.data.issueCount} manual fix(es) for your dev team.`)
185
+ }
186
+ } finally {
187
+ setExportingBrief(false)
188
+ }
189
+ }
190
+
108
191
  return (
109
192
  <div className="space-y-4">
110
193
  <AuditSummaryCards refetchKey={refetchKey} />
111
194
 
112
195
  <SectionCard>
113
- <div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
114
- <div className="flex flex-wrap gap-1" role="group" aria-label="Filter by severity">
115
- {SEVERITY_FILTERS.map((f) => (
116
- <button
117
- key={f.id}
118
- type="button"
119
- aria-pressed={severity === f.id}
120
- onClick={() => setSeverity(f.id)}
121
- className={`focus-visible:ring-ring rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none ${
122
- severity === f.id
123
- ? 'bg-primary text-primary-foreground'
124
- : 'text-muted-foreground hover:bg-accent hover:text-foreground'
125
- }`}
126
- >
127
- {f.label}
128
- </button>
129
- ))}
196
+ <div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
197
+ <div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
198
+ <div className="flex flex-wrap gap-1" role="group" aria-label="Filter by severity">
199
+ {SEVERITY_FILTERS.map((f) => (
200
+ <button
201
+ key={f.id}
202
+ type="button"
203
+ aria-pressed={severity === f.id}
204
+ onClick={() => setSeverity(f.id)}
205
+ className={`focus-visible:ring-ring rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none ${
206
+ severity === f.id
207
+ ? 'bg-primary text-primary-foreground'
208
+ : 'text-muted-foreground hover:bg-accent hover:text-foreground'
209
+ }`}
210
+ >
211
+ {f.label}
212
+ </button>
213
+ ))}
214
+ </div>
215
+ <div className="flex flex-wrap gap-1" role="group" aria-label="Filter by status">
216
+ {STATUS_FILTERS.map((f) => (
217
+ <button
218
+ key={f.id}
219
+ type="button"
220
+ aria-pressed={status === f.id}
221
+ onClick={() => setStatus(f.id)}
222
+ className={`focus-visible:ring-ring rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none ${
223
+ status === f.id
224
+ ? 'bg-accent text-foreground'
225
+ : 'text-muted-foreground hover:bg-accent hover:text-foreground'
226
+ }`}
227
+ >
228
+ {f.label}
229
+ </button>
230
+ ))}
231
+ </div>
130
232
  </div>
131
- <div className="flex flex-wrap gap-1" role="group" aria-label="Filter by status">
132
- {STATUS_FILTERS.map((f) => (
133
- <button
134
- key={f.id}
135
- type="button"
136
- aria-pressed={status === f.id}
137
- onClick={() => setStatus(f.id)}
138
- className={`focus-visible:ring-ring rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none ${
139
- status === f.id
140
- ? 'bg-accent text-foreground'
141
- : 'text-muted-foreground hover:bg-accent hover:text-foreground'
142
- }`}
143
- >
144
- {f.label}
145
- </button>
146
- ))}
233
+ <div className="flex flex-wrap gap-2">
234
+ <button
235
+ type="button"
236
+ className={btnSecondary}
237
+ onClick={handleBatchGenerateBriefs}
238
+ disabled={batchGenerating || loading || briefEligibleCount === 0}
239
+ aria-busy={batchGenerating}
240
+ >
241
+ <Sparkles className="h-4 w-4" aria-hidden />
242
+ {batchGenerating ? 'Generating…' : 'Generate briefs'}
243
+ </button>
244
+ <button
245
+ type="button"
246
+ className={btnSecondary}
247
+ onClick={handleExportBrief}
248
+ disabled={exportingBrief || loading}
249
+ aria-busy={exportingBrief}
250
+ >
251
+ <FileDown className="h-4 w-4" aria-hidden />
252
+ {exportingBrief ? 'Exporting…' : 'Export fix brief'}
253
+ </button>
147
254
  </div>
148
255
  </div>
149
256
 
@@ -178,37 +285,53 @@ export function AuditTab({
178
285
  <tbody className="divide-border divide-y">
179
286
  {issues.map((issue: SeoIssue) => {
180
287
  const meta = severityMeta(issue.severity)
288
+ const expanded = expandedIssueId === issue.id
181
289
  return (
182
- <tr key={issue.id} className="hover:bg-accent/40">
183
- <td className="py-2.5 pr-4">
184
- <span className={`inline-flex items-center gap-1.5 text-sm ${meta.text}`}>
185
- <span className={`h-1.5 w-1.5 rounded-full ${meta.dot}`} aria-hidden />
186
- {meta.label}
187
- </span>
188
- </td>
189
- <td className="py-2.5 pr-4">
190
- <span className="text-foreground block font-medium">{issue.title}</span>
191
- <span className="text-muted-foreground block capitalize">
192
- {issue.category}
193
- </span>
194
- </td>
195
- <td className="text-muted-foreground py-2.5 pr-4">
196
- <span className="block truncate">
197
- {issue.entityTitle || issue.url || '—'}
198
- </span>
199
- </td>
200
- <td className="py-2.5 pr-4">
201
- <SeoStatusBadge label={issue.status} tone={issueStatusTone(issue.status)} />
202
- </td>
203
- <td className="py-2.5 text-right">
204
- <button
205
- onClick={() => setOpenIssue(issue.id)}
206
- className="text-primary hover:bg-accent focus-visible:ring-ring rounded-md px-2 py-1 text-sm focus-visible:ring-2 focus-visible:outline-none"
207
- >
208
- {issue.fixable ? 'Fix' : 'View'}
209
- </button>
210
- </td>
211
- </tr>
290
+ <Fragment key={issue.id}>
291
+ <tr className={`hover:bg-accent/40 ${expanded ? 'bg-accent/20' : ''}`}>
292
+ <td className="py-2.5 pr-4">
293
+ <span className={`inline-flex items-center gap-1.5 text-sm ${meta.text}`}>
294
+ <span className={`h-1.5 w-1.5 rounded-full ${meta.dot}`} aria-hidden />
295
+ {meta.label}
296
+ </span>
297
+ </td>
298
+ <td className="py-2.5 pr-4">
299
+ <span className="text-foreground block font-medium">{issue.title}</span>
300
+ <span className="text-muted-foreground block capitalize">
301
+ {issue.category}
302
+ </span>
303
+ </td>
304
+ <td className="text-muted-foreground py-2.5 pr-4">
305
+ <span className="block truncate">
306
+ {issue.entityTitle || issue.url || '—'}
307
+ </span>
308
+ </td>
309
+ <td className="py-2.5 pr-4">
310
+ <SeoStatusBadge
311
+ label={issue.status}
312
+ tone={issueStatusTone(issue.status)}
313
+ />
314
+ </td>
315
+ <td className="py-2.5 text-right">
316
+ <SeoIssueFixToggle
317
+ expanded={expanded}
318
+ fixable={
319
+ issue.fixable ||
320
+ isInlineSeoFixIssue(issue.type) ||
321
+ supportsSeoContentBrief(issue.type)
322
+ }
323
+ onToggle={() => toggleExpanded(issue.id)}
324
+ />
325
+ </td>
326
+ </tr>
327
+ <SeoIssueFixPanel
328
+ issue={issue}
329
+ expanded={expanded}
330
+ onChanged={handleChanged}
331
+ onNavigate={onNavigate}
332
+ onOpenDetails={() => setDetailIssueId(issue.id)}
333
+ />
334
+ </Fragment>
212
335
  )
213
336
  })}
214
337
  </tbody>
@@ -218,10 +341,10 @@ export function AuditTab({
218
341
  </SectionCard>
219
342
 
220
343
  <SeoIssueFixDrawer
221
- open={openIssue !== null}
222
- onOpenChange={(o) => !o && setOpenIssue(null)}
223
- issueId={openIssue}
224
- onChanged={refetch}
344
+ open={detailIssueId !== null}
345
+ onOpenChange={(o) => !o && setDetailIssueId(null)}
346
+ issueId={detailIssueId}
347
+ onChanged={handleChanged}
225
348
  onNavigate={onNavigate}
226
349
  />
227
350
  </div>