@actuate-media/cms-admin 0.19.0 → 0.21.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 (129) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +13 -9
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/__tests__/layout/sidebar-posts.test.js +6 -4
  5. package/dist/__tests__/layout/sidebar-posts.test.js.map +1 -1
  6. package/dist/__tests__/lib/seo-service-errors.test.d.ts +2 -0
  7. package/dist/__tests__/lib/seo-service-errors.test.d.ts.map +1 -0
  8. package/dist/__tests__/lib/seo-service-errors.test.js +46 -0
  9. package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -0
  10. package/dist/__tests__/views/use-seo-resource.test.d.ts +2 -0
  11. package/dist/__tests__/views/use-seo-resource.test.d.ts.map +1 -0
  12. package/dist/__tests__/views/use-seo-resource.test.js +56 -0
  13. package/dist/__tests__/views/use-seo-resource.test.js.map +1 -0
  14. package/dist/actuate-admin.css +1 -1
  15. package/dist/components/Breadcrumbs.d.ts.map +1 -1
  16. package/dist/components/Breadcrumbs.js +4 -1
  17. package/dist/components/Breadcrumbs.js.map +1 -1
  18. package/dist/components/seo/Drawer.d.ts +11 -0
  19. package/dist/components/seo/Drawer.d.ts.map +1 -0
  20. package/dist/components/seo/Drawer.js +17 -0
  21. package/dist/components/seo/Drawer.js.map +1 -0
  22. package/dist/components/seo/RedirectEditorDrawer.d.ts +9 -0
  23. package/dist/components/seo/RedirectEditorDrawer.d.ts.map +1 -0
  24. package/dist/components/seo/RedirectEditorDrawer.js +94 -0
  25. package/dist/components/seo/RedirectEditorDrawer.js.map +1 -0
  26. package/dist/components/seo/SeoCopilotDrawer.d.ts +10 -0
  27. package/dist/components/seo/SeoCopilotDrawer.d.ts.map +1 -0
  28. package/dist/components/seo/SeoCopilotDrawer.js +34 -0
  29. package/dist/components/seo/SeoCopilotDrawer.js.map +1 -0
  30. package/dist/components/seo/SeoEditorDrawer.d.ts +8 -0
  31. package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -0
  32. package/dist/components/seo/SeoEditorDrawer.js +120 -0
  33. package/dist/components/seo/SeoEditorDrawer.js.map +1 -0
  34. package/dist/components/seo/SeoIssueFixDrawer.d.ts +8 -0
  35. package/dist/components/seo/SeoIssueFixDrawer.d.ts.map +1 -0
  36. package/dist/components/seo/SeoIssueFixDrawer.js +111 -0
  37. package/dist/components/seo/SeoIssueFixDrawer.js.map +1 -0
  38. package/dist/components/seo/primitives.d.ts +53 -0
  39. package/dist/components/seo/primitives.d.ts.map +1 -0
  40. package/dist/components/seo/primitives.js +101 -0
  41. package/dist/components/seo/primitives.js.map +1 -0
  42. package/dist/layout/Sidebar.d.ts.map +1 -1
  43. package/dist/layout/Sidebar.js +8 -5
  44. package/dist/layout/Sidebar.js.map +1 -1
  45. package/dist/lib/seo-service.d.ts +322 -0
  46. package/dist/lib/seo-service.d.ts.map +1 -0
  47. package/dist/lib/seo-service.js +331 -0
  48. package/dist/lib/seo-service.js.map +1 -0
  49. package/dist/router/index.d.ts.map +1 -1
  50. package/dist/router/index.js +4 -1
  51. package/dist/router/index.js.map +1 -1
  52. package/dist/views/ApiKeys.d.ts +4 -1
  53. package/dist/views/ApiKeys.d.ts.map +1 -1
  54. package/dist/views/ApiKeys.js +5 -3
  55. package/dist/views/ApiKeys.js.map +1 -1
  56. package/dist/views/Dashboard.js +2 -2
  57. package/dist/views/Dashboard.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/NewPostTypeInstructions.js +2 -2
  62. package/dist/views/Posts/post-type-presets.d.ts +5 -0
  63. package/dist/views/Posts/post-type-presets.d.ts.map +1 -1
  64. package/dist/views/Posts/post-type-presets.js +10 -64
  65. package/dist/views/Posts/post-type-presets.js.map +1 -1
  66. package/dist/views/SEO.d.ts.map +1 -1
  67. package/dist/views/SEO.js +89 -132
  68. package/dist/views/SEO.js.map +1 -1
  69. package/dist/views/Settings.d.ts +4 -1
  70. package/dist/views/Settings.d.ts.map +1 -1
  71. package/dist/views/Settings.js +45 -6
  72. package/dist/views/Settings.js.map +1 -1
  73. package/dist/views/Users.d.ts +4 -1
  74. package/dist/views/Users.d.ts.map +1 -1
  75. package/dist/views/Users.js +5 -3
  76. package/dist/views/Users.js.map +1 -1
  77. package/dist/views/seo/AuditTab.d.ts +6 -0
  78. package/dist/views/seo/AuditTab.d.ts.map +1 -0
  79. package/dist/views/seo/AuditTab.js +57 -0
  80. package/dist/views/seo/AuditTab.js.map +1 -0
  81. package/dist/views/seo/ContentTab.d.ts +5 -0
  82. package/dist/views/seo/ContentTab.d.ts.map +1 -0
  83. package/dist/views/seo/ContentTab.js +27 -0
  84. package/dist/views/seo/ContentTab.js.map +1 -0
  85. package/dist/views/seo/OverviewTab.d.ts +8 -0
  86. package/dist/views/seo/OverviewTab.d.ts.map +1 -0
  87. package/dist/views/seo/OverviewTab.js +35 -0
  88. package/dist/views/seo/OverviewTab.js.map +1 -0
  89. package/dist/views/seo/RedirectsTab.d.ts +2 -0
  90. package/dist/views/seo/RedirectsTab.d.ts.map +1 -0
  91. package/dist/views/seo/RedirectsTab.js +130 -0
  92. package/dist/views/seo/RedirectsTab.js.map +1 -0
  93. package/dist/views/seo/TechnicalTab.d.ts +2 -0
  94. package/dist/views/seo/TechnicalTab.d.ts.map +1 -0
  95. package/dist/views/seo/TechnicalTab.js +105 -0
  96. package/dist/views/seo/TechnicalTab.js.map +1 -0
  97. package/dist/views/seo/useSeoResource.d.ts +8 -0
  98. package/dist/views/seo/useSeoResource.d.ts.map +1 -0
  99. package/dist/views/seo/useSeoResource.js +55 -0
  100. package/dist/views/seo/useSeoResource.js.map +1 -0
  101. package/package.json +2 -2
  102. package/src/AdminRoot.tsx +13 -9
  103. package/src/__tests__/layout/sidebar-posts.test.ts +6 -4
  104. package/src/__tests__/lib/seo-service-errors.test.ts +60 -0
  105. package/src/__tests__/views/use-seo-resource.test.tsx +68 -0
  106. package/src/components/Breadcrumbs.tsx +4 -1
  107. package/src/components/seo/Drawer.tsx +65 -0
  108. package/src/components/seo/RedirectEditorDrawer.tsx +235 -0
  109. package/src/components/seo/SeoCopilotDrawer.tsx +76 -0
  110. package/src/components/seo/SeoEditorDrawer.tsx +359 -0
  111. package/src/components/seo/SeoIssueFixDrawer.tsx +244 -0
  112. package/src/components/seo/primitives.tsx +240 -0
  113. package/src/layout/Sidebar.tsx +7 -10
  114. package/src/lib/seo-service.ts +660 -0
  115. package/src/router/index.ts +4 -1
  116. package/src/views/ApiKeys.tsx +10 -5
  117. package/src/views/Dashboard.tsx +2 -2
  118. package/src/views/MediaBrowser.tsx +89 -67
  119. package/src/views/Posts/NewPostTypeInstructions.tsx +2 -2
  120. package/src/views/Posts/post-type-presets.ts +15 -64
  121. package/src/views/SEO.tsx +186 -863
  122. package/src/views/Settings.tsx +93 -40
  123. package/src/views/Users.tsx +10 -4
  124. package/src/views/seo/AuditTab.tsx +229 -0
  125. package/src/views/seo/ContentTab.tsx +164 -0
  126. package/src/views/seo/OverviewTab.tsx +257 -0
  127. package/src/views/seo/RedirectsTab.tsx +398 -0
  128. package/src/views/seo/TechnicalTab.tsx +315 -0
  129. package/src/views/seo/useSeoResource.ts +66 -0
