@prenta/admin 1.16.0 → 1.17.1

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 (97) hide show
  1. package/CHANGELOG.md +18 -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 +5 -6
  9. package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
  10. package/dist/__tests__/lib/seo-service-proposals.test.js +24 -0
  11. package/dist/__tests__/lib/seo-service-proposals.test.js.map +1 -1
  12. package/dist/__tests__/views/seo-proposals-tab.render.test.js +277 -0
  13. package/dist/__tests__/views/seo-proposals-tab.render.test.js.map +1 -1
  14. package/dist/components/seo/FaqProposalPreview.d.ts +39 -0
  15. package/dist/components/seo/FaqProposalPreview.d.ts.map +1 -0
  16. package/dist/components/seo/FaqProposalPreview.js +62 -0
  17. package/dist/components/seo/FaqProposalPreview.js.map +1 -0
  18. package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
  19. package/dist/components/seo/SeoIssueFixPanel.js +7 -2
  20. package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
  21. package/dist/lib/seo-service.d.ts +61 -7
  22. package/dist/lib/seo-service.d.ts.map +1 -1
  23. package/dist/lib/seo-service.js +9 -13
  24. package/dist/lib/seo-service.js.map +1 -1
  25. package/dist/prenta-admin.css +1 -1
  26. package/dist/views/seo/ProposalsTab.d.ts.map +1 -1
  27. package/dist/views/seo/ProposalsTab.js +160 -7
  28. package/dist/views/seo/ProposalsTab.js.map +1 -1
  29. package/dist/views/settings/SeoAutopilotCard.d.ts.map +1 -1
  30. package/dist/views/settings/SeoAutopilotCard.js +6 -0
  31. package/dist/views/settings/SeoAutopilotCard.js.map +1 -1
  32. package/package.json +5 -5
  33. package/src/__tests__/components/faq-proposal-preview.render.test.tsx +137 -0
  34. package/src/__tests__/components/seo-issue-fix-panel.render.test.tsx +42 -11
  35. package/src/__tests__/lib/seo-service-errors.test.ts +5 -9
  36. package/src/__tests__/lib/seo-service-proposals.test.ts +30 -0
  37. package/src/__tests__/views/seo-proposals-tab.render.test.tsx +358 -0
  38. package/src/components/seo/FaqProposalPreview.tsx +134 -0
  39. package/src/components/seo/SeoIssueFixPanel.tsx +7 -2
  40. package/src/lib/seo-service.ts +57 -15
  41. package/src/views/seo/ProposalsTab.tsx +279 -13
  42. package/src/views/settings/SeoAutopilotCard.tsx +6 -0
  43. package/dist/components/ContentOverviewChart.d.ts +0 -8
  44. package/dist/components/ContentOverviewChart.d.ts.map +0 -1
  45. package/dist/components/ContentOverviewChart.js +0 -20
  46. package/dist/components/ContentOverviewChart.js.map +0 -1
  47. package/dist/components/SEOPerformance.d.ts +0 -5
  48. package/dist/components/SEOPerformance.d.ts.map +0 -1
  49. package/dist/components/SEOPerformance.js +0 -29
  50. package/dist/components/SEOPerformance.js.map +0 -1
  51. package/dist/components/ui/Avatar.d.ts +0 -9
  52. package/dist/components/ui/Avatar.d.ts.map +0 -1
  53. package/dist/components/ui/Avatar.js +0 -21
  54. package/dist/components/ui/Avatar.js.map +0 -1
  55. package/dist/components/ui/CommandPalette.d.ts +0 -14
  56. package/dist/components/ui/CommandPalette.d.ts.map +0 -1
  57. package/dist/components/ui/CommandPalette.js +0 -58
  58. package/dist/components/ui/CommandPalette.js.map +0 -1
  59. package/dist/components/ui/DataTable.d.ts +0 -30
  60. package/dist/components/ui/DataTable.d.ts.map +0 -1
  61. package/dist/components/ui/DataTable.js +0 -44
  62. package/dist/components/ui/DataTable.js.map +0 -1
  63. package/dist/components/ui/Pagination.d.ts +0 -9
  64. package/dist/components/ui/Pagination.d.ts.map +0 -1
  65. package/dist/components/ui/Pagination.js +0 -26
  66. package/dist/components/ui/Pagination.js.map +0 -1
  67. package/dist/components/ui/Select.d.ts +0 -16
  68. package/dist/components/ui/Select.d.ts.map +0 -1
  69. package/dist/components/ui/Select.js +0 -24
  70. package/dist/components/ui/Select.js.map +0 -1
  71. package/dist/components/ui/Toast.d.ts +0 -18
  72. package/dist/components/ui/Toast.d.ts.map +0 -1
  73. package/dist/components/ui/Toast.js +0 -29
  74. package/dist/components/ui/Toast.js.map +0 -1
  75. package/dist/components/ui/index.d.ts +0 -46
  76. package/dist/components/ui/index.d.ts.map +0 -1
  77. package/dist/components/ui/index.js +0 -24
  78. package/dist/components/ui/index.js.map +0 -1
  79. package/dist/hooks/useContentLock.d.ts +0 -12
  80. package/dist/hooks/useContentLock.d.ts.map +0 -1
  81. package/dist/hooks/useContentLock.js +0 -38
  82. package/dist/hooks/useContentLock.js.map +0 -1
  83. package/dist/hooks/useDebounce.d.ts +0 -2
  84. package/dist/hooks/useDebounce.d.ts.map +0 -1
  85. package/dist/hooks/useDebounce.js +0 -11
  86. package/dist/hooks/useDebounce.js.map +0 -1
  87. package/src/components/ContentOverviewChart.tsx +0 -75
  88. package/src/components/SEOPerformance.tsx +0 -163
  89. package/src/components/ui/Avatar.tsx +0 -42
  90. package/src/components/ui/CommandPalette.tsx +0 -133
  91. package/src/components/ui/DataTable.tsx +0 -204
  92. package/src/components/ui/Pagination.tsx +0 -94
  93. package/src/components/ui/Select.tsx +0 -53
  94. package/src/components/ui/Toast.tsx +0 -72
  95. package/src/components/ui/index.ts +0 -61
  96. package/src/hooks/useContentLock.ts +0 -55
  97. package/src/hooks/useDebounce.ts +0 -14
