@prenta/admin 1.14.0 → 1.15.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/__tests__/lib/api.test.d.ts +2 -0
  3. package/dist/__tests__/lib/api.test.d.ts.map +1 -0
  4. package/dist/__tests__/lib/api.test.js +58 -0
  5. package/dist/__tests__/lib/api.test.js.map +1 -0
  6. package/dist/__tests__/lib/seo-service-autopilot.test.d.ts +2 -0
  7. package/dist/__tests__/lib/seo-service-autopilot.test.d.ts.map +1 -0
  8. package/dist/__tests__/lib/seo-service-autopilot.test.js +171 -0
  9. package/dist/__tests__/lib/seo-service-autopilot.test.js.map +1 -0
  10. package/dist/__tests__/lib/seo-service-proposals.test.js +28 -0
  11. package/dist/__tests__/lib/seo-service-proposals.test.js.map +1 -1
  12. package/dist/__tests__/views/redirects-pane.render.test.js +30 -12
  13. package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
  14. package/dist/__tests__/views/seo-autopilot-applied.render.test.d.ts +2 -0
  15. package/dist/__tests__/views/seo-autopilot-applied.render.test.d.ts.map +1 -0
  16. package/dist/__tests__/views/seo-autopilot-applied.render.test.js +330 -0
  17. package/dist/__tests__/views/seo-autopilot-applied.render.test.js.map +1 -0
  18. package/dist/__tests__/views/seo-autopilot-card.render.test.d.ts +2 -0
  19. package/dist/__tests__/views/seo-autopilot-card.render.test.d.ts.map +1 -0
  20. package/dist/__tests__/views/seo-autopilot-card.render.test.js +512 -0
  21. package/dist/__tests__/views/seo-autopilot-card.render.test.js.map +1 -0
  22. package/dist/__tests__/views/seo-proposals-tab.render.test.js +139 -0
  23. package/dist/__tests__/views/seo-proposals-tab.render.test.js.map +1 -1
  24. package/dist/__tests__/views/seo-settings.render.test.js +41 -4
  25. package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
  26. package/dist/lib/api.d.ts +13 -2
  27. package/dist/lib/api.d.ts.map +1 -1
  28. package/dist/lib/api.js +1 -2
  29. package/dist/lib/api.js.map +1 -1
  30. package/dist/lib/seo-service.d.ts +206 -2
  31. package/dist/lib/seo-service.d.ts.map +1 -1
  32. package/dist/lib/seo-service.js +139 -5
  33. package/dist/lib/seo-service.js.map +1 -1
  34. package/dist/prenta-admin.css +1 -1
  35. package/dist/views/SEO.js +1 -1
  36. package/dist/views/SEO.js.map +1 -1
  37. package/dist/views/seo/AutopilotAppliedSection.d.ts +10 -0
  38. package/dist/views/seo/AutopilotAppliedSection.d.ts.map +1 -0
  39. package/dist/views/seo/AutopilotAppliedSection.js +295 -0
  40. package/dist/views/seo/AutopilotAppliedSection.js.map +1 -0
  41. package/dist/views/seo/ProposalsTab.d.ts.map +1 -1
  42. package/dist/views/seo/ProposalsTab.js +53 -17
  43. package/dist/views/seo/ProposalsTab.js.map +1 -1
  44. package/dist/views/seo/RedirectsTab.d.ts +3 -1
  45. package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
  46. package/dist/views/seo/RedirectsTab.js +14 -19
  47. package/dist/views/seo/RedirectsTab.js.map +1 -1
  48. package/dist/views/seo/relative-age.d.ts +7 -0
  49. package/dist/views/seo/relative-age.d.ts.map +1 -0
  50. package/dist/views/seo/relative-age.js +20 -0
  51. package/dist/views/seo/relative-age.js.map +1 -0
  52. package/dist/views/settings/SeoAutopilotCard.d.ts +30 -0
  53. package/dist/views/settings/SeoAutopilotCard.d.ts.map +1 -0
  54. package/dist/views/settings/SeoAutopilotCard.js +375 -0
  55. package/dist/views/settings/SeoAutopilotCard.js.map +1 -0
  56. package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
  57. package/dist/views/settings/SeoSettingsTab.js +2 -1
  58. package/dist/views/settings/SeoSettingsTab.js.map +1 -1
  59. package/dist/views/settings/components.d.ts +3 -1
  60. package/dist/views/settings/components.d.ts.map +1 -1
  61. package/dist/views/settings/components.js +2 -2
  62. package/dist/views/settings/components.js.map +1 -1
  63. package/package.json +3 -3
  64. package/src/__tests__/lib/api.test.ts +76 -0
  65. package/src/__tests__/lib/seo-service-autopilot.test.ts +196 -0
  66. package/src/__tests__/lib/seo-service-proposals.test.ts +29 -0
  67. package/src/__tests__/views/redirects-pane.render.test.tsx +36 -16
  68. package/src/__tests__/views/seo-autopilot-applied.render.test.tsx +382 -0
  69. package/src/__tests__/views/seo-autopilot-card.render.test.tsx +610 -0
  70. package/src/__tests__/views/seo-proposals-tab.render.test.tsx +161 -0
  71. package/src/__tests__/views/seo-settings.render.test.tsx +47 -7
  72. package/src/lib/api.ts +19 -3
  73. package/src/lib/seo-service.ts +334 -6
  74. package/src/views/SEO.tsx +1 -1
  75. package/src/views/seo/AutopilotAppliedSection.tsx +597 -0
  76. package/src/views/seo/ProposalsTab.tsx +92 -12
  77. package/src/views/seo/RedirectsTab.tsx +39 -60
  78. package/src/views/seo/relative-age.ts +15 -0
  79. package/src/views/settings/SeoAutopilotCard.tsx +826 -0
  80. package/src/views/settings/SeoSettingsTab.tsx +2 -0
  81. package/src/views/settings/components.tsx +10 -1