package/src/views/SEO.tsx CHANGED
@@ -1,904 +1,227 @@
1
1
  'use client'
2
2
 
3
+ /**
4
+ * SEO Operations Center — five-tab workflow surface (Overview / Content /
5
+ * Technical / Redirects / Audit). Tab + deep-link state is driven by the
6
+ * `?tab=` query string so audits, issues, redirects, and content editors are
7
+ * all linkable. All data flows through `lib/seo-service.ts`.
8
+ */
3
9
  import * as Tabs from '@radix-ui/react-tabs'
4
10
  import {
5
- Search,
6
- Globe,
7
- FileCode2,
8
- BarChart3,
9
- AlertTriangle,
10
- CheckCircle2,
11
- XCircle,
12
- ArrowUpRight,
11
+ LayoutDashboard,
12
+ FileText,
13
+ Settings2,
14
+ ArrowRightLeft,
15
+ ClipboardCheck,
13
16
  RefreshCw,
14
- BookOpen,
15
- Link2,
16
17
  Bot,
17
- Plus,
18
- Pencil,
19
- Trash2,
20
- ExternalLink,
21
- ArrowRightLeft,
22
- Copy,
23
- ShieldCheck,
24
- Loader2,
25
18
  } from 'lucide-react'
26
- import { useState, useMemo, type FormEvent } from 'react'
19
+ import { useCallback, useEffect, useMemo, useState } from 'react'
27
20
  import { toast } from 'sonner'
28
- import * as Dialog from '@radix-ui/react-dialog'
29
- import { useApiData } from '../lib/useApiData.js'
30
- import { cmsApi } from '../lib/api.js'
21
+ import { ErrorBoundary } from '../components/ErrorBoundary.js'
22
+ import { fetchSeoOverview, runSeoAudit } from '../lib/seo-service.js'
23
+ import { ScorePill, btnPrimary, btnSecondary } from '../components/seo/primitives.js'
24
+ import { SeoCopilotDrawer } from '../components/seo/SeoCopilotDrawer.js'
25
+ import { useSeoResource } from './seo/useSeoResource.js'
26
+ import { OverviewTab } from './seo/OverviewTab.js'
27
+ import { ContentTab } from './seo/ContentTab.js'
28
+ import { TechnicalTab } from './seo/TechnicalTab.js'
29
+ import { RedirectsTab } from './seo/RedirectsTab.js'
30
+ import { AuditTab } from './seo/AuditTab.js'
31
31
 
32
32
  export interface SEOProps {
33
33
  onNavigate?: (path: string) => void
34
34
  initialTab?: string
35
35
  }
36
36
 