@@ -0,0 +1,137 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { fireEvent, render, screen } from '@testing-library/react'
4
+ import {
5
+ FAQ_MIN_SELECTED,
6
+ FaqProposalPreview,
7
+ faqDraftBlocker,
8
+ faqSignalLine,
9
+ type FaqProposalPreviewProps,
10
+ } from '../../components/seo/FaqProposalPreview.js'
11
+
12
+ const items = [
13
+ {
14
+ question: 'How long does it take?',
15
+ answer: 'Three to five days.',
16
+ sourceQuote: 'take three to five days',
17
+ },
18
+ { question: 'Can I finance?', answer: 'Yes, zero down.', sourceQuote: 'zero down' },
19
+ { question: 'Warranty?', answer: '25 years.', sourceQuote: '25-year' },
20
+ ]
21
+ const signals = {
22
+ geo: { referralVisits: 14, retrievalHits: 0, engines: ['chatgpt'] },
23
+ queries: [{ query: 'how long does it take', impressions: 120, clicks: 3 }],
24
+ }
25
+
26
+ function setup(selected = [true, true, true], over: Partial<FaqProposalPreviewProps> = {}) {
27
+ const onToggle = vi.fn()
28
+ const onHeadingChange = vi.fn()
29
+ render(
30
+ <FaqProposalPreview
31
+ heading="Questions"
32
+ items={items}
33
+ signals={signals}
34
+ selected={selected}
35
+ onToggle={onToggle}
36
+ headingValue="Questions"
37
+ onHeadingChange={onHeadingChange}
38
+ {...over}
39
+ />,
40
+ )
41
+ return { onToggle, onHeadingChange }
42
+ }
43
+
44
+ describe('FaqProposalPreview', () => {
45
+ it('renders every Q&A with its source quote and a labelled checkbox', () => {
46
+ setup()
47
+ expect(screen.getAllByRole('checkbox')).toHaveLength(3)
48
+ expect(screen.getByRole('checkbox', { name: /How long does it take\?/ })).toHaveProperty(
49
+ 'checked',
50
+ true,
51
+ )
52
+ expect(screen.getByText('Three to five days.')).toBeTruthy()
53
+ expect(screen.getByText(/take three to five days/)).toBeTruthy()
54
+ })
55
+
56
+ it('shows the signal line', () => {
57
+ setup()
58
+ expect(screen.getByText(/Cited by chatgpt 14×/)).toBeTruthy()
59
+ expect(screen.getByText(/"how long does it take"/)).toBeTruthy()
60
+ })
61
+
62
+ it('calls onToggle with the index and onHeadingChange with the value', () => {
63
+ const { onToggle, onHeadingChange } = setup()
64
+ fireEvent.click(screen.getByRole('checkbox', { name: /Warranty\?/ }))
65
+ expect(onToggle).toHaveBeenCalledWith(2)
66
+ fireEvent.change(screen.getByLabelText('Section heading'), {
67
+ target: { value: 'Top questions' },
68
+ })
69
+ expect(onHeadingChange).toHaveBeenCalledWith('Top questions')
70
+ })
71
+
72
+ it('warns when fewer than two items are selected', () => {
73
+ setup([true, false, false])
74
+ expect(screen.getByRole('status').textContent).toBe(
75
+ `Select at least ${FAQ_MIN_SELECTED} questions to approve (1 selected).`,
76
+ )
77
+ })
78
+
79
+ it('reports how many questions will be added when the draft is approvable', () => {
80
+ setup([true, true, false])
81
+ expect(screen.getByRole('status').textContent).toBe('2 of 3 questions will be added.')
82
+ })
83
+
84
+ it('treats a blank heading as "use the drafted heading", not a blocker', () => {
85
+ setup([true, true, true], { headingValue: ' ' })
86
+ expect(screen.getByRole('status').textContent).toBe('3 of 3 questions will be added.')
87
+ expect(screen.getByLabelText('Section heading')).toHaveProperty('placeholder', 'Questions')
88
+ })
89
+
90
+ it('renders no source line for an item without a quote', () => {
91
+ setup([true, true], {
92
+ items: [
93
+ { question: 'Q1?', answer: 'A1.', sourceQuote: '' },
94
+ { question: 'Q2?', answer: 'A2.' },
95
+ ],
96
+ })
97
+ expect(screen.queryByText(/^Source:/)).toBeNull()
98
+ expect(screen.queryByText(/“”/)).toBeNull()
99
+ })
100
+
101
+ it('omits the signal line when there are no signals', () => {
102
+ setup([true, true, true], { signals: null })
103
+ expect(screen.queryByText(/Cited by|Fetched by|asked/)).toBeNull()
104
+ })
105
+
106
+ it('disables the heading field and every checkbox when disabled', () => {
107
+ setup([true, true, true], { disabled: true })
108
+ expect(screen.getByLabelText('Section heading')).toHaveProperty('disabled', true)
109
+ for (const box of screen.getAllByRole('checkbox')) expect(box).toHaveProperty('disabled', true)
110
+ })
111
+ })
112
+
113
+ describe('faqSignalLine', () => {
114
+ it('prefers referral citations, then retrieval fetches, then the top query', () => {
115
+ expect(faqSignalLine(signals)).toBe(
116
+ 'Cited by chatgpt 14× in 28 days · asked "how long does it take" 120× on Google',
117
+ )
118
+ expect(
119
+ faqSignalLine({ geo: { referralVisits: 0, retrievalHits: 40, engines: [] }, queries: [] }),
120
+ ).toBe('Fetched by AI engines 40× in 28 days')
121
+ expect(
122
+ faqSignalLine({ geo: null, queries: [{ query: 'why', impressions: 9, clicks: 0 }] }),
123
+ ).toBe('asked "why" 9× on Google')
124
+ expect(faqSignalLine({ geo: null, queries: [] })).toBeNull()
125
+ expect(faqSignalLine(null)).toBeNull()
126
+ })
127
+ })
128
+
129
+ describe('faqDraftBlocker', () => {
130
+ it('blocks only on too few selected items', () => {
131
+ expect(faqDraftBlocker([true, false, false])).toBe(
132
+ `Select at least ${FAQ_MIN_SELECTED} questions to approve (1 selected).`,
133
+ )
134
+ expect(faqDraftBlocker([false, false, false])).toMatch(/\(0 selected\)/)
135
+ expect(faqDraftBlocker([true, true, false])).toBeNull()
136
+ })
137
+ })
@@ -27,21 +27,45 @@ const orphanIssue: SeoIssue = {
27
27
  resolvedAt: null,
28
28
  }
