Clarify keyword intent badges (#157)

This commit is contained in:
Ben Senescu 2026-05-06 12:30:33 -04:00 committed by GitHub
parent 9e5afc9d48
commit b9ad31704f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 238 additions and 162 deletions

View File

@ -10,8 +10,9 @@ import {
XAxis, XAxis,
YAxis, YAxis,
} from "recharts"; } from "recharts";
import type { KeywordIntent, MonthlySearch } from "@/types/keywords"; import type { MonthlySearch } from "@/types/keywords";
import { formatNumber } from "../utils"; import { formatNumber } from "../utils";
import { FloatingTooltip, useFloatingTooltip } from "./FloatingTooltip";
export type SortField = export type SortField =
| "keyword" | "keyword"
@ -30,80 +31,27 @@ export function HeaderHelpLabel({
helpText: string; helpText: string;
delayMs?: number; delayMs?: number;
}) { }) {
const [isOpen, setIsOpen] = useState(false); const tooltip = useFloatingTooltip<HTMLSpanElement>({ delayMs });
const [position, setPosition] = useState({ top: 0, left: 0 });
const openTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const triggerRef = useRef<HTMLSpanElement | null>(null);
const updatePosition = () => {
const rect = triggerRef.current?.getBoundingClientRect();
if (!rect) return;
setPosition({
top: rect.top - 8,
left: rect.left + rect.width / 2,
});
};
const clearOpenTimeout = () => {
if (openTimeoutRef.current) {
clearTimeout(openTimeoutRef.current);
openTimeoutRef.current = null;
}
};
const scheduleOpen = () => {
clearOpenTimeout();
openTimeoutRef.current = setTimeout(() => {
updatePosition();
setIsOpen(true);
openTimeoutRef.current = null;
}, delayMs);
};
const closeNow = () => {
clearOpenTimeout();
setIsOpen(false);
};
useEffect(() => clearOpenTimeout, []);
useEffect(() => {
if (!isOpen) return;
updatePosition();
const handleReposition = () => updatePosition();
window.addEventListener("resize", handleReposition);
window.addEventListener("scroll", handleReposition, true);
return () => {
window.removeEventListener("resize", handleReposition);
window.removeEventListener("scroll", handleReposition, true);
};
}, [isOpen]);
return ( return (
<span <span
ref={triggerRef} ref={tooltip.triggerRef}
className="relative inline-flex items-center" className="relative inline-flex items-center"
onMouseEnter={scheduleOpen} onMouseEnter={tooltip.scheduleOpen}
onMouseLeave={closeNow} onMouseLeave={tooltip.close}
onFocus={scheduleOpen} onFocus={tooltip.scheduleOpen}
onBlur={closeNow} onBlur={tooltip.close}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Escape") closeNow(); if (e.key === "Escape") tooltip.close();
}} }}
aria-describedby={tooltip.isOpen ? tooltip.tooltipId : undefined}
> >
<span>{label}</span> <span>{label}</span>
{isOpen && typeof document !== "undefined" {tooltip.isOpen && typeof document !== "undefined"
? createPortal( ? createPortal(
<span <FloatingTooltip id={tooltip.tooltipId} position={tooltip.position}>
role="tooltip"
className="pointer-events-none fixed z-[1000] w-max max-w-56 -translate-x-1/2 -translate-y-full rounded-md border border-base-300 bg-base-100 px-2 py-1 text-[11px] font-normal normal-case leading-snug text-base-content shadow-md"
style={{ left: position.left, top: position.top }}
>
{helpText} {helpText}
</span>, </FloatingTooltip>,
document.body, document.body,
) )
: null} : null}
@ -251,40 +199,41 @@ export function SortHeader({
className?: string; className?: string;
}) { }) {
const isActive = field === current; const isActive = field === current;
const tooltip = useFloatingTooltip<HTMLButtonElement>({
enabled: !!helpText,
});
return ( return (
<button <button
ref={tooltip.triggerRef}
className={`inline-flex items-center gap-0.5 hover:text-primary transition-colors cursor-pointer select-none ${className ?? ""}`} className={`inline-flex items-center gap-0.5 hover:text-primary transition-colors cursor-pointer select-none ${className ?? ""}`}
onClick={() => onToggle(field)} onClick={() => onToggle(field)}
onMouseEnter={tooltip.scheduleOpen}
onMouseLeave={tooltip.close}
onFocus={tooltip.scheduleOpen}
onBlur={tooltip.close}
onKeyDown={(e) => {
if (e.key === "Escape") tooltip.close();
}}
aria-describedby={
tooltip.isOpen && helpText ? tooltip.tooltipId : undefined
}
> >
{helpText ? <HeaderHelpLabel label={label} helpText={helpText} /> : label} {label}
{isActive && {isActive &&
(dir === "asc" ? ( (dir === "asc" ? (
<ChevronUp className="size-3" /> <ChevronUp className="size-3" />
) : ( ) : (
<ChevronDown className="size-3" /> <ChevronDown className="size-3" />
))} ))}
{tooltip.isOpen && helpText && typeof document !== "undefined"
? createPortal(
<FloatingTooltip id={tooltip.tooltipId} position={tooltip.position}>
{helpText}
</FloatingTooltip>,
document.body,
)
: null}
</button> </button>
); );
} }
export function IntentBadge({ intent }: { intent: KeywordIntent }) {
const colors: Record<KeywordIntent, string> = {
informational: "badge-info",
commercial: "badge-warning",
transactional: "badge-success",
navigational: "badge-primary",
unknown: "badge-ghost",
};
const shortLabels: Record<KeywordIntent, string> = {
informational: "Info",
commercial: "Comm",
transactional: "Trans",
navigational: "Nav",
unknown: "?",
};
return (
<span className={`badge badge-sm ${colors[intent]}`}>
{shortLabels[intent]}
</span>
);
}