37
- export function SEO({ onNavigate, initialTab = 'pages' }: SEOProps) {
38
- const {
39
- data: seoData,
40
- loading: seoLoading,
41
- error: seoError,
42
- refetch: seoRefetch,
43
- } = useApiData<any[]>('/seo/pages')
44
- const {
45
- data: redirectsData,
46
- loading: redirectsLoading,
47
- error: redirectsError,
48
- refetch: redirectsRefetch,
49
- } = useApiData<any[]>('/redirects')
50
- // /seo/link-health returns `{ truncated, checked, issues: [...] }`,
51
- // NOT a bare array — the previous `useApiData<any[]>` typing was a lie
52
- // and `linkHealthData ?? []` coerced the wrapper object into the
53
- // `linkHealth` variable, which then crashed every render with
54
- // "M.filter is not a function" because the JSX inside the (unmounted)
55
- // links tab is still evaluated eagerly before being passed to Radix.
56
- interface LinkHealthResponse {
57
- truncated: boolean
58
- checked: number
59
- issues: Array<{
60
- id: string
61
- page: string
62
- url: string
63
- status: number
64
- type: string
65
- }>
66
- }
67
- const { data: linkHealthData, loading: linkHealthLoading } =
68
- useApiData<LinkHealthResponse>('/seo/link-health')
37
+ const TABS = [
38
+ { id: 'overview', label: 'Overview', icon: LayoutDashboard },
39
+ { id: 'content', label: 'Content SEO', icon: FileText },
40
+ { id: 'technical', label: 'Technical', icon: Settings2 },
41
+ { id: 'redirects', label: 'Redirects', icon: ArrowRightLeft },
42
+ { id: 'audit', label: 'Audit', icon: ClipboardCheck },
43
+ ] as const
69
44
 
70
- const [activeTab, setActiveTab] = useState(initialTab)
71
- const [searchQuery, setSearchQuery] = useState('')
72
- const [filterScore, setFilterScore] = useState<string>('all')
73
- const [scanning, setScanning] = useState(false)
45
+ type TabId = (typeof TABS)[number]['id']
74
46
 
75
- const [showAddRedirect, setShowAddRedirect] = useState(false)
76
- const [newRedirect, setNewRedirect] = useState({
77
- source: '',
78
- destination: '',
79
- type: '301' as '301' | '302',
80
- })
81
- const [redirectSearch, setRedirectSearch] = useState('')
47
+ function normalizeTab(tab: string | undefined): TabId {
48
+ const valid = TABS.map((t) => t.id) as string[]
49
+ return (valid.includes(tab ?? '') ? tab : 'overview') as TabId
50
+ }
82
51
 
83
- const seoPages = seoData ?? []
84
- const redirects = redirectsData ?? []
85
- const linkHealth = linkHealthData?.issues ?? []
52
+ interface UrlState {
53
+ tab: string | null
54
+ entityType: string | null
55
+ entityId: string | null
56
+ issueId: string | null
57
+ }
86
58
 
87
- const handleTabChange = (tab: string) => {
88
- setActiveTab(tab)
89
- const path = tab === 'pages' ? '/seo' : `/seo/${tab}`
90
- onNavigate?.(path)
59
+ /** Read tab + deep-link query params from the current URL. */
60
+ function readUrlState(): UrlState {
61
+ if (typeof window === 'undefined') {
62
+ return { tab: null, entityType: null, entityId: null, issueId: null }
91
63
  }
92
-
93
- // --- Pages tab data ---
94
- const filtered = seoPages
95
- .filter((page: any) => {
96
- const matchesSearch =
97
- (page.url ?? '').toLowerCase().includes(searchQuery.toLowerCase()) ||
98
- (page.title ?? '').toLowerCase().includes(searchQuery.toLowerCase())
99
- const matchesScore =
100
- filterScore === 'all' ||
101
- (filterScore === 'good' && page.score >= 80) ||
102
- (filterScore === 'warning' && page.score >= 50 && page.score < 80) ||
103
- (filterScore === 'critical' && page.score < 50)
104
- return matchesSearch && matchesScore
105
- })
106
- .sort((a: any, b: any) => (!searchQuery ? b.issues - a.issues : 0))
107
-
108
- const totalIssues = seoPages.reduce((sum: number, p: any) => sum + p.issues, 0)
109
- const avgScore =
110
- seoPages.length > 0
111
- ? Math.round(seoPages.reduce((sum: number, p: any) => sum + p.score, 0) / seoPages.length)
112
- : 0
113
- const missingMeta = seoPages.filter((p: any) => !p.metaTitle || !p.metaDescription).length
114
- const missingSchema = seoPages.filter((p: any) => !p.schemaType).length
115
-
116
- // --- Canonical data ---
117
- const missingCanonical = seoPages.filter((p: any) => !p.canonical)
118
- const allCanonicals = seoPages.filter((p: any) => p.canonical)
119
- const canonicalDomains = [
120
- ...new Set(
121
- allCanonicals
122
- .map((p: any) => {
123
- try {
124
- return new URL(p.canonical).hostname
125
- } catch {
126
- return ''
127
- }
128
- })
129
- .filter(Boolean),
130
- ),
131
- ]
132
-
133
- // --- Redirects data ---
134
- const filteredRedirects = redirects.filter(
135
- (r: any) =>
136
- (r.from ?? '').toLowerCase().includes(redirectSearch.toLowerCase()) ||
137
- (r.to ?? '').toLowerCase().includes(redirectSearch.toLowerCase()),
138
- )
139
-
140
- const handleScan = async () => {
141
- setScanning(true)
142
- try {
143
- const res = await cmsApi('/seo/scan', { method: 'POST' })
144
- if (res.error) {
145
- toast.error(res.error)
146
- } else {
147
- const d = res.data as { total: number; pagesWithIssues: number; totalProblems: number }
148
- toast.success(
149
- `SEO scan complete — ${d.totalProblems} issues found across ${d.pagesWithIssues} of ${d.total} pages`,
150
- )
151
- seoRefetch()
152
- }
153
- } catch {
154
- toast.error('SEO scan failed')
155
- } finally {
156
- setScanning(false)
157
- }
64
+ const params = new URLSearchParams(window.location.search)
65
+ return {
66
+ tab: params.get('tab'),
67
+ entityType: params.get('entityType'),
68
+ entityId: params.get('entityId'),
69
+ issueId: params.get('issueId'),
158
70
  }
71
+ }
159
72
 
160
- const handleAddRedirect = async (e: FormEvent) => {
161
- e.preventDefault()
162
- const res = await cmsApi('/redirects', {
163
- method: 'POST',
164
- body: JSON.stringify({
165
- from: newRedirect.source,
166
- to: newRedirect.destination,
167
- type: newRedirect.type,
168
- }),
169
- })
170
- if (res.error) {
171
- toast.error(res.error)
172
- } else {
173
- toast.success('Redirect added')
174
- redirectsRefetch()
73
+ export function SEO({ onNavigate, initialTab = 'overview' }: SEOProps) {
74
+ const initialUrl = readUrlState()
75
+ const [activeTab, setActiveTab] = useState<TabId>(normalizeTab(initialUrl.tab ?? initialTab))
76
+ const [refetchKey, setRefetchKey] = useState(0)
77
+ const [auditing, setAuditing] = useState(false)
78
+ const [copilotOpen, setCopilotOpen] = useState(false)
79
+ const [deepLink, setDeepLink] = useState<UrlState>(initialUrl)
80
+
81
+ // Overview resource is owned here so the header score pill and the Overview
82
+ // tab share a single request; `refetchKey` re-runs it after an audit.
83
+ const overviewResource = useSeoResource(fetchSeoOverview, [refetchKey])
84
+
85
+ // Keep tab + deep links in sync with browser back/forward navigation.
86
+ useEffect(() => {
87
+ function sync() {
88
+ const url = readUrlState()
89
+ setActiveTab(normalizeTab(url.tab ?? initialTab))
90
+ setDeepLink(url)
175
91
  }
176
- setShowAddRedirect(false)
177
- setNewRedirect({ source: '', destination: '', type: '301' })
178
- }
92
+ window.addEventListener('popstate', sync)
93
+ return () => window.removeEventListener('popstate', sync)
94
+ }, [initialTab])
95
+
96
+ useEffect(() => {
97
+ const url = readUrlState()
98
+ setActiveTab(normalizeTab(url.tab ?? initialTab))
99
+ setDeepLink(url)
100
+ }, [initialTab])
101
+
102
+ const changeTab = useCallback(
103
+ (tab: string) => {
104
+ const next = normalizeTab(tab)
105
+ setActiveTab(next)
106
+ setDeepLink({ tab: next, entityType: null, entityId: null, issueId: null })
107
+ onNavigate?.(`/seo?tab=${next}`)
108
+ },
109
+ [onNavigate],
110
+ )
179
111
 
180
- const handleDeleteRedirect = async (id: number) => {
181
- const res = await cmsApi(`/redirects/${id}`, { method: 'DELETE' })
112
+ const handleRunAudit = useCallback(async () => {
113
+ setAuditing(true)
114
+ const res = await runSeoAudit('full')
115
+ setAuditing(false)
182
116
  if (res.error) {
183
117
  toast.error(res.error)
184
- } else {
185
- toast.success('Redirect deleted')
186
- redirectsRefetch()
118
+ return
187
119
  }
188
- }
189
-
190
- function scoreBadge(score: number) {
191
- if (score >= 80) return 'bg-green-100 text-green-800'
192
- if (score >= 50) return 'bg-yellow-100 text-yellow-800'
193
- return 'bg-red-100 text-red-800'
194
- }
195
- function scoreIcon(score: number) {
196
- if (score >= 80) return <CheckCircle2 className="h-4 w-4 text-green-600" />
197
- if (score >= 50) return <AlertTriangle className="h-4 w-4 text-yellow-600" />
198
- return <XCircle className="h-4 w-4 text-red-600" />
199
- }
120
+ toast.success('Audit complete.')
121
+ setRefetchKey((k) => k + 1)
122
+ }, [])
200
123
 
201
- const tabClass =
202
- 'px-4 py-2 text-sm font-medium text-gray-600 transition-colors hover:text-gray-900 data-[state=active]:border-b-2 data-[state=active]:border-blue-600 data-[state=active]:text-blue-600 shrink-0'
124
+ const score = overviewResource.data?.siteScore.score
203
125
 
204
- const isLoading = seoLoading || redirectsLoading || linkHealthLoading
205
-
206
- if (isLoading) {
207
- return (
208
- <div className="flex h-64 items-center justify-center p-3 pr-6 sm:p-4 sm:pr-8">
209
- <Loader2 className="h-6 w-6 animate-spin text-blue-600" />
210
- </div>
211
- )
212
- }
126
+ const badgeCounts = useMemo(() => {
127
+ const summary = overviewResource.data?.issuesSummary
128
+ return { audit: summary ? summary.critical + summary.warning : 0 }
129
+ }, [overviewResource.data])
213
130
 
214
131
  return (
215
- <div className="flex h-full flex-col p-3 pr-6 sm:p-4 sm:pr-8">
216
- {(seoError || redirectsError) && (
217
- <div className="mb-4 flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3">
218
- <AlertTriangle className="h-5 w-5 shrink-0 text-red-600" />
219
- <span className="flex-1 text-sm text-red-800">{seoError || redirectsError}</span>
220
- <button
221
- onClick={() => {
222
- seoRefetch()
223
- redirectsRefetch()
224
- }}
225
- className="rounded-lg border border-red-300 px-3 py-1 text-sm text-red-700 transition-colors hover:bg-red-100"
226
- >
227
- Retry
228
- </button>
229
- </div>
230
- )}
231
-
232
- <div className="mb-4 flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
132
+ <div className="space-y-6 p-6">
133
+ <header className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
233
134
  <div>
234
- <h1 className="mb-1 text-xl font-semibold text-gray-900 sm:text-2xl">SEO & Redirects</h1>
235
- <p className="text-sm text-gray-600">
236
- Search optimization, redirects, canonicalization, and link health
135
+ <h1 className="text-foreground text-2xl font-medium">SEO Operations Center</h1>
136
+ <p className="text-muted-foreground mt-1 text-sm">
137
+ Monitor, fix, and automate SEO across your content, redirects, and technical setup.
237
138
  </p>
238
139
  </div>
239
- <button
240
- onClick={handleScan}
241
- disabled={scanning}
242
- className="flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-700 disabled:opacity-60"
243
- >
244
- <RefreshCw className={`h-4 w-4 ${scanning ? 'animate-spin' : ''}`} />
245
- {scanning ? 'Scanning...' : 'Run SEO Scan'}
246
- </button>
247
- </div>
248
-
249
- <Tabs.Root value={activeTab} onValueChange={handleTabChange}>
250
- <Tabs.List className="mb-4 flex gap-1 overflow-x-auto border-b border-gray-200">
251
- <Tabs.Trigger value="pages" className={tabClass}>
252
- <span className="flex items-center gap-1.5">
253
- <BarChart3 className="h-4 w-4" />
254
- Pages
140
+ <div className="flex items-center gap-3">
141
+ {typeof score === 'number' && (
142
+ <span className="text-muted-foreground flex items-center gap-2 text-sm">
143
+ Site score <ScorePill score={score} />
255
144
  </span>
256
- </Tabs.Trigger>
257
- <Tabs.Trigger value="redirects" className={tabClass}>
258
- <span className="flex items-center gap-1.5">
259
- <ArrowRightLeft className="h-4 w-4" />
260
- Redirects
261
- </span>
262
- </Tabs.Trigger>
263
- <Tabs.Trigger value="canonicals" className={tabClass}>
264
- <span className="flex items-center gap-1.5">
265
- <Copy className="h-4 w-4" />
266
- Canonicalization
267
- </span>
268
- </Tabs.Trigger>
269
- <Tabs.Trigger value="links" className={tabClass}>
270
- <span className="flex items-center gap-1.5">
271
- <Link2 className="h-4 w-4" />
272
- Link Health
273
- </span>
274
- </Tabs.Trigger>
275
- </Tabs.List>
276
-
277
- {/* PAGES TAB */}
278
- <Tabs.Content value="pages" className="flex min-h-0 flex-1 flex-col">
279
- <div className="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-4">
280
- <div className="rounded-lg border border-gray-200 bg-white p-4">
281
- <div className="mb-2 flex items-center gap-2">
282
- <BarChart3 className="h-4 w-4 text-blue-600" />
283
- <span className="text-xs font-medium text-gray-600">Avg SEO Score</span>
284
- </div>
285
- <div className="text-2xl font-semibold text-gray-900">{avgScore}</div>
286
- <div
287
- className={`mt-1 text-xs ${avgScore >= 80 ? 'text-green-600' : avgScore >= 50 ? 'text-yellow-600' : 'text-red-600'}`}
288
- >
289
- {avgScore >= 80 ? 'Good' : avgScore >= 50 ? 'Needs improvement' : 'Critical'}
290
- </div>
291
- </div>
292
- <div className="rounded-lg border border-gray-200 bg-white p-4">
293
- <div className="mb-2 flex items-center gap-2">
294
- <AlertTriangle className="h-4 w-4 text-yellow-600" />
295
- <span className="text-xs font-medium text-gray-600">Total Issues</span>
296
- </div>
297
- <div className="text-2xl font-semibold text-gray-900">{totalIssues}</div>
298
- <div className="mt-1 text-xs text-gray-500">
299
- Across {seoPages.filter((p: any) => p.issues > 0).length} pages
300
- </div>
301
- </div>
302
- <div className="rounded-lg border border-gray-200 bg-white p-4">
303
- <div className="mb-2 flex items-center gap-2">
304
- <FileCode2 className="h-4 w-4 text-purple-600" />
305
- <span className="text-xs font-medium text-gray-600">Missing Meta</span>
306
- </div>
307
- <div className="text-2xl font-semibold text-gray-900">{missingMeta}</div>
308
- <div className="mt-1 text-xs text-gray-500">Title or description</div>
309
- </div>
310
- <div className="rounded-lg border border-gray-200 bg-white p-4">
311
- <div className="mb-2 flex items-center gap-2">
312
- <Globe className="h-4 w-4 text-green-600" />
313
- <span className="text-xs font-medium text-gray-600">Missing Schema</span>
314
- </div>
315
- <div className="text-2xl font-semibold text-gray-900">{missingSchema}</div>
316
- <div className="mt-1 text-xs text-gray-500">No Schema.org markup</div>
317
- </div>
318
- </div>
319
-
320
- <div className="mb-4 rounded-lg border border-indigo-200 bg-linear-to-r from-indigo-50 to-purple-50 p-4">
321
- <div className="flex items-start gap-3">
322
- <Bot className="mt-0.5 h-5 w-5 shrink-0 text-indigo-600" />
323
- <div className="flex-1">
324
- <h3 className="mb-1 text-sm font-semibold text-indigo-900">
325
- AI SEO Recommendations
326
- </h3>
327
- <p className="mb-2 text-sm text-indigo-700">
328
- {missingMeta} pages missing meta descriptions. {missingCanonical.length} pages
329
- without canonical URLs. AI can auto-generate these from page content.
330
- </p>
331
- <div className="flex items-center gap-2">
332
- <button
333
- onClick={() => onNavigate?.('/settings')}
334
- className="rounded-lg bg-indigo-600 px-3 py-1.5 text-xs text-white transition-colors hover:bg-indigo-700"
335
- >
336
- Configure AI
337
- </button>
338
- <button className="rounded-lg border border-indigo-300 px-3 py-1.5 text-xs text-indigo-700 transition-colors hover:bg-indigo-50">
339
- Auto-fix All
340
- </button>
341
- </div>
342
- </div>
343
- </div>
344
- </div>
345
-
346
- <div className="mb-4 rounded-lg border border-gray-200 bg-white">
347
- <div className="flex flex-col gap-3 p-3 sm:flex-row">
348
- <div className="relative flex-1">
349
- <Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
350
- <input
351
- type="text"
352
- placeholder="Search pages by URL or title..."
353
- value={searchQuery}
354
- onChange={(e) => setSearchQuery(e.target.value)}
355
- className="w-full rounded-lg border border-gray-300 py-2 pr-3 pl-9 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
356
- />
357
- </div>
358
- <select
359
- value={filterScore}
360
- onChange={(e) => setFilterScore(e.target.value)}
361
- className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
362
- >
363
- <option value="all">All Scores</option>
364
- <option value="good">Good (80+)</option>
365
- <option value="warning">Needs Work (50-79)</option>
366
- <option value="critical">Critical (&lt;50)</option>
367
- </select>
368
- </div>
369
- </div>
370
-
371
- <div className="flex-1 overflow-hidden rounded-lg border border-gray-200 bg-white">
372
- <div className="h-full overflow-x-auto">
373
- <table className="w-full">
374
- <thead className="sticky top-0 border-b border-gray-200 bg-gray-50">
375
- <tr>
376
- <th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Page</th>
377
- <th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Score</th>
378
- <th className="px-4 py-2 text-left text-xs font-medium text-gray-700">
379
- Readability
380
- </th>
381
- <th className="px-4 py-2 text-left text-xs font-medium text-gray-700">
382
- Schema
383
- </th>
384
- <th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Meta</th>
385
- <th className="px-4 py-2 text-left text-xs font-medium text-gray-700">
386
- Issues
387
- </th>
388
- <th className="px-4 py-2 text-left text-xs font-medium text-gray-700">
389
- Actions
390
- </th>
391
- </tr>
392
- </thead>
393
- <tbody className="divide-y divide-gray-200">
394
- {filtered.map((page: any) => (
395
- <tr key={page.id} className="transition-colors hover:bg-gray-50">
396
- <td className="px-4 py-3">
397
- <div className="text-sm font-medium text-gray-900">{page.title}</div>
398
- <div className="text-xs text-gray-500">{page.url}</div>
399
- </td>
400
- <td className="px-4 py-3">
401
- <div className="flex items-center gap-2">
402
- {scoreIcon(page.score)}
403
- <span
404
- className={`rounded-full px-2 py-0.5 text-xs font-medium ${scoreBadge(page.score)}`}
405
- >
406
- {page.score}
407
- </span>
408
- </div>
409
- </td>
410
- <td className="px-4 py-3">
411
- {page.readability > 0 ? (
412
- <div className="flex items-center gap-2">
413
- <BookOpen className="h-3.5 w-3.5 text-gray-400" />
414
- <span
415
- className={`text-sm ${page.readability >= 80 ? 'text-green-700' : page.readability >= 60 ? 'text-yellow-700' : 'text-red-700'}`}
416
- >
417
- {page.readability}
418
- </span>
419
- </div>
420
- ) : (
421
- <span className="text-xs text-gray-400">—</span>
422
- )}
423
- </td>
424
- <td className="px-4 py-3">
425
- {page.schemaType ? (
426
- <span className="rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-800">
427
- {page.schemaType}
428
- </span>
429
- ) : (
430
- <span className="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800">
431
- Missing
432
- </span>
433
- )}
434
- </td>
435
- <td className="px-4 py-3">
436
- <div className="flex flex-col gap-0.5">
437
- <span
438
- className={`text-xs ${page.metaTitle ? 'text-green-700' : 'text-red-600'}`}
439
- >
440
- {page.metaTitle ? '✓ Title' : '✗ Title'}
441
- </span>
442
- <span
443
- className={`text-xs ${page.metaDescription ? 'text-green-700' : 'text-red-600'}`}
444
- >
445
- {page.metaDescription ? '✓ Desc' : '✗ Desc'}
446
- </span>
447
- </div>
448
- </td>
449
- <td className="px-4 py-3">
450
- {page.issues > 0 ? (
451
- <span className="rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800">
452
- {page.issues}
453
- </span>
454
- ) : (
455
- <span className="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800">
456
-
457
- </span>
458
- )}
459
- </td>
460
- <td className="px-4 py-3">
461
- <div className="flex items-center gap-1">
462
- <button
463
- onClick={() => onNavigate?.(`/pages/${page.id}`)}
464
- className="rounded p-1.5 transition-colors hover:bg-gray-100"
465
- title="Edit"
466
- >
467
- <ArrowUpRight className="h-4 w-4 text-gray-600" />
468
- </button>
469
- <button
470
- className="rounded p-1.5 transition-colors hover:bg-gray-100"
471
- title="AI analyze"
472
- >
473
- <Bot className="h-4 w-4 text-indigo-600" />
474
- </button>
475
- </div>
476
- </td>
477
- </tr>
478
- ))}
479
- </tbody>
480
- </table>
481
- </div>
482
- </div>
483
- </Tabs.Content>
484
-
485
- {/* REDIRECTS TAB */}
486
- <Tabs.Content value="redirects" className="flex min-h-0 flex-1 flex-col">
487
- <div className="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-4">
488
- <div className="rounded-lg border border-gray-200 bg-white p-4">
489
- <div className="mb-1 text-xs text-gray-600">Total Redirects</div>
490
- <div className="text-2xl font-semibold text-gray-900">{redirects.length}</div>
491
- </div>
492
- <div className="rounded-lg border border-gray-200 bg-white p-4">
493
- <div className="mb-1 text-xs text-gray-600">Active</div>
494
- <div className="text-2xl font-semibold text-green-600">
495
- {redirects.filter((r: any) => r.active).length}
496
- </div>
497
- </div>
498
- <div className="rounded-lg border border-gray-200 bg-white p-4">
499
- <div className="mb-1 text-xs text-gray-600">Total Hits</div>
500
- <div className="text-2xl font-semibold text-blue-600">
501
- {redirects.reduce((s: number, r: any) => s + r.hits, 0).toLocaleString()}
502
- </div>
503
- </div>
504
- <div className="rounded-lg border border-gray-200 bg-white p-4">
505
- <div className="mb-1 text-xs text-gray-600">301 Permanent</div>
506
- <div className="text-2xl font-semibold text-gray-900">
507
- {redirects.filter((r: any) => r.type === '301').length}
508
- </div>
509
- </div>
510
- </div>
511
-
512
- <div className="mb-4 flex items-center gap-3">
513
- <div className="relative max-w-md flex-1">
514
- <Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
515
- <input
516
- type="text"
517
- placeholder="Search redirects..."
518
- value={redirectSearch}
519
- onChange={(e) => setRedirectSearch(e.target.value)}
520
- className="w-full rounded-lg border border-gray-300 bg-white py-2 pr-3 pl-9 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
521
- />
522
- </div>
523
- <button
524
- onClick={() => setShowAddRedirect(true)}
525
- className="flex shrink-0 items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-700"
526
- >
527
- <Plus className="h-4 w-4" />
528
- Add Redirect
529
- </button>
530
- </div>
531
-
532
- <div className="flex-1 overflow-hidden rounded-lg border border-gray-200 bg-white">
533
- <div className="h-full overflow-x-auto">
534
- <table className="w-full">
535
- <thead className="sticky top-0 border-b border-gray-200 bg-gray-50">
536
- <tr>
537
- <th className="px-4 py-2 text-left text-xs font-medium text-gray-700">
538
- Source
539
- </th>
540
- <th className="px-4 py-2 text-left text-xs font-medium text-gray-700">
541
- Destination
542
- </th>
543
- <th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Type</th>
544
- <th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Hits</th>
545
- <th className="px-4 py-2 text-left text-xs font-medium text-gray-700">
546
- Status
547
- </th>
548
- <th className="px-4 py-2 text-left text-xs font-medium text-gray-700">
549
- Actions
550
- </th>
551
- </tr>
552
- </thead>
553
- <tbody className="divide-y divide-gray-200">
554
- {filteredRedirects.map((r: any) => (
555
- <tr key={r.id} className="transition-colors hover:bg-gray-50">
556
- <td className="px-4 py-3">
557
- <code className="rounded bg-gray-100 px-2 py-1 text-xs text-gray-900">
558
- {r.from}
559
- </code>
560
- </td>
561
- <td className="px-4 py-3">
562
- <code className="rounded bg-gray-100 px-2 py-1 text-xs text-gray-900">
563
- {r.to}
564
- </code>
565
- </td>
566
- <td className="px-4 py-3">
567
- <span
568
- className={`rounded-full px-2 py-0.5 text-xs font-medium ${r.type === '301' ? 'bg-blue-100 text-blue-800' : 'bg-purple-100 text-purple-800'}`}
569
- >
570
- {r.type}
571
- </span>
572
- </td>
573
- <td className="px-4 py-3 text-sm text-gray-600">{r.hits.toLocaleString()}</td>
574
- <td className="px-4 py-3">
575
- <span
576
- className={`rounded-full px-2 py-0.5 text-xs font-medium ${r.active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}
577
- >
578
- {r.active ? 'Active' : 'Inactive'}
579
- </span>
580
- </td>
581
- <td className="px-4 py-3">
582
- <div className="flex items-center gap-2">
583
- <button className="rounded p-1.5 transition-colors hover:bg-gray-100">
584
- <Pencil className="h-4 w-4 text-gray-600" />
585
- </button>
586
- <button
587
- onClick={() => handleDeleteRedirect(r.id)}
588
- className="rounded p-1.5 transition-colors hover:bg-gray-100"
589
- >
590
- <Trash2 className="h-4 w-4 text-red-600" />
591
- </button>
592
- </div>
593
- </td>
594
- </tr>
595
- ))}
596
- </tbody>
597
- </table>
598
- </div>
599
- </div>
600
-
601
- <Dialog.Root open={showAddRedirect} onOpenChange={setShowAddRedirect}>
602
- <Dialog.Portal>
603
- <Dialog.Overlay className="fixed inset-0 z-50 bg-black/50" />
604
- <Dialog.Content className="fixed top-1/2 left-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg bg-white p-6 shadow-lg">
605
- <Dialog.Title className="mb-4 text-lg font-semibold text-gray-900">
606
- Add Redirect
607
- </Dialog.Title>
608
- <form onSubmit={handleAddRedirect} className="space-y-4">
609
- <div>
610
- <label className="mb-1 block text-sm font-medium text-gray-700">
611
- Source URL
612
- </label>
613
- <input
614
- type="text"
615
- value={newRedirect.source}
616
- onChange={(e) => setNewRedirect({ ...newRedirect, source: e.target.value })}
617
- placeholder="/old-page"
618
- className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
619
- required
620
- />
621
- </div>
622
- <div>
623
- <label className="mb-1 block text-sm font-medium text-gray-700">
624
- Destination URL
625
- </label>
626
- <input
627
- type="text"
628
- value={newRedirect.destination}
629
- onChange={(e) =>
630
- setNewRedirect({ ...newRedirect, destination: e.target.value })
631
- }
632
- placeholder="/new-page"
633
- className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
634
- required
635
- />
636
- </div>
637
- <div>
638
- <label className="mb-1 block text-sm font-medium text-gray-700">Type</label>
639
- <select
640
- value={newRedirect.type}
641
- onChange={(e) =>
642
- setNewRedirect({ ...newRedirect, type: e.target.value as '301' | '302' })
643
- }
644
- className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
645
- >
646
- <option value="301">301 (Permanent)</option>
647
- <option value="302">302 (Temporary)</option>
648
- </select>
649
- </div>
650
- <div className="flex justify-end gap-3 pt-4">
651
- <Dialog.Close asChild>
652
- <button
653
- type="button"
654
- className="rounded-lg border border-gray-300 px-4 py-2 text-sm hover:bg-gray-50"
655
- >
656
- Cancel
657
- </button>
658
- </Dialog.Close>
659
- <button
660
- type="submit"
661
- className="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
662
- >
663
- Add Redirect
664
- </button>
665
- </div>
666
- </form>
667
- </Dialog.Content>
668
- </Dialog.Portal>
669
- </Dialog.Root>
670
- </Tabs.Content>
671
-
672
- {/* CANONICALIZATION TAB */}
673
- <Tabs.Content value="canonicals" className="flex min-h-0 flex-1 flex-col space-y-4">
674
- <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
675
- <div className="rounded-lg border border-gray-200 bg-white p-4">
676
- <div className="mb-2 flex items-center gap-2">
677
- <ShieldCheck className="h-4 w-4 text-green-600" />
678
- <span className="text-xs font-medium text-gray-600">With Canonical</span>
679
- </div>
680
- <div className="text-2xl font-semibold text-green-700">{allCanonicals.length}</div>
681
- </div>
682
- <div className="rounded-lg border border-gray-200 bg-white p-4">
683
- <div className="mb-2 flex items-center gap-2">
684
- <AlertTriangle className="h-4 w-4 text-red-600" />
685
- <span className="text-xs font-medium text-gray-600">Missing Canonical</span>
686
- </div>
687
- <div className="text-2xl font-semibold text-red-700">{missingCanonical.length}</div>
688
- </div>
689
- <div className="rounded-lg border border-gray-200 bg-white p-4">
690
- <div className="mb-2 flex items-center gap-2">
691
- <Globe className="h-4 w-4 text-blue-600" />
692
- <span className="text-xs font-medium text-gray-600">Canonical Domains</span>
693
- </div>
694
- <div className="text-2xl font-semibold text-gray-900">{canonicalDomains.length}</div>
695
- </div>
696
- <div className="rounded-lg border border-gray-200 bg-white p-4">
697
- <div className="mb-2 flex items-center gap-2">
698
- <CheckCircle2 className="h-4 w-4 text-green-600" />
699
- <span className="text-xs font-medium text-gray-600">Self-Referencing</span>
700
- </div>
701
- <div className="text-2xl font-semibold text-green-700">{allCanonicals.length}</div>
702
- <div className="mt-1 text-xs text-gray-500">Correct pattern</div>
703
- </div>
704
- </div>
705
-
706
- {missingCanonical.length > 0 && (
707
- <div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
708
- <div className="flex items-start gap-3">
709
- <AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-yellow-600" />
710
- <div className="flex-1">
711
- <h3 className="mb-1 text-sm font-semibold text-yellow-900">
712
- {missingCanonical.length} page{missingCanonical.length !== 1 ? 's' : ''} missing
713
- canonical URLs
714
- </h3>
715
- <p className="mb-2 text-sm text-yellow-700">
716
- Without canonical tags, search engines may index duplicate versions of these
717
- pages, diluting your ranking signals.
718
- </p>
719
- <div className="flex flex-wrap gap-2">
720
- {missingCanonical.map((p: any) => (
721
- <button
722
- key={p.id}
723
- onClick={() => onNavigate?.(`/pages/${p.id}`)}
724
- className="rounded-lg bg-yellow-100 px-2.5 py-1 text-xs text-yellow-900 transition-colors hover:bg-yellow-200"
725
- >
726
- {p.title} ({p.url})
727
- </button>
728
- ))}
729
- </div>
730
- </div>
731
- </div>
732
- </div>
733
145
  )}
146
+ <button className={btnSecondary} onClick={() => setCopilotOpen(true)}>
147
+ <Bot className="h-4 w-4" aria-hidden />
148
+ AI Copilot
149
+ </button>
150
+ <button className={btnPrimary} onClick={handleRunAudit} disabled={auditing}>
151
+ <RefreshCw className={`h-4 w-4 ${auditing ? 'animate-spin' : ''}`} aria-hidden />
152
+ {auditing ? 'Running audit…' : 'Run audit'}
153
+ </button>
154
+ </div>
155
+ </header>
734
156
 
735
- <div className="flex-1 overflow-hidden rounded-lg border border-gray-200 bg-white">
736
- <div className="h-full overflow-x-auto">
737
- <table className="w-full">
738
- <thead className="sticky top-0 border-b border-gray-200 bg-gray-50">
739
- <tr>
740
- <th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Page</th>
741
- <th className="px-4 py-2 text-left text-xs font-medium text-gray-700">URL</th>
742
- <th className="px-4 py-2 text-left text-xs font-medium text-gray-700">
743
- Canonical URL
744
- </th>
745
- <th className="px-4 py-2 text-left text-xs font-medium text-gray-700">
746
- Status
747
- </th>
748
- <th className="px-4 py-2 text-left text-xs font-medium text-gray-700">
749
- Actions
750
- </th>
751
- </tr>
752
- </thead>
753
- <tbody className="divide-y divide-gray-200">
754
- {seoPages.map((page: any) => (
755
- <tr key={page.id} className="transition-colors hover:bg-gray-50">
756
- <td className="px-4 py-3 text-sm font-medium text-gray-900">{page.title}</td>
757
- <td className="px-4 py-3">
758
- <code className="rounded bg-gray-100 px-2 py-1 text-xs text-gray-700">
759
- {page.url}
760
- </code>
761
- </td>
762
- <td className="px-4 py-3">
763
- {page.canonical ? (
764
- <code className="block max-w-[200px] truncate rounded bg-gray-100 px-2 py-1 text-xs text-gray-700">
765
- {page.canonical}
766
- </code>
767
- ) : (
768
- <span className="text-xs font-medium text-red-600">Not set</span>
769
- )}
770
- </td>
771
- <td className="px-4 py-3">
772
- {page.canonical ? (
773
- <span className="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800">
774
- ✓ Set
775
- </span>
776
- ) : (
777
- <span className="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800">
778
- Missing
779
- </span>
780
- )}
781
- </td>
782
- <td className="px-4 py-3">
783
- <button
784
- onClick={() => onNavigate?.(`/pages/${page.id}`)}
785
- className="rounded p-1.5 transition-colors hover:bg-gray-100"
786
- title="Edit"
787
- >
788
- <Pencil className="h-4 w-4 text-gray-600" />
789
- </button>
790
- </td>
791
- </tr>
792
- ))}
793
- </tbody>
794
- </table>
795
- </div>
796
- </div>
797
- </Tabs.Content>
157
+ <SeoCopilotDrawer open={copilotOpen} onOpenChange={setCopilotOpen} />
798
158
 
799
- {/* LINK HEALTH TAB */}
800
- <Tabs.Content value="links" className="flex min-h-0 flex-1 flex-col space-y-4">
801
- <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
802
- <div className="rounded-lg border border-gray-200 bg-white p-4">
803
- <div className="mb-1 text-xs text-gray-600">Broken Links</div>
804
- <div className="text-2xl font-semibold text-red-600">
805
- {linkHealth.filter((l: any) => l.status === 404).length}
806
- </div>
807
- </div>
808
- <div className="rounded-lg border border-gray-200 bg-white p-4">
809
- <div className="mb-1 text-xs text-gray-600">Redirect Chains</div>
810
- <div className="text-2xl font-semibold text-yellow-600">
811
- {linkHealth.filter((l: any) => l.status === 301).length}
812
- </div>
813
- </div>
814
- <div className="rounded-lg border border-gray-200 bg-white p-4">
815
- <div className="mb-1 text-xs text-gray-600">Internal Issues</div>
816
- <div className="text-2xl font-semibold text-gray-900">
817
- {linkHealth.filter((l: any) => l.type === 'internal').length}
818
- </div>
819
- </div>
820
- <div className="rounded-lg border border-gray-200 bg-white p-4">
821
- <div className="mb-1 text-xs text-gray-600">External Issues</div>
822
- <div className="text-2xl font-semibold text-gray-900">
823
- {linkHealth.filter((l: any) => l.type === 'external').length}
824
- </div>
825
- </div>
826
- </div>
159
+ <Tabs.Root value={activeTab} onValueChange={changeTab}>
160
+ <Tabs.List
161
+ className="border-border flex gap-1 overflow-x-auto border-b"
162
+ aria-label="SEO sections"
163
+ >
164
+ {TABS.map((t) => {
165
+ const Icon = t.icon
166
+ const badge = t.id === 'audit' ? badgeCounts.audit : 0
167
+ return (
168
+ <Tabs.Trigger
169
+ key={t.id}
170
+ value={t.id}
171
+ className="text-muted-foreground hover:text-foreground data-[state=active]:text-foreground data-[state=active]:border-primary focus-visible:ring-ring -mb-px flex items-center gap-2 border-b-2 border-transparent px-3 py-2.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
172
+ >
173
+ <Icon className="h-4 w-4" aria-hidden />
174
+ {t.label}
175
+ {badge > 0 && (
176
+ <span className="bg-destructive/10 text-destructive ml-1 rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums">
177
+ {badge}
178
+ </span>
179
+ )}
180
+ </Tabs.Trigger>
181
+ )
182
+ })}
183
+ </Tabs.List>
827
184
 
828
- <div className="flex-1 overflow-hidden rounded-lg border border-gray-200 bg-white">
829
- <div className="h-full overflow-x-auto">
830
- <table className="w-full">
831
- <thead className="sticky top-0 border-b border-gray-200 bg-gray-50">
832
- <tr>
833
- <th className="px-4 py-2 text-left text-xs font-medium text-gray-700">
834
- Found On
835
- </th>
836
- <th className="px-4 py-2 text-left text-xs font-medium text-gray-700">
837
- Broken URL
838
- </th>
839
- <th className="px-4 py-2 text-left text-xs font-medium text-gray-700">
840
- Status
841
- </th>
842
- <th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Type</th>
843
- <th className="px-4 py-2 text-left text-xs font-medium text-gray-700">
844
- Last Checked
845
- </th>
846
- <th className="px-4 py-2 text-left text-xs font-medium text-gray-700">
847
- Actions
848
- </th>
849
- </tr>
850
- </thead>
851
- <tbody className="divide-y divide-gray-200">
852
- {linkHealth.map((link: any) => (
853
- <tr key={link.id} className="transition-colors hover:bg-gray-50">
854
- <td className="px-4 py-3">
855
- <code className="rounded bg-gray-100 px-2 py-1 text-xs text-gray-700">
856
- {link.page}
857
- </code>
858
- </td>
859
- <td className="px-4 py-3">
860
- <code className="block max-w-[200px] truncate rounded bg-red-50 px-2 py-1 text-xs text-red-800">
861
- {link.url}
862
- </code>
863
- </td>
864
- <td className="px-4 py-3">
865
- <span
866
- className={`rounded-full px-2 py-0.5 text-xs font-medium ${link.status === 404 ? 'bg-red-100 text-red-800' : 'bg-yellow-100 text-yellow-800'}`}
867
- >
868
- {link.status}
869
- </span>
870
- </td>
871
- <td className="px-4 py-3">
872
- <span
873
- className={`rounded-full px-2 py-0.5 text-xs font-medium ${link.type === 'internal' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'}`}
874
- >
875
- {link.type}
876
- </span>
877
- </td>
878
- <td className="px-4 py-3 text-sm text-gray-600">{link.lastChecked}</td>
879
- <td className="px-4 py-3">
880
- <div className="flex items-center gap-1">
881
- <button
882
- className="rounded p-1.5 transition-colors hover:bg-gray-100"
883
- title="Create redirect"
884
- >
885
- <ArrowRightLeft className="h-4 w-4 text-gray-600" />
886
- </button>
887
- <button
888
- className="rounded p-1.5 transition-colors hover:bg-gray-100"
889
- title="Open URL"
890
- >
891
- <ExternalLink className="h-4 w-4 text-gray-600" />
892
- </button>
893
- </div>
894
- </td>
895
- </tr>
896
- ))}
897
- </tbody>
898
- </table>
899
- </div>
900
- </div>
901
- </Tabs.Content>
185
+ <div className="pt-6">
186
+ <ErrorBoundary>
187
+ <Tabs.Content value="overview" tabIndex={-1}>
188
+ {activeTab === 'overview' && (
189
+ <OverviewTab
190
+ resource={overviewResource}
191
+ onOpenIssue={(issueId) => onNavigate?.(`/seo?tab=audit&issueId=${issueId}`)}
192
+ onConnectSearchConsole={() =>
193
+ toast.info(
194
+ 'Google Search Console integration is coming soon. Demo performance data is available in local development.',
195
+ )
196
+ }
197
+ />
198
+ )}
199
+ </Tabs.Content>
200
+ <Tabs.Content value="content" tabIndex={-1}>
201
+ {activeTab === 'content' && (
202
+ <ContentTab
203
+ initialEntityType={deepLink.entityType}
204
+ initialEntityId={deepLink.entityId}
205
+ />
206
+ )}
207
+ </Tabs.Content>
208
+ <Tabs.Content value="technical" tabIndex={-1}>
209
+ {activeTab === 'technical' && <TechnicalTab />}
210
+ </Tabs.Content>
211
+ <Tabs.Content value="redirects" tabIndex={-1}>
212
+ {activeTab === 'redirects' && <RedirectsTab />}
213
+ </Tabs.Content>
214
+ <Tabs.Content value="audit" tabIndex={-1}>
215
+ {activeTab === 'audit' && (
216
+ <AuditTab
217
+ refetchKey={refetchKey}
218
+ initialIssueId={deepLink.issueId}
219
+ onNavigate={onNavigate}
220
+ />
221
+ )}
222
+ </Tabs.Content>
223
+ </ErrorBoundary>
224
+ </div>
902
225
  </Tabs.Root>
903
226
  </div>
904
227
  )