@prenta/admin 1.14.0 → 1.16.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.
- package/CHANGELOG.md +16 -0
- package/dist/__tests__/lib/api.test.d.ts +2 -0
- package/dist/__tests__/lib/api.test.d.ts.map +1 -0
- package/dist/__tests__/lib/api.test.js +58 -0
- package/dist/__tests__/lib/api.test.js.map +1 -0
- package/dist/__tests__/lib/seo-service-autopilot.test.d.ts +2 -0
- package/dist/__tests__/lib/seo-service-autopilot.test.d.ts.map +1 -0
- package/dist/__tests__/lib/seo-service-autopilot.test.js +171 -0
- package/dist/__tests__/lib/seo-service-autopilot.test.js.map +1 -0
- package/dist/__tests__/lib/seo-service-geo.test.d.ts +2 -0
- package/dist/__tests__/lib/seo-service-geo.test.d.ts.map +1 -0
- package/dist/__tests__/lib/seo-service-geo.test.js +103 -0
- package/dist/__tests__/lib/seo-service-geo.test.js.map +1 -0
- package/dist/__tests__/lib/seo-service-proposals.test.js +28 -0
- package/dist/__tests__/lib/seo-service-proposals.test.js.map +1 -1
- package/dist/__tests__/router/seo-tab-for-path.test.js +4 -0
- package/dist/__tests__/router/seo-tab-for-path.test.js.map +1 -1
- package/dist/__tests__/views/redirects-pane.render.test.js +30 -12
- package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-ai-visibility.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-ai-visibility.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-ai-visibility.render.test.js +244 -0
- package/dist/__tests__/views/seo-ai-visibility.render.test.js.map +1 -0
- package/dist/__tests__/views/seo-autopilot-applied.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-autopilot-applied.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-autopilot-applied.render.test.js +330 -0
- package/dist/__tests__/views/seo-autopilot-applied.render.test.js.map +1 -0
- package/dist/__tests__/views/seo-autopilot-card.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-autopilot-card.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-autopilot-card.render.test.js +512 -0
- package/dist/__tests__/views/seo-autopilot-card.render.test.js.map +1 -0
- package/dist/__tests__/views/seo-geo-settings-card.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-geo-settings-card.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-geo-settings-card.render.test.js +227 -0
- package/dist/__tests__/views/seo-geo-settings-card.render.test.js.map +1 -0
- package/dist/__tests__/views/seo-proposals-tab.render.test.js +139 -0
- package/dist/__tests__/views/seo-proposals-tab.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-settings.render.test.js +53 -4
- package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-technical-tab.render.test.js +13 -0
- package/dist/__tests__/views/seo-technical-tab.render.test.js.map +1 -1
- package/dist/lib/api.d.ts +13 -2
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +1 -2
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/seo-service.d.ts +300 -2
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +172 -5
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/dist/views/SEO.d.ts +5 -1
- package/dist/views/SEO.d.ts.map +1 -1
- package/dist/views/SEO.js +12 -7
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/seo/AiVisibilityTab.d.ts +4 -0
- package/dist/views/seo/AiVisibilityTab.d.ts.map +1 -0
- package/dist/views/seo/AiVisibilityTab.js +165 -0
- package/dist/views/seo/AiVisibilityTab.js.map +1 -0
- package/dist/views/seo/AutopilotAppliedSection.d.ts +10 -0
- package/dist/views/seo/AutopilotAppliedSection.d.ts.map +1 -0
- package/dist/views/seo/AutopilotAppliedSection.js +295 -0
- package/dist/views/seo/AutopilotAppliedSection.js.map +1 -0
- package/dist/views/seo/ProposalsTab.d.ts.map +1 -1
- package/dist/views/seo/ProposalsTab.js +53 -17
- package/dist/views/seo/ProposalsTab.js.map +1 -1
- package/dist/views/seo/RedirectsTab.d.ts +3 -1
- package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
- package/dist/views/seo/RedirectsTab.js +14 -19
- package/dist/views/seo/RedirectsTab.js.map +1 -1
- package/dist/views/seo/TechnicalTab.d.ts +3 -1
- package/dist/views/seo/TechnicalTab.d.ts.map +1 -1
- package/dist/views/seo/TechnicalTab.js +4 -4
- package/dist/views/seo/TechnicalTab.js.map +1 -1
- package/dist/views/seo/relative-age.d.ts +7 -0
- package/dist/views/seo/relative-age.d.ts.map +1 -0
- package/dist/views/seo/relative-age.js +20 -0
- package/dist/views/seo/relative-age.js.map +1 -0
- package/dist/views/settings/SeoAutopilotCard.d.ts +30 -0
- package/dist/views/settings/SeoAutopilotCard.d.ts.map +1 -0
- package/dist/views/settings/SeoAutopilotCard.js +375 -0
- package/dist/views/settings/SeoAutopilotCard.js.map +1 -0
- package/dist/views/settings/SeoGeoSettingsCard.d.ts +12 -0
- package/dist/views/settings/SeoGeoSettingsCard.d.ts.map +1 -0
- package/dist/views/settings/SeoGeoSettingsCard.js +178 -0
- package/dist/views/settings/SeoGeoSettingsCard.js.map +1 -0
- package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
- package/dist/views/settings/SeoSettingsTab.js +3 -1
- package/dist/views/settings/SeoSettingsTab.js.map +1 -1
- package/dist/views/settings/components.d.ts +3 -1
- package/dist/views/settings/components.d.ts.map +1 -1
- package/dist/views/settings/components.js +2 -2
- package/dist/views/settings/components.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/lib/api.test.ts +76 -0
- package/src/__tests__/lib/seo-service-autopilot.test.ts +196 -0
- package/src/__tests__/lib/seo-service-geo.test.ts +118 -0
- package/src/__tests__/lib/seo-service-proposals.test.ts +29 -0
- package/src/__tests__/router/seo-tab-for-path.test.ts +5 -0
- package/src/__tests__/views/redirects-pane.render.test.tsx +36 -16
- package/src/__tests__/views/seo-ai-visibility.render.test.tsx +279 -0
- package/src/__tests__/views/seo-autopilot-applied.render.test.tsx +382 -0
- package/src/__tests__/views/seo-autopilot-card.render.test.tsx +610 -0
- package/src/__tests__/views/seo-geo-settings-card.render.test.tsx +253 -0
- package/src/__tests__/views/seo-proposals-tab.render.test.tsx +161 -0
- package/src/__tests__/views/seo-settings.render.test.tsx +68 -7
- package/src/__tests__/views/seo-technical-tab.render.test.tsx +15 -0
- package/src/lib/api.ts +19 -3
- package/src/lib/seo-service.ts +439 -6
- package/src/views/SEO.tsx +16 -7
- package/src/views/seo/AiVisibilityTab.tsx +616 -0
- package/src/views/seo/AutopilotAppliedSection.tsx +597 -0
- package/src/views/seo/ProposalsTab.tsx +92 -12
- package/src/views/seo/RedirectsTab.tsx +39 -60
- package/src/views/seo/TechnicalTab.tsx +27 -8
- package/src/views/seo/relative-age.ts +15 -0
- package/src/views/settings/SeoAutopilotCard.tsx +826 -0
- package/src/views/settings/SeoGeoSettingsCard.tsx +310 -0
- package/src/views/settings/SeoSettingsTab.tsx +4 -0
- package/src/views/settings/components.tsx +10 -1
|
@@ -0,0 +1,253 @@
|
|
|
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
|
+
|
|
5
|
+
type Settings = { enabled: boolean; retentionDays: number }
|
|
6
|
+
type UpdateResult = { ok: boolean; error?: string }
|
|
7
|
+
|
|
8
|
+
let current: Settings = { enabled: true, retentionDays: 90 }
|
|
9
|
+
/** When set, the next PUT waits on this so a test can observe the in-flight state. */
|
|
10
|
+
let putGate: Promise<void> | null = null
|
|
11
|
+
|
|
12
|
+
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
|
13
|
+
let resolve!: () => void
|
|
14
|
+
const promise = new Promise<void>((r) => {
|
|
15
|
+
resolve = r
|
|
16
|
+
})
|
|
17
|
+
return { promise, resolve }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function defaultUpdate(patch: Partial<Settings>): Promise<UpdateResult> {
|
|
21
|
+
if (putGate) await putGate
|
|
22
|
+
current = { ...current, ...patch }
|
|
23
|
+
return { ok: true }
|
|
24
|
+
}
|
|
25
|
+
const fetchGeoSettings = vi.fn(async (): Promise<Settings | null> => ({ ...current }))
|
|
26
|
+
const updateGeoSettings = vi.fn(defaultUpdate)
|
|
27
|
+
vi.mock('../../lib/seo-service.js', () => ({
|
|
28
|
+
SEO_GEO_RETENTION: { min: 7, max: 365 },
|
|
29
|
+
fetchGeoSettings: () => fetchGeoSettings(),
|
|
30
|
+
updateGeoSettings: (p: Partial<Settings>) => updateGeoSettings(p),
|
|
31
|
+
}))
|
|
32
|
+
const toastSuccess = vi.fn()
|
|
33
|
+
const toastError = vi.fn()
|
|
34
|
+
vi.mock('sonner', () => ({
|
|
35
|
+
toast: {
|
|
36
|
+
success: (...a: unknown[]) => toastSuccess(...a),
|
|
37
|
+
error: (...a: unknown[]) => toastError(...a),
|
|
38
|
+
warning: vi.fn(),
|
|
39
|
+
info: vi.fn(),
|
|
40
|
+
},
|
|
41
|
+
}))
|
|
42
|
+
|
|
43
|
+
const { SeoGeoSettingsCard } = await import('../../views/settings/SeoGeoSettingsCard.js')
|
|
44
|
+
|
|
45
|
+
const SWITCH = /record ai crawler and referral activity/i
|
|
46
|
+
const RETENTION = /retention \(days\)/i
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
current = { enabled: true, retentionDays: 90 }
|
|
50
|
+
putGate = null
|
|
51
|
+
fetchGeoSettings.mockClear()
|
|
52
|
+
fetchGeoSettings.mockImplementation(async () => ({ ...current }))
|
|
53
|
+
updateGeoSettings.mockClear()
|
|
54
|
+
updateGeoSettings.mockImplementation(defaultUpdate)
|
|
55
|
+
toastSuccess.mockClear()
|
|
56
|
+
toastError.mockClear()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('SeoGeoSettingsCard', () => {
|
|
60
|
+
it('renders the toggle, retention, and both middleware snippets', async () => {
|
|
61
|
+
render(<SeoGeoSettingsCard canEdit />)
|
|
62
|
+
const toggle = await screen.findByRole('switch', { name: SWITCH })
|
|
63
|
+
expect(toggle.getAttribute('aria-checked')).toBe('true')
|
|
64
|
+
expect((screen.getByLabelText(RETENTION) as HTMLInputElement).value).toBe('90')
|
|
65
|
+
expect(screen.getByText(/observeGeoTraffic\(request/)).toBeTruthy()
|
|
66
|
+
expect(screen.getByText(/createAstroRedirectMiddleware/)).toBeTruthy()
|
|
67
|
+
// Scrollable snippets are focusable and named so keyboard users can scroll them.
|
|
68
|
+
for (const name of ['Next.js middleware snippet', 'Astro middleware snippet']) {
|
|
69
|
+
const pre = screen.getByLabelText(name)
|
|
70
|
+
expect(pre.tagName).toBe('PRE')
|
|
71
|
+
expect(pre.getAttribute('tabindex')).toBe('0')
|
|
72
|
+
}
|
|
73
|
+
// The kill-switch consequences are spelled out in the card copy.
|
|
74
|
+
const card = screen.getByRole('region', { name: 'AI visibility' })
|
|
75
|
+
expect(card.textContent ?? '').toMatch(/drops incoming beacons/i)
|
|
76
|
+
expect(card.textContent ?? '').toMatch(/AI visibility tab/i)
|
|
77
|
+
// This card takes no `onNavigate`, so it must not link anywhere.
|
|
78
|
+
expect(card.querySelector('a')).toBeNull()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('toggling PUTs { enabled: false }, reloads, and reflects the server state', async () => {
|
|
82
|
+
render(<SeoGeoSettingsCard canEdit />)
|
|
83
|
+
fireEvent.click(await screen.findByRole('switch', { name: SWITCH }))
|
|
84
|
+
await waitFor(() => expect(updateGeoSettings).toHaveBeenCalledWith({ enabled: false }))
|
|
85
|
+
await waitFor(() => expect(fetchGeoSettings).toHaveBeenCalledTimes(2))
|
|
86
|
+
await waitFor(() =>
|
|
87
|
+
expect(screen.getByRole('switch', { name: SWITCH }).getAttribute('aria-checked')).toBe(
|
|
88
|
+
'false',
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
expect(toastSuccess).toHaveBeenCalledWith('AI visibility measurement disabled')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('marks the card busy while a save is in flight and keeps the toggle focused', async () => {
|
|
95
|
+
const gate = deferred()
|
|
96
|
+
putGate = gate.promise
|
|
97
|
+
render(<SeoGeoSettingsCard canEdit />)
|
|
98
|
+
const toggle = await screen.findByRole('switch', { name: SWITCH })
|
|
99
|
+
const card = screen.getByRole('region', { name: 'AI visibility' })
|
|
100
|
+
toggle.focus()
|
|
101
|
+
fireEvent.click(toggle)
|
|
102
|
+
await waitFor(() => expect(card.getAttribute('aria-busy')).toBe('true'))
|
|
103
|
+
expect((toggle as HTMLButtonElement).disabled).toBe(false)
|
|
104
|
+
expect(document.activeElement).toBe(toggle)
|
|
105
|
+
gate.resolve()
|
|
106
|
+
await waitFor(() => expect(card.getAttribute('aria-busy')).toBeNull())
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('surfaces a failed toggle PUT as a toast and keeps the previous state', async () => {
|
|
110
|
+
updateGeoSettings.mockResolvedValueOnce({ ok: false, error: 'enabled must be a boolean' })
|
|
111
|
+
render(<SeoGeoSettingsCard canEdit />)
|
|
112
|
+
fireEvent.click(await screen.findByRole('switch', { name: SWITCH }))
|
|
113
|
+
await waitFor(() => expect(toastError).toHaveBeenCalledWith('enabled must be a boolean'))
|
|
114
|
+
expect(screen.getByRole('switch', { name: SWITCH }).getAttribute('aria-checked')).toBe('true')
|
|
115
|
+
expect(toastSuccess).not.toHaveBeenCalled()
|
|
116
|
+
expect(fetchGeoSettings).toHaveBeenCalledTimes(1)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('clamps retention to 7–365 and commits on blur', async () => {
|
|
120
|
+
render(<SeoGeoSettingsCard canEdit />)
|
|
121
|
+
const input = (await screen.findByLabelText(RETENTION)) as HTMLInputElement
|
|
122
|
+
fireEvent.change(input, { target: { value: '1000' } })
|
|
123
|
+
fireEvent.blur(input)
|
|
124
|
+
await waitFor(() => expect(updateGeoSettings).toHaveBeenCalledWith({ retentionDays: 365 }))
|
|
125
|
+
await waitFor(() => expect(input.value).toBe('365'))
|
|
126
|
+
expect(toastSuccess).toHaveBeenCalledWith('Retention set to 365 days')
|
|
127
|
+
|
|
128
|
+
fireEvent.change(input, { target: { value: '2' } })
|
|
129
|
+
fireEvent.blur(input)
|
|
130
|
+
await waitFor(() => expect(updateGeoSettings).toHaveBeenLastCalledWith({ retentionDays: 7 }))
|
|
131
|
+
await waitFor(() => expect(input.value).toBe('7'))
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('commits retention on Enter', async () => {
|
|
135
|
+
render(<SeoGeoSettingsCard canEdit />)
|
|
136
|
+
const input = (await screen.findByLabelText(RETENTION)) as HTMLInputElement
|
|
137
|
+
// Enter commits by blurring the (focused) input, so the blur path is the
|
|
138
|
+
// single commit point and a following real blur cannot double-PUT.
|
|
139
|
+
input.focus()
|
|
140
|
+
fireEvent.change(input, { target: { value: '30' } })
|
|
141
|
+
fireEvent.keyDown(input, { key: 'Enter' })
|
|
142
|
+
await waitFor(() => expect(updateGeoSettings).toHaveBeenCalledWith({ retentionDays: 30 }))
|
|
143
|
+
expect(updateGeoSettings).toHaveBeenCalledTimes(1)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('does not PUT when retention is blurred unchanged', async () => {
|
|
147
|
+
render(<SeoGeoSettingsCard canEdit />)
|
|
148
|
+
const input = (await screen.findByLabelText(RETENTION)) as HTMLInputElement
|
|
149
|
+
fireEvent.blur(input)
|
|
150
|
+
fireEvent.change(input, { target: { value: '90' } })
|
|
151
|
+
fireEvent.blur(input)
|
|
152
|
+
expect(updateGeoSettings).not.toHaveBeenCalled()
|
|
153
|
+
expect(input.value).toBe('90')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('shows an inline error for a non-numeric retention instead of a toast or a PUT', async () => {
|
|
157
|
+
render(<SeoGeoSettingsCard canEdit />)
|
|
158
|
+
const input = (await screen.findByLabelText(RETENTION)) as HTMLInputElement
|
|
159
|
+
fireEvent.change(input, { target: { value: '' } })
|
|
160
|
+
fireEvent.blur(input)
|
|
161
|
+
const error = await screen.findByText(/whole number of days/i)
|
|
162
|
+
expect(input.getAttribute('aria-invalid')).toBe('true')
|
|
163
|
+
expect(input.getAttribute('aria-describedby') ?? '').toContain(error.id)
|
|
164
|
+
expect(updateGeoSettings).not.toHaveBeenCalled()
|
|
165
|
+
expect(toastError).not.toHaveBeenCalled()
|
|
166
|
+
// Typing again clears the error; a valid commit then lands.
|
|
167
|
+
fireEvent.change(input, { target: { value: '14' } })
|
|
168
|
+
expect(input.getAttribute('aria-invalid')).toBeNull()
|
|
169
|
+
fireEvent.blur(input)
|
|
170
|
+
await waitFor(() => expect(updateGeoSettings).toHaveBeenCalledWith({ retentionDays: 14 }))
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('rejects a fractional retention with the inline error and no PUT', async () => {
|
|
174
|
+
render(<SeoGeoSettingsCard canEdit />)
|
|
175
|
+
const input = (await screen.findByLabelText(RETENTION)) as HTMLInputElement
|
|
176
|
+
fireEvent.change(input, { target: { value: '7.5' } })
|
|
177
|
+
fireEvent.blur(input)
|
|
178
|
+
await screen.findByText(/whole number of days/i)
|
|
179
|
+
expect(input.getAttribute('aria-invalid')).toBe('true')
|
|
180
|
+
expect(input.value).toBe('7.5')
|
|
181
|
+
expect(updateGeoSettings).not.toHaveBeenCalled()
|
|
182
|
+
expect(toastError).not.toHaveBeenCalled()
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('a failed second queued commit reverts to the first commit’s confirmed value', async () => {
|
|
186
|
+
// A: 90→30 is held in flight; B: →60 is blurred behind it and fails only
|
|
187
|
+
// after A's result has rendered. The revert must land on 30 (what the
|
|
188
|
+
// server holds), not the 90 that B's commit closed over.
|
|
189
|
+
const gateA = deferred()
|
|
190
|
+
const gateB = deferred()
|
|
191
|
+
updateGeoSettings.mockImplementation(async (patch) => {
|
|
192
|
+
if (updateGeoSettings.mock.calls.length === 1) {
|
|
193
|
+
await gateA.promise
|
|
194
|
+
current = { ...current, ...patch }
|
|
195
|
+
return { ok: true }
|
|
196
|
+
}
|
|
197
|
+
await gateB.promise
|
|
198
|
+
return { ok: false, error: 'nope' }
|
|
199
|
+
})
|
|
200
|
+
render(<SeoGeoSettingsCard canEdit />)
|
|
201
|
+
const input = (await screen.findByLabelText(RETENTION)) as HTMLInputElement
|
|
202
|
+
fireEvent.change(input, { target: { value: '30' } })
|
|
203
|
+
fireEvent.blur(input)
|
|
204
|
+
await waitFor(() => expect(updateGeoSettings).toHaveBeenCalledWith({ retentionDays: 30 }))
|
|
205
|
+
fireEvent.change(input, { target: { value: '60' } })
|
|
206
|
+
fireEvent.blur(input)
|
|
207
|
+
expect(updateGeoSettings).toHaveBeenCalledTimes(1)
|
|
208
|
+
|
|
209
|
+
gateA.resolve()
|
|
210
|
+
await waitFor(() => expect(toastSuccess).toHaveBeenCalledWith('Retention set to 30 days'))
|
|
211
|
+
await waitFor(() => expect(input.value).toBe('30'))
|
|
212
|
+
await waitFor(() => expect(updateGeoSettings).toHaveBeenCalledWith({ retentionDays: 60 }))
|
|
213
|
+
|
|
214
|
+
gateB.resolve()
|
|
215
|
+
await waitFor(() => expect(toastError).toHaveBeenCalledWith('nope'))
|
|
216
|
+
// Let the failed commit's revert (a microtask after the toast) render
|
|
217
|
+
// before asserting; a stale-closure revert would now show "90".
|
|
218
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
219
|
+
expect(input.value).toBe('30')
|
|
220
|
+
expect(toastSuccess).toHaveBeenCalledTimes(1)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('reverts retention to the last saved value when its PUT fails', async () => {
|
|
224
|
+
updateGeoSettings.mockResolvedValueOnce({ ok: false, error: 'nope' })
|
|
225
|
+
render(<SeoGeoSettingsCard canEdit />)
|
|
226
|
+
const input = (await screen.findByLabelText(RETENTION)) as HTMLInputElement
|
|
227
|
+
fireEvent.change(input, { target: { value: '1000' } })
|
|
228
|
+
fireEvent.blur(input)
|
|
229
|
+
await waitFor(() => expect(updateGeoSettings).toHaveBeenCalledWith({ retentionDays: 365 }))
|
|
230
|
+
await waitFor(() => expect(toastError).toHaveBeenCalledWith('nope'))
|
|
231
|
+
await waitFor(() => expect(input.value).toBe('90'))
|
|
232
|
+
expect(toastSuccess).not.toHaveBeenCalled()
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('disables controls without edit rights', async () => {
|
|
236
|
+
render(<SeoGeoSettingsCard canEdit={false} />)
|
|
237
|
+
const toggle = (await screen.findByRole('switch', { name: SWITCH })) as HTMLButtonElement
|
|
238
|
+
expect(toggle.disabled).toBe(true)
|
|
239
|
+
expect((screen.getByLabelText(RETENTION) as HTMLInputElement).disabled).toBe(true)
|
|
240
|
+
// Read-only users still see the setup snippets.
|
|
241
|
+
expect(screen.getByText(/observeGeoTraffic\(request/)).toBeTruthy()
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('shows an error state with Retry when settings fail to load', async () => {
|
|
245
|
+
fetchGeoSettings.mockResolvedValueOnce(null)
|
|
246
|
+
render(<SeoGeoSettingsCard canEdit />)
|
|
247
|
+
const retry = await screen.findByRole('button', { name: /retry/i })
|
|
248
|
+
expect(screen.getByRole('alert').textContent ?? '').toMatch(/AI visibility settings/i)
|
|
249
|
+
fireEvent.click(retry)
|
|
250
|
+
await screen.findByRole('switch', { name: SWITCH })
|
|
251
|
+
expect(fetchGeoSettings).toHaveBeenCalledTimes(2)
|
|
252
|
+
})
|
|
253
|
+
})
|
|
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
3
3
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
4
4
|
|
|
5
5
|
import type {
|
|
6
|
+
SeoAutoApplyList,
|
|
6
7
|
SeoProposal,
|
|
7
8
|
SeoProposalBulkApplyResult,
|
|
8
9
|
SeoProposalsPayload,
|
|
@@ -57,11 +58,24 @@ function payload(over: Partial<SeoProposalsPayload> = {}): SeoProposalsPayload {
|
|
|
57
58
|
],
|
|
58
59
|
counts: { issueFixes: 2, redirects: 1, stale: 0, eligibleForGeneration: 5 },
|
|
59
60
|
governance: { issueFixBulk: true, redirectBulk: true },
|
|
61
|
+
autopilot: { enabled: false, pausedReason: null, lastRunAt: null, appliedLast7d: 0 },
|
|
60
62
|
...over,
|
|
61
63
|
}
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
const fetchSeoProposals = vi.fn(async () => payload())
|
|
67
|
+
const fetchAutopilotApplied = vi.fn(
|
|
68
|
+
async (_params: unknown): Promise<SeoAutoApplyList> => ({
|
|
69
|
+
docs: [],
|
|
70
|
+
total: 0,
|
|
71
|
+
page: 1,
|
|
72
|
+
pageSize: 20,
|
|
73
|
+
canRevert: false,
|
|
74
|
+
}),
|
|
75
|
+
)
|
|
76
|
+
const revertAutopilotChange = vi.fn(async (_id: string, _force?: boolean) => ({
|
|
77
|
+
ok: true as const,
|
|
78
|
+
}))
|
|
65
79
|
const generateSeoProposals = vi.fn(async () => ({
|
|
66
80
|
result: { generated: 2, skipped: 0, failed: 0, remaining: 0, haltedReason: null },
|
|
67
81
|
}))
|
|
@@ -79,6 +93,8 @@ vi.mock('../../lib/seo-service.js', () => ({
|
|
|
79
93
|
bulkApplySeoProposals: (items: unknown) => bulkApplySeoProposals(items),
|
|
80
94
|
dismissSeoIssueFix: (id: string) => dismissSeoIssueFix(id),
|
|
81
95
|
dismissRedirectSuggestion: (id: string) => dismissRedirectSuggestion(id),
|
|
96
|
+
fetchAutopilotApplied: (params: unknown) => fetchAutopilotApplied(params),
|
|
97
|
+
revertAutopilotChange: (id: string, force?: boolean) => revertAutopilotChange(id, force),
|
|
82
98
|
}))
|
|
83
99
|
vi.mock('sonner', () => ({
|
|
84
100
|
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), loading: vi.fn() },
|
|
@@ -109,6 +125,8 @@ beforeEach(() => {
|
|
|
109
125
|
bulkApplySeoProposals.mockClear()
|
|
110
126
|
dismissSeoIssueFix.mockClear()
|
|
111
127
|
dismissRedirectSuggestion.mockClear()
|
|
128
|
+
fetchAutopilotApplied.mockClear()
|
|
129
|
+
revertAutopilotChange.mockClear()
|
|
112
130
|
vi.mocked(toast.success).mockClear()
|
|
113
131
|
vi.mocked(toast.error).mockClear()
|
|
114
132
|
})
|
|
@@ -361,6 +379,149 @@ describe('ProposalsTab', () => {
|
|
|
361
379
|
)
|
|
362
380
|
})
|
|
363
381
|
|
|
382
|
+
it('renders the applied-by-autopilot section under the proposals card', async () => {
|
|
383
|
+
render(<ProposalsTab planInfo={null} />)
|
|
384
|
+
await screen.findByText('Duplicate meta title')
|
|
385
|
+
expect(await screen.findByText('Applied by autopilot')).toBeTruthy()
|
|
386
|
+
await waitFor(() => expect(fetchAutopilotApplied).toHaveBeenCalledTimes(1))
|
|
387
|
+
expect(await screen.findByText('Autopilot has not applied anything yet')).toBeTruthy()
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
it('shows no autopilot chip while autopilot is disabled', async () => {
|
|
391
|
+
render(<ProposalsTab planInfo={null} />)
|
|
392
|
+
await screen.findByText('Duplicate meta title')
|
|
393
|
+
expect(screen.queryByRole('button', { name: /autopilot (on|paused)/i })).toBeNull()
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
it('shows an "Autopilot on" chip with the weekly count that opens SEO settings', async () => {
|
|
397
|
+
fetchSeoProposals.mockImplementationOnce(async () =>
|
|
398
|
+
payload({
|
|
399
|
+
autopilot: {
|
|
400
|
+
enabled: true,
|
|
401
|
+
pausedReason: null,
|
|
402
|
+
lastRunAt: '2026-09-07T03:00:00.000Z',
|
|
403
|
+
appliedLast7d: 4,
|
|
404
|
+
},
|
|
405
|
+
}),
|
|
406
|
+
)
|
|
407
|
+
const onNavigate = vi.fn()
|
|
408
|
+
render(<ProposalsTab planInfo={null} onNavigate={onNavigate} />)
|
|
409
|
+
await screen.findByText('Duplicate meta title')
|
|
410
|
+
const chip = screen.getByRole('button', { name: 'Autopilot on · 4 applied this week' })
|
|
411
|
+
fireEvent.click(chip)
|
|
412
|
+
expect(onNavigate).toHaveBeenCalledWith('/settings?tab=seo')
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it('shows a paused chip with a short reason when autopilot is paused', async () => {
|
|
416
|
+
fetchSeoProposals.mockImplementationOnce(async () =>
|
|
417
|
+
payload({
|
|
418
|
+
autopilot: {
|
|
419
|
+
enabled: true,
|
|
420
|
+
pausedReason: 'outcome_regression',
|
|
421
|
+
lastRunAt: null,
|
|
422
|
+
appliedLast7d: 1,
|
|
423
|
+
},
|
|
424
|
+
}),
|
|
425
|
+
)
|
|
426
|
+
render(<ProposalsTab planInfo={null} />)
|
|
427
|
+
await screen.findByText('Duplicate meta title')
|
|
428
|
+
expect(
|
|
429
|
+
screen.getByRole('button', { name: 'Autopilot paused — traffic regression' }),
|
|
430
|
+
).toBeTruthy()
|
|
431
|
+
expect(screen.queryByRole('button', { name: /applied this week/ })).toBeNull()
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
it.each([
|
|
435
|
+
['owner_unavailable', 'Autopilot paused — owner unavailable'],
|
|
436
|
+
['manual', 'Autopilot paused — paused by admin'],
|
|
437
|
+
['some_future_reason', 'Autopilot paused — some_future_reason'],
|
|
438
|
+
])('labels the %s pause reason as "%s"', async (pausedReason, name) => {
|
|
439
|
+
fetchSeoProposals.mockImplementationOnce(async () =>
|
|
440
|
+
payload({ autopilot: { enabled: true, pausedReason, lastRunAt: null, appliedLast7d: 0 } }),
|
|
441
|
+
)
|
|
442
|
+
render(<ProposalsTab planInfo={null} />)
|
|
443
|
+
await screen.findByText('Duplicate meta title')
|
|
444
|
+
expect(screen.getByRole('button', { name })).toBeTruthy()
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it('shows no chip when autopilot was disabled while still paused', async () => {
|
|
448
|
+
// `PUT { enabled: false }` does not clear the pause; a disabled engine is
|
|
449
|
+
// simply off, not "paused".
|
|
450
|
+
fetchSeoProposals.mockImplementationOnce(async () =>
|
|
451
|
+
payload({
|
|
452
|
+
autopilot: { enabled: false, pausedReason: 'manual', lastRunAt: null, appliedLast7d: 0 },
|
|
453
|
+
}),
|
|
454
|
+
)
|
|
455
|
+
render(<ProposalsTab planInfo={null} />)
|
|
456
|
+
await screen.findByText('Duplicate meta title')
|
|
457
|
+
expect(screen.queryByRole('button', { name: /autopilot/i })).toBeNull()
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
it('shows no chip and does not throw when the payload predates autopilot', async () => {
|
|
461
|
+
fetchSeoProposals.mockImplementationOnce(async () => {
|
|
462
|
+
// A `@prenta/core` deployed before autopilot shipped omits the field entirely.
|
|
463
|
+
const legacy: Partial<SeoProposalsPayload> = payload()
|
|
464
|
+
delete legacy.autopilot
|
|
465
|
+
return legacy as SeoProposalsPayload
|
|
466
|
+
})
|
|
467
|
+
render(<ProposalsTab planInfo={null} />)
|
|
468
|
+
expect(await screen.findByText('Duplicate meta title')).toBeTruthy()
|
|
469
|
+
expect(screen.queryByRole('button', { name: /autopilot (on|paused)/i })).toBeNull()
|
|
470
|
+
expect(screen.getByRole('button', { name: 'Generate proposals (5 eligible)' })).toBeTruthy()
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
it('refreshes the applied section after a proposal is approved', async () => {
|
|
474
|
+
render(<ProposalsTab planInfo={null} />)
|
|
475
|
+
await screen.findByText('Duplicate meta title')
|
|
476
|
+
await waitFor(() => expect(fetchAutopilotApplied).toHaveBeenCalledTimes(1))
|
|
477
|
+
fireEvent.click(screen.getByRole('button', { name: 'Approve Duplicate meta title' }))
|
|
478
|
+
await waitFor(() => expect(bulkApplySeoProposals).toHaveBeenCalledTimes(1))
|
|
479
|
+
await waitFor(() => expect(fetchAutopilotApplied).toHaveBeenCalledTimes(2))
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
it('refetches the inbox and notifies the parent after an autopilot change is reverted', async () => {
|
|
483
|
+
fetchAutopilotApplied.mockImplementation(async () => ({
|
|
484
|
+
docs: [
|
|
485
|
+
{
|
|
486
|
+
id: 'la-1',
|
|
487
|
+
kind: 'issue-fix',
|
|
488
|
+
issueType: 'missing-meta-title',
|
|
489
|
+
entityId: 'd1',
|
|
490
|
+
collection: 'pages',
|
|
491
|
+
entityTitle: 'Pricing',
|
|
492
|
+
entityUrl: '/pricing',
|
|
493
|
+
path: '/pricing',
|
|
494
|
+
before: { metaTitle: null },
|
|
495
|
+
after: { metaTitle: 'Pricing for growing teams' },
|
|
496
|
+
gate: { source: 'ai', brandAlignment: 92, confidence: null, checks: {} },
|
|
497
|
+
status: 'applied',
|
|
498
|
+
outcome: 'pending',
|
|
499
|
+
outcomeMetrics: null,
|
|
500
|
+
appliedAt: new Date().toISOString(),
|
|
501
|
+
revertedAt: null,
|
|
502
|
+
revertReason: null,
|
|
503
|
+
},
|
|
504
|
+
],
|
|
505
|
+
total: 1,
|
|
506
|
+
page: 1,
|
|
507
|
+
pageSize: 20,
|
|
508
|
+
canRevert: true,
|
|
509
|
+
}))
|
|
510
|
+
const onChanged = vi.fn()
|
|
511
|
+
render(<ProposalsTab planInfo={null} onChanged={onChanged} />)
|
|
512
|
+
await screen.findByText('Duplicate meta title')
|
|
513
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Revert change to Pricing' }))
|
|
514
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Revert change' }))
|
|
515
|
+
await waitFor(() => expect(revertAutopilotChange).toHaveBeenCalledWith('la-1', false))
|
|
516
|
+
// The revert reopened the issue server-side: the inbox and the SEO page must both refresh.
|
|
517
|
+
await waitFor(() => expect(fetchSeoProposals).toHaveBeenCalledTimes(2))
|
|
518
|
+
await waitFor(() => expect(onChanged).toHaveBeenCalledTimes(1))
|
|
519
|
+
// The ledger reloads itself once; the parent must not bump `refreshKey` on top.
|
|
520
|
+
await waitFor(() => expect(fetchAutopilotApplied).toHaveBeenCalledTimes(2))
|
|
521
|
+
await flushMicrotasks()
|
|
522
|
+
expect(fetchAutopilotApplied).toHaveBeenCalledTimes(2)
|
|
523
|
+
})
|
|
524
|
+
|
|
364
525
|
it('hides Generate behind the plan callout when seo.inlineApply is off', async () => {
|
|
365
526
|
render(<ProposalsTab planInfo={{ tier: 'starter', features: [], upgradeUrl: null }} />)
|
|
366
527
|
await screen.findByText('Duplicate meta title')
|
|
@@ -2,10 +2,12 @@
|
|
|
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
|
|
5
|
+
// The SEO defaults tab reads eight shared backends: /seo/config (meta defaults +
|
|
6
6
|
// verification), /seo/crawl-settings (sitemap toggles), /seo/sitemap/status
|
|
7
7
|
// (read-only summary), /seo/index-status (environment), /seo/search-console
|
|
8
|
-
// (Search data card),
|
|
8
|
+
// (Search data card), /seo/geo-settings (AI visibility card),
|
|
9
|
+
// /seo/autopilot/settings (SEO Autopilot card), and /seo/digest-settings
|
|
10
|
+
// (Weekly SEO digest card).
|
|
9
11
|
// `defaultApi` is the per-path route map; individual tests override the mock
|
|
10
12
|
// implementation and delegate back to it for unrelated paths.
|
|
11
13
|
type MockApiResponse = { data?: unknown; error?: string; status: number }
|
|
@@ -74,12 +76,37 @@ async function defaultApi(
|
|
|
74
76
|
status: 200,
|
|
75
77
|
}
|
|
76
78
|
}
|
|
79
|
+
if (endpoint === '/seo/geo-settings' && method === 'GET') {
|
|
80
|
+
return { data: { enabled: true, retentionDays: 90 }, status: 200 }
|
|
81
|
+
}
|
|
77
82
|
if (endpoint === '/seo/digest-settings' && method === 'GET') {
|
|
78
83
|
return {
|
|
79
84
|
data: { enabled: false, recipients: [], lastSentAt: null, lastError: null },
|
|
80
85
|
status: 200,
|
|
81
86
|
}
|
|
82
87
|
}
|
|
88
|
+
if (endpoint === '/seo/autopilot/settings' && method === 'GET') {
|
|
89
|
+
return {
|
|
90
|
+
data: {
|
|
91
|
+
enabled: false,
|
|
92
|
+
enabledById: null,
|
|
93
|
+
pausedAt: null,
|
|
94
|
+
pausedReason: null,
|
|
95
|
+
types: {},
|
|
96
|
+
pausedTypes: {},
|
|
97
|
+
minBrandAlignment: 85,
|
|
98
|
+
redirectMinConfidence: 85,
|
|
99
|
+
maxPerRun: 5,
|
|
100
|
+
maxPerDay: 20,
|
|
101
|
+
verifyFailuresPerRun: 2,
|
|
102
|
+
outcomeWatch: { enabled: true, days: 14, dropRatio: 0.4, minPriorClicks: 50 },
|
|
103
|
+
lastRunAt: null,
|
|
104
|
+
lastRun: null,
|
|
105
|
+
governance: { issueFixApprovalRequired: false, redirectApprovalRequired: false },
|
|
106
|
+
},
|
|
107
|
+
status: 200,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
83
110
|
return { data: {}, status: 200 }
|
|
84
111
|
}
|
|
85
112
|
|
|
@@ -98,6 +125,7 @@ vi.mock('sonner', () => ({
|
|
|
98
125
|
success: (...args: unknown[]) => toastSuccess(...args),
|
|
99
126
|
error: (...args: unknown[]) => toastError(...args),
|
|
100
127
|
warning: vi.fn(),
|
|
128
|
+
info: vi.fn(),
|
|
101
129
|
loading: vi.fn(),
|
|
102
130
|
},
|
|
103
131
|
}))
|
|
@@ -331,6 +359,40 @@ describe('SeoSettingsTab', () => {
|
|
|
331
359
|
expect(body.serviceAccountKey).toContain('svc@acme.iam.gserviceaccount.com')
|
|
332
360
|
})
|
|
333
361
|
|
|
362
|
+
it('renders Search data, AI visibility, SEO Autopilot, and the digest in that order', async () => {
|
|
363
|
+
render(<SeoSettingsTab role="ADMIN" />)
|
|
364
|
+
// Titles also render in the cards' skeleton states; wait for controls
|
|
365
|
+
// that only exist once each card's settings have loaded.
|
|
366
|
+
const toggle = await screen.findByRole('switch', { name: 'Enable autopilot' })
|
|
367
|
+
expect(toggle.getAttribute('aria-checked')).toBe('false')
|
|
368
|
+
const geoToggle = await screen.findByRole('switch', {
|
|
369
|
+
name: 'Record AI crawler and referral activity',
|
|
370
|
+
})
|
|
371
|
+
expect(geoToggle.getAttribute('aria-checked')).toBe('true')
|
|
372
|
+
const regions = screen
|
|
373
|
+
.getAllByRole('region')
|
|
374
|
+
.map((r) => r.getAttribute('aria-labelledby'))
|
|
375
|
+
.map((id) => (id ? document.getElementById(id)?.textContent : null))
|
|
376
|
+
const order = ['Search data', 'AI visibility', 'SEO Autopilot', 'Weekly SEO digest'].map((t) =>
|
|
377
|
+
regions.indexOf(t),
|
|
378
|
+
)
|
|
379
|
+
expect(order.every((i) => i >= 0)).toBe(true)
|
|
380
|
+
expect(order).toEqual([...order].sort((a, b) => a - b))
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it('PUTs the AI visibility kill switch to /seo/geo-settings', async () => {
|
|
384
|
+
render(<SeoSettingsTab role="ADMIN" />)
|
|
385
|
+
fireEvent.click(
|
|
386
|
+
await screen.findByRole('switch', { name: 'Record AI crawler and referral activity' }),
|
|
387
|
+
)
|
|
388
|
+
await waitFor(() =>
|
|
389
|
+
expect(cmsApi).toHaveBeenCalledWith(
|
|
390
|
+
'/seo/geo-settings',
|
|
391
|
+
expect.objectContaining({ method: 'PUT', body: JSON.stringify({ enabled: false }) }),
|
|
392
|
+
),
|
|
393
|
+
)
|
|
394
|
+
})
|
|
395
|
+
|
|
334
396
|
it('renders the Weekly SEO digest card with the toggle off by default', async () => {
|
|
335
397
|
render(<SeoSettingsTab role="ADMIN" />)
|
|
336
398
|
expect(await screen.findByText('Weekly SEO digest')).toBeTruthy()
|
|
@@ -342,12 +404,13 @@ describe('SeoSettingsTab', () => {
|
|
|
342
404
|
|
|
343
405
|
it('saves recipients as a trimmed, comma-split list', async () => {
|
|
344
406
|
render(<SeoSettingsTab role="ADMIN" />)
|
|
345
|
-
|
|
407
|
+
// 'Weekly SEO digest' is also the loading-state title; wait for the form.
|
|
408
|
+
const save = await screen.findByRole('button', { name: 'Save recipients' })
|
|
346
409
|
|
|
347
410
|
fireEvent.change(screen.getByLabelText('Recipients'), {
|
|
348
411
|
target: { value: ' a@x.com , b@x.com ' },
|
|
349
412
|
})
|
|
350
|
-
fireEvent.click(
|
|
413
|
+
fireEvent.click(save)
|
|
351
414
|
|
|
352
415
|
await waitFor(() =>
|
|
353
416
|
expect(cmsApi).toHaveBeenCalledWith(
|
|
@@ -370,9 +433,7 @@ describe('SeoSettingsTab', () => {
|
|
|
370
433
|
return defaultApi(endpoint, options)
|
|
371
434
|
})
|
|
372
435
|
render(<SeoSettingsTab role="ADMIN" />)
|
|
373
|
-
await screen.
|
|
374
|
-
|
|
375
|
-
fireEvent.click(screen.getByRole('button', { name: /Send test digest/ }))
|
|
436
|
+
fireEvent.click(await screen.findByRole('button', { name: /Send test digest/ }))
|
|
376
437
|
|
|
377
438
|
await waitFor(() =>
|
|
378
439
|
expect(toastError).toHaveBeenCalledWith('No completed SEO audit yet — run an audit first'),
|
|
@@ -78,6 +78,21 @@ beforeEach(() => {
|
|
|
78
78
|
pendingPut = null
|
|
79
79
|
})
|
|
80
80
|
|
|
81
|
+
describe('TechnicalTab AI crawler card', () => {
|
|
82
|
+
it('links to the AI visibility tab via onNavigate', async () => {
|
|
83
|
+
const onNavigate = vi.fn()
|
|
84
|
+
render(<TechnicalTab onNavigate={onNavigate} />)
|
|
85
|
+
fireEvent.click(await screen.findByRole('button', { name: /view trends/i }))
|
|
86
|
+
expect(onNavigate).toHaveBeenCalledWith('/seo/ai-visibility')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('omits the trends link when the host gives no navigator', async () => {
|
|
90
|
+
render(<TechnicalTab />)
|
|
91
|
+
await screen.findByRole('switch', { name: /Audit rendered public HTML/i })
|
|
92
|
+
expect(screen.queryByRole('button', { name: /view trends/i })).toBeNull()
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
81
96
|
describe('TechnicalTab rendered audit setting', () => {
|
|
82
97
|
it('renders the accessible setting and persists changes', async () => {
|
|
83
98
|
render(<TechnicalTab />)
|
package/src/lib/api.ts
CHANGED
|
@@ -62,10 +62,27 @@ function appendLocaleParam(endpoint: string): string {
|
|
|
62
62
|
return `${endpoint}${separator}locale=${encodeURIComponent(locale)}`
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
export interface CmsApiResult<T> {
|
|
66
|
+
data?: T
|
|
67
|
+
error?: string
|
|
68
|
+
/**
|
|
69
|
+
* Machine-readable error discriminator (e.g. IP_LOCKOUT_RISK, conflict) set
|
|
70
|
+
* by guard responses so callers can branch without parsing the message.
|
|
71
|
+
*/
|
|
72
|
+
code?: string
|
|
73
|
+
/**
|
|
74
|
+
* The parsed non-2xx body, for the few endpoints that return structured
|
|
75
|
+
* detail beyond `error` / `code` (e.g. the 409 `fields` list from
|
|
76
|
+
* `/seo/autopilot/applied/:id/revert`). Callers must narrow it themselves.
|
|
77
|
+
*/
|
|
78
|
+
errorBody?: unknown
|
|
79
|
+
status: number
|
|
80
|
+
}
|
|
81
|
+
|
|
65
82
|
export async function cmsApi<T = unknown>(
|
|
66
83
|
endpoint: string,
|
|
67
84
|
options: RequestInit = {},
|
|
68
|
-
): Promise<
|
|
85
|
+
): Promise<CmsApiResult<T>> {
|
|
69
86
|
const url = `${basePath}${appendLocaleParam(endpoint)}`
|
|
70
87
|
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData
|
|
71
88
|
|
|
@@ -98,9 +115,8 @@ export async function cmsApi<T = unknown>(
|
|
|
98
115
|
if (!res.ok) {
|
|
99
116
|
return {
|
|
100
117
|
error: json.error || `Request failed (${res.status})`,
|
|
101
|
-
// Machine-readable error discriminator (e.g. IP_LOCKOUT_RISK) set by
|
|
102
|
-
// guard responses so callers can branch without parsing the message.
|
|
103
118
|
code: typeof json.code === 'string' ? json.code : undefined,
|
|
119
|
+
errorBody: json,
|
|
104
120
|
status: res.status,
|
|
105
121
|
}
|
|
106
122
|
}
|