@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
@@ -9,7 +9,9 @@
9
9
  */
10
10
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
11
11
  import {
12
+ AlertTriangle,
12
13
  ArrowRightLeft,
14
+ Bot,
13
15
  Check,
14
16
  ChevronDown,
15
17
  Inbox,
@@ -27,6 +29,7 @@ import {
27
29
  generateSeoProposals,
28
30
  type SeoProposal,
29
31
  type SeoProposalBulkApplyItem,
32
+ type SeoProposalsPayload,
30
33
  } from '../../lib/seo-service.js'
31
34
  import { hasPlanFeature, type PlanInfo } from '../../lib/plan.js'
32
35
  import { brandAlignmentLabel, brandAlignmentTone } from '../../lib/brand-voice-client.js'
@@ -42,10 +45,13 @@ import {
42
45
  btnSecondary,
43
46
  } from '../../components/seo/primitives.js'
44
47
  import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
48
+ import { AutopilotAppliedSection } from './AutopilotAppliedSection.js'
49
+ import { relativeAge } from './relative-age.js'
45
50
  import { useSeoResource } from './useSeoResource.js'
46
51
 
47
52
  const GENERATE_BATCH_SIZE = 10
48
53
  const AI_SETTINGS_PATH = '/settings?tab=ai'
54
+ const SEO_SETTINGS_PATH = '/settings?tab=seo'
49
55
  /** Stable empty list so memoised derivations don't recompute while loading. */
50
56
  const NO_PROPOSALS: SeoProposal[] = []
51
57
 
@@ -56,17 +62,6 @@ function errorMessage(err: unknown): string {
56
62
  return err instanceof Error && err.message ? err.message : 'Request failed.'
57
63
  }
58
64
 
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
65
  function confidenceTone(c: number): 'good' | 'fair' | 'poor' {
71
66
  return c >= 0.7 ? 'good' : c >= 0.4 ? 'fair' : 'poor'
72
67
  }
@@ -122,6 +117,60 @@ function SourceBadge({ source }: { source: SeoProposal['source'] }) {
122
117
  }
123
118
  }
