@prenta/admin 1.11.0 → 1.12.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 (70) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/__tests__/components/seo-editor-pane.render.test.js +15 -0
  3. package/dist/__tests__/components/seo-editor-pane.render.test.js.map +1 -1
  4. package/dist/__tests__/lib/seo-service-errors.test.js +54 -1
  5. package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
  6. package/dist/__tests__/views/ai-settings.render.test.js +26 -8
  7. package/dist/__tests__/views/ai-settings.render.test.js.map +1 -1
  8. package/dist/__tests__/views/redirects-pane.render.test.js +20 -2
  9. package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
  10. package/dist/__tests__/views/seo-settings.render.test.js +40 -2
  11. package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
  12. package/dist/components/seo/SeoCopilotDrawer.d.ts.map +1 -1
  13. package/dist/components/seo/SeoCopilotDrawer.js +4 -0
  14. package/dist/components/seo/SeoCopilotDrawer.js.map +1 -1
  15. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
  16. package/dist/components/seo/SeoEditorPane.js +8 -1
  17. package/dist/components/seo/SeoEditorPane.js.map +1 -1
  18. package/dist/components/seo/SeoIssueFixDrawer.d.ts.map +1 -1
  19. package/dist/components/seo/SeoIssueFixDrawer.js +4 -0
  20. package/dist/components/seo/SeoIssueFixDrawer.js.map +1 -1
  21. package/dist/components/seo/SeoSearchStats.d.ts +4 -0
  22. package/dist/components/seo/SeoSearchStats.d.ts.map +1 -0
  23. package/dist/components/seo/SeoSearchStats.js +91 -0
  24. package/dist/components/seo/SeoSearchStats.js.map +1 -0
  25. package/dist/lib/seo-service.d.ts +41 -0
  26. package/dist/lib/seo-service.d.ts.map +1 -1
  27. package/dist/lib/seo-service.js +22 -1
  28. package/dist/lib/seo-service.js.map +1 -1
  29. package/dist/prenta-admin.css +1 -1
  30. package/dist/views/seo/OverviewTab.js +1 -1
  31. package/dist/views/seo/OverviewTab.js.map +1 -1
  32. package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
  33. package/dist/views/seo/RedirectsTab.js +6 -0
  34. package/dist/views/seo/RedirectsTab.js.map +1 -1
  35. package/dist/views/settings/AISettingsTab.d.ts.map +1 -1
  36. package/dist/views/settings/AISettingsTab.js +1 -1
  37. package/dist/views/settings/AISettingsTab.js.map +1 -1
  38. package/dist/views/settings/AiFeaturesCard.d.ts +8 -3
  39. package/dist/views/settings/AiFeaturesCard.d.ts.map +1 -1
  40. package/dist/views/settings/AiFeaturesCard.js +7 -6
  41. package/dist/views/settings/AiFeaturesCard.js.map +1 -1
  42. package/dist/views/settings/SearchDataCard.d.ts +10 -0
  43. package/dist/views/settings/SearchDataCard.d.ts.map +1 -0
  44. package/dist/views/settings/SearchDataCard.js +126 -0
  45. package/dist/views/settings/SearchDataCard.js.map +1 -0
  46. package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
  47. package/dist/views/settings/SeoSettingsTab.js +2 -1
  48. package/dist/views/settings/SeoSettingsTab.js.map +1 -1
  49. package/dist/views/settings/useAiSettings.d.ts +12 -0
  50. package/dist/views/settings/useAiSettings.d.ts.map +1 -1
  51. package/dist/views/settings/useAiSettings.js +29 -1
  52. package/dist/views/settings/useAiSettings.js.map +1 -1
  53. package/package.json +3 -3
  54. package/src/__tests__/components/seo-editor-pane.render.test.tsx +25 -3
  55. package/src/__tests__/lib/seo-service-errors.test.ts +73 -0
  56. package/src/__tests__/views/ai-settings.render.test.tsx +38 -8
  57. package/src/__tests__/views/redirects-pane.render.test.tsx +28 -2
  58. package/src/__tests__/views/seo-settings.render.test.tsx +55 -2
  59. package/src/components/seo/SeoCopilotDrawer.tsx +4 -0
  60. package/src/components/seo/SeoEditorPane.tsx +13 -0
  61. package/src/components/seo/SeoIssueFixDrawer.tsx +4 -0
  62. package/src/components/seo/SeoSearchStats.tsx +183 -0
  63. package/src/lib/seo-service.ts +64 -2
  64. package/src/views/seo/OverviewTab.tsx +1 -1
  65. package/src/views/seo/RedirectsTab.tsx +6 -0
  66. package/src/views/settings/AISettingsTab.tsx +1 -0
  67. package/src/views/settings/AiFeaturesCard.tsx +59 -2
  68. package/src/views/settings/SearchDataCard.tsx +390 -0
  69. package/src/views/settings/SeoSettingsTab.tsx +2 -0
  70. package/src/views/settings/useAiSettings.ts +71 -2
