@prenta/admin 1.15.0 → 1.17.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 (90) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/__tests__/components/faq-proposal-preview.render.test.d.ts +2 -0
  3. package/dist/__tests__/components/faq-proposal-preview.render.test.d.ts.map +1 -0
  4. package/dist/__tests__/components/faq-proposal-preview.render.test.js +97 -0
  5. package/dist/__tests__/components/faq-proposal-preview.render.test.js.map +1 -0
  6. package/dist/__tests__/components/seo-issue-fix-panel.render.test.js +37 -11
  7. package/dist/__tests__/components/seo-issue-fix-panel.render.test.js.map +1 -1
  8. package/dist/__tests__/lib/seo-service-errors.test.js +3 -0
  9. package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
  10. package/dist/__tests__/lib/seo-service-geo.test.d.ts +2 -0
  11. package/dist/__tests__/lib/seo-service-geo.test.d.ts.map +1 -0
  12. package/dist/__tests__/lib/seo-service-geo.test.js +103 -0
  13. package/dist/__tests__/lib/seo-service-geo.test.js.map +1 -0
  14. package/dist/__tests__/lib/seo-service-proposals.test.js +24 -0
  15. package/dist/__tests__/lib/seo-service-proposals.test.js.map +1 -1
  16. package/dist/__tests__/router/seo-tab-for-path.test.js +4 -0
  17. package/dist/__tests__/router/seo-tab-for-path.test.js.map +1 -1
  18. package/dist/__tests__/views/seo-ai-visibility.render.test.d.ts +2 -0
  19. package/dist/__tests__/views/seo-ai-visibility.render.test.d.ts.map +1 -0
  20. package/dist/__tests__/views/seo-ai-visibility.render.test.js +244 -0
  21. package/dist/__tests__/views/seo-ai-visibility.render.test.js.map +1 -0
  22. package/dist/__tests__/views/seo-geo-settings-card.render.test.d.ts +2 -0
  23. package/dist/__tests__/views/seo-geo-settings-card.render.test.d.ts.map +1 -0
  24. package/dist/__tests__/views/seo-geo-settings-card.render.test.js +227 -0
  25. package/dist/__tests__/views/seo-geo-settings-card.render.test.js.map +1 -0
  26. package/dist/__tests__/views/seo-proposals-tab.render.test.js +277 -0
  27. package/dist/__tests__/views/seo-proposals-tab.render.test.js.map +1 -1
  28. package/dist/__tests__/views/seo-settings.render.test.js +16 -4
  29. package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
  30. package/dist/__tests__/views/seo-technical-tab.render.test.js +13 -0
  31. package/dist/__tests__/views/seo-technical-tab.render.test.js.map +1 -1
  32. package/dist/components/seo/FaqProposalPreview.d.ts +39 -0
  33. package/dist/components/seo/FaqProposalPreview.d.ts.map +1 -0
  34. package/dist/components/seo/FaqProposalPreview.js +62 -0
  35. package/dist/components/seo/FaqProposalPreview.js.map +1 -0
  36. package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
  37. package/dist/components/seo/SeoIssueFixPanel.js +7 -2
  38. package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
  39. package/dist/lib/seo-service.d.ts +155 -2
  40. package/dist/lib/seo-service.d.ts.map +1 -1
  41. package/dist/lib/seo-service.js +42 -2
  42. package/dist/lib/seo-service.js.map +1 -1
  43. package/dist/prenta-admin.css +1 -1
  44. package/dist/views/SEO.d.ts +5 -1
  45. package/dist/views/SEO.d.ts.map +1 -1
  46. package/dist/views/SEO.js +12 -7
  47. package/dist/views/SEO.js.map +1 -1
  48. package/dist/views/seo/AiVisibilityTab.d.ts +4 -0
  49. package/dist/views/seo/AiVisibilityTab.d.ts.map +1 -0
  50. package/dist/views/seo/AiVisibilityTab.js +165 -0
  51. package/dist/views/seo/AiVisibilityTab.js.map +1 -0
  52. package/dist/views/seo/ProposalsTab.d.ts.map +1 -1
  53. package/dist/views/seo/ProposalsTab.js +160 -7
  54. package/dist/views/seo/ProposalsTab.js.map +1 -1
  55. package/dist/views/seo/TechnicalTab.d.ts +3 -1
  56. package/dist/views/seo/TechnicalTab.d.ts.map +1 -1
  57. package/dist/views/seo/TechnicalTab.js +4 -4
  58. package/dist/views/seo/TechnicalTab.js.map +1 -1
  59. package/dist/views/settings/SeoAutopilotCard.d.ts.map +1 -1
  60. package/dist/views/settings/SeoAutopilotCard.js +6 -0
  61. package/dist/views/settings/SeoAutopilotCard.js.map +1 -1
  62. package/dist/views/settings/SeoGeoSettingsCard.d.ts +12 -0
  63. package/dist/views/settings/SeoGeoSettingsCard.d.ts.map +1 -0
  64. package/dist/views/settings/SeoGeoSettingsCard.js +178 -0
  65. package/dist/views/settings/SeoGeoSettingsCard.js.map +1 -0
  66. package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
  67. package/dist/views/settings/SeoSettingsTab.js +2 -1
  68. package/dist/views/settings/SeoSettingsTab.js.map +1 -1
  69. package/package.json +3 -3
  70. package/src/__tests__/components/faq-proposal-preview.render.test.tsx +137 -0
  71. package/src/__tests__/components/seo-issue-fix-panel.render.test.tsx +42 -11
  72. package/src/__tests__/lib/seo-service-errors.test.ts +4 -0
  73. package/src/__tests__/lib/seo-service-geo.test.ts +118 -0
  74. package/src/__tests__/lib/seo-service-proposals.test.ts +30 -0
  75. package/src/__tests__/router/seo-tab-for-path.test.ts +5 -0
  76. package/src/__tests__/views/seo-ai-visibility.render.test.tsx +279 -0
  77. package/src/__tests__/views/seo-geo-settings-card.render.test.tsx +253 -0
  78. package/src/__tests__/views/seo-proposals-tab.render.test.tsx +358 -0
  79. package/src/__tests__/views/seo-settings.render.test.tsx +28 -7
  80. package/src/__tests__/views/seo-technical-tab.render.test.tsx +15 -0
  81. package/src/components/seo/FaqProposalPreview.tsx +134 -0
  82. package/src/components/seo/SeoIssueFixPanel.tsx +7 -2
  83. package/src/lib/seo-service.ts +162 -1
  84. package/src/views/SEO.tsx +15 -6
  85. package/src/views/seo/AiVisibilityTab.tsx +616 -0
  86. package/src/views/seo/ProposalsTab.tsx +279 -13
  87. package/src/views/seo/TechnicalTab.tsx +27 -8
  88. package/src/views/settings/SeoAutopilotCard.tsx +6 -0
  89. package/src/views/settings/SeoGeoSettingsCard.tsx +310 -0
  90. package/src/views/settings/SeoSettingsTab.tsx +2 -0
