@actuate-media/cms-admin 0.18.0 → 0.20.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 (167) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +9 -5
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/__tests__/lib/post-editor-service.test.js +75 -23
  5. package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
  6. package/dist/__tests__/lib/posts-service.test.js +132 -142
  7. package/dist/__tests__/lib/posts-service.test.js.map +1 -1
  8. package/dist/__tests__/views/posts-list-view.render.test.d.ts +2 -0
  9. package/dist/__tests__/views/posts-list-view.render.test.d.ts.map +1 -0
  10. package/dist/__tests__/views/posts-list-view.render.test.js +73 -0
  11. package/dist/__tests__/views/posts-list-view.render.test.js.map +1 -0
  12. package/dist/actuate-admin.css +1 -1
  13. package/dist/components/seo/Drawer.d.ts +11 -0
  14. package/dist/components/seo/Drawer.d.ts.map +1 -0
  15. package/dist/components/seo/Drawer.js +13 -0
  16. package/dist/components/seo/Drawer.js.map +1 -0
  17. package/dist/components/seo/RedirectEditorDrawer.d.ts +9 -0
  18. package/dist/components/seo/RedirectEditorDrawer.d.ts.map +1 -0
  19. package/dist/components/seo/RedirectEditorDrawer.js +94 -0
  20. package/dist/components/seo/RedirectEditorDrawer.js.map +1 -0
  21. package/dist/components/seo/SeoCopilotDrawer.d.ts +10 -0
  22. package/dist/components/seo/SeoCopilotDrawer.d.ts.map +1 -0
  23. package/dist/components/seo/SeoCopilotDrawer.js +34 -0
  24. package/dist/components/seo/SeoCopilotDrawer.js.map +1 -0
  25. package/dist/components/seo/SeoEditorDrawer.d.ts +8 -0
  26. package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -0
  27. package/dist/components/seo/SeoEditorDrawer.js +111 -0
  28. package/dist/components/seo/SeoEditorDrawer.js.map +1 -0
  29. package/dist/components/seo/SeoIssueFixDrawer.d.ts +8 -0
  30. package/dist/components/seo/SeoIssueFixDrawer.d.ts.map +1 -0
  31. package/dist/components/seo/SeoIssueFixDrawer.js +111 -0
  32. package/dist/components/seo/SeoIssueFixDrawer.js.map +1 -0
  33. package/dist/components/seo/primitives.d.ts +53 -0
  34. package/dist/components/seo/primitives.d.ts.map +1 -0
  35. package/dist/components/seo/primitives.js +100 -0
  36. package/dist/components/seo/primitives.js.map +1 -0
  37. package/dist/index.d.ts +0 -2
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +0 -2
  40. package/dist/index.js.map +1 -1
  41. package/dist/layout/Sidebar.d.ts.map +1 -1
  42. package/dist/layout/Sidebar.js +3 -0
  43. package/dist/layout/Sidebar.js.map +1 -1
  44. package/dist/lib/post-editor-service.d.ts +6 -4
  45. package/dist/lib/post-editor-service.d.ts.map +1 -1
  46. package/dist/lib/post-editor-service.js +32 -37
  47. package/dist/lib/post-editor-service.js.map +1 -1
  48. package/dist/lib/posts-service.d.ts.map +1 -1
  49. package/dist/lib/posts-service.js +91 -160
  50. package/dist/lib/posts-service.js.map +1 -1
  51. package/dist/lib/seo-service.d.ts +322 -0
  52. package/dist/lib/seo-service.d.ts.map +1 -0
  53. package/dist/lib/seo-service.js +295 -0
  54. package/dist/lib/seo-service.js.map +1 -0
  55. package/dist/router/index.d.ts.map +1 -1
  56. package/dist/router/index.js +4 -1
  57. package/dist/router/index.js.map +1 -1
  58. package/dist/views/MediaBrowser.d.ts.map +1 -1
  59. package/dist/views/MediaBrowser.js +36 -25
  60. package/dist/views/MediaBrowser.js.map +1 -1
  61. package/dist/views/Posts/NewPostPicker.d.ts +5 -4
  62. package/dist/views/Posts/NewPostPicker.d.ts.map +1 -1
  63. package/dist/views/Posts/NewPostPicker.js +5 -4
  64. package/dist/views/Posts/NewPostPicker.js.map +1 -1
  65. package/dist/views/Posts/NewPostTypeInstructions.js +2 -2
  66. package/dist/views/Posts/PostTypesView.d.ts +5 -4
  67. package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
  68. package/dist/views/Posts/PostTypesView.js.map +1 -1
  69. package/dist/views/Posts/PostsListView.d.ts.map +1 -1
  70. package/dist/views/Posts/PostsListView.js +8 -0
  71. package/dist/views/Posts/PostsListView.js.map +1 -1
  72. package/dist/views/Posts/post-type-presets.d.ts +5 -0
  73. package/dist/views/Posts/post-type-presets.d.ts.map +1 -1
  74. package/dist/views/Posts/post-type-presets.js +10 -64
  75. package/dist/views/Posts/post-type-presets.js.map +1 -1
  76. package/dist/views/SEO.d.ts.map +1 -1
  77. package/dist/views/SEO.js +89 -132
  78. package/dist/views/SEO.js.map +1 -1
  79. package/dist/views/page-editor/sections/ArticleBodySection.d.ts +6 -2
  80. package/dist/views/page-editor/sections/ArticleBodySection.d.ts.map +1 -1
  81. package/dist/views/page-editor/sections/ArticleBodySection.js +8 -8
  82. package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -1
  83. package/dist/views/post-editor/PostEditorCanvas.js +7 -1
  84. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  85. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
  86. package/dist/views/post-editor/PostFieldsModal.js +10 -4
  87. package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
  88. package/dist/views/post-editor/PostHeader.d.ts.map +1 -1
  89. package/dist/views/post-editor/PostHeader.js +4 -3
  90. package/dist/views/post-editor/PostHeader.js.map +1 -1
  91. package/dist/views/post-editor/PostPreview.d.ts.map +1 -1
  92. package/dist/views/post-editor/PostPreview.js +11 -1
  93. package/dist/views/post-editor/PostPreview.js.map +1 -1
  94. package/dist/views/post-editor/PostSectionEditor.d.ts +22 -2
  95. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  96. package/dist/views/post-editor/PostSectionEditor.js +4 -1
  97. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  98. package/dist/views/seo/AuditTab.d.ts +6 -0
  99. package/dist/views/seo/AuditTab.d.ts.map +1 -0
  100. package/dist/views/seo/AuditTab.js +57 -0
  101. package/dist/views/seo/AuditTab.js.map +1 -0
  102. package/dist/views/seo/ContentTab.d.ts +5 -0
  103. package/dist/views/seo/ContentTab.d.ts.map +1 -0
  104. package/dist/views/seo/ContentTab.js +27 -0
  105. package/dist/views/seo/ContentTab.js.map +1 -0
  106. package/dist/views/seo/OverviewTab.d.ts +8 -0
  107. package/dist/views/seo/OverviewTab.d.ts.map +1 -0
  108. package/dist/views/seo/OverviewTab.js +35 -0
  109. package/dist/views/seo/OverviewTab.js.map +1 -0
  110. package/dist/views/seo/RedirectsTab.d.ts +2 -0
  111. package/dist/views/seo/RedirectsTab.d.ts.map +1 -0
  112. package/dist/views/seo/RedirectsTab.js +124 -0
  113. package/dist/views/seo/RedirectsTab.js.map +1 -0
  114. package/dist/views/seo/TechnicalTab.d.ts +2 -0
  115. package/dist/views/seo/TechnicalTab.d.ts.map +1 -0
  116. package/dist/views/seo/TechnicalTab.js +102 -0
  117. package/dist/views/seo/TechnicalTab.js.map +1 -0
  118. package/dist/views/seo/useSeoResource.d.ts +8 -0
  119. package/dist/views/seo/useSeoResource.d.ts.map +1 -0
  120. package/dist/views/seo/useSeoResource.js +42 -0
  121. package/dist/views/seo/useSeoResource.js.map +1 -0
  122. package/package.json +2 -2
  123. package/src/AdminRoot.tsx +9 -5
  124. package/src/__tests__/lib/post-editor-service.test.ts +78 -23
  125. package/src/__tests__/lib/posts-service.test.ts +134 -163
  126. package/src/__tests__/views/posts-list-view.render.test.tsx +84 -0
  127. package/src/components/seo/Drawer.tsx +61 -0
  128. package/src/components/seo/RedirectEditorDrawer.tsx +235 -0
  129. package/src/components/seo/SeoCopilotDrawer.tsx +76 -0
  130. package/src/components/seo/SeoEditorDrawer.tsx +347 -0
  131. package/src/components/seo/SeoIssueFixDrawer.tsx +244 -0
  132. package/src/components/seo/primitives.tsx +237 -0
  133. package/src/index.ts +0 -2
  134. package/src/layout/Sidebar.tsx +3 -0
  135. package/src/lib/post-editor-service.ts +41 -40
  136. package/src/lib/posts-service.ts +109 -180
  137. package/src/lib/seo-service.ts +623 -0
  138. package/src/router/index.ts +4 -1
  139. package/src/views/MediaBrowser.tsx +89 -67
  140. package/src/views/Posts/NewPostPicker.tsx +5 -4
  141. package/src/views/Posts/NewPostTypeInstructions.tsx +2 -2
  142. package/src/views/Posts/PostTypesView.tsx +5 -4
  143. package/src/views/Posts/PostsListView.tsx +9 -0
  144. package/src/views/Posts/post-type-presets.ts +15 -64
  145. package/src/views/SEO.tsx +186 -863
  146. package/src/views/page-editor/sections/ArticleBodySection.tsx +12 -16
  147. package/src/views/post-editor/PostEditorCanvas.tsx +7 -1
  148. package/src/views/post-editor/PostFieldsModal.tsx +36 -21
  149. package/src/views/post-editor/PostHeader.tsx +4 -4
  150. package/src/views/post-editor/PostPreview.tsx +12 -1
  151. package/src/views/post-editor/PostSectionEditor.tsx +22 -5
  152. package/src/views/seo/AuditTab.tsx +229 -0
  153. package/src/views/seo/ContentTab.tsx +164 -0
  154. package/src/views/seo/OverviewTab.tsx +257 -0
  155. package/src/views/seo/RedirectsTab.tsx +392 -0
  156. package/src/views/seo/TechnicalTab.tsx +303 -0
  157. package/src/views/seo/useSeoResource.ts +52 -0
  158. package/dist/views/PostEditor.d.ts +0 -6
  159. package/dist/views/PostEditor.d.ts.map +0 -1
  160. package/dist/views/PostEditor.js +0 -79
  161. package/dist/views/PostEditor.js.map +0 -1
  162. package/dist/views/Posts.d.ts +0 -5
  163. package/dist/views/Posts.d.ts.map +0 -1
  164. package/dist/views/Posts.js +0 -73
  165. package/dist/views/Posts.js.map +0 -1
  166. package/src/views/PostEditor.tsx +0 -257
  167. package/src/views/Posts.tsx +0 -375
