@prenta/admin 1.14.0 → 1.16.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 (119) hide show
  1. package/CHANGELOG.md +16 -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-geo.test.d.ts +2 -0
  11. package/dist/__tests__/lib/seo-service-geo.test.d.ts.map +1 -0
  12. package/dist/__tests__/lib/seo-service-geo.test.js +103 -0
  13. package/dist/__tests__/lib/seo-service-geo.test.js.map +1 -0
  14. package/dist/__tests__/lib/seo-service-proposals.test.js +28 -0
  15. package/dist/__tests__/lib/seo-service-proposals.test.js.map +1 -1
  16. package/dist/__tests__/router/seo-tab-for-path.test.js +4 -0
  17. package/dist/__tests__/router/seo-tab-for-path.test.js.map +1 -1
  18. package/dist/__tests__/views/redirects-pane.render.test.js +30 -12
  19. package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
  20. package/dist/__tests__/views/seo-ai-visibility.render.test.d.ts +2 -0
  21. package/dist/__tests__/views/seo-ai-visibility.render.test.d.ts.map +1 -0
  22. package/dist/__tests__/views/seo-ai-visibility.render.test.js +244 -0
  23. package/dist/__tests__/views/seo-ai-visibility.render.test.js.map +1 -0
  24. package/dist/__tests__/views/seo-autopilot-applied.render.test.d.ts +2 -0
  25. package/dist/__tests__/views/seo-autopilot-applied.render.test.d.ts.map +1 -0
  26. package/dist/__tests__/views/seo-autopilot-applied.render.test.js +330 -0
  27. package/dist/__tests__/views/seo-autopilot-applied.render.test.js.map +1 -0
  28. package/dist/__tests__/views/seo-autopilot-card.render.test.d.ts +2 -0
  29. package/dist/__tests__/views/seo-autopilot-card.render.test.d.ts.map +1 -0
  30. package/dist/__tests__/views/seo-autopilot-card.render.test.js +512 -0
  31. package/dist/__tests__/views/seo-autopilot-card.render.test.js.map +1 -0
  32. package/dist/__tests__/views/seo-geo-settings-card.render.test.d.ts +2 -0
  33. package/dist/__tests__/views/seo-geo-settings-card.render.test.d.ts.map +1 -0
  34. package/dist/__tests__/views/seo-geo-settings-card.render.test.js +227 -0
  35. package/dist/__tests__/views/seo-geo-settings-card.render.test.js.map +1 -0
  36. package/dist/__tests__/views/seo-proposals-tab.render.test.js +139 -0
  37. package/dist/__tests__/views/seo-proposals-tab.render.test.js.map +1 -1
  38. package/dist/__tests__/views/seo-settings.render.test.js +53 -4
  39. package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
  40. package/dist/__tests__/views/seo-technical-tab.render.test.js +13 -0
  41. package/dist/__tests__/views/seo-technical-tab.render.test.js.map +1 -1
  42. package/dist/lib/api.d.ts +13 -2
  43. package/dist/lib/api.d.ts.map +1 -1
  44. package/dist/lib/api.js +1 -2
  45. package/dist/lib/api.js.map +1 -1
  46. package/dist/lib/seo-service.d.ts +300 -2
  47. package/dist/lib/seo-service.d.ts.map +1 -1
  48. package/dist/lib/seo-service.js +172 -5
  49. package/dist/lib/seo-service.js.map +1 -1
  50. package/dist/prenta-admin.css +1 -1
  51. package/dist/views/SEO.d.ts +5 -1
  52. package/dist/views/SEO.d.ts.map +1 -1
  53. package/dist/views/SEO.js +12 -7
  54. package/dist/views/SEO.js.map +1 -1
  55. package/dist/views/seo/AiVisibilityTab.d.ts +4 -0
  56. package/dist/views/seo/AiVisibilityTab.d.ts.map +1 -0
  57. package/dist/views/seo/AiVisibilityTab.js +165 -0
  58. package/dist/views/seo/AiVisibilityTab.js.map +1 -0
  59. package/dist/views/seo/AutopilotAppliedSection.d.ts +10 -0
  60. package/dist/views/seo/AutopilotAppliedSection.d.ts.map +1 -0
  61. package/dist/views/seo/AutopilotAppliedSection.js +295 -0
  62. package/dist/views/seo/AutopilotAppliedSection.js.map +1 -0
  63. package/dist/views/seo/ProposalsTab.d.ts.map +1 -1
  64. package/dist/views/seo/ProposalsTab.js +53 -17
  65. package/dist/views/seo/ProposalsTab.js.map +1 -1
  66. package/dist/views/seo/RedirectsTab.d.ts +3 -1
  67. package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
  68. package/dist/views/seo/RedirectsTab.js +14 -19
  69. package/dist/views/seo/RedirectsTab.js.map +1 -1
  70. package/dist/views/seo/TechnicalTab.d.ts +3 -1
  71. package/dist/views/seo/TechnicalTab.d.ts.map +1 -1
  72. package/dist/views/seo/TechnicalTab.js +4 -4
  73. package/dist/views/seo/TechnicalTab.js.map +1 -1
  74. package/dist/views/seo/relative-age.d.ts +7 -0
  75. package/dist/views/seo/relative-age.d.ts.map +1 -0
  76. package/dist/views/seo/relative-age.js +20 -0
  77. package/dist/views/seo/relative-age.js.map +1 -0
  78. package/dist/views/settings/SeoAutopilotCard.d.ts +30 -0
  79. package/dist/views/settings/SeoAutopilotCard.d.ts.map +1 -0
  80. package/dist/views/settings/SeoAutopilotCard.js +375 -0
  81. package/dist/views/settings/SeoAutopilotCard.js.map +1 -0
  82. package/dist/views/settings/SeoGeoSettingsCard.d.ts +12 -0
  83. package/dist/views/settings/SeoGeoSettingsCard.d.ts.map +1 -0
  84. package/dist/views/settings/SeoGeoSettingsCard.js +178 -0
  85. package/dist/views/settings/SeoGeoSettingsCard.js.map +1 -0
  86. package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
  87. package/dist/views/settings/SeoSettingsTab.js +3 -1
  88. package/dist/views/settings/SeoSettingsTab.js.map +1 -1
  89. package/dist/views/settings/components.d.ts +3 -1
  90. package/dist/views/settings/components.d.ts.map +1 -1
  91. package/dist/views/settings/components.js +2 -2
  92. package/dist/views/settings/components.js.map +1 -1
  93. package/package.json +3 -3
  94. package/src/__tests__/lib/api.test.ts +76 -0
  95. package/src/__tests__/lib/seo-service-autopilot.test.ts +196 -0
  96. package/src/__tests__/lib/seo-service-geo.test.ts +118 -0
  97. package/src/__tests__/lib/seo-service-proposals.test.ts +29 -0
  98. package/src/__tests__/router/seo-tab-for-path.test.ts +5 -0
  99. package/src/__tests__/views/redirects-pane.render.test.tsx +36 -16
  100. package/src/__tests__/views/seo-ai-visibility.render.test.tsx +279 -0
  101. package/src/__tests__/views/seo-autopilot-applied.render.test.tsx +382 -0
  102. package/src/__tests__/views/seo-autopilot-card.render.test.tsx +610 -0
  103. package/src/__tests__/views/seo-geo-settings-card.render.test.tsx +253 -0
  104. package/src/__tests__/views/seo-proposals-tab.render.test.tsx +161 -0
  105. package/src/__tests__/views/seo-settings.render.test.tsx +68 -7
  106. package/src/__tests__/views/seo-technical-tab.render.test.tsx +15 -0
  107. package/src/lib/api.ts +19 -3
  108. package/src/lib/seo-service.ts +439 -6
  109. package/src/views/SEO.tsx +16 -7
  110. package/src/views/seo/AiVisibilityTab.tsx +616 -0
  111. package/src/views/seo/AutopilotAppliedSection.tsx +597 -0
  112. package/src/views/seo/ProposalsTab.tsx +92 -12
  113. package/src/views/seo/RedirectsTab.tsx +39 -60
  114. package/src/views/seo/TechnicalTab.tsx +27 -8
  115. package/src/views/seo/relative-age.ts +15 -0
  116. package/src/views/settings/SeoAutopilotCard.tsx +826 -0
  117. package/src/views/settings/SeoGeoSettingsCard.tsx +310 -0
  118. package/src/views/settings/SeoSettingsTab.tsx +4 -0
  119. package/src/views/settings/components.tsx +10 -1
