@prenta/admin 0.97.4 → 0.98.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 (35) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/__tests__/components/modal.render.test.js +62 -2
  3. package/dist/__tests__/components/modal.render.test.js.map +1 -1
  4. package/dist/__tests__/lib/seo-service-errors.test.js +34 -3
  5. package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
  6. package/dist/__tests__/views/seo-audit-tab.render.test.d.ts +2 -0
  7. package/dist/__tests__/views/seo-audit-tab.render.test.d.ts.map +1 -0
  8. package/dist/__tests__/views/seo-audit-tab.render.test.js +100 -0
  9. package/dist/__tests__/views/seo-audit-tab.render.test.js.map +1 -0
  10. package/dist/__tests__/views/seo-technical-tab.render.test.d.ts +2 -0
  11. package/dist/__tests__/views/seo-technical-tab.render.test.d.ts.map +1 -0
  12. package/dist/__tests__/views/seo-technical-tab.render.test.js +121 -0
  13. package/dist/__tests__/views/seo-technical-tab.render.test.js.map +1 -0
  14. package/dist/components/ui/Modal.d.ts +8 -0
  15. package/dist/components/ui/Modal.d.ts.map +1 -1
  16. package/dist/components/ui/Modal.js +22 -1
  17. package/dist/components/ui/Modal.js.map +1 -1
  18. package/dist/lib/seo-service.d.ts +30 -1
  19. package/dist/lib/seo-service.d.ts.map +1 -1
  20. package/dist/lib/seo-service.js.map +1 -1
  21. package/dist/views/seo/AuditTab.d.ts.map +1 -1
  22. package/dist/views/seo/AuditTab.js +3 -2
  23. package/dist/views/seo/AuditTab.js.map +1 -1
  24. package/dist/views/seo/TechnicalTab.d.ts.map +1 -1
  25. package/dist/views/seo/TechnicalTab.js +5 -0
  26. package/dist/views/seo/TechnicalTab.js.map +1 -1
  27. package/package.json +7 -7
  28. package/src/__tests__/components/modal.render.test.tsx +109 -1
  29. package/src/__tests__/lib/seo-service-errors.test.ts +55 -1
  30. package/src/__tests__/views/seo-audit-tab.render.test.tsx +113 -0
  31. package/src/__tests__/views/seo-technical-tab.render.test.tsx +156 -0
  32. package/src/components/ui/Modal.tsx +25 -1
  33. package/src/lib/seo-service.ts +30 -2
  34. package/src/views/seo/AuditTab.tsx +60 -27
  35. package/src/views/seo/TechnicalTab.tsx +5 -0
