@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.
Files changed (75) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/__tests__/components/seo-editor-pane.render.test.js +15 -0
  3. package/dist/__tests__/components/seo-editor-pane.render.test.js.map +1 -1
  4. package/dist/__tests__/lib/seo-service-errors.test.js +87 -1
  5. package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
  6. package/dist/__tests__/views/ai-settings.render.test.js +26 -8
  7. package/dist/__tests__/views/ai-settings.render.test.js.map +1 -1
  8. package/dist/__tests__/views/redirects-pane.render.test.js +20 -2
  9. package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
  10. package/dist/__tests__/views/seo-settings.render.test.js +110 -5
  11. package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
  12. package/dist/components/seo/SeoCopilotDrawer.d.ts.map +1 -1
  13. package/dist/components/seo/SeoCopilotDrawer.js +4 -0
  14. package/dist/components/seo/SeoCopilotDrawer.js.map +1 -1
  15. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
  16. package/dist/components/seo/SeoEditorPane.js +8 -1
  17. package/dist/components/seo/SeoEditorPane.js.map +1 -1
  18. package/dist/components/seo/SeoIssueFixDrawer.d.ts.map +1 -1
  19. package/dist/components/seo/SeoIssueFixDrawer.js +4 -0
  20. package/dist/components/seo/SeoIssueFixDrawer.js.map +1 -1
  21. package/dist/components/seo/SeoSearchStats.d.ts +4 -0
  22. package/dist/components/seo/SeoSearchStats.d.ts.map +1 -0
  23. package/dist/components/seo/SeoSearchStats.js +91 -0
  24. package/dist/components/seo/SeoSearchStats.js.map +1 -0
  25. package/dist/lib/seo-service.d.ts +60 -0
  26. package/dist/lib/seo-service.d.ts.map +1 -1
  27. package/dist/lib/seo-service.js +53 -1
  28. package/dist/lib/seo-service.js.map +1 -1
  29. package/dist/prenta-admin.css +1 -1
  30. package/dist/views/seo/OverviewTab.js +1 -1
  31. package/dist/views/seo/OverviewTab.js.map +1 -1
  32. package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
  33. package/dist/views/seo/RedirectsTab.js +6 -0
  34. package/dist/views/seo/RedirectsTab.js.map +1 -1
  35. package/dist/views/settings/AISettingsTab.d.ts.map +1 -1
  36. package/dist/views/settings/AISettingsTab.js +1 -1
  37. package/dist/views/settings/AISettingsTab.js.map +1 -1
  38. package/dist/views/settings/AiFeaturesCard.d.ts +8 -3
  39. package/dist/views/settings/AiFeaturesCard.d.ts.map +1 -1
  40. package/dist/views/settings/AiFeaturesCard.js +7 -6
  41. package/dist/views/settings/AiFeaturesCard.js.map +1 -1
  42. package/dist/views/settings/SearchDataCard.d.ts +10 -0
  43. package/dist/views/settings/SearchDataCard.d.ts.map +1 -0
  44. package/dist/views/settings/SearchDataCard.js +126 -0
  45. package/dist/views/settings/SearchDataCard.js.map +1 -0
  46. package/dist/views/settings/SeoDigestCard.d.ts +9 -0
  47. package/dist/views/settings/SeoDigestCard.d.ts.map +1 -0
  48. package/dist/views/settings/SeoDigestCard.js +77 -0
  49. package/dist/views/settings/SeoDigestCard.js.map +1 -0
  50. package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
  51. package/dist/views/settings/SeoSettingsTab.js +3 -1
  52. package/dist/views/settings/SeoSettingsTab.js.map +1 -1
  53. package/dist/views/settings/useAiSettings.d.ts +12 -0
  54. package/dist/views/settings/useAiSettings.d.ts.map +1 -1
  55. package/dist/views/settings/useAiSettings.js +29 -1
  56. package/dist/views/settings/useAiSettings.js.map +1 -1
  57. package/package.json +3 -3
  58. package/src/__tests__/components/seo-editor-pane.render.test.tsx +25 -3
  59. package/src/__tests__/lib/seo-service-errors.test.ts +111 -0
  60. package/src/__tests__/views/ai-settings.render.test.tsx +38 -8
  61. package/src/__tests__/views/redirects-pane.render.test.tsx +28 -2
  62. package/src/__tests__/views/seo-settings.render.test.tsx +156 -4
  63. package/src/components/seo/SeoCopilotDrawer.tsx +4 -0
  64. package/src/components/seo/SeoEditorPane.tsx +13 -0
  65. package/src/components/seo/SeoIssueFixDrawer.tsx +4 -0
  66. package/src/components/seo/SeoSearchStats.tsx +183 -0
  67. package/src/lib/seo-service.ts +107 -2
  68. package/src/views/seo/OverviewTab.tsx +1 -1
  69. package/src/views/seo/RedirectsTab.tsx +6 -0
  70. package/src/views/settings/AISettingsTab.tsx +1 -0
  71. package/src/views/settings/AiFeaturesCard.tsx +59 -2
  72. package/src/views/settings/SearchDataCard.tsx +390 -0
  73. package/src/views/settings/SeoDigestCard.tsx +164 -0
  74. package/src/views/settings/SeoSettingsTab.tsx +4 -0
  75. 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). Enforcement across modules lands in a later phase; here we surface
17
- * and store the operator's intent.
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">