29
29
 
30
+ const faqSchemaIssue: SeoIssue = {
31
+ ...orphanIssue,
32
+ id: 'iss-faq-schema',
33
+ entityTitle: 'Pricing',
34
+ url: '/pricing',
35
+ category: 'schema',
36
+ type: 'faq-schema-missing',
37
+ title: 'FAQ section without FAQPage schema',
38
+ fixActionType: 'edit-schema',
39
+ }
40
+
41
+ const fetchSuggestionMock = vi.fn(async (issueId: string) => ({
42
+ suggestion:
43
+ issueId === faqSchemaIssue.id
44
+ ? {
45
+ autoFixable: true,
46
+ source: 'deterministic',
47
+ // Metadata/schema fixes carry no strategy — only redirects do.
48
+ fingerprint: 'fp-faq',
49
+ patch: { structuredData: {} },
50
+ changes: [],
51
+ justification: 'Adds a FAQPage node.',
52
+ }
53
+ : {
54
+ autoFixable: true,
55
+ source: 'deterministic',
56
+ fixStrategy: 'insert-link',
57
+ fingerprint: 'fp-insert',
58
+ patch: {},
59
+ changes: [],
60
+ justification: 'Add an inbound link from a related page.',
61
+ },
62
+ }))
63
+
30
64
  vi.mock('../../lib/seo-service.js', async (importOriginal) => {
31
65
  const actual = await importOriginal<typeof import('../../lib/seo-service.js')>()
32
66
  return {
33
67
  ...actual,
34
- fetchSeoIssueFixSuggestion: vi.fn(async () => ({
35
- suggestion: {
36
- autoFixable: true,
37
- source: 'deterministic',
38
- fixStrategy: 'insert-link',
39
- fingerprint: 'fp-insert',
40
- patch: {},
41
- changes: [],
42
- justification: 'Add an inbound link from a related page.',
43
- },
44
- })),
68
+ fetchSeoIssueFixSuggestion: (issueId: string) => fetchSuggestionMock(issueId),
45
69
  applySeoIssueFix: vi.fn(async () => ({})),
46
70
  fetchSeoContentBrief: vi.fn(async () => ({})),
47
71
  fetchSeoContentResearch: vi.fn(async () => ({})),
@@ -62,4 +86,11 @@ describe('SeoIssueFixPanel insert-link copy', () => {
62
86
  expect(await screen.findByRole('button', { name: 'Approve link' })).toBeTruthy()
63
87
  expect(screen.queryByRole('button', { name: 'Approve redirect' })).toBeNull()
64
88
  })
89
+
90
+ it('labels a strategy-less schema fix (faq-schema-missing) Approve fix, not Approve redirect', async () => {
91
+ render(<SeoIssueFixPanel issue={faqSchemaIssue} expanded />)
92
+
93
+ expect(await screen.findByRole('button', { name: 'Approve fix' })).toBeTruthy()
94
+ expect(screen.queryByRole('button', { name: 'Approve redirect' })).toBeNull()
95
+ })
65
96
  })
@@ -17,7 +17,6 @@ const {
17
17
  fetchRedirectRules,
18
18
  generate404RecoverySuggestions,
19
19
  generateSeoField,
20
- generateStructuredData,
21
20
  isInlineSeoFixIssue,
22
21
  runSeoAudit,
23
22
  summarizeSeoAudit,
@@ -113,7 +112,7 @@ describe('SEO service read fetchers surface errors', () => {
113
112
 
114
113
  /**
115
114
  * Regression tests for the "swallowed governance errors" gap: the governed AI
116
- * runtime returns crafted 429/403 messages (daily request cap, monthly budget,
115
+ * runtime returns crafted 429/503 messages (daily request cap, monthly budget,
117
116
  * disabled feature) and the copilot helpers must surface them, not collapse to
118
117
  * `{}` — which the UI renders as the misleading "No suggestion was returned."
119
118
  * Unavailability (501/503) stays a distinct graceful state.
@@ -141,13 +140,6 @@ describe('AI copilot helpers surface governance errors', () => {
141
140
  await expect(explainSeoIssue('issue-1')).resolves.toEqual({ error: capMsg })
142
141
  })
143
142
 
144
- it('generateStructuredData returns the server message on 403', async () => {
145
- cmsApi.mockResolvedValue({ error: 'This AI feature is disabled.', status: 403 })
146
- await expect(
147
- generateStructuredData({ entityType: 'page', entityId: 'p1', schemaType: 'Article' }),
148
- ).resolves.toEqual({ error: 'This AI feature is disabled.' })
149
- })
150
-
151
143
  it('summarizeSeoAudit returns the server message on 429', async () => {
152
144
  cmsApi.mockResolvedValue({ error: capMsg, status: 429 })
153
145
  await expect(summarizeSeoAudit()).resolves.toEqual({ error: capMsg })
@@ -220,6 +212,10 @@ describe('inline SEO fix issue types', () => {
220
212
  it('treats orphan-page as an inline Approve issue', () => {
221
213
  expect(isInlineSeoFixIssue('orphan-page')).toBe(true)
222
214
  })
215
+
216
+ it('treats faq-schema-missing as an inline Approve issue (mirrors core DETERMINISTIC_FIX_TYPES)', () => {
217
+ expect(isInlineSeoFixIssue('faq-schema-missing')).toBe(true)
218
+ })
223
219
  })
224
220
 
225
221
  describe('SEO audit mutation contract', () => {
@@ -100,6 +100,36 @@ describe('proposal service helpers', () => {
100
100
  })
101
101
  })
102
102
 
103
+ it('applySeoIssueFix sends includeItems and headingOverride only when given', async () => {
104
+ cmsApi.mockResolvedValueOnce({ status: 200, data: { issue: { id: 'i1' } } })
105
+ await expect(
106
+ svc.applySeoIssueFix('i1', 'fp', { includeItems: [0, 2], headingOverride: 'H' }),
107
+ ).resolves.toEqual({ issue: { id: 'i1' } })
108
+ expect(cmsApi).toHaveBeenLastCalledWith('/seo/issues/i1/apply-fix', {
109
+ method: 'POST',
110
+ body: JSON.stringify({ fingerprint: 'fp', includeItems: [0, 2], headingOverride: 'H' }),
111
+ })
112
+
113
+ cmsApi.mockResolvedValueOnce({ status: 200, data: { issue: { id: 'i1' } } })
114
+ await svc.applySeoIssueFix('i1', 'fp')
115
+ expect(cmsApi).toHaveBeenLastCalledWith('/seo/issues/i1/apply-fix', {
116
+ method: 'POST',
117
+ body: JSON.stringify({ fingerprint: 'fp' }),
118
+ })
119
+
120
+ cmsApi.mockResolvedValueOnce({ status: 200, data: { issue: { id: 'i1' } } })
121
+ await svc.applySeoIssueFix('i1', 'fp', {})
122
+ expect(cmsApi).toHaveBeenLastCalledWith('/seo/issues/i1/apply-fix', {
123
+ method: 'POST',
124
+ body: JSON.stringify({ fingerprint: 'fp' }),
125
+ })
126
+
127
+ cmsApi.mockResolvedValueOnce({ status: 409, error: 'Fix suggestion is stale.' })
128
+ await expect(svc.applySeoIssueFix('i1', 'fp', { includeItems: [0, 1] })).resolves.toEqual({
129
+ error: 'Fix suggestion is stale.',
130
+ })
131
+ })
132
+
103
133
  it('dismissSeoIssueFix POSTs to the encoded issue path', async () => {
104
134
  cmsApi.mockResolvedValueOnce({ status: 200, data: { dismissed: true } })
105
135
  await expect(svc.dismissSeoIssueFix('a/b')).resolves.toEqual({})