@@ -501,15 +501,30 @@ export async function fetchSeoIssueFixSuggestion(issueId: string): Promise<{
501
501
  return { suggestion: res.data?.suggestion }
502
502
  }
503
503
 
504
+ /**
505
+ * Per-approval refinements for an `add-section` (FAQ) fix. Both are ignored by
506
+ * the server for every other fix strategy.
507
+ */
508
+ export interface SeoApplyFixOptions {
509
+ /** Indices into `section.content.items` to keep; the server requires at least two. */
510
+ includeItems?: number[]
511
+ /** Replacement section heading (server trims, then enforces `FAQ_HEADING_MAX`, mirrored from core). */
512
+ headingOverride?: string
513
+ }
514
+
504
515
  export async function applySeoIssueFix(
505
516
  issueId: string,
506
517
  fingerprint: string,
518
+ opts?: SeoApplyFixOptions,
507
519
  ): Promise<{ issue?: SeoIssue; error?: string }> {
520
+ const body: Record<string, unknown> = { fingerprint }
521
+ if (opts?.includeItems) body.includeItems = opts.includeItems
522
+ if (opts?.headingOverride !== undefined) body.headingOverride = opts.headingOverride
508
523
  const res = await cmsApi<{ issue: SeoIssue }>(
509
524
  `/seo/issues/${encodeURIComponent(issueId)}/apply-fix`,
510
525
  {
511
526
  method: 'POST',
512
- body: JSON.stringify({ fingerprint }),
527
+ body: JSON.stringify(body),
513
528
  },
514
529
  )
515
530
  if (res.error) return { error: res.error }
@@ -561,6 +576,7 @@ export const SEO_INLINE_FIX_ISSUE_TYPES = new Set([
561
576
  'duplicate-meta-title',
562
577
  'duplicate-meta-description',
563
578
  'missing-structured-data',
579
+ 'faq-schema-missing',
564
580
  'broken-internal-link',
565
581
  'orphan-page',
566
582
  ])
@@ -843,6 +859,7 @@ export type SeoAutopilotIssueType =
843
859
  | 'missing-canonical'
844
860
  | 'accidental-noindex'
845
861
  | 'missing-structured-data'
862
+ | 'faq-schema-missing'
846
863
  | 'redirect'
847
864
 
848
865
  export const SEO_AUTOPILOT_ISSUE_TYPES: readonly SeoAutopilotIssueType[] = [
@@ -855,6 +872,7 @@ export const SEO_AUTOPILOT_ISSUE_TYPES: readonly SeoAutopilotIssueType[] = [
855
872
  'missing-canonical',
856
873
  'accidental-noindex',
857
874
  'missing-structured-data',
875
+ 'faq-schema-missing',
858
876
  'redirect',
859
877
  ]
860
878
 
@@ -1140,6 +1158,111 @@ export async function fetchAiCrawlerActivity(): Promise<AiCrawlerActivity> {
1140
1158
  return res.data ?? { enabled: false, rows: [], totals: { hits: 0, bots: 0, honeypot: 0 } }
1141
1159
  }
1142
1160
 
1161
+ // ─── AI visibility (GEO measurement) ────────────────────────────────────────
1162
+ // Mirrors `packages/cms-core/src/seo/geo-report.ts` (`SeoGeoReport`) and the
1163
+ // `GET /seo/geo?days=N` route. The server is the contract; keep in lock-step.
1164
+
1165
+ export type SeoGeoDays = 7 | 28 | 90
1166
+ export type SeoGeoBotCategory = 'training' | 'retrieval' | 'search'
1167
+ /**
1168
+ * `blocked_still_crawling` = robots.txt disallows this bot's category, yet it
1169
+ * was observed in the window — the signal the tab surfaces most prominently.
1170
+ */
1171
+ export type SeoGeoBotPolicy = 'allowed' | 'blocked' | 'blocked_still_crawling'
1172
+
1173
+ /** A top-path row's optional join to the document behind it. */
1174
+ export interface SeoGeoEntityRef {
1175
+ entityId?: string
1176
+ entityTitle?: string
1177
+ /** Collection slug of the matched document; routes the editor link. */
1178
+ collection?: string
1179
+ }
1180
+
1181
+ /**
1182
+ * Why `enabled` is `false`: the kill switch in Settings → SEO (`settings`) or
1183
+ * the GEO tables are not migrated on the CMS database (`unavailable`).
1184
+ */
1185
+ export type SeoGeoDisabledReason = 'settings' | 'unavailable'
1186
+
1187
+ export interface SeoGeoReport {
1188
+ enabled: boolean
1189
+ /** Present only when `enabled` is `false`. */
1190
+ disabledReason?: SeoGeoDisabledReason
1191
+ window: { from: string; to: string; days: number }
1192
+ /** ISO date of the earliest recorded row, or null when nothing has arrived. */
1193
+ firstSeenAt: string | null
1194
+ crawl: {
1195
+ /** Whole-window crawl volume (incl. the capped "other" bucket). */
1196
+ total: { hits: number; prevHits: number | null }
1197
+ series: Array<{ date: string; hits: Record<string, number> }>
1198
+ bots: Array<{
1199
+ bot: string
1200
+ category: SeoGeoBotCategory
1201
+ hits: number
1202
+ /** `null` when the prior window is not fully covered by data. */
1203
+ prevHits: number | null
1204
+ lastSeenAt: string | null
1205
+ policy: SeoGeoBotPolicy
1206
+ }>
1207
+ topPaths: Array<SeoGeoEntityRef & { path: string; hits: number; bots: string[] }>
1208
+ /** Hits that rolled into the capped "other" bucket. */
1209
+ otherHits: number
1210
+ }
1211
+ referrals: {
1212
+ total: { visits: number; prevVisits: number | null }
1213
+ engines: Array<{ engine: string; visits: number; prevVisits: number | null }>
1214
+ topPaths: Array<SeoGeoEntityRef & { path: string; visits: number; engines: string[] }>
1215
+ /** Visits that rolled into the capped "other" bucket. */
1216
+ otherVisits: number
1217
+ }
1218
+ }
1219
+
1220
+ export async function fetchSeoGeo(days: SeoGeoDays): Promise<SeoGeoReport> {
1221
+ const res = await cmsApi<SeoGeoReport>(`/seo/geo?days=${days}`)
1222
+ throwIfError(res)
1223
+ // Validate the shape before handing it to the tab — an unexpected body must
1224
+ // render the error state, not crash in render. `crawl.total` is the newest
1225
+ // field on the wire; a `@prenta/core` deployed before it lands here.
1226
+ const d = res.data
1227
+ if (!d || typeof d.enabled !== 'boolean' || typeof d.crawl?.total?.hits !== 'number') {
1228
+ throw new Error('Unexpected AI visibility response')
1229
+ }
1230
+ return d
1231
+ }
1232
+
1233
+ // Mirrors `GET|PUT /seo/geo-settings` (`routes/seo-geo.ts`). The server clamps
1234
+ // `retentionDays` to `SEO_GEO_RETENTION` itself; the card clamps first so the
1235
+ // value it shows is the value that lands.
1236
+ export const SEO_GEO_RETENTION = { min: 7, max: 365 } as const
1237
+
1238
+ export interface SeoGeoSettings {
1239
+ /** Kill switch: when off the server drops incoming beacons. */
1240
+ enabled: boolean
1241
+ /** Daily-bucket retention, 7–365 days. */
1242
+ retentionDays: number
1243
+ }
1244
+
1245
+ export type SeoGeoSettingsPatch = Partial<SeoGeoSettings>
1246
+
1247
+ /** Resolves `null` on an error envelope or a malformed body so the card renders its error state. */
1248
+ export async function fetchGeoSettings(): Promise<SeoGeoSettings | null> {
1249
+ const res = await cmsApi<SeoGeoSettings>('/seo/geo-settings')
1250
+ const d = res.data
1251
+ if (!d || typeof d.enabled !== 'boolean' || typeof d.retentionDays !== 'number') return null
1252
+ return { enabled: d.enabled, retentionDays: d.retentionDays }
1253
+ }
1254
+
1255
+ export async function updateGeoSettings(
1256
+ patch: SeoGeoSettingsPatch,
1257
+ ): Promise<{ ok: boolean; error?: string }> {
1258
+ const res = await cmsApi<{ ok: boolean }>('/seo/geo-settings', {
1259
+ method: 'PUT',
1260
+ body: JSON.stringify(patch),
1261
+ })
1262
+ if (res.error) return { ok: false, error: res.error }
1263
+ return { ok: true }
1264
+ }
1265
+
1143
1266
  export interface RobotsConfig {
1144
1267
  body: string
1145
1268
  generated: boolean
@@ -1642,6 +1765,28 @@ export async function fetchLinkHealthIssues(): Promise<{
1642
1765
  }
1643
1766
 
1644
1767
  // ─── AI proposals inbox ─────────────────────────────────────────────────
1768
+ // Mirrors `packages/cms-core/src/seo/proposals.ts` (`SeoProposal`) and
1769
+ // `seo/faq-signals.ts` (`FaqSignals`). The server is the contract.
1770
+
1771
+ /** Same union as core's `SeoProposalFixStrategy`; `null` for plain metadata writes. */
1772
+ export type SeoProposalFixStrategy = 'redirect' | 'fix-link' | 'insert-link' | 'add-section'
1773
+
1774
+ /** Why a page got a `missing-faq` proposal (28-day window). */
1775
+ export interface SeoFaqSignals {
1776
+ /**
1777
+ * Answer-engine activity. `engines` carries the referrer registry's display
1778
+ * names (`ChatGPT`, `Perplexity`, …), sorted; empty when only retrieval
1779
+ * crawls were seen.
1780
+ */
1781
+ geo: { referralVisits: number; retrievalHits: number; engines: string[] } | null
1782
+ /** Question-shaped Search Console queries, highest impressions first. */
1783
+ queries: Array<{ query: string; impressions: number; clicks: number }>
1784
+ }
1785
+
1786
+ export interface SeoFaqSectionDraft {
1787
+ sectionType: 'faq'
1788
+ content: { heading: string; items: Array<{ question: string; answer: string }> }
1789
+ }
1645
1790
 
1646
1791
  export interface SeoProposal {
1647
1792
  /** `issue:<issueId>` | `redirect:<suggestionId>` */
@@ -1665,6 +1810,22 @@ export interface SeoProposal {
1665
1810
  severity: SeoSeverity | null
1666
1811
  issueId: string | null
1667
1812
  suggestionId: string | null
1813
+ /** Always `'redirect'` for redirect rows; `null` when the cached patch carries none. */
1814
+ fixStrategy: SeoProposalFixStrategy | null
1815
+ /**
1816
+ * `add-section` only: the section approval will insert. `null` when the
1817
+ * cached patch failed core's structural checks — the row must still render
1818
+ * (badge + signals) but cannot be approved from the inbox.
1819
+ */
1820
+ section: SeoFaqSectionDraft | null
1821
+ /**
1822
+ * `add-section` only: verbatim page quotes parallel to `section.content.items`.
1823
+ * A malformed row arrives as `sourceQuote: ''` — render as "no quote", never
1824
+ * as an empty blockquote.
1825
+ */
1826
+ grounding: Array<{ sourceQuote: string }> | null
1827
+ /** `missing-faq` only. */
1828
+ signals: SeoFaqSignals | null
1668
1829
  }
1669
1830
 
1670
1831
  export interface SeoProposalsPayload {
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} />}