@prenta/admin 1.13.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 (100) hide show
  1. package/CHANGELOG.md +31 -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.d.ts +2 -0
  11. package/dist/__tests__/lib/seo-service-proposals.test.d.ts.map +1 -0
  12. package/dist/__tests__/lib/seo-service-proposals.test.js +99 -0
  13. package/dist/__tests__/lib/seo-service-proposals.test.js.map +1 -0
  14. package/dist/__tests__/router/seo-tab-for-path.test.js +1 -0
  15. package/dist/__tests__/router/seo-tab-for-path.test.js.map +1 -1
  16. package/dist/__tests__/views/redirects-pane.render.test.js +30 -12
  17. package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
  18. package/dist/__tests__/views/seo-autopilot-applied.render.test.d.ts +2 -0
  19. package/dist/__tests__/views/seo-autopilot-applied.render.test.d.ts.map +1 -0
  20. package/dist/__tests__/views/seo-autopilot-applied.render.test.js +330 -0
  21. package/dist/__tests__/views/seo-autopilot-applied.render.test.js.map +1 -0
  22. package/dist/__tests__/views/seo-autopilot-card.render.test.d.ts +2 -0
  23. package/dist/__tests__/views/seo-autopilot-card.render.test.d.ts.map +1 -0
  24. package/dist/__tests__/views/seo-autopilot-card.render.test.js +512 -0
  25. package/dist/__tests__/views/seo-autopilot-card.render.test.js.map +1 -0
  26. package/dist/__tests__/views/seo-proposals-tab.render.test.d.ts +2 -0
  27. package/dist/__tests__/views/seo-proposals-tab.render.test.d.ts.map +1 -0
  28. package/dist/__tests__/views/seo-proposals-tab.render.test.js +431 -0
  29. package/dist/__tests__/views/seo-proposals-tab.render.test.js.map +1 -0
  30. package/dist/__tests__/views/seo-settings.render.test.js +41 -4
  31. package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
  32. package/dist/components/seo/ChangePreview.d.ts +6 -0
  33. package/dist/components/seo/ChangePreview.d.ts.map +1 -0
  34. package/dist/components/seo/ChangePreview.js +18 -0
  35. package/dist/components/seo/ChangePreview.js.map +1 -0
  36. package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
  37. package/dist/components/seo/SeoIssueFixPanel.js +1 -14
  38. package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
  39. package/dist/lib/api.d.ts +13 -2
  40. package/dist/lib/api.d.ts.map +1 -1
  41. package/dist/lib/api.js +1 -2
  42. package/dist/lib/api.js.map +1 -1
  43. package/dist/lib/seo-service.d.ts +293 -2
  44. package/dist/lib/seo-service.d.ts.map +1 -1
  45. package/dist/lib/seo-service.js +170 -0
  46. package/dist/lib/seo-service.js.map +1 -1
  47. package/dist/prenta-admin.css +1 -1
  48. package/dist/views/SEO.d.ts +5 -1
  49. package/dist/views/SEO.d.ts.map +1 -1
  50. package/dist/views/SEO.js +28 -9
  51. package/dist/views/SEO.js.map +1 -1
  52. package/dist/views/seo/AutopilotAppliedSection.d.ts +10 -0
  53. package/dist/views/seo/AutopilotAppliedSection.d.ts.map +1 -0
  54. package/dist/views/seo/AutopilotAppliedSection.js +295 -0
  55. package/dist/views/seo/AutopilotAppliedSection.js.map +1 -0
  56. package/dist/views/seo/ProposalsTab.d.ts +7 -0
  57. package/dist/views/seo/ProposalsTab.d.ts.map +1 -0
  58. package/dist/views/seo/ProposalsTab.js +334 -0
  59. package/dist/views/seo/ProposalsTab.js.map +1 -0
  60. package/dist/views/seo/RedirectsTab.d.ts +3 -1
  61. package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
  62. package/dist/views/seo/RedirectsTab.js +14 -19
  63. package/dist/views/seo/RedirectsTab.js.map +1 -1
  64. package/dist/views/seo/relative-age.d.ts +7 -0
  65. package/dist/views/seo/relative-age.d.ts.map +1 -0
  66. package/dist/views/seo/relative-age.js +20 -0
  67. package/dist/views/seo/relative-age.js.map +1 -0
  68. package/dist/views/settings/SeoAutopilotCard.d.ts +30 -0
  69. package/dist/views/settings/SeoAutopilotCard.d.ts.map +1 -0
  70. package/dist/views/settings/SeoAutopilotCard.js +375 -0
  71. package/dist/views/settings/SeoAutopilotCard.js.map +1 -0
  72. package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
  73. package/dist/views/settings/SeoSettingsTab.js +2 -1
  74. package/dist/views/settings/SeoSettingsTab.js.map +1 -1
  75. package/dist/views/settings/components.d.ts +3 -1
  76. package/dist/views/settings/components.d.ts.map +1 -1
  77. package/dist/views/settings/components.js +2 -2
  78. package/dist/views/settings/components.js.map +1 -1
  79. package/package.json +3 -3
  80. package/src/__tests__/lib/api.test.ts +76 -0
  81. package/src/__tests__/lib/seo-service-autopilot.test.ts +196 -0
  82. package/src/__tests__/lib/seo-service-proposals.test.ts +108 -0
  83. package/src/__tests__/router/seo-tab-for-path.test.ts +1 -0
  84. package/src/__tests__/views/redirects-pane.render.test.tsx +36 -16
  85. package/src/__tests__/views/seo-autopilot-applied.render.test.tsx +382 -0
  86. package/src/__tests__/views/seo-autopilot-card.render.test.tsx +610 -0
  87. package/src/__tests__/views/seo-proposals-tab.render.test.tsx +531 -0
  88. package/src/__tests__/views/seo-settings.render.test.tsx +47 -7
  89. package/src/components/seo/ChangePreview.tsx +47 -0
  90. package/src/components/seo/SeoIssueFixPanel.tsx +1 -44
  91. package/src/lib/api.ts +19 -3
  92. package/src/lib/seo-service.ts +455 -2
  93. package/src/views/SEO.tsx +41 -8
  94. package/src/views/seo/AutopilotAppliedSection.tsx +597 -0
  95. package/src/views/seo/ProposalsTab.tsx +761 -0
  96. package/src/views/seo/RedirectsTab.tsx +39 -60
  97. package/src/views/seo/relative-age.ts +15 -0
  98. package/src/views/settings/SeoAutopilotCard.tsx +826 -0
  99. package/src/views/settings/SeoSettingsTab.tsx +2 -0
  100. package/src/views/settings/components.tsx +10 -1
