@actuate-media/cms-admin 0.19.0 → 0.20.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 (94) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +9 -5
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/actuate-admin.css +1 -1
  5. package/dist/components/seo/Drawer.d.ts +11 -0
  6. package/dist/components/seo/Drawer.d.ts.map +1 -0
  7. package/dist/components/seo/Drawer.js +13 -0
  8. package/dist/components/seo/Drawer.js.map +1 -0
  9. package/dist/components/seo/RedirectEditorDrawer.d.ts +9 -0
  10. package/dist/components/seo/RedirectEditorDrawer.d.ts.map +1 -0
  11. package/dist/components/seo/RedirectEditorDrawer.js +94 -0
  12. package/dist/components/seo/RedirectEditorDrawer.js.map +1 -0
  13. package/dist/components/seo/SeoCopilotDrawer.d.ts +10 -0
  14. package/dist/components/seo/SeoCopilotDrawer.d.ts.map +1 -0
  15. package/dist/components/seo/SeoCopilotDrawer.js +34 -0
  16. package/dist/components/seo/SeoCopilotDrawer.js.map +1 -0
  17. package/dist/components/seo/SeoEditorDrawer.d.ts +8 -0
  18. package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -0
  19. package/dist/components/seo/SeoEditorDrawer.js +111 -0
  20. package/dist/components/seo/SeoEditorDrawer.js.map +1 -0
  21. package/dist/components/seo/SeoIssueFixDrawer.d.ts +8 -0
  22. package/dist/components/seo/SeoIssueFixDrawer.d.ts.map +1 -0
  23. package/dist/components/seo/SeoIssueFixDrawer.js +111 -0
  24. package/dist/components/seo/SeoIssueFixDrawer.js.map +1 -0
  25. package/dist/components/seo/primitives.d.ts +53 -0
  26. package/dist/components/seo/primitives.d.ts.map +1 -0
  27. package/dist/components/seo/primitives.js +100 -0
  28. package/dist/components/seo/primitives.js.map +1 -0
  29. package/dist/layout/Sidebar.d.ts.map +1 -1
  30. package/dist/layout/Sidebar.js +3 -0
  31. package/dist/layout/Sidebar.js.map +1 -1
  32. package/dist/lib/seo-service.d.ts +322 -0
  33. package/dist/lib/seo-service.d.ts.map +1 -0
  34. package/dist/lib/seo-service.js +295 -0
  35. package/dist/lib/seo-service.js.map +1 -0
  36. package/dist/router/index.d.ts.map +1 -1
  37. package/dist/router/index.js +4 -1
  38. package/dist/router/index.js.map +1 -1
  39. package/dist/views/MediaBrowser.d.ts.map +1 -1
  40. package/dist/views/MediaBrowser.js +36 -25
  41. package/dist/views/MediaBrowser.js.map +1 -1
  42. package/dist/views/Posts/NewPostTypeInstructions.js +2 -2
  43. package/dist/views/Posts/post-type-presets.d.ts +5 -0
  44. package/dist/views/Posts/post-type-presets.d.ts.map +1 -1
  45. package/dist/views/Posts/post-type-presets.js +10 -64
  46. package/dist/views/Posts/post-type-presets.js.map +1 -1
  47. package/dist/views/SEO.d.ts.map +1 -1
  48. package/dist/views/SEO.js +89 -132
  49. package/dist/views/SEO.js.map +1 -1
  50. package/dist/views/seo/AuditTab.d.ts +6 -0
  51. package/dist/views/seo/AuditTab.d.ts.map +1 -0
  52. package/dist/views/seo/AuditTab.js +57 -0
  53. package/dist/views/seo/AuditTab.js.map +1 -0
  54. package/dist/views/seo/ContentTab.d.ts +5 -0
  55. package/dist/views/seo/ContentTab.d.ts.map +1 -0
  56. package/dist/views/seo/ContentTab.js +27 -0
  57. package/dist/views/seo/ContentTab.js.map +1 -0
  58. package/dist/views/seo/OverviewTab.d.ts +8 -0
  59. package/dist/views/seo/OverviewTab.d.ts.map +1 -0
  60. package/dist/views/seo/OverviewTab.js +35 -0
  61. package/dist/views/seo/OverviewTab.js.map +1 -0
  62. package/dist/views/seo/RedirectsTab.d.ts +2 -0
  63. package/dist/views/seo/RedirectsTab.d.ts.map +1 -0
  64. package/dist/views/seo/RedirectsTab.js +124 -0
  65. package/dist/views/seo/RedirectsTab.js.map +1 -0
  66. package/dist/views/seo/TechnicalTab.d.ts +2 -0
  67. package/dist/views/seo/TechnicalTab.d.ts.map +1 -0
  68. package/dist/views/seo/TechnicalTab.js +102 -0
  69. package/dist/views/seo/TechnicalTab.js.map +1 -0
  70. package/dist/views/seo/useSeoResource.d.ts +8 -0
  71. package/dist/views/seo/useSeoResource.d.ts.map +1 -0
  72. package/dist/views/seo/useSeoResource.js +42 -0
  73. package/dist/views/seo/useSeoResource.js.map +1 -0
  74. package/package.json +2 -2
  75. package/src/AdminRoot.tsx +9 -5
  76. package/src/components/seo/Drawer.tsx +61 -0
  77. package/src/components/seo/RedirectEditorDrawer.tsx +235 -0
  78. package/src/components/seo/SeoCopilotDrawer.tsx +76 -0
  79. package/src/components/seo/SeoEditorDrawer.tsx +347 -0
  80. package/src/components/seo/SeoIssueFixDrawer.tsx +244 -0
  81. package/src/components/seo/primitives.tsx +237 -0
  82. package/src/layout/Sidebar.tsx +3 -0
  83. package/src/lib/seo-service.ts +623 -0
  84. package/src/router/index.ts +4 -1
  85. package/src/views/MediaBrowser.tsx +89 -67
  86. package/src/views/Posts/NewPostTypeInstructions.tsx +2 -2
  87. package/src/views/Posts/post-type-presets.ts +15 -64
  88. package/src/views/SEO.tsx +186 -863
  89. package/src/views/seo/AuditTab.tsx +229 -0
  90. package/src/views/seo/ContentTab.tsx +164 -0
  91. package/src/views/seo/OverviewTab.tsx +257 -0
  92. package/src/views/seo/RedirectsTab.tsx +392 -0
  93. package/src/views/seo/TechnicalTab.tsx +303 -0
  94. package/src/views/seo/useSeoResource.ts +52 -0