@@ -0,0 +1,183 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Read-only search-performance block for the SEO editor pane: synced Google
5
+ * Search Console 28-day stats, top queries, and CrUX Core Web Vitals for one
6
+ * document. Purely informational — editing happens in the fields above it.
7
+ */
8
+ import { useEffect, useState } from 'react'
9
+ import { fetchSearchInsight, type SearchInsight } from '../../lib/seo-service.js'
10
+ import { SeoLoading } from './primitives.js'
11
+
12
+ function formatCompact(n: number): string {
13
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
14
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}K`
15
+ return String(n)
16
+ }
17
+
18
+ function DeltaHint({
19
+ current,
20
+ previous,
21
+ lowerIsBetter = false,
22
+ }: {
23
+ current: number | null
24
+ previous: number | null
25
+ lowerIsBetter?: boolean
26
+ }) {
27
+ if (current == null || previous == null || previous === 0) return null
28
+ const diff = current - previous
29
+ if (diff === 0) return null
30
+ const improved = lowerIsBetter ? diff < 0 : diff > 0
31
+ return (
32
+ <span className={improved ? 'text-[var(--suc)]' : 'text-[var(--err)]'}>
33
+ {diff > 0 ? '+' : ''}
34
+ {Number.isInteger(diff) ? diff : diff.toFixed(1)}
35
+ </span>
36
+ )
37
+ }
38
+
39
+ function cwvTone(status: SearchInsight['cwvStatus']): { label: string; cls: string } {
40
+ switch (status) {
41
+ case 'good':
42
+ return { label: 'Good', cls: 'bg-[var(--suc-l)] text-[var(--suc)]' }
43
+ case 'needs-improvement':
44
+ return { label: 'Needs improvement', cls: 'bg-[var(--warn-l)] text-[var(--warn)]' }
45
+ case 'poor':
46
+ return { label: 'Poor', cls: 'bg-[var(--err-l)] text-[var(--err)]' }
47
+ default:
48
+ return { label: 'No field data', cls: 'bg-[var(--bg)] text-[var(--muted)]' }
49
+ }
50
+ }
51
+
52
+ export function SeoSearchStats({ entityId }: { entityId: string }) {
53
+ const [loading, setLoading] = useState(true)
54
+ const [connected, setConnected] = useState(false)
55
+ const [insight, setInsight] = useState<SearchInsight | null>(null)
56
+
57
+ useEffect(() => {
58
+ let alive = true
59
+ setLoading(true)
60
+ fetchSearchInsight(entityId)
61
+ .then((res) => {
62
+ if (!alive) return
63
+ setConnected(res.connected)
64
+ setInsight(res.insight)
65
+ })
66
+ .catch(() => {
67
+ if (!alive) return
68
+ setConnected(false)
69
+ setInsight(null)
70
+ })
71
+ .finally(() => alive && setLoading(false))
72
+ return () => {
73
+ alive = false
74
+ }
75
+ }, [entityId])
76
+
77
+ if (loading) return <SeoLoading />
78
+
79
+ if (!connected) {
80
+ return (
81
+ <p className="text-[11.5px] text-[var(--sub)]">
82
+ Connect Google Search Console in Settings → SEO → Search data to see real clicks,
83
+ impressions, and queries for this page.
84
+ </p>
85
+ )
86
+ }
87
+
88
+ if (!insight) {
89
+ return (
90
+ <p className="text-[11.5px] text-[var(--sub)]">
91
+ No search data synced for this page yet. Data appears after the next nightly sync once the
92
+ page has impressions in Google Search.
93
+ </p>
94
+ )
95
+ }
96
+
97
+ const cwv = cwvTone(insight.cwvStatus)
98
+
99
+ return (
100
+ <div className="space-y-3">
101
+ <div className="grid grid-cols-3 gap-2">
102
+ {[
103
+ {
104
+ label: 'Clicks',
105
+ value: formatCompact(insight.clicks28d),
106
+ delta: <DeltaHint current={insight.clicks28d} previous={insight.clicksPrev28d} />,
107
+ },
108
+ {
109
+ label: 'Impressions',
110
+ value: formatCompact(insight.impressions28d),
111
+ delta: (
112
+ <DeltaHint current={insight.impressions28d} previous={insight.impressionsPrev28d} />
113
+ ),
114
+ },
115
+ {
116
+ label: 'Avg. position',
117
+ value: insight.avgPosition28d != null ? insight.avgPosition28d.toFixed(1) : '—',
118
+ delta: (
119
+ <DeltaHint
120
+ current={insight.avgPosition28d}
121
+ previous={insight.avgPositionPrev28d}
122
+ lowerIsBetter
123
+ />
124
+ ),
125
+ },
126
+ ].map((stat) => (
127
+ <div key={stat.label} className="rounded-[7px] border border-[var(--bdr)] px-2.5 py-2">
128
+ <div className="text-[10.5px] text-[var(--muted)]">{stat.label}</div>
129
+ <div className="flex items-baseline gap-1.5">
130
+ <span className="text-[15px] font-semibold text-[var(--txt)]">{stat.value}</span>
131
+ <span className="text-[10.5px]">{stat.delta}</span>
132
+ </div>
133
+ </div>
134
+ ))}
135
+ </div>
136
+ <p className="text-[10.5px] text-[var(--muted)]">
137
+ Last 28 days vs the prior 28 — synced from Google Search Console.
138
+ </p>
139
+
140
+ {insight.topQueries.length > 0 && (
141
+ <div>
142
+ <p className="mb-1.5 text-[11px] font-medium text-[var(--sub)]">Top queries</p>
143
+ <table className="w-full text-[11.5px]" aria-label="Top search queries for this page">
144
+ <thead>
145
+ <tr className="text-left text-[10.5px] text-[var(--muted)]">
146
+ <th className="pb-1 font-medium">Query</th>
147
+ <th className="pb-1 text-right font-medium">Clicks</th>
148
+ <th className="pb-1 text-right font-medium">Pos.</th>
149
+ </tr>
150
+ </thead>
151
+ <tbody>
152
+ {insight.topQueries.slice(0, 8).map((q) => (
153
+ <tr key={q.query} className="border-t border-[var(--bdr)]">
154
+ <td className="max-w-0 truncate py-1 pr-2 text-[var(--txt)]" title={q.query}>
155
+ {q.query}
156
+ </td>
157
+ <td className="py-1 text-right text-[var(--sub)]">{formatCompact(q.clicks)}</td>
158
+ <td className="py-1 text-right text-[var(--sub)]">{q.position.toFixed(1)}</td>
159
+ </tr>
160
+ ))}
161
+ </tbody>
162
+ </table>
163
+ </div>
164
+ )}
165
+
166
+ <div className="flex items-center justify-between gap-2 border-t border-[var(--bdr)] pt-2.5">
167
+ <span className="text-[11px] font-medium text-[var(--sub)]">
168
+ Core Web Vitals (real users)
169
+ </span>
170
+ <span className={`rounded-full px-2 py-0.5 text-[10.5px] font-medium ${cwv.cls}`}>
171
+ {cwv.label}
172
+ </span>
173
+ </div>
174
+ {insight.cwvStatus !== null && (
175
+ <div className="grid grid-cols-3 gap-2 text-[11px] text-[var(--sub)]">
176
+ <span>LCP {insight.lcpMs != null ? `${(insight.lcpMs / 1000).toFixed(1)}s` : '—'}</span>
177
+ <span>INP {insight.inpMs != null ? `${insight.inpMs}ms` : '—'}</span>
178
+ <span>CLS {insight.cls != null ? insight.cls : '—'}</span>
179
+ </div>
180
+ )}
181
+ </div>
182
+ )
183
+ }
@@ -144,6 +144,46 @@ export async function fetchSearchPerformance(
144
144
  return res.data?.rows ?? []
145
145
  }
146
146
 
147
+ // ─── Per-document search insight (synced GSC + CrUX) ─────────────────
148
+
149
+ export interface SearchInsightTopQuery {
150
+ query: string
151
+ clicks: number
152
+ impressions: number
153
+ position: number
154
+ }
155
+
156
+ export interface SearchInsight {
157
+ path: string
158
+ clicks28d: number
159
+ impressions28d: number
160
+ avgPosition28d: number | null
161
+ clicksPrev28d: number
162
+ impressionsPrev28d: number
163
+ avgPositionPrev28d: number | null
164
+ topQueries: SearchInsightTopQuery[]
165
+ lcpMs: number | null
166
+ inpMs: number | null
167
+ cls: number | null
168
+ cwvStatus: 'good' | 'needs-improvement' | 'poor' | null
169
+ lastSyncedAt: string | null
170
+ }
171
+
172
+ /**
173
+ * Synced search stats for one document. `insight` is null when Search Console
174
+ * isn't connected or no data has synced for the page yet — the editor pane
175
+ * renders a quiet hint instead of an error in that case.
176
+ */
177
+ export async function fetchSearchInsight(
178
+ entityId: string,
179
+ ): Promise<{ connected: boolean; insight: SearchInsight | null }> {
180
+ const res = await cmsApi<{ connected: boolean; insight: SearchInsight | null }>(
181
+ `/seo/search-insights/${encodeURIComponent(entityId)}`,
182
+ )
183
+ throwIfError(res)
184
+ return res.data ?? { connected: false, insight: null }
185
+ }
186
+
147
187
  // ─── Content SEO ─────────────────────────────────────────────────────
148
188
 
149
189
  /** Schema.org types available in document SEO editors. */
@@ -995,12 +1035,24 @@ export async function saveRedirectRecoverySettings(
995
1035
  export async function generate404RecoverySuggestions(): Promise<{
996
1036
  error?: string
997
1037
  autoApplied?: number
1038
+ /**
1039
+ * Set when AI governance vetoed a requested auto-apply (eligible plans were
1040
+ * stored as pending suggestions instead). Human-readable reason to surface.
1041
+ */
1042
+ governanceBlocked?: string
998
1043
  }> {
999
- const res = await cmsApi<{ created?: number; autoApplied?: number }>('/seo/redirects/suggest', {
1044
+ const res = await cmsApi<{
1045
+ created?: number
1046
+ autoApplied?: number
1047
+ governanceBlocked?: string | null
1048
+ }>('/seo/redirects/suggest', {
1000
1049
  method: 'POST',
1001
1050
  })
1002
1051
  if (res.error) return { error: res.error }
1003
- return { autoApplied: res.data?.autoApplied ?? 0 }
1052
+ return {
1053
+ autoApplied: res.data?.autoApplied ?? 0,
1054
+ governanceBlocked: res.data?.governanceBlocked ?? undefined,
1055
+ }
1004
1056
  }
1005
1057
 
1006
1058
  export async function acceptRedirectSuggestion(suggestionId: string): Promise<{ error?: string }> {
@@ -1047,6 +1099,12 @@ export interface SeoAiResult {
1047
1099
  brandAlignment?: BrandAlignmentScoreResult
1048
1100
  /** True when the AI provider is not configured (graceful fallback). */
1049
1101
  unavailable?: boolean
1102
+ /**
1103
+ * Server-reported refusal (daily request cap, monthly budget, disabled
1104
+ * feature). Surface verbatim — the runtime crafts these messages to tell the
1105
+ * user which limit was hit and where to raise it.
1106
+ */
1107
+ error?: string
1050
1108
  }
1051
1109
 
1052
1110
  /**
@@ -1073,6 +1131,7 @@ export async function generateSeoField(
1073
1131
  body: JSON.stringify({ field, ...context }),
1074
1132
  })
1075
1133
  if (isAiUnavailable(res.status)) return { unavailable: true }
1134
+ if (res.error) return { error: res.error }
1076
1135
  return res.data ?? {}
1077
1136
  }
1078
1137
 
@@ -1082,6 +1141,7 @@ export async function explainSeoIssue(issueId: string): Promise<SeoAiResult> {
1082
1141
  body: JSON.stringify({ issueId }),
1083
1142
  })
1084
1143
  if (isAiUnavailable(res.status)) return { unavailable: true }
1144
+ if (res.error) return { error: res.error }
1085
1145
  return res.data ?? {}
1086
1146
  }
1087
1147
 
@@ -1095,12 +1155,14 @@ export async function generateStructuredData(context: {
1095
1155
  body: JSON.stringify(context),
1096
1156
  })
1097
1157
  if (isAiUnavailable(res.status)) return { unavailable: true }
1158
+ if (res.error) return { error: res.error }
1098
1159
  return res.data ?? {}
1099
1160
  }
1100
1161
 
1101
1162
  export async function summarizeSeoAudit(): Promise<SeoAiResult> {
1102
1163
  const res = await cmsApi<SeoAiResult>('/ai/seo/summarize', { method: 'POST' })
1103
1164
  if (isAiUnavailable(res.status)) return { unavailable: true }
1165
+ if (res.error) return { error: res.error }
1104
1166
  return res.data ?? {}
1105
1167
  }
1106
1168
 
@@ -200,7 +200,7 @@ export function OverviewTab({
200
200
  <SeoEmptyState
201
201
  icon={<BarChart3 size={24} />}
202
202
  title="Search Console not connected"
203
- description="Add a Google Search Console verification token in Settings → SEO. Performance charts will appear here when the Search Analytics API is wired."
203
+ description="Connect a Google service account in Settings → SEO Search data, then run a sync. Real clicks, impressions, and positions will appear here."
204
204
  />
205
205
  ) : searchPerformance.length === 0 ? (
206
206
  <SeoEmptyState title="No search performance data" />
@@ -172,6 +172,12 @@ function Ai404RecoveryCard({ onApplied }: { onApplied: () => void }) {
172
172
  ? ` ${res.autoApplied} high-confidence redirect${res.autoApplied === 1 ? '' : 's'} applied automatically.`
173
173
  : ''
174
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
+ }
175
181
  refetch()
176
182
  }
177
183
 
@@ -122,6 +122,7 @@ export function AISettingsTab({ role, config }: AISettingsTabProps) {
122
122
  canEdit={canEdit}
123
123
  onToggle={ai.toggleFeature}
124
124
  onSetFeatureModel={ai.setFeatureModel}
125
+ onSetGovernance={ai.setFeatureGovernance}
125
126
  />
126
127
  <BrandVoiceCard
127
128
  value={ai.form.brandVoice}
@@ -13,8 +13,8 @@ const SELECT_CLASS =
13
13
  * Global AI feature toggles with per-feature model routing. Each switch persists
14
14
  * (via the Save bar) to the shared `ai.features.*` flags; the optional model
15
15
  * picker overrides which model that feature uses (otherwise it inherits the
16
- * default). Enforcement across modules lands in a later phase; here we surface
17
- * and store the operator's intent.
16
+ * default). The governance flags (approval, bulk apply, daily cap) are enforced
17
+ * by the governed AI runtime and the auto-apply gates in core.
18
18
  */
19
19
  export function AiFeaturesCard({
20
20
  features,
@@ -23,6 +23,7 @@ export function AiFeaturesCard({
23
23
  canEdit,
24
24
  onToggle,
25
25
  onSetFeatureModel,
26
+ onSetGovernance,
26
27
  }: {
27
28
  features: AiFeatureView[]
28
29
  models: AiModelView[]
@@ -30,6 +31,14 @@ export function AiFeaturesCard({
30
31
  canEdit: boolean
31
32
  onToggle: (key: string, enabled: boolean) => void
32
33
  onSetFeatureModel: (key: string, modelId: string) => void
34
+ onSetGovernance: (
35
+ key: string,
36
+ patch: Partial<{
37
+ approvalRequired: boolean
38
+ allowBulkApply: boolean
39
+ maxRequestsPerDay: string
40
+ }>,
41
+ ) => void
33
42
  }) {
34
43
  return (
35
44
  <SettingsCard
@@ -49,6 +58,7 @@ export function AiFeaturesCard({
49
58
  canEdit={canEdit}
50
59
  onToggle={onToggle}
51
60
  onSetFeatureModel={onSetFeatureModel}
61
+ onSetGovernance={onSetGovernance}
52
62
  />
53
63
  ))}
54
64
  </div>
@@ -64,6 +74,7 @@ function FeatureRow({
64
74
  canEdit,
65
75
  onToggle,
66
76
  onSetFeatureModel,
77
+ onSetGovernance,
67
78
  }: {
68
79
  feature: AiFeatureView
69
80
  models: AiModelView[]
@@ -71,8 +82,17 @@ function FeatureRow({
71
82
  canEdit: boolean
72
83
  onToggle: (key: string, enabled: boolean) => void
73
84
  onSetFeatureModel: (key: string, modelId: string) => void
85
+ onSetGovernance: (
86
+ key: string,
87
+ patch: Partial<{
88
+ approvalRequired: boolean
89
+ allowBulkApply: boolean
90
+ maxRequestsPerDay: string
91
+ }>,
92
+ ) => void
74
93
  }) {
75
94
  const selectId = useId()
95
+ const capId = useId()
76
96
  const required = feature.requiredCapabilities ?? []
77
97
  const effective = effectiveFeatureModel(feature.modelId, defaultModelId, required, models)
78
98
  const defaultModel = models.find(
@@ -141,6 +161,43 @@ function FeatureRow({
141
161
  needs. Pick a compatible model.
142
162
  </p>
143
163
  )}
164
+
165
+ <div className="border-border mt-4 space-y-3 border-t pt-3">
166
+ <SettingsToggleRow
167
+ label="Require approval"
168
+ description="AI output must be human-approved before it is applied."
169
+ checked={feature.approvalRequired}
170
+ disabled={!canEdit}
171
+ onChange={(v) => onSetGovernance(feature.key, { approvalRequired: v })}
172
+ />
173
+ <SettingsToggleRow
174
+ label="Allow bulk apply"
175
+ description="Permit applying many AI results in one operation."
176
+ checked={feature.allowBulkApply}
177
+ disabled={!canEdit}
178
+ onChange={(v) => onSetGovernance(feature.key, { allowBulkApply: v })}
179
+ />
180
+ <div>
181
+ <label htmlFor={capId} className="text-muted-foreground mb-1 block text-sm">
182
+ Max requests per day
183
+ </label>
184
+ <input
185
+ id={capId}
186
+ type="text"
187
+ inputMode="numeric"
188
+ value={feature.maxRequestsPerDay}
189
+ placeholder="Unlimited"
190
+ disabled={!canEdit}
191
+ onChange={(e) =>
192
+ onSetGovernance(feature.key, { maxRequestsPerDay: e.target.value })
193
+ }
194
+ className={SELECT_CLASS}
195
+ />
196
+ <p className="text-muted-foreground mt-1 text-sm">
197
+ Site-wide daily cap for this feature. Leave empty for unlimited.
198
+ </p>
199
+ </div>
200
+ </div>
144
201
  </div>
145
202
  )}
146
203
  </div>