@@ -0,0 +1,761 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * SEO → Proposals: every cached AI issue fix and open 404-redirect suggestion
5
+ * in one review surface. Batch approve honours server governance
6
+ * (`allowBulkApply`); the UI only pre-disables the button using the flags the
7
+ * list payload exposes. Generation is human-triggered, capped per pass, and
8
+ * loops client-side while the server reports `remaining > 0`.
9
+ */
10
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
11
+ import {
12
+ AlertTriangle,
13
+ ArrowRightLeft,
14
+ Bot,
15
+ Check,
16
+ ChevronDown,
17
+ Inbox,
18
+ Loader2,
19
+ Sparkles,
20
+ Square,
21
+ X,
22
+ } from 'lucide-react'
23
+ import { toast } from 'sonner'
24
+ import {
25
+ bulkApplySeoProposals,
26
+ dismissRedirectSuggestion,
27
+ dismissSeoIssueFix,
28
+ fetchSeoProposals,
29
+ generateSeoProposals,
30
+ type SeoProposal,
31
+ type SeoProposalBulkApplyItem,
32
+ type SeoProposalsPayload,
33
+ } from '../../lib/seo-service.js'
34
+ import { hasPlanFeature, type PlanInfo } from '../../lib/plan.js'
35
+ import { brandAlignmentLabel, brandAlignmentTone } from '../../lib/brand-voice-client.js'
36
+ import { PlanUpgradeCallout } from '../../components/PlanUpgradeCallout.js'
37
+ import { ChangePreview } from '../../components/seo/ChangePreview.js'
38
+ import {
39
+ SectionCard,
40
+ SeoEmptyState,
41
+ SeoErrorState,
42
+ SeoLoading,
43
+ SeoStatusBadge,
44
+ btnPrimary,
45
+ btnSecondary,
46
+ } from '../../components/seo/primitives.js'
47
+ import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
48
+ import { AutopilotAppliedSection } from './AutopilotAppliedSection.js'
49
+ import { relativeAge } from './relative-age.js'
50
+ import { useSeoResource } from './useSeoResource.js'
51
+
52
+ const GENERATE_BATCH_SIZE = 10
53
+ const AI_SETTINGS_PATH = '/settings?tab=ai'
54
+ const SEO_SETTINGS_PATH = '/settings?tab=seo'
55
+ /** Stable empty list so memoised derivations don't recompute while loading. */
56
+ const NO_PROPOSALS: SeoProposal[] = []
57
+
58
+ /** The one mutation allowed in flight at a time; every control is disabled while set. */
59
+ type Mutation = 'apply' | 'dismiss' | 'generate'
60
+
61
+ function errorMessage(err: unknown): string {
62
+ return err instanceof Error && err.message ? err.message : 'Request failed.'
63
+ }
64
+
65
+ function confidenceTone(c: number): 'good' | 'fair' | 'poor' {
66
+ return c >= 0.7 ? 'good' : c >= 0.4 ? 'fair' : 'poor'
67
+ }
68
+
69
+ function plural(count: number, noun: string): string {
70
+ return `${count} ${noun}${count === 1 ? '' : 's'}`
71
+ }
72
+
73
+ function toBulkItem(p: SeoProposal): SeoProposalBulkApplyItem {
74
+ return p.kind === 'redirect'
75
+ ? { kind: 'redirect', id: p.suggestionId ?? '' }
76
+ : { kind: 'issue-fix', id: p.issueId ?? '', fingerprint: p.fingerprint ?? undefined }
77
+ }
78
+
79
+ function BrandScoreBadge({ score }: { score: number }) {
80
+ return (
81
+ <span
82
+ className="border-border bg-muted/40 inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs"
83
+ aria-label={`Brand voice score ${score} out of 100`}
84
+ >
85
+ <span className={`font-medium ${brandAlignmentTone(score)}`}>{score}/100</span>
86
+ <span className="text-muted-foreground">{brandAlignmentLabel(score)}</span>
87
+ </span>
88
+ )
89
+ }
90
+
91
+ function SourceBadge({ source }: { source: SeoProposal['source'] }) {
92
+ switch (source) {
93
+ case 'ai':
94
+ return (
95
+ <span className="bg-primary/10 text-primary inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium">
96
+ <Sparkles className="h-3 w-3" aria-hidden />
97
+ AI
98
+ </span>
99
+ )
100
+ case 'ai-404-recovery':
101
+ return (
102
+ <span className="bg-primary/10 text-primary inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium">
103
+ <ArrowRightLeft className="h-3 w-3" aria-hidden />
104
+ 404 recovery
105
+ </span>
106
+ )
107
+ case 'deterministic':
108
+ return (
109
+ <span className="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-xs font-medium">
110
+ Rule-based
111
+ </span>
112
+ )
113
+ default: {
114
+ const _exhaustive: never = source
115
+ return _exhaustive
116
+ }
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Short chip wording for the engine-level pause reasons the server emits
122
+ * (`AutopilotPauseReason`). An unknown value falls through verbatim so a new
123
+ * reason is still visible rather than hidden behind a generic label.
124
+ */
125
+ const CHIP_PAUSE_LABEL: Record<string, string> = {
126
+ outcome_regression: 'traffic regression',
127
+ owner_unavailable: 'owner unavailable',
128
+ manual: 'paused by admin',
129
+ }
130
+
131
+ function chipPauseLabel(reason: string): string {
132
+ return CHIP_PAUSE_LABEL[reason] ?? reason
133
+ }
134
+
135
+ /**
136
+ * Header chip summarising the autopilot's state. Hidden while autopilot is off
137
+ * (nothing to report — including an engine disabled while it was still paused,
138
+ * since `PUT { enabled: false }` does not clear the pause); a paused engine
139
+ * gets the warning tone and names the reason. Clicking opens the Autopilot
140
+ * card in Settings → SEO.
141
+ */
142
+ function AutopilotChip({
143
+ autopilot,
144
+ onNavigate,
145
+ }: {
146
+ autopilot: SeoProposalsPayload['autopilot']
147
+ onNavigate?: (path: string) => void
148
+ }) {
149
+ if (!autopilot.enabled) return null
150
+ const paused = autopilot.pausedReason !== null
151
+ const label =
152
+ autopilot.pausedReason !== null
153
+ ? `Autopilot paused — ${chipPauseLabel(autopilot.pausedReason)}`
154
+ : `Autopilot on · ${autopilot.appliedLast7d} applied this week`
155
+ const tone = paused
156
+ ? 'border-warning/40 bg-warning/10 text-foreground hover:bg-warning/20'
157
+ : 'border-border bg-muted/40 text-foreground hover:bg-accent'
158
+ return (
159
+ <button
160
+ type="button"
161
+ className={`focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none ${tone}`}
162
+ onClick={() => onNavigate?.(SEO_SETTINGS_PATH)}
163
+ >
164
+ {paused ? (
165
+ <AlertTriangle className="text-warning h-3.5 w-3.5" aria-hidden />
166
+ ) : (
167
+ <Bot className="h-3.5 w-3.5" aria-hidden />
168
+ )}
169
+ {label}
170
+ </button>
171
+ )
172
+ }
173
+
174
+ export function ProposalsTab({
175
+ planInfo,
176
+ onChanged,
177
+ onNavigate,
178
+ }: {
179
+ planInfo: PlanInfo | null
180
+ onChanged?: () => void
181
+ onNavigate?: (path: string) => void
182
+ }) {
183
+ const { data, loading, error, refetch } = useSeoResource(fetchSeoProposals, [])
184
+ const [selected, setSelected] = useState<Set<string>>(new Set())
185
+ const [expanded, setExpanded] = useState<Set<string>>(new Set())
186
+ const [confirmApply, setConfirmApply] = useState(false)
187
+ const [confirmGenerate, setConfirmGenerate] = useState(false)
188
+ const [mutation, setMutation] = useState<Mutation | null>(null)
189
+ const [pendingItems, setPendingItems] = useState<SeoProposalBulkApplyItem[]>([])
190
+ const [generating, setGenerating] = useState<{ done: number; total: number } | null>(null)
191
+ /** Bumped after every mutation so the autopilot ledger below refetches with the inbox. */
192
+ const [refreshKey, setRefreshKey] = useState(0)
193
+ const stopRef = useRef(false)
194
+ /** Mirrors `mutation` synchronously so a second call in the same tick cannot slip past. */
195
+ const mutationRef = useRef<Mutation | null>(null)
196
+ const mountedRef = useRef(true)
197
+ const busy = mutation !== null
198
+
199
+ useEffect(() => {
200
+ mountedRef.current = true
201
+ return () => {
202
+ mountedRef.current = false
203
+ stopRef.current = true
204
+ }
205
+ }, [])
206
+
207
+ /** Claims the mutation slot; returns false when another mutation is already running. */
208
+ const beginMutation = useCallback((kind: Mutation): boolean => {
209
+ if (mutationRef.current !== null) return false
210
+ mutationRef.current = kind
211
+ setMutation(kind)
212
+ return true
213
+ }, [])
214
+ const endMutation = useCallback(() => {
215
+ mutationRef.current = null
216
+ if (mountedRef.current) setMutation(null)
217
+ }, [])
218
+
219
+ const inlineApplyEnabled = hasPlanFeature(planInfo, 'seo.inlineApply')
220
+ const proposals = data?.proposals ?? NO_PROPOSALS
221
+ const live = useMemo(() => proposals.filter((p) => !p.stale), [proposals])
222
+ const selectedProposals = useMemo(() => live.filter((p) => selected.has(p.id)), [live, selected])
223
+
224
+ const bulkBlockedReason = useMemo(() => {
225
+ if (!data || selectedProposals.length <= 1) return null
226
+ const hasIssue = selectedProposals.some((p) => p.kind === 'issue-fix')
227
+ const hasRedirect = selectedProposals.some((p) => p.kind === 'redirect')
228
+ if (hasIssue && !data.governance.issueFixBulk) return 'SEO metadata generation'
229
+ if (hasRedirect && !data.governance.redirectBulk) return '404 recovery'
230
+ return null
231
+ }, [data, selectedProposals])
232
+
233
+ const allSelected = live.length > 0 && selected.size === live.length
234
+ const toggleSelectAll = useCallback(() => {
235
+ setSelected((cur) => (cur.size === live.length ? new Set() : new Set(live.map((p) => p.id))))
236
+ }, [live])
237
+ const toggleSelect = useCallback((id: string) => {
238
+ setSelected((cur) => {
239
+ const next = new Set(cur)
240
+ if (next.has(id)) next.delete(id)
241
+ else next.add(id)
242
+ return next
243
+ })
244
+ }, [])
245
+ const toggleExpanded = useCallback((id: string) => {
246
+ setExpanded((cur) => {
247
+ const next = new Set(cur)
248
+ if (next.has(id)) next.delete(id)
249
+ else next.add(id)
250
+ return next
251
+ })
252
+ }, [])
253
+
254
+ const afterMutation = useCallback(() => {
255
+ setSelected(new Set())
256
+ refetch()
257
+ setRefreshKey((k) => k + 1)
258
+ onChanged?.()
259
+ }, [refetch, onChanged])
260
+
261
+ /**
262
+ * A revert in the ledger reopens its issue / suggestion server-side, so the
263
+ * inbox and the SEO page refetch. Not `afterMutation`: that bumps
264
+ * `refreshKey`, and the ledger already reloads itself after a revert.
265
+ */
266
+ const handleLedgerChanged = useCallback(() => {
267
+ refetch()
268
+ onChanged?.()
269
+ }, [refetch, onChanged])
270
+
271
+ const runApply = useCallback(
272
+ async (items: SeoProposalBulkApplyItem[]) => {
273
+ if (items.length === 0) return
274
+ if (!beginMutation('apply')) return
275
+ try {
276
+ const res = await bulkApplySeoProposals(items)
277
+ if (!mountedRef.current) return
278
+ const result = res.result
279
+ if (res.error || !result) {
280
+ toast.error(res.error ?? 'Unexpected response from the server.')
281
+ return
282
+ }
283
+ if (result.applied > 0) {
284
+ toast.success(`${plural(result.applied, 'proposal')} applied.`)
285
+ }
286
+ if (result.failed > 0) {
287
+ const reasons = result.results
288
+ .filter((r) => r.status !== 'applied' && r.status !== 'pending')
289
+ .map((r) => `${r.id}: ${r.reason ?? r.status}`)
290
+ .slice(0, 3)
291
+ .join('; ')
292
+ toast.error(`${result.failed} could not be applied. ${reasons}`)
293
+ }
294
+ const pendingIds = new Set(
295
+ result.results.filter((r) => r.status === 'pending').map((r) => r.id),
296
+ )
297
+ setPendingItems(items.filter((i) => pendingIds.has(i.id)))
298
+ afterMutation()
299
+ } catch (err) {
300
+ if (mountedRef.current) toast.error(errorMessage(err))
301
+ } finally {
302
+ endMutation()
303
+ }
304
+ },
305
+ [afterMutation, beginMutation, endMutation],
306
+ )
307
+
308
+ const handleDismissSelected = useCallback(async () => {
309
+ const targets = selectedProposals
310
+ if (targets.length === 0) return
311
+ if (!beginMutation('dismiss')) return
312
+ try {
313
+ const results = await Promise.allSettled(
314
+ targets.map((p) =>
315
+ p.kind === 'redirect'
316
+ ? dismissRedirectSuggestion(p.suggestionId ?? '')
317
+ : dismissSeoIssueFix(p.issueId ?? ''),
318
+ ),
319
+ )
320
+ if (!mountedRef.current) return
321
+ const failed = results.filter((r) => r.status === 'rejected' || r.value.error).length
322
+ if (failed > 0) toast.error(`${failed} of ${targets.length} could not be dismissed.`)
323
+ else toast.success(`${plural(targets.length, 'proposal')} dismissed.`)
324
+ afterMutation()
325
+ } finally {
326
+ endMutation()
327
+ }
328
+ }, [selectedProposals, afterMutation, beginMutation, endMutation])
329
+
330
+ const handleDismissOne = useCallback(
331
+ async (p: SeoProposal) => {
332
+ if (!beginMutation('dismiss')) return
333
+ try {
334
+ const res =
335
+ p.kind === 'redirect'
336
+ ? await dismissRedirectSuggestion(p.suggestionId ?? '')
337
+ : await dismissSeoIssueFix(p.issueId ?? '')
338
+ if (!mountedRef.current) return
339
+ if (res.error) {
340
+ toast.error(res.error)
341
+ return
342
+ }
343
+ afterMutation()
344
+ } catch (err) {
345
+ if (mountedRef.current) toast.error(errorMessage(err))
346
+ } finally {
347
+ endMutation()
348
+ }
349
+ },
350
+ [afterMutation, beginMutation, endMutation],
351
+ )
352
+
353
+ const handleGenerate = useCallback(async () => {
354
+ if (!beginMutation('generate')) return
355
+ const eligible = data?.counts.eligibleForGeneration ?? 0
356
+ stopRef.current = false
357
+ let total = 0
358
+ let done = 0
359
+ let transportError = false
360
+ setGenerating({ done: 0, total: eligible })
361
+ try {
362
+ for (;;) {
363
+ const res = await generateSeoProposals()
364
+ // The tab may have unmounted mid-pass; stop issuing requests and stay silent.
365
+ if (!mountedRef.current) return
366
+ const r = res.result
367
+ if (res.error || !r) {
368
+ toast.error(res.error ?? 'Unexpected response from the server.')
369
+ transportError = true
370
+ break
371
+ }
372
+ total += r.generated
373
+ done += r.generated + r.skipped + r.failed
374
+ setGenerating({ done, total: eligible })
375
+ if (r.haltedReason) {
376
+ toast.error(`Generation stopped: ${r.haltedReason}`)
377
+ break
378
+ }
379
+ if (r.failed > 0) {
380
+ toast.error(`${plural(r.failed, 'issue')} failed to generate.`)
381
+ break
382
+ }
383
+ if (r.remaining <= 0 || stopRef.current) break
384
+ }
385
+ if (!transportError) toast.success(`${plural(total, 'proposal')} generated.`)
386
+ afterMutation()
387
+ } catch (err) {
388
+ if (mountedRef.current) toast.error(errorMessage(err))
389
+ } finally {
390
+ if (mountedRef.current) setGenerating(null)
391
+ endMutation()
392
+ }
393
+ }, [data, afterMutation, beginMutation, endMutation])
394
+
395
+ const eligible = data?.counts.eligibleForGeneration ?? 0
396
+
397
+ const generateAction = inlineApplyEnabled ? (
398
+ generating ? (
399
+ <div className="flex items-center gap-2">
400
+ <span
401
+ className="text-muted-foreground flex items-center gap-2 text-sm"
402
+ role="status"
403
+ aria-live="polite"
404
+ >
405
+ <Loader2 className="h-4 w-4 motion-safe:animate-spin" aria-hidden />
406
+ Generating… {generating.done} of {generating.total}
407
+ </span>
408
+ <button
409
+ type="button"
410
+ className={btnSecondary}
411
+ onClick={() => {
412
+ stopRef.current = true
413
+ }}
414
+ >
415
+ <Square className="h-4 w-4" aria-hidden />
416
+ Stop
417
+ </button>
418
+ </div>
419
+ ) : (
420
+ <button
421
+ type="button"
422
+ className={btnPrimary}
423
+ onClick={() => setConfirmGenerate(true)}
424
+ disabled={eligible === 0 || loading || busy}
425
+ >
426
+ <Sparkles className="h-4 w-4" aria-hidden />
427
+ Generate proposals ({eligible} eligible)
428
+ </button>
429
+ )
430
+ ) : null
431
+
432
+ // `data?.autopilot` (not just `data`): a core deployed before autopilot omits the field.
433
+ const autopilotChip = data?.autopilot ? (
434
+ <AutopilotChip autopilot={data.autopilot} onNavigate={onNavigate} />
435
+ ) : null
436
+ const headerAction =
437
+ autopilotChip || generateAction ? (
438
+ <div className="flex flex-wrap items-center justify-end gap-2">
439
+ {autopilotChip}
440
+ {generateAction}
441
+ </div>
442
+ ) : undefined
443
+
444
+ return (
445
+ <div className="space-y-6">
446
+ {!inlineApplyEnabled ? (
447
+ <PlanUpgradeCallout feature="seo.inlineApply" upgradeUrl={planInfo?.upgradeUrl} />
448
+ ) : null}
449
+
450
+ <SectionCard
451
+ title="Proposals"
452
+ description="Review, approve, or dismiss what the AI proposes across issue fixes and 404 redirects."
453
+ action={headerAction}
454
+ >
455
+ {loading ? (
456
+ <SeoLoading />
457
+ ) : error ? (
458
+ <SeoErrorState message={error} onRetry={refetch} />
459
+ ) : proposals.length === 0 ? (
460
+ <SeoEmptyState
461
+ icon={<Inbox size={24} />}
462
+ title="No proposals yet"
463
+ description="Generate proposals to draft fixes for open, fixable issues, or run 404 recovery from the Redirects tab."
464
+ />
465
+ ) : (
466
+ <div className="space-y-3">
467
+ {pendingItems.length > 0 && (
468
+ <div
469
+ className="border-border bg-muted/40 flex flex-wrap items-center gap-2 rounded-lg border px-3 py-2 text-sm"
470
+ role="status"
471
+ aria-live="polite"
472
+ >
473
+ <span className="text-foreground">
474
+ {plural(pendingItems.length, 'proposal')} not reached within the time budget.
475
+ </span>
476
+ <button
477
+ type="button"
478
+ className="text-primary hover:bg-accent focus-visible:ring-ring rounded-md px-2 py-1 font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-60"
479
+ onClick={() => void runApply(pendingItems)}
480
+ disabled={busy}
481
+ >
482
+ Apply remaining ({pendingItems.length})
483
+ </button>
484
+ <button
485
+ type="button"
486
+ className="text-muted-foreground hover:text-foreground focus-visible:ring-ring ml-auto rounded-md px-2 py-1 font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
487
+ onClick={() => setPendingItems([])}
488
+ >
489
+ Dismiss
490
+ </button>
491
+ </div>
492
+ )}
493
+
494
+ {selected.size > 0 && (
495
+ <div
496
+ className="border-border bg-accent/40 flex flex-wrap items-center gap-2 rounded-lg border px-3 py-2 text-sm"
497
+ role="region"
498
+ aria-label="Bulk actions"
499
+ >
500
+ <span className="text-foreground font-medium">{selected.size} selected</span>
501
+ <span className="bg-border mx-1 h-4 w-px" aria-hidden />
502
+ <button
503
+ type="button"
504
+ className="text-foreground hover:bg-accent focus-visible:ring-ring rounded-md px-2 py-1 font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-60"
505
+ onClick={() => setConfirmApply(true)}
506
+ disabled={busy || bulkBlockedReason !== null}
507
+ >
508
+ Approve selected
509
+ </button>
510
+ <button
511
+ type="button"
512
+ className="text-foreground hover:bg-accent focus-visible:ring-ring rounded-md px-2 py-1 font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-60"
513
+ onClick={() => void handleDismissSelected()}
514
+ disabled={busy}
515
+ >
516
+ Dismiss selected
517
+ </button>
518
+ <button
519
+ type="button"
520
+ className="text-muted-foreground hover:text-foreground focus-visible:ring-ring ml-auto rounded-md px-2 py-1 font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
521
+ onClick={() => setSelected(new Set())}
522
+ >
523
+ Clear
524
+ </button>
525
+ {bulkBlockedReason && (
526
+ <p className="text-muted-foreground basis-full text-sm" role="status">
527
+ Applying several {bulkBlockedReason} proposals at once needs “Allow bulk apply”.
528
+ {onNavigate ? (
529
+ <button
530
+ type="button"
531
+ onClick={() => onNavigate(AI_SETTINGS_PATH)}
532
+ className="text-primary focus-visible:ring-ring ml-1 rounded-sm font-medium hover:underline focus-visible:ring-2 focus-visible:outline-none"
533
+ >
534
+ Open Settings → AI
535
+ </button>
536
+ ) : null}
537
+ </p>
538
+ )}
539
+ </div>
540
+ )}
541
+
542
+ <div className="border-border overflow-x-auto rounded-md border">
543
+ <table className="w-full text-sm" aria-label="AI proposals">
544
+ <thead>
545
+ <tr className="border-border bg-muted/40 text-muted-foreground border-b text-left text-xs">
546
+ <th scope="col" className="w-10 px-3 py-2.5">
547
+ <input
548
+ type="checkbox"
549
+ checked={allSelected}
550
+ onChange={toggleSelectAll}
551
+ disabled={live.length === 0}
552
+ aria-label="Select all proposals"
553
+ className="border-border text-primary focus-visible:ring-ring h-4 w-4 rounded"
554
+ />
555
+ </th>
556
+ <th scope="col" className="px-3 py-2.5 font-medium">
557
+ Proposal
558
+ </th>
559
+ <th scope="col" className="px-3 py-2.5 font-medium">
560
+ Change
561
+ </th>
562
+ <th scope="col" className="px-3 py-2.5 font-medium">
563
+ Signals
564
+ </th>
565
+ <th scope="col" className="px-3 py-2.5 font-medium">
566
+ Age
567
+ </th>
568
+ <th scope="col" className="px-3 py-2.5">
569
+ <span className="sr-only">Actions</span>
570
+ </th>
571
+ </tr>
572
+ </thead>
573
+ <tbody className="divide-border divide-y">
574
+ {proposals.map((p) => {
575
+ const first = p.changes[0]
576
+ return (
577
+ <ProposalRow
578
+ key={p.id}
579
+ proposal={p}
580
+ selected={selected.has(p.id)}
581
+ expanded={expanded.has(p.id)}
582
+ summary={
583
+ first
584
+ ? `${String(first.before ?? '—')} → ${String(first.after ?? '—')}`
585
+ : p.justification
586
+ }
587
+ onToggleSelect={() => toggleSelect(p.id)}
588
+ onToggleExpanded={() => toggleExpanded(p.id)}
589
+ onApprove={() => void runApply([toBulkItem(p)])}
590
+ onDismiss={() => void handleDismissOne(p)}
591
+ busy={busy}
592
+ canApply={inlineApplyEnabled || p.kind === 'redirect'}
593
+ />
594
+ )
595
+ })}
596
+ </tbody>
597
+ </table>
598
+ </div>
599
+ </div>
600
+ )}
601
+ </SectionCard>
602
+
603
+ <AutopilotAppliedSection
604
+ onNavigate={onNavigate}
605
+ refreshKey={refreshKey}
606
+ onChanged={handleLedgerChanged}
607
+ />
608
+
609
+ <ConfirmDialog
610
+ open={confirmApply}
611
+ onClose={() => setConfirmApply(false)}
612
+ onConfirm={() => void runApply(selectedProposals.map(toBulkItem))}
613
+ title={`Apply ${plural(selectedProposals.length, 'proposal')}?`}
614
+ description="Approved fixes write SEO fields, content links, or redirects immediately and mark their issues resolved. Each document keeps a version you can restore."
615
+ confirmLabel={`Apply ${plural(selectedProposals.length, 'proposal')}`}
616
+ />
617
+ <ConfirmDialog
618
+ open={confirmGenerate}
619
+ onClose={() => setConfirmGenerate(false)}
620
+ onConfirm={() => void handleGenerate()}
621
+ title={`Generate proposals for ${plural(eligible, 'eligible issue')}?`}
622
+ description={`Runs in passes of ${GENERATE_BATCH_SIZE}. AI tokens are spent for each issue; you can stop between passes.`}
623
+ confirmLabel="Generate"
624
+ />
625
+ </div>
626
+ )
627
+ }
628
+
629
+ function ProposalRow({
630
+ proposal: p,
631
+ selected,
632
+ expanded,
633
+ summary,
634
+ onToggleSelect,
635
+ onToggleExpanded,
636
+ onApprove,
637
+ onDismiss,
638
+ busy,
639
+ canApply,
640
+ }: {
641
+ proposal: SeoProposal
642
+ selected: boolean
643
+ expanded: boolean
644
+ summary: string
645
+ onToggleSelect: () => void
646
+ onToggleExpanded: () => void
647
+ onApprove: () => void
648
+ onDismiss: () => void
649
+ busy: boolean
650
+ canApply: boolean
651
+ }) {
652
+ return (
653
+ <>
654
+ <tr className={`${selected ? 'bg-accent/30' : ''} hover:bg-accent/20 transition-colors`}>
655
+ <td className="px-3 py-3 align-top">
656
+ {p.stale ? null : (
657
+ <input
658
+ type="checkbox"
659
+ checked={selected}
660
+ onChange={onToggleSelect}
661
+ aria-label={`Select proposal ${p.title}`}
662
+ className="border-border text-primary focus-visible:ring-ring h-4 w-4 rounded"
663
+ />
664
+ )}
665
+ </td>
666
+ <td className="px-3 py-3 align-top">
667
+ <div className="flex flex-wrap items-center gap-2">
668
+ <span className="text-foreground font-medium">{p.title}</span>
669
+ {p.severity ? (
670
+ <SeoStatusBadge
671
+ label={p.severity}
672
+ tone={
673
+ p.severity === 'critical'
674
+ ? 'critical'
675
+ : p.severity === 'warning'
676
+ ? 'fair'
677
+ : 'neutral'
678
+ }
679
+ />
680
+ ) : null}
681
+ {p.stale ? <SeoStatusBadge label="Stale — regenerate" tone="poor" /> : null}
682
+ </div>
683
+ {p.entity.title || p.entity.url ? (
684
+ <p className="text-muted-foreground mt-0.5 text-xs">
685
+ {p.entity.title ?? ''}
686
+ {p.entity.title && p.entity.url ? ' · ' : ''}
687
+ {p.entity.url ?? ''}
688
+ </p>
689
+ ) : null}
690
+ </td>
691
+ <td className="px-3 py-3 align-top">
692
+ <p className="text-foreground max-w-md truncate">{summary}</p>
693
+ <button
694
+ type="button"
695
+ className="text-primary hover:bg-accent focus-visible:ring-ring mt-1 inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs focus-visible:ring-2 focus-visible:outline-none"
696
+ onClick={onToggleExpanded}
697
+ aria-expanded={expanded}
698
+ aria-label={`${expanded ? 'Hide' : 'Show'} changes for ${p.title}`}
699
+ >
700
+ {expanded ? 'Hide changes' : 'Show changes'}
701
+ <ChevronDown
702
+ className={`h-3 w-3 motion-safe:transition-transform ${expanded ? 'rotate-180' : ''}`}
703
+ aria-hidden
704
+ />
705
+ </button>
706
+ </td>
707
+ <td className="px-3 py-3 align-top">
708
+ <div className="flex flex-wrap items-center gap-2">
709
+ <SourceBadge source={p.source} />
710
+ {typeof p.confidence === 'number' ? (
711
+ <SeoStatusBadge
712
+ label={`${Math.round(p.confidence * 100)}%`}
713
+ tone={confidenceTone(p.confidence)}
714
+ />
715
+ ) : null}
716
+ {typeof p.brandAlignment === 'number' ? (
717
+ <BrandScoreBadge score={p.brandAlignment} />
718
+ ) : null}
719
+ </div>
720
+ </td>
721
+ <td className="text-muted-foreground px-3 py-3 align-top text-xs whitespace-nowrap">
722
+ {relativeAge(p.generatedAt)}
723
+ </td>
724
+ <td className="px-3 py-3 align-top">
725
+ <div className="flex items-center justify-end gap-1">
726
+ {!p.stale && canApply ? (
727
+ <button
728
+ type="button"
729
+ onClick={onApprove}
730
+ disabled={busy}
731
+ className="text-success hover:bg-accent focus-visible:ring-ring rounded-md p-1.5 focus-visible:ring-2 focus-visible:outline-none disabled:opacity-60"
732
+ aria-label={`Approve ${p.title}`}
733
+ >
734
+ <Check className="h-4 w-4" aria-hidden />
735
+ </button>
736
+ ) : null}
737
+ <button
738
+ type="button"
739
+ onClick={onDismiss}
740
+ disabled={busy}
741
+ className="text-muted-foreground hover:bg-accent focus-visible:ring-ring rounded-md p-1.5 focus-visible:ring-2 focus-visible:outline-none disabled:opacity-60"
742
+ aria-label={`Dismiss ${p.title}`}
743
+ >
744
+ <X className="h-4 w-4" aria-hidden />
745
+ </button>
746
+ </div>
747
+ </td>
748
+ </tr>
749
+ {expanded ? (
750
+ <tr>
751
+ <td colSpan={6} className="bg-muted/30 px-4 py-4">
752
+ <div className="space-y-3">
753
+ <p className="text-foreground text-sm">{p.justification}</p>
754
+ <ChangePreview changes={p.changes} />
755
+ </div>
756
+ </td>
757
+ </tr>
758
+ ) : null}
759
+ </>
760
+ )
761
+ }