Clarify keyword intent badges (#157)
This commit is contained in:
parent
9e5afc9d48
commit
b9ad31704f
@ -10,8 +10,9 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import type { KeywordIntent, MonthlySearch } from "@/types/keywords";
|
||||
import type { MonthlySearch } from "@/types/keywords";
|
||||
import { formatNumber } from "../utils";
|
||||
import { FloatingTooltip, useFloatingTooltip } from "./FloatingTooltip";
|
||||
|
||||
export type SortField =
|
||||
| "keyword"
|
||||
@ -30,80 +31,27 @@ export function HeaderHelpLabel({
|
||||
helpText: string;
|
||||
delayMs?: number;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
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]);
|
||||
const tooltip = useFloatingTooltip<HTMLSpanElement>({ delayMs });
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={triggerRef}
|
||||
ref={tooltip.triggerRef}
|
||||
className="relative inline-flex items-center"
|
||||
onMouseEnter={scheduleOpen}
|
||||
onMouseLeave={closeNow}
|
||||
onFocus={scheduleOpen}
|
||||
onBlur={closeNow}
|
||||
onMouseEnter={tooltip.scheduleOpen}
|
||||
onMouseLeave={tooltip.close}
|
||||
onFocus={tooltip.scheduleOpen}
|
||||
onBlur={tooltip.close}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") closeNow();
|
||||
if (e.key === "Escape") tooltip.close();
|
||||
}}
|
||||
aria-describedby={tooltip.isOpen ? tooltip.tooltipId : undefined}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{isOpen && typeof document !== "undefined"
|
||||
{tooltip.isOpen && typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<span
|
||||
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 }}
|
||||
>
|
||||
<FloatingTooltip id={tooltip.tooltipId} position={tooltip.position}>
|
||||
{helpText}
|
||||
</span>,
|
||||
</FloatingTooltip>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
@ -251,40 +199,41 @@ export function SortHeader({
|
||||
className?: string;
|
||||
}) {
|
||||
const isActive = field === current;
|
||||
const tooltip = useFloatingTooltip<HTMLButtonElement>({
|
||||
enabled: !!helpText,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={tooltip.triggerRef}
|
||||
className={`inline-flex items-center gap-0.5 hover:text-primary transition-colors cursor-pointer select-none ${className ?? ""}`}
|
||||
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 &&
|
||||
(dir === "asc" ? (
|
||||
<ChevronUp 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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
102
src/client/features/keywords/components/FloatingTooltip.tsx
Normal file
102
src/client/features/keywords/components/FloatingTooltip.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
83
src/client/features/keywords/components/IntentBadge.tsx
Normal file
83
src/client/features/keywords/components/IntentBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import type { KeywordResearchRow } from "@/types/keywords";
|
||||
import { formatNumber, scoreTierClass } from "../utils";
|
||||
import { IntentBadge } from "./DisplayPrimitives";
|
||||
import { IntentBadge } from "./IntentBadge";
|
||||
export { SerpAnalysisCard } from "./SerpAnalysisCard";
|
||||
|
||||
export type { SortDir, SortField } from "./DisplayPrimitives";
|
||||
|
||||
@ -1,14 +1,6 @@
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Minus,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, ExternalLink } from "lucide-react";
|
||||
import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
|
||||
import type { SerpResultItem } from "@/types/keywords";
|
||||
import { formatNumber } from "../utils";
|
||||
|
||||
export function SerpAnalysisCard({
|
||||
items,
|
||||
@ -54,25 +46,12 @@ export function SerpAnalysisCard({
|
||||
{items.length} organic results
|
||||
</div>
|
||||
<ExportToSheetsButton
|
||||
headers={[
|
||||
"Rank",
|
||||
"Title",
|
||||
"URL",
|
||||
"Domain",
|
||||
"Traffic",
|
||||
"Referring Domains",
|
||||
"Backlinks",
|
||||
"Rank Change",
|
||||
]}
|
||||
headers={["Rank", "Title", "URL", "Domain"]}
|
||||
rows={items.map((item) => [
|
||||
item.rank,
|
||||
item.title ?? "",
|
||||
item.url,
|
||||
item.domain,
|
||||
item.etv ?? "",
|
||||
item.referringDomains ?? "",
|
||||
item.backlinks ?? "",
|
||||
item.isNew ? "new" : (item.rankChange ?? ""),
|
||||
])}
|
||||
feature="serp_analysis"
|
||||
/>
|
||||
@ -95,10 +74,6 @@ function SerpAnalysisTable({ items }: { items: SerpResultItem[] }) {
|
||||
<tr className="text-xs text-base-content/60">
|
||||
<th className="w-8">#</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>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -110,7 +85,7 @@ function SerpAnalysisTable({ items }: { items: SerpResultItem[] }) {
|
||||
<td className="font-mono text-base-content/50 text-xs">
|
||||
{item.rank}
|
||||
</td>
|
||||
<td className="max-w-[280px]">
|
||||
<td className="min-w-0 max-w-0">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<a
|
||||
href={item.url}
|
||||
@ -127,18 +102,6 @@ function SerpAnalysisTable({ items }: { items: SerpResultItem[] }) {
|
||||
</span>
|
||||
</div>
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
@ -209,34 +172,3 @@ function SerpAnalysisEmptyState({ keyword }: { keyword?: string | null }) {
|
||||
</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" />;
|
||||
}
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./KeywordUi";
|
||||
export * from "./DisplayPrimitives";
|
||||
export * from "./IntentBadge";
|
||||
|
||||
@ -80,7 +80,7 @@ function DesktopKeywordPanel({ controller }: Props) {
|
||||
} = controller;
|
||||
|
||||
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 ? (
|
||||
<div
|
||||
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";
|
||||
|
||||
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 ? (
|
||||
<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">
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
useSelectionAnchor,
|
||||
} from "@/client/components/table/AppDataTable";
|
||||
import {
|
||||
IntentBadge,
|
||||
SortHeader,
|
||||
type SortDir,
|
||||
type SortField,
|
||||
@ -152,6 +153,14 @@ export function KeywordResearchDesktopTable({
|
||||
cell: ({ getValue }) => <ScoreCell value={getValue()} />,
|
||||
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],
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user