View File

@ -0,0 +1,102 @@
import type { ReactNode } from "react";
import { useEffect, useId, useRef, useState } from "react";
type Position = { top: number; left: number };
export function FloatingTooltip({
id,
position,
children,
}: {
id: string;
position: Position;
children: ReactNode;
}) {
return (
<span
id={id}
role="tooltip"
className="pointer-events-none fixed z-[1000] w-max max-w-64 -translate-x-1/2 -translate-y-full rounded-md border border-base-300 bg-base-100 px-2.5 py-2 text-[11px] font-normal normal-case leading-snug text-base-content shadow-md"
style={{ left: position.left, top: position.top }}
>
{children}
</span>
);
}
export function useFloatingTooltip<T extends HTMLElement>({
delayMs = 150,
enabled = true,
}: {
delayMs?: number;
enabled?: boolean;
} = {}) {
const tooltipId = useId();
const triggerRef = useRef<T | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState<Position>({ top: 0, left: 0 });
const updatePosition = () => {
const rect = triggerRef.current?.getBoundingClientRect();
if (!rect) return;
setPosition({
top: rect.top - 8,
left: rect.left + rect.width / 2,
});
};
const clearOpenTimeout = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
const open = () => {
if (!enabled) return;
updatePosition();
setIsOpen(true);
};
const scheduleOpen = () => {
if (!enabled) return;
clearOpenTimeout();
timeoutRef.current = setTimeout(() => {
open();
timeoutRef.current = null;
}, delayMs);
};
const close = () => {
clearOpenTimeout();
setIsOpen(false);
};
useEffect(() => clearOpenTimeout, []);
useEffect(() => {
if (!isOpen) return;
updatePosition();
const handleReposition = () => updatePosition();
window.addEventListener("resize", handleReposition);
window.addEventListener("scroll", handleReposition, true);
return () => {
window.removeEventListener("resize", handleReposition);
window.removeEventListener("scroll", handleReposition, true);
};
}, [isOpen]);
return {
close,
isOpen,
open,
position,
scheduleOpen,
tooltipId,
triggerRef,
};
}

View File

