@prenta/admin 1.15.0 → 1.17.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 (90) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/__tests__/components/faq-proposal-preview.render.test.d.ts +2 -0
  3. package/dist/__tests__/components/faq-proposal-preview.render.test.d.ts.map +1 -0
  4. package/dist/__tests__/components/faq-proposal-preview.render.test.js +97 -0
  5. package/dist/__tests__/components/faq-proposal-preview.render.test.js.map +1 -0
  6. package/dist/__tests__/components/seo-issue-fix-panel.render.test.js +37 -11
  7. package/dist/__tests__/components/seo-issue-fix-panel.render.test.js.map +1 -1
  8. package/dist/__tests__/lib/seo-service-errors.test.js +3 -0
  9. package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
  10. package/dist/__tests__/lib/seo-service-geo.test.d.ts +2 -0
  11. package/dist/__tests__/lib/seo-service-geo.test.d.ts.map +1 -0
  12. package/dist/__tests__/lib/seo-service-geo.test.js +103 -0
  13. package/dist/__tests__/lib/seo-service-geo.test.js.map +1 -0
  14. package/dist/__tests__/lib/seo-service-proposals.test.js +24 -0
  15. package/dist/__tests__/lib/seo-service-proposals.test.js.map +1 -1
  16. package/dist/__tests__/router/seo-tab-for-path.test.js +4 -0
  17. package/dist/__tests__/router/seo-tab-for-path.test.js.map +1 -1
  18. package/dist/__tests__/views/seo-ai-visibility.render.test.d.ts +2 -0
  19. package/dist/__tests__/views/seo-ai-visibility.render.test.d.ts.map +1 -0
  20. package/dist/__tests__/views/seo-ai-visibility.render.test.js +244 -0
  21. package/dist/__tests__/views/seo-ai-visibility.render.test.js.map +1 -0
  22. package/dist/__tests__/views/seo-geo-settings-card.render.test.d.ts +2 -0
  23. package/dist/__tests__/views/seo-geo-settings-card.render.test.d.ts.map +1 -0
  24. package/dist/__tests__/views/seo-geo-settings-card.render.test.js +227 -0
  25. package/dist/__tests__/views/seo-geo-settings-card.render.test.js.map +1 -0
  26. package/dist/__tests__/views/seo-proposals-tab.render.test.js +277 -0
  27. package/dist/__tests__/views/seo-proposals-tab.render.test.js.map +1 -1
  28. package/dist/__tests__/views/seo-settings.render.test.js +16 -4
  29. package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
  30. package/dist/__tests__/views/seo-technical-tab.render.test.js +13 -0
  31. package/dist/__tests__/views/seo-technical-tab.render.test.js.map +1 -1
  32. package/dist/components/seo/FaqProposalPreview.d.ts +39 -0
  33. package/dist/components/seo/FaqProposalPreview.d.ts.map +1 -0
  34. package/dist/components/seo/FaqProposalPreview.js +62 -0
  35. package/dist/components/seo/FaqProposalPreview.js.map +1 -0
  36. package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
  37. package/dist/components/seo/SeoIssueFixPanel.js +7 -2
  38. package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
  39. package/dist/lib/seo-service.d.ts +155 -2
  40. package/dist/lib/seo-service.d.ts.map +1 -1
  41. package/dist/lib/seo-service.js +42 -2
  42. package/dist/lib/seo-service.js.map +1 -1
  43. package/dist/prenta-admin.css +1 -1
  44. package/dist/views/SEO.d.ts +5 -1
  45. package/dist/views/SEO.d.ts.map +1 -1
  46. package/dist/views/SEO.js +12 -7
  47. package/dist/views/SEO.js.map +1 -1
  48. package/dist/views/seo/AiVisibilityTab.d.ts +4 -0
  49. package/dist/views/seo/AiVisibilityTab.d.ts.map +1 -0
  50. package/dist/views/seo/AiVisibilityTab.js +165 -0
  51. package/dist/views/seo/AiVisibilityTab.js.map +1 -0
  52. package/dist/views/seo/ProposalsTab.d.ts.map +1 -1
  53. package/dist/views/seo/ProposalsTab.js +160 -7
  54. package/dist/views/seo/ProposalsTab.js.map +1 -1
  55. package/dist/views/seo/TechnicalTab.d.ts +3 -1
  56. package/dist/views/seo/TechnicalTab.d.ts.map +1 -1
  57. package/dist/views/seo/TechnicalTab.js +4 -4
  58. package/dist/views/seo/TechnicalTab.js.map +1 -1
  59. package/dist/views/settings/SeoAutopilotCard.d.ts.map +1 -1
  60. package/dist/views/settings/SeoAutopilotCard.js +6 -0
  61. package/dist/views/settings/SeoAutopilotCard.js.map +1 -1
  62. package/dist/views/settings/SeoGeoSettingsCard.d.ts +12 -0
  63. package/dist/views/settings/SeoGeoSettingsCard.d.ts.map +1 -0
  64. package/dist/views/settings/SeoGeoSettingsCard.js +178 -0
  65. package/dist/views/settings/SeoGeoSettingsCard.js.map +1 -0
  66. package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
  67. package/dist/views/settings/SeoSettingsTab.js +2 -1
  68. package/dist/views/settings/SeoSettingsTab.js.map +1 -1
  69. package/package.json +3 -3
  70. package/src/__tests__/components/faq-proposal-preview.render.test.tsx +137 -0
  71. package/src/__tests__/components/seo-issue-fix-panel.render.test.tsx +42 -11
  72. package/src/__tests__/lib/seo-service-errors.test.ts +4 -0
  73. package/src/__tests__/lib/seo-service-geo.test.ts +118 -0
  74. package/src/__tests__/lib/seo-service-proposals.test.ts +30 -0
  75. package/src/__tests__/router/seo-tab-for-path.test.ts +5 -0
  76. package/src/__tests__/views/seo-ai-visibility.render.test.tsx +279 -0
  77. package/src/__tests__/views/seo-geo-settings-card.render.test.tsx +253 -0
  78. package/src/__tests__/views/seo-proposals-tab.render.test.tsx +358 -0
  79. package/src/__tests__/views/seo-settings.render.test.tsx +28 -7
  80. package/src/__tests__/views/seo-technical-tab.render.test.tsx +15 -0
  81. package/src/components/seo/FaqProposalPreview.tsx +134 -0
  82. package/src/components/seo/SeoIssueFixPanel.tsx +7 -2
  83. package/src/lib/seo-service.ts +162 -1
  84. package/src/views/SEO.tsx +15 -6
  85. package/src/views/seo/AiVisibilityTab.tsx +616 -0
  86. package/src/views/seo/ProposalsTab.tsx +279 -13
  87. package/src/views/seo/TechnicalTab.tsx +27 -8
  88. package/src/views/settings/SeoAutopilotCard.tsx +6 -0
  89. package/src/views/settings/SeoGeoSettingsCard.tsx +310 -0
  90. package/src/views/settings/SeoSettingsTab.tsx +2 -0
