@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.
- package/CHANGELOG.md +10 -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-proposals.test.js +28 -0
- package/dist/__tests__/lib/seo-service-proposals.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-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-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 +41 -4
- package/dist/__tests__/views/seo-settings.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 +206 -2
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +139 -5
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/dist/views/SEO.js +1 -1
- package/dist/views/SEO.js.map +1 -1
- 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/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/SeoSettingsTab.d.ts.map +1 -1
- package/dist/views/settings/SeoSettingsTab.js +2 -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-proposals.test.ts +29 -0
- package/src/__tests__/views/redirects-pane.render.test.tsx +36 -16
- 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-proposals-tab.render.test.tsx +161 -0
- package/src/__tests__/views/seo-settings.render.test.tsx +47 -7
- package/src/lib/api.ts +19 -3
- package/src/lib/seo-service.ts +334 -6
- package/src/views/SEO.tsx +1 -1
- 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/relative-age.ts +15 -0
- package/src/views/settings/SeoAutopilotCard.tsx +826 -0
- package/src/views/settings/SeoSettingsTab.tsx +2 -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 {
|
|
@@ -1057,7 +1346,17 @@ export async function fetchRedirectSuggestions(): Promise<RedirectSuggestion[]>
|
|
|
1057
1346
|
|
|
1058
1347
|
export interface RedirectRecoverySettings {
|
|
1059
1348
|
aiRerank: boolean
|
|
1349
|
+
/**
|
|
1350
|
+
* @deprecated Auto-apply moved to the SEO Autopilot engine (`types.redirect`
|
|
1351
|
+
* on `SeoAutopilotSettings`, managed from the Settings → SEO "SEO Autopilot"
|
|
1352
|
+
* card). The server still emits this for one release; nothing in the admin
|
|
1353
|
+
* reads it. Remove together with the server field.
|
|
1354
|
+
*/
|
|
1060
1355
|
autoApply: boolean
|
|
1356
|
+
/**
|
|
1357
|
+
* @deprecated Superseded by `SeoAutopilotSettings.redirectMinConfidence`,
|
|
1358
|
+
* managed from the "SEO Autopilot" card. Server-emitted for one release only.
|
|
1359
|
+
*/
|
|
1061
1360
|
autoApplyMinConfidence: number
|
|
1062
1361
|
}
|
|
1063
1362
|
|
|
@@ -1079,10 +1378,16 @@ export async function saveRedirectRecoverySettings(
|
|
|
1079
1378
|
|
|
1080
1379
|
export async function generate404RecoverySuggestions(): Promise<{
|
|
1081
1380
|
error?: string
|
|
1381
|
+
/**
|
|
1382
|
+
* @deprecated Always `0`: generation no longer applies redirects. Auto-apply
|
|
1383
|
+
* is the SEO Autopilot engine's job (Settings → SEO "SEO Autopilot" card);
|
|
1384
|
+
* the server keeps the field for one release. Drop with the server field.
|
|
1385
|
+
*/
|
|
1082
1386
|
autoApplied?: number
|
|
1083
1387
|
/**
|
|
1084
|
-
*
|
|
1085
|
-
*
|
|
1388
|
+
* @deprecated Always `undefined` (server sends `null`): the governance veto
|
|
1389
|
+
* now happens inside the autopilot gate, not at generation time. Kept for
|
|
1390
|
+
* one release alongside `autoApplied`.
|
|
1086
1391
|
*/
|
|
1087
1392
|
governanceBlocked?: string
|
|
1088
1393
|
}> {
|
|
@@ -1367,6 +1672,15 @@ export interface SeoProposalsPayload {
|
|
|
1367
1672
|
counts: { issueFixes: number; redirects: number; stale: number; eligibleForGeneration: number }
|
|
1368
1673
|
/** `allowBulkApply` per feature, so the UI can disable batch approve up front. */
|
|
1369
1674
|
governance: { issueFixBulk: boolean; redirectBulk: boolean }
|
|
1675
|
+
/** Autopilot status strip for the proposals inbox (mirror of core's payload). */
|
|
1676
|
+
autopilot: {
|
|
1677
|
+
enabled: boolean
|
|
1678
|
+
/** Set only while the engine is actually paused. */
|
|
1679
|
+
pausedReason: string | null
|
|
1680
|
+
lastRunAt: string | null
|
|
1681
|
+
/** Live autopilot changes (`status = applied`) from the last 7 days. */
|
|
1682
|
+
appliedLast7d: number
|
|
1683
|
+
}
|
|
1370
1684
|
}
|
|
1371
1685
|
|
|
1372
1686
|
export interface SeoProposalGenerateResult {
|
|
@@ -1410,16 +1724,30 @@ export interface SeoProposalBulkApplyResult {
|
|
|
1410
1724
|
pending: number
|
|
1411
1725
|
}
|
|
1412
1726
|
|
|
1727
|
+
/** What the chip shows when the server says nothing about autopilot. */
|
|
1728
|
+
const AUTOPILOT_OFF: SeoProposalsPayload['autopilot'] = {
|
|
1729
|
+
enabled: false,
|
|
1730
|
+
pausedReason: null,
|
|
1731
|
+
lastRunAt: null,
|
|
1732
|
+
appliedLast7d: 0,
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1413
1735
|
export async function fetchSeoProposals(): Promise<SeoProposalsPayload> {
|
|
1414
|
-
|
|
1736
|
+
// `autopilot` is optional on the wire: a `@prenta/core` deployed before
|
|
1737
|
+
// autopilot shipped omits it, and the tab must still render.
|
|
1738
|
+
const res = await cmsApi<Omit<SeoProposalsPayload, 'autopilot'> & Partial<SeoProposalsPayload>>(
|
|
1739
|
+
'/seo/proposals',
|
|
1740
|
+
)
|
|
1415
1741
|
throwIfError(res)
|
|
1416
|
-
|
|
1417
|
-
|
|
1742
|
+
if (!res.data) {
|
|
1743
|
+
return {
|
|
1418
1744
|
proposals: [],
|
|
1419
1745
|
counts: { issueFixes: 0, redirects: 0, stale: 0, eligibleForGeneration: 0 },
|
|
1420
1746
|
governance: { issueFixBulk: false, redirectBulk: false },
|
|
1747
|
+
autopilot: AUTOPILOT_OFF,
|
|
1421
1748
|
}
|
|
1422
|
-
|
|
1749
|
+
}
|
|
1750
|
+
return { ...res.data, autopilot: res.data.autopilot ?? AUTOPILOT_OFF }
|
|
1423
1751
|
}
|
|
1424
1752
|
|
|
1425
1753
|
export async function generateSeoProposals(): Promise<{
|
package/src/views/SEO.tsx
CHANGED
|
@@ -284,7 +284,7 @@ export function SEO({ onNavigate, initialTab = 'overview' }: SEOProps) {
|
|
|
284
284
|
{activeTab === 'technical' && <TechnicalTab />}
|
|
285
285
|
</Tabs.Content>
|
|
286
286
|
<Tabs.Content value="redirects" tabIndex={-1}>
|
|
287
|
-
{activeTab === 'redirects' && <RedirectsTab />}
|
|
287
|
+
{activeTab === 'redirects' && <RedirectsTab onNavigate={onNavigate} />}
|
|
288
288
|
</Tabs.Content>
|
|
289
289
|
<Tabs.Content value="audit" tabIndex={-1}>
|
|
290
290
|
{activeTab === 'audit' && (
|