@prenta/admin 1.12.0 → 1.14.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 (55) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/__tests__/lib/seo-service-errors.test.js +34 -1
  3. package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
  4. package/dist/__tests__/lib/seo-service-proposals.test.d.ts +2 -0
  5. package/dist/__tests__/lib/seo-service-proposals.test.d.ts.map +1 -0
  6. package/dist/__tests__/lib/seo-service-proposals.test.js +71 -0
  7. package/dist/__tests__/lib/seo-service-proposals.test.js.map +1 -0
  8. package/dist/__tests__/router/seo-tab-for-path.test.js +1 -0
  9. package/dist/__tests__/router/seo-tab-for-path.test.js.map +1 -1
  10. package/dist/__tests__/views/seo-proposals-tab.render.test.d.ts +2 -0
  11. package/dist/__tests__/views/seo-proposals-tab.render.test.d.ts.map +1 -0
  12. package/dist/__tests__/views/seo-proposals-tab.render.test.js +292 -0
  13. package/dist/__tests__/views/seo-proposals-tab.render.test.js.map +1 -0
  14. package/dist/__tests__/views/seo-settings.render.test.js +73 -6
  15. package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
  16. package/dist/components/seo/ChangePreview.d.ts +6 -0
  17. package/dist/components/seo/ChangePreview.d.ts.map +1 -0
  18. package/dist/components/seo/ChangePreview.js +18 -0
  19. package/dist/components/seo/ChangePreview.js.map +1 -0
  20. package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
  21. package/dist/components/seo/SeoIssueFixPanel.js +1 -14
  22. package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
  23. package/dist/lib/seo-service.d.ts +106 -0
  24. package/dist/lib/seo-service.d.ts.map +1 -1
  25. package/dist/lib/seo-service.js +67 -0
  26. package/dist/lib/seo-service.js.map +1 -1
  27. package/dist/prenta-admin.css +1 -1
  28. package/dist/views/SEO.d.ts +5 -1
  29. package/dist/views/SEO.d.ts.map +1 -1
  30. package/dist/views/SEO.js +28 -9
  31. package/dist/views/SEO.js.map +1 -1
  32. package/dist/views/seo/ProposalsTab.d.ts +7 -0
  33. package/dist/views/seo/ProposalsTab.d.ts.map +1 -0
  34. package/dist/views/seo/ProposalsTab.js +298 -0
  35. package/dist/views/seo/ProposalsTab.js.map +1 -0
  36. package/dist/views/settings/SeoDigestCard.d.ts +9 -0
  37. package/dist/views/settings/SeoDigestCard.d.ts.map +1 -0
  38. package/dist/views/settings/SeoDigestCard.js +77 -0
  39. package/dist/views/settings/SeoDigestCard.js.map +1 -0
  40. package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
  41. package/dist/views/settings/SeoSettingsTab.js +2 -1
  42. package/dist/views/settings/SeoSettingsTab.js.map +1 -1
  43. package/package.json +3 -3
  44. package/src/__tests__/lib/seo-service-errors.test.ts +38 -0
  45. package/src/__tests__/lib/seo-service-proposals.test.ts +79 -0
  46. package/src/__tests__/router/seo-tab-for-path.test.ts +1 -0
  47. package/src/__tests__/views/seo-proposals-tab.render.test.tsx +370 -0
  48. package/src/__tests__/views/seo-settings.render.test.tsx +104 -5
  49. package/src/components/seo/ChangePreview.tsx +47 -0
  50. package/src/components/seo/SeoIssueFixPanel.tsx +1 -44
  51. package/src/lib/seo-service.ts +168 -0
  52. package/src/views/SEO.tsx +40 -7
  53. package/src/views/seo/ProposalsTab.tsx +681 -0
  54. package/src/views/settings/SeoDigestCard.tsx +164 -0
  55. package/src/views/settings/SeoSettingsTab.tsx +2 -0
@@ -2,11 +2,18 @@
2
2
  import { beforeEach, describe, expect, it, vi } from 'vitest'
3
3
  import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
4
4
 
5
- // The SEO defaults tab reads five shared backends: /seo/config (meta defaults +
5
+ // The SEO defaults tab reads six shared backends: /seo/config (meta defaults +
6
6
  // verification), /seo/crawl-settings (sitemap toggles), /seo/sitemap/status