@@ -0,0 +1,113 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { render, screen, waitFor } from '@testing-library/react'
4
+
5
+ const baseRun = {
6
+ id: 'audit-1',
7
+ status: 'COMPLETED',
8
+ scope: 'full',
9
+ startedAt: '2026-08-11T12:00:00.000Z',
10
+ finishedAt: '2026-08-11T12:00:01.000Z',
11
+ siteScoreBefore: 70,
12
+ siteScoreAfter: 82,
13
+ pagesScanned: 10,
14
+ issuesFound: 3,
15
+ criticalCount: 0,
16
+ warningCount: 2,
17
+ infoCount: 1,
18
+ errorMessage: null,
19
+ }
20
+
21
+ let latestRun: Record<string, unknown> = { ...baseRun }
22
+
23
+ const cmsApi = vi.fn(async (endpoint: string) => {
24
+ if (endpoint === '/seo/audits') {
25
+ return { data: { runs: [latestRun] }, status: 200 }
26
+ }
27
+ if (endpoint.startsWith('/seo/issues')) {
28
+ return { data: { issues: [] }, status: 200 }
29
+ }
30
+ return { data: {}, status: 200 }
31
+ })
32
+
33
+ vi.mock('../../lib/api.js', () => ({
34
+ cmsApi: (endpoint: string) => cmsApi(endpoint),
35
+ }))
36
+
37
+ vi.mock('sonner', () => ({
38
+ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), message: vi.fn() },
39
+ }))
40
+
41
+ const { AuditTab } = await import('../../views/seo/AuditTab.js')
42
+
43
+ beforeEach(() => {
44
+ cmsApi.mockClear()
45
+ latestRun = { ...baseRun }
46
+ })
47
+
48
+ describe('AuditTab rendered audit coverage', () => {
49
+ it('shows safe coverage details and a warning for partial runs', async () => {
50
+ latestRun = {
51
+ ...baseRun,
52
+ metadata: {
53
+ renderedAudit: {
54
+ enabled: true,
55
+ originSource: 'override',
56
+ verified: 8,
57
+ unverified: 2,
58
+ notAttempted: 0,
59
+ partial: true,
60
+ bytesRead: 2048,
61
+ durationMs: 50,
62
+ reasons: [{ reason: 'timeout', count: 2 }],
63
+ },
64
+ },
65
+ }
66
+
67
+ render(<AuditTab />)
68
+
69
+ expect(await screen.findByText('8 verified')).toBeTruthy()
70
+ expect(screen.getByText('2 unverified')).toBeTruthy()
71
+ const warning = screen.getByRole('status')
72
+ expect(warning.textContent).toContain('The score covers 8 verified pages.')
73
+ expect(warning.textContent).toContain('their prior rendered-dependent issues were preserved.')
74
+ expect(screen.queryByText(/timeout/i)).toBeNull()
75
+ expect(screen.queryByText(/https?:\/\//i)).toBeNull()
76
+ expect(screen.queryByText(/transport error/i)).toBeNull()
77
+ })
78
+
79
+ it('does not show a partial warning for complete coverage', async () => {
80
+ latestRun = {
81
+ ...baseRun,
82
+ metadata: {
83
+ renderedAudit: {
84
+ enabled: true,
85
+ originSource: 'config',
86
+ verified: 10,
87
+ unverified: 0,
88
+ notAttempted: 0,
89
+ partial: false,
90
+ bytesRead: 4096,
91
+ durationMs: 40,
92
+ reasons: [],
93
+ },
94
+ },
95
+ }
96
+
97
+ render(<AuditTab />)
98
+
99
+ expect(await screen.findByText('10 verified')).toBeTruthy()
100
+ expect(screen.getByText('0 unverified')).toBeTruthy()
101
+ expect(screen.queryByText('Partial rendered audit')).toBeNull()
102
+ expect(screen.queryByRole('status')).toBeNull()
103
+ })
104
+
105
+ it('renders legacy audit runs without coverage or warnings', async () => {
106
+ render(<AuditTab />)
107
+
108
+ await waitFor(() => expect(screen.getByText('10')).toBeTruthy())
109
+ expect(screen.queryByText(/verified$/i)).toBeNull()
110
+ expect(screen.queryByText('Partial rendered audit')).toBeNull()
111
+ expect(screen.queryByRole('status')).toBeNull()
112
+ })
113
+ })
@@ -0,0 +1,156 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
4
+ import { toast } from 'sonner'
5
+
6
+ const initialCrawlSettings = {
7
+ indexEntireSite: true,
8
+ includeImagesInSitemap: true,
9
+ includePostsInSitemap: true,
10
+ sitemapEnabled: true,
11
+ autoRegenerateSitemapOnPublish: true,
12
+ pingSearchEnginesOnPublish: true,
13
+ auditRenderedHtml: true,
14
+ }
15
+
16
+ let crawlSettings = { ...initialCrawlSettings }
17
+ let failNextPut = false
18
+ let pendingPut: Promise<{ data?: unknown; error?: string }> | null = null
19
+
20
+ const cmsApi = vi.fn(async (endpoint: string, options?: RequestInit) => {
21
+ const method = options?.method ?? 'GET'
22
+ if (endpoint === '/seo/crawl-settings' && method === 'GET') {
23
+ return { data: { ...crawlSettings }, status: 200 }
24
+ }
25
+ if (endpoint === '/seo/crawl-settings' && method === 'PUT') {
26
+ if (pendingPut) await pendingPut
27
+ if (failNextPut) {
28
+ failNextPut = false
29
+ return { error: 'Unable to save crawl settings.', status: 500 }
30
+ }
31
+ crawlSettings = {
32
+ ...crawlSettings,
33
+ ...(JSON.parse(String(options?.body)) as Partial<typeof initialCrawlSettings>),
34
+ }
35
+ return { data: { ...crawlSettings }, status: 200 }
36
+ }
37
+ if (endpoint === '/seo/sitemap/status') {
38
+ return {
39
+ data: {
40
+ sitemapUrl: 'https://example.test/sitemap.xml',
41
+ lastGeneratedAt: null,
42
+ pagesIncluded: 10,
43
+ pagesExcluded: 0,
44
+ autoRegenerateOnPublish: true,
45
+ errors: [],
46
+ warnings: [],
47
+ },
48
+ status: 200,
49
+ }
50
+ }
51
+ if (endpoint === '/seo/robots') {
52
+ return { data: { body: 'User-agent: *\nAllow: /', generated: true }, status: 200 }
53
+ }
54
+ if (endpoint === '/ai-crawlers') {
55
+ return {
56
+ data: { enabled: false, rows: [], totals: { hits: 0, bots: 0, honeypot: 0 } },
57
+ status: 200,
58
+ }
59
+ }
60
+ return { data: {}, status: 200 }
61
+ })
62
+
63
+ vi.mock('../../lib/api.js', () => ({
64
+ cmsApi: (endpoint: string, options?: RequestInit) => cmsApi(endpoint, options),
65
+ }))
66
+
67
+ vi.mock('sonner', () => ({
68
+ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), message: vi.fn() },
69
+ }))
70
+
71
+ const { TechnicalTab } = await import('../../views/seo/TechnicalTab.js')
72
+
73
+ beforeEach(() => {
74
+ cmsApi.mockClear()
75
+ vi.mocked(toast.error).mockClear()
76
+ crawlSettings = { ...initialCrawlSettings }
77
+ failNextPut = false
78
+ pendingPut = null
79
+ })
80
+
81
+ describe('TechnicalTab rendered audit setting', () => {
82
+ it('renders the accessible setting and persists changes', async () => {
83
+ render(<TechnicalTab />)
84
+
85
+ const renderedAudit = await screen.findByRole('switch', {
86
+ name: /Audit rendered public HTML/i,
87
+ description: 'Fetch public page output so audits grade crawler-visible headings and content.',
88
+ })
89
+ expect(renderedAudit.getAttribute('aria-checked')).toBe('true')
90
+
91
+ fireEvent.click(renderedAudit)
92
+
93
+ await waitFor(() =>
94
+ expect(cmsApi).toHaveBeenCalledWith(
95
+ '/seo/crawl-settings',
96
+ expect.objectContaining({
97
+ method: 'PUT',
98
+ body: JSON.stringify({ auditRenderedHtml: false }),
99
+ }),
100
+ ),
101
+ )
102
+ })
103
+
104
+ it('disables crawl switches while a setting is saving', async () => {
105
+ let resolvePut: (() => void) | undefined
106
+ pendingPut = new Promise((resolve) => {
107
+ resolvePut = () => resolve({})
108
+ })
109
+ render(<TechnicalTab />)
110
+
111
+ fireEvent.click(await screen.findByRole('switch', { name: /Audit rendered public HTML/i }))
112
+
113
+ await waitFor(() => {
114
+ for (const toggle of screen.getAllByRole('switch')) {
115
+ expect((toggle as HTMLButtonElement).disabled).toBe(true)
116
+ }
117
+ })
118
+
119
+ resolvePut?.()
120
+ await waitFor(() =>
121
+ expect(
122
+ (
123
+ screen.getByRole('switch', {
124
+ name: /Audit rendered public HTML/i,
125
+ }) as HTMLButtonElement
126
+ ).disabled,
127
+ ).toBe(false),
128
+ )
129
+ })
130
+
131
+ it('reports save failures and refetches the server value', async () => {
132
+ failNextPut = true
133
+ render(<TechnicalTab />)
134
+
135
+ const renderedAudit = await screen.findByRole('switch', {
136
+ name: /Audit rendered public HTML/i,
137
+ })
138
+ const getCallsBeforeSave = cmsApi.mock.calls.filter(
139
+ ([endpoint, options]) =>
140
+ endpoint === '/seo/crawl-settings' &&
141
+ ((options as RequestInit | undefined)?.method ?? 'GET') === 'GET',
142
+ ).length
143
+
144
+ fireEvent.click(renderedAudit)
145
+
146
+ await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Unable to save crawl settings.'))
147
+ await waitFor(() => expect(renderedAudit.getAttribute('aria-checked')).toBe('true'))
148
+ expect(
149
+ cmsApi.mock.calls.filter(
150
+ ([endpoint, options]) =>
151
+ endpoint === '/seo/crawl-settings' &&
152
+ ((options as RequestInit | undefined)?.method ?? 'GET') === 'GET',
153
+ ).length,
154
+ ).toBeGreaterThan(getCallsBeforeSave)
155
+ })
156
+ })
@@ -2,7 +2,7 @@
2
2
 
