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:
Ben Senescu 2026-04-06 14:10:58 -04:00 committed by Ben Senescu
parent 5e9ebaf19f
commit c1190550b0
3 changed files with 43 additions and 59 deletions

View File

@ -1,5 +1,4 @@
import { Clock, Globe, History, Search, X } from "lucide-react";
import { reverse } from "remeda";
import { LOCATIONS } from "@/client/features/keywords/utils";
import type { KeywordResearchControllerState } from "./types";
@ -18,17 +17,10 @@ export function KeywordResearchEmptyState({ controller }: Props) {
}
function NoResultsState({ controller }: Props) {
const {
controlsForm,
lastResultSource,
lastSearchKeyword,
lastSearchLocationCode,
lastUsedFallback,
onSearch,
} = controller;
const { lastSearchKeyword, lastSearchLocationCode } = controller;
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">
<Globe className="size-10 mx-auto text-base-content/40" />
<div className="space-y-2">
@ -47,54 +39,6 @@ function NoResultsState({ controller }: Props) {
.
</p>
</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>
);

View 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([]);
});
});

View File

@ -12,7 +12,7 @@ const dataforseoTaskSchema = z
.array(
z
.object({
items: z.array(z.unknown()).optional(),
items: z.array(z.unknown()).nullable().optional(),
})
.passthrough(),
)