@ -0,0 +1,83 @@
import { createPortal } from "react-dom";
import type { KeywordIntent } from "@/types/keywords";
import { FloatingTooltip, useFloatingTooltip } from "./FloatingTooltip";
const COLORS: Record<KeywordIntent, string> = {
informational: "border-info/30 bg-info/15 text-info",
commercial: "border-warning/35 bg-warning/20 text-warning",
transactional: "border-success/30 bg-success/15 text-success",
navigational: "border-primary/30 bg-primary/15 text-primary",
unknown: "border-base-300 bg-base-200 text-base-content/60",
};
const SHORT_LABELS: Record<KeywordIntent, string> = {
informational: "Info",
commercial: "Comm",
transactional: "Trans",
navigational: "Nav",
unknown: "?",
};
const DESCRIPTIONS: Record<
KeywordIntent,
{ label: string; description: string }
> = {
informational: {
label: "Informational",
description:
"The searcher wants information or answers. Use this for educational content, guides, and comparison-light explainers.",
},
commercial: {
label: "Commercial",
description:
"The searcher is researching options before a purchase. Treat this as buying intent for comparisons, alternatives, and product-led pages.",
},
transactional: {
label: "Transactional",
description:
"The searcher is ready to complete an action, often a purchase. Prioritize clear offers, pricing, trials, or conversion paths.",
},
navigational: {
label: "Navigational",
description:
"The searcher is looking for a specific site, brand, or page. These queries usually reward matching the expected destination.",
},
unknown: {
label: "Unknown",
description:
"Intent was not available for this keyword, so avoid making content strategy decisions from this badge alone.",
},
};
export function IntentBadge({ intent }: { intent: KeywordIntent }) {
const tooltip = useFloatingTooltip<HTMLSpanElement>({ delayMs: 0 });
const details = DESCRIPTIONS[intent];
return (
<span
ref={tooltip.triggerRef}
className={`inline-flex h-6 min-w-11 cursor-help items-center justify-center rounded-full border px-2 text-xs font-semibold leading-none ${COLORS[intent]}`}
tabIndex={0}
aria-label={`${details.label} search intent`}
aria-describedby={tooltip.isOpen ? tooltip.tooltipId : undefined}
onMouseEnter={tooltip.open}
onMouseLeave={tooltip.close}
onFocus={tooltip.open}
onBlur={tooltip.close}
onKeyDown={(e) => {
if (e.key === "Escape") tooltip.close();
}}
>
{SHORT_LABELS[intent]}
{tooltip.isOpen && typeof document !== "undefined"
? createPortal(
<FloatingTooltip id={tooltip.tooltipId} position={tooltip.position}>
<span className="block font-semibold">{details.label}</span>
<span className="mt-1 block">{details.description}</span>
</FloatingTooltip>,
document.body,
)
: null}
</span>
);
}

View File

@ -1,6 +1,6 @@
import type { KeywordResearchRow } from "@/types/keywords"; import type { KeywordResearchRow } from "@/types/keywords";
import { formatNumber, scoreTierClass } from "../utils"; import { formatNumber, scoreTierClass } from "../utils";
import { IntentBadge } from "./DisplayPrimitives"; import { IntentBadge } from "./IntentBadge";
export { SerpAnalysisCard } from "./SerpAnalysisCard"; export { SerpAnalysisCard } from "./SerpAnalysisCard";
export type { SortDir, SortField } from "./DisplayPrimitives"; export type { SortDir, SortField } from "./DisplayPrimitives";

View File

