@actuate-media/cms-admin 0.20.0 → 0.21.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/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +4 -4
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/layout/sidebar-posts.test.js +6 -4
- package/dist/__tests__/layout/sidebar-posts.test.js.map +1 -1
- package/dist/__tests__/lib/seo-service-errors.test.d.ts +2 -0
- package/dist/__tests__/lib/seo-service-errors.test.d.ts.map +1 -0
- package/dist/__tests__/lib/seo-service-errors.test.js +46 -0
- package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -0
- package/dist/__tests__/views/use-seo-resource.test.d.ts +2 -0
- package/dist/__tests__/views/use-seo-resource.test.d.ts.map +1 -0
- package/dist/__tests__/views/use-seo-resource.test.js +56 -0
- package/dist/__tests__/views/use-seo-resource.test.js.map +1 -0
- package/dist/components/Breadcrumbs.d.ts.map +1 -1
- package/dist/components/Breadcrumbs.js +4 -1
- package/dist/components/Breadcrumbs.js.map +1 -1
- package/dist/components/seo/Drawer.d.ts.map +1 -1
- package/dist/components/seo/Drawer.js +5 -1
- package/dist/components/seo/Drawer.js.map +1 -1
- package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -1
- package/dist/components/seo/SeoEditorDrawer.js +11 -2
- package/dist/components/seo/SeoEditorDrawer.js.map +1 -1
- package/dist/components/seo/primitives.d.ts.map +1 -1
- package/dist/components/seo/primitives.js +2 -1
- package/dist/components/seo/primitives.js.map +1 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +5 -5
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +40 -4
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/ApiKeys.d.ts +4 -1
- package/dist/views/ApiKeys.d.ts.map +1 -1
- package/dist/views/ApiKeys.js +5 -3
- package/dist/views/ApiKeys.js.map +1 -1
- package/dist/views/Dashboard.js +2 -2
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/Settings.d.ts +4 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +45 -6
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/Users.d.ts +4 -1
- package/dist/views/Users.d.ts.map +1 -1
- package/dist/views/Users.js +5 -3
- package/dist/views/Users.js.map +1 -1
- package/dist/views/seo/AuditTab.js +2 -2
- package/dist/views/seo/AuditTab.js.map +1 -1
- package/dist/views/seo/ContentTab.js +1 -1
- package/dist/views/seo/ContentTab.js.map +1 -1
- package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
- package/dist/views/seo/RedirectsTab.js +8 -2
- package/dist/views/seo/RedirectsTab.js.map +1 -1
- package/dist/views/seo/TechnicalTab.d.ts.map +1 -1
- package/dist/views/seo/TechnicalTab.js +7 -4
- package/dist/views/seo/TechnicalTab.js.map +1 -1
- package/dist/views/seo/useSeoResource.d.ts.map +1 -1
- package/dist/views/seo/useSeoResource.js +20 -7
- package/dist/views/seo/useSeoResource.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +4 -4
- package/src/__tests__/layout/sidebar-posts.test.ts +6 -4
- package/src/__tests__/lib/seo-service-errors.test.ts +60 -0
- package/src/__tests__/views/use-seo-resource.test.tsx +68 -0
- package/src/components/Breadcrumbs.tsx +4 -1
- package/src/components/seo/Drawer.tsx +4 -0
- package/src/components/seo/SeoEditorDrawer.tsx +15 -3
- package/src/components/seo/primitives.tsx +4 -1
- package/src/layout/Sidebar.tsx +4 -10
- package/src/lib/seo-service.ts +41 -4
- package/src/views/ApiKeys.tsx +10 -5
- package/src/views/Dashboard.tsx +2 -2
- package/src/views/Settings.tsx +93 -40
- package/src/views/Users.tsx +10 -4
- package/src/views/seo/AuditTab.tsx +6 -6
- package/src/views/seo/ContentTab.tsx +3 -3
- package/src/views/seo/RedirectsTab.tsx +8 -2
- package/src/views/seo/TechnicalTab.tsx +25 -13
- package/src/views/seo/useSeoResource.ts +21 -7
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useEffect, useState } from 'react';
|
|
3
|
+
import { useEffect, useId, useState } from 'react';
|
|
4
4
|
import { RefreshCw, Map, AlertTriangle, CheckCircle2, ExternalLink } from 'lucide-react';
|
|
5
|
+
import * as Switch from '@radix-ui/react-switch';
|
|
5
6
|
import { toast } from 'sonner';
|
|
6
7
|
import { fetchSitemapStatus, regenerateSitemap, fetchCrawlSettings, updateCrawlSettings, fetchRobotsTxt, updateRobotsTxt, validateRobotsTxt, } from '../../lib/seo-service.js';
|
|
7
8
|
import { SectionCard, SeoLoading, SeoErrorState, btnPrimary, btnSecondary, } from '../../components/seo/primitives.js';
|
|
@@ -33,8 +34,10 @@ const CRAWL_TOGGLES = [
|
|
|
33
34
|
help: 'Keep thin tag archives out of the index.',
|
|
34
35
|
},
|
|
35
36
|
];
|
|
36
|
-
function Toggle({ checked, onChange, label, help, }) {
|
|
37
|
-
|
|
37
|
+
function Toggle({ checked, onChange, label, help, disabled, }) {
|
|
38
|
+
const labelId = useId();
|
|
39
|
+
const helpId = useId();
|
|
40
|
+
return (_jsxs("div", { className: "flex items-center justify-between gap-3 py-2.5", children: [_jsxs("span", { className: "min-w-0", children: [_jsx("span", { id: labelId, className: "text-foreground block text-sm font-medium", children: label }), _jsx("span", { id: helpId, className: "text-muted-foreground block text-sm", children: help })] }), _jsx(Switch.Root, { checked: checked, onCheckedChange: onChange, disabled: disabled, "aria-labelledby": labelId, "aria-describedby": helpId, className: "bg-input-background data-[state=checked]:bg-primary focus-visible:ring-ring relative h-[18px] w-8 shrink-0 rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50", children: _jsx(Switch.Thumb, { className: "bg-background block h-3.5 w-3.5 translate-x-0.5 rounded-full shadow-sm transition-transform data-[state=checked]:translate-x-[14px]" }) })] }));
|
|
38
41
|
}
|
|
39
42
|
function SitemapCard() {
|
|
40
43
|
const { data, loading, error, refetch } = useSeoResource(fetchSitemapStatus, []);
|
|
@@ -70,7 +73,7 @@ function CrawlCard() {
|
|
|
70
73
|
refetch();
|
|
71
74
|
}
|
|
72
75
|
}
|
|
73
|
-
return (_jsx(SectionCard, { title: "Crawl Settings", children: loading ? (_jsx(SeoLoading, { label: "Loading crawl settings\u2026" })) : error ? (_jsx(SeoErrorState, { message: error, onRetry: refetch })) : !settings ? (_jsx("p", { className: "text-muted-foreground text-sm", children: "Crawl settings unavailable." })) : (_jsx("div", { className: "divide-border divide-y", "aria-busy": saving, children: CRAWL_TOGGLES.map((t) => (_jsx(Toggle, { label: t.label, help: t.help, checked: settings[t.key], onChange: (v) => save({ ...settings, [t.key]: v }, t.key) }, t.key))) })) }));
|
|
76
|
+
return (_jsx(SectionCard, { title: "Crawl Settings", children: loading ? (_jsx(SeoLoading, { label: "Loading crawl settings\u2026" })) : error ? (_jsx(SeoErrorState, { message: error, onRetry: refetch })) : !settings ? (_jsx("p", { className: "text-muted-foreground text-sm", children: "Crawl settings unavailable." })) : (_jsx("div", { className: "divide-border divide-y", "aria-busy": saving, children: CRAWL_TOGGLES.map((t) => (_jsx(Toggle, { label: t.label, help: t.help, checked: settings[t.key], disabled: saving, onChange: (v) => save({ ...settings, [t.key]: v }, t.key) }, t.key))) })) }));
|
|
74
77
|
}
|
|
75
78
|
function RobotsCard() {
|
|
76
79
|
const { data, loading, error, refetch } = useSeoResource(fetchRobotsTxt, []);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TechnicalTab.js","sourceRoot":"","sources":["../../../src/views/seo/TechnicalTab.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAA;;AAEZ,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"TechnicalTab.js","sourceRoot":"","sources":["../../../src/views/seo/TechnicalTab.tsx"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useSeoResource.d.ts","sourceRoot":"","sources":["../../../src/views/seo/useSeoResource.ts"],"names":[],"mappings":"AASA,MAAM,WAAW,WAAW,CAAC,CAAC;IAC5B,IAAI,EAAE,CAAC,GAAG,IAAI,CAAA;IACd,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,OAAO,EAAE,MAAM,IAAI,CAAA;CACpB;AAED,wBAAgB,cAAc,CAAC,CAAC,EAC9B,OAAO,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACzB,IAAI,GAAE,aAAa,CAAC,OAAO,CAAM,GAChC,WAAW,CAAC,CAAC,CAAC,
|
|
1
|
+
{"version":3,"file":"useSeoResource.d.ts","sourceRoot":"","sources":["../../../src/views/seo/useSeoResource.ts"],"names":[],"mappings":"AASA,MAAM,WAAW,WAAW,CAAC,CAAC;IAC5B,IAAI,EAAE,CAAC,GAAG,IAAI,CAAA;IACd,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,OAAO,EAAE,MAAM,IAAI,CAAA;CACpB;AAED,wBAAgB,cAAc,CAAC,CAAC,EAC9B,OAAO,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACzB,IAAI,GAAE,aAAa,CAAC,OAAO,CAAM,GAChC,WAAW,CAAC,CAAC,CAAC,CA8ChB"}
|
|
@@ -9,33 +9,46 @@ export function useSeoResource(fetcher, deps = []) {
|
|
|
9
9
|
const [data, setData] = useState(null);
|
|
10
10
|
const [loading, setLoading] = useState(true);
|
|
11
11
|
const [error, setError] = useState(null);
|
|
12
|
-
|
|
12
|
+
// Tracks whether the component is still mounted (guards setState after
|
|
13
|
+
// unmount). Set once on mount; never toggled by dependency-driven reloads.
|
|
14
|
+
const mountedRef = useRef(true);
|
|
15
|
+
// Monotonic id of the most recently *started* request. A response is only
|
|
16
|
+
// applied when its id still matches — otherwise a slow earlier request that
|
|
17
|
+
// resolves after a faster later one (rapid filter switches) would clobber
|
|
18
|
+
// the newer, correct data.
|
|
19
|
+
const requestIdRef = useRef(0);
|
|
13
20
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
14
21
|
const stableFetcher = useCallback(fetcher, deps);
|
|
15
22
|
const load = useCallback(async () => {
|
|
23
|
+
const requestId = ++requestIdRef.current;
|
|
24
|
+
const isCurrent = () => mountedRef.current && requestId === requestIdRef.current;
|
|
16
25
|
setLoading(true);
|
|
17
26
|
setError(null);
|
|
18
27
|
try {
|
|
19
28
|
const result = await stableFetcher();
|
|
20
|
-
if (
|
|
29
|
+
if (isCurrent())
|
|
21
30
|
setData(result);
|
|
22
31
|
}
|
|
23
32
|
catch (err) {
|
|
24
|
-
if (
|
|
33
|
+
if (isCurrent()) {
|
|
25
34
|
setError(err instanceof Error ? err.message : 'Failed to load data');
|
|
26
35
|
}
|
|
27
36
|
}
|
|
28
37
|
finally {
|
|
29
|
-
if (
|
|
38
|
+
if (isCurrent())
|
|
30
39
|
setLoading(false);
|
|
31
40
|
}
|
|
32
41
|
}, [stableFetcher]);
|
|
42
|
+
// Mount/unmount guard only — independent of `load` so a dependency change
|
|
43
|
+
// (which recreates `load`) never flips `mountedRef` to false mid-flight.
|
|
33
44
|
useEffect(() => {
|
|
34
|
-
|
|
35
|
-
load();
|
|
45
|
+
mountedRef.current = true;
|
|
36
46
|
return () => {
|
|
37
|
-
|
|
47
|
+
mountedRef.current = false;
|
|
38
48
|
};
|
|
49
|
+
}, []);
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
load();
|
|
39
52
|
}, [load]);
|
|
40
53
|
return { data, loading, error, refetch: load };
|
|
41
54
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useSeoResource.js","sourceRoot":"","sources":["../../../src/views/seo/useSeoResource.ts"],"names":[],"mappings":"AAAA,YAAY,CAAA;AAEZ;;;;GAIG;AACH,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAShE,MAAM,UAAU,cAAc,CAC5B,OAAyB,EACzB,OAA+B,EAAE;IAEjC,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAW,IAAI,CAAC,CAAA;IAChD,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAA;IAC5C,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,QAAQ,CAAgB,IAAI,CAAC,CAAA;IACvD,MAAM,
|
|
1
|
+
{"version":3,"file":"useSeoResource.js","sourceRoot":"","sources":["../../../src/views/seo/useSeoResource.ts"],"names":[],"mappings":"AAAA,YAAY,CAAA;AAEZ;;;;GAIG;AACH,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAShE,MAAM,UAAU,cAAc,CAC5B,OAAyB,EACzB,OAA+B,EAAE;IAEjC,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAW,IAAI,CAAC,CAAA;IAChD,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAA;IAC5C,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,QAAQ,CAAgB,IAAI,CAAC,CAAA;IACvD,uEAAuE;IACvE,2EAA2E;IAC3E,MAAM,UAAU,GAAG,MAAM,CAAC,IAAI,CAAC,CAAA;IAC/B,0EAA0E;IAC1E,4EAA4E;IAC5E,0EAA0E;IAC1E,2BAA2B;IAC3B,MAAM,YAAY,GAAG,MAAM,CAAC,CAAC,CAAC,CAAA;IAC9B,uDAAuD;IACvD,MAAM,aAAa,GAAG,WAAW,CAAC,OAAO,EAAE,IAAI,CAAC,CAAA;IAEhD,MAAM,IAAI,GAAG,WAAW,CAAC,KAAK,IAAI,EAAE;QAClC,MAAM,SAAS,GAAG,EAAE,YAAY,CAAC,OAAO,CAAA;QACxC,MAAM,SAAS,GAAG,GAAG,EAAE,CAAC,UAAU,CAAC,OAAO,IAAI,SAAS,KAAK,YAAY,CAAC,OAAO,CAAA;QAChF,UAAU,CAAC,IAAI,CAAC,CAAA;QAChB,QAAQ,CAAC,IAAI,CAAC,CAAA;QACd,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,aAAa,EAAE,CAAA;YACpC,IAAI,SAAS,EAAE;gBAAE,OAAO,CAAC,MAAM,CAAC,CAAA;QAClC,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,IAAI,SAAS,EAAE,EAAE,CAAC;gBAChB,QAAQ,CAAC,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,qBAAqB,CAAC,CAAA;YACtE,CAAC;QACH,CAAC;gBAAS,CAAC;YACT,IAAI,SAAS,EAAE;gBAAE,UAAU,CAAC,KAAK,CAAC,CAAA;QACpC,CAAC;IACH,CAAC,EAAE,CAAC,aAAa,CAAC,CAAC,CAAA;IAEnB,0EAA0E;IAC1E,yEAAyE;IACzE,SAAS,CAAC,GAAG,EAAE;QACb,UAAU,CAAC,OAAO,GAAG,IAAI,CAAA;QACzB,OAAO,GAAG,EAAE;YACV,UAAU,CAAC,OAAO,GAAG,KAAK,CAAA;QAC5B,CAAC,CAAA;IACH,CAAC,EAAE,EAAE,CAAC,CAAA;IAEN,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,EAAE,CAAA;IACR,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAAA;IAEV,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,CAAA;AAChD,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@actuate-media/cms-admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/actuate-media/actuatecms.git",
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
"tailwindcss": "^4.0.0",
|
|
88
88
|
"typescript": "^5.7.0",
|
|
89
89
|
"vitest": "^3.0.0",
|
|
90
|
-
"@actuate-media/cms-core": "0.
|
|
90
|
+
"@actuate-media/cms-core": "0.30.0",
|
|
91
91
|
"@actuate-media/component-blocks": "0.2.0"
|
|
92
92
|
},
|
|
93
93
|
"scripts": {
|
package/src/AdminRoot.tsx
CHANGED
|
@@ -13,8 +13,6 @@ import { Settings } from './views/Settings.js'
|
|
|
13
13
|
import { Forms } from './views/Forms.js'
|
|
14
14
|
import { FormEditor } from './views/FormEditor.js'
|
|
15
15
|
import { FormSubmissions } from './views/FormSubmissions.js'
|
|
16
|
-
import { Users } from './views/Users.js'
|
|
17
|
-
import { ApiKeys } from './views/ApiKeys.js'
|
|
18
16
|
import { SEO } from './views/SEO.js'
|
|
19
17
|
import { ScriptTags } from './views/ScriptTags.js'
|
|
20
18
|
import { ScriptTagEditor } from './views/ScriptTagEditor.js'
|
|
@@ -419,12 +417,14 @@ function AdminShell({
|
|
|
419
417
|
return <Widgets onNavigate={navigate} />
|
|
420
418
|
}
|
|
421
419
|
|
|
420
|
+
// Users + API Keys moved into the Settings view as tabs. The legacy
|
|
421
|
+
// top-level routes still resolve — they just land on the matching tab.
|
|
422
422
|
if (matchRoute('/users')) {
|
|
423
|
-
return <
|
|
423
|
+
return <Settings onNavigate={navigate} config={config} initialTab="users" />
|
|
424
424
|
}
|
|
425
425
|
|
|
426
426
|
if (matchRoute('/api-keys')) {
|
|
427
|
-
return <
|
|
427
|
+
return <Settings onNavigate={navigate} config={config} initialTab="api-keys" />
|
|
428
428
|
}
|
|
429
429
|
|
|
430
430
|
if (matchRoute('/settings')) {
|
|
@@ -156,9 +156,9 @@ describe('buildNavItems', () => {
|
|
|
156
156
|
// Lands after the static Content destinations, before Settings.
|
|
157
157
|
const menusIdx = items.findIndex((i) => i.path === '/navigations')
|
|
158
158
|
const scriptIdx = items.findIndex((i) => i.path === '/script-tags')
|
|
159
|
-
const
|
|
159
|
+
const settingsIdx = items.findIndex((i) => i.path === '/settings')
|
|
160
160
|
expect(menusIdx).toBeGreaterThan(scriptIdx)
|
|
161
|
-
expect(menusIdx).toBeLessThan(
|
|
161
|
+
expect(menusIdx).toBeLessThan(settingsIdx)
|
|
162
162
|
})
|
|
163
163
|
|
|
164
164
|
it('keeps standard Content/Settings entries after collections', () => {
|
|
@@ -172,8 +172,10 @@ describe('buildNavItems', () => {
|
|
|
172
172
|
expect(paths).toContain('/forms')
|
|
173
173
|
expect(paths).toContain('/seo')
|
|
174
174
|
expect(paths).toContain('/script-tags')
|
|
175
|
-
expect(paths).toContain('/users')
|
|
176
|
-
expect(paths).toContain('/api-keys')
|
|
177
175
|
expect(paths).toContain('/settings')
|
|
176
|
+
// Users + API Keys moved into Settings tabs — they are no longer
|
|
177
|
+
// standalone sidebar destinations.
|
|
178
|
+
expect(paths).not.toContain('/users')
|
|
179
|
+
expect(paths).not.toContain('/api-keys')
|
|
178
180
|
})
|
|
179
181
|
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
const cmsApi = vi.fn()
|
|
5
|
+
vi.mock('../../lib/api.js', () => ({
|
|
6
|
+
cmsApi: (...args: unknown[]) => cmsApi(...args),
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
fetchSeoOverview,
|
|
11
|
+
fetchSeoContentRecords,
|
|
12
|
+
fetchSeoIssues,
|
|
13
|
+
fetchRedirectRules,
|
|
14
|
+
updateSeoFields,
|
|
15
|
+
} = await import('../../lib/seo-service.js')
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
cmsApi.mockReset()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Regression tests for the "swallowed read errors" blocker. `cmsApi` never
|
|
23
|
+
* throws — it returns `{ error }` on failure — so the SEO read fetchers must
|
|
24
|
+
* re-throw, otherwise `useSeoResource` can't distinguish "request failed" from
|
|
25
|
+
* "empty result" and renders a misleading fallback (e.g. a fake score of 0 /
|
|
26
|
+
* grade "Critical") with the error/retry UI permanently unreachable.
|
|
27
|
+
*/
|
|
28
|
+
describe('SEO service read fetchers surface errors', () => {
|
|
29
|
+
it('fetchSeoOverview throws when the request fails (instead of returning a fake 0 score)', async () => {
|
|
30
|
+
cmsApi.mockResolvedValue({ error: 'Request failed (500)', status: 500 })
|
|
31
|
+
await expect(fetchSeoOverview()).rejects.toThrow('Request failed (500)')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('fetchSeoContentRecords throws on error', async () => {
|
|
35
|
+
cmsApi.mockResolvedValue({ error: 'Network error', status: 0 })
|
|
36
|
+
await expect(fetchSeoContentRecords()).rejects.toThrow('Network error')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('fetchSeoIssues throws on error', async () => {
|
|
40
|
+
cmsApi.mockResolvedValue({ error: 'Request failed (503)', status: 503 })
|
|
41
|
+
await expect(fetchSeoIssues()).rejects.toThrow('Request failed (503)')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('fetchRedirectRules throws on error', async () => {
|
|
45
|
+
cmsApi.mockResolvedValue({ error: 'boom', status: 500 })
|
|
46
|
+
await expect(fetchRedirectRules()).rejects.toThrow('boom')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('returns data normally on success', async () => {
|
|
50
|
+
cmsApi.mockResolvedValue({ data: { records: [{ id: 'r1' }] }, status: 200 })
|
|
51
|
+
await expect(fetchSeoContentRecords()).resolves.toEqual([{ id: 'r1' }])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('mutations still return { error } rather than throwing', async () => {
|
|
55
|
+
cmsApi.mockResolvedValue({ error: 'forbidden', status: 403 })
|
|
56
|
+
await expect(updateSeoFields('page', 'doc-1', { metaTitle: 'x' })).resolves.toEqual({
|
|
57
|
+
error: 'forbidden',
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { act, render, screen, waitFor } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
import { useSeoResource } from '../../views/seo/useSeoResource.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Regression test for the `useSeoResource` stale-response race: when the
|
|
9
|
+
* dependency (and therefore the fetcher) changes rapidly, a slow earlier
|
|
10
|
+
* request that resolves AFTER a faster later one must not clobber the newer
|
|
11
|
+
* result. We drive this with two deferred promises whose resolution order is
|
|
12
|
+
* inverted relative to the request order.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
function Probe({ dep, fetcher }: { dep: number; fetcher: () => Promise<string> }) {
|
|
16
|
+
const { data, loading, error } = useSeoResource<string>(fetcher, [dep])
|
|
17
|
+
return (
|
|
18
|
+
<div>
|
|
19
|
+
<span data-testid="state">{loading ? 'loading' : (error ?? data ?? 'empty')}</span>
|
|
20
|
+
</div>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function deferred<T>() {
|
|
25
|
+
let resolve!: (value: T) => void
|
|
26
|
+
let reject!: (reason?: unknown) => void
|
|
27
|
+
const promise = new Promise<T>((res, rej) => {
|
|
28
|
+
resolve = res
|
|
29
|
+
reject = rej
|
|
30
|
+
})
|
|
31
|
+
return { promise, resolve, reject }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('useSeoResource', () => {
|
|
35
|
+
it('ignores a stale (earlier) response that resolves after the latest one', async () => {
|
|
36
|
+
const first = deferred<string>()
|
|
37
|
+
const second = deferred<string>()
|
|
38
|
+
|
|
39
|
+
// First render uses the "first" fetcher (slow), then we switch deps so the
|
|
40
|
+
// hook re-runs with the "second" fetcher (fast).
|
|
41
|
+
const firstFetcher = () => first.promise
|
|
42
|
+
const secondFetcher = () => second.promise
|
|
43
|
+
|
|
44
|
+
const { rerender } = render(<Probe dep={1} fetcher={firstFetcher} />)
|
|
45
|
+
rerender(<Probe dep={2} fetcher={secondFetcher} />)
|
|
46
|
+
|
|
47
|
+
// The newer request resolves FIRST.
|
|
48
|
+
await act(async () => {
|
|
49
|
+
second.resolve('NEW')
|
|
50
|
+
})
|
|
51
|
+
await waitFor(() => expect(screen.getByTestId('state').textContent).toBe('NEW'))
|
|
52
|
+
|
|
53
|
+
// The stale earlier request resolves LATER — it must be ignored.
|
|
54
|
+
await act(async () => {
|
|
55
|
+
first.resolve('OLD')
|
|
56
|
+
})
|
|
57
|
+
expect(screen.getByTestId('state').textContent).toBe('NEW')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('surfaces fetcher errors', async () => {
|
|
61
|
+
const d = deferred<string>()
|
|
62
|
+
render(<Probe dep={1} fetcher={() => d.promise} />)
|
|
63
|
+
await act(async () => {
|
|
64
|
+
d.reject(new Error('boom'))
|
|
65
|
+
})
|
|
66
|
+
await waitFor(() => expect(screen.getByTestId('state').textContent).toBe('boom'))
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -36,7 +36,10 @@ export interface BreadcrumbsProps {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
export function Breadcrumbs({ currentPath, onNavigate }: BreadcrumbsProps) {
|
|
39
|
-
|
|
39
|
+
// Strip query string + hash so tab-driven routes (e.g. `/settings?tab=users`,
|
|
40
|
+
// `/seo?tab=audit`) don't leak the raw query into a crumb label.
|
|
41
|
+
const cleanPath = currentPath.split('?')[0]!.split('#')[0]!
|
|
42
|
+
const segments = cleanPath.split('/').filter(Boolean)
|
|
40
43
|
|
|
41
44
|
if (segments.length === 0) return null
|
|
42
45
|
|
|
@@ -31,6 +31,10 @@ export function Drawer({
|
|
|
31
31
|
<Dialog.Portal>
|
|
32
32
|
<Dialog.Overlay className="bg-foreground/40 motion-safe:animate-in motion-safe:fade-in fixed inset-0 z-50" />
|
|
33
33
|
<Dialog.Content
|
|
34
|
+
// Radix auto-associates `aria-describedby` with the Dialog.Description
|
|
35
|
+
// when one is present; when it isn't, explicitly clear the attribute so
|
|
36
|
+
// Radix doesn't emit its "Missing Description" console warning.
|
|
37
|
+
{...(description ? {} : { 'aria-describedby': undefined })}
|
|
34
38
|
className={`bg-card text-foreground fixed inset-y-0 right-0 z-50 flex w-full ${width} motion-safe:animate-in motion-safe:slide-in-from-right flex-col shadow-xl outline-none`}
|
|
35
39
|
>
|
|
36
40
|
<header className="border-border flex items-start justify-between gap-3 border-b px-5 py-4">
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useEffect, useState } from 'react'
|
|
3
|
+
import { cloneElement, isValidElement, useEffect, useId, useState, type ReactElement } from 'react'
|
|
4
4
|
import { Sparkles, Check } from 'lucide-react'
|
|
5
5
|
import { toast } from 'sonner'
|
|
6
6
|
import {
|
|
@@ -36,13 +36,25 @@ function Field({
|
|
|
36
36
|
children: React.ReactNode
|
|
37
37
|
hint?: React.ReactNode
|
|
38
38
|
}) {
|
|
39
|
+
// Associate the visible label with its control for screen readers. We inject
|
|
40
|
+
// a generated id onto the single child input/textarea (unless it already has
|
|
41
|
+
// one) and point the <label htmlFor> at it.
|
|
42
|
+
const generatedId = useId()
|
|
43
|
+
let control = children
|
|
44
|
+
if (isValidElement(children) && (children.props as { id?: string }).id == null) {
|
|
45
|
+
control = cloneElement(children as ReactElement<{ id?: string }>, { id: generatedId })
|
|
46
|
+
}
|
|
47
|
+
const controlId = isValidElement(control) ? (control.props as { id?: string }).id : undefined
|
|
48
|
+
|
|
39
49
|
return (
|
|
40
50
|
<div className="space-y-1.5">
|
|
41
51
|
<div className="flex items-center justify-between">
|
|
42
|
-
<label className="text-foreground text-sm font-medium">
|
|
52
|
+
<label htmlFor={controlId} className="text-foreground text-sm font-medium">
|
|
53
|
+
{label}
|
|
54
|
+
</label>
|
|
43
55
|
{hint}
|
|
44
56
|
</div>
|
|
45
|
-
{
|
|
57
|
+
{control}
|
|
46
58
|
</div>
|
|
47
59
|
)
|
|
48
60
|
}
|
|
@@ -112,13 +112,16 @@ export function SeoStatusBadge({
|
|
|
112
112
|
export function SeoScoreBar({ score, className = '' }: { score: number; className?: string }) {
|
|
113
113
|
const grade = gradeFromScore(score)
|
|
114
114
|
const fill = grade === 'good' ? 'bg-success' : grade === 'fair' ? 'bg-warning' : 'bg-destructive'
|
|
115
|
+
const clamped = Math.max(0, Math.min(100, Math.round(score)))
|
|
115
116
|
return (
|
|
116
117
|
<div
|
|
117
118
|
className={`bg-muted h-2 w-full overflow-hidden rounded-full ${className}`}
|
|
118
119
|
role="progressbar"
|
|
119
|
-
aria-
|
|
120
|
+
aria-label="SEO score"
|
|
121
|
+
aria-valuenow={clamped}
|
|
120
122
|
aria-valuemin={0}
|
|
121
123
|
aria-valuemax={100}
|
|
124
|
+
aria-valuetext={`${clamped} out of 100`}
|
|
122
125
|
>
|
|
123
126
|
<div
|
|
124
127
|
className={`h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none ${fill}`}
|
package/src/layout/Sidebar.tsx
CHANGED
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
Settings,
|
|
10
10
|
ChevronLeft,
|
|
11
11
|
ClipboardList,
|
|
12
|
-
Users,
|
|
13
12
|
Search as SearchIcon,
|
|
14
13
|
Briefcase,
|
|
15
14
|
FolderOpen,
|
|
@@ -22,7 +21,6 @@ import {
|
|
|
22
21
|
LayoutGrid,
|
|
23
22
|
Code2,
|
|
24
23
|
Library,
|
|
25
|
-
KeyRound,
|
|
26
24
|
ListTree,
|
|
27
25
|
Menu as MenuIcon,
|
|
28
26
|
PanelLeftClose,
|
|
@@ -156,8 +154,6 @@ const defaultNavItems: NavItem[] = [
|
|
|
156
154
|
{ path: '/forms', label: 'Forms', icon: ClipboardList, group: 'Content' },
|
|
157
155
|
{ path: '/seo', label: 'SEO', icon: SearchIcon, group: 'Content' },
|
|
158
156
|
{ path: '/script-tags', label: 'Script Tags', icon: Code2, group: 'Content' },
|
|
159
|
-
{ path: '/users', label: 'Users', icon: Users, group: 'Settings' },
|
|
160
|
-
{ path: '/api-keys', label: 'API Keys', icon: KeyRound, group: 'Settings' },
|
|
161
157
|
{ path: '/settings', label: 'Settings', icon: Settings, group: 'Settings' },
|
|
162
158
|
]
|
|
163
159
|
|
|
@@ -460,12 +456,10 @@ export function buildNavItems(config: any): NavItem[] {
|
|
|
460
456
|
}
|
|
461
457
|
for (const bucket of groupBuckets.values()) items.push(...bucket)
|
|
462
458
|
|
|
463
|
-
// Settings destinations — always last.
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
{ path: '/settings', label: 'Settings', icon: Settings, group: 'Settings' },
|
|
468
|
-
)
|
|
459
|
+
// Settings destinations — always last. Users + API Keys now live inside
|
|
460
|
+
// the Settings view as tabs (see Settings.tsx), so only Settings is anchored
|
|
461
|
+
// here.
|
|
462
|
+
items.push({ path: '/settings', label: 'Settings', icon: Settings, group: 'Settings' })
|
|
469
463
|
|
|
470
464
|
return items
|
|
471
465
|
}
|
package/src/lib/seo-service.ts
CHANGED
|
@@ -10,6 +10,19 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { cmsApi } from './api.js'
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* `cmsApi` never throws — it returns `{ error, status }` on a network/HTTP
|
|
15
|
+
* failure. Read (GET) fetchers below must re-surface that as a thrown error so
|
|
16
|
+
* `useSeoResource` can distinguish "request failed" from "empty result".
|
|
17
|
+
* Without this, a failed `/seo/overview` would fall through to the empty
|
|
18
|
+
* fallback (a fake score of 0 / grade "Critical") and the error/retry UI would
|
|
19
|
+
* be permanently unreachable. Mutations intentionally return `{ error }` and do
|
|
20
|
+
* not use this.
|
|
21
|
+
*/
|
|
22
|
+
function throwIfError(res: { error?: string }): void {
|
|
23
|
+
if (res.error) throw new Error(res.error)
|
|
24
|
+
}
|
|
25
|
+
|
|
13
26
|
// ─── Shared enums ────────────────────────────────────────────────────
|
|
14
27
|
|
|
15
28
|
export type SeoGrade = 'good' | 'fair' | 'poor' | 'critical'
|
|
@@ -110,6 +123,7 @@ const EMPTY_OVERVIEW: SeoOverview = {
|
|
|
110
123
|
|
|
111
124
|
export async function fetchSeoOverview(): Promise<SeoOverview> {
|
|
112
125
|
const res = await cmsApi<SeoOverview>('/seo/overview')
|
|
126
|
+
throwIfError(res)
|
|
113
127
|
return res.data ?? EMPTY_OVERVIEW
|
|
114
128
|
}
|
|
115
129
|
|
|
@@ -120,6 +134,7 @@ export async function fetchSearchPerformance(
|
|
|
120
134
|
): Promise<SearchPerformanceRow[]> {
|
|
121
135
|
const qs = params.limit ? `?limit=${params.limit}` : ''
|
|
122
136
|
const res = await cmsApi<{ rows: SearchPerformanceRow[] }>(`/seo/search-performance${qs}`)
|
|
137
|
+
throwIfError(res)
|
|
123
138
|
return res.data?.rows ?? []
|
|
124
139
|
}
|
|
125
140
|
|
|
@@ -155,6 +170,7 @@ export type ContentSeoFilter = 'all' | 'pages' | 'posts'
|
|
|
155
170
|
|
|
156
171
|
export async function fetchSeoContentRecords(): Promise<SeoContentRecord[]> {
|
|
157
172
|
const res = await cmsApi<{ records: SeoContentRecord[] }>('/seo/content')
|
|
173
|
+
throwIfError(res)
|
|
158
174
|
return res.data?.records ?? []
|
|
159
175
|
}
|
|
160
176
|
|
|
@@ -165,6 +181,7 @@ export async function fetchSeoContentRecord(
|
|
|
165
181
|
const res = await cmsApi<SeoContentRecord>(
|
|
166
182
|
`/seo/content/${entityType}/${encodeURIComponent(entityId)}`,
|
|
167
183
|
)
|
|
184
|
+
throwIfError(res)
|
|
168
185
|
return res.data ?? null
|
|
169
186
|
}
|
|
170
187
|
|
|
@@ -257,11 +274,13 @@ export async function runSeoAudit(
|
|
|
257
274
|
|
|
258
275
|
export async function fetchSeoAuditRuns(): Promise<SeoAuditRun[]> {
|
|
259
276
|
const res = await cmsApi<{ runs: SeoAuditRun[] }>('/seo/audits')
|
|
277
|
+
throwIfError(res)
|
|
260
278
|
return res.data?.runs ?? []
|
|
261
279
|
}
|
|
262
280
|
|
|
263
281
|
export async function fetchSeoAuditRun(auditRunId: string): Promise<SeoAuditRun | null> {
|
|
264
282
|
const res = await cmsApi<SeoAuditRun>(`/seo/audits/${encodeURIComponent(auditRunId)}`)
|
|
283
|
+
throwIfError(res)
|
|
265
284
|
return res.data ?? null
|
|
266
285
|
}
|
|
267
286
|
|
|
@@ -280,11 +299,13 @@ export async function fetchSeoIssues(query: SeoIssueQuery = {}): Promise<SeoIssu
|
|
|
280
299
|
if (query.auditRunId) params.set('auditRunId', query.auditRunId)
|
|
281
300
|
const qs = params.toString()
|
|
282
301
|
const res = await cmsApi<{ issues: SeoIssue[] }>(`/seo/issues${qs ? `?${qs}` : ''}`)
|
|
302
|
+
throwIfError(res)
|
|
283
303
|
return res.data?.issues ?? []
|
|
284
304
|
}
|
|
285
305
|
|
|
286
306
|
export async function fetchSeoIssue(issueId: string): Promise<SeoIssue | null> {
|
|
287
307
|
const res = await cmsApi<SeoIssue>(`/seo/issues/${encodeURIComponent(issueId)}`)
|
|
308
|
+
throwIfError(res)
|
|
288
309
|
return res.data ?? null
|
|
289
310
|
}
|
|
290
311
|
|
|
@@ -325,6 +346,7 @@ export interface CrawlSettings {
|
|
|
325
346
|
|
|
326
347
|
export async function fetchSitemapStatus(): Promise<SitemapStatus | null> {
|
|
327
348
|
const res = await cmsApi<SitemapStatus>('/seo/sitemap/status')
|
|
349
|
+
throwIfError(res)
|
|
328
350
|
return res.data ?? null
|
|
329
351
|
}
|
|
330
352
|
|
|
@@ -336,6 +358,7 @@ export async function regenerateSitemap(): Promise<{ status?: SitemapStatus; err
|
|
|
336
358
|
|
|
337
359
|
export async function fetchCrawlSettings(): Promise<CrawlSettings | null> {
|
|
338
360
|
const res = await cmsApi<CrawlSettings>('/seo/crawl-settings')
|
|
361
|
+
throwIfError(res)
|
|
339
362
|
return res.data ?? null
|
|
340
363
|
}
|
|
341
364
|
|
|
@@ -363,6 +386,7 @@ export interface RobotsValidation {
|
|
|
363
386
|
|
|
364
387
|
export async function fetchRobotsTxt(): Promise<RobotsConfig | null> {
|
|
365
388
|
const res = await cmsApi<RobotsConfig>('/seo/robots')
|
|
389
|
+
throwIfError(res)
|
|
366
390
|
return res.data ?? null
|
|
367
391
|
}
|
|
368
392
|
|
|
@@ -463,6 +487,7 @@ export interface RedirectChain {
|
|
|
463
487
|
|
|
464
488
|
export async function fetchRedirectOverview(): Promise<RedirectOverview> {
|
|
465
489
|
const res = await cmsApi<RedirectOverview>('/seo/redirects/overview')
|
|
490
|
+
throwIfError(res)
|
|
466
491
|
return (
|
|
467
492
|
res.data ?? {
|
|
468
493
|
activeRedirects: 0,
|
|
@@ -477,6 +502,7 @@ export async function fetchRedirectOverview(): Promise<RedirectOverview> {
|
|
|
477
502
|
|
|
478
503
|
export async function fetchRedirectRules(): Promise<RedirectRule[]> {
|
|
479
504
|
const res = await cmsApi<{ rules: RedirectRule[] }>('/seo/redirects')
|
|
505
|
+
throwIfError(res)
|
|
480
506
|
return res.data?.rules ?? []
|
|
481
507
|
}
|
|
482
508
|
|
|
@@ -516,6 +542,7 @@ export async function deleteRedirectRule(redirectId: string): Promise<{ error?:
|
|
|
516
542
|
|
|
517
543
|
export async function fetchRedirectChains(): Promise<RedirectChain[]> {
|
|
518
544
|
const res = await cmsApi<{ chains: RedirectChain[] }>('/seo/redirects/chains')
|
|
545
|
+
throwIfError(res)
|
|
519
546
|
return res.data?.chains ?? []
|
|
520
547
|
}
|
|
521
548
|
|
|
@@ -529,6 +556,7 @@ export async function flattenRedirectChain(chainId: string): Promise<{ error?: s
|
|
|
529
556
|
|
|
530
557
|
export async function fetchRedirectSuggestions(): Promise<RedirectSuggestion[]> {
|
|
531
558
|
const res = await cmsApi<{ suggestions: RedirectSuggestion[] }>('/seo/redirects/suggestions')
|
|
559
|
+
throwIfError(res)
|
|
532
560
|
return res.data?.suggestions ?? []
|
|
533
561
|
}
|
|
534
562
|
|
|
@@ -582,6 +610,15 @@ export interface SeoAiResult {
|
|
|
582
610
|
unavailable?: boolean
|
|
583
611
|
}
|
|
584
612
|
|
|
613
|
+
/**
|
|
614
|
+
* AI SEO endpoints report unavailability with two statuses: 501 (plugin-ai not
|
|
615
|
+
* installed) and 503 (installed but the provider isn't configured). Both map to
|
|
616
|
+
* the same graceful "AI unavailable" UX.
|
|
617
|
+
*/
|
|
618
|
+
function isAiUnavailable(status?: number): boolean {
|
|
619
|
+
return status === 501 || status === 503
|
|
620
|
+
}
|
|
621
|
+
|
|
585
622
|
export async function generateSeoField(
|
|
586
623
|
field: 'metaTitle' | 'metaDescription' | 'ogTitle' | 'ogDescription' | 'focusKeyword',
|
|
587
624
|
context: { entityType: string; entityId: string },
|
|
@@ -590,7 +627,7 @@ export async function generateSeoField(
|
|
|
590
627
|
method: 'POST',
|
|
591
628
|
body: JSON.stringify({ field, ...context }),
|
|
592
629
|
})
|
|
593
|
-
if (res.status
|
|
630
|
+
if (isAiUnavailable(res.status)) return { unavailable: true }
|
|
594
631
|
return res.data ?? {}
|
|
595
632
|
}
|
|
596
633
|
|
|
@@ -599,7 +636,7 @@ export async function explainSeoIssue(issueId: string): Promise<SeoAiResult> {
|
|
|
599
636
|
method: 'POST',
|
|
600
637
|
body: JSON.stringify({ issueId }),
|
|
601
638
|
})
|
|
602
|
-
if (res.status
|
|
639
|
+
if (isAiUnavailable(res.status)) return { unavailable: true }
|
|
603
640
|
return res.data ?? {}
|
|
604
641
|
}
|
|
605
642
|
|
|
@@ -612,12 +649,12 @@ export async function generateStructuredData(context: {
|
|
|
612
649
|
method: 'POST',
|
|
613
650
|
body: JSON.stringify(context),
|
|
614
651
|
})
|
|
615
|
-
if (res.status
|
|
652
|
+
if (isAiUnavailable(res.status)) return { unavailable: true }
|
|
616
653
|
return res.data ?? {}
|
|
617
654
|
}
|
|
618
655
|
|
|
619
656
|
export async function summarizeSeoAudit(): Promise<SeoAiResult> {
|
|
620
657
|
const res = await cmsApi<SeoAiResult>('/ai/seo/summarize', { method: 'POST' })
|
|
621
|
-
if (res.status
|
|
658
|
+
if (isAiUnavailable(res.status)) return { unavailable: true }
|
|
622
659
|
return res.data ?? {}
|
|
623
660
|
}
|