@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.
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +13 -9
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/layout/sidebar-posts.test.js +6 -4
- package/dist/__tests__/layout/sidebar-posts.test.js.map +1 -1
- package/dist/__tests__/lib/seo-service-errors.test.d.ts +2 -0
- package/dist/__tests__/lib/seo-service-errors.test.d.ts.map +1 -0
- package/dist/__tests__/lib/seo-service-errors.test.js +46 -0
- package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -0
- package/dist/__tests__/views/use-seo-resource.test.d.ts +2 -0
- package/dist/__tests__/views/use-seo-resource.test.d.ts.map +1 -0
- package/dist/__tests__/views/use-seo-resource.test.js +56 -0
- package/dist/__tests__/views/use-seo-resource.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/Breadcrumbs.d.ts.map +1 -1
- package/dist/components/Breadcrumbs.js +4 -1
- package/dist/components/Breadcrumbs.js.map +1 -1
- package/dist/components/seo/Drawer.d.ts +11 -0
- package/dist/components/seo/Drawer.d.ts.map +1 -0
- package/dist/components/seo/Drawer.js +17 -0
- package/dist/components/seo/Drawer.js.map +1 -0
- package/dist/components/seo/RedirectEditorDrawer.d.ts +9 -0
- package/dist/components/seo/RedirectEditorDrawer.d.ts.map +1 -0
- package/dist/components/seo/RedirectEditorDrawer.js +94 -0
- package/dist/components/seo/RedirectEditorDrawer.js.map +1 -0
- package/dist/components/seo/SeoCopilotDrawer.d.ts +10 -0
- package/dist/components/seo/SeoCopilotDrawer.d.ts.map +1 -0
- package/dist/components/seo/SeoCopilotDrawer.js +34 -0
- package/dist/components/seo/SeoCopilotDrawer.js.map +1 -0
- package/dist/components/seo/SeoEditorDrawer.d.ts +8 -0
- package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -0
- package/dist/components/seo/SeoEditorDrawer.js +120 -0
- package/dist/components/seo/SeoEditorDrawer.js.map +1 -0
- package/dist/components/seo/SeoIssueFixDrawer.d.ts +8 -0
- package/dist/components/seo/SeoIssueFixDrawer.d.ts.map +1 -0
- package/dist/components/seo/SeoIssueFixDrawer.js +111 -0
- package/dist/components/seo/SeoIssueFixDrawer.js.map +1 -0
- package/dist/components/seo/primitives.d.ts +53 -0
- package/dist/components/seo/primitives.d.ts.map +1 -0
- package/dist/components/seo/primitives.js +101 -0
- package/dist/components/seo/primitives.js.map +1 -0
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +8 -5
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/lib/seo-service.d.ts +322 -0
- package/dist/lib/seo-service.d.ts.map +1 -0
- package/dist/lib/seo-service.js +331 -0
- package/dist/lib/seo-service.js.map +1 -0
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +4 -1
- package/dist/router/index.js.map +1 -1
- package/dist/views/ApiKeys.d.ts +4 -1
- package/dist/views/ApiKeys.d.ts.map +1 -1
- package/dist/views/ApiKeys.js +5 -3
- package/dist/views/ApiKeys.js.map +1 -1
- package/dist/views/Dashboard.js +2 -2
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +36 -25
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/Posts/NewPostTypeInstructions.js +2 -2
- package/dist/views/Posts/post-type-presets.d.ts +5 -0
- package/dist/views/Posts/post-type-presets.d.ts.map +1 -1
- package/dist/views/Posts/post-type-presets.js +10 -64
- package/dist/views/Posts/post-type-presets.js.map +1 -1
- package/dist/views/SEO.d.ts.map +1 -1
- package/dist/views/SEO.js +89 -132
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/Settings.d.ts +4 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +45 -6
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/Users.d.ts +4 -1
- package/dist/views/Users.d.ts.map +1 -1
- package/dist/views/Users.js +5 -3
- package/dist/views/Users.js.map +1 -1
- package/dist/views/seo/AuditTab.d.ts +6 -0
- package/dist/views/seo/AuditTab.d.ts.map +1 -0
- package/dist/views/seo/AuditTab.js +57 -0
- package/dist/views/seo/AuditTab.js.map +1 -0
- package/dist/views/seo/ContentTab.d.ts +5 -0
- package/dist/views/seo/ContentTab.d.ts.map +1 -0
- package/dist/views/seo/ContentTab.js +27 -0
- package/dist/views/seo/ContentTab.js.map +1 -0
- package/dist/views/seo/OverviewTab.d.ts +8 -0
- package/dist/views/seo/OverviewTab.d.ts.map +1 -0
- package/dist/views/seo/OverviewTab.js +35 -0
- package/dist/views/seo/OverviewTab.js.map +1 -0
- package/dist/views/seo/RedirectsTab.d.ts +2 -0
- package/dist/views/seo/RedirectsTab.d.ts.map +1 -0
- package/dist/views/seo/RedirectsTab.js +130 -0
- package/dist/views/seo/RedirectsTab.js.map +1 -0
- package/dist/views/seo/TechnicalTab.d.ts +2 -0
- package/dist/views/seo/TechnicalTab.d.ts.map +1 -0
- package/dist/views/seo/TechnicalTab.js +105 -0
- package/dist/views/seo/TechnicalTab.js.map +1 -0
- package/dist/views/seo/useSeoResource.d.ts +8 -0
- package/dist/views/seo/useSeoResource.d.ts.map +1 -0
- package/dist/views/seo/useSeoResource.js +55 -0
- package/dist/views/seo/useSeoResource.js.map +1 -0
- package/package.json +2 -2
- package/src/AdminRoot.tsx +13 -9
- package/src/__tests__/layout/sidebar-posts.test.ts +6 -4
- package/src/__tests__/lib/seo-service-errors.test.ts +60 -0
- package/src/__tests__/views/use-seo-resource.test.tsx +68 -0
- package/src/components/Breadcrumbs.tsx +4 -1
- package/src/components/seo/Drawer.tsx +65 -0
- package/src/components/seo/RedirectEditorDrawer.tsx +235 -0
- package/src/components/seo/SeoCopilotDrawer.tsx +76 -0
- package/src/components/seo/SeoEditorDrawer.tsx +359 -0
- package/src/components/seo/SeoIssueFixDrawer.tsx +244 -0
- package/src/components/seo/primitives.tsx +240 -0
- package/src/layout/Sidebar.tsx +7 -10
- package/src/lib/seo-service.ts +660 -0
- package/src/router/index.ts +4 -1
- package/src/views/ApiKeys.tsx +10 -5
- package/src/views/Dashboard.tsx +2 -2
- package/src/views/MediaBrowser.tsx +89 -67
- package/src/views/Posts/NewPostTypeInstructions.tsx +2 -2
- package/src/views/Posts/post-type-presets.ts +15 -64
- package/src/views/SEO.tsx +186 -863
- package/src/views/Settings.tsx +93 -40
- package/src/views/Users.tsx +10 -4
- package/src/views/seo/AuditTab.tsx +229 -0
- package/src/views/seo/ContentTab.tsx +164 -0
- package/src/views/seo/OverviewTab.tsx +257 -0
- package/src/views/seo/RedirectsTab.tsx +398 -0
- package/src/views/seo/TechnicalTab.tsx +315 -0
- 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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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 {
|
|
19
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
27
20
|
import { toast } from 'sonner'
|
|
28
|
-
import
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
52
|
+
interface UrlState {
|
|
53
|
+
tab: string | null
|
|
54
|
+
entityType: string | null
|
|
55
|
+
entityId: string | null
|
|
56
|
+
issueId: string | null
|
|
57
|
+
}
|
|
86
58
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
.
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
|
181
|
-
|
|
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
|
-
|
|
185
|
-
toast.success('Redirect deleted')
|
|
186
|
-
redirectsRefetch()
|
|
118
|
+
return
|
|
187
119
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
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
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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="
|
|
216
|
-
|
|
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="
|
|
235
|
-
<p className="text-
|
|
236
|
-
|
|
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
|
-
<
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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 (<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
|
-
|
|
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
|
-
|
|
800
|
-
<Tabs.
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
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
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
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
|
)
|