@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,826 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useId, useRef, useState } from 'react'
4
+ import { AlertTriangle, Bot, Info, Loader2, Play, RefreshCw } from 'lucide-react'
5
+ import { toast } from 'sonner'
6
+ import {
7
+ SEO_AUTOPILOT_LIMITS,
8
+ fetchAutopilotSettings,
9
+ runAutopilotNow,
10
+ updateAutopilotSettings,
11
+ type SeoAutopilotHaltReason,
12
+ type SeoAutopilotIssueType,
13
+ type SeoAutopilotPauseReason,
14
+ type SeoAutopilotRunSummary,
15
+ type SeoAutopilotSettings,
16
+ type SeoAutopilotSettingsPatch,
17
+ } from '../../lib/seo-service.js'
18
+ import { brandAlignmentLabel } from '../../lib/brand-voice-client.js'
19
+ import { Skeleton } from '../../components/ui/Skeleton.js'
20
+ import { relativeAge } from '../seo/relative-age.js'
21
+ import { SettingsCard, SettingsToggleRow } from './components.js'
22
+
23
+ const INPUT_CLASS =
24
+ 'w-full rounded-md border border-border bg-input-background px-3 py-2 text-base text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60'
25
+ const CHECKBOX_CLASS =
26
+ 'border-border accent-primary mt-1 h-4 w-4 shrink-0 rounded focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60'
27
+ const SECONDARY_BUTTON_CLASS =
28
+ 'border-border text-foreground hover:bg-accent inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60'
29
+ const LINK_BUTTON_CLASS =
30
+ 'text-primary rounded-sm underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none'
31
+
32
+ /**
33
+ * The one mutation allowed in flight at a time. Inputs stay enabled while it
34
+ * runs (so focus and a blur-then-click sequence survive); only the action
35
+ * buttons lock and the card is marked `aria-busy`.
36
+ */
37
+ type Mutation = 'save' | 'run'
38
+
39
+ interface TypeOption {
40
+ id: SeoAutopilotIssueType
41
+ label: string
42
+ help: string
43
+ producer: 'Rule-based' | 'AI'
44
+ }
45
+
46
+ /**
47
+ * The fix types offered, grouped for display. Must cover `SEO_AUTOPILOT_ISSUE_TYPES`
48
+ * exactly once each — the id type keeps stray ids out, and the render test
49
+ * pins the set against the mirrored constant so a type added server-side
50
+ * cannot silently go un-offered. Exported for that test only.
51
+ */
52
+ export const AUTOPILOT_TYPE_GROUPS: ReadonlyArray<{
53
+ label: string
54
+ options: readonly TypeOption[]
55
+ }> = [
56
+ {
57
+ label: 'Titles',
58
+ options: [
59
+ {
60
+ id: 'missing-meta-title',
61
+ label: 'Missing meta title',
62
+ help: 'Writes a page title when none is set.',
63
+ producer: 'AI',
64
+ },
65
+ {
66
+ id: 'meta-title-length',
67
+ label: 'Meta title length',
68
+ help: 'Trims or rewrites titles outside 30–60 characters.',
69
+ producer: 'Rule-based',
70
+ },
71
+ {
72
+ id: 'duplicate-meta-title',
73
+ label: 'Duplicate meta title',
74
+ help: 'Rewrites a title shared with another page.',
75
+ producer: 'AI',
76
+ },
77
+ ],
78
+ },
79
+ {
80
+ label: 'Descriptions',
81
+ options: [
82
+ {
83
+ id: 'missing-meta-description',
84
+ label: 'Missing meta description',
85
+ help: 'Writes a description when none is set.',
86
+ producer: 'AI',
87
+ },
88
+ {
89
+ id: 'meta-description-length',
90
+ label: 'Meta description length',
91
+ help: 'Trims or rewrites descriptions outside 120–160 characters.',
92
+ producer: 'Rule-based',
93
+ },
94
+ {
95
+ id: 'duplicate-meta-description',
96
+ label: 'Duplicate meta description',
97
+ help: 'Rewrites a description shared with another page.',
98
+ producer: 'AI',
99
+ },
100
+ ],
101
+ },
102
+ {
103
+ label: 'Technical',
104
+ options: [
105
+ {
106
+ id: 'missing-canonical',
107
+ label: 'Missing canonical',
108
+ help: 'Sets the canonical URL to the page’s own URL.',
109
+ producer: 'Rule-based',
110
+ },
111
+ {
112
+ id: 'accidental-noindex',
113
+ label: 'Accidental noindex',
114
+ help: 'Clears noindex on a published page.',
115
+ producer: 'Rule-based',
116
+ },
117
+ {
118
+ id: 'missing-structured-data',
119
+ label: 'Missing structured data',
120
+ help: 'Adds the collection’s default Schema.org type.',
121
+ producer: 'Rule-based',
122
+ },
123
+ ],
124
+ },
125
+ {
126
+ label: 'Redirects',
127
+ options: [
128
+ {
129
+ id: 'redirect',
130
+ label: '404-recovery redirects',
131
+ help: 'Creates a 301 when a dead URL matches a live page above the confidence threshold.',
132
+ producer: 'AI',
133
+ },
134
+ ],
135
+ },
136
+ ]
137
+
138
+ const TYPE_LABEL: Record<SeoAutopilotIssueType, string> = Object.fromEntries(
139
+ AUTOPILOT_TYPE_GROUPS.flatMap((g) => g.options.map((o) => [o.id, o.label])),
140
+ ) as Record<SeoAutopilotIssueType, string>
141
+
142
+ const PAUSE_LABEL: Record<SeoAutopilotPauseReason, string> = {
143
+ outcome_regression: 'a page lost traffic after an autopilot change',
144
+ owner_unavailable: 'the admin who enabled it is no longer active',
145
+ manual: 'it was paused manually',
146
+ }
147
+
148
+ /**
149
+ * `budget_exhausted` is the run's wall-clock budget, not the daily change cap:
150
+ * the cap is a gate reason (`cap_per_day`) and never halts a run. Remaining
151
+ * proposals are simply picked up by the next tick.
152
+ */
153
+ const HALT_LABEL: Record<SeoAutopilotHaltReason, string> = {
154
+ disabled: 'The run stopped because autopilot was disabled',
155
+ paused: 'The run stopped because autopilot was paused',
156
+ owner_unavailable: 'The run stopped because the admin who enabled autopilot is no longer active',
157
+ already_running: 'A run was already in progress',
158
+ verify_failures: 'The run stopped after too many verification failures',
159
+ budget_exhausted:
160
+ 'The run stopped because it ran out of time; remaining proposals wait for the next tick',
161
+ ledger_write_failed: 'The run stopped because a change could not be recorded (it was undone)',
162
+ apply_failed:
163
+ 'The run stopped because applying a change failed unexpectedly; details are in the server log',
164
+ run_failed: 'The run stopped with an unexpected error; details are in the server log',
165
+ }
166
+
167
+ function isKnownHaltReason(reason: string): reason is SeoAutopilotHaltReason {
168
+ return Object.prototype.hasOwnProperty.call(HALT_LABEL, reason)
169
+ }
170
+
171
+ function haltLabel(reason: string): string {
172
+ return isKnownHaltReason(reason) ? HALT_LABEL[reason] : `The run stopped with an error: ${reason}`
173
+ }
174
+
175
+ /** `relativeAge` returns `''` for future or unparsable timestamps; fall back to an absolute date. */
176
+ function lastRunLabel(iso: string | null): string {
177
+ if (!iso) return 'Never run'
178
+ const age = relativeAge(iso)
179
+ if (age) return `Last run ${age}`
180
+ const t = new Date(iso).getTime()
181
+ return Number.isFinite(t) ? `Last run ${new Date(t).toLocaleDateString()}` : 'Last run: unknown'
182
+ }
183
+
184
+ function runToast(s: SeoAutopilotRunSummary): void {
185
+ const counts = `${s.applied} applied, ${s.verifyFailed} verify-failed${
186
+ s.reverted > 0 ? `, ${s.reverted} reverted` : ''
187
+ }.`
188
+ if (!s.halted || !s.haltedReason) {
189
+ toast.success(`Autopilot finished: ${counts}`)
190
+ return
191
+ }
192
+ if (s.haltedReason === 'already_running') {
193
+ toast.success('A run was already in progress — nothing new was applied.')
194
+ return
195
+ }
196
+ toast.warning(`${haltLabel(s.haltedReason)}. ${counts}`)
197
+ }
198
+
199
+ /**
200
+ * Settings → SEO "SEO Autopilot" card (spec §6). Owns its own fetch/save like
201
+ * `SeoDigestCard`; every control PUTs a sparse patch immediately and the card
202
+ * adopts the server's resolved policy from the response. One mutation (a save
203
+ * or a manual run) is allowed in flight at a time; saves queue behind each
204
+ * other so a blur-commit followed by a click both land, in order.
205
+ */
206
+ export function SeoAutopilotCard({
207
+ canEdit,
208
+ onNavigate,
209
+ }: {
210
+ canEdit: boolean
211
+ onNavigate?: (path: string) => void
212
+ }) {
213
+ const baseId = useId()
214
+ const [settings, setSettings] = useState<SeoAutopilotSettings | null>(null)
215
+ /** Mirrors `settings` synchronously so a queued save reads the latest resolved policy. */
216
+ const settingsRef = useRef<SeoAutopilotSettings | null>(null)
217
+ const [loading, setLoading] = useState(true)
218
+ const [loadError, setLoadError] = useState<string | null>(null)
219
+ const [mutation, setMutation] = useState<Mutation | null>(null)
220
+ /** Mirrors `mutation` synchronously so a second call in the same tick cannot slip past. */
221
+ const mutationRef = useRef<Mutation | null>(null)
222
+ /** Saves are serialised through this chain instead of being dropped when one is in flight. */
223
+ const saveChainRef = useRef<Promise<unknown>>(Promise.resolve())
224
+ const mountedRef = useRef(true)
225
+ const busy = mutation !== null
226
+
227
+ useEffect(() => {
228
+ mountedRef.current = true
229
+ return () => {
230
+ mountedRef.current = false
231
+ }
232
+ }, [])
233
+
234
+ const commitSettings = useCallback((next: SeoAutopilotSettings) => {
235
+ settingsRef.current = next
236
+ setSettings(next)
237
+ }, [])
238
+
239
+ const load = useCallback(async () => {
240
+ setLoading(true)
241
+ setLoadError(null)
242
+ const res = await fetchAutopilotSettings()
243
+ if (!mountedRef.current) return
244
+ if (res.settings) commitSettings(res.settings)
245
+ else setLoadError(res.error)
246
+ setLoading(false)
247
+ }, [commitSettings])
248
+
249
+ useEffect(() => {
250
+ void load()
251
+ }, [load])
252
+
253
+ /** Re-read without the skeleton (after a run); a failure is a toast, not a card-level error. */
254
+ const refresh = useCallback(async () => {
255
+ const res = await fetchAutopilotSettings()
256
+ if (!mountedRef.current) return
257
+ if (res.settings) commitSettings(res.settings)
258
+ else toast.error(res.error)
259
+ }, [commitSettings])
260
+
261
+ const beginMutation = useCallback((kind: Mutation): boolean => {
262
+ if (mutationRef.current !== null) return false
263
+ mutationRef.current = kind
264
+ setMutation(kind)
265
+ return true
266
+ }, [])
267
+ const endMutation = useCallback(() => {
268
+ mutationRef.current = null
269
+ if (mountedRef.current) setMutation(null)
270
+ }, [])
271
+ /** An overlapping mutation is never dropped silently. */
272
+ const reportOverlap = useCallback(() => {
273
+ toast.info(
274
+ mutationRef.current === 'run'
275
+ ? 'Autopilot is still running — try again when it finishes.'
276
+ : 'Still saving the previous change…',
277
+ )
278
+ }, [])
279
+
280
+ /** Resolves `true` when the server accepted the patch. */
281
+ async function performSave(
282
+ patch: SeoAutopilotSettingsPatch,
283
+ successMessage: string,
284
+ ): Promise<boolean> {
285
+ const current = settingsRef.current
286
+ if (!current || !mountedRef.current) return false
287
+ if (!beginMutation('save')) {
288
+ reportOverlap()
289
+ return false
290
+ }
291
+ try {
292
+ // A pause for `owner_unavailable` is repaired by any save (the saver
293
+ // becomes the owner), so clear the stale reason in the same write —
294
+ // except when the save turns autopilot off: "disabled — resumed" would
295
+ // be contradictory and there is nothing to resume.
296
+ const resumes =
297
+ current.pausedAt !== null &&
298
+ current.pausedReason === 'owner_unavailable' &&
299
+ patch.enabled !== false
300
+ const body: SeoAutopilotSettingsPatch = resumes ? { ...patch, resume: true } : patch
301
+ const res = await updateAutopilotSettings(body)
302
+ if (!mountedRef.current) return false
303
+ if (!res.ok) {
304
+ toast.error(res.error ?? 'Failed to update autopilot settings')
305
+ return false
306
+ }
307
+ toast.success(
308
+ resumes && !patch.resume ? `${successMessage} — autopilot resumed` : successMessage,
309
+ )
310
+ if (res.settings) commitSettings(res.settings)
311
+ else await refresh()
312
+ return true
313
+ } finally {
314
+ endMutation()
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Queue a sparse patch behind any save already in flight. Callers await the
320
+ * result to know whether to keep or reset a local draft.
321
+ */
322
+ function save(patch: SeoAutopilotSettingsPatch, successMessage: string): Promise<boolean> {
323
+ const next = saveChainRef.current.then(() => performSave(patch, successMessage))
324
+ saveChainRef.current = next.catch(() => undefined)
325
+ return next
326
+ }
327
+
328
+ async function handleRun() {
329
+ if (!beginMutation('run')) {
330
+ reportOverlap()
331
+ return
332
+ }
333
+ try {
334
+ const res = await runAutopilotNow()
335
+ if (!mountedRef.current) return
336
+ if (!res.ok || !res.summary) {
337
+ toast.error(res.error ?? 'Autopilot run failed')
338
+ return
339
+ }
340
+ runToast(res.summary)
341
+ await refresh()
342
+ } finally {
343
+ endMutation()
344
+ }
345
+ }
346
+
347
+ if (loading) {
348
+ return (
349
+ <SettingsCard title="SEO Autopilot">
350
+ <div aria-busy="true" aria-live="polite">
351
+ <span className="sr-only">Loading autopilot settings…</span>
352
+ <Skeleton lines={4} />
353
+ </div>
354
+ </SettingsCard>
355
+ )
356
+ }
357
+ if (!settings) {
358
+ return (
359
+ <SettingsCard title="SEO Autopilot">
360
+ <div
361
+ role="alert"
362
+ className="border-destructive/30 bg-destructive/10 text-destructive flex items-center gap-3 rounded-lg border p-4 text-sm"
363
+ >
364
+ <AlertTriangle size={16} className="shrink-0" aria-hidden="true" />
365
+ <span className="flex-1">
366
+ Failed to load autopilot settings{loadError ? `: ${loadError}` : ''}.
367
+ </span>
368
+ <button
369
+ type="button"
370
+ onClick={() => void load()}
371
+ className="border-destructive/40 focus-visible:ring-ring inline-flex items-center gap-2 rounded-md border px-3 py-1 transition-colors hover:opacity-80 focus-visible:ring-2 focus-visible:outline-none"
372
+ >
373
+ <RefreshCw size={14} aria-hidden="true" />
374
+ Retry
375
+ </button>
376
+ </div>
377
+ </SettingsCard>
378
+ )
379
+ }
380
+
381
+ const paused = settings.pausedAt !== null
382
+ const pausedTypes = Object.entries(settings.pausedTypes) as Array<[SeoAutopilotIssueType, string]>
383
+ // Inputs lock only for read-only users; a save in flight must not steal
384
+ // focus or swallow the next click (saves queue). Action buttons also lock while busy.
385
+ const disabled = !canEdit
386
+ const actionDisabled = !canEdit || busy
387
+ const running = mutation === 'run'
388
+ const lastRun = settings.lastRun
389
+ const haltNeutral = lastRun?.haltedReason === 'already_running'
390
+
391
+ // Governance veto (spec §2): an enabled type whose AI feature has "Requires
392
+ // approval" on can never pass the gate. Warn only when it actually bites.
393
+ const anyMetaEnabled = AUTOPILOT_TYPE_GROUPS.some((g) =>
394
+ g.options.some((o) => o.id !== 'redirect' && settings.types[o.id] === true),
395
+ )
396
+ const vetoed: string[] = []
397
+ if (anyMetaEnabled && settings.governance.issueFixApprovalRequired) vetoed.push('Issue fixes')
398
+ if (settings.types.redirect === true && settings.governance.redirectApprovalRequired) {
399
+ vetoed.push('404-recovery redirects')
400
+ }
401
+
402
+ // Why "Run now" is unavailable — surfaced as `title` for pointer users and
403
+ // as an sr-only description for keyboard/AT users. `null` when runnable.
404
+ const runHint = !settings.enabled
405
+ ? 'Enable autopilot to run it now'
406
+ : paused
407
+ ? 'Resume autopilot to run it now'
408
+ : null
409
+ const runHintId = `${baseId}-run-hint`
410
+
411
+ const settingsAiLink = (
412
+ <button
413
+ type="button"
414
+ className={LINK_BUTTON_CLASS}
415
+ onClick={() => onNavigate?.('/settings?tab=ai')}
416
+ >
417
+ Settings → AI
418
+ </button>
419
+ )
420
+
421
+ return (
422
+ <SettingsCard
423
+ title="SEO Autopilot"
424
+ description="Apply SEO fixes without a click. Each fix type is opt-in; every unattended change is gated, recorded, and revertible from SEO → Proposals."
425
+ busy={busy}
426
+ actions={
427
+ <>
428
+ <button
429
+ type="button"
430
+ className="bg-primary text-primary-foreground focus-visible:ring-ring inline-flex items-center gap-2 rounded-md px-3 py-2 text-base font-medium transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60"
431
+ onClick={() => void handleRun()}
432
+ disabled={actionDisabled || runHint !== null}
433
+ aria-busy={running || undefined}
434
+ title={runHint ?? undefined}
435
+ aria-describedby={runHint ? runHintId : undefined}
436
+ >
437
+ {running ? (
438
+ <Loader2 size={16} className="motion-safe:animate-spin" aria-hidden="true" />
439
+ ) : (
440
+ <Play size={16} aria-hidden="true" />
441
+ )}
442
+ Run now
443
+ </button>
444
+ {runHint ? (
445
+ <span id={runHintId} className="sr-only">
446
+ {runHint}
447
+ </span>
448
+ ) : null}
449
+ </>
450
+ }
451
+ >
452
+ <div className="flex flex-col gap-5">
453
+ <SettingsToggleRow
454
+ label="Enable autopilot"
455
+ description="Runs daily after the search-data sync. The admin who enables it is the author of record for every change."
456
+ checked={settings.enabled}
457
+ onChange={(v) =>
458
+ void save({ enabled: v }, v ? 'Autopilot enabled' : 'Autopilot disabled')
459
+ }
460
+ disabled={disabled}
461
+ />
462
+
463
+ <div className="flex flex-col gap-1">
464
+ <p
465
+ className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-sm"
466
+ role="status"
467
+ aria-label="Last run"
468
+ >
469
+ <Bot size={16} className="shrink-0" aria-hidden="true" />
470
+ <span>
471
+ {settings.lastRunAt ? (
472
+ <time
473
+ dateTime={settings.lastRunAt}
474
+ title={new Date(settings.lastRunAt).toLocaleString()}
475
+ >
476
+ {lastRunLabel(settings.lastRunAt)}
477
+ </time>
478
+ ) : (
479
+ lastRunLabel(null)
480
+ )}
481
+ </span>
482
+ {lastRun ? (
483
+ <>
484
+ <span aria-hidden="true">·</span>
485
+ <span>{lastRun.applied} applied</span>
486
+ <span aria-hidden="true">·</span>
487
+ <span>{lastRun.verifyFailed} verify-failed</span>
488
+ <span aria-hidden="true">·</span>
489
+ <span>{lastRun.reverted} reverted</span>
490
+ </>
491
+ ) : null}
492
+ </p>
493
+ {lastRun?.halted && lastRun.haltedReason ? (
494
+ <p
495
+ className={`flex items-start gap-2 text-sm ${
496
+ haltNeutral ? 'text-muted-foreground' : 'text-warning'
497
+ }`}
498
+ role="status"
499
+ aria-label="Run stopped"
500
+ >
501
+ {haltNeutral ? (
502
+ <Info size={16} className="mt-0.5 shrink-0" aria-hidden="true" />
503
+ ) : (
504
+ <AlertTriangle size={16} className="mt-0.5 shrink-0" aria-hidden="true" />
505
+ )}
506
+ <span>{haltLabel(lastRun.haltedReason)}.</span>
507
+ </p>
508
+ ) : null}
509
+ </div>
510
+
511
+ {paused ? (
512
+ <section
513
+ className="border-warning/30 bg-warning/10 flex flex-wrap items-center justify-between gap-3 rounded-lg border px-4 py-3"
514
+ aria-label="Autopilot is paused"
515
+ >
516
+ <p className="text-foreground flex items-start gap-2 text-sm">
517
+ <AlertTriangle
518
+ size={16}
519
+ className="text-warning mt-0.5 shrink-0"
520
+ aria-hidden="true"
521
+ />
522
+ <span>
523
+ Autopilot is <span className="font-medium">paused</span>
524
+ {settings.pausedReason ? ` because ${PAUSE_LABEL[settings.pausedReason]}` : ''}.
525
+ {settings.pausedReason === 'owner_unavailable'
526
+ ? ' Saving any setting resumes autopilot with you as the author of record.'
527
+ : ''}
528
+ </span>
529
+ </p>
530
+ <button
531
+ type="button"
532
+ className={SECONDARY_BUTTON_CLASS}
533
+ onClick={() => void save({ resume: true }, 'Autopilot resumed')}
534
+ disabled={actionDisabled}
535
+ >
536
+ Resume
537
+ </button>
538
+ </section>
539
+ ) : null}
540
+
541
+ {pausedTypes.length > 0 ? (
542
+ <ul className="flex flex-col gap-2" aria-label="Paused fix types">
543
+ {pausedTypes.map(([type, reason]) => (
544
+ <li
545
+ key={type}
546
+ className="border-border bg-muted flex flex-wrap items-center justify-between gap-3 rounded-lg border px-4 py-2 text-sm"
547
+ >
548
+ {/* The server writes a complete sentence per paused type; render it verbatim. */}
549
+ <span className="text-foreground">
550
+ <span className="font-medium">{TYPE_LABEL[type] ?? type}</span> — {reason}
551
+ </span>
552
+ <button
553
+ type="button"
554
+ className={SECONDARY_BUTTON_CLASS}
555
+ onClick={() =>
556
+ void save({ resumeTypes: [type] }, `Resumed ${TYPE_LABEL[type] ?? type}`)
557
+ }
558
+ disabled={actionDisabled}
559
+ >
560
+ Resume {TYPE_LABEL[type] ?? type}
561
+ </button>
562
+ </li>
563
+ ))}
564
+ </ul>
565
+ ) : null}
566
+
567
+ <fieldset className="flex flex-col gap-4" disabled={disabled}>
568
+ <legend className="text-foreground mb-2 text-base font-medium">Fix types</legend>
569
+ {AUTOPILOT_TYPE_GROUPS.map((group) => (
570
+ <div key={group.label}>
571
+ <p className="text-muted-foreground mb-2 text-sm font-medium">{group.label}</p>
572
+ <ul className="flex flex-col gap-2">
573
+ {group.options.map((opt) => {
574
+ const inputId = `${baseId}-${opt.id}`
575
+ const helpId = `${inputId}-help`
576
+ const typePaused = settings.pausedTypes[opt.id] !== undefined
577
+ return (
578
+ <li key={opt.id} className="flex items-start gap-3">
579
+ <input
580
+ id={inputId}
581
+ type="checkbox"
582
+ className={CHECKBOX_CLASS}
583
+ checked={settings.types[opt.id] === true}
584
+ onChange={(e) =>
585
+ void save(
586
+ { types: { [opt.id]: e.target.checked } },
587
+ `${opt.label} ${e.target.checked ? 'enabled' : 'disabled'}`,
588
+ )
589
+ }
590
+ disabled={disabled}
591
+ aria-describedby={helpId}
592
+ />
593
+ <div className="min-w-0">
594
+ <div className="flex flex-wrap items-center gap-2">
595
+ <label htmlFor={inputId} className="text-foreground text-sm font-medium">
596
+ {opt.label}
597
+ </label>
598
+ <span className="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-xs font-medium">
599
+ {opt.producer}
600
+ </span>
601
+ {typePaused ? (
602
+ <span className="text-warning inline-flex items-center gap-1 text-xs font-medium">
603
+ <AlertTriangle size={12} aria-hidden="true" />
604
+ Paused
605
+ </span>
606
+ ) : null}
607
+ </div>
608
+ <p id={helpId} className="text-muted-foreground text-sm">
609
+ {opt.help}
610
+ </p>
611
+ </div>
612
+ </li>
613
+ )
614
+ })}
615
+ </ul>
616
+ </div>
617
+ ))}
618
+ </fieldset>
619
+
620
+ <div className="grid gap-4 md:grid-cols-2">
621
+ <NumberField
622
+ id={`${baseId}-brand`}
623
+ label="Minimum brand alignment"
624
+ hint={`${settings.minBrandAlignment} = ${brandAlignmentLabel(settings.minBrandAlignment)}. 0–100.`}
625
+ value={settings.minBrandAlignment}
626
+ min={SEO_AUTOPILOT_LIMITS.minBrandAlignment.min}
627
+ max={SEO_AUTOPILOT_LIMITS.minBrandAlignment.max}
628
+ disabled={disabled}
629
+ onCommit={(v) => save({ minBrandAlignment: v }, 'Brand threshold saved')}
630
+ />
631
+ <NumberField
632
+ id={`${baseId}-redirect`}
633
+ label="Minimum redirect confidence"
634
+ hint="50–100."
635
+ value={settings.redirectMinConfidence}
636
+ min={SEO_AUTOPILOT_LIMITS.redirectMinConfidence.min}
637
+ max={SEO_AUTOPILOT_LIMITS.redirectMinConfidence.max}
638
+ disabled={disabled}
639
+ onCommit={(v) => save({ redirectMinConfidence: v }, 'Redirect threshold saved')}
640
+ />
641
+ <NumberField
642
+ id={`${baseId}-per-run`}
643
+ label="Max changes per run"
644
+ hint="1–25."
645
+ value={settings.maxPerRun}
646
+ min={SEO_AUTOPILOT_LIMITS.maxPerRun.min}
647
+ max={SEO_AUTOPILOT_LIMITS.maxPerRun.max}
648
+ disabled={disabled}
649
+ onCommit={(v) => save({ maxPerRun: v }, 'Per-run cap saved')}
650
+ />
651
+ <NumberField
652
+ id={`${baseId}-per-day`}
653
+ label="Max changes per day"
654
+ hint="1–100."
655
+ value={settings.maxPerDay}
656
+ min={SEO_AUTOPILOT_LIMITS.maxPerDay.min}
657
+ max={SEO_AUTOPILOT_LIMITS.maxPerDay.max}
658
+ disabled={disabled}
659
+ onCommit={(v) => save({ maxPerDay: v }, 'Per-day cap saved')}
660
+ />
661
+ <NumberField
662
+ id={`${baseId}-verify`}
663
+ label="Stop a run after N verification failures"
664
+ hint="1–10."
665
+ value={settings.verifyFailuresPerRun}
666
+ min={SEO_AUTOPILOT_LIMITS.verifyFailuresPerRun.min}
667
+ max={SEO_AUTOPILOT_LIMITS.verifyFailuresPerRun.max}
668
+ disabled={disabled}
669
+ onCommit={(v) => save({ verifyFailuresPerRun: v }, 'Breaker saved')}
670
+ />
671
+ </div>
672
+
673
+ <div className="border-border flex flex-col gap-4 border-t pt-4">
674
+ <SettingsToggleRow
675
+ label="Outcome watch"
676
+ description="Compare Search Console clicks before and after each change; revert and pause the type on a regression."
677
+ checked={settings.outcomeWatch.enabled}
678
+ onChange={(v) =>
679
+ void save(
680
+ { outcomeWatch: { enabled: v } },
681
+ v ? 'Outcome watch enabled' : 'Outcome watch disabled',
682
+ )
683
+ }
684
+ disabled={disabled}
685
+ />
686
+ <div className="grid gap-4 md:grid-cols-3">
687
+ <NumberField
688
+ id={`${baseId}-days`}
689
+ label="Window (days)"
690
+ hint="7–28."
691
+ value={settings.outcomeWatch.days}
692
+ min={SEO_AUTOPILOT_LIMITS.outcomeDays.min}
693
+ max={SEO_AUTOPILOT_LIMITS.outcomeDays.max}
694
+ disabled={disabled || !settings.outcomeWatch.enabled}
695
+ onCommit={(v) => save({ outcomeWatch: { days: v } }, 'Window saved')}
696
+ />
697
+ <NumberField
698
+ id={`${baseId}-drop`}
699
+ label="Regression threshold (%)"
700
+ hint="20–80."
701
+ value={Math.round(settings.outcomeWatch.dropRatio * 100)}
702
+ min={SEO_AUTOPILOT_LIMITS.dropPercent.min}
703
+ max={SEO_AUTOPILOT_LIMITS.dropPercent.max}
704
+ disabled={disabled || !settings.outcomeWatch.enabled}
705
+ onCommit={(v) => save({ outcomeWatch: { dropRatio: v / 100 } }, 'Threshold saved')}
706
+ />
707
+ <NumberField
708
+ id={`${baseId}-prior`}
709
+ label="Minimum prior clicks"
710
+ hint="Pages below this are not judged."
711
+ value={settings.outcomeWatch.minPriorClicks}
712
+ min={SEO_AUTOPILOT_LIMITS.minPriorClicks.min}
713
+ max={SEO_AUTOPILOT_LIMITS.minPriorClicks.max}
714
+ disabled={disabled || !settings.outcomeWatch.enabled}
715
+ onCommit={(v) => save({ outcomeWatch: { minPriorClicks: v } }, 'Floor saved')}
716
+ />
717
+ </div>
718
+ </div>
719
+
720
+ {vetoed.length > 0 ? (
721
+ <section
722
+ className="border-warning/30 bg-warning/10 flex items-start gap-2 rounded-lg border px-4 py-3 text-sm"
723
+ aria-label="Governance warning"
724
+ >
725
+ <AlertTriangle size={16} className="text-warning mt-0.5 shrink-0" aria-hidden="true" />
726
+ <p className="text-foreground">
727
+ {vetoed.join(' and ')} {vetoed.length > 1 ? 'are' : 'is'} enabled here but the AI
728
+ feature still requires approval, so autopilot will skip{' '}
729
+ {vetoed.length > 1 ? 'them' : 'it'}. Turn off “Requires approval” in {settingsAiLink}{' '}
730
+ to let it run.
731
+ </p>
732
+ </section>
733
+ ) : (
734
+ <p className="text-muted-foreground text-sm">
735
+ AI governance still applies: a feature with “Requires approval” on in {settingsAiLink}{' '}
736
+ is never auto-applied, whatever is enabled here.
737
+ </p>
738
+ )}
739
+ </div>
740
+ </SettingsCard>
741
+ )
742
+ }
743
+
744
+ /**
745
+ * A bounded integer input that commits on blur (or Enter). The draft is local
746
+ * so typing never fires a PUT per keystroke; an unchanged or unparsable blur
747
+ * restores the saved value without a request. Out-of-range values are clamped
748
+ * to the server's limits before they are sent. A rejected commit (`onCommit`
749
+ * resolves `false`) falls back to the last saved value so the input never
750
+ * shows text the server refused.
751
+ */
752
+ function NumberField({
753
+ id,
754
+ label,
755
+ hint,
756
+ value,
757
+ min,
758
+ max,
759
+ disabled,
760
+ onCommit,
761
+ }: {
762
+ id: string
763
+ label: string
764
+ hint: string
765
+ value: number
766
+ min: number
767
+ max: number
768
+ disabled: boolean
769
+ onCommit: (v: number) => Promise<boolean>
770
+ }) {
771
+ const [draft, setDraft] = useState(String(value))
772
+ const [syncedValue, setSyncedValue] = useState(value)
773
+ // Adopt a new server value without an effect: reset the draft during render
774
+ // when the prop changes (React's recommended "derive state from props" form).
775
+ if (value !== syncedValue) {
776
+ setSyncedValue(value)
777
+ setDraft(String(value))
778
+ }
779
+ const hintId = `${id}-hint`
780
+
781
+ function commit() {
782
+ const n = Number(draft)
783
+ if (draft.trim() === '' || !Number.isFinite(n)) {
784
+ setDraft(String(value))
785
+ return
786
+ }
787
+ const next = Math.max(min, Math.min(max, Math.round(n)))
788
+ if (next === value) {
789
+ setDraft(String(value))
790
+ return
791
+ }
792
+ void onCommit(next).then((ok) => {
793
+ // On success the parent adopts the server value and the derive-from-props
794
+ // branch above resets the draft; on failure `value` is unchanged, so reset here.
795
+ if (!ok) setDraft(String(value))
796
+ })
797
+ }
798
+
799
+ return (
800
+ <div>
801
+ <label htmlFor={id} className="text-foreground mb-1 block text-sm font-medium">
802
+ {label}
803
+ </label>
804
+ <input
805
+ id={id}
806
+ type="number"
807
+ inputMode="numeric"
808
+ className={INPUT_CLASS}
809
+ min={min}
810
+ max={max}
811
+ step={1}
812
+ value={draft}
813
+ disabled={disabled}
814
+ aria-describedby={hintId}
815
+ onChange={(e) => setDraft(e.target.value)}
816
+ onBlur={commit}
817
+ onKeyDown={(e) => {
818
+ if (e.key === 'Enter') e.currentTarget.blur()
819
+ }}
820
+ />
821
+ <p id={hintId} className="text-muted-foreground mt-1 text-sm">
822
+ {hint}
823
+ </p>
824
+ </div>
825
+ )
826
+ }