@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
@@ -0,0 +1,616 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * SEO → AI visibility (spec 2026-09-08 §6): daily AI/search crawler trend,
5
+ * AI-engine referral visits, and the pages behind both. Data is self-reported
6
+ * by the site middleware via `POST /geo/beacon`; the tab says so. Everything
7
+ * flows through `fetchSeoGeo` in `lib/seo-service.ts`.
8
+ */
9
+ import { useMemo, useState } from 'react'
10
+ import {
11
+ Bar,
12
+ BarChart,
13
+ CartesianGrid,
14
+ Legend,
15
+ ResponsiveContainer,
16
+ Tooltip,
17
+ XAxis,
18
+ YAxis,
19
+ } from 'recharts'
20
+ import {
21
+ Bot,
22
+ ExternalLink,
23
+ Minus,
24
+ RefreshCw,
25
+ ShieldAlert,
26
+ ShieldCheck,
27
+ ShieldOff,
28
+ TrendingDown,
29
+ TrendingUp,
30
+ } from 'lucide-react'
31
+ import {
32
+ fetchSeoGeo,
33
+ type SeoGeoBotCategory,
34
+ type SeoGeoBotPolicy,
35
+ type SeoGeoDays,
36
+ type SeoGeoEntityRef,
37
+ type SeoGeoReport,
38
+ } from '../../lib/seo-service.js'
39
+ import { sourceEditPath } from '../../lib/collection-routes.js'
40
+ import {
41
+ SectionCard,
42
+ SeoEmptyState,
43
+ SeoErrorState,
44
+ SeoLoading,
45
+ btnSecondary,
46
+ } from '../../components/seo/primitives.js'
47
+ import { relativeAge } from './relative-age.js'
48
+ import { useSeoResource } from './useSeoResource.js'
49
+
50
+ const WINDOWS: ReadonlyArray<{ days: SeoGeoDays; label: string }> = [
51
+ { days: 7, label: '7 days' },
52
+ { days: 28, label: '28 days' },
53
+ { days: 90, label: '90 days' },
54
+ ]
55
+ const CATEGORY_LABEL: Record<SeoGeoBotCategory, string> = {
56
+ training: 'AI training',
57
+ retrieval: 'AI retrieval',
58
+ search: 'Search',
59
+ }
60
+ /** Bots drawn as their own stacked series; the rest fold into "Other". */
61
+ const CHART_SERIES = 5
62
+ /**
63
+ * recharts paints SVG `fill`s, which Tailwind utilities cannot reach, so the
64
+ * chart tokens are referenced as CSS variables. They are the theme's own
65
+ * `--chart-*` series colours and follow dark mode.
66
+ */
67
+ const CHART_TOKENS = [
68
+ 'var(--chart-1)',
69
+ 'var(--chart-2)',
70
+ 'var(--chart-3)',
71
+ 'var(--chart-4)',
72
+ 'var(--chart-5)',
73
+ ] as const
74
+ const CHART_OTHER_TOKEN = 'var(--muted-foreground)'
75
+ const CHART_TOOLTIP_STYLE = {
76
+ background: 'var(--card)',
77
+ border: '1px solid var(--border)',
78
+ borderRadius: 'var(--radius-md)',
79
+ color: 'var(--foreground)',
80
+ fontSize: 12,
81
+ } as const
82
+ const SEO_SETTINGS_PATH = '/settings?tab=seo'
83
+ const EM_DASH = '—'
84
+
85
+ const th = 'py-2 pr-3 font-medium'
86
+ const thRight = 'py-2 pr-3 text-right font-medium'
87
+ const headRow = 'border-border text-muted-foreground border-b text-left'
88
+ const tdNum = 'text-foreground py-2 pr-3 text-right tabular-nums'
89
+
90
+ // ─── Cells ───────────────────────────────────────────────────────────────
91
+
92
+ /** Percentage change vs the prior window; `null` prev means "no full baseline". */
93
+ function Delta({ current, prev }: { current: number; prev: number | null }) {
94
+ if (prev === null) {
95
+ return (
96
+ <span className="text-muted-foreground" title="No full prior window yet">
97
+ <span aria-hidden>{EM_DASH}</span>
98
+ <span className="sr-only">No full prior window yet</span>
99
+ </span>
100
+ )
101
+ }
102
+ if (prev === 0 && current > 0) return <span className="text-muted-foreground">new</span>
103
+ const pct = prev === 0 ? 0 : Math.round(((current - prev) / prev) * 100)
104
+ const Icon = pct > 0 ? TrendingUp : pct < 0 ? TrendingDown : Minus
105
+ const tone = pct > 0 ? 'text-success' : pct < 0 ? 'text-destructive' : 'text-muted-foreground'
106
+ return (
107
+ <span className={`inline-flex items-center gap-1 tabular-nums ${tone}`}>
108
+ <Icon className="h-4 w-4" aria-hidden />
109
+ {pct > 0 ? '+' : ''}
110
+ {pct}%
111
+ </span>
112
+ )
113
+ }
114
+
115
+ function PolicyLabel({ policy }: { policy: SeoGeoBotPolicy }) {
116
+ switch (policy) {
117
+ case 'allowed':
118
+ return (
119
+ <span className="text-muted-foreground inline-flex items-center gap-1">
120
+ <ShieldCheck className="h-4 w-4" aria-hidden /> Allowed
121
+ </span>
122
+ )
123
+ case 'blocked':
124
+ return (
125
+ <span className="text-muted-foreground inline-flex items-center gap-1">
126
+ <ShieldOff className="h-4 w-4" aria-hidden /> Blocked
127
+ </span>
128
+ )
129
+ case 'blocked_still_crawling':
130
+ return (
131
+ <span className="text-destructive inline-flex items-center gap-1">
132
+ <ShieldAlert className="h-4 w-4" aria-hidden /> Blocked — still crawling
133
+ </span>
134
+ )
135
+ default: {
136
+ const _exhaustive: never = policy
137
+ return _exhaustive
138
+ }
139
+ }
140
+ }
141
+
142
+ /**
143
+ * A path, promoted to an "open in editor" button only when the server joined
144
+ * it to a document *and* the host gave us a navigator.
145
+ */
146
+ function PathCell({
147
+ path,
148
+ entityId,
149
+ entityTitle,
150
+ collection,
151
+ onNavigate,
152
+ }: SeoGeoEntityRef & { path: string; onNavigate?: (p: string) => void }) {
153
+ if (!entityId || !onNavigate) return <span className="text-foreground break-all">{path}</span>
154
+ return (
155
+ <button
156
+ type="button"
157
+ className="text-foreground hover:text-primary focus-visible:ring-ring inline-flex items-center gap-1 rounded-sm text-left break-all underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:outline-none"
158
+ onClick={() => onNavigate(sourceEditPath(collection, entityId))}
159
+ aria-label={`Open ${entityTitle ?? path} in editor`}
160
+ >
161
+ {path}
162
+ <ExternalLink className="h-3.5 w-3.5 shrink-0" aria-hidden />
163
+ </button>
164
+ )
165
+ }
166
+
167
+ // ─── Controls ────────────────────────────────────────────────────────────
168
+
169
+ /**
170
+ * Segmented window picker — the same `role="group"` + `aria-pressed` toggle
171
+ * pattern as the Audit / Content filters. Never disabled while loading:
172
+ * `useSeoResource` discards stale responses, and disabling the focused
173
+ * button would drop keyboard focus to `<body>` mid-request.
174
+ */
175
+ function WindowSwitch({
176
+ value,
177
+ onChange,
178
+ }: {
179
+ value: SeoGeoDays
180
+ onChange: (d: SeoGeoDays) => void
181
+ }) {
182
+ return (
183
+ <div
184
+ role="group"
185
+ aria-label="Window"
186
+ className="border-border inline-flex rounded-md border p-0.5"
187
+ >
188
+ {WINDOWS.map((w) => (
189
+ <button
190
+ key={w.days}
191
+ type="button"
192
+ aria-pressed={value === w.days}
193
+ className={`focus-visible:ring-ring rounded-sm px-3 py-1 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none ${
194
+ value === w.days
195
+ ? 'bg-primary text-primary-foreground'
196
+ : 'text-muted-foreground hover:bg-accent hover:text-foreground'
197
+ }`}
198
+ onClick={() => onChange(w.days)}
199
+ >
200
+ {w.label}
201
+ </button>
202
+ ))}
203
+ </div>
204
+ )
205
+ }
206
+
207
+ // ─── Chart ───────────────────────────────────────────────────────────────
208
+
209
+ function CrawlChart({
210
+ series,
211
+ bots,
212
+ }: {
213
+ series: SeoGeoReport['crawl']['series']
214
+ bots: SeoGeoReport['crawl']['bots']
215
+ }) {
216
+ const { top, data, hasOther } = useMemo(() => {
217
+ const top = bots.slice(0, CHART_SERIES).map((b) => b.bot)
218
+ const hasOther = bots.length > CHART_SERIES
219
+ const data = series.map((day) => {
220
+ const row: Record<string, number | string> = { date: day.date.slice(5) }
221
+ let other = 0
222
+ for (const [bot, hits] of Object.entries(day.hits)) {
223
+ if (top.includes(bot)) row[bot] = hits
224
+ else other += hits
225
+ }
226
+ if (hasOther) row.Other = other
227
+ return row
228
+ })
229
+ return { top, data, hasOther }
230
+ }, [series, bots])
231
+
232
+ return (
233
+ // The Crawlers table below carries the same numbers for assistive tech.
234
+ <div className="h-56 w-full" role="img" aria-label="Daily crawler hits by bot">
235
+ <ResponsiveContainer width="100%" height="100%">
236
+ <BarChart data={data}>
237
+ <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
238
+ <XAxis dataKey="date" tick={{ fontSize: 12, fill: 'var(--muted-foreground)' }} />
239
+ <YAxis
240
+ allowDecimals={false}
241
+ tick={{ fontSize: 12, fill: 'var(--muted-foreground)' }}
242
+ width={32}
243
+ />
244
+ <Tooltip contentStyle={CHART_TOOLTIP_STYLE} cursor={{ fill: 'var(--accent)' }} />
245
+ <Legend wrapperStyle={{ fontSize: 12 }} />
246
+ {top.map((bot, i) => (
247
+ <Bar
248
+ key={bot}
249
+ dataKey={bot}
250
+ stackId="hits"
251
+ fill={CHART_TOKENS[i % CHART_TOKENS.length]}
252
+ />
253
+ ))}
254
+ {hasOther && <Bar dataKey="Other" stackId="hits" fill={CHART_OTHER_TOKEN} />}
255
+ </BarChart>
256
+ </ResponsiveContainer>
257
+ </div>
258
+ )
259
+ }
260
+
261
+ // ─── Sections ────────────────────────────────────────────────────────────
262
+
263
+ function CrawlSection({ crawl }: { crawl: SeoGeoReport['crawl'] }) {
264
+ return (
265
+ <SectionCard
266
+ title="Crawl trend"
267
+ description="Daily hits by AI training, AI retrieval, and search crawlers (Googlebot, Bingbot — counted so AI answer surfaces backed by web search are visible; the AI robots policy never blocks these)."
268
+ >
269
+ <div className="space-y-4">
270
+ <dl
271
+ role="group"
272
+ aria-label="Crawl summary"
273
+ className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-3"
274
+ >
275
+ <div>
276
+ <dt className="text-muted-foreground">Crawler hits</dt>
277
+ <dd className="text-foreground font-medium tabular-nums">{crawl.total.hits}</dd>
278
+ </div>
279
+ <div>
280
+ <dt className="text-muted-foreground">Δ vs prior</dt>
281
+ <dd>
282
+ <Delta current={crawl.total.hits} prev={crawl.total.prevHits} />
283
+ </dd>
284
+ </div>
285
+ <div>
286
+ <dt className="text-muted-foreground">Distinct bots</dt>
287
+ <dd className="text-foreground font-medium tabular-nums">{crawl.bots.length}</dd>
288
+ </div>
289
+ </dl>
290
+ <CrawlChart series={crawl.series} bots={crawl.bots} />
291
+ <div className="overflow-x-auto">
292
+ <table className="w-full min-w-[640px] text-sm" aria-label="Crawlers">
293
+ <thead>
294
+ <tr className={headRow}>
295
+ <th scope="col" className={th}>
296
+ Bot
297
+ </th>
298
+ <th scope="col" className={th}>
299
+ Type
300
+ </th>
301
+ <th scope="col" className={thRight}>
302
+ Hits
303
+ </th>
304
+ <th scope="col" className={thRight}>
305
+ Δ vs prior
306
+ </th>
307
+ <th scope="col" className={th}>
308
+ Last seen
309
+ </th>
310
+ <th scope="col" className={th}>
311
+ Policy
312
+ </th>
313
+ </tr>
314
+ </thead>
315
+ <tbody className="divide-border divide-y">
316
+ {crawl.bots.map((b) => (
317
+ <tr key={b.bot}>
318
+ <td className="text-foreground py-2 pr-3 font-medium">{b.bot}</td>
319
+ <td className="text-muted-foreground py-2 pr-3">{CATEGORY_LABEL[b.category]}</td>
320
+ <td className={tdNum}>{b.hits}</td>
321
+ <td className="py-2 pr-3 text-right">
322
+ <Delta current={b.hits} prev={b.prevHits} />
323
+ </td>
324
+ <td className="text-muted-foreground py-2 pr-3 tabular-nums">
325
+ {b.lastSeenAt ?? EM_DASH}
326
+ </td>
327
+ <td className="py-2">
328
+ <PolicyLabel policy={b.policy} />
329
+ </td>
330
+ </tr>
331
+ ))}
332
+ </tbody>
333
+ </table>
334
+ </div>
335
+ </div>
336
+ </SectionCard>
337
+ )
338
+ }
339
+
340
+ function ReferralsSection({
341
+ referrals,
342
+ onNavigate,
343
+ }: {
344
+ referrals: SeoGeoReport['referrals']
345
+ onNavigate?: (path: string) => void
346
+ }) {
347
+ return (
348
+ <SectionCard
349
+ title="AI referrals"
350
+ description="Visits that arrived from an answer engine (Referer or utm_source)."
351
+ >
352
+ <div className="space-y-4">
353
+ <dl
354
+ role="group"
355
+ aria-label="Referral summary"
356
+ className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-3"
357
+ >
358
+ <div>
359
+ <dt className="text-muted-foreground">Visits</dt>
360
+ <dd className="text-foreground font-medium tabular-nums">{referrals.total.visits}</dd>
361
+ </div>
362
+ <div>
363
+ <dt className="text-muted-foreground">Δ vs prior</dt>
364
+ <dd>
365
+ <Delta current={referrals.total.visits} prev={referrals.total.prevVisits} />
366
+ </dd>
367
+ </div>
368
+ </dl>
369
+ {referrals.engines.length > 0 && (
370
+ <div className="overflow-x-auto">
371
+ <table className="w-full min-w-[480px] text-sm" aria-label="Engines">
372
+ <thead>
373
+ <tr className={headRow}>
374
+ <th scope="col" className={th}>
375
+ Engine
376
+ </th>
377
+ <th scope="col" className={thRight}>
378
+ Visits
379
+ </th>
380
+ <th scope="col" className={thRight}>
381
+ Δ vs prior
382
+ </th>
383
+ </tr>
384
+ </thead>
385
+ <tbody className="divide-border divide-y">
386
+ {referrals.engines.map((e) => (
387
+ <tr key={e.engine}>
388
+ <td className="text-foreground py-2 pr-3 font-medium">{e.engine}</td>
389
+ <td className={tdNum}>{e.visits}</td>
390
+ <td className="py-2 pr-3 text-right">
391
+ <Delta current={e.visits} prev={e.prevVisits} />
392
+ </td>
393
+ </tr>
394
+ ))}
395
+ </tbody>
396
+ </table>
397
+ </div>
398
+ )}
399
+ {referrals.topPaths.length > 0 && (
400
+ <div className="overflow-x-auto">
401
+ <table className="w-full min-w-[640px] text-sm" aria-label="Top cited pages">
402
+ <thead>
403
+ <tr className={headRow}>
404
+ <th scope="col" className={th}>
405
+ Path
406
+ </th>
407
+ <th scope="col" className={thRight}>
408
+ Visits
409
+ </th>
410
+ <th scope="col" className={th}>
411
+ Engines
412
+ </th>
413
+ </tr>
414
+ </thead>
415
+ <tbody className="divide-border divide-y">
416
+ {referrals.topPaths.map((p) => (
417
+ <tr key={p.path}>
418
+ <td className="py-2 pr-3">
419
+ <PathCell
420
+ path={p.path}
421
+ entityId={p.entityId}
422
+ entityTitle={p.entityTitle}
423
+ collection={p.collection}
424
+ onNavigate={onNavigate}
425
+ />
426
+ </td>
427
+ <td className={tdNum}>{p.visits}</td>
428
+ <td className="text-muted-foreground py-2">{p.engines.join(', ')}</td>
429
+ </tr>
430
+ ))}
431
+ </tbody>
432
+ </table>
433
+ </div>
434
+ )}
435
+ {referrals.otherVisits > 0 && (
436
+ <p className="text-muted-foreground text-sm">
437
+ {referrals.otherVisits} visits on paths beyond the daily cap.
438
+ </p>
439
+ )}
440
+ </div>
441
+ </SectionCard>
442
+ )
443
+ }
444
+
445
+ function TopCrawledSection({
446
+ crawl,
447
+ onNavigate,
448
+ }: {
449
+ crawl: SeoGeoReport['crawl']
450
+ onNavigate?: (path: string) => void
451
+ }) {
452
+ return (
453
+ <SectionCard
454
+ title="Top crawled pages"
455
+ description="Paths most fetched by AI and search crawlers in this window."
456
+ >
457
+ <div className="overflow-x-auto">
458
+ <table className="w-full min-w-[640px] text-sm" aria-label="Top crawled pages">
459
+ <thead>
460
+ <tr className={headRow}>
461
+ <th scope="col" className={th}>
462
+ Path
463
+ </th>
464
+ <th scope="col" className={thRight}>
465
+ Hits
466
+ </th>
467
+ <th scope="col" className={th}>
468
+ Bots
469
+ </th>
470
+ </tr>
471
+ </thead>
472
+ <tbody className="divide-border divide-y">
473
+ {crawl.topPaths.map((p) => (
474
+ <tr key={p.path}>
475
+ <td className="py-2 pr-3">
476
+ <PathCell
477
+ path={p.path}
478
+ entityId={p.entityId}
479
+ entityTitle={p.entityTitle}
480
+ collection={p.collection}
481
+ onNavigate={onNavigate}
482
+ />
483
+ </td>
484
+ <td className={tdNum}>{p.hits}</td>
485
+ <td className="text-muted-foreground py-2">{p.bots.join(', ')}</td>
486
+ </tr>
487
+ ))}
488
+ </tbody>
489
+ </table>
490
+ </div>
491
+ {crawl.otherHits > 0 && (
492
+ <p className="text-muted-foreground mt-3 text-sm">
493
+ {crawl.otherHits} hits on paths beyond the daily cap.
494
+ </p>
495
+ )}
496
+ </SectionCard>
497
+ )
498
+ }
499
+
500
+ // ─── Tab ─────────────────────────────────────────────────────────────────
501
+
502
+ export function AiVisibilityTab({ onNavigate }: { onNavigate?: (path: string) => void }) {
503
+ const [days, setDays] = useState<SeoGeoDays>(28)
504
+ const { data, loading, error, refetch } = useSeoResource(() => fetchSeoGeo(days), [days])
505
+
506
+ const header = (
507
+ <div className="flex flex-wrap items-center justify-between gap-3">
508
+ <div className="flex flex-wrap items-center gap-3">
509
+ <WindowSwitch value={days} onChange={setDays} />
510
+ {data?.firstSeenAt && (
511
+ <span className="text-muted-foreground text-sm">
512
+ First seen {relativeAge(data.firstSeenAt) || data.firstSeenAt}
513
+ </span>
514
+ )}
515
+ </div>
516
+ <button type="button" className={btnSecondary} onClick={() => refetch()} disabled={loading}>
517
+ <RefreshCw className={`h-4 w-4 ${loading ? 'motion-safe:animate-spin' : ''}`} aria-hidden />
518
+ Refresh
519
+ </button>
520
+ </div>
521
+ )
522
+
523
+ if (loading && !data) {
524
+ return (
525
+ <div className="space-y-4">
526
+ {header}
527
+ <SeoLoading label="Loading AI visibility…" />
528
+ </div>
529
+ )
530
+ }
531
+ if (error) {
532
+ return (
533
+ <div className="space-y-4">
534
+ {header}
535
+ <SeoErrorState message={error} onRetry={refetch} />
536
+ </div>
537
+ )
538
+ }
539
+ if (!data) return null
540
+
541
+ // Both setup-ish empty states send the editor to Settings → SEO (the kill
542
+ // switch and the middleware snippet live on the same card).
543
+ const settingsAction = (label: string) =>
544
+ onNavigate && (
545
+ <button type="button" className={btnSecondary} onClick={() => onNavigate(SEO_SETTINGS_PATH)}>
546
+ {label}
547
+ </button>
548
+ )
549
+
550
+ if (!data.enabled) {
551
+ // `unavailable` = the tables are not in the schema; the setting cannot
552
+ // fix that, so no settings action is offered for it.
553
+ const unavailable = data.disabledReason === 'unavailable'
554
+ return (
555
+ <div className="space-y-4">
556
+ {header}
557
+ {unavailable ? (
558
+ <SeoEmptyState
559
+ icon={<Bot size={24} />}
560
+ title="GEO tables not migrated"
561
+ description="Run `prisma migrate deploy` (migration `0018_geo_daily`) on the CMS database, then reload."
562
+ />
563
+ ) : (
564
+ <SeoEmptyState
565
+ icon={<Bot size={24} />}
566
+ title="Measurement is off"
567
+ description="Turn on AI visibility measurement in Settings → SEO to start recording AI crawler hits and AI-engine referrals."
568
+ action={settingsAction('Open SEO settings')}
569
+ />
570
+ )}
571
+ </div>
572
+ )
573
+ }
574
+
575
+ if (data.firstSeenAt === null) {
576
+ return (
577
+ <div className="space-y-4">
578
+ {header}
579
+ <SeoEmptyState
580
+ icon={<Bot size={24} />}
581
+ title="The site middleware is not reporting yet"
582
+ description="Add observeGeoTraffic to your site's proxy.ts (Next) or use createAstroRedirectMiddleware (Astro). Counts appear here as soon as the first AI or search crawler visits."
583
+ action={settingsAction('Show middleware snippet')}
584
+ />
585
+ </div>
586
+ )
587
+ }
588
+
589
+ const hasRows = data.crawl.bots.length > 0 || data.referrals.engines.length > 0
590
+
591
+ return (
592
+ <div className="space-y-4">
593
+ {header}
594
+ <p className="text-muted-foreground text-sm">
595
+ Counts are self-reported by your middleware; they show real bot names on real paths but are
596
+ not audited traffic.
597
+ </p>
598
+
599
+ {!hasRows ? (
600
+ <SeoEmptyState
601
+ icon={<Bot size={24} />}
602
+ title="No AI crawler or referral activity in this window"
603
+ description="Try a longer window."
604
+ />
605
+ ) : (
606
+ <>
607
+ <CrawlSection crawl={data.crawl} />
608
+ <ReferralsSection referrals={data.referrals} onNavigate={onNavigate} />
609
+ {data.crawl.topPaths.length > 0 && (
610
+ <TopCrawledSection crawl={data.crawl} onNavigate={onNavigate} />
611
+ )}
612
+ </>
613
+ )}
614
+ </div>
615
+ )
616
+ }