@@ -0,0 +1,623 @@
1
+ /**
2
+ * SEO service — typed client over the cms-core SEO REST endpoints.
3
+ *
4
+ * Every SEO admin component reads/writes through this module (never raw
5
+ * `cmsApi` / `fetch`), so the network contract lives in one place. Fetchers
6
+ * return typed data with safe fallbacks; mutations return `{ error?: string }`.
7
+ *
8
+ * Endpoint paths here are the contract implemented by cms-core
9
+ * `packages/cms-core/src/api/handlers.ts` across phases 2-3 and 6.
10
+ */
11
+ import { cmsApi } from './api.js'
12
+
13
+ // ─── Shared enums ────────────────────────────────────────────────────
14
+
15
+ export type SeoGrade = 'good' | 'fair' | 'poor' | 'critical'
16
+ export type SeoSeverity = 'critical' | 'warning' | 'info'
17
+ export type SeoIssueStatus = 'open' | 'ignored' | 'resolved' | 'recurring'
18
+ export type SeoIssueCategory =
19
+ | 'content'
20
+ | 'technical'
21
+ | 'redirects'
22
+ | 'media'
23
+ | 'performance'
24
+ | 'schema'
25
+ | 'social'
26
+ | 'indexing'
27
+ export type RedirectStatusCode = 301 | 302 | 307 | 308 | 410
28
+
29
+ // ─── Overview ────────────────────────────────────────────────────────
30
+
31
+ export interface SeoSiteScore {
32
+ score: number
33
+ grade: SeoGrade
34
+ /** Change vs the previous audit run, e.g. +6. */
35
+ delta: number | null
36
+ breakdown: { category: string; score: number; weight: number; contribution: number }[]
37
+ }
38
+
39
+ export interface SeoMetric {
40
+ label: string
41
+ value: string
42
+ helper: string
43
+ /** Optional trend direction for the helper colour. */
44
+ trend?: 'up' | 'down' | 'flat'
45
+ }
46
+
47
+ export interface SeoIssue {
48
+ id: string
49
+ auditRunId: string | null
50
+ entityType: string | null
51
+ entityId: string | null
52
+ entityTitle: string | null
53
+ url: string | null
54
+ severity: SeoSeverity
55
+ category: SeoIssueCategory
56
+ type: string
57
+ title: string
58
+ description: string | null
59
+ impact: string | null
60
+ recommendation: string | null
61
+ status: SeoIssueStatus
62
+ fixable: boolean
63
+ fixActionType: string | null
64
+ relatedEntityIds: string[]
65
+ source: string
66
+ firstDetectedAt: string | null
67
+ lastDetectedAt: string | null
68
+ resolvedAt: string | null
69
+ }
70
+
71
+ export interface QuickWin {
72
+ id: string
73
+ title: string
74
+ explanation: string
75
+ impact: 'High' | 'Medium' | 'Low'
76
+ issueId: string | null
77
+ fixActionType: string | null
78
+ }
79
+
80
+ export interface SearchPerformanceRow {
81
+ url: string
82
+ title: string
83
+ impressions: number
84
+ clicks: number
85
+ ctr: number
86
+ averagePosition: number
87
+ }
88
+
89
+ export interface SeoOverview {
90
+ siteScore: SeoSiteScore
91
+ metrics: SeoMetric[]
92
+ issues: SeoIssue[]
93
+ issuesSummary: { total: number; critical: number; warning: number; info: number }
94
+ quickWins: QuickWin[]
95
+ searchPerformance: SearchPerformanceRow[]
96
+ searchConsoleConnected: boolean
97
+ lastAuditRunId: string | null
98
+ }
99
+
100
+ const EMPTY_OVERVIEW: SeoOverview = {
101
+ siteScore: { score: 0, grade: 'critical', delta: null, breakdown: [] },
102
+ metrics: [],
103
+ issues: [],
104
+ issuesSummary: { total: 0, critical: 0, warning: 0, info: 0 },
105
+ quickWins: [],
106
+ searchPerformance: [],
107
+ searchConsoleConnected: false,
108
+ lastAuditRunId: null,
109
+ }
110
+
111
+ export async function fetchSeoOverview(): Promise<SeoOverview> {
112
+ const res = await cmsApi<SeoOverview>('/seo/overview')
113
+ return res.data ?? EMPTY_OVERVIEW
114
+ }
115
+
116
+ export async function fetchSearchPerformance(
117
+ params: {
118
+ limit?: number
119
+ } = {},
120
+ ): Promise<SearchPerformanceRow[]> {
121
+ const qs = params.limit ? `?limit=${params.limit}` : ''
122
+ const res = await cmsApi<{ rows: SearchPerformanceRow[] }>(`/seo/search-performance${qs}`)
123
+ return res.data?.rows ?? []
124
+ }
125
+
126
+ // ─── Content SEO ─────────────────────────────────────────────────────
127
+
128
+ export interface SeoContentRecord {
129
+ id: string
130
+ entityType: 'page' | 'post'
131
+ entityId: string
132
+ title: string
133
+ url: string
134
+ canonicalUrl: string | null
135
+ metaTitle: string | null
136
+ metaDescription: string | null
137
+ focusKeyword: string | null
138
+ ogTitle: string | null
139
+ ogDescription: string | null
140
+ ogImageId: string | null
141
+ ogImage: string | null
142
+ twitterTitle: string | null
143
+ twitterDescription: string | null
144
+ twitterImageId: string | null
145
+ noindex: boolean
146
+ nofollow: boolean
147
+ structuredDataType: string | null
148
+ structuredData: Record<string, unknown> | null
149
+ seoScore: number
150
+ seoGrade: SeoGrade
151
+ updatedAt: string | null
152
+ }
153
+
154
+ export type ContentSeoFilter = 'all' | 'pages' | 'posts'
155
+
156
+ export async function fetchSeoContentRecords(): Promise<SeoContentRecord[]> {
157
+ const res = await cmsApi<{ records: SeoContentRecord[] }>('/seo/content')
158
+ return res.data?.records ?? []
159
+ }
160
+
161
+ export async function fetchSeoContentRecord(
162
+ entityType: string,
163
+ entityId: string,
164
+ ): Promise<SeoContentRecord | null> {
165
+ const res = await cmsApi<SeoContentRecord>(
166
+ `/seo/content/${entityType}/${encodeURIComponent(entityId)}`,
167
+ )
168
+ return res.data ?? null
169
+ }
170
+
171
+ export interface SeoFieldPayload {
172
+ metaTitle?: string
173
+ metaDescription?: string
174
+ canonicalUrl?: string
175
+ focusKeyword?: string
176
+ ogTitle?: string
177
+ ogDescription?: string
178
+ ogImage?: string
179
+ twitterTitle?: string
180
+ twitterDescription?: string
181
+ twitterImage?: string
182
+ noindex?: boolean
183
+ nofollow?: boolean
184
+ structuredDataType?: string
185
+ structuredData?: Record<string, unknown>
186
+ }
187
+
188
+ export async function updateSeoFields(
189
+ entityType: string,
190
+ entityId: string,
191
+ payload: SeoFieldPayload,
192
+ ): Promise<{ error?: string }> {
193
+ const res = await cmsApi(`/seo/content/${entityType}/${encodeURIComponent(entityId)}`, {
194
+ method: 'PUT',
195
+ body: JSON.stringify(payload),
196
+ })
197
+ return res.error ? { error: res.error } : {}
198
+ }
199
+
200
+ /** Pure search/filter over the content records (decoupled from network for tests). */
201
+ export function applyContentSeoQuery(
202
+ records: SeoContentRecord[],
203
+ query: { search?: string; filter?: ContentSeoFilter },
204
+ ): SeoContentRecord[] {
205
+ const search = (query.search ?? '').trim().toLowerCase()
206
+ const filter = query.filter ?? 'all'
207
+ return records.filter((r) => {
208
+ if (filter === 'pages' && r.entityType !== 'page') return false
209
+ if (filter === 'posts' && r.entityType !== 'post') return false
210
+ if (search) {
211
+ const haystack = [
212
+ r.title,
213
+ r.url,
214
+ r.metaTitle ?? '',
215
+ r.metaDescription ?? '',
216
+ String(r.seoScore),
217
+ r.entityType,
218
+ ]
219
+ .join(' ')
220
+ .toLowerCase()
221
+ if (!haystack.includes(search)) return false
222
+ }
223
+ return true
224
+ })
225
+ }
226
+
227
+ // ─── Audit ───────────────────────────────────────────────────────────
228
+
229
+ export type SeoAuditScope = 'full' | 'content' | 'technical' | 'redirect'
230
+
231
+ export interface SeoAuditRun {
232
+ id: string
233
+ status: 'RUNNING' | 'COMPLETED' | 'FAILED'
234
+ scope: string
235
+ startedAt: string
236
+ finishedAt: string | null
237
+ siteScoreBefore: number | null
238
+ siteScoreAfter: number | null
239
+ pagesScanned: number
240
+ issuesFound: number
241
+ criticalCount: number
242
+ warningCount: number
243
+ infoCount: number
244
+ errorMessage: string | null
245
+ }
246
+
247
+ export async function runSeoAudit(
248
+ scope: SeoAuditScope = 'full',
249
+ ): Promise<{ run?: SeoAuditRun; error?: string }> {
250
+ const res = await cmsApi<SeoAuditRun>('/seo/audit', {
251
+ method: 'POST',
252
+ body: JSON.stringify({ scope }),
253
+ })
254
+ if (res.error) return { error: res.error }
255
+ return { run: res.data }
256
+ }
257
+
258
+ export async function fetchSeoAuditRuns(): Promise<SeoAuditRun[]> {
259
+ const res = await cmsApi<{ runs: SeoAuditRun[] }>('/seo/audits')
260
+ return res.data?.runs ?? []
261
+ }
262
+
263
+ export async function fetchSeoAuditRun(auditRunId: string): Promise<SeoAuditRun | null> {
264
+ const res = await cmsApi<SeoAuditRun>(`/seo/audits/${encodeURIComponent(auditRunId)}`)
265
+ return res.data ?? null
266
+ }
267
+
268
+ export interface SeoIssueQuery {
269
+ severity?: SeoSeverity | 'all'
270
+ category?: SeoIssueCategory | 'all'
271
+ status?: SeoIssueStatus | 'all'
272
+ auditRunId?: string
273
+ }
274
+
275
+ export async function fetchSeoIssues(query: SeoIssueQuery = {}): Promise<SeoIssue[]> {
276
+ const params = new URLSearchParams()
277
+ if (query.severity && query.severity !== 'all') params.set('severity', query.severity)
278
+ if (query.category && query.category !== 'all') params.set('category', query.category)
279
+ if (query.status && query.status !== 'all') params.set('status', query.status)
280
+ if (query.auditRunId) params.set('auditRunId', query.auditRunId)
281
+ const qs = params.toString()
282
+ const res = await cmsApi<{ issues: SeoIssue[] }>(`/seo/issues${qs ? `?${qs}` : ''}`)
283
+ return res.data?.issues ?? []
284
+ }
285
+
286
+ export async function fetchSeoIssue(issueId: string): Promise<SeoIssue | null> {
287
+ const res = await cmsApi<SeoIssue>(`/seo/issues/${encodeURIComponent(issueId)}`)
288
+ return res.data ?? null
289
+ }
290
+
291
+ export async function resolveSeoIssue(issueId: string): Promise<{ error?: string }> {
292
+ const res = await cmsApi(`/seo/issues/${encodeURIComponent(issueId)}/resolve`, { method: 'POST' })
293
+ return res.error ? { error: res.error } : {}
294
+ }
295
+
296
+ export async function ignoreSeoIssue(issueId: string): Promise<{ error?: string }> {
297
+ const res = await cmsApi(`/seo/issues/${encodeURIComponent(issueId)}/ignore`, { method: 'POST' })
298
+ return res.error ? { error: res.error } : {}
299
+ }
300
+
301
+ export async function reopenSeoIssue(issueId: string): Promise<{ error?: string }> {
302
+ const res = await cmsApi(`/seo/issues/${encodeURIComponent(issueId)}/reopen`, { method: 'POST' })
303
+ return res.error ? { error: res.error } : {}
304
+ }
305
+
306
+ // ─── Technical (sitemap / robots / crawl) ────────────────────────────
307
+
308
+ export interface SitemapStatus {
309
+ sitemapUrl: string
310
+ lastGeneratedAt: string | null
311
+ pagesIncluded: number
312
+ pagesExcluded: number
313
+ autoRegenerateOnPublish: boolean
314
+ errors: string[]
315
+ warnings: string[]
316
+ }
317
+
318
+ export interface CrawlSettings {
319
+ indexEntireSite: boolean
320
+ includeImagesInSitemap: boolean
321
+ includePostsInSitemap: boolean
322
+ noindexPaginatedArchives: boolean
323
+ noindexTagPages: boolean
324
+ }
325
+
326
+ export async function fetchSitemapStatus(): Promise<SitemapStatus | null> {
327
+ const res = await cmsApi<SitemapStatus>('/seo/sitemap/status')
328
+ return res.data ?? null
329
+ }
330
+
331
+ export async function regenerateSitemap(): Promise<{ status?: SitemapStatus; error?: string }> {
332
+ const res = await cmsApi<SitemapStatus>('/seo/sitemap/regenerate', { method: 'POST' })
333
+ if (res.error) return { error: res.error }
334
+ return { status: res.data }
335
+ }
336
+
337
+ export async function fetchCrawlSettings(): Promise<CrawlSettings | null> {
338
+ const res = await cmsApi<CrawlSettings>('/seo/crawl-settings')
339
+ return res.data ?? null
340
+ }
341
+
342
+ export async function updateCrawlSettings(
343
+ payload: Partial<CrawlSettings>,
344
+ ): Promise<{ error?: string }> {
345
+ const res = await cmsApi('/seo/crawl-settings', {
346
+ method: 'PUT',
347
+ body: JSON.stringify(payload),
348
+ })
349
+ return res.error ? { error: res.error } : {}
350
+ }
351
+
352
+ export interface RobotsConfig {
353
+ body: string
354
+ generated: boolean
355
+ }
356
+
357
+ export interface RobotsValidation {
358
+ valid: boolean
359
+ blocksEntireSite: boolean
360
+ errors: string[]
361
+ warnings: string[]
362
+ }
363
+
364
+ export async function fetchRobotsTxt(): Promise<RobotsConfig | null> {
365
+ const res = await cmsApi<RobotsConfig>('/seo/robots')
366
+ return res.data ?? null
367
+ }
368
+
369
+ export async function updateRobotsTxt(body: string): Promise<{ error?: string }> {
370
+ const res = await cmsApi('/seo/robots', { method: 'PUT', body: JSON.stringify({ body }) })
371
+ return res.error ? { error: res.error } : {}
372
+ }
373
+
374
+ /** Client-side robots.txt validation (mirrors server-side checks). */
375
+ export function validateRobotsTxt(body: string): RobotsValidation {
376
+ const errors: string[] = []
377
+ const warnings: string[] = []
378
+ const lines = body.split(/\r?\n/)
379
+ let blocksEntireSite = false
380
+ let sawUserAgent = false
381
+ let starAgent = false
382
+
383
+ for (const raw of lines) {
384
+ const line = raw.trim()
385
+ if (!line || line.startsWith('#')) continue
386
+ const colon = line.indexOf(':')
387
+ if (colon === -1) {
388
+ errors.push(`Invalid directive (missing ":"): "${line}"`)
389
+ continue
390
+ }
391
+ const directive = line.slice(0, colon).trim().toLowerCase()
392
+ const value = line.slice(colon + 1).trim()
393
+ if (directive === 'user-agent') {
394
+ sawUserAgent = true
395
+ starAgent = value === '*'
396
+ } else if (directive === 'disallow') {
397
+ if (value === '/' && starAgent) blocksEntireSite = true
398
+ } else if (!['allow', 'sitemap', 'crawl-delay', 'host'].includes(directive)) {
399
+ warnings.push(`Unknown directive: "${directive}"`)
400
+ }
401
+ }
402
+
403
+ if (!sawUserAgent) errors.push('No User-agent directive found.')
404
+ if (blocksEntireSite) {
405
+ warnings.push('This configuration blocks the entire site from all crawlers.')
406
+ }
407
+ return { valid: errors.length === 0, blocksEntireSite, errors, warnings }
408
+ }
409
+
410
+ // ─── Redirects ───────────────────────────────────────────────────────
411
+
412
+ export interface RedirectRule {
413
+ id: string
414
+ type: RedirectStatusCode
415
+ fromPath: string
416
+ toPath: string
417
+ enabled: boolean
418
+ priority: number
419
+ preserveQuery: boolean
420
+ hits: number
421
+ lastHitAt: string | null
422
+ stale: boolean
423
+ source: string
424
+ notes: string | null
425
+ createdAt: string
426
+ updatedAt: string
427
+ }
428
+
429
+ export interface RedirectOverview {
430
+ activeRedirects: number
431
+ staleRedirects: number
432
+ notFoundCount: number
433
+ notFoundHits: number
434
+ chainsAndLoops: number
435
+ trafficRecovered: number
436
+ }
437
+
438
+ export interface Redirect404 {
439
+ id: string
440
+ path: string
441
+ hits: number
442
+ lastSeenAt: string
443
+ }
444
+
445
+ export interface RedirectSuggestion {
446
+ id: string
447
+ fromPath: string
448
+ toPath: string
449
+ confidence: number
450
+ reason: string | null
451
+ status: 'open' | 'accepted' | 'dismissed'
452
+ hits: number
453
+ trend: string | null
454
+ }
455
+
456
+ export interface RedirectChain {
457
+ id: string
458
+ kind: 'chain' | 'loop'
459
+ hops: string[]
460
+ suggestedFrom: string
461
+ suggestedTo: string
462
+ }
463
+
464
+ export async function fetchRedirectOverview(): Promise<RedirectOverview> {
465
+ const res = await cmsApi<RedirectOverview>('/seo/redirects/overview')
466
+ return (
467
+ res.data ?? {
468
+ activeRedirects: 0,
469
+ staleRedirects: 0,
470
+ notFoundCount: 0,
471
+ notFoundHits: 0,
472
+ chainsAndLoops: 0,
473
+ trafficRecovered: 0,
474
+ }
475
+ )
476
+ }
477
+
478
+ export async function fetchRedirectRules(): Promise<RedirectRule[]> {
479
+ const res = await cmsApi<{ rules: RedirectRule[] }>('/seo/redirects')
480
+ return res.data?.rules ?? []
481
+ }
482
+
483
+ export interface RedirectRulePayload {
484
+ type: RedirectStatusCode
485
+ fromPath: string
486
+ toPath: string
487
+ enabled?: boolean
488
+ preserveQuery?: boolean
489
+ notes?: string
490
+ }
491
+
492
+ export async function createRedirectRule(
493
+ payload: RedirectRulePayload,
494
+ ): Promise<{ error?: string }> {
495
+ const res = await cmsApi('/seo/redirects', { method: 'POST', body: JSON.stringify(payload) })
496
+ return res.error ? { error: res.error } : {}
497
+ }
498
+
499
+ export async function updateRedirectRule(
500
+ redirectId: string,
501
+ payload: Partial<RedirectRulePayload>,
502
+ ): Promise<{ error?: string }> {
503
+ const res = await cmsApi(`/seo/redirects/${encodeURIComponent(redirectId)}`, {
504
+ method: 'PUT',
505
+ body: JSON.stringify(payload),
506
+ })
507
+ return res.error ? { error: res.error } : {}
508
+ }
509
+
510
+ export async function deleteRedirectRule(redirectId: string): Promise<{ error?: string }> {
511
+ const res = await cmsApi(`/seo/redirects/${encodeURIComponent(redirectId)}`, {
512
+ method: 'DELETE',
513
+ })
514
+ return res.error ? { error: res.error } : {}
515
+ }
516
+
517
+ export async function fetchRedirectChains(): Promise<RedirectChain[]> {
518
+ const res = await cmsApi<{ chains: RedirectChain[] }>('/seo/redirects/chains')
519
+ return res.data?.chains ?? []
520
+ }
521
+
522
+ export async function flattenRedirectChain(chainId: string): Promise<{ error?: string }> {
523
+ const res = await cmsApi('/seo/redirects/flatten', {
524
+ method: 'POST',
525
+ body: JSON.stringify({ chainId }),
526
+ })
527
+ return res.error ? { error: res.error } : {}
528
+ }
529
+
530
+ export async function fetchRedirectSuggestions(): Promise<RedirectSuggestion[]> {
531
+ const res = await cmsApi<{ suggestions: RedirectSuggestion[] }>('/seo/redirects/suggestions')
532
+ return res.data?.suggestions ?? []
533
+ }
534
+
535
+ export async function generate404RecoverySuggestions(): Promise<{ error?: string }> {
536
+ const res = await cmsApi('/seo/redirects/suggest', { method: 'POST' })
537
+ return res.error ? { error: res.error } : {}
538
+ }
539
+
540
+ export async function acceptRedirectSuggestion(suggestionId: string): Promise<{ error?: string }> {
541
+ const res = await cmsApi(
542
+ `/seo/redirects/suggestions/${encodeURIComponent(suggestionId)}/accept`,
543
+ {
544
+ method: 'POST',
545
+ },
546
+ )
547
+ return res.error ? { error: res.error } : {}
548
+ }
549
+
550
+ export async function dismissRedirectSuggestion(suggestionId: string): Promise<{ error?: string }> {
551
+ const res = await cmsApi(
552
+ `/seo/redirects/suggestions/${encodeURIComponent(suggestionId)}/dismiss`,
553
+ { method: 'POST' },
554
+ )
555
+ return res.error ? { error: res.error } : {}
556
+ }
557
+
558
+ export async function exportRedirectsCsv(): Promise<{ csv?: string; error?: string }> {
559
+ const res = await cmsApi<{ csv: string }>('/seo/redirects/export')
560
+ if (res.error) return { error: res.error }
561
+ return { csv: res.data?.csv ?? '' }
562
+ }
563
+
564
+ export async function importRedirectsCsv(
565
+ csv: string,
566
+ ): Promise<{ imported?: number; errors?: string[]; error?: string }> {
567
+ const res = await cmsApi<{ imported: number; errors: string[] }>('/seo/redirects/import', {
568
+ method: 'POST',
569
+ body: JSON.stringify({ csv }),
570
+ })
571
+ if (res.error) return { error: res.error }
572
+ return { imported: res.data?.imported ?? 0, errors: res.data?.errors ?? [] }
573
+ }
574
+
575
+ // ─── AI Copilot ──────────────────────────────────────────────────────
576
+
577
+ export interface SeoAiResult {
578
+ text?: string
579
+ alternatives?: string[]
580
+ structuredData?: Record<string, unknown>
581
+ /** True when the AI provider is not configured (graceful fallback). */
582
+ unavailable?: boolean
583
+ }
584
+
585
+ export async function generateSeoField(
586
+ field: 'metaTitle' | 'metaDescription' | 'ogTitle' | 'ogDescription' | 'focusKeyword',
587
+ context: { entityType: string; entityId: string },
588
+ ): Promise<SeoAiResult> {
589
+ const res = await cmsApi<SeoAiResult>('/ai/seo/generate-field', {
590
+ method: 'POST',
591
+ body: JSON.stringify({ field, ...context }),
592
+ })
593
+ if (res.status === 501) return { unavailable: true }
594
+ return res.data ?? {}
595
+ }
596
+
597
+ export async function explainSeoIssue(issueId: string): Promise<SeoAiResult> {
598
+ const res = await cmsApi<SeoAiResult>('/ai/seo/explain-issue', {
599
+ method: 'POST',
600
+ body: JSON.stringify({ issueId }),
601
+ })
602
+ if (res.status === 501) return { unavailable: true }
603
+ return res.data ?? {}
604
+ }
605
+
606
+ export async function generateStructuredData(context: {
607
+ entityType: string
608
+ entityId: string
609
+ schemaType: string
610
+ }): Promise<SeoAiResult> {
611
+ const res = await cmsApi<SeoAiResult>('/ai/seo/generate-schema', {
612
+ method: 'POST',
613
+ body: JSON.stringify(context),
614
+ })
615
+ if (res.status === 501) return { unavailable: true }
616
+ return res.data ?? {}
617
+ }
618
+
619
+ export async function summarizeSeoAudit(): Promise<SeoAiResult> {
620
+ const res = await cmsApi<SeoAiResult>('/ai/seo/summarize', { method: 'POST' })
621
+ if (res.status === 501) return { unavailable: true }
622
+ return res.data ?? {}
623
+ }
@@ -55,8 +55,11 @@ export function useAdminRouter(basePath = '/admin', serverPath = '/') {
55
55
 
56
56
  const matchRoute = useCallback(
57
57
  (pattern: string): RouteParams | null => {
58
+ // Match against the path only — query string / hash are state-bearing
59
+ // (e.g. `/seo?tab=audit`) and must not defeat route matching.
60
+ const pathname = currentPath.split('?')[0]!.split('#')[0]!
58
61
  const patternParts = pattern.split('/').filter(Boolean)
59
- const pathParts = currentPath.split('/').filter(Boolean)
62
+ const pathParts = pathname.split('/').filter(Boolean)
60
63
 
61
64
  if (pattern === '/' && pathParts.length === 0) {
62
65
  return {}