@@ -0,0 +1,597 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * SEO → Proposals "Applied by autopilot": the ledger of every unattended change
5
+ * (spec §4), newest first, with its gate results and Search Console outcome.
6
+ * Revert is admin-only (`canRevert` from the list payload) and confirmed; a 409
7
+ * conflict re-prompts with the fields a human edited since and offers a forced
8
+ * retry. One mutation is allowed in flight at a time (same guard pattern as
9
+ * `ProposalsTab`).
10
+ */
11
+ import { useCallback, useEffect, useId, useRef, useState } from 'react'
12
+ import { ArrowRight, Bot, Undo2 } from 'lucide-react'
13
+ import { toast } from 'sonner'
14
+ import {
15
+ fetchAutopilotApplied,
16
+ revertAutopilotChange,
17
+ type SeoAutoApplyList,
18
+ type SeoAutoApplyOutcome,
19
+ type SeoAutoApplyRow,
20
+ type SeoAutoApplyStatus,
21
+ } from '../../lib/seo-service.js'
22
+ import { sourceEditPath } from '../../lib/collection-routes.js'
23
+ import { brandAlignmentLabel, brandAlignmentTone } from '../../lib/brand-voice-client.js'
24
+ import {
25
+ SectionCard,
26
+ SeoEmptyState,
27
+ SeoErrorState,
28
+ SeoLoading,
29
+ SeoStatusBadge,
30
+ btnSecondary,
31
+ } from '../../components/seo/primitives.js'
32
+ import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
33
+ import { relativeAge } from './relative-age.js'
34
+
35
+ const PAGE_SIZE = 20
36
+ const SEO_SETTINGS_PATH = '/settings?tab=seo'
37
+ const SELECT_CLASS =
38
+ 'border-border bg-input-background text-foreground focus-visible:ring-ring rounded-md border px-2 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-60'
39
+ /** Compact secondary button for table rows (parity with the Settings autopilot card). */
40
+ const ROW_BUTTON_CLASS =
41
+ 'border-border text-foreground hover:bg-accent focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-sm font-medium whitespace-nowrap transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60'
42
+ const PAGER_BUTTON_CLASS =
43
+ 'text-foreground hover:bg-accent 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 disabled:cursor-not-allowed disabled:opacity-50'
44
+
45
+ /** The one mutation this section performs; every Revert locks while it runs. */
46
+ type Mutation = 'revert'
47
+
48
+ const STATUS_OPTIONS: ReadonlyArray<{ value: SeoAutoApplyStatus | ''; label: string }> = [
49
+ { value: '', label: 'All statuses' },
50
+ { value: 'applied', label: 'Applied' },
51
+ { value: 'reverted', label: 'Reverted' },
52
+ { value: 'outcome_reverted', label: 'Reverted after regression' },
53
+ { value: 'verify_failed', label: 'Verify failed' },
54
+ ]
55
+
56
+ const OUTCOME_OPTIONS: ReadonlyArray<{ value: SeoAutoApplyOutcome | ''; label: string }> = [
57
+ { value: '', label: 'All outcomes' },
58
+ { value: 'pending', label: 'Watching' },
59
+ { value: 'held', label: 'Held' },
60
+ { value: 'regressed', label: 'Regressed' },
61
+ { value: 'insufficient_data', label: 'Not enough data' },
62
+ { value: 'skipped', label: 'Not watched' },
63
+ ]
64
+
65
+ function isStatus(v: string): v is SeoAutoApplyStatus {
66
+ return STATUS_OPTIONS.some((o) => o.value !== '' && o.value === v)
67
+ }
68
+ function isOutcome(v: string): v is SeoAutoApplyOutcome {
69
+ return OUTCOME_OPTIONS.some((o) => o.value !== '' && o.value === v)
70
+ }
71
+
72
+ function StatusBadge({ status }: { status: SeoAutoApplyStatus }) {
73
+ switch (status) {
74
+ case 'applied':
75
+ return <SeoStatusBadge label="Applied" tone="good" />
76
+ case 'verify_failed':
77
+ return <SeoStatusBadge label="Verify failed" tone="poor" />
78
+ case 'reverted':
79
+ return <SeoStatusBadge label="Reverted" tone="neutral" />
80
+ case 'outcome_reverted':
81
+ return <SeoStatusBadge label="Reverted · regression" tone="fair" />
82
+ default: {
83
+ const _exhaustive: never = status
84
+ return _exhaustive
85
+ }
86
+ }
87
+ }
88
+
89
+ /** Only outcomes that carry information get a badge; `pending` / `skipped` stay quiet. */
90
+ function OutcomeBadge({ outcome }: { outcome: SeoAutoApplyOutcome }) {
91
+ switch (outcome) {
92
+ case 'pending':
93
+ case 'skipped':
94
+ return null
95
+ case 'held':
96
+ return <SeoStatusBadge label="Held" tone="good" />
97
+ case 'regressed':
98
+ return <SeoStatusBadge label="Regressed" tone="poor" />
99
+ case 'insufficient_data':
100
+ return <SeoStatusBadge label="Not enough data" tone="neutral" />
101
+ default: {
102
+ const _exhaustive: never = outcome
103
+ return _exhaustive
104
+ }
105
+ }
106
+ }
107
+
108
+ function KindBadge({ kind }: { kind: SeoAutoApplyRow['kind'] }) {
109
+ switch (kind) {
110
+ case 'issue-fix':
111
+ return <SeoStatusBadge label="Issue fix" tone="neutral" />
112
+ case 'redirect':
113
+ return <SeoStatusBadge label="Redirect" tone="neutral" />
114
+ default: {
115
+ const _exhaustive: never = kind
116
+ return _exhaustive
117
+ }
118
+ }
119
+ }
120
+
121
+ /** `missing-meta-title` → `Missing meta title`; the redirect pseudo-type names its producer. */
122
+ function issueTypeLabel(issueType: string): string {
123
+ if (issueType === 'redirect') return '404 recovery'
124
+ const words = issueType.replace(/[-_]+/g, ' ').trim()
125
+ return words ? words.charAt(0).toUpperCase() + words.slice(1) : issueType
126
+ }
127
+
128
+ /** `metaTitle` → `Meta title`. */
129
+ function fieldLabel(field: string): string {
130
+ const spaced = field.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[-_]+/g, ' ')
131
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase()
132
+ }
133
+
134
+ function formatValue(v: unknown): string {
135
+ if (v === null || v === undefined || v === '') return '—'
136
+ if (typeof v === 'string') return v
137
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v)
138
+ return JSON.stringify(v)
139
+ }
140
+
141
+ function displayTitle(row: SeoAutoApplyRow): string {
142
+ if (row.kind === 'redirect') return formatValue(row.after.source ?? row.entityUrl)
143
+ return row.entityTitle ?? row.entityUrl ?? row.path ?? issueTypeLabel(row.issueType)
144
+ }
145
+
146
+ function AppliedTime({ iso }: { iso: string }) {
147
+ const absolute = new Date(iso).toLocaleString()
148
+ const relative = relativeAge(iso)
149
+ return (
150
+ <time dateTime={iso} title={absolute}>
151
+ {relative || absolute}
152
+ {relative ? <span className="sr-only"> ({absolute})</span> : null}
153
+ </time>
154
+ )
155
+ }
156
+
157
+ function Diff({ row }: { row: SeoAutoApplyRow }) {
158
+ if (row.kind === 'redirect') {
159
+ return (
160
+ <p className="text-foreground flex flex-wrap items-center gap-1 font-mono text-sm break-all">
161
+ <span>{formatValue(row.after.source)}</span>
162
+ <ArrowRight className="text-muted-foreground h-3.5 w-3.5 shrink-0" aria-hidden />
163
+ <span className="sr-only">to</span>
164
+ <span className="text-muted-foreground">{formatValue(row.after.destination)}</span>
165
+ </p>
166
+ )
167
+ }
168
+ const fields = Object.keys(row.after)
169
+ if (fields.length === 0) return <span className="text-muted-foreground text-sm">—</span>
170
+ return (
171
+ <ul className="space-y-1 text-sm">
172
+ {fields.map((field) => (
173
+ <li key={field} className="flex flex-wrap items-baseline gap-x-1.5">
174
+ <span className="text-muted-foreground">{fieldLabel(field)}:</span>
175
+ <span className="text-muted-foreground line-through">
176
+ {formatValue(row.before?.[field])}
177
+ </span>
178
+ <ArrowRight className="text-muted-foreground h-3 w-3 shrink-0 self-center" aria-hidden />
179
+ <span className="sr-only">to</span>
180
+ <span className="text-foreground">{formatValue(row.after[field])}</span>
181
+ </li>
182
+ ))}
183
+ </ul>
184
+ )
185
+ }
186
+
187
+ function Quality({ gate }: { gate: SeoAutoApplyRow['gate'] }) {
188
+ if (gate.brandAlignment !== null) {
189
+ return (
190
+ <span
191
+ className="border-border bg-muted/40 inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs"
192
+ aria-label={`Brand voice score ${gate.brandAlignment} out of 100`}
193
+ >
194
+ <span className={`font-medium ${brandAlignmentTone(gate.brandAlignment)}`}>
195
+ {gate.brandAlignment}/100
196
+ </span>
197
+ <span className="text-muted-foreground">{brandAlignmentLabel(gate.brandAlignment)}</span>
198
+ </span>
199
+ )
200
+ }
201
+ if (gate.confidence !== null) {
202
+ const pct = Math.round(gate.confidence * 100)
203
+ return (
204
+ <SeoStatusBadge label={`${pct}%`} tone={pct >= 70 ? 'good' : pct >= 40 ? 'fair' : 'poor'} />
205
+ )
206
+ }
207
+ return <SeoStatusBadge label="Rule-based" tone="neutral" />
208
+ }
209
+
210
+ export function AutopilotAppliedSection({
211
+ onNavigate,
212
+ onChanged,
213
+ refreshKey = 0,
214
+ }: {
215
+ onNavigate?: (path: string) => void
216
+ /**
217
+ * Fired after a successful revert. The server reopens the originating issue
218
+ * or redirect suggestion, so the parent inbox / overview must refetch.
219
+ */
220
+ onChanged?: () => void
221
+ refreshKey?: number
222
+ }) {
223
+ const baseId = useId()
224
+ const [data, setData] = useState<SeoAutoApplyList | null>(null)
225
+ const [loading, setLoading] = useState(true)
226
+ const [error, setError] = useState<string | null>(null)
227
+ const [page, setPage] = useState(1)
228
+ const [status, setStatus] = useState<SeoAutoApplyStatus | ''>('')
229
+ const [outcome, setOutcome] = useState<SeoAutoApplyOutcome | ''>('')
230
+ const [target, setTarget] = useState<SeoAutoApplyRow | null>(null)
231
+ const [conflict, setConflict] = useState<{ row: SeoAutoApplyRow; fields: string[] } | null>(null)
232
+ const [mutation, setMutation] = useState<Mutation | null>(null)
233
+ /** The one line that changes after a revert — announced via `role="status"`. */
234
+ const [lastReverted, setLastReverted] = useState<string | null>(null)
235
+ /** Mirrors `mutation` synchronously so a second click in the same tick cannot slip past. */
236
+ const mutationRef = useRef<Mutation | null>(null)
237
+ const mountedRef = useRef(true)
238
+ /** Ignore responses from a superseded request (filter/page changed mid-flight). */
239
+ const requestRef = useRef(0)
240
+ const busy = mutation !== null
241
+
242
+ useEffect(() => {
243
+ mountedRef.current = true
244
+ return () => {
245
+ mountedRef.current = false
246
+ }
247
+ }, [])
248
+
249
+ /**
250
+ * `silent` re-reads keep the table on screen (post-revert / parent refresh);
251
+ * the initial load and filter or page changes show the loading state.
252
+ */
253
+ const load = useCallback(
254
+ async (silent: boolean) => {
255
+ const requestId = ++requestRef.current
256
+ if (!silent) {
257
+ setLoading(true)
258
+ setLastReverted(null)
259
+ }
260
+ setError(null)
261
+ try {
262
+ const next = await fetchAutopilotApplied({
263
+ page,
264
+ pageSize: PAGE_SIZE,
265
+ status: status || undefined,
266
+ outcome: outcome || undefined,
267
+ })
268
+ if (!mountedRef.current || requestId !== requestRef.current) return
269
+ setData(next)
270
+ } catch (err) {
271
+ if (!mountedRef.current || requestId !== requestRef.current) return
272
+ setError(
273
+ err instanceof Error && err.message ? err.message : 'Failed to load autopilot history.',
274
+ )
275
+ } finally {
276
+ if (mountedRef.current && requestId === requestRef.current) setLoading(false)
277
+ }
278
+ },
279
+ [page, status, outcome],
280
+ )
281
+
282
+ useEffect(() => {
283
+ void load(false)
284
+ }, [load])
285
+
286
+ const seenRefreshKeyRef = useRef(refreshKey)
287
+ useEffect(() => {
288
+ if (seenRefreshKeyRef.current === refreshKey) return
289
+ seenRefreshKeyRef.current = refreshKey
290
+ void load(true)
291
+ }, [load, refreshKey])
292
+
293
+ // A filtered refetch can land on a page that no longer exists (e.g. the
294
+ // only `applied` row on page 3 was just reverted). Snap back to the last page.
295
+ useEffect(() => {
296
+ if (!data || data.docs.length > 0 || page <= 1) return
297
+ setPage(Math.max(1, Math.ceil(data.total / data.pageSize)))
298
+ }, [data, page])
299
+
300
+ const beginMutation = useCallback((kind: Mutation): boolean => {
301
+ if (mutationRef.current !== null) return false
302
+ mutationRef.current = kind
303
+ setMutation(kind)
304
+ return true
305
+ }, [])
306
+ const endMutation = useCallback(() => {
307
+ mutationRef.current = null
308
+ if (mountedRef.current) setMutation(null)
309
+ }, [])
310
+
311
+ const doRevert = useCallback(
312
+ async (row: SeoAutoApplyRow, force: boolean) => {
313
+ if (!beginMutation('revert')) return
314
+ try {
315
+ const res = await revertAutopilotChange(row.id, force)
316
+ if (!mountedRef.current) return
317
+ if (res.ok) {
318
+ toast.success('Change reverted.')
319
+ setLastReverted(displayTitle(row))
320
+ onChanged?.()
321
+ await load(true)
322
+ return
323
+ }
324
+ if (res.conflictFields !== undefined) {
325
+ setConflict({ row, fields: res.conflictFields })
326
+ return
327
+ }
328
+ toast.error(res.error)
329
+ } catch (err) {
330
+ if (mountedRef.current) {
331
+ toast.error(err instanceof Error && err.message ? err.message : 'Request failed.')
332
+ }
333
+ } finally {
334
+ endMutation()
335
+ }
336
+ },
337
+ [beginMutation, endMutation, load, onChanged],
338
+ )
339
+
340
+ const rows = data?.docs ?? []
341
+ const total = data?.total ?? 0
342
+ const pageSize = data?.pageSize ?? PAGE_SIZE
343
+ const totalPages = Math.max(1, Math.ceil(total / pageSize))
344
+ const rangeStart = total === 0 ? 0 : (page - 1) * pageSize + 1
345
+ const rangeEnd = Math.min(total, page * pageSize)
346
+ const filtering = status !== '' || outcome !== ''
347
+ const statusId = `${baseId}-status`
348
+ const outcomeId = `${baseId}-outcome`
349
+
350
+ return (
351
+ <SectionCard
352
+ title="Applied by autopilot"
353
+ description="Every unattended change with its gate results and Search Console outcome. Revert restores the previous values and reopens the issue."
354
+ action={
355
+ <div className="flex flex-wrap items-center gap-2">
356
+ <label htmlFor={statusId} className="sr-only">
357
+ Filter by status
358
+ </label>
359
+ <select
360
+ id={statusId}
361
+ className={SELECT_CLASS}
362
+ value={status}
363
+ disabled={busy}
364
+ onChange={(e) => {
365
+ const v = e.target.value
366
+ setStatus(isStatus(v) ? v : '')
367
+ setPage(1)
368
+ }}
369
+ >
370
+ {STATUS_OPTIONS.map((o) => (
371
+ <option key={o.value} value={o.value}>
372
+ {o.label}
373
+ </option>
374
+ ))}
375
+ </select>
376
+ <label htmlFor={outcomeId} className="sr-only">
377
+ Filter by outcome
378
+ </label>
379
+ <select
380
+ id={outcomeId}
381
+ className={SELECT_CLASS}
382
+ value={outcome}
383
+ disabled={busy}
384
+ onChange={(e) => {
385
+ const v = e.target.value
386
+ setOutcome(isOutcome(v) ? v : '')
387
+ setPage(1)
388
+ }}
389
+ >
390
+ {OUTCOME_OPTIONS.map((o) => (
391
+ <option key={o.value} value={o.value}>
392
+ {o.label}
393
+ </option>
394
+ ))}
395
+ </select>
396
+ </div>
397
+ }
398
+ >
399
+ {/* Always mounted so AT reliably announces the text change; empty (and unpadded) until a revert lands. */}
400
+ <p
401
+ className={`text-muted-foreground text-sm ${lastReverted && !loading ? 'mb-3' : ''}`}
402
+ role="status"
403
+ aria-live="polite"
404
+ >
405
+ {lastReverted && !loading ? `Reverted the change to “${lastReverted}”.` : ''}
406
+ </p>
407
+ {loading ? (
408
+ <SeoLoading label="Loading autopilot history…" />
409
+ ) : error ? (
410
+ <SeoErrorState message={error} onRetry={() => void load(false)} />
411
+ ) : rows.length === 0 ? (
412
+ <SeoEmptyState
413
+ icon={<Bot size={24} />}
414
+ title={
415
+ filtering ? 'No changes match these filters' : 'Autopilot has not applied anything yet'
416
+ }
417
+ description={
418
+ filtering
419
+ ? 'Clear the status or outcome filter to see the full ledger.'
420
+ : 'Enable fix types in Settings → SEO → SEO Autopilot; changes show up here after the daily run.'
421
+ }
422
+ action={
423
+ filtering ? (
424
+ <button
425
+ type="button"
426
+ className={btnSecondary}
427
+ onClick={() => {
428
+ setStatus('')
429
+ setOutcome('')
430
+ setPage(1)
431
+ }}
432
+ >
433
+ Clear filters
434
+ </button>
435
+ ) : onNavigate ? (
436
+ <button
437
+ type="button"
438
+ className={btnSecondary}
439
+ onClick={() => onNavigate(SEO_SETTINGS_PATH)}
440
+ >
441
+ Open autopilot settings
442
+ </button>
443
+ ) : undefined
444
+ }
445
+ />
446
+ ) : (
447
+ <div className="space-y-3">
448
+ <div className="border-border overflow-x-auto rounded-md border">
449
+ <table className="w-full text-sm" aria-label="Changes applied by autopilot">
450
+ <thead>
451
+ <tr className="border-border bg-muted/40 text-muted-foreground border-b text-left text-xs">
452
+ <th scope="col" className="px-3 py-2.5 font-medium">
453
+ Change
454
+ </th>
455
+ <th scope="col" className="px-3 py-2.5 font-medium">
456
+ Before → after
457
+ </th>
458
+ <th scope="col" className="px-3 py-2.5 font-medium">
459
+ Quality
460
+ </th>
461
+ <th scope="col" className="px-3 py-2.5 font-medium">
462
+ Applied on
463
+ </th>
464
+ <th scope="col" className="px-3 py-2.5 font-medium">
465
+ Outcome
466
+ </th>
467
+ <th scope="col" className="px-3 py-2.5">
468
+ <span className="sr-only">Actions</span>
469
+ </th>
470
+ </tr>
471
+ </thead>
472
+ <tbody className="divide-border divide-y">
473
+ {rows.map((row) => {
474
+ const title = displayTitle(row)
475
+ const editPath =
476
+ row.entityId !== null
477
+ ? sourceEditPath(row.collection ?? undefined, row.entityId)
478
+ : null
479
+ return (
480
+ <tr key={row.id} className="hover:bg-accent/20 align-top transition-colors">
481
+ <td className="px-3 py-3">
482
+ <div className="flex flex-wrap items-center gap-2">
483
+ {editPath && onNavigate ? (
484
+ <button
485
+ type="button"
486
+ className="text-foreground focus-visible:ring-ring rounded-sm text-left font-medium hover:underline focus-visible:ring-2 focus-visible:outline-none"
487
+ onClick={() => onNavigate(editPath)}
488
+ aria-label={`Open ${title} in editor`}
489
+ >
490
+ {title}
491
+ </button>
492
+ ) : (
493
+ <span className="text-foreground font-medium">{title}</span>
494
+ )}
495
+ <KindBadge kind={row.kind} />
496
+ <StatusBadge status={row.status} />
497
+ </div>
498
+ <p className="text-muted-foreground mt-0.5 text-xs">
499
+ <span>{issueTypeLabel(row.issueType)}</span>
500
+ {row.entityUrl && row.entityTitle ? (
501
+ <>
502
+ <span aria-hidden> · </span>
503
+ <span>{row.entityUrl}</span>
504
+ </>
505
+ ) : null}
506
+ </p>
507
+ </td>
508
+ <td className="max-w-md px-3 py-3">
509
+ <Diff row={row} />
510
+ </td>
511
+ <td className="px-3 py-3">
512
+ <Quality gate={row.gate} />
513
+ </td>
514
+ <td className="text-muted-foreground px-3 py-3 text-xs whitespace-nowrap">
515
+ <AppliedTime iso={row.appliedAt} />
516
+ </td>
517
+ <td className="px-3 py-3">
518
+ <OutcomeBadge outcome={row.outcome} />
519
+ </td>
520
+ <td className="px-3 py-3 text-right">
521
+ {data?.canRevert && row.status === 'applied' ? (
522
+ <button
523
+ type="button"
524
+ className={ROW_BUTTON_CLASS}
525
+ onClick={() => setTarget(row)}
526
+ disabled={busy}
527
+ aria-label={`Revert change to ${title}`}
528
+ >
529
+ <Undo2 className="h-4 w-4" aria-hidden />
530
+ Revert
531
+ </button>
532
+ ) : null}
533
+ </td>
534
+ </tr>
535
+ )
536
+ })}
537
+ </tbody>
538
+ </table>
539
+ </div>
540
+ {total > pageSize ? (
541
+ <nav
542
+ className="flex items-center justify-between gap-3 text-sm"
543
+ aria-label="Autopilot history pagination"
544
+ >
545
+ <p className="text-muted-foreground">
546
+ {rangeStart}–{rangeEnd} of {total}
547
+ </p>
548
+ <div className="flex gap-2">
549
+ <button
550
+ type="button"
551
+ className={PAGER_BUTTON_CLASS}
552
+ disabled={page <= 1 || busy}
553
+ onClick={() => setPage((p) => Math.max(1, p - 1))}
554
+ >
555
+ Previous
556
+ </button>
557
+ <button
558
+ type="button"
559
+ className={PAGER_BUTTON_CLASS}
560
+ disabled={page >= totalPages || busy}
561
+ onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
562
+ >
563
+ Next
564
+ </button>
565
+ </div>
566
+ </nav>
567
+ ) : null}
568
+ </div>
569
+ )}
570
+
571
+ <ConfirmDialog
572
+ open={target !== null}
573
+ onClose={() => setTarget(null)}
574
+ onConfirm={() => {
575
+ if (target) void doRevert(target, false)
576
+ }}
577
+ title="Revert this autopilot change?"
578
+ description="The previous values are restored (a new version is created) and the issue is reopened. Autopilot will not touch this issue again for 30 days."
579
+ confirmLabel="Revert change"
580
+ destructive
581
+ />
582
+ <ConfirmDialog
583
+ open={conflict !== null}
584
+ onClose={() => setConflict(null)}
585
+ onConfirm={() => {
586
+ if (conflict) void doRevert(conflict.row, true)
587
+ }}
588
+ title="Someone edited this since"
589
+ description={`These fields were changed after autopilot applied them: ${
590
+ conflict?.fields.join(', ') ?? ''
591
+ }. Reverting anyway overwrites those edits.`}
592
+ confirmLabel="Revert anyway"
593
+ destructive
594
+ />
595
+ </SectionCard>
596
+ )
597
+ }