@@ -828,6 +828,295 @@ export async function sendTestDigest(): Promise<{ ok: boolean; to?: string; erro
828
828
  return { ok: true, to: res.data?.to }
829
829
  }
830
830
 
831
+ // ─── SEO Autopilot ───────────────────────────────────────────────────
832
+ // Mirrors `packages/cms-core/src/seo/autopilot-policy.ts` (`SeoAutopilotPolicy`,
833
+ // `AutopilotRunSummary`) and the `/seo/autopilot/*` routes. The server is the
834
+ // contract; keep these shapes in lock-step with it.
835
+
836
+ export type SeoAutopilotIssueType =
837
+ | 'missing-meta-title'
838
+ | 'meta-title-length'
839
+ | 'duplicate-meta-title'
840
+ | 'missing-meta-description'
841
+ | 'meta-description-length'
842
+ | 'duplicate-meta-description'
843
+ | 'missing-canonical'
844
+ | 'accidental-noindex'
845
+ | 'missing-structured-data'
846
+ | 'redirect'
847
+
848
+ export const SEO_AUTOPILOT_ISSUE_TYPES: readonly SeoAutopilotIssueType[] = [
849
+ 'missing-meta-title',
850
+ 'meta-title-length',
851
+ 'duplicate-meta-title',
852
+ 'missing-meta-description',
853
+ 'meta-description-length',
854
+ 'duplicate-meta-description',
855
+ 'missing-canonical',
856
+ 'accidental-noindex',
857
+ 'missing-structured-data',
858
+ 'redirect',
859
+ ]
860
+
861
+ export type SeoAutopilotPauseReason = 'outcome_regression' | 'owner_unavailable' | 'manual'
862
+
863
+ /**
864
+ * Run-level stop reasons the engine reports. `already_running` is
865
+ * informational (another tick held the run lease; nothing was written); the
866
+ * rest are warnings. The engine never stores raw error messages; a throwing
867
+ * phase is reported as the coded `apply_failed` / `run_failed`.
868
+ */
869
+ export type SeoAutopilotHaltReason =
870
+ | 'disabled'
871
+ | 'paused'
872
+ | 'owner_unavailable'
873
+ | 'already_running'
874
+ | 'verify_failures'
875
+ | 'budget_exhausted'
876
+ | 'ledger_write_failed'
877
+ | 'apply_failed'
878
+ | 'run_failed'
879
+
880
+ export interface SeoAutopilotRunSummary {
881
+ startedAt: string
882
+ durationMs: number
883
+ applied: number
884
+ verifyFailed: number
885
+ /** Outcome-watch reverts this tick. */
886
+ reverted: number
887
+ /** Gate reason → count (`policy_disabled`, `cap_per_run`, `brand_below_threshold`, …). */
888
+ gated: Record<string, number>
889
+ /**
890
+ * Non-applied apply statuses plus run-level counters: `error`,
891
+ * `gate_mismatch`, `after_unreadable`, `outcome_watch_error`,
892
+ * `outcome_revert_error`, `budget_exhausted`.
893
+ */
894
+ applyOutcomes: Record<string, number>
895
+ generated: { generated: number; skipped: number; failed: number; haltedReason: string | null }
896
+ halted: boolean
897
+ /** A {@link SeoAutopilotHaltReason}; unknown strings come from a newer core and are shown verbatim. */
898
+ haltedReason: string | null
899
+ }
900
+
901
+ export interface SeoAutopilotOutcomeWatch {
902
+ enabled: boolean
903
+ days: number
904
+ dropRatio: number
905
+ minPriorClicks: number
906
+ }
907
+
908
+ export interface SeoAutopilotGovernance {
909
+ /** Settings → AI "Requires approval" on the issue-fix feature vetoes every metadata type. */
910
+ issueFixApprovalRequired: boolean
911
+ /** Same veto for 404-recovery redirects. */
912
+ redirectApprovalRequired: boolean
913
+ }
914
+
915
+ export interface SeoAutopilotSettings {
916
+ enabled: boolean
917
+ enabledById: string | null
918
+ pausedAt: string | null
919
+ pausedReason: SeoAutopilotPauseReason | null
920
+ types: Partial<Record<SeoAutopilotIssueType, boolean>>
921
+ pausedTypes: Partial<Record<SeoAutopilotIssueType, string>>
922
+ minBrandAlignment: number
923
+ redirectMinConfidence: number
924
+ maxPerRun: number
925
+ maxPerDay: number
926
+ verifyFailuresPerRun: number
927
+ outcomeWatch: SeoAutopilotOutcomeWatch
928
+ lastRunAt: string | null
929
+ lastRun: SeoAutopilotRunSummary | null
930
+ governance: SeoAutopilotGovernance
931
+ }
932
+
933
+ /** Server-side ranges (`AUTOPILOT_LIMITS`) — mirrored so the card clamps before it PUTs. */
934
+ export const SEO_AUTOPILOT_LIMITS = {
935
+ maxPerRun: { min: 1, max: 25 },
936
+ maxPerDay: { min: 1, max: 100 },
937
+ verifyFailuresPerRun: { min: 1, max: 10 },
938
+ minBrandAlignment: { min: 0, max: 100 },
939
+ redirectMinConfidence: { min: 50, max: 100 },
940
+ outcomeDays: { min: 7, max: 28 },
941
+ /** Whole percentages; the API takes a 0.2–0.8 ratio. */
942
+ dropPercent: { min: 20, max: 80 },
943
+ minPriorClicks: { min: 0, max: 100_000 },
944
+ } as const
945
+
946
+ /**
947
+ * A sparse PUT body: only the keys sent are written. `outcomeWatch` is itself
948
+ * partial. `resume` clears the engine-level pause; `resumeTypes` clears
949
+ * per-type pauses. Owner and pause state are never accepted from the client.
950
+ */
951
+ export type SeoAutopilotSettingsPatch = Partial<
952
+ Pick<
953
+ SeoAutopilotSettings,
954
+ | 'enabled'
955
+ | 'types'
956
+ | 'minBrandAlignment'
957
+ | 'redirectMinConfidence'
958
+ | 'maxPerRun'
959
+ | 'maxPerDay'
960
+ | 'verifyFailuresPerRun'
961
+ >
962
+ > & {
963
+ outcomeWatch?: Partial<SeoAutopilotOutcomeWatch>
964
+ resume?: boolean
965
+ resumeTypes?: SeoAutopilotIssueType[]
966
+ }
967
+
968
+ function isRecord(v: unknown): v is Record<string, unknown> {
969
+ return typeof v === 'object' && v !== null && !Array.isArray(v)
970
+ }
971
+
972
+ /**
973
+ * Validate the settings payload before handing it to the card: an unexpected
974
+ * body must render the error state, not crash on `settings.types[...]`.
975
+ * `governance` is normalised defensively (missing → no veto).
976
+ */
977
+ function normalizeAutopilotSettings(raw: unknown): SeoAutopilotSettings | null {
978
+ if (!isRecord(raw)) return null
979
+ if (typeof raw.enabled !== 'boolean' || !isRecord(raw.types) || !isRecord(raw.outcomeWatch)) {
980
+ return null
981
+ }
982
+ const d = raw as unknown as SeoAutopilotSettings
983
+ const gov = isRecord(raw.governance) ? raw.governance : {}
984
+ return {
985
+ ...d,
986
+ pausedTypes: isRecord(raw.pausedTypes) ? d.pausedTypes : {},
987
+ lastRunAt: typeof raw.lastRunAt === 'string' ? raw.lastRunAt : null,
988
+ lastRun: isRecord(raw.lastRun) ? d.lastRun : null,
989
+ governance: {
990
+ issueFixApprovalRequired: gov.issueFixApprovalRequired === true,
991
+ redirectApprovalRequired: gov.redirectApprovalRequired === true,
992
+ },
993
+ }
994
+ }
995
+
996
+ export async function fetchAutopilotSettings(): Promise<
997
+ { settings: SeoAutopilotSettings } | { settings: null; error: string }
998
+ > {
999
+ const res = await cmsApi<unknown>('/seo/autopilot/settings')
1000
+ if (res.error) return { settings: null, error: res.error }
1001
+ const settings = normalizeAutopilotSettings(res.data)
1002
+ if (!settings) return { settings: null, error: 'Unexpected autopilot settings response' }
1003
+ return { settings }
1004
+ }
1005
+
1006
+ export async function updateAutopilotSettings(
1007
+ patch: SeoAutopilotSettingsPatch,
1008
+ ): Promise<{ ok: boolean; error?: string; settings?: SeoAutopilotSettings }> {
1009
+ const res = await cmsApi<unknown>('/seo/autopilot/settings', {
1010
+ method: 'PUT',
1011
+ body: JSON.stringify(patch),
1012
+ })
1013
+ if (res.error) return { ok: false, error: res.error }
1014
+ return { ok: true, settings: normalizeAutopilotSettings(res.data) ?? undefined }
1015
+ }
1016
+
1017
+ export async function runAutopilotNow(): Promise<{
1018
+ ok: boolean
1019
+ error?: string
1020
+ summary?: SeoAutopilotRunSummary
1021
+ }> {
1022
+ const res = await cmsApi<SeoAutopilotRunSummary>('/seo/autopilot/run', {
1023
+ method: 'POST',
1024
+ body: '{}',
1025
+ })
1026
+ if (res.error) return { ok: false, error: res.error }
1027
+ return { ok: true, summary: res.data }
1028
+ }
1029
+
1030
+ export type SeoAutoApplyStatus = 'applied' | 'verify_failed' | 'reverted' | 'outcome_reverted'
1031
+ export type SeoAutoApplyOutcome = 'pending' | 'held' | 'regressed' | 'insufficient_data' | 'skipped'
1032
+
1033
+ export interface SeoAutoApplyRow {
1034
+ id: string
1035
+ kind: 'issue-fix' | 'redirect'
1036
+ issueType: string
1037
+ entityId: string | null
1038
+ /** Collection slug of the touched document; `null` for redirects. */
1039
+ collection: string | null
1040
+ entityTitle: string | null
1041
+ entityUrl: string | null
1042
+ path: string | null
1043
+ before: Record<string, unknown> | null
1044
+ after: Record<string, unknown>
1045
+ gate: {
1046
+ source: string
1047
+ brandAlignment: number | null
1048
+ confidence: number | null
1049
+ checks: Record<string, boolean>
1050
+ }
1051
+ status: SeoAutoApplyStatus
1052
+ outcome: SeoAutoApplyOutcome
1053
+ outcomeMetrics: Record<string, unknown> | null
1054
+ appliedAt: string
1055
+ revertedAt: string | null
1056
+ revertReason: string | null
1057
+ }
1058
+
1059
+ export interface SeoAutoApplyList {
1060
+ docs: SeoAutoApplyRow[]
1061
+ total: number
1062
+ page: number
1063
+ pageSize: number
1064
+ /** True for ADMIN callers — the only role the revert route accepts. */
1065
+ canRevert: boolean
1066
+ }
1067
+
1068
+ export async function fetchAutopilotApplied(params: {
1069
+ page: number
1070
+ pageSize: number
1071
+ status?: SeoAutoApplyStatus
1072
+ outcome?: SeoAutoApplyOutcome
1073
+ }): Promise<SeoAutoApplyList> {
1074
+ const search = new URLSearchParams({
1075
+ page: String(params.page),
1076
+ pageSize: String(params.pageSize),
1077
+ })
1078
+ if (params.status) search.set('status', params.status)
1079
+ if (params.outcome) search.set('outcome', params.outcome)
1080
+ const res = await cmsApi<SeoAutoApplyList>(`/seo/autopilot/applied?${search.toString()}`)
1081
+ throwIfError(res)
1082
+ return (
1083
+ res.data ?? {
1084
+ docs: [],
1085
+ total: 0,
1086
+ page: params.page,
1087
+ pageSize: params.pageSize,
1088
+ canRevert: false,
1089
+ }
1090
+ )
1091
+ }
1092
+
1093
+ /**
1094
+ * Revert one ledgered change. A 409 (`code: 'conflict'`) means a human edited
1095
+ * the same fields since autopilot wrote them; the server lists them in
1096
+ * `fields` and `force: true` overrides.
1097
+ */
1098
+ export async function revertAutopilotChange(
1099
+ id: string,
1100
+ force = false,
1101
+ ): Promise<{ ok: true } | { ok: false; error: string; conflictFields?: string[] }> {
1102
+ const res = await cmsApi<unknown>(`/seo/autopilot/applied/${encodeURIComponent(id)}/revert`, {
1103
+ method: 'POST',
1104
+ body: JSON.stringify({ force }),
1105
+ })
1106
+ if (res.error) {
1107
+ if (res.code !== 'conflict') return { ok: false, error: res.error }
1108
+ const fields = isRecord(res.errorBody) ? res.errorBody.fields : undefined
1109
+ return {
1110
+ ok: false,
1111
+ error: res.error,
1112
+ conflictFields: Array.isArray(fields)
1113
+ ? fields.filter((f): f is string => typeof f === 'string')
1114
+ : [],
1115
+ }
1116
+ }
1117
+ return { ok: true }
1118
+ }
1119
+
831
1120
  // ─── AI crawler activity ─────────────────────────────────────────────