124
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
+
125
174
  export function ProposalsTab({
126
175
  planInfo,
127
176
  onChanged,
@@ -139,6 +188,8 @@ export function ProposalsTab({
139
188
  const [mutation, setMutation] = useState<Mutation | null>(null)
140
189
  const [pendingItems, setPendingItems] = useState<SeoProposalBulkApplyItem[]>([])
141
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)
142
193
  const stopRef = useRef(false)
143
194
  /** Mirrors `mutation` synchronously so a second call in the same tick cannot slip past. */
144
195
  const mutationRef = useRef<Mutation | null>(null)
@@ -202,6 +253,17 @@ export function ProposalsTab({
202
253
 
203
254
  const afterMutation = useCallback(() => {
204
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(() => {
205
267
  refetch()
206
268
  onChanged?.()
207
269
  }, [refetch, onChanged])
@@ -367,6 +429,18 @@ export function ProposalsTab({
367
429
  )
368
430
  ) : null
369
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
+
370
444
  return (
371
445
  <div className="space-y-6">
372
446
  {!inlineApplyEnabled ? (
@@ -376,7 +450,7 @@ export function ProposalsTab({
376
450
  <SectionCard
377
451
  title="Proposals"
378
452
  description="Review, approve, or dismiss what the AI proposes across issue fixes and 404 redirects."
379
- action={generateAction}
453
+ action={headerAction}
380
454
  >
381
455
  {loading ? (
382
456
  <SeoLoading />
@@ -526,6 +600,12 @@ export function ProposalsTab({
526
600
  )}
527
601
  </SectionCard>
528
602
 
603
+ <AutopilotAppliedSection
604
+ onNavigate={onNavigate}
605
+ refreshKey={refreshKey}
606
+ onChanged={handleLedgerChanged}
607
+ />
608
+
529
609
  <ConfirmDialog
530
610
  open={confirmApply}
531
611
  onClose={() => setConfirmApply(false)}
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useEffect, useRef, useState } from 'react'
3
+ import { useEffect, useId, useRef, useState } from 'react'
4
4
  import {
5
5
  Plus,
6
6
  Trash2,
@@ -46,6 +46,8 @@ import { InspectorPane, SplitPaneLayout, SplitPaneList } from '../../components/
46
46
  import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
47
47
  import { useSeoResource } from './useSeoResource.js'
48
48
 
49
+ const SEO_SETTINGS_PATH = '/settings?tab=seo'
50
+
49
51
  function OverviewCards({ onRefetchKey }: { onRefetchKey: number }) {
50
52
  const { data, loading, error, refetch } = useSeoResource(fetchRedirectOverview, [onRefetchKey])
51
53
  if (loading) return <SeoLoading label="Loading redirect overview…" />
@@ -70,10 +72,16 @@ function OverviewCards({ onRefetchKey }: { onRefetchKey: number }) {
70
72
  )
71
73
  }
72
74
 
73
- function RecoverySettingsCard() {
75
+ /**
76
+ * 404 recovery settings. Only AI re-ranking lives here; the auto-apply policy
77
+ * (threshold, budget, gate) moved to SEO Autopilot, so the card points there
78
+ * instead of exposing a second copy of the controls.
79
+ */
80
+ function RecoverySettingsCard({ onNavigate }: { onNavigate?: (path: string) => void }) {
74
81
  const { data, loading, error, refetch } = useSeoResource(fetchRedirectRecoverySettings, [])
75
82
  const [saving, setSaving] = useState(false)
76
83
  const [local, setLocal] = useState<RedirectRecoverySettings | null>(null)
84
+ const aiRerankId = useId()
77
85
 
78
86
  useEffect(() => {
79
87
  if (data) setLocal(data)
@@ -82,7 +90,7 @@ function RecoverySettingsCard() {
82
90
  async function handleSave() {
83
91
  if (!local) return
84
92
  setSaving(true)
85
- const res = await saveRedirectRecoverySettings(local)
93
+ const res = await saveRedirectRecoverySettings({ aiRerank: local.aiRerank })
86
94
  setSaving(false)
87
95
  if (res.error) return toast.error(res.error)
88
96
  toast.success('Recovery settings saved.')
@@ -96,58 +104,39 @@ function RecoverySettingsCard() {
96
104
  return (
97
105
  <SectionCard
98
106
  title="404 recovery settings"
99
- description="Control AI re-ranking and optional auto-redirect for high-confidence matches"
107
+ description="Control how suggested redirects are ranked before you review them"
100
108
  >
101
109
  <div className="space-y-4">
102
- <label className="flex items-start gap-3">
110
+ <div className="flex items-start gap-3">
103
111
  <input
112
+ id={aiRerankId}
104
113
  type="checkbox"
105
- className="border-border mt-1 h-4 w-4 rounded"
114
+ className="border-border focus-visible:ring-ring mt-1 h-4 w-4 rounded focus-visible:ring-2 focus-visible:outline-none"
106
115
  checked={local.aiRerank}
107
116
  onChange={(e) => setLocal({ ...local, aiRerank: e.target.checked })}
117
+ aria-describedby={`${aiRerankId}-help`}
108
118
  />
109
- <span>
110
- <span className="text-foreground text-sm font-medium">AI re-ranking</span>
111
- <span className="text-muted-foreground block text-sm">
119
+ <div>
120
+ <label htmlFor={aiRerankId} className="text-foreground text-sm font-medium">
121
+ AI re-ranking
122
+ </label>
123
+ <p id={`${aiRerankId}-help`} className="text-muted-foreground text-sm">
112
124
  Use semantic search to resolve ambiguous matches (40–84% heuristic confidence).
113
- </span>
114
- </span>
115
- </label>
116
- <label className="flex items-start gap-3">
117
- <input
118
- type="checkbox"
119
- className="border-border mt-1 h-4 w-4 rounded"
120
- checked={local.autoApply}
121
- onChange={(e) => setLocal({ ...local, autoApply: e.target.checked })}
122
- />
123
- <span>
124
- <span className="text-foreground text-sm font-medium">
125
- Auto-apply high-confidence redirects
126
- </span>
127
- <span className="text-muted-foreground block text-sm">
128
- Create 301 redirects automatically when confidence meets the threshold (off by
129
- default).
130
- </span>
131
- </span>
132
- </label>
133
- <label className="block max-w-xs">
134
- <span className="text-foreground text-sm font-medium">Auto-apply minimum confidence</span>
135
- <input
136
- type="number"
137
- min={50}
138
- max={100}
139
- value={local.autoApplyMinConfidence}
140
- disabled={!local.autoApply}
141
- onChange={(e) =>
142
- setLocal({
143
- ...local,
144
- autoApplyMinConfidence: Math.max(50, Math.min(100, Number(e.target.value) || 85)),
145
- })
146
- }
147
- className="border-border bg-input-background text-foreground focus-visible:ring-ring mt-1 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
148
- />
149
- </label>
150
- <button className={btnSecondary} onClick={handleSave} disabled={saving}>
125
+ </p>
126
+ </div>
127
+ </div>
128
+ <p className="text-muted-foreground text-sm">
129
+ Auto-apply for high-confidence redirects is managed by{' '}
130
+ <button
131
+ type="button"
132
+ className="text-primary focus-visible:ring-ring rounded-sm font-medium underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:outline-none"
133
+ onClick={() => onNavigate?.(SEO_SETTINGS_PATH)}
134
+ >
135
+ SEO Autopilot
136
+ </button>
137
+ .
138
+ </p>
139
+ <button type="button" className={btnSecondary} onClick={handleSave} disabled={saving}>
151
140
  {saving ? 'Saving…' : 'Save settings'}
152
141
  </button>
153
142
  </div>
@@ -167,17 +156,7 @@ function Ai404RecoveryCard({ onApplied }: { onApplied: () => void }) {
167
156
  toast.error(res.error)
168
157
  return
169
158
  }
170
- const autoMsg =
171
- res.autoApplied && res.autoApplied > 0
172
- ? ` ${res.autoApplied} high-confidence redirect${res.autoApplied === 1 ? '' : 's'} applied automatically.`
173
- : ''
174
- toast.success(`Generated 404 recovery suggestions.${autoMsg}`)
175
- if (res.governanceBlocked) {
176
- // AI governance vetoed the auto-apply (eligible plans were stored as
177
- // pending suggestions). Tell the operator why, since the SEO module's
178
- // "Auto-apply" toggle alone no longer decides.
179
- toast.info(`Auto-apply skipped: ${res.governanceBlocked}`)
180
- }
159
+ toast.success('Generated 404 recovery suggestions.')
181
160
  refetch()
182
161
  }
183
162
 
@@ -313,7 +292,7 @@ function ChainMapCard({ onFlattened }: { onFlattened: () => void }) {
313
292
  )
314
293
  }
315
294
 
316
- export function RedirectsTab() {
295
+ export function RedirectsTab({ onNavigate }: { onNavigate?: (path: string) => void }) {
317
296
  const [refetchKey, setRefetchKey] = useState(0)
318
297
  const { data, loading, error, refetch } = useSeoResource(fetchRedirectRules, [refetchKey])
319
298
  const [editor, setEditor] = useState<{ rule: RedirectRule | null } | null>(null)
@@ -371,7 +350,7 @@ export function RedirectsTab() {
371
350
  <OverviewCards onRefetchKey={refetchKey} />
372
351
 
373
352
  <div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
374
- <RecoverySettingsCard />
353
+ <RecoverySettingsCard onNavigate={onNavigate} />
375
354
  <Ai404RecoveryCard onApplied={refetchAll} />
376
355
  <ChainMapCard onFlattened={refetchAll} />
377
356
  </div>
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Compact "how long ago" label shared by the SEO review surfaces (proposal age,
3
+ * autopilot ledger). Returns `''` for missing, unparsable, or future timestamps
4
+ * so callers can fall back to an absolute date.
5
+ */
6
+ export function relativeAge(iso: string | null, now: number = Date.now()): string {
7
+ if (!iso) return ''
8
+ const ms = now - new Date(iso).getTime()
9
+ if (!Number.isFinite(ms) || ms < 0) return ''
10
+ const minutes = Math.round(ms / 60_000)
11
+ if (minutes < 60) return `${Math.max(1, minutes)}m ago`
12
+ const hours = Math.round(minutes / 60)
13
+ if (hours < 48) return `${hours}h ago`
14
+ return `${Math.round(hours / 24)}d ago`
15
+ }