@prenta/admin 1.15.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 (56) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/__tests__/lib/seo-service-geo.test.d.ts +2 -0
  3. package/dist/__tests__/lib/seo-service-geo.test.d.ts.map +1 -0
  4. package/dist/__tests__/lib/seo-service-geo.test.js +103 -0
  5. package/dist/__tests__/lib/seo-service-geo.test.js.map +1 -0
  6. package/dist/__tests__/router/seo-tab-for-path.test.js +4 -0
  7. package/dist/__tests__/router/seo-tab-for-path.test.js.map +1 -1
  8. package/dist/__tests__/views/seo-ai-visibility.render.test.d.ts +2 -0
  9. package/dist/__tests__/views/seo-ai-visibility.render.test.d.ts.map +1 -0
  10. package/dist/__tests__/views/seo-ai-visibility.render.test.js +244 -0
  11. package/dist/__tests__/views/seo-ai-visibility.render.test.js.map +1 -0
  12. package/dist/__tests__/views/seo-geo-settings-card.render.test.d.ts +2 -0
  13. package/dist/__tests__/views/seo-geo-settings-card.render.test.d.ts.map +1 -0
  14. package/dist/__tests__/views/seo-geo-settings-card.render.test.js +227 -0
  15. package/dist/__tests__/views/seo-geo-settings-card.render.test.js.map +1 -0
  16. package/dist/__tests__/views/seo-settings.render.test.js +16 -4
  17. package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
  18. package/dist/__tests__/views/seo-technical-tab.render.test.js +13 -0
  19. package/dist/__tests__/views/seo-technical-tab.render.test.js.map +1 -1
  20. package/dist/lib/seo-service.d.ts +94 -0
  21. package/dist/lib/seo-service.d.ts.map +1 -1
  22. package/dist/lib/seo-service.js +33 -0
  23. package/dist/lib/seo-service.js.map +1 -1
  24. package/dist/prenta-admin.css +1 -1
  25. package/dist/views/SEO.d.ts +5 -1
  26. package/dist/views/SEO.d.ts.map +1 -1
  27. package/dist/views/SEO.js +12 -7
  28. package/dist/views/SEO.js.map +1 -1
  29. package/dist/views/seo/AiVisibilityTab.d.ts +4 -0
  30. package/dist/views/seo/AiVisibilityTab.d.ts.map +1 -0
  31. package/dist/views/seo/AiVisibilityTab.js +165 -0
  32. package/dist/views/seo/AiVisibilityTab.js.map +1 -0
  33. package/dist/views/seo/TechnicalTab.d.ts +3 -1
  34. package/dist/views/seo/TechnicalTab.d.ts.map +1 -1
  35. package/dist/views/seo/TechnicalTab.js +4 -4
  36. package/dist/views/seo/TechnicalTab.js.map +1 -1
  37. package/dist/views/settings/SeoGeoSettingsCard.d.ts +12 -0
  38. package/dist/views/settings/SeoGeoSettingsCard.d.ts.map +1 -0
  39. package/dist/views/settings/SeoGeoSettingsCard.js +178 -0
  40. package/dist/views/settings/SeoGeoSettingsCard.js.map +1 -0
  41. package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
  42. package/dist/views/settings/SeoSettingsTab.js +2 -1
  43. package/dist/views/settings/SeoSettingsTab.js.map +1 -1
  44. package/package.json +3 -3
  45. package/src/__tests__/lib/seo-service-geo.test.ts +118 -0
  46. package/src/__tests__/router/seo-tab-for-path.test.ts +5 -0
  47. package/src/__tests__/views/seo-ai-visibility.render.test.tsx +279 -0
  48. package/src/__tests__/views/seo-geo-settings-card.render.test.tsx +253 -0
  49. package/src/__tests__/views/seo-settings.render.test.tsx +28 -7
  50. package/src/__tests__/views/seo-technical-tab.render.test.tsx +15 -0
  51. package/src/lib/seo-service.ts +105 -0
  52. package/src/views/SEO.tsx +15 -6
  53. package/src/views/seo/AiVisibilityTab.tsx +616 -0
  54. package/src/views/seo/TechnicalTab.tsx +27 -8
  55. package/src/views/settings/SeoGeoSettingsCard.tsx +310 -0
  56. package/src/views/settings/SeoSettingsTab.tsx +2 -0
@@ -1140,6 +1140,111 @@ export async function fetchAiCrawlerActivity(): Promise<AiCrawlerActivity> {
1140
1140
  return res.data ?? { enabled: false, rows: [], totals: { hits: 0, bots: 0, honeypot: 0 } }
1141
1141
  }
1142
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
+
1143
1248
  export interface RobotsConfig {
1144
1249
  body: string
1145
1250
  generated: boolean
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,7 +287,10 @@ 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
296
  {activeTab === 'redirects' && <RedirectsTab onNavigate={onNavigate} />}