@ -1,14 +1,6 @@
import { import { ChevronLeft, ChevronRight, ExternalLink } from "lucide-react";
ChevronLeft,
ChevronRight,
ExternalLink,
Minus,
TrendingDown,
TrendingUp,
} from "lucide-react";
import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton"; import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
import type { SerpResultItem } from "@/types/keywords"; import type { SerpResultItem } from "@/types/keywords";
import { formatNumber } from "../utils";
export function SerpAnalysisCard({ export function SerpAnalysisCard({
items, items,
@ -54,25 +46,12 @@ export function SerpAnalysisCard({
{items.length} organic results {items.length} organic results
</div> </div>
<ExportToSheetsButton <ExportToSheetsButton
headers={[ headers={["Rank", "Title", "URL", "Domain"]}
"Rank",
"Title",
"URL",
"Domain",
"Traffic",
"Referring Domains",
"Backlinks",
"Rank Change",
]}
rows={items.map((item) => [ rows={items.map((item) => [
item.rank, item.rank,
item.title ?? "", item.title ?? "",
item.url, item.url,
item.domain, item.domain,
item.etv ?? "",
item.referringDomains ?? "",
item.backlinks ?? "",
item.isNew ? "new" : (item.rankChange ?? ""),
])} ])}
feature="serp_analysis" feature="serp_analysis"
/> />
@ -95,10 +74,6 @@ function SerpAnalysisTable({ items }: { items: SerpResultItem[] }) {
<tr className="text-xs text-base-content/60"> <tr className="text-xs text-base-content/60">
<th className="w-8">#</th> <th className="w-8">#</th>
<th>Page</th> <th>Page</th>
<th className="text-right w-20">Traffic</th>
<th className="text-right w-20">Ref. Domains</th>
<th className="text-right w-20">Backlinks</th>
<th className="text-center w-16">Change</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -110,7 +85,7 @@ function SerpAnalysisTable({ items }: { items: SerpResultItem[] }) {
<td className="font-mono text-base-content/50 text-xs"> <td className="font-mono text-base-content/50 text-xs">
{item.rank} {item.rank}
</td> </td>
<td className="max-w-[280px]"> <td className="min-w-0 max-w-0">
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<a <a
href={item.url} href={item.url}
@ -127,18 +102,6 @@ function SerpAnalysisTable({ items }: { items: SerpResultItem[] }) {
</span> </span>
</div> </div>
</td> </td>
<td className="text-right tabular-nums text-base-content/70">
{formatNumber(item.etv)}
</td>
<td className="text-right tabular-nums text-base-content/70">
{formatNumber(item.referringDomains)}
</td>
<td className="text-right tabular-nums text-base-content/70">
{formatNumber(item.backlinks)}
</td>
<td className="text-center">
<RankChangeBadge change={item.rankChange} isNew={item.isNew} />
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@ -209,34 +172,3 @@ function SerpAnalysisEmptyState({ keyword }: { keyword?: string | null }) {
</div> </div>
); );
} }
function RankChangeBadge({
change,
isNew,
}: {
change: number | null;
isNew?: boolean;
}) {
if (isNew) {
return <span className="badge badge-success badge-xs">new</span>;
}
if (change == null)
return <Minus className="size-3 text-base-content/40 mx-auto" />;
if (change > 0) {
return (
<span className="inline-flex items-center gap-0.5 text-success text-xs">
<TrendingUp className="size-3" />
{change}
</span>
);
}
if (change < 0) {
return (
<span className="inline-flex items-center gap-0.5 text-error text-xs">
<TrendingDown className="size-3" />
{Math.abs(change)}
</span>
);
}
return <Minus className="size-3 text-base-content/40 mx-auto" />;
}

View File

@ -1,2 +1,3 @@
export * from "./KeywordUi"; export * from "./KeywordUi";
export * from "./DisplayPrimitives"; export * from "./DisplayPrimitives";
export * from "./IntentBadge";

View File

@ -80,7 +80,7 @@ function DesktopKeywordPanel({ controller }: Props) {
} = controller; } = controller;
return ( return (
<div className="order-2 xl:order-1 flex flex-col min-w-0 gap-2 xl:flex-1"> <div className="order-2 xl:order-1 flex flex-col min-w-0 gap-2 xl:basis-3/5">
{showApproximateMatchNotice ? ( {showApproximateMatchNotice ? (
<div <div
className="rounded-lg border border-warning/40 bg-warning/15 px-3 py-2 text-sm text-base-content" className="rounded-lg border border-warning/40 bg-warning/15 px-3 py-2 text-sm text-base-content"
@ -277,7 +277,7 @@ function DesktopSerpPanel({ controller }: Props) {
: "Last 12 available months"; : "Last 12 available months";
return ( return (
<div className="order-1 xl:order-2 flex flex-col min-w-0 gap-2 xl:flex-1 xl:overflow-y-auto"> <div className="order-1 xl:order-2 flex flex-col min-w-0 gap-2 xl:basis-2/5 xl:overflow-y-auto">
{overviewKeyword && overviewKeyword.trend.length > 0 ? ( {overviewKeyword && overviewKeyword.trend.length > 0 ? (
<div className="shrink-0 overflow-hidden border border-base-300 rounded-xl bg-base-100 px-4 py-3"> <div className="shrink-0 overflow-hidden border border-base-300 rounded-xl bg-base-100 px-4 py-3">
<h4 className="text-sm font-semibold mb-1"> <h4 className="text-sm font-semibold mb-1">

View File

@ -11,6 +11,7 @@ import {
useSelectionAnchor, useSelectionAnchor,
} from "@/client/components/table/AppDataTable"; } from "@/client/components/table/AppDataTable";
import { import {
IntentBadge,
SortHeader, SortHeader,
type SortDir, type SortDir,
type SortField, type SortField,
@ -152,6 +153,14 @@ export function KeywordResearchDesktopTable({
cell: ({ getValue }) => <ScoreCell value={getValue()} />, cell: ({ getValue }) => <ScoreCell value={getValue()} />,
meta: { headerClassName: "text-right", cellClassName: "text-right" }, meta: { headerClassName: "text-right", cellClassName: "text-right" },
}), }),
keywordColumnHelper.accessor("intent", {
header: "Intent",
cell: ({ getValue }) => <IntentBadge intent={getValue()} />,
meta: {
headerClassName: "text-center",
cellClassName: "text-center",
},
}),
], ],
[selectAnchorRef, sortDir, sortField, toggleSort], [selectAnchorRef, sortDir, sortField, toggleSort],
); );