@@ -0,0 +1,310 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useId, useRef, useState } from 'react'
4
+ import { AlertTriangle, RefreshCw } from 'lucide-react'
5
+ import { toast } from 'sonner'
6
+ import {
7
+ SEO_GEO_RETENTION,
8
+ fetchGeoSettings,
9
+ updateGeoSettings,
10
+ type SeoGeoSettings,
11
+ type SeoGeoSettingsPatch,
12
+ } from '../../lib/seo-service.js'
13
+ import { Skeleton } from '../../components/ui/Skeleton.js'
14
+ import { SettingsCard, SettingsToggleRow } from './components.js'
15
+
16
+ const INPUT_CLASS =
17
+ 'w-32 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 aria-invalid:border-destructive'
18
+ /** Horizontally scrollable, so focusable (`tabIndex={0}`) for keyboard scrolling. */
19
+ const SNIPPET_CLASS =
20
+ 'bg-muted text-foreground focus-visible:ring-ring overflow-x-auto rounded-md p-3 text-sm focus-visible:ring-2 focus-visible:outline-none'
21
+
22
+ /**
23
+ * The two host-side one-liners. Kept as plain strings so the test can pin the
24
+ * identifiers (`observeGeoTraffic`, `createAstroRedirectMiddleware`) against
25
+ * the `@prenta/core/site` and `@prenta/core/astro/redirects` exports.
26
+ */
27
+ const NEXT_SNIPPET = `import { applyPublicRedirect, observeGeoTraffic } from '@prenta/core/site'
28
+ // inside proxy(), just before the applyPublicRedirect call:
29
+ observeGeoTraffic(request, { baseUrl: origin, adminPaths: ['/admin'] })`
30
+ const ASTRO_SNIPPET = `// src/middleware.ts — GEO capture is on by default:
31
+ import { createAstroRedirectMiddleware } from '@prenta/core/astro/redirects'
32
+ export const onRequest = createAstroRedirectMiddleware({ baseUrl: import.meta.env.PRENTA_CMS_URL })`
33
+
34
+ /**
35
+ * Settings → SEO "AI visibility" card (spec 2026-09-08 §6): the measurement
36
+ * kill switch, daily-bucket retention, and the one-line middleware setup for
37
+ * Next and Astro hosts. Owns its own fetch/save like `SeoAutopilotCard`; every
38
+ * control PUTs a sparse patch immediately, saves queue behind each other, and
39
+ * the card re-reads the server's resolved settings after each success. Takes
40
+ * no `onNavigate`, so it names the AI visibility tab in copy without linking.
41
+ */
42
+ export function SeoGeoSettingsCard({ canEdit }: { canEdit: boolean }) {
43
+ const baseId = useId()
44
+ const [settings, setSettings] = useState<SeoGeoSettings | null>(null)
45
+ /** Mirrors `settings` synchronously so a queued save reads the latest resolved values. */
46
+ const settingsRef = useRef<SeoGeoSettings | null>(null)
47
+ const [loading, setLoading] = useState(true)
48
+ const [busy, setBusy] = useState(false)
49
+ /** Saves are serialised through this chain instead of being dropped when one is in flight. */
50
+ const saveChainRef = useRef<Promise<unknown>>(Promise.resolve())
51
+ const mountedRef = useRef(true)
52
+
53
+ useEffect(() => {
54
+ mountedRef.current = true
55
+ return () => {
56
+ mountedRef.current = false
57
+ }
58
+ }, [])
59
+
60
+ const commitSettings = useCallback((next: SeoGeoSettings) => {
61
+ settingsRef.current = next
62
+ setSettings(next)
63
+ }, [])
64
+
65
+ const load = useCallback(async () => {
66
+ setLoading(true)
67
+ const s = await fetchGeoSettings()
68
+ if (!mountedRef.current) return
69
+ if (s) commitSettings(s)
70
+ setLoading(false)
71
+ }, [commitSettings])
72
+
73
+ useEffect(() => {
74
+ void load()
75
+ }, [load])
76
+
77
+ /**
78
+ * Re-read without the skeleton after a successful save. The PUT answers only
79
+ * `{ ok }`, so the server-resolved values come from a fresh GET; if that GET
80
+ * fails, the accepted patch is applied locally — it is exactly what the
81
+ * server stored (the card clamps retention the way the server does).
82
+ */
83
+ const refresh = useCallback(
84
+ async (accepted: SeoGeoSettingsPatch) => {
85
+ const s = await fetchGeoSettings()
86
+ if (!mountedRef.current) return
87
+ if (s) commitSettings(s)
88
+ else if (settingsRef.current) commitSettings({ ...settingsRef.current, ...accepted })
89
+ },
90
+ [commitSettings],
91
+ )
92
+
93
+ /** Resolves `true` when the server accepted the patch. */
94
+ async function performSave(patch: SeoGeoSettingsPatch, successMessage: string): Promise<boolean> {
95
+ if (!settingsRef.current || !mountedRef.current) return false
96
+ setBusy(true)
97
+ try {
98
+ const res = await updateGeoSettings(patch)
99
+ if (!mountedRef.current) return false
100
+ if (!res.ok) {
101
+ toast.error(res.error ?? 'Failed to update AI visibility settings')
102
+ return false
103
+ }
104
+ toast.success(successMessage)
105
+ await refresh(patch)
106
+ return true
107
+ } finally {
108
+ if (mountedRef.current) setBusy(false)
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Queue a sparse patch behind any save already in flight. Callers await the
114
+ * result to know whether to keep or reset a local draft.
115
+ */
116
+ function save(patch: SeoGeoSettingsPatch, successMessage: string): Promise<boolean> {
117
+ const next = saveChainRef.current.then(() => performSave(patch, successMessage))
118
+ saveChainRef.current = next.catch(() => undefined)
119
+ return next
120
+ }
121
+
122
+ if (loading) {
123
+ return (
124
+ <SettingsCard title="AI visibility">
125
+ <div aria-busy="true" aria-live="polite">
126
+ <span className="sr-only">Loading AI visibility settings…</span>
127
+ <Skeleton lines={4} />
128
+ </div>
129
+ </SettingsCard>
130
+ )
131
+ }
132
+ if (!settings) {
133
+ return (
134
+ <SettingsCard title="AI visibility">
135
+ <div
136
+ role="alert"
137
+ className="border-destructive/30 bg-destructive/10 text-destructive flex items-center gap-3 rounded-lg border p-4 text-sm"
138
+ >
139
+ <AlertTriangle size={16} className="shrink-0" aria-hidden="true" />
140
+ <span className="flex-1">Failed to load AI visibility settings.</span>
141
+ <button
142
+ type="button"
143
+ onClick={() => void load()}
144
+ className="border-destructive/40 focus-visible:ring-ring inline-flex items-center gap-2 rounded-md border px-3 py-1 transition-colors hover:opacity-80 focus-visible:ring-2 focus-visible:outline-none"
145
+ >
146
+ <RefreshCw size={14} aria-hidden="true" />
147
+ Retry
148
+ </button>
149
+ </div>
150
+ </SettingsCard>
151
+ )
152
+ }
153
+
154
+ // Controls lock only for read-only users; a save in flight must not steal
155
+ // focus (saves queue), so the card is marked busy instead.
156
+ const disabled = !canEdit
157
+
158
+ return (
159
+ <SettingsCard
160
+ title="AI visibility"
161
+ description="Record which AI and search crawlers fetch your pages and which answer engines send visitors. Only bot names, engine names, and your own paths are stored — never IPs, user agents, or referrer URLs."
162
+ busy={busy}
163
+ >
164
+ <div className="flex flex-col gap-5">
165
+ <SettingsToggleRow
166
+ label="Record AI crawler and referral activity"
167
+ description="When off, the server drops incoming beacons and the AI visibility tab in SEO shows a disabled state. Existing data stays until retention prunes it."
168
+ checked={settings.enabled}
169
+ onChange={(v) =>
170
+ void save(
171
+ { enabled: v },
172
+ v ? 'AI visibility measurement enabled' : 'AI visibility measurement disabled',
173
+ )
174
+ }
175
+ disabled={disabled}
176
+ />
177
+
178
+ <RetentionField
179
+ id={`${baseId}-retention`}
180
+ value={settings.retentionDays}
181
+ disabled={disabled}
182
+ onCommit={(v) => save({ retentionDays: v }, `Retention set to ${v} days`)}
183
+ />
184
+
185
+ <div className="border-border flex flex-col gap-3 border-t pt-4">
186
+ <div>
187
+ <p className="text-foreground text-base font-medium">Site middleware setup</p>
188
+ <p className="text-muted-foreground mt-0.5 text-sm">
189
+ Counts arrive only once the public site reports them. Add one line to the host that
190
+ serves your pages.
191
+ </p>
192
+ </div>
193
+ <div>
194
+ <p className="text-muted-foreground mb-1 text-sm">
195
+ Next.js — <code className="text-foreground">proxy.ts</code>
196
+ </p>
197
+ <pre className={SNIPPET_CLASS} tabIndex={0} aria-label="Next.js middleware snippet">
198
+ <code>{NEXT_SNIPPET}</code>
199
+ </pre>
200
+ </div>
201
+ <div>
202
+ <p className="text-muted-foreground mb-1 text-sm">Astro</p>
203
+ <pre className={SNIPPET_CLASS} tabIndex={0} aria-label="Astro middleware snippet">
204
+ <code>{ASTRO_SNIPPET}</code>
205
+ </pre>
206
+ </div>
207
+ </div>
208
+ </div>
209
+ </SettingsCard>
210
+ )
211
+ }
212
+
213
+ /**
214
+ * Retention input that commits on blur (or Enter). The draft is local so typing
215
+ * never fires a PUT per keystroke; an unchanged blur restores the saved value
216
+ * without a request. Out-of-range integers are clamped to 7–365 before they are
217
+ * sent (the server clamps identically). An empty, non-numeric, or fractional
218
+ * draft is a client-side validation failure: it shows an inline error and sends
219
+ * nothing. A rejected commit (`onCommit` resolves `false`) falls back to the
220
+ * last saved value so the input never shows text the server refused.
221
+ */
222
+ function RetentionField({
223
+ id,
224
+ value,
225
+ disabled,
226
+ onCommit,
227
+ }: {
228
+ id: string
229
+ value: number
230
+ disabled: boolean
231
+ onCommit: (v: number) => Promise<boolean>
232
+ }) {
233
+ const [draft, setDraft] = useState(String(value))
234
+ const [syncedValue, setSyncedValue] = useState(value)
235
+ const [error, setError] = useState<string | null>(null)
236
+ // Adopt a new server value without an effect: reset the draft during render
237
+ // when the prop changes (React's recommended "derive state from props" form).
238
+ if (value !== syncedValue) {
239
+ setSyncedValue(value)
240
+ setDraft(String(value))
241
+ }
242
+ /**
243
+ * Latest confirmed value, readable from a commit's `.then` after later
244
+ * renders. Saves queue, so a second commit can fail after an earlier one
245
+ * changed `value`; reverting to the closed-over `value` would show the
246
+ * pre-first-save number while the server holds the first save.
247
+ */
248
+ const valueRef = useRef(value)
249
+ valueRef.current = value
250
+ const hintId = `${id}-hint`
251
+ const errorId = `${id}-error`
252
+ const { min, max } = SEO_GEO_RETENTION
253
+
254
+ function commit() {
255
+ const n = Number(draft)
256
+ if (draft.trim() === '' || !Number.isInteger(n)) {
257
+ setError('Retention must be a whole number of days')
258
+ return
259
+ }
260
+ const next = Math.max(min, Math.min(max, n))
261
+ if (next === valueRef.current) {
262
+ setDraft(String(valueRef.current))
263
+ return
264
+ }
265
+ void onCommit(next).then((ok) => {
266
+ // On success the parent adopts the server value and the derive-from-props
267
+ // branch above resets the draft; on failure revert to the latest confirmed
268
+ // value (not the one closed over when this commit started).
269
+ if (!ok) setDraft(String(valueRef.current))
270
+ })
271
+ }
272
+
273
+ return (
274
+ <div>
275
+ <label htmlFor={id} className="text-foreground mb-1 block text-sm font-medium">
276
+ Retention (days)
277
+ </label>
278
+ <input
279
+ id={id}
280
+ type="number"
281
+ inputMode="numeric"
282
+ className={INPUT_CLASS}
283
+ min={min}
284
+ max={max}
285
+ step={1}
286
+ value={draft}
287
+ disabled={disabled}
288
+ aria-invalid={error ? true : undefined}
289
+ aria-describedby={error ? `${hintId} ${errorId}` : hintId}
290
+ onChange={(e) => {
291
+ setDraft(e.target.value)
292
+ if (error) setError(null)
293
+ }}
294
+ onBlur={commit}
295
+ onKeyDown={(e) => {
296
+ if (e.key === 'Enter') e.currentTarget.blur()
297
+ }}
298
+ />
299
+ <p id={hintId} className="text-muted-foreground mt-1 text-sm">
300
+ Daily counts older than this are deleted by the nightly cleanup. {min}–{max} days.
301
+ </p>
302
+ {error ? (
303
+ <p id={errorId} className="text-destructive mt-1 flex items-center gap-1 text-sm">
304
+ <AlertTriangle size={14} className="shrink-0" aria-hidden="true" />
305
+ {error}
306
+ </p>
307
+ ) : null}
308
+ </div>
309
+ )
310
+ }
@@ -19,6 +19,7 @@ import { SEOConfigPanel } from '../../components/SEOConfigPanel.js'
19
19
  import { SearchDataCard } from './SearchDataCard.js'
20
20
  import { SeoAutopilotCard } from './SeoAutopilotCard.js'
21
21
  import { SeoDigestCard } from './SeoDigestCard.js'
22
+ import { SeoGeoSettingsCard } from './SeoGeoSettingsCard.js'
22
23
  import {
23
24
  ConfirmDangerousSettingDialog,
24
25
  SettingsCard,
@@ -153,6 +154,7 @@ export function SeoSettingsTab({ onNavigate, role }: SeoSettingsTabProps) {
153
154
  <OrganizationCard seo={seo} canEdit={canEdit} />
154
155
  <SearchEngineVerificationCard seo={seo} canEdit={canEdit} />
155
156
  <SearchDataCard canEdit={canEdit} />
157
+ <SeoGeoSettingsCard canEdit={canEdit} />
156
158
  <SeoAutopilotCard canEdit={canEdit} onNavigate={onNavigate} />
157
159
  <SeoDigestCard canEdit={canEdit} />
158
160
  <SitemapDefaultsCard seo={seo} canEdit={canEdit} onNavigate={onNavigate} />