832
1121
 
833
1122
  export interface AiCrawlerRow {
@@ -851,6 +1140,111 @@ export async function fetchAiCrawlerActivity(): Promise<AiCrawlerActivity> {
851
1140
  return res.data ?? { enabled: false, rows: [], totals: { hits: 0, bots: 0, honeypot: 0 } }
852
1141
  }
853
1142
 
1143
+ // ─── AI visibility (GEO measurement) ────────────────────────────────────────
1144
+ // Mirrors `packages/cms-core/src/seo/geo-report.ts` (`SeoGeoReport`) and the
1145
+ // `GET /seo/geo?days=N` route. The server is the contract; keep in lock-step.
1146
+
1147
+ export type SeoGeoDays = 7 | 28 | 90
1148
+ export type SeoGeoBotCategory = 'training' | 'retrieval' | 'search'
1149
+ /**
1150
+ * `blocked_still_crawling` = robots.txt disallows this bot's category, yet it
1151
+ * was observed in the window — the signal the tab surfaces most prominently.
1152
+ */
1153
+ export type SeoGeoBotPolicy = 'allowed' | 'blocked' | 'blocked_still_crawling'
1154
+
1155
+ /** A top-path row's optional join to the document behind it. */
1156
+ export interface SeoGeoEntityRef {
1157
+ entityId?: string
1158
+ entityTitle?: string
1159
+ /** Collection slug of the matched document; routes the editor link. */
1160
+ collection?: string
1161
+ }
1162
+
1163
+ /**
1164
+ * Why `enabled` is `false`: the kill switch in Settings → SEO (`settings`) or
1165
+ * the GEO tables are not migrated on the CMS database (`unavailable`).
1166
+ */
1167
+ export type SeoGeoDisabledReason = 'settings' | 'unavailable'
1168
+
1169
+ export interface SeoGeoReport {
1170
+ enabled: boolean
1171
+ /** Present only when `enabled` is `false`. */
1172
+ disabledReason?: SeoGeoDisabledReason
1173
+ window: { from: string; to: string; days: number }
1174
+ /** ISO date of the earliest recorded row, or null when nothing has arrived. */
1175
+ firstSeenAt: string | null
1176
+ crawl: {
1177
+ /** Whole-window crawl volume (incl. the capped "other" bucket). */
1178
+ total: { hits: number; prevHits: number | null }
1179
+ series: Array<{ date: string; hits: Record<string, number> }>
1180
+ bots: Array<{
1181
+ bot: string
1182
+ category: SeoGeoBotCategory
1183
+ hits: number
1184
+ /** `null` when the prior window is not fully covered by data. */
1185
+ prevHits: number | null
1186
+ lastSeenAt: string | null
1187
+ policy: SeoGeoBotPolicy
1188
+ }>
1189
+ topPaths: Array<SeoGeoEntityRef & { path: string; hits: number; bots: string[] }>
1190
+ /** Hits that rolled into the capped "other" bucket. */
1191
+ otherHits: number
1192
+ }
1193
+ referrals: {
1194
+ total: { visits: number; prevVisits: number | null }
1195
+ engines: Array<{ engine: string; visits: number; prevVisits: number | null }>
1196
+ topPaths: Array<SeoGeoEntityRef & { path: string; visits: number; engines: string[] }>
1197
+ /** Visits that rolled into the capped "other" bucket. */
1198
+ otherVisits: number
1199
+ }
1200
+ }
1201
+
1202
+ export async function fetchSeoGeo(days: SeoGeoDays): Promise<SeoGeoReport> {
1203
+ const res = await cmsApi<SeoGeoReport>(`/seo/geo?days=${days}`)
1204
+ throwIfError(res)
1205
+ // Validate the shape before handing it to the tab — an unexpected body must
1206
+ // render the error state, not crash in render. `crawl.total` is the newest
1207
+ // field on the wire; a `@prenta/core` deployed before it lands here.
1208
+ const d = res.data
1209
+ if (!d || typeof d.enabled !== 'boolean' || typeof d.crawl?.total?.hits !== 'number') {
1210
+ throw new Error('Unexpected AI visibility response')
1211
+ }
1212
+ return d
1213
+ }
1214
+
1215
+ // Mirrors `GET|PUT /seo/geo-settings` (`routes/seo-geo.ts`). The server clamps
1216
+ // `retentionDays` to `SEO_GEO_RETENTION` itself; the card clamps first so the
1217
+ // value it shows is the value that lands.
1218
+ export const SEO_GEO_RETENTION = { min: 7, max: 365 } as const
1219
+
1220
+ export interface SeoGeoSettings {
1221
+ /** Kill switch: when off the server drops incoming beacons. */
1222
+ enabled: boolean
1223
+ /** Daily-bucket retention, 7–365 days. */
1224
+ retentionDays: number
1225
+ }
1226
+
1227
+ export type SeoGeoSettingsPatch = Partial<SeoGeoSettings>
1228
+
1229
+ /** Resolves `null` on an error envelope or a malformed body so the card renders its error state. */
1230
+ export async function fetchGeoSettings(): Promise<SeoGeoSettings | null> {
1231
+ const res = await cmsApi<SeoGeoSettings>('/seo/geo-settings')
1232
+ const d = res.data
1233
+ if (!d || typeof d.enabled !== 'boolean' || typeof d.retentionDays !== 'number') return null
1234
+ return { enabled: d.enabled, retentionDays: d.retentionDays }
1235
+ }
1236
+
1237
+ export async function updateGeoSettings(
1238
+ patch: SeoGeoSettingsPatch,
1239
+ ): Promise<{ ok: boolean; error?: string }> {
1240
+ const res = await cmsApi<{ ok: boolean }>('/seo/geo-settings', {
1241
+ method: 'PUT',
1242
+ body: JSON.stringify(patch),
1243
+ })
1244
+ if (res.error) return { ok: false, error: res.error }
1245
+ return { ok: true }
1246
+ }
1247
+
854
1248
  export interface RobotsConfig {
855
1249
  body: string
856
1250
  generated: boolean
@@ -1057,7 +1451,17 @@ export async function fetchRedirectSuggestions(): Promise<RedirectSuggestion[]>
1057
1451
 
1058
1452
  export interface RedirectRecoverySettings {
1059
1453
  aiRerank: boolean
1454
+ /**
1455
+ * @deprecated Auto-apply moved to the SEO Autopilot engine (`types.redirect`
1456
+ * on `SeoAutopilotSettings`, managed from the Settings → SEO "SEO Autopilot"
1457
+ * card). The server still emits this for one release; nothing in the admin
1458
+ * reads it. Remove together with the server field.
1459
+ */
1060
1460
  autoApply: boolean
1461
+ /**
1462
+ * @deprecated Superseded by `SeoAutopilotSettings.redirectMinConfidence`,
1463
+ * managed from the "SEO Autopilot" card. Server-emitted for one release only.
1464
+ */
1061
1465
  autoApplyMinConfidence: number
1062
1466
  }
1063
1467
 
@@ -1079,10 +1483,16 @@ export async function saveRedirectRecoverySettings(
1079
1483
 
1080
1484
  export async function generate404RecoverySuggestions(): Promise<{
1081
1485
  error?: string
1486
+ /**
1487
+ * @deprecated Always `0`: generation no longer applies redirects. Auto-apply
1488
+ * is the SEO Autopilot engine's job (Settings → SEO "SEO Autopilot" card);
1489
+ * the server keeps the field for one release. Drop with the server field.
1490
+ */
1082
1491
  autoApplied?: number
1083
1492
  /**
1084
- * Set when AI governance vetoed a requested auto-apply (eligible plans were
1085
- * stored as pending suggestions instead). Human-readable reason to surface.
1493
+ * @deprecated Always `undefined` (server sends `null`): the governance veto
1494
+ * now happens inside the autopilot gate, not at generation time. Kept for
1495
+ * one release alongside `autoApplied`.
1086
1496
  */
1087
1497
  governanceBlocked?: string
1088
1498
  }> {
@@ -1367,6 +1777,15 @@ export interface SeoProposalsPayload {
1367
1777
  counts: { issueFixes: number; redirects: number; stale: number; eligibleForGeneration: number }
1368
1778
  /** `allowBulkApply` per feature, so the UI can disable batch approve up front. */
1369
1779
  governance: { issueFixBulk: boolean; redirectBulk: boolean }
1780
+ /** Autopilot status strip for the proposals inbox (mirror of core's payload). */
1781
+ autopilot: {
1782
+ enabled: boolean
1783
+ /** Set only while the engine is actually paused. */
1784
+ pausedReason: string | null
1785
+ lastRunAt: string | null
1786
+ /** Live autopilot changes (`status = applied`) from the last 7 days. */
1787
+ appliedLast7d: number
1788
+ }
1370
1789
  }
1371
1790
 
1372
1791
  export interface SeoProposalGenerateResult {
@@ -1410,16 +1829,30 @@ export interface SeoProposalBulkApplyResult {
1410
1829
  pending: number
1411
1830
  }
1412
1831
 
1832
+ /** What the chip shows when the server says nothing about autopilot. */
1833
+ const AUTOPILOT_OFF: SeoProposalsPayload['autopilot'] = {
1834
+ enabled: false,
1835
+ pausedReason: null,
1836
+ lastRunAt: null,
1837
+ appliedLast7d: 0,
1838
+ }
1839
+
1413
1840
  export async function fetchSeoProposals(): Promise<SeoProposalsPayload> {
1414
- const res = await cmsApi<SeoProposalsPayload>('/seo/proposals')
1841
+ // `autopilot` is optional on the wire: a `@prenta/core` deployed before
1842
+ // autopilot shipped omits it, and the tab must still render.
1843
+ const res = await cmsApi<Omit<SeoProposalsPayload, 'autopilot'> & Partial<SeoProposalsPayload>>(
1844
+ '/seo/proposals',
1845
+ )
1415
1846
  throwIfError(res)
1416
- return (
1417
- res.data ?? {
1847
+ if (!res.data) {
1848
+ return {
1418
1849
  proposals: [],
1419
1850
  counts: { issueFixes: 0, redirects: 0, stale: 0, eligibleForGeneration: 0 },
1420
1851
  governance: { issueFixBulk: false, redirectBulk: false },
1852
+ autopilot: AUTOPILOT_OFF,
1421
1853
  }
1422
- )
1854
+ }
1855
+ return { ...res.data, autopilot: res.data.autopilot ?? AUTOPILOT_OFF }
1423
1856
  }
1424
1857
 
1425
1858
  export async function generateSeoProposals(): Promise<{
package/src/views/SEO.tsx CHANGED
@@ -1,11 +1,11 @@
1
1
  'use client'
2
2
 
3
3
  /**
4
- * SEO Operations Center — seven-tab workflow surface (Overview / Content /
5
- * Links / Technical / Redirects / Audit / Proposals). Tab + deep-link state is
6
- * driven by the `?tab=` query string so audits, issues, redirects, proposals,
7
- * and content editors are all linkable. All data flows through
8
- * `lib/seo-service.ts`.
4
+ * SEO Operations Center — eight-tab workflow surface (Overview / Content /
5
+ * Links / Technical / AI visibility / Redirects / Audit / Proposals). Tab +
6
+ * deep-link state is driven by the `?tab=` query string so audits, issues,
7
+ * redirects, proposals, and content editors are all linkable. All data flows
8
+ * through `lib/seo-service.ts`.
9
9
  */
10
10
  import * as Tabs from '@radix-ui/react-tabs'
11
11
  import {
@@ -18,6 +18,7 @@ import {
18
18
  Bot,
19
19
  Link2,
20
20
  Inbox,
21
+ Radar,
21
22
  } from 'lucide-react'
22
23
  import { useCallback, useEffect, useMemo, useState } from 'react'
23
24
  import { toast } from 'sonner'
@@ -35,6 +36,7 @@ import { RedirectsTab } from './seo/RedirectsTab.js'
35
36
  import { AuditTab } from './seo/AuditTab.js'
36
37
  import { LinksTab } from './seo/LinksTab.js'
37
38
  import { ProposalsTab } from './seo/ProposalsTab.js'
39
+ import { AiVisibilityTab } from './seo/AiVisibilityTab.js'
38
40
 
39
41
  export interface SEOProps {
40
42
  onNavigate?: (path: string) => void
@@ -46,6 +48,7 @@ const TABS = [
46
48
  { id: 'content', label: 'Content SEO', icon: FileText },
47
49
  { id: 'links', label: 'Links', icon: Link2 },
48
50
  { id: 'technical', label: 'Technical', icon: Settings2 },
51
+ { id: 'ai-visibility', label: 'AI visibility', icon: Radar },
49
52
  { id: 'redirects', label: 'Redirects', icon: ArrowRightLeft },
50
53
  { id: 'audit', label: 'Audit', icon: ClipboardCheck },
51
54
  { id: 'proposals', label: 'Proposals', icon: Inbox },
@@ -74,6 +77,7 @@ export const SEO_DEEP_PATHS = [
74
77
  '/seo/links',
75
78
  '/seo/audit',
76
79
  '/seo/proposals',
80
+ '/seo/ai-visibility',
77
81
  '/seo',
78
82
  ] as const
79
83
 
@@ -96,6 +100,8 @@ export function seoTabForPath(pathname: string): TabId | null {
96
100
  return 'audit'
97
101
  case '/seo/proposals':
98
102
  return 'proposals'
103
+ case '/seo/ai-visibility':
104
+ return 'ai-visibility'
99
105
  case '/seo':
100
106
  return 'overview'
101
107
  default:
@@ -281,10 +287,13 @@ export function SEO({ onNavigate, initialTab = 'overview' }: SEOProps) {
281
287
  {activeTab === 'links' && <LinksTab onNavigate={onNavigate} planInfo={planInfo} />}
282
288
  </Tabs.Content>
283
289
  <Tabs.Content value="technical" tabIndex={-1}>
284
- {activeTab === 'technical' && <TechnicalTab />}
290
+ {activeTab === 'technical' && <TechnicalTab onNavigate={onNavigate} />}
291
+ </Tabs.Content>
292
+ <Tabs.Content value="ai-visibility" tabIndex={-1}>
293
+ {activeTab === 'ai-visibility' && <AiVisibilityTab onNavigate={onNavigate} />}
285
294
  </Tabs.Content>
286
295
  <Tabs.Content value="redirects" tabIndex={-1}>
287
- {activeTab === 'redirects' && <RedirectsTab />}
296
+ {activeTab === 'redirects' && <RedirectsTab onNavigate={onNavigate} />}
288
297
  </Tabs.Content>
289
298
  <Tabs.Content value="audit" tabIndex={-1}>
290
299
  {activeTab === 'audit' && (