7
- // (read-only summary), /seo/index-status (environment), and
8
- // /seo/search-console (Search data card).
9
- const cmsApi = vi.fn(async (endpoint: string, options?: { method?: string }) => {
7
+ // (read-only summary), /seo/index-status (environment), /seo/search-console
8
+ // (Search data card), and /seo/digest-settings (Weekly SEO digest card).
9
+ // `defaultApi` is the per-path route map; individual tests override the mock
10
+ // implementation and delegate back to it for unrelated paths.
11
+ type MockApiResponse = { data?: unknown; error?: string; status: number }
12
+
13
+ async function defaultApi(
14
+ endpoint: string,
15
+ options?: { method?: string },
16
+ ): Promise<MockApiResponse> {
10
17
  const method = options?.method ?? 'GET'
11
18
  if (endpoint === '/seo/config' && method === 'GET') {
12
19
  return {
@@ -67,14 +74,34 @@ const cmsApi = vi.fn(async (endpoint: string, options?: { method?: string }) =>
67
74
  status: 200,
68
75
  }
69
76
  }
77
+ if (endpoint === '/seo/digest-settings' && method === 'GET') {
78
+ return {
79
+ data: { enabled: false, recipients: [], lastSentAt: null, lastError: null },
80
+ status: 200,
81
+ }
82
+ }
70
83
  return { data: {}, status: 200 }
71
- })
84
+ }
85
+
86
+ const cmsApi = vi.fn(defaultApi)
72
87
 
73
88
  vi.mock('../../lib/api.js', () => ({
74
89
  cmsApi: (e: string, o?: unknown) => cmsApi(e, o as any),
75
90
  ensureCsrfToken: vi.fn(async () => {}),
76
91
  }))
77
92
 
93
+ // Record toast calls so tests can assert server errors are surfaced verbatim.
94
+ const toastSuccess = vi.fn()
95
+ const toastError = vi.fn()
96
+ vi.mock('sonner', () => ({
97
+ toast: {
98
+ success: (...args: unknown[]) => toastSuccess(...args),
99
+ error: (...args: unknown[]) => toastError(...args),
100
+ warning: vi.fn(),
101
+ loading: vi.fn(),
102
+ },
103
+ }))
104
+
78
105
  // Stub the Media Center picker: when open, expose a button that selects a known
79
106
  // asset (id + url), exercising the asset-id storage path without the real
80
107
  // MediaBrowser/network stack.
@@ -105,6 +132,9 @@ const { SeoSettingsTab } = await import('../../views/settings/SeoSettingsTab.js'
105
132
 
106
133
  beforeEach(() => {
107
134
  cmsApi.mockClear()
135
+ cmsApi.mockImplementation(defaultApi)
136
+ toastSuccess.mockClear()
137
+ toastError.mockClear()
108
138
  })
109
139
 
110
140
  describe('SeoSettingsTab', () => {
@@ -300,4 +330,73 @@ describe('SeoSettingsTab', () => {
300
330
  expect(body.propertyUrl).toBe('sc-domain:acme.test')
301
331
  expect(body.serviceAccountKey).toContain('svc@acme.iam.gserviceaccount.com')
302
332
  })
333
+
334
+ it('renders the Weekly SEO digest card with the toggle off by default', async () => {
335
+ render(<SeoSettingsTab role="ADMIN" />)
336
+ expect(await screen.findByText('Weekly SEO digest')).toBeTruthy()
337
+
338
+ const toggle = screen.getByRole('switch', { name: 'Send weekly digest' })
339
+ expect(toggle.getAttribute('aria-checked')).toBe('false')
340
+ expect(screen.getByText('Never sent')).toBeTruthy()
341
+ })
342
+
343
+ it('saves recipients as a trimmed, comma-split list', async () => {
344
+ render(<SeoSettingsTab role="ADMIN" />)
345
+ await screen.findByText('Weekly SEO digest')
346
+
347
+ fireEvent.change(screen.getByLabelText('Recipients'), {
348
+ target: { value: ' a@x.com , b@x.com ' },
349
+ })
350
+ fireEvent.click(screen.getByRole('button', { name: 'Save recipients' }))
351
+
352
+ await waitFor(() =>
353
+ expect(cmsApi).toHaveBeenCalledWith(
354
+ '/seo/digest-settings',
355
+ expect.objectContaining({ method: 'PUT' }),
356
+ ),
357
+ )
358
+ const putCall = cmsApi.mock.calls.find(
359
+ ([e, o]) => e === '/seo/digest-settings' && (o as { method?: string })?.method === 'PUT',
360
+ )
361
+ const body = JSON.parse((putCall![1] as { body: string }).body)
362
+ expect(body.recipients).toEqual(['a@x.com', 'b@x.com'])
363
+ })
364
+
365
+ it('surfaces the server error verbatim when test-send fails', async () => {
366
+ cmsApi.mockImplementation(async (endpoint, options) => {
367
+ if (endpoint === '/seo/digest-settings/test-send' && options?.method === 'POST') {
368
+ return { error: 'No completed SEO audit yet — run an audit first', status: 409 }
369
+ }
370
+ return defaultApi(endpoint, options)
371
+ })
372
+ render(<SeoSettingsTab role="ADMIN" />)
373
+ await screen.findByText('Weekly SEO digest')
374
+
375
+ fireEvent.click(screen.getByRole('button', { name: /Send test digest/ }))
376
+
377
+ await waitFor(() =>
378
+ expect(toastError).toHaveBeenCalledWith('No completed SEO audit yet — run an audit first'),
379
+ )
380
+ })
381
+
382
+ it('shows the last error from settings in destructive tone', async () => {
383
+ cmsApi.mockImplementation(async (endpoint, options) => {
384
+ if (endpoint === '/seo/digest-settings' && (options?.method ?? 'GET') === 'GET') {
385
+ return {
386
+ data: {
387
+ enabled: true,
388
+ recipients: ['owner@acme.test'],
389
+ lastSentAt: '2026-09-01T04:00:00Z',
390
+ lastError: '1/2 digest sends failed',
391
+ },
392
+ status: 200,
393
+ }
394
+ }
395
+ return defaultApi(endpoint, options)
396
+ })
397
+ render(<SeoSettingsTab role="ADMIN" />)
398
+
399
+ const lastError = await screen.findByText(/1\/2 digest sends failed/)
400
+ expect(lastError.className).toContain('text-destructive')
401
+ })
303
402
  })
@@ -0,0 +1,47 @@
1
+ 'use client'
2
+
3
+ import type { SeoFixFieldChange } from '../../lib/seo-service.js'
4
+
5
+ function formatValue(value: string | boolean | null): string {
6
+ if (value === null || value === '') return '—'
7
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No'
8
+ if (value.length > 280) return `${value.slice(0, 277)}…`
9
+ return value
10
+ }
11
+
12
+ /** Before/after table for a proposed SEO change set. Shared by the issue fix panel and the proposals inbox. */
13
+ export function ChangePreview({ changes }: { changes: SeoFixFieldChange[] }) {
14
+ if (changes.length === 0) return null
15
+ return (
16
+ <div className="border-border overflow-x-auto rounded-md border">
17
+ <table className="w-full min-w-[480px] text-sm" aria-label="Proposed SEO changes">
18
+ <thead>
19
+ <tr className="border-border bg-muted/40 text-muted-foreground border-b text-left">
20
+ <th scope="col" className="py-2 pr-3 pl-3 font-medium">
21
+ Field
22
+ </th>
23
+ <th scope="col" className="py-2 pr-3 font-medium">
24
+ Current
25
+ </th>
26
+ <th scope="col" className="py-2 pr-3 font-medium">
27
+ Proposed
28
+ </th>
29
+ </tr>
30
+ </thead>
31
+ <tbody className="divide-border divide-y">
32
+ {changes.map((c) => (
33
+ <tr key={c.field}>
34
+ <td className="text-foreground py-2 pr-3 pl-3 font-medium">{c.label}</td>
35
+ <td className="text-muted-foreground max-w-48 py-2 pr-3 wrap-break-word">
36
+ {formatValue(c.before)}
37
+ </td>
38
+ <td className="text-foreground max-w-48 py-2 pr-3 wrap-break-word">
39
+ {formatValue(c.after)}
40
+ </td>
41
+ </tr>
42
+ ))}
43
+ </tbody>
44
+ </table>
45
+ </div>
46
+ )
47
+ }
@@ -16,12 +16,12 @@ import {
16
16
  supportsSeoContentResearch,
17
17
  type SeoContentBrief,
18
18
  type SeoContentResearch,
19
- type SeoFixFieldChange,
20
19
  type SeoIssue,
21
20
  type SeoIssueFixSuggestion,
22
21
  } from '../../lib/seo-service.js'
23
22
  import { hasPlanFeature, type PlanInfo } from '../../lib/plan.js'
24
23
  import { PlanUpgradeCallout } from '../PlanUpgradeCallout.js'
24
+ import { ChangePreview } from './ChangePreview.js'
25
25
  import { SeoErrorState, btnPrimary, btnSecondary } from './primitives.js'
26
26
 
27
27
  function approveButtonLabel(strategy: SeoIssueFixSuggestion['fixStrategy']): string {
@@ -56,49 +56,6 @@ function approveSuccessToast(strategy: SeoIssueFixSuggestion['fixStrategy']): st
56
56
  }
57
57
  }
58
58
 
59
- function formatValue(value: string | boolean | null): string {
60
- if (value === null || value === '') return '—'
61
- if (typeof value === 'boolean') return value ? 'Yes' : 'No'
62
- if (value.length > 280) return `${value.slice(0, 277)}…`
63
- return value
64
- }
65
-
66
- function ChangePreview({ changes }: { changes: SeoFixFieldChange[] }) {
67
- if (changes.length === 0) return null
68
- return (
69
- <div className="border-border overflow-x-auto rounded-md border">
70
- <table className="w-full min-w-[480px] text-sm" aria-label="Proposed SEO changes">
71
- <thead>
72
- <tr className="border-border bg-muted/40 text-muted-foreground border-b text-left">
73
- <th scope="col" className="py-2 pr-3 pl-3 font-medium">
74
- Field
75
- </th>
76
- <th scope="col" className="py-2 pr-3 font-medium">
77
- Current
78
- </th>
79
- <th scope="col" className="py-2 pr-3 font-medium">
80
- Proposed
81
- </th>
82
- </tr>
83
- </thead>
84
- <tbody className="divide-border divide-y">
85
- {changes.map((c) => (
86
- <tr key={c.field}>
87
- <td className="text-foreground py-2 pr-3 pl-3 font-medium">{c.label}</td>
88
- <td className="text-muted-foreground max-w-48 py-2 pr-3 wrap-break-word">
89
- {formatValue(c.before)}
90
- </td>
91
- <td className="text-foreground max-w-48 py-2 pr-3 wrap-break-word">
92
- {formatValue(c.after)}
93
- </td>
94
- </tr>
95
- ))}
96
- </tbody>
97
- </table>
98
- </div>
99
- )
100
- }
101
-
102
59
  function ContentResearchCard({ research }: { research: SeoContentResearch }) {
103
60
  return (
104
61
  <div className="border-border bg-card space-y-3 rounded-md border p-4">
@@ -113,6 +113,8 @@ export interface SeoOverview {
113
113
  lastAuditRunId: string | null
114
114
  /** True when the last audit hit AUDIT_MAX_ENTITIES. */
115
115
  auditTruncated?: boolean
116
+ /** Cached AI issue fixes + open redirect suggestions awaiting review. */
117
+ pendingProposals?: { issueFixes: number; redirects: number }
116
118
  }
117
119
 
118
120
  const EMPTY_OVERVIEW: SeoOverview = {
@@ -783,6 +785,49 @@ export async function updateCrawlSettings(
783
785
  return res.error ? { error: res.error } : {}
784
786
  }
785
787
 
788
+ // ─── Weekly digest ───────────────────────────────────────────────────
789
+
790
+ export interface SeoDigestSettings {
791
+ enabled: boolean
792
+ recipients: string[]
793
+ lastSentAt: string | null
794
+ lastError: string | null
795
+ }
796
+
797
+ export async function fetchDigestSettings(): Promise<SeoDigestSettings | null> {
798
+ const res = await cmsApi<SeoDigestSettings>('/seo/digest-settings')
799
+ const d = res.data
800
+ // Validate the shape before handing it to the UI — an unexpected response
801
+ // body must render the card's error state, not crash on `recipients.join`.
802
+ if (!d || typeof d.enabled !== 'boolean' || !Array.isArray(d.recipients)) return null
803
+ return {
804
+ enabled: d.enabled,
805
+ recipients: d.recipients,
806
+ lastSentAt: d.lastSentAt ?? null,
807
+ lastError: d.lastError ?? null,
808
+ }
809
+ }
810
+
811
+ export async function updateDigestSettings(patch: {
812
+ enabled?: boolean
813
+ recipients?: string[]
814
+ }): Promise<{ ok: boolean; error?: string }> {
815
+ const res = await cmsApi<{ ok: boolean }>('/seo/digest-settings', {
816
+ method: 'PUT',
817
+ body: JSON.stringify(patch),
818
+ })
819
+ if (res.error) return { ok: false, error: res.error }
820
+ return { ok: true }
821
+ }
822
+
823
+ export async function sendTestDigest(): Promise<{ ok: boolean; to?: string; error?: string }> {
824
+ const res = await cmsApi<{ ok: boolean; to?: string }>('/seo/digest-settings/test-send', {
825
+ method: 'POST',
826
+ })
827
+ if (res.error) return { ok: false, error: res.error }
828
+ return { ok: true, to: res.data?.to }
829
+ }
830
+
786
831
  // ─── AI crawler activity ─────────────────────────────────────────────
787
832
 
788
833
  export interface AiCrawlerRow {
@@ -1290,3 +1335,126 @@ export async function fetchLinkHealthIssues(): Promise<{
1290
1335
  },
1291
1336
  }
1292
1337
  }
1338
+
1339
+ // ─── AI proposals inbox ─────────────────────────────────────────────────
1340
+
1341
+ export interface SeoProposal {
1342
+ /** `issue:<issueId>` | `redirect:<suggestionId>` */
1343
+ id: string
1344
+ kind: 'issue-fix' | 'redirect'
1345
+ title: string
1346
+ entity: { type: string | null; id: string | null; title: string | null; url: string | null }
1347
+ changes: SeoFixFieldChange[]
1348
+ justification: string
1349
+ source: 'ai' | 'deterministic' | 'ai-404-recovery'
1350
+ /** 0–1, redirects only. */
1351
+ confidence: number | null
1352
+ /** 0–100, issue fixes only. */
1353
+ brandAlignment: number | null
1354
+ generatedAt: string | null
1355
+ /** Issue fixes only; redirects are keyed by id. */
1356
+ fingerprint: string | null
1357
+ /** Issue fixes: cached fingerprint ≠ live fingerprint. Redirects: always false. */
1358
+ stale: boolean
1359
+ issueType: string | null
1360
+ severity: SeoSeverity | null
1361
+ issueId: string | null
1362
+ suggestionId: string | null
1363
+ }
1364
+
1365
+ export interface SeoProposalsPayload {
1366
+ proposals: SeoProposal[]
1367
+ counts: { issueFixes: number; redirects: number; stale: number; eligibleForGeneration: number }
1368
+ /** `allowBulkApply` per feature, so the UI can disable batch approve up front. */
1369
+ governance: { issueFixBulk: boolean; redirectBulk: boolean }
1370
+ }
1371
+
1372
+ export interface SeoProposalGenerateResult {
1373
+ generated: number
1374
+ skipped: number
1375
+ failed: number
1376
+ remaining: number
1377
+ haltedReason: string | null
1378
+ }
1379
+
1380
+ export interface SeoProposalBulkApplyItem {
1381
+ kind: 'issue-fix' | 'redirect'
1382
+ id: string
1383
+ fingerprint?: string
1384
+ }
1385
+
1386
+ /** Mirrors core's `BulkApplyItemStatus` (`seo/proposals-bulk-apply.ts`). */
1387
+ export type SeoProposalBulkApplyItemStatus =
1388
+ | 'applied'
1389
+ | 'stale'
1390
+ | 'verify-failed'
1391
+ | 'plan-required'
1392
+ | 'not-found'
1393
+ | 'not-allowed'
1394
+ | 'not-fixable'
1395
+ | 'invalid'
1396
+ | 'still-live'
1397
+ | 'error'
1398
+ | 'pending'
1399
+
1400
+ export interface SeoProposalBulkApplyResult {
1401
+ results: Array<{
1402
+ kind: 'issue-fix' | 'redirect'
1403
+ id: string
1404
+ status: SeoProposalBulkApplyItemStatus
1405
+ reason?: string
1406
+ }>
1407
+ applied: number
1408
+ failed: number
1409
+ /** Items the server did not reach within its time budget — resubmit exactly these. */
1410
+ pending: number
1411
+ }
1412
+
1413
+ export async function fetchSeoProposals(): Promise<SeoProposalsPayload> {
1414
+ const res = await cmsApi<SeoProposalsPayload>('/seo/proposals')
1415
+ throwIfError(res)
1416
+ return (
1417
+ res.data ?? {
1418
+ proposals: [],
1419
+ counts: { issueFixes: 0, redirects: 0, stale: 0, eligibleForGeneration: 0 },
1420
+ governance: { issueFixBulk: false, redirectBulk: false },
1421
+ }
1422
+ )
1423
+ }
1424
+
1425
+ export async function generateSeoProposals(): Promise<{
1426
+ result?: SeoProposalGenerateResult
1427
+ error?: string
1428
+ }> {
1429
+ const res = await cmsApi<SeoProposalGenerateResult>('/seo/proposals/generate', {
1430
+ method: 'POST',
1431
+ body: JSON.stringify({ confirm: true }),
1432
+ })
1433
+ if (res.error) return { error: res.error }
1434
+ return { result: res.data }
1435
+ }
1436
+
1437
+ export async function bulkApplySeoProposals(items: SeoProposalBulkApplyItem[]): Promise<{
1438
+ result?: SeoProposalBulkApplyResult
1439
+ error?: string
1440
+ /** True when the server refused because `allowBulkApply` is off for a represented feature. */
1441
+ governanceBlocked?: boolean
1442
+ }> {
1443
+ const res = await cmsApi<SeoProposalBulkApplyResult>('/seo/proposals/bulk-apply', {
1444
+ method: 'POST',
1445
+ body: JSON.stringify({ items }),
1446
+ })
1447
+ if (res.error) {
1448
+ return res.code === 'governance_blocked'
1449
+ ? { error: res.error, governanceBlocked: true }
1450
+ : { error: res.error }
1451
+ }
1452
+ return { result: res.data }
1453
+ }
1454
+
1455
+ export async function dismissSeoIssueFix(issueId: string): Promise<{ error?: string }> {
1456
+ const res = await cmsApi(`/seo/issues/${encodeURIComponent(issueId)}/dismiss-fix`, {
1457
+ method: 'POST',
1458
+ })
1459
+ return res.error ? { error: res.error } : {}
1460
+ }
package/src/views/SEO.tsx CHANGED
@@ -1,10 +1,11 @@
1
1
  'use client'
2
2
 
3
3
  /**
4
- * SEO Operations Center — five-tab workflow surface (Overview / Content /
5
- * Technical / Redirects / Audit). Tab + deep-link state is driven by the
6
- * `?tab=` query string so audits, issues, redirects, and content editors are
7
- * all linkable. All data flows through `lib/seo-service.ts`.
4
+ * SEO Operations Center — seven-tab workflow surface (Overview / Content /
5
+ * Links / Technical / Redirects / Audit / Proposals). Tab + deep-link state is
6
+ * driven by the `?tab=` query string so audits, issues, redirects, proposals,
7
+ * and content editors are all linkable. All data flows through
8
+ * `lib/seo-service.ts`.
8
9
  */
9
10
  import * as Tabs from '@radix-ui/react-tabs'
10
11
  import {
@@ -16,6 +17,7 @@ import {
16
17
  RefreshCw,
17
18
  Bot,
18
19
  Link2,
20
+ Inbox,
19
21
  } from 'lucide-react'
20
22
  import { useCallback, useEffect, useMemo, useState } from 'react'
21
23
  import { toast } from 'sonner'
@@ -32,6 +34,7 @@ import { TechnicalTab } from './seo/TechnicalTab.js'
32
34
  import { RedirectsTab } from './seo/RedirectsTab.js'
33
35
  import { AuditTab } from './seo/AuditTab.js'
34
36
  import { LinksTab } from './seo/LinksTab.js'
37
+ import { ProposalsTab } from './seo/ProposalsTab.js'
35
38
 
36
39
  export interface SEOProps {
37
40
  onNavigate?: (path: string) => void
@@ -45,6 +48,7 @@ const TABS = [
45
48
  { id: 'technical', label: 'Technical', icon: Settings2 },
46
49
  { id: 'redirects', label: 'Redirects', icon: ArrowRightLeft },
47
50
  { id: 'audit', label: 'Audit', icon: ClipboardCheck },
51
+ { id: 'proposals', label: 'Proposals', icon: Inbox },
48
52
  ] as const
49
53
 
50
54
  type TabId = (typeof TABS)[number]['id']
@@ -69,6 +73,7 @@ export const SEO_DEEP_PATHS = [
69
73
  '/seo/technical',
70
74
  '/seo/links',
71
75
  '/seo/audit',
76
+ '/seo/proposals',
72
77
  '/seo',
73
78
  ] as const
74
79
 
@@ -89,6 +94,8 @@ export function seoTabForPath(pathname: string): TabId | null {
89
94
  return 'technical'
90
95
  case '/seo/audit':
91
96
  return 'audit'
97
+ case '/seo/proposals':
98
+ return 'proposals'
92
99
  case '/seo':
93
100
  return 'overview'
94
101
  default:
@@ -177,7 +184,11 @@ export function SEO({ onNavigate, initialTab = 'overview' }: SEOProps) {
177
184
 
178
185
  const badgeCounts = useMemo(() => {
179
186
  const summary = overviewResource.data?.issuesSummary
180
- return { audit: summary ? summary.critical + summary.warning : 0 }
187
+ const pending = overviewResource.data?.pendingProposals
188
+ return {
189
+ audit: summary ? summary.critical + summary.warning : 0,
190
+ proposals: pending ? pending.issueFixes + pending.redirects : 0,
191
+ }
181
192
  }, [overviewResource.data])
182
193
 
183
194
  return (
@@ -215,7 +226,18 @@ export function SEO({ onNavigate, initialTab = 'overview' }: SEOProps) {
215
226
  >
216
227
  {TABS.map((t) => {
217
228
  const Icon = t.icon
218
- const badge = t.id === 'audit' ? badgeCounts.audit : 0
229
+ const badge =
230
+ t.id === 'audit'
231
+ ? badgeCounts.audit
232
+ : t.id === 'proposals'
233
+ ? badgeCounts.proposals
234
+ : 0
235
+ // The proposals count is informational (items awaiting review),
236
+ // not a warning like the audit issue count.
237
+ const badgeClass =
238
+ t.id === 'proposals'
239
+ ? 'bg-muted text-muted-foreground'
240
+ : 'bg-destructive/10 text-destructive'
219
241
  return (
220
242
  <Tabs.Trigger
221
243
  key={t.id}
@@ -225,7 +247,9 @@ export function SEO({ onNavigate, initialTab = 'overview' }: SEOProps) {
225
247
  <Icon className="h-4 w-4" aria-hidden />
226
248
  {t.label}
227
249
  {badge > 0 && (
228
- <span className="bg-destructive/10 text-destructive ml-1 rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums">
250
+ <span
251
+ className={`${badgeClass} ml-1 rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums`}
252
+ >
229
253
  {badge}
230
254
  </span>
231
255
  )}
@@ -272,6 +296,15 @@ export function SEO({ onNavigate, initialTab = 'overview' }: SEOProps) {
272
296
  />
273
297
  )}
274
298
  </Tabs.Content>
299
+ <Tabs.Content value="proposals" tabIndex={-1}>
300
+ {activeTab === 'proposals' && (
301
+ <ProposalsTab
302
+ planInfo={planInfo}
303
+ onChanged={() => setRefetchKey((k) => k + 1)}
304
+ onNavigate={onNavigate}
305
+ />
306
+ )}
307
+ </Tabs.Content>
275
308
  </ErrorBoundary>
276
309
  </div>
277
310
  </Tabs.Root>