@prenta/admin 1.11.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -0
- package/dist/__tests__/components/seo-editor-pane.render.test.js +15 -0
- package/dist/__tests__/components/seo-editor-pane.render.test.js.map +1 -1
- package/dist/__tests__/lib/seo-service-errors.test.js +87 -1
- package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
- package/dist/__tests__/views/ai-settings.render.test.js +26 -8
- package/dist/__tests__/views/ai-settings.render.test.js.map +1 -1
- package/dist/__tests__/views/redirects-pane.render.test.js +20 -2
- package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-settings.render.test.js +110 -5
- package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
- package/dist/components/seo/SeoCopilotDrawer.d.ts.map +1 -1
- package/dist/components/seo/SeoCopilotDrawer.js +4 -0
- package/dist/components/seo/SeoCopilotDrawer.js.map +1 -1
- package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
- package/dist/components/seo/SeoEditorPane.js +8 -1
- package/dist/components/seo/SeoEditorPane.js.map +1 -1
- package/dist/components/seo/SeoIssueFixDrawer.d.ts.map +1 -1
- package/dist/components/seo/SeoIssueFixDrawer.js +4 -0
- package/dist/components/seo/SeoIssueFixDrawer.js.map +1 -1
- package/dist/components/seo/SeoSearchStats.d.ts +4 -0
- package/dist/components/seo/SeoSearchStats.d.ts.map +1 -0
- package/dist/components/seo/SeoSearchStats.js +91 -0
- package/dist/components/seo/SeoSearchStats.js.map +1 -0
- package/dist/lib/seo-service.d.ts +60 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +53 -1
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/dist/views/seo/OverviewTab.js +1 -1
- package/dist/views/seo/OverviewTab.js.map +1 -1
- package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
- package/dist/views/seo/RedirectsTab.js +6 -0
- package/dist/views/seo/RedirectsTab.js.map +1 -1
- package/dist/views/settings/AISettingsTab.d.ts.map +1 -1
- package/dist/views/settings/AISettingsTab.js +1 -1
- package/dist/views/settings/AISettingsTab.js.map +1 -1
- package/dist/views/settings/AiFeaturesCard.d.ts +8 -3
- package/dist/views/settings/AiFeaturesCard.d.ts.map +1 -1
- package/dist/views/settings/AiFeaturesCard.js +7 -6
- package/dist/views/settings/AiFeaturesCard.js.map +1 -1
- package/dist/views/settings/SearchDataCard.d.ts +10 -0
- package/dist/views/settings/SearchDataCard.d.ts.map +1 -0
- package/dist/views/settings/SearchDataCard.js +126 -0
- package/dist/views/settings/SearchDataCard.js.map +1 -0
- package/dist/views/settings/SeoDigestCard.d.ts +9 -0
- package/dist/views/settings/SeoDigestCard.d.ts.map +1 -0
- package/dist/views/settings/SeoDigestCard.js +77 -0
- package/dist/views/settings/SeoDigestCard.js.map +1 -0
- package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
- package/dist/views/settings/SeoSettingsTab.js +3 -1
- package/dist/views/settings/SeoSettingsTab.js.map +1 -1
- package/dist/views/settings/useAiSettings.d.ts +12 -0
- package/dist/views/settings/useAiSettings.d.ts.map +1 -1
- package/dist/views/settings/useAiSettings.js +29 -1
- package/dist/views/settings/useAiSettings.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/components/seo-editor-pane.render.test.tsx +25 -3
- package/src/__tests__/lib/seo-service-errors.test.ts +111 -0
- package/src/__tests__/views/ai-settings.render.test.tsx +38 -8
- package/src/__tests__/views/redirects-pane.render.test.tsx +28 -2
- package/src/__tests__/views/seo-settings.render.test.tsx +156 -4
- package/src/components/seo/SeoCopilotDrawer.tsx +4 -0
- package/src/components/seo/SeoEditorPane.tsx +13 -0
- package/src/components/seo/SeoIssueFixDrawer.tsx +4 -0
- package/src/components/seo/SeoSearchStats.tsx +183 -0
- package/src/lib/seo-service.ts +107 -2
- package/src/views/seo/OverviewTab.tsx +1 -1
- package/src/views/seo/RedirectsTab.tsx +6 -0
- package/src/views/settings/AISettingsTab.tsx +1 -0
- package/src/views/settings/AiFeaturesCard.tsx +59 -2
- package/src/views/settings/SearchDataCard.tsx +390 -0
- package/src/views/settings/SeoDigestCard.tsx +164 -0
- package/src/views/settings/SeoSettingsTab.tsx +4 -0
- package/src/views/settings/useAiSettings.ts +71 -2
|
@@ -13,8 +13,8 @@ const SELECT_CLASS =
|
|
|
13
13
|
* Global AI feature toggles with per-feature model routing. Each switch persists
|
|
14
14
|
* (via the Save bar) to the shared `ai.features.*` flags; the optional model
|
|
15
15
|
* picker overrides which model that feature uses (otherwise it inherits the
|
|
16
|
-
* default).
|
|
17
|
-
* and
|
|
16
|
+
* default). The governance flags (approval, bulk apply, daily cap) are enforced
|
|
17
|
+
* by the governed AI runtime and the auto-apply gates in core.
|
|
18
18
|
*/
|
|
19
19
|
export function AiFeaturesCard({
|
|
20
20
|
features,
|
|
@@ -23,6 +23,7 @@ export function AiFeaturesCard({
|
|
|
23
23
|
canEdit,
|
|
24
24
|
onToggle,
|
|
25
25
|
onSetFeatureModel,
|
|
26
|
+
onSetGovernance,
|
|
26
27
|
}: {
|
|
27
28
|
features: AiFeatureView[]
|
|
28
29
|
models: AiModelView[]
|
|
@@ -30,6 +31,14 @@ export function AiFeaturesCard({
|
|
|
30
31
|
canEdit: boolean
|
|
31
32
|
onToggle: (key: string, enabled: boolean) => void
|
|
32
33
|
onSetFeatureModel: (key: string, modelId: string) => void
|
|
34
|
+
onSetGovernance: (
|
|
35
|
+
key: string,
|
|
36
|
+
patch: Partial<{
|
|
37
|
+
approvalRequired: boolean
|
|
38
|
+
allowBulkApply: boolean
|
|
39
|
+
maxRequestsPerDay: string
|
|
40
|
+
}>,
|
|
41
|
+
) => void
|
|
33
42
|
}) {
|
|
34
43
|
return (
|
|
35
44
|
<SettingsCard
|
|
@@ -49,6 +58,7 @@ export function AiFeaturesCard({
|
|
|
49
58
|
canEdit={canEdit}
|
|
50
59
|
onToggle={onToggle}
|
|
51
60
|
onSetFeatureModel={onSetFeatureModel}
|
|
61
|
+
onSetGovernance={onSetGovernance}
|
|
52
62
|
/>
|
|
53
63
|
))}
|
|
54
64
|
</div>
|
|
@@ -64,6 +74,7 @@ function FeatureRow({
|
|
|
64
74
|
canEdit,
|
|
65
75
|
onToggle,
|
|
66
76
|
onSetFeatureModel,
|
|
77
|
+
onSetGovernance,
|
|
67
78
|
}: {
|
|
68
79
|
feature: AiFeatureView
|
|
69
80
|
models: AiModelView[]
|
|
@@ -71,8 +82,17 @@ function FeatureRow({
|
|
|
71
82
|
canEdit: boolean
|
|
72
83
|
onToggle: (key: string, enabled: boolean) => void
|
|
73
84
|
onSetFeatureModel: (key: string, modelId: string) => void
|
|
85
|
+
onSetGovernance: (
|
|
86
|
+
key: string,
|
|
87
|
+
patch: Partial<{
|
|
88
|
+
approvalRequired: boolean
|
|
89
|
+
allowBulkApply: boolean
|
|
90
|
+
maxRequestsPerDay: string
|
|
91
|
+
}>,
|
|
92
|
+
) => void
|
|
74
93
|
}) {
|
|
75
94
|
const selectId = useId()
|
|
95
|
+
const capId = useId()
|
|
76
96
|
const required = feature.requiredCapabilities ?? []
|
|
77
97
|
const effective = effectiveFeatureModel(feature.modelId, defaultModelId, required, models)
|
|
78
98
|
const defaultModel = models.find(
|
|
@@ -141,6 +161,43 @@ function FeatureRow({
|
|
|
141
161
|
needs. Pick a compatible model.
|
|
142
162
|
</p>
|
|
143
163
|
)}
|
|
164
|
+
|
|
165
|
+
<div className="border-border mt-4 space-y-3 border-t pt-3">
|
|
166
|
+
<SettingsToggleRow
|
|
167
|
+
label="Require approval"
|
|
168
|
+
description="AI output must be human-approved before it is applied."
|
|
169
|
+
checked={feature.approvalRequired}
|
|
170
|
+
disabled={!canEdit}
|
|
171
|
+
onChange={(v) => onSetGovernance(feature.key, { approvalRequired: v })}
|
|
172
|
+
/>
|
|
173
|
+
<SettingsToggleRow
|
|
174
|
+
label="Allow bulk apply"
|
|
175
|
+
description="Permit applying many AI results in one operation."
|
|
176
|
+
checked={feature.allowBulkApply}
|
|
177
|
+
disabled={!canEdit}
|
|
178
|
+
onChange={(v) => onSetGovernance(feature.key, { allowBulkApply: v })}
|
|
179
|
+
/>
|
|
180
|
+
<div>
|
|
181
|
+
<label htmlFor={capId} className="text-muted-foreground mb-1 block text-sm">
|
|
182
|
+
Max requests per day
|
|
183
|
+
</label>
|
|
184
|
+
<input
|
|
185
|
+
id={capId}
|
|
186
|
+
type="text"
|
|
187
|
+
inputMode="numeric"
|
|
188
|
+
value={feature.maxRequestsPerDay}
|
|
189
|
+
placeholder="Unlimited"
|
|
190
|
+
disabled={!canEdit}
|
|
191
|
+
onChange={(e) =>
|
|
192
|
+
onSetGovernance(feature.key, { maxRequestsPerDay: e.target.value })
|
|
193
|
+
}
|
|
194
|
+
className={SELECT_CLASS}
|
|
195
|
+
/>
|
|
196
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
197
|
+
Site-wide daily cap for this feature. Leave empty for unlimited.
|
|
198
|
+
</p>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
144
201
|
</div>
|
|
145
202
|
)}
|
|
146
203
|
</div>
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useId, useState } from 'react'
|
|
4
|
+
import { AlertTriangle, CheckCircle2, KeyRound, Loader2, PlugZap, RefreshCw, X } from 'lucide-react'
|
|
5
|
+
import { toast } from 'sonner'
|
|
6
|
+
import { cmsApi } from '../../lib/api.js'
|
|
7
|
+
import { SettingsCard } from './components.js'
|
|
8
|
+
|
|
9
|
+
const INPUT_CLASS =
|
|
10
|
+
'w-full rounded-md border border-border bg-input-background px-3 py-2 text-base text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60'
|
|
11
|
+
const LABEL_CLASS = 'mb-1 flex items-center gap-2 text-sm font-medium text-foreground'
|
|
12
|
+
|
|
13
|
+
interface SearchConsoleStatus {
|
|
14
|
+
propertyUrl: string
|
|
15
|
+
hasServiceAccountKey: boolean
|
|
16
|
+
serviceAccountEmail: string | null
|
|
17
|
+
hasCruxApiKey: boolean
|
|
18
|
+
connected: boolean
|
|
19
|
+
lastSyncAt: string | null
|
|
20
|
+
syncStatus: {
|
|
21
|
+
state: 'ok' | 'partial' | 'error' | 'not-configured'
|
|
22
|
+
message?: string
|
|
23
|
+
parts?: {
|
|
24
|
+
searchConsole?: { ok: boolean; message?: string; rowsUpserted?: number }
|
|
25
|
+
crux?: { ok: boolean; message?: string; pagesChecked?: number }
|
|
26
|
+
}
|
|
27
|
+
} | null
|
|
28
|
+
suggestedProperty: string | null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function KeyStoredBadge() {
|
|
32
|
+
return (
|
|
33
|
+
<span className="text-muted-foreground border-border bg-muted inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-xs">
|
|
34
|
+
<CheckCircle2 size={11} className="text-success" aria-hidden="true" />
|
|
35
|
+
Key stored
|
|
36
|
+
</span>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Settings → SEO "Search data" card: Google Search Console service-account
|
|
42
|
+
* credentials, CrUX API key, connection test, and manual sync. Key material is
|
|
43
|
+
* write-only — the server stores it encrypted and never echoes it back, so the
|
|
44
|
+
* inputs stay empty with a "Key stored" badge once saved.
|
|
45
|
+
*/
|
|
46
|
+
export function SearchDataCard({ canEdit }: { canEdit: boolean }) {
|
|
47
|
+
const propertyId = useId()
|
|
48
|
+
const keyId = useId()
|
|
49
|
+
const cruxId = useId()
|
|
50
|
+
|
|
51
|
+
const [status, setStatus] = useState<SearchConsoleStatus | null>(null)
|
|
52
|
+
const [loading, setLoading] = useState(true)
|
|
53
|
+
const [loadError, setLoadError] = useState<string | null>(null)
|
|
54
|
+
|
|
55
|
+
const [property, setProperty] = useState('')
|
|
56
|
+
const [serviceKey, setServiceKey] = useState('')
|
|
57
|
+
const [cruxKey, setCruxKey] = useState('')
|
|
58
|
+
const [saving, setSaving] = useState(false)
|
|
59
|
+
const [testing, setTesting] = useState(false)
|
|
60
|
+
const [syncing, setSyncing] = useState(false)
|
|
61
|
+
|
|
62
|
+
const load = useCallback(async () => {
|
|
63
|
+
setLoading(true)
|
|
64
|
+
setLoadError(null)
|
|
65
|
+
const res = await cmsApi<SearchConsoleStatus>('/seo/search-console')
|
|
66
|
+
if (res.data) {
|
|
67
|
+
setStatus(res.data)
|
|
68
|
+
setProperty(res.data.propertyUrl ?? '')
|
|
69
|
+
} else {
|
|
70
|
+
setLoadError(res.error ?? 'Failed to load search data settings')
|
|
71
|
+
}
|
|
72
|
+
setLoading(false)
|
|
73
|
+
}, [])
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
void load()
|
|
77
|
+
}, [load])
|
|
78
|
+
|
|
79
|
+
const dirty =
|
|
80
|
+
property.trim() !== (status?.propertyUrl ?? '') ||
|
|
81
|
+
serviceKey.trim() !== '' ||
|
|
82
|
+
cruxKey.trim() !== ''
|
|
83
|
+
|
|
84
|
+
async function save(extra?: { serviceAccountKey?: string; cruxApiKey?: string }) {
|
|
85
|
+
setSaving(true)
|
|
86
|
+
const body: Record<string, string> = { propertyUrl: property.trim(), ...extra }
|
|
87
|
+
if (!extra) {
|
|
88
|
+
if (serviceKey.trim()) body.serviceAccountKey = serviceKey.trim()
|
|
89
|
+
if (cruxKey.trim()) body.cruxApiKey = cruxKey.trim()
|
|
90
|
+
}
|
|
91
|
+
const res = await cmsApi('/seo/search-console', {
|
|
92
|
+
method: 'PUT',
|
|
93
|
+
body: JSON.stringify(body),
|
|
94
|
+
})
|
|
95
|
+
setSaving(false)
|
|
96
|
+
if (res.error) {
|
|
97
|
+
toast.error(res.error)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
toast.success('Search data settings saved')
|
|
101
|
+
setServiceKey('')
|
|
102
|
+
setCruxKey('')
|
|
103
|
+
await load()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function testConnection() {
|
|
107
|
+
setTesting(true)
|
|
108
|
+
const res = await cmsApi<{ ok: boolean; code?: string; message?: string }>(
|
|
109
|
+
'/seo/search-console/test',
|
|
110
|
+
{ method: 'POST' },
|
|
111
|
+
)
|
|
112
|
+
setTesting(false)
|
|
113
|
+
if (res.error) {
|
|
114
|
+
toast.error(res.error)
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
if (res.data?.ok) {
|
|
118
|
+
toast.success('Connected to Google Search Console')
|
|
119
|
+
} else {
|
|
120
|
+
toast.error(res.data?.message ?? 'Connection test failed')
|
|
121
|
+
}
|
|
122
|
+
await load()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function syncNow() {
|
|
126
|
+
setSyncing(true)
|
|
127
|
+
const res = await cmsApi<{
|
|
128
|
+
status: NonNullable<SearchConsoleStatus['syncStatus']>
|
|
129
|
+
rowsUpserted: number
|
|
130
|
+
cruxChecked: number
|
|
131
|
+
}>('/seo/search-sync', { method: 'POST' })
|
|
132
|
+
setSyncing(false)
|
|
133
|
+
if (res.error) {
|
|
134
|
+
toast.error(res.error)
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
const state = res.data?.status.state
|
|
138
|
+
if (state === 'ok') {
|
|
139
|
+
toast.success(`Sync complete — ${res.data?.rowsUpserted ?? 0} rows updated`)
|
|
140
|
+
} else if (state === 'partial') {
|
|
141
|
+
toast.warning(res.data?.status.message ?? 'Sync finished with warnings')
|
|
142
|
+
} else if (state === 'not-configured') {
|
|
143
|
+
toast.error('Add a service account key and property first.')
|
|
144
|
+
} else {
|
|
145
|
+
toast.error(res.data?.status.message ?? 'Sync failed')
|
|
146
|
+
}
|
|
147
|
+
await load()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (loading) {
|
|
151
|
+
return (
|
|
152
|
+
<SettingsCard
|
|
153
|
+
title="Search data"
|
|
154
|
+
description="Google Search Console and Core Web Vitals feed real search performance into the SEO workspace."
|
|
155
|
+
>
|
|
156
|
+
<div className="space-y-3" aria-busy="true" aria-live="polite">
|
|
157
|
+
<span className="sr-only">Loading search data settings…</span>
|
|
158
|
+
<div className="bg-muted h-9 w-full animate-pulse rounded" />
|
|
159
|
+
<div className="bg-muted h-20 w-full animate-pulse rounded" />
|
|
160
|
+
</div>
|
|
161
|
+
</SettingsCard>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (loadError) {
|
|
166
|
+
return (
|
|
167
|
+
<SettingsCard
|
|
168
|
+
title="Search data"
|
|
169
|
+
description="Google Search Console and Core Web Vitals feed real search performance into the SEO workspace."
|
|
170
|
+
>
|
|
171
|
+
<div
|
|
172
|
+
role="alert"
|
|
173
|
+
className="border-destructive/30 bg-destructive/10 text-destructive flex items-center gap-3 rounded-lg border p-4 text-sm"
|
|
174
|
+
>
|
|
175
|
+
<AlertTriangle size={16} className="shrink-0" aria-hidden="true" />
|
|
176
|
+
<span className="flex-1">{loadError}</span>
|
|
177
|
+
<button
|
|
178
|
+
type="button"
|
|
179
|
+
onClick={() => void load()}
|
|
180
|
+
className="border-destructive/40 rounded-md border px-3 py-1 transition-colors hover:opacity-80"
|
|
181
|
+
>
|
|
182
|
+
Retry
|
|
183
|
+
</button>
|
|
184
|
+
</div>
|
|
185
|
+
</SettingsCard>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const lastSync = status?.lastSyncAt ? new Date(status.lastSyncAt).toLocaleString() : 'Never'
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<SettingsCard
|
|
193
|
+
title="Search data"
|
|
194
|
+
description="Connect Google Search Console (service account) and the CrUX API to feed real clicks, impressions, and Core Web Vitals into audits, quick wins, and AI suggestions."
|
|
195
|
+
>
|
|
196
|
+
<div className="space-y-4">
|
|
197
|
+
{/* Connection status */}
|
|
198
|
+
<div className="flex items-center justify-between gap-2 text-sm">
|
|
199
|
+
<span className="text-muted-foreground">Status</span>
|
|
200
|
+
{status?.connected ? (
|
|
201
|
+
<span className="text-success inline-flex items-center gap-1 font-medium">
|
|
202
|
+
<CheckCircle2 size={14} aria-hidden="true" />
|
|
203
|
+
Connected
|
|
204
|
+
</span>
|
|
205
|
+
) : (
|
|
206
|
+
<span className="text-muted-foreground inline-flex items-center gap-1 font-medium">
|
|
207
|
+
<X size={14} aria-hidden="true" />
|
|
208
|
+
Not connected
|
|
209
|
+
</span>
|
|
210
|
+
)}
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
{/* Property */}
|
|
214
|
+
<div>
|
|
215
|
+
<label htmlFor={propertyId} className={LABEL_CLASS}>
|
|
216
|
+
Search Console property
|
|
217
|
+
</label>
|
|
218
|
+
<input
|
|
219
|
+
id={propertyId}
|
|
220
|
+
type="text"
|
|
221
|
+
autoComplete="off"
|
|
222
|
+
spellCheck={false}
|
|
223
|
+
value={property}
|
|
224
|
+
disabled={!canEdit}
|
|
225
|
+
placeholder={status?.suggestedProperty ?? 'sc-domain:example.com'}
|
|
226
|
+
onChange={(e) => setProperty(e.target.value)}
|
|
227
|
+
className={INPUT_CLASS}
|
|
228
|
+
/>
|
|
229
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
230
|
+
Domain property (<code>sc-domain:example.com</code>) or URL prefix (
|
|
231
|
+
<code>https://example.com/</code>) exactly as it appears in Search Console.
|
|
232
|
+
{status?.suggestedProperty && !property.trim() && canEdit && (
|
|
233
|
+
<>
|
|
234
|
+
{' '}
|
|
235
|
+
<button
|
|
236
|
+
type="button"
|
|
237
|
+
onClick={() => setProperty(status.suggestedProperty ?? '')}
|
|
238
|
+
className="text-brand hover:underline"
|
|
239
|
+
>
|
|
240
|
+
Use {status.suggestedProperty}
|
|
241
|
+
</button>
|
|
242
|
+
</>
|
|
243
|
+
)}
|
|
244
|
+
</p>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
{/* Service account key */}
|
|
248
|
+
<div>
|
|
249
|
+
<label htmlFor={keyId} className={LABEL_CLASS}>
|
|
250
|
+
Service account key (JSON)
|
|
251
|
+
{status?.hasServiceAccountKey && <KeyStoredBadge />}
|
|
252
|
+
</label>
|
|
253
|
+
<textarea
|
|
254
|
+
id={keyId}
|
|
255
|
+
rows={3}
|
|
256
|
+
value={serviceKey}
|
|
257
|
+
disabled={!canEdit}
|
|
258
|
+
autoComplete="off"
|
|
259
|
+
spellCheck={false}
|
|
260
|
+
placeholder={
|
|
261
|
+
status?.hasServiceAccountKey
|
|
262
|
+
? 'Paste a new key to replace the stored one'
|
|
263
|
+
: '{ "type": "service_account", "client_email": … }'
|
|
264
|
+
}
|
|
265
|
+
onChange={(e) => setServiceKey(e.target.value)}
|
|
266
|
+
className={`${INPUT_CLASS} resize-y font-mono text-sm`}
|
|
267
|
+
/>
|
|
268
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
269
|
+
{status?.serviceAccountEmail ? (
|
|
270
|
+
<>
|
|
271
|
+
Grant{' '}
|
|
272
|
+
<span className="text-foreground font-medium">{status.serviceAccountEmail}</span>{' '}
|
|
273
|
+
access in Search Console → Settings → Users and permissions.
|
|
274
|
+
</>
|
|
275
|
+
) : (
|
|
276
|
+
'Create a service account in Google Cloud, enable the Search Console API, and add its email as a user on the property.'
|
|
277
|
+
)}
|
|
278
|
+
</p>
|
|
279
|
+
{status?.hasServiceAccountKey && canEdit && (
|
|
280
|
+
<button
|
|
281
|
+
type="button"
|
|
282
|
+
onClick={() => void save({ serviceAccountKey: '' })}
|
|
283
|
+
className="text-muted-foreground hover:text-destructive mt-1 inline-flex items-center gap-1 rounded-md text-sm transition-colors"
|
|
284
|
+
>
|
|
285
|
+
<X size={13} aria-hidden="true" />
|
|
286
|
+
Remove stored key
|
|
287
|
+
</button>
|
|
288
|
+
)}
|
|
289
|
+
</div>
|
|
290
|
+
|
|
291
|
+
{/* CrUX key */}
|
|
292
|
+
<div>
|
|
293
|
+
<label htmlFor={cruxId} className={LABEL_CLASS}>
|
|
294
|
+
CrUX API key
|
|
295
|
+
{status?.hasCruxApiKey && <KeyStoredBadge />}
|
|
296
|
+
</label>
|
|
297
|
+
<input
|
|
298
|
+
id={cruxId}
|
|
299
|
+
type="password"
|
|
300
|
+
autoComplete="off"
|
|
301
|
+
value={cruxKey}
|
|
302
|
+
disabled={!canEdit}
|
|
303
|
+
placeholder={
|
|
304
|
+
status?.hasCruxApiKey ? 'Paste a new key to replace the stored one' : 'AIza…'
|
|
305
|
+
}
|
|
306
|
+
onChange={(e) => setCruxKey(e.target.value)}
|
|
307
|
+
className={INPUT_CLASS}
|
|
308
|
+
/>
|
|
309
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
310
|
+
Optional — collects real-user Core Web Vitals (LCP, INP, CLS) from the Chrome UX Report.
|
|
311
|
+
</p>
|
|
312
|
+
{status?.hasCruxApiKey && canEdit && (
|
|
313
|
+
<button
|
|
314
|
+
type="button"
|
|
315
|
+
onClick={() => void save({ cruxApiKey: '' })}
|
|
316
|
+
className="text-muted-foreground hover:text-destructive mt-1 inline-flex items-center gap-1 rounded-md text-sm transition-colors"
|
|
317
|
+
>
|
|
318
|
+
<X size={13} aria-hidden="true" />
|
|
319
|
+
Remove stored key
|
|
320
|
+
</button>
|
|
321
|
+
)}
|
|
322
|
+
</div>
|
|
323
|
+
|
|
324
|
+
{/* Actions */}
|
|
325
|
+
<div className="border-border flex flex-wrap items-center gap-2 border-t pt-4">
|
|
326
|
+
<button
|
|
327
|
+
type="button"
|
|
328
|
+
onClick={() => void save()}
|
|
329
|
+
disabled={!canEdit || saving || !dirty}
|
|
330
|
+
className="bg-primary text-primary-foreground inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
|
331
|
+
>
|
|
332
|
+
{saving ? (
|
|
333
|
+
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
|
|
334
|
+
) : (
|
|
335
|
+
<KeyRound size={14} aria-hidden="true" />
|
|
336
|
+
)}
|
|
337
|
+
Save credentials
|
|
338
|
+
</button>
|
|
339
|
+
<button
|
|
340
|
+
type="button"
|
|
341
|
+
onClick={() => void testConnection()}
|
|
342
|
+
disabled={!canEdit || testing || !status?.hasServiceAccountKey}
|
|
343
|
+
className="border-border text-foreground hover:bg-accent inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
|
344
|
+
>
|
|
345
|
+
{testing ? (
|
|
346
|
+
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
|
|
347
|
+
) : (
|
|
348
|
+
<PlugZap size={14} aria-hidden="true" />
|
|
349
|
+
)}
|
|
350
|
+
Test connection
|
|
351
|
+
</button>
|
|
352
|
+
<button
|
|
353
|
+
type="button"
|
|
354
|
+
onClick={() => void syncNow()}
|
|
355
|
+
disabled={!canEdit || syncing || !status?.connected}
|
|
356
|
+
className="border-border text-foreground hover:bg-accent inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
|
357
|
+
>
|
|
358
|
+
{syncing ? (
|
|
359
|
+
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
|
|
360
|
+
) : (
|
|
361
|
+
<RefreshCw size={14} aria-hidden="true" />
|
|
362
|
+
)}
|
|
363
|
+
Sync now
|
|
364
|
+
</button>
|
|
365
|
+
</div>
|
|
366
|
+
|
|
367
|
+
{/* Sync status */}
|
|
368
|
+
<div className="space-y-2 text-sm">
|
|
369
|
+
<div className="flex items-center justify-between gap-2">
|
|
370
|
+
<span className="text-muted-foreground">Last sync</span>
|
|
371
|
+
<span className="text-foreground font-medium">{lastSync}</span>
|
|
372
|
+
</div>
|
|
373
|
+
{status?.syncStatus && status.syncStatus.state !== 'ok' && status.syncStatus.message && (
|
|
374
|
+
<div
|
|
375
|
+
role="status"
|
|
376
|
+
className={
|
|
377
|
+
status.syncStatus.state === 'error'
|
|
378
|
+
? 'border-destructive/30 bg-destructive/10 text-destructive flex items-start gap-2 rounded-md border p-3'
|
|
379
|
+
: 'border-warning/30 bg-warning/10 text-warning flex items-start gap-2 rounded-md border p-3'
|
|
380
|
+
}
|
|
381
|
+
>
|
|
382
|
+
<AlertTriangle size={14} className="mt-0.5 shrink-0" aria-hidden="true" />
|
|
383
|
+
<span>{status.syncStatus.message}</span>
|
|
384
|
+
</div>
|
|
385
|
+
)}
|
|
386
|
+
</div>
|
|
387
|
+
</div>
|
|
388
|
+
</SettingsCard>
|
|
389
|
+
)
|
|
390
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useId, useState } from 'react'
|
|
4
|
+
import { Loader2, Send } from 'lucide-react'
|
|
5
|
+
import { toast } from 'sonner'
|
|
6
|
+
import {
|
|
7
|
+
fetchDigestSettings,
|
|
8
|
+
sendTestDigest,
|
|
9
|
+
updateDigestSettings,
|
|
10
|
+
type SeoDigestSettings,
|
|
11
|
+
} from '../../lib/seo-service.js'
|
|
12
|
+
import { SettingsCard, SettingsToggleRow } from './components.js'
|
|
13
|
+
|
|
14
|
+
const INPUT_CLASS =
|
|
15
|
+
'w-full rounded-md border border-border bg-input-background px-3 py-2 text-base text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Settings → SEO "Weekly SEO digest" card: opt-in toggle, explicit recipient
|
|
19
|
+
* list, last-sent status, and a rate-limited test send. The digest itself is
|
|
20
|
+
* sent by the Monday /cron/seo-scan tick after the persisted audit.
|
|
21
|
+
*/
|
|
22
|
+
export function SeoDigestCard({ canEdit }: { canEdit: boolean }) {
|
|
23
|
+
const recipientsId = useId()
|
|
24
|
+
const [settings, setSettings] = useState<SeoDigestSettings | null>(null)
|
|
25
|
+
const [loading, setLoading] = useState(true)
|
|
26
|
+
const [recipientsText, setRecipientsText] = useState('')
|
|
27
|
+
const [saving, setSaving] = useState(false)
|
|
28
|
+
const [sendingTest, setSendingTest] = useState(false)
|
|
29
|
+
|
|
30
|
+
const load = useCallback(async () => {
|
|
31
|
+
setLoading(true)
|
|
32
|
+
const s = await fetchDigestSettings()
|
|
33
|
+
if (s) {
|
|
34
|
+
setSettings(s)
|
|
35
|
+
setRecipientsText(s.recipients.join(', '))
|
|
36
|
+
}
|
|
37
|
+
setLoading(false)
|
|
38
|
+
}, [])
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
void load()
|
|
42
|
+
}, [load])
|
|
43
|
+
|
|
44
|
+
async function handleToggle(enabled: boolean) {
|
|
45
|
+
const res = await updateDigestSettings({ enabled })
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
toast.error(res.error ?? 'Failed to update digest settings')
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
toast.success(enabled ? 'Weekly digest enabled' : 'Weekly digest disabled')
|
|
51
|
+
await load()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function handleSaveRecipients() {
|
|
55
|
+
setSaving(true)
|
|
56
|
+
const recipients = recipientsText
|
|
57
|
+
.split(',')
|
|
58
|
+
.map((r) => r.trim())
|
|
59
|
+
.filter((r) => r.length > 0)
|
|
60
|
+
const res = await updateDigestSettings({ recipients })
|
|
61
|
+
setSaving(false)
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
toast.error(res.error ?? 'Failed to save recipients')
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
toast.success('Digest recipients saved')
|
|
67
|
+
await load()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function handleTestSend() {
|
|
71
|
+
setSendingTest(true)
|
|
72
|
+
const res = await sendTestDigest()
|
|
73
|
+
setSendingTest(false)
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
toast.error(res.error ?? 'Test digest failed')
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
toast.success(`Test digest sent to ${res.to ?? 'your email'}`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (loading) {
|
|
82
|
+
return (
|
|
83
|
+
<SettingsCard title="Weekly SEO digest">
|
|
84
|
+
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
|
85
|
+
<Loader2 size={16} className="motion-safe:animate-spin" aria-hidden="true" />
|
|
86
|
+
Loading…
|
|
87
|
+
</div>
|
|
88
|
+
</SettingsCard>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
if (!settings) {
|
|
92
|
+
return (
|
|
93
|
+
<SettingsCard title="Weekly SEO digest">
|
|
94
|
+
<p className="text-destructive text-sm">Failed to load digest settings.</p>
|
|
95
|
+
</SettingsCard>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<SettingsCard
|
|
101
|
+
title="Weekly SEO digest"
|
|
102
|
+
description="A Monday email with the site score, search performance, quick wins, new issues, and pending proposals. Sent by the weekly audit cron."
|
|
103
|
+
>
|
|
104
|
+
<div className="flex flex-col gap-4">
|
|
105
|
+
<SettingsToggleRow
|
|
106
|
+
label="Send weekly digest"
|
|
107
|
+
description="Requires at least one recipient below."
|
|
108
|
+
checked={settings.enabled}
|
|
109
|
+
onChange={handleToggle}
|
|
110
|
+
disabled={!canEdit}
|
|
111
|
+
/>
|
|
112
|
+
<div>
|
|
113
|
+
<label
|
|
114
|
+
htmlFor={recipientsId}
|
|
115
|
+
className="text-foreground mb-1 flex items-center gap-2 text-sm font-medium"
|
|
116
|
+
>
|
|
117
|
+
Recipients
|
|
118
|
+
</label>
|
|
119
|
+
<input
|
|
120
|
+
id={recipientsId}
|
|
121
|
+
type="text"
|
|
122
|
+
className={INPUT_CLASS}
|
|
123
|
+
placeholder="owner@example.com, seo@example.com"
|
|
124
|
+
value={recipientsText}
|
|
125
|
+
onChange={(e) => setRecipientsText(e.target.value)}
|
|
126
|
+
disabled={!canEdit}
|
|
127
|
+
/>
|
|
128
|
+
<p className="text-muted-foreground mt-1 text-sm">Comma-separated, up to 10 addresses.</p>
|
|
129
|
+
</div>
|
|
130
|
+
<div className="flex items-center gap-3">
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-base font-medium disabled:cursor-not-allowed disabled:opacity-60"
|
|
134
|
+
onClick={() => void handleSaveRecipients()}
|
|
135
|
+
disabled={!canEdit || saving}
|
|
136
|
+
>
|
|
137
|
+
{saving ? 'Saving…' : 'Save recipients'}
|
|
138
|
+
</button>
|
|
139
|
+
<button
|
|
140
|
+
type="button"
|
|
141
|
+
className="border-border text-foreground inline-flex items-center gap-2 rounded-md border px-4 py-2 text-base font-medium disabled:cursor-not-allowed disabled:opacity-60"
|
|
142
|
+
onClick={() => void handleTestSend()}
|
|
143
|
+
disabled={!canEdit || sendingTest}
|
|
144
|
+
>
|
|
145
|
+
{sendingTest ? (
|
|
146
|
+
<Loader2 size={16} className="motion-safe:animate-spin" aria-hidden="true" />
|
|
147
|
+
) : (
|
|
148
|
+
<Send size={16} aria-hidden="true" />
|
|
149
|
+
)}
|
|
150
|
+
Send test digest
|
|
151
|
+
</button>
|
|
152
|
+
</div>
|
|
153
|
+
<p className="text-muted-foreground text-sm">
|
|
154
|
+
{settings.lastSentAt
|
|
155
|
+
? `Last sent ${new Date(settings.lastSentAt).toLocaleString()}`
|
|
156
|
+
: 'Never sent'}
|
|
157
|
+
{settings.lastError ? (
|
|
158
|
+
<span className="text-destructive"> · {settings.lastError}</span>
|
|
159
|
+
) : null}
|
|
160
|
+
</p>
|
|
161
|
+
</div>
|
|
162
|
+
</SettingsCard>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
import { siteUrlMatchesAdminOrigin } from '../../lib/preview-link.js'
|
|
17
17
|
import { MediaPickerModal } from '../../components/MediaPickerModal.js'
|
|
18
18
|
import { SEOConfigPanel } from '../../components/SEOConfigPanel.js'
|
|
19
|
+
import { SearchDataCard } from './SearchDataCard.js'
|
|
20
|
+
import { SeoDigestCard } from './SeoDigestCard.js'
|
|
19
21
|
import {
|
|
20
22
|
ConfirmDangerousSettingDialog,
|
|
21
23
|
SettingsCard,
|
|
@@ -149,6 +151,8 @@ export function SeoSettingsTab({ onNavigate, role }: SeoSettingsTabProps) {
|
|
|
149
151
|
<MetaDefaultsCard seo={seo} canEdit={canEdit} />
|
|
150
152
|
<OrganizationCard seo={seo} canEdit={canEdit} />
|
|
151
153
|
<SearchEngineVerificationCard seo={seo} canEdit={canEdit} />
|
|
154
|
+
<SearchDataCard canEdit={canEdit} />
|
|
155
|
+
<SeoDigestCard canEdit={canEdit} />
|
|
152
156
|
<SitemapDefaultsCard seo={seo} canEdit={canEdit} onNavigate={onNavigate} />
|
|
153
157
|
|
|
154
158
|
<details className="border-border bg-card group rounded-lg border">
|