@@ -0,0 +1,229 @@
1
+ 'use client'
2
+
3
+ import { useMemo, useState } from 'react'
4
+ import { ClipboardCheck, Clock, CheckCircle2 } from 'lucide-react'
5
+ import {
6
+ fetchSeoIssues,
7
+ fetchSeoAuditRuns,
8
+ type SeoIssue,
9
+ type SeoIssueQuery,
10
+ type SeoSeverity,
11
+ type SeoIssueStatus,
12
+ } from '../../lib/seo-service.js'
13
+ import {
14
+ SectionCard,
15
+ SeoLoading,
16
+ SeoEmptyState,
17
+ SeoErrorState,
18
+ severityMeta,
19
+ SeoStatusBadge,
20
+ } from '../../components/seo/primitives.js'
21
+ import { SeoIssueFixDrawer } from '../../components/seo/SeoIssueFixDrawer.js'
22
+ import { useSeoResource } from './useSeoResource.js'
23
+
24
+ const SEVERITY_FILTERS: { id: SeoSeverity | 'all'; label: string }[] = [
25
+ { id: 'all', label: 'All' },
26
+ { id: 'critical', label: 'Critical' },
27
+ { id: 'warning', label: 'Warnings' },
28
+ { id: 'info', label: 'Info' },
29
+ ]
30
+
31
+ const STATUS_FILTERS: { id: SeoIssueStatus | 'all'; label: string }[] = [
32
+ { id: 'open', label: 'Open' },
33
+ { id: 'recurring', label: 'Recurring' },
34
+ { id: 'ignored', label: 'Ignored' },
35
+ { id: 'resolved', label: 'Resolved' },
36
+ { id: 'all', label: 'All' },
37
+ ]
38
+
39
+ function issueStatusTone(status: SeoIssueStatus) {
40
+ switch (status) {
41
+ case 'resolved':
42
+ return 'good' as const
43
+ case 'ignored':
44
+ return 'neutral' as const
45
+ case 'recurring':
46
+ return 'fair' as const
47
+ default:
48
+ return 'critical' as const
49
+ }
50
+ }
51
+
52
+ function AuditSummaryCards({ refetchKey }: { refetchKey: number }) {
53
+ const { data, loading } = useSeoResource(fetchSeoAuditRuns, [refetchKey])
54
+ const last = (data ?? [])[0]
55
+ return (
56
+ <div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
57
+ <div className="border-border bg-card rounded-lg border p-4">
58
+ <p className="text-muted-foreground flex items-center gap-1.5 text-sm">
59
+ <Clock className="h-3.5 w-3.5" aria-hidden /> Last audit
60
+ </p>
61
+ <p className="text-foreground mt-1 text-sm font-medium">
62
+ {loading ? '…' : last ? new Date(last.startedAt).toLocaleString() : 'Never'}
63
+ </p>
64
+ </div>
65
+ <div className="border-border bg-card rounded-lg border p-4">
66
+ <p className="text-muted-foreground text-sm">Pages scanned</p>
67
+ <p className="text-foreground mt-1 text-2xl font-medium tabular-nums">
68
+ {last?.pagesScanned ?? 0}
69
+ </p>
70
+ </div>
71
+ <div className="border-border bg-card rounded-lg border p-4">
72
+ <p className="text-muted-foreground text-sm">Issues found</p>
73
+ <p className="text-foreground mt-1 text-2xl font-medium tabular-nums">
74
+ {last?.issuesFound ?? 0}
75
+ </p>
76
+ </div>
77
+ <div className="border-border bg-card rounded-lg border p-4">
78
+ <p className="text-muted-foreground text-sm">Site score</p>
79
+ <p className="text-foreground mt-1 text-2xl font-medium tabular-nums">
80
+ {last?.siteScoreAfter ?? '—'}
81
+ </p>
82
+ </div>
83
+ </div>
84
+ )
85
+ }
86
+
87
+ export function AuditTab({
88
+ refetchKey = 0,
89
+ initialIssueId,
90
+ onNavigate,
91
+ }: {
92
+ refetchKey?: number
93
+ initialIssueId?: string | null
94
+ onNavigate?: (path: string) => void
95
+ }) {
96
+ const [severity, setSeverity] = useState<SeoSeverity | 'all'>('all')
97
+ const [status, setStatus] = useState<SeoIssueStatus | 'all'>('open')
98
+ const [openIssue, setOpenIssue] = useState<string | null>(initialIssueId ?? null)
99
+
100
+ const query: SeoIssueQuery = useMemo(() => ({ severity, status }), [severity, status])
101
+ const { data, loading, error, refetch } = useSeoResource(
102
+ () => fetchSeoIssues(query),
103
+ [severity, status, refetchKey],
104
+ )
105
+
106
+ const issues = data ?? []
107
+
108
+ return (
109
+ <div className="space-y-4">
110
+ <AuditSummaryCards refetchKey={refetchKey} />
111
+
112
+ <SectionCard>
113
+ <div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
114
+ <div className="flex flex-wrap gap-1" role="tablist" aria-label="Filter by severity">
115
+ {SEVERITY_FILTERS.map((f) => (
116
+ <button
117
+ key={f.id}
118
+ role="tab"
119
+ aria-selected={severity === f.id}
120
+ onClick={() => setSeverity(f.id)}
121
+ className={`focus-visible:ring-ring rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none ${
122
+ severity === f.id
123
+ ? 'bg-primary text-primary-foreground'
124
+ : 'text-muted-foreground hover:bg-accent hover:text-foreground'
125
+ }`}
126
+ >
127
+ {f.label}
128
+ </button>
129
+ ))}
130
+ </div>
131
+ <div className="flex flex-wrap gap-1" role="tablist" aria-label="Filter by status">
132
+ {STATUS_FILTERS.map((f) => (
133
+ <button
134
+ key={f.id}
135
+ role="tab"
136
+ aria-selected={status === f.id}
137
+ onClick={() => setStatus(f.id)}
138
+ className={`focus-visible:ring-ring rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none ${
139
+ status === f.id
140
+ ? 'bg-accent text-foreground'
141
+ : 'text-muted-foreground hover:bg-accent hover:text-foreground'
142
+ }`}
143
+ >
144
+ {f.label}
145
+ </button>
146
+ ))}
147
+ </div>
148
+ </div>
149
+
150
+ {loading ? (
151
+ <SeoLoading label="Loading issues…" />
152
+ ) : error ? (
153
+ <SeoErrorState message={error} onRetry={refetch} />
154
+ ) : issues.length === 0 ? (
155
+ <SeoEmptyState
156
+ icon={status === 'open' ? <CheckCircle2 size={24} /> : <ClipboardCheck size={24} />}
157
+ title={status === 'open' ? 'No open issues' : 'No issues match'}
158
+ description={
159
+ status === 'open'
160
+ ? 'Run an audit to scan for new issues, or adjust the filters.'
161
+ : 'Try a different severity or status filter.'
162
+ }
163
+ />
164
+ ) : (
165
+ <div className="overflow-x-auto">
166
+ <table className="w-full text-sm" aria-label="SEO issues">
167
+ <thead>
168
+ <tr className="text-muted-foreground border-border border-b text-left">
169
+ <th className="py-2 pr-4 font-medium">Severity</th>
170
+ <th className="py-2 pr-4 font-medium">Issue</th>
171
+ <th className="py-2 pr-4 font-medium">Page</th>
172
+ <th className="py-2 pr-4 font-medium">Status</th>
173
+ <th className="py-2 text-right font-medium">
174
+ <span className="sr-only">Actions</span>
175
+ </th>
176
+ </tr>
177
+ </thead>
178
+ <tbody className="divide-border divide-y">
179
+ {issues.map((issue: SeoIssue) => {
180
+ const meta = severityMeta(issue.severity)
181
+ return (
182
+ <tr key={issue.id} className="hover:bg-accent/40">
183
+ <td className="py-2.5 pr-4">
184
+ <span className={`inline-flex items-center gap-1.5 text-sm ${meta.text}`}>
185
+ <span className={`h-1.5 w-1.5 rounded-full ${meta.dot}`} aria-hidden />
186
+ {meta.label}
187
+ </span>
188
+ </td>
189
+ <td className="py-2.5 pr-4">
190
+ <span className="text-foreground block font-medium">{issue.title}</span>
191
+ <span className="text-muted-foreground block capitalize">
192
+ {issue.category}
193
+ </span>
194
+ </td>
195
+ <td className="text-muted-foreground py-2.5 pr-4">
196
+ <span className="block truncate">
197
+ {issue.entityTitle || issue.url || '—'}
198
+ </span>
199
+ </td>
200
+ <td className="py-2.5 pr-4">
201
+ <SeoStatusBadge label={issue.status} tone={issueStatusTone(issue.status)} />
202
+ </td>
203
+ <td className="py-2.5 text-right">
204
+ <button
205
+ onClick={() => setOpenIssue(issue.id)}
206
+ className="text-primary hover:bg-accent focus-visible:ring-ring rounded-md px-2 py-1 text-sm focus-visible:ring-2 focus-visible:outline-none"
207
+ >
208
+ {issue.fixable ? 'Fix' : 'View'}
209
+ </button>
210
+ </td>
211
+ </tr>
212
+ )
213
+ })}
214
+ </tbody>
215
+ </table>
216
+ </div>
217
+ )}
218
+ </SectionCard>
219
+
220
+ <SeoIssueFixDrawer
221
+ open={openIssue !== null}
222
+ onOpenChange={(o) => !o && setOpenIssue(null)}
223
+ issueId={openIssue}
224
+ onChanged={refetch}
225
+ onNavigate={onNavigate}
226
+ />
227
+ </div>
228
+ )
229
+ }
@@ -0,0 +1,164 @@
1
+ 'use client'
2
+
3
+ import { useMemo, useState } from 'react'
4
+ import { Search, FileText, Pencil } from 'lucide-react'
5
+ import {
6
+ fetchSeoContentRecords,
7
+ applyContentSeoQuery,
8
+ type ContentSeoFilter,
9
+ } from '../../lib/seo-service.js'
10
+ import {
11
+ SectionCard,
12
+ SeoLoading,
13
+ SeoEmptyState,
14
+ SeoErrorState,
15
+ ScorePill,
16
+ SeoStatusBadge,
17
+ } from '../../components/seo/primitives.js'
18
+ import { SeoEditorDrawer } from '../../components/seo/SeoEditorDrawer.js'
19
+ import { useSeoResource } from './useSeoResource.js'
20
+
21
+ const FILTERS: { id: ContentSeoFilter; label: string }[] = [
22
+ { id: 'all', label: 'All' },
23
+ { id: 'pages', label: 'Pages' },
24
+ { id: 'posts', label: 'Posts' },
25
+ ]
26
+
27
+ const inputCls =
28
+ 'w-full rounded-md border border-border bg-input-background py-2 pl-9 pr-3 text-base text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
29
+
30
+ export function ContentTab({
31
+ initialEntityType,
32
+ initialEntityId,
33
+ }: {
34
+ initialEntityType?: string | null
35
+ initialEntityId?: string | null
36
+ }) {
37
+ const { data, loading, error, refetch } = useSeoResource(fetchSeoContentRecords, [])
38
+ const [search, setSearch] = useState('')
39
+ const [filter, setFilter] = useState<ContentSeoFilter>('all')
40
+ const [editor, setEditor] = useState<{ type: string; id: string } | null>(
41
+ initialEntityType && initialEntityId ? { type: initialEntityType, id: initialEntityId } : null,
42
+ )
43
+
44
+ const rows = useMemo(
45
+ () => applyContentSeoQuery(data ?? [], { search, filter }),
46
+ [data, search, filter],
47
+ )
48
+
49
+ return (
50
+ <div className="space-y-4">
51
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
52
+ <div className="relative max-w-sm flex-1">
53
+ <Search
54
+ className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
55
+ aria-hidden
56
+ />
57
+ <input
58
+ className={inputCls}
59
+ placeholder="Search content…"
60
+ value={search}
61
+ onChange={(e) => setSearch(e.target.value)}
62
+ aria-label="Search content"
63
+ />
64
+ </div>
65
+ <div className="flex gap-1" role="tablist" aria-label="Content type filter">
66
+ {FILTERS.map((f) => (
67
+ <button
68
+ key={f.id}
69
+ role="tab"
70
+ aria-selected={filter === f.id}
71
+ onClick={() => setFilter(f.id)}
72
+ className={`focus-visible:ring-ring rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none ${
73
+ filter === f.id
74
+ ? 'bg-primary text-primary-foreground'
75
+ : 'text-muted-foreground hover:bg-accent hover:text-foreground'
76
+ }`}
77
+ >
78
+ {f.label}
79
+ </button>
80
+ ))}
81
+ </div>
82
+ </div>
83
+
84
+ <SectionCard>
85
+ {loading ? (
86
+ <SeoLoading label="Loading content…" />
87
+ ) : error ? (
88
+ <SeoErrorState message={error} onRetry={refetch} />
89
+ ) : rows.length === 0 ? (
90
+ <SeoEmptyState
91
+ icon={<FileText size={24} />}
92
+ title={search || filter !== 'all' ? 'No matching content' : 'No content yet'}
93
+ description={
94
+ search || filter !== 'all'
95
+ ? 'Try adjusting your search or filter.'
96
+ : 'Publish pages or posts to manage their SEO here.'
97
+ }
98
+ />
99
+ ) : (
100
+ <div className="overflow-x-auto">
101
+ <table className="w-full text-sm" aria-label="Content SEO">
102
+ <thead>
103
+ <tr className="text-muted-foreground border-border border-b text-left">
104
+ <th className="py-2 pr-4 font-medium">Title</th>
105
+ <th className="py-2 pr-4 font-medium">Type</th>
106
+ <th className="py-2 pr-4 font-medium">Meta</th>
107
+ <th className="py-2 pr-4 text-right font-medium">Score</th>
108
+ <th className="py-2 text-right font-medium">
109
+ <span className="sr-only">Actions</span>
110
+ </th>
111
+ </tr>
112
+ </thead>
113
+ <tbody className="divide-border divide-y">
114
+ {rows.map((r) => (
115
+ <tr key={`${r.entityType}-${r.entityId}`} className="hover:bg-accent/40">
116
+ <td className="py-2.5 pr-4">
117
+ <span className="text-foreground block font-medium">{r.title}</span>
118
+ <span className="text-muted-foreground block truncate">{r.url}</span>
119
+ </td>
120
+ <td className="py-2.5 pr-4 capitalize">
121
+ <span className="text-muted-foreground">{r.entityType}</span>
122
+ </td>
123
+ <td className="py-2.5 pr-4">
124
+ <span className="flex flex-wrap gap-1.5">
125
+ <SeoStatusBadge
126
+ label={r.metaTitle ? 'Title' : 'No title'}
127
+ tone={r.metaTitle ? 'good' : 'critical'}
128
+ />
129
+ <SeoStatusBadge
130
+ label={r.metaDescription ? 'Desc' : 'No desc'}
131
+ tone={r.metaDescription ? 'good' : 'critical'}
132
+ />
133
+ {r.noindex && <SeoStatusBadge label="No-index" tone="fair" />}
134
+ </span>
135
+ </td>
136
+ <td className="py-2.5 pr-4 text-right">
137
+ <ScorePill score={r.seoScore} />
138
+ </td>
139
+ <td className="py-2.5 text-right">
140
+ <button
141
+ onClick={() => setEditor({ type: r.entityType, id: r.entityId })}
142
+ className="text-primary hover:bg-accent focus-visible:ring-ring inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm focus-visible:ring-2 focus-visible:outline-none"
143
+ >
144
+ <Pencil className="h-3.5 w-3.5" aria-hidden /> Edit
145
+ </button>
146
+ </td>
147
+ </tr>
148
+ ))}
149
+ </tbody>
150
+ </table>
151
+ </div>
152
+ )}
153
+ </SectionCard>
154
+
155
+ <SeoEditorDrawer
156
+ open={editor !== null}
157
+ onOpenChange={(o) => !o && setEditor(null)}
158
+ entityType={editor?.type ?? null}
159
+ entityId={editor?.id ?? null}
160
+ onSaved={refetch}
161
+ />
162
+ </div>
163
+ )
164
+ }
@@ -0,0 +1,257 @@
1
+ 'use client'
2
+
3
+ import {
4
+ TrendingUp,
5
+ TrendingDown,
6
+ Minus,
7
+ AlertOctagon,
8
+ AlertTriangle,
9
+ Info,
10
+ Zap,
11
+ BarChart3,
12
+ ArrowRight,
13
+ } from 'lucide-react'
14
+ import type { QuickWin, SeoMetric, SeoOverview } from '../../lib/seo-service.js'
15
+ import {
16
+ SectionCard,
17
+ SeoScoreBar,
18
+ SeoLoading,
19
+ SeoEmptyState,
20
+ SeoErrorState,
21
+ gradeMeta,
22
+ btnSecondary,
23
+ } from '../../components/seo/primitives.js'
24
+ import type { SeoResource } from './useSeoResource.js'
25
+
26
+ function TrendIcon({ trend }: { trend?: SeoMetric['trend'] }) {
27
+ if (trend === 'up') return <TrendingUp className="text-success h-4 w-4" aria-hidden />
28
+ if (trend === 'down') return <TrendingDown className="text-destructive h-4 w-4" aria-hidden />
29
+ return <Minus className="text-muted-foreground h-4 w-4" aria-hidden />
30
+ }
31
+
32
+ function impactTone(impact: QuickWin['impact']): 'good' | 'fair' | 'poor' {
33
+ return impact === 'High' ? 'poor' : impact === 'Medium' ? 'fair' : 'good'
34
+ }
35
+
36
+ export function OverviewTab({
37
+ resource,
38
+ onOpenIssue,
39
+ onConnectSearchConsole,
40
+ }: {
41
+ resource: SeoResource<SeoOverview>
42
+ onOpenIssue?: (issueId: string) => void
43
+ onConnectSearchConsole?: () => void
44
+ }) {
45
+ const { data, loading, error, refetch } = resource
46
+
47
+ if (loading) return <SeoLoading label="Loading SEO overview…" />
48
+ if (error) return <SeoErrorState message={error} onRetry={refetch} />
49
+ if (!data) return <SeoEmptyState title="No overview data" />
50
+
51
+ const {
52
+ siteScore,
53
+ metrics,
54
+ issuesSummary,
55
+ quickWins,
56
+ searchPerformance,
57
+ searchConsoleConnected,
58
+ } = data
59
+ const grade = gradeMeta(siteScore.grade)
60
+
61
+ return (
62
+ <div className="space-y-6">
63
+ {/* Site score + metrics */}
64
+ <div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
65
+ <SectionCard title="Site SEO Score" className="lg:col-span-1">
66
+ <div className="flex items-center gap-4">
67
+ <div className="flex flex-col">
68
+ <span className="text-foreground text-4xl font-medium tabular-nums">
69
+ {siteScore.score}
70
+ </span>
71
+ <span className={`text-sm font-medium ${grade.text}`}>{grade.label}</span>
72
+ </div>
73
+ {siteScore.delta !== null && (
74
+ <span
75
+ className={`inline-flex items-center gap-1 text-sm ${
76
+ siteScore.delta >= 0 ? 'text-success' : 'text-destructive'
77
+ }`}
78
+ >
79
+ {siteScore.delta >= 0 ? (
80
+ <TrendingUp className="h-4 w-4" aria-hidden />
81
+ ) : (
82
+ <TrendingDown className="h-4 w-4" aria-hidden />
83
+ )}
84
+ {siteScore.delta >= 0 ? '+' : ''}
85
+ {siteScore.delta} pts
86
+ </span>
87
+ )}
88
+ </div>
89
+ <SeoScoreBar score={siteScore.score} className="mt-4" />
90
+ {siteScore.breakdown.length > 0 && (
91
+ <dl className="mt-4 space-y-2">
92
+ {siteScore.breakdown.map((b) => (
93
+ <div key={b.category} className="flex items-center justify-between text-sm">
94
+ <dt className="text-muted-foreground capitalize">{b.category}</dt>
95
+ <dd className="text-foreground tabular-nums">
96
+ {b.score}/{100} <span className="text-muted-foreground">({b.weight}%)</span>
97
+ </dd>
98
+ </div>
99
+ ))}
100
+ </dl>
101
+ )}
102
+ </SectionCard>
103
+
104
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:col-span-2">
105
+ {metrics.length === 0 ? (
106
+ <div className="sm:col-span-2">
107
+ <SeoEmptyState
108
+ title="No metrics yet"
109
+ description="Run an audit to populate site metrics."
110
+ />
111
+ </div>
112
+ ) : (
113
+ metrics.map((m) => (
114
+ <div key={m.label} className="border-border bg-card rounded-lg border p-4">
115
+ <p className="text-muted-foreground text-sm">{m.label}</p>
116
+ <p className="text-foreground mt-1 text-2xl font-medium tabular-nums">{m.value}</p>
117
+ <p className="text-muted-foreground mt-1 flex items-center gap-1.5 text-sm">
118
+ <TrendIcon trend={m.trend} />
119
+ {m.helper}
120
+ </p>
121
+ </div>
122
+ ))
123
+ )}
124
+ </div>
125
+ </div>
126
+
127
+ {/* Issues found + quick wins */}
128
+ <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
129
+ <SectionCard title="Issues Found">
130
+ <ul className="space-y-3">
131
+ <li className="flex items-center justify-between">
132
+ <span className="text-foreground flex items-center gap-2 text-sm">
133
+ <AlertOctagon className="text-destructive h-4 w-4" aria-hidden /> Critical
134
+ </span>
135
+ <span className="text-foreground font-medium tabular-nums">
136
+ {issuesSummary.critical}
137
+ </span>
138
+ </li>
139
+ <li className="flex items-center justify-between">
140
+ <span className="text-foreground flex items-center gap-2 text-sm">
141
+ <AlertTriangle className="text-warning h-4 w-4" aria-hidden /> Warnings
142
+ </span>
143
+ <span className="text-foreground font-medium tabular-nums">
144
+ {issuesSummary.warning}
145
+ </span>
146
+ </li>
147
+ <li className="flex items-center justify-between">
148
+ <span className="text-foreground flex items-center gap-2 text-sm">
149
+ <Info className="text-muted-foreground h-4 w-4" aria-hidden /> Info
150
+ </span>
151
+ <span className="text-foreground font-medium tabular-nums">{issuesSummary.info}</span>
152
+ </li>
153
+ </ul>
154
+ </SectionCard>
155
+
156
+ <SectionCard title="Quick Wins" description="High-impact fixes ranked by priority">
157
+ {quickWins.length === 0 ? (
158
+ <SeoEmptyState
159
+ icon={<Zap size={24} />}
160
+ title="No quick wins"
161
+ description="Great — no high-impact issues are waiting."
162
+ />
163
+ ) : (
164
+ <ul className="divide-border divide-y">
165
+ {quickWins.map((w) => {
166
+ const tone = impactTone(w.impact)
167
+ const toneCls =
168
+ tone === 'poor'
169
+ ? 'bg-destructive/10 text-destructive'
170
+ : tone === 'fair'
171
+ ? 'bg-warning/10 text-warning'
172
+ : 'bg-success/10 text-success'
173
+ return (
174
+ <li key={w.id} className="flex items-start justify-between gap-3 py-3 first:pt-0">
175
+ <div className="min-w-0">
176
+ <p className="text-foreground text-sm font-medium">{w.title}</p>
177
+ <p className="text-muted-foreground mt-0.5 text-sm">{w.explanation}</p>
178
+ </div>
179
+ <div className="flex shrink-0 items-center gap-2">
180
+ <span className={`rounded-full px-2 py-0.5 text-xs font-medium ${toneCls}`}>
181
+ {w.impact}
182
+ </span>
183
+ {w.issueId && onOpenIssue && (
184
+ <button
185
+ onClick={() => onOpenIssue(w.issueId as string)}
186
+ className="text-primary hover:bg-accent focus-visible:ring-ring inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm focus-visible:ring-2 focus-visible:outline-none"
187
+ >
188
+ Fix <ArrowRight className="h-3.5 w-3.5" aria-hidden />
189
+ </button>
190
+ )}
191
+ </div>
192
+ </li>
193
+ )
194
+ })}
195
+ </ul>
196
+ )}
197
+ </SectionCard>
198
+ </div>
199
+
200
+ {/* Search performance */}
201
+ <SectionCard title="Search Performance" description="Top pages by impressions (last 28 days)">
202
+ {!searchConsoleConnected ? (
203
+ <SeoEmptyState
204
+ icon={<BarChart3 size={24} />}
205
+ title="Search Console not connected"
206
+ description="Connect Google Search Console to see real impressions, clicks, CTR, and average position."
207
+ action={
208
+ onConnectSearchConsole ? (
209
+ <button className={btnSecondary} onClick={onConnectSearchConsole}>
210
+ Connect Search Console
211
+ </button>
212
+ ) : undefined
213
+ }
214
+ />
215
+ ) : searchPerformance.length === 0 ? (
216
+ <SeoEmptyState title="No search performance data" />
217
+ ) : (
218
+ <div className="overflow-x-auto">
219
+ <table className="w-full text-sm" aria-label="Search performance">
220
+ <thead>
221
+ <tr className="text-muted-foreground border-border border-b text-left">
222
+ <th className="py-2 pr-4 font-medium">Page</th>
223
+ <th className="py-2 pr-4 text-right font-medium">Impressions</th>
224
+ <th className="py-2 pr-4 text-right font-medium">Clicks</th>
225
+ <th className="py-2 pr-4 text-right font-medium">CTR</th>
226
+ <th className="py-2 text-right font-medium">Avg. Position</th>
227
+ </tr>
228
+ </thead>
229
+ <tbody className="divide-border divide-y">
230
+ {searchPerformance.map((row) => (
231
+ <tr key={row.url}>
232
+ <td className="py-2 pr-4">
233
+ <span className="text-foreground block font-medium">{row.title}</span>
234
+ <span className="text-muted-foreground block truncate">{row.url}</span>
235
+ </td>
236
+ <td className="text-foreground py-2 pr-4 text-right tabular-nums">
237
+ {row.impressions.toLocaleString()}
238
+ </td>
239
+ <td className="text-foreground py-2 pr-4 text-right tabular-nums">
240
+ {row.clicks.toLocaleString()}
241
+ </td>
242
+ <td className="text-foreground py-2 pr-4 text-right tabular-nums">
243
+ {(row.ctr * 100).toFixed(1)}%
244
+ </td>
245
+ <td className="text-foreground py-2 text-right tabular-nums">
246
+ {row.averagePosition.toFixed(1)}
247
+ </td>
248
+ </tr>
249
+ ))}
250
+ </tbody>
251
+ </table>
252
+ </div>
253
+ )}
254
+ </SectionCard>
255
+ </div>
256
+ )
257
+ }