3
3
  import * as Dialog from '@radix-ui/react-dialog'
4
4
  import { X } from 'lucide-react'
5
- import { type ReactNode, type RefObject } from 'react'
5
+ import { type ReactNode, type RefObject, useRef } from 'react'
6
6
  import { adminPortalContainer } from '../../lib/portal-container.js'
7
7
 
8
8
  const MODAL_SIZE_CLASS = {
@@ -41,6 +41,22 @@ export interface ModalProps {
41
41
  onDismiss?: () => void
42
42
  }
43
43
 
44
+ /**
45
+ * True when the event originated inside a *different* `[role="dialog"]` than
46
+ * `content` (e.g. MediaPickerModal / ConfirmDialog portaled above this Modal).
47
+ * Nested dialogs must own their own dismiss; treating their clicks as "outside"
48
+ * would discard the parent form before the nested selection can apply
49
+ * (UpChat #27 / featured-image picker).
50
+ */
51
+ export function isNestedDialogEvent(
52
+ target: EventTarget | null,
53
+ content: EventTarget | null,
54
+ ): boolean {
55
+ if (!(target instanceof Element) || !(content instanceof Element)) return false
56
+ const owningDialog = target.closest('[role="dialog"]')
57
+ return Boolean(owningDialog && owningDialog !== content)
58
+ }
59
+
44
60
  /**
45
61
  * The canonical admin modal. Built on Radix `Dialog`, which provides the
46
62
  * accessibility contract for free: focus is trapped while open and restored to
@@ -64,6 +80,7 @@ export function Modal({
64
80
  onDismiss,
65
81
  }: ModalProps) {
66
82
  const scrollBody = size === 'xl'
83
+ const contentRef = useRef<HTMLDivElement>(null)
67
84
 
68
85
  function handleDismissAttempt() {
69
86
  if (onDismiss) onDismiss()
@@ -80,8 +97,15 @@ export function Modal({
80
97
  <Dialog.Portal container={adminPortalContainer()}>
81
98
  <Dialog.Overlay className="fixed inset-0 z-60 bg-black/50" />
82
99
  <Dialog.Content
100
+ ref={contentRef}
83
101
  className={`bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-60 w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-lg border shadow-xl focus:outline-none ${MODAL_SIZE_CLASS[size]}`}
84
102
  onInteractOutside={(event) => {
103
+ // Radix dispatches this custom event on the outside target, so
104
+ // `event.currentTarget` is not the parent Dialog.Content.
105
+ if (isNestedDialogEvent(event.target, contentRef.current)) {
106
+ event.preventDefault()
107
+ return
108
+ }
85
109
  if (!onDismiss) return
86
110
  event.preventDefault()
87
111
  handleDismissAttempt()
@@ -299,6 +299,18 @@ export function applyContentSeoQuery(
299
299
 
300
300
  export type SeoAuditScope = 'full' | 'content' | 'technical' | 'redirect'
301
301
 
302
+ export interface RenderedAuditSummary {
303
+ enabled: boolean
304
+ originSource: 'override' | 'config' | null
305
+ verified: number
306
+ unverified: number
307
+ notAttempted: number
308
+ partial: boolean
309
+ bytesRead: number
310
+ durationMs: number
311
+ reasons: Array<{ reason: string; count: number }>
312
+ }
313
+
302
314
  export interface SeoAuditRun {
303
315
  id: string
304
316
  status: 'RUNNING' | 'COMPLETED' | 'FAILED'
@@ -313,12 +325,27 @@ export interface SeoAuditRun {
313
325
  warningCount: number
314
326
  infoCount: number
315
327
  errorMessage: string | null
328
+ metadata: {
329
+ renderedAudit?: RenderedAuditSummary
330
+ } | null
331
+ }
332
+
333
+ export interface SeoAuditRunResponse {
334
+ id: string
335
+ status: 'COMPLETED'
336
+ siteScoreAfter: number
337
+ issuesFound: number
338
+ criticalCount: number
339
+ warningCount: number
340
+ infoCount: number
341
+ pagesScanned: number
342
+ renderedAudit: RenderedAuditSummary
316
343
  }
317
344
 
318
345
  export async function runSeoAudit(
319
346
  scope: SeoAuditScope = 'full',
320
- ): Promise<{ run?: SeoAuditRun; error?: string }> {
321
- const res = await cmsApi<SeoAuditRun>('/seo/audit', {
347
+ ): Promise<{ run?: SeoAuditRunResponse; error?: string }> {
348
+ const res = await cmsApi<SeoAuditRunResponse>('/seo/audit', {
322
349
  method: 'POST',
323
350
  body: JSON.stringify({ scope }),
324
351
  })
@@ -662,6 +689,7 @@ export interface CrawlSettings {
662
689
  sitemapEnabled: boolean
663
690
  autoRegenerateSitemapOnPublish: boolean
664
691
  pingSearchEnginesOnPublish: boolean
692
+ auditRenderedHtml: boolean
665
693
  }
666
694
 
667
695
  export async function fetchSitemapStatus(): Promise<SitemapStatus | null> {
@@ -1,7 +1,14 @@
1
1
  'use client'
2
2
 
3
3
  import { Fragment, useMemo, useState } from 'react'
4
- import { ClipboardCheck, Clock, CheckCircle2, FileDown, Sparkles } from 'lucide-react'
4
+ import {
5
+ AlertTriangle,
6
+ ClipboardCheck,
7
+ Clock,
8
+ CheckCircle2,
9
+ FileDown,
10
+ Sparkles,
11
+ } from 'lucide-react'
5
12
  import { toast } from 'sonner'
6
13
  import {
7
14
  fetchSeoIssues,
@@ -61,34 +68,60 @@ function issueStatusTone(status: SeoIssueStatus) {
61
68
  function AuditSummaryCards({ refetchKey }: { refetchKey: number }) {
62
69
  const { data, loading } = useSeoResource(fetchSeoAuditRuns, [refetchKey])
63
70
  const last = (data ?? [])[0]
71
+ const coverage = last?.metadata?.renderedAudit
64
72
  return (
65
- <div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
66
- <div className="border-border bg-card rounded-lg border p-4">
67
- <p className="text-muted-foreground flex items-center gap-1.5 text-sm">
68
- <Clock className="h-3.5 w-3.5" aria-hidden /> Last audit
69
- </p>
70
- <p className="text-foreground mt-1 text-sm font-medium">
71
- {loading ? '…' : last ? new Date(last.startedAt).toLocaleString() : 'Never'}
72
- </p>
73
- </div>
74
- <div className="border-border bg-card rounded-lg border p-4">
75
- <p className="text-muted-foreground text-sm">Pages scanned</p>
76
- <p className="text-foreground mt-1 text-2xl font-medium tabular-nums">
77
- {last?.pagesScanned ?? 0}
78
- </p>
79
- </div>
80
- <div className="border-border bg-card rounded-lg border p-4">
81
- <p className="text-muted-foreground text-sm">Issues found</p>
82
- <p className="text-foreground mt-1 text-2xl font-medium tabular-nums">
83
- {last?.issuesFound ?? 0}
84
- </p>
85
- </div>
86
- <div className="border-border bg-card rounded-lg border p-4">
87
- <p className="text-muted-foreground text-sm">Site score</p>
88
- <p className="text-foreground mt-1 text-2xl font-medium tabular-nums">
89
- {last?.siteScoreAfter ?? '—'}
90
- </p>
73
+ <div className="space-y-4">
74
+ <div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
75
+ <div className="border-border bg-card rounded-lg border p-4">
76
+ <p className="text-muted-foreground flex items-center gap-1.5 text-sm">
77
+ <Clock className="h-3.5 w-3.5" aria-hidden /> Last audit
78
+ </p>
79
+ <p className="text-foreground mt-1 text-sm font-medium">
80
+ {loading ? '…' : last ? new Date(last.startedAt).toLocaleString() : 'Never'}
81
+ </p>
82
+ </div>
83
+ <div className="border-border bg-card rounded-lg border p-4">
84
+ <p className="text-muted-foreground text-sm">Pages scanned</p>
85
+ <p className="text-foreground mt-1 text-2xl font-medium tabular-nums">
86
+ {last?.pagesScanned ?? 0}
87
+ </p>
88
+ {coverage ? (
89
+ <p className="text-muted-foreground mt-1 flex flex-wrap gap-x-3 text-sm">
90
+ <span>{coverage.verified} verified</span>
91
+ <span>{coverage.unverified} unverified</span>
92
+ </p>
93
+ ) : null}
94
+ </div>
95
+ <div className="border-border bg-card rounded-lg border p-4">
96
+ <p className="text-muted-foreground text-sm">Issues found</p>
97
+ <p className="text-foreground mt-1 text-2xl font-medium tabular-nums">
98
+ {last?.issuesFound ?? 0}
99
+ </p>
100
+ </div>
101
+ <div className="border-border bg-card rounded-lg border p-4">
102
+ <p className="text-muted-foreground text-sm">Site score</p>
103
+ <p className="text-foreground mt-1 text-2xl font-medium tabular-nums">
104
+ {last?.siteScoreAfter ?? '—'}
105
+ </p>
106
+ </div>
91
107
  </div>
108
+ {coverage?.partial ? (
109
+ <div
110
+ role="status"
111
+ className="border-warning/30 bg-warning/10 text-foreground rounded-lg border p-4"
112
+ >
113
+ <div className="flex items-start gap-3">
114
+ <AlertTriangle size={20} className="text-warning shrink-0" aria-hidden="true" />
115
+ <div>
116
+ <p className="font-medium">Partial rendered audit</p>
117
+ <p className="text-muted-foreground mt-1 text-sm">
118
+ The score covers {coverage.verified} verified pages. Evidence was unavailable for{' '}
119
+ {coverage.unverified}; their prior rendered-dependent issues were preserved.
120
+ </p>
121
+ </div>
122
+ </div>
123
+ </div>
124
+ ) : null}
92
125
  </div>
93
126
  )
94
127
  }
@@ -55,6 +55,11 @@ const CRAWL_TOGGLES: { key: keyof CrawlSettings; label: string; help: string }[]
55
55
  label: 'Ping search engines on publish',
56
56
  help: 'Notify Google and Bing when the sitemap changes (production only).',
57
57
  },
58
+ {
59
+ key: 'auditRenderedHtml',
60
+ label: 'Audit rendered public HTML',
61
+ help: 'Fetch public page output so audits grade crawler-visible headings and content.',
62
+ },
58
63
  ]
59
64
 
60
65
  function Toggle({