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