@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.
- package/CHANGELOG.md +16 -0
- package/dist/__tests__/components/faq-proposal-preview.render.test.d.ts +2 -0
- package/dist/__tests__/components/faq-proposal-preview.render.test.d.ts.map +1 -0
- package/dist/__tests__/components/faq-proposal-preview.render.test.js +97 -0
- package/dist/__tests__/components/faq-proposal-preview.render.test.js.map +1 -0
- package/dist/__tests__/components/seo-issue-fix-panel.render.test.js +37 -11
- package/dist/__tests__/components/seo-issue-fix-panel.render.test.js.map +1 -1
- package/dist/__tests__/lib/seo-service-errors.test.js +3 -0
- package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
- package/dist/__tests__/lib/seo-service-geo.test.d.ts +2 -0
- package/dist/__tests__/lib/seo-service-geo.test.d.ts.map +1 -0
- package/dist/__tests__/lib/seo-service-geo.test.js +103 -0
- package/dist/__tests__/lib/seo-service-geo.test.js.map +1 -0
- package/dist/__tests__/lib/seo-service-proposals.test.js +24 -0
- package/dist/__tests__/lib/seo-service-proposals.test.js.map +1 -1
- package/dist/__tests__/router/seo-tab-for-path.test.js +4 -0
- package/dist/__tests__/router/seo-tab-for-path.test.js.map +1 -1
- package/dist/__tests__/views/seo-ai-visibility.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-ai-visibility.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-ai-visibility.render.test.js +244 -0
- package/dist/__tests__/views/seo-ai-visibility.render.test.js.map +1 -0
- package/dist/__tests__/views/seo-geo-settings-card.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-geo-settings-card.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-geo-settings-card.render.test.js +227 -0
- package/dist/__tests__/views/seo-geo-settings-card.render.test.js.map +1 -0
- package/dist/__tests__/views/seo-proposals-tab.render.test.js +277 -0
- package/dist/__tests__/views/seo-proposals-tab.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-settings.render.test.js +16 -4
- package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-technical-tab.render.test.js +13 -0
- package/dist/__tests__/views/seo-technical-tab.render.test.js.map +1 -1
- package/dist/components/seo/FaqProposalPreview.d.ts +39 -0
- package/dist/components/seo/FaqProposalPreview.d.ts.map +1 -0
- package/dist/components/seo/FaqProposalPreview.js +62 -0
- package/dist/components/seo/FaqProposalPreview.js.map +1 -0
- package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
- package/dist/components/seo/SeoIssueFixPanel.js +7 -2
- package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
- package/dist/lib/seo-service.d.ts +155 -2
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +42 -2
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/dist/views/SEO.d.ts +5 -1
- package/dist/views/SEO.d.ts.map +1 -1
- package/dist/views/SEO.js +12 -7
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/seo/AiVisibilityTab.d.ts +4 -0
- package/dist/views/seo/AiVisibilityTab.d.ts.map +1 -0
- package/dist/views/seo/AiVisibilityTab.js +165 -0
- package/dist/views/seo/AiVisibilityTab.js.map +1 -0
- package/dist/views/seo/ProposalsTab.d.ts.map +1 -1
- package/dist/views/seo/ProposalsTab.js +160 -7
- package/dist/views/seo/ProposalsTab.js.map +1 -1
- package/dist/views/seo/TechnicalTab.d.ts +3 -1
- package/dist/views/seo/TechnicalTab.d.ts.map +1 -1
- package/dist/views/seo/TechnicalTab.js +4 -4
- package/dist/views/seo/TechnicalTab.js.map +1 -1
- package/dist/views/settings/SeoAutopilotCard.d.ts.map +1 -1
- package/dist/views/settings/SeoAutopilotCard.js +6 -0
- package/dist/views/settings/SeoAutopilotCard.js.map +1 -1
- package/dist/views/settings/SeoGeoSettingsCard.d.ts +12 -0
- package/dist/views/settings/SeoGeoSettingsCard.d.ts.map +1 -0
- package/dist/views/settings/SeoGeoSettingsCard.js +178 -0
- package/dist/views/settings/SeoGeoSettingsCard.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/package.json +3 -3
- package/src/__tests__/components/faq-proposal-preview.render.test.tsx +137 -0
- package/src/__tests__/components/seo-issue-fix-panel.render.test.tsx +42 -11
- package/src/__tests__/lib/seo-service-errors.test.ts +4 -0
- package/src/__tests__/lib/seo-service-geo.test.ts +118 -0
- package/src/__tests__/lib/seo-service-proposals.test.ts +30 -0
- package/src/__tests__/router/seo-tab-for-path.test.ts +5 -0
- package/src/__tests__/views/seo-ai-visibility.render.test.tsx +279 -0
- package/src/__tests__/views/seo-geo-settings-card.render.test.tsx +253 -0
- package/src/__tests__/views/seo-proposals-tab.render.test.tsx +358 -0
- package/src/__tests__/views/seo-settings.render.test.tsx +28 -7
- package/src/__tests__/views/seo-technical-tab.render.test.tsx +15 -0
- package/src/components/seo/FaqProposalPreview.tsx +134 -0
- package/src/components/seo/SeoIssueFixPanel.tsx +7 -2
- package/src/lib/seo-service.ts +162 -1
- package/src/views/SEO.tsx +15 -6
- package/src/views/seo/AiVisibilityTab.tsx +616 -0
- package/src/views/seo/ProposalsTab.tsx +279 -13
- package/src/views/seo/TechnicalTab.tsx +27 -8
- package/src/views/settings/SeoAutopilotCard.tsx +6 -0
- package/src/views/settings/SeoGeoSettingsCard.tsx +310 -0
- 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
|
+
}
|