fix: handle empty keyword research results (#81)
Treat successful DataForSEO keyword responses with null items as empty results so auto mode can fall back cleanly. Simplify the no-results state by removing dead-end actions and top-aligning the empty card.
This commit is contained in:
parent
5e9ebaf19f
commit
c1190550b0
@ -1,5 +1,4 @@
|
|||||||
import { Clock, Globe, History, Search, X } from "lucide-react";
|
import { Clock, Globe, History, Search, X } from "lucide-react";
|
||||||
import { reverse } from "remeda";
|
|
||||||
import { LOCATIONS } from "@/client/features/keywords/utils";
|
import { LOCATIONS } from "@/client/features/keywords/utils";
|
||||||
import type { KeywordResearchControllerState } from "./types";
|
import type { KeywordResearchControllerState } from "./types";
|
||||||
|
|
||||||
@ -18,17 +17,10 @@ export function KeywordResearchEmptyState({ controller }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function NoResultsState({ controller }: Props) {
|
function NoResultsState({ controller }: Props) {
|
||||||
const {
|
const { lastSearchKeyword, lastSearchLocationCode } = controller;
|
||||||
controlsForm,
|
|
||||||
lastResultSource,
|
|
||||||
lastSearchKeyword,
|
|
||||||
lastSearchLocationCode,
|
|
||||||
lastUsedFallback,
|
|
||||||
onSearch,
|
|
||||||
} = controller;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center px-4 md:px-6 py-6">
|
<div className="flex-1 flex items-start justify-center px-4 md:px-6 py-6">
|
||||||
<div className="w-full max-w-2xl rounded-2xl border border-base-300 bg-base-100 p-6 md:p-8 text-center space-y-4">
|
<div className="w-full max-w-2xl rounded-2xl border border-base-300 bg-base-100 p-6 md:p-8 text-center space-y-4">
|
||||||
<Globe className="size-10 mx-auto text-base-content/40" />
|
<Globe className="size-10 mx-auto text-base-content/40" />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -47,54 +39,6 @@ function NoResultsState({ controller }: Props) {
|
|||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl bg-base-200/70 px-4 py-3 text-left text-sm text-base-content/70 space-y-1">
|
|
||||||
<p>
|
|
||||||
Source checked:{" "}
|
|
||||||
<span className="font-medium">{lastResultSource}</span>
|
|
||||||
{lastUsedFallback ? (
|
|
||||||
<span> (with fallback chain: related - suggestions - ideas)</span>
|
|
||||||
) : null}
|
|
||||||
</p>
|
|
||||||
<p>Try a broader phrase, swap word order, or change location.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-outline"
|
|
||||||
onClick={() => {
|
|
||||||
const words = lastSearchKeyword.split(/\s+/).filter(Boolean);
|
|
||||||
const reversedKeyword = reverse(words).join(" ");
|
|
||||||
if (!reversedKeyword || reversedKeyword === lastSearchKeyword) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
controlsForm.setFieldValue("keyword", reversedKeyword);
|
|
||||||
onSearch({
|
|
||||||
keyword: reversedKeyword,
|
|
||||||
locationCode: lastSearchLocationCode,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
disabled={lastSearchKeyword.trim().split(/\s+/).length < 2}
|
|
||||||
>
|
|
||||||
Try reversed phrase
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-ghost"
|
|
||||||
onClick={() => {
|
|
||||||
const firstWord = lastSearchKeyword
|
|
||||||
.split(/\s+/)
|
|
||||||
.filter(Boolean)[0];
|
|
||||||
if (!firstWord) return;
|
|
||||||
controlsForm.setFieldValue("keyword", firstWord);
|
|
||||||
onSearch({
|
|
||||||
keyword: firstWord,
|
|
||||||
locationCode: lastSearchLocationCode,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Try broader seed
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
40
src/server/lib/dataforseoSchemas.test.ts
Normal file
40
src/server/lib/dataforseoSchemas.test.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
parseTaskItems,
|
||||||
|
relatedKeywordItemSchema,
|
||||||
|
successfulDataforseoTaskSchema,
|
||||||
|
} from "@/server/lib/dataforseoSchemas";
|
||||||
|
|
||||||
|
describe("dataforseoSchemas", () => {
|
||||||
|
it("accepts null items for empty successful tasks", () => {
|
||||||
|
const task = {
|
||||||
|
id: "04042314-1577-0387-0000-33dc4b485cfd",
|
||||||
|
status_code: 20000,
|
||||||
|
status_message: "Ok.",
|
||||||
|
path: ["v3", "dataforseo_labs", "google", "related_keywords", "live"],
|
||||||
|
cost: 0.02,
|
||||||
|
result_count: 1,
|
||||||
|
result: [
|
||||||
|
{
|
||||||
|
se_type: "google",
|
||||||
|
seed_keyword: "canva ai video alternative",
|
||||||
|
location_code: 2840,
|
||||||
|
language_code: "en",
|
||||||
|
total_count: null,
|
||||||
|
items_count: 0,
|
||||||
|
items: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsedTask = successfulDataforseoTaskSchema.parse(task);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
parseTaskItems(
|
||||||
|
"google-related-keywords-live",
|
||||||
|
parsedTask,
|
||||||
|
relatedKeywordItemSchema,
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -12,7 +12,7 @@ const dataforseoTaskSchema = z
|
|||||||
.array(
|
.array(
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
items: z.array(z.unknown()).optional(),
|
items: z.array(z.unknown()).nullable().optional(),
|
||||||
})
|
})
|
||||||
.passthrough(),
|
.passthrough(),
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user