@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.
- package/CHANGELOG.md +16 -0
- package/dist/__tests__/lib/api.test.d.ts +2 -0
- package/dist/__tests__/lib/api.test.d.ts.map +1 -0
- package/dist/__tests__/lib/api.test.js +58 -0
- package/dist/__tests__/lib/api.test.js.map +1 -0
- package/dist/__tests__/lib/seo-service-autopilot.test.d.ts +2 -0
- package/dist/__tests__/lib/seo-service-autopilot.test.d.ts.map +1 -0
- package/dist/__tests__/lib/seo-service-autopilot.test.js +171 -0
- package/dist/__tests__/lib/seo-service-autopilot.test.js.map +1 -0
- package/dist/__tests__/lib/seo-service-geo.test.d.ts +2 -0
- package/dist/__tests__/lib/seo-service-geo.test.d.ts.map +1 -0
- package/dist/__tests__/lib/seo-service-geo.test.js +103 -0
- package/dist/__tests__/lib/seo-service-geo.test.js.map +1 -0
- package/dist/__tests__/lib/seo-service-proposals.test.js +28 -0
- package/dist/__tests__/lib/seo-service-proposals.test.js.map +1 -1
- package/dist/__tests__/router/seo-tab-for-path.test.js +4 -0
- package/dist/__tests__/router/seo-tab-for-path.test.js.map +1 -1
- package/dist/__tests__/views/redirects-pane.render.test.js +30 -12
- package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-ai-visibility.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-ai-visibility.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-ai-visibility.render.test.js +244 -0
- package/dist/__tests__/views/seo-ai-visibility.render.test.js.map +1 -0
- package/dist/__tests__/views/seo-autopilot-applied.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-autopilot-applied.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-autopilot-applied.render.test.js +330 -0
- package/dist/__tests__/views/seo-autopilot-applied.render.test.js.map +1 -0
- package/dist/__tests__/views/seo-autopilot-card.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-autopilot-card.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-autopilot-card.render.test.js +512 -0
- package/dist/__tests__/views/seo-autopilot-card.render.test.js.map +1 -0
- package/dist/__tests__/views/seo-geo-settings-card.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-geo-settings-card.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-geo-settings-card.render.test.js +227 -0
- package/dist/__tests__/views/seo-geo-settings-card.render.test.js.map +1 -0
- package/dist/__tests__/views/seo-proposals-tab.render.test.js +139 -0
- package/dist/__tests__/views/seo-proposals-tab.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-settings.render.test.js +53 -4
- package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-technical-tab.render.test.js +13 -0
- package/dist/__tests__/views/seo-technical-tab.render.test.js.map +1 -1
- package/dist/lib/api.d.ts +13 -2
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +1 -2
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/seo-service.d.ts +300 -2
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +172 -5
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/dist/views/SEO.d.ts +5 -1
- package/dist/views/SEO.d.ts.map +1 -1
- package/dist/views/SEO.js +12 -7
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/seo/AiVisibilityTab.d.ts +4 -0
- package/dist/views/seo/AiVisibilityTab.d.ts.map +1 -0
- package/dist/views/seo/AiVisibilityTab.js +165 -0
- package/dist/views/seo/AiVisibilityTab.js.map +1 -0
- package/dist/views/seo/AutopilotAppliedSection.d.ts +10 -0
- package/dist/views/seo/AutopilotAppliedSection.d.ts.map +1 -0
- package/dist/views/seo/AutopilotAppliedSection.js +295 -0
- package/dist/views/seo/AutopilotAppliedSection.js.map +1 -0
- package/dist/views/seo/ProposalsTab.d.ts.map +1 -1
- package/dist/views/seo/ProposalsTab.js +53 -17
- package/dist/views/seo/ProposalsTab.js.map +1 -1
- package/dist/views/seo/RedirectsTab.d.ts +3 -1
- package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
- package/dist/views/seo/RedirectsTab.js +14 -19
- package/dist/views/seo/RedirectsTab.js.map +1 -1
- package/dist/views/seo/TechnicalTab.d.ts +3 -1
- package/dist/views/seo/TechnicalTab.d.ts.map +1 -1
- package/dist/views/seo/TechnicalTab.js +4 -4
- package/dist/views/seo/TechnicalTab.js.map +1 -1
- package/dist/views/seo/relative-age.d.ts +7 -0
- package/dist/views/seo/relative-age.d.ts.map +1 -0
- package/dist/views/seo/relative-age.js +20 -0
- package/dist/views/seo/relative-age.js.map +1 -0
- package/dist/views/settings/SeoAutopilotCard.d.ts +30 -0
- package/dist/views/settings/SeoAutopilotCard.d.ts.map +1 -0
- package/dist/views/settings/SeoAutopilotCard.js +375 -0
- package/dist/views/settings/SeoAutopilotCard.js.map +1 -0
- package/dist/views/settings/SeoGeoSettingsCard.d.ts +12 -0
- package/dist/views/settings/SeoGeoSettingsCard.d.ts.map +1 -0
- package/dist/views/settings/SeoGeoSettingsCard.js +178 -0
- package/dist/views/settings/SeoGeoSettingsCard.js.map +1 -0
- package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
- package/dist/views/settings/SeoSettingsTab.js +3 -1
- package/dist/views/settings/SeoSettingsTab.js.map +1 -1
- package/dist/views/settings/components.d.ts +3 -1
- package/dist/views/settings/components.d.ts.map +1 -1
- package/dist/views/settings/components.js +2 -2
- package/dist/views/settings/components.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/lib/api.test.ts +76 -0
- package/src/__tests__/lib/seo-service-autopilot.test.ts +196 -0
- package/src/__tests__/lib/seo-service-geo.test.ts +118 -0
- package/src/__tests__/lib/seo-service-proposals.test.ts +29 -0
- package/src/__tests__/router/seo-tab-for-path.test.ts +5 -0
- package/src/__tests__/views/redirects-pane.render.test.tsx +36 -16
- package/src/__tests__/views/seo-ai-visibility.render.test.tsx +279 -0
- package/src/__tests__/views/seo-autopilot-applied.render.test.tsx +382 -0
- package/src/__tests__/views/seo-autopilot-card.render.test.tsx +610 -0
- package/src/__tests__/views/seo-geo-settings-card.render.test.tsx +253 -0
- package/src/__tests__/views/seo-proposals-tab.render.test.tsx +161 -0
- package/src/__tests__/views/seo-settings.render.test.tsx +68 -7
- package/src/__tests__/views/seo-technical-tab.render.test.tsx +15 -0
- package/src/lib/api.ts +19 -3
- package/src/lib/seo-service.ts +439 -6
- package/src/views/SEO.tsx +16 -7
- package/src/views/seo/AiVisibilityTab.tsx +616 -0
- package/src/views/seo/AutopilotAppliedSection.tsx +597 -0
- package/src/views/seo/ProposalsTab.tsx +92 -12
- package/src/views/seo/RedirectsTab.tsx +39 -60
- package/src/views/seo/TechnicalTab.tsx +27 -8
- package/src/views/seo/relative-age.ts +15 -0
- package/src/views/settings/SeoAutopilotCard.tsx +826 -0
- package/src/views/settings/SeoGeoSettingsCard.tsx +310 -0
- package/src/views/settings/SeoSettingsTab.tsx +4 -0
- package/src/views/settings/components.tsx +10 -1
package/src/lib/seo-service.ts
CHANGED
|
@@ -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
|
-
*
|
|
1085
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
1417
|
-
|
|
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 —
|
|
5
|
-
* Links / Technical / Redirects / Audit / Proposals). Tab +
|
|
6
|
-
* driven by the `?tab=` query string so audits, issues,
|
|
7
|
-
* and content editors are all linkable. All data flows
|
|
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' && (
|