diff --git a/src/client/features/keywords/components/DisplayPrimitives.tsx b/src/client/features/keywords/components/DisplayPrimitives.tsx index e225f4d..c7a551a 100644 --- a/src/client/features/keywords/components/DisplayPrimitives.tsx +++ b/src/client/features/keywords/components/DisplayPrimitives.tsx @@ -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 | null>(null); - const triggerRef = useRef(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({ delayMs }); return ( { - if (e.key === "Escape") closeNow(); + if (e.key === "Escape") tooltip.close(); }} + aria-describedby={tooltip.isOpen ? tooltip.tooltipId : undefined} > {label} - {isOpen && typeof document !== "undefined" + {tooltip.isOpen && typeof document !== "undefined" ? createPortal( - + {helpText} - , + , document.body, ) : null} @@ -251,40 +199,41 @@ export function SortHeader({ className?: string; }) { const isActive = field === current; + const tooltip = useFloatingTooltip({ + enabled: !!helpText, + }); + return ( ); } - -export function IntentBadge({ intent }: { intent: KeywordIntent }) { - const colors: Record = { - informational: "badge-info", - commercial: "badge-warning", - transactional: "badge-success", - navigational: "badge-primary", - unknown: "badge-ghost", - }; - const shortLabels: Record = { - informational: "Info", - commercial: "Comm", - transactional: "Trans", - navigational: "Nav", - unknown: "?", - }; - return ( - - {shortLabels[intent]} - - ); -} diff --git a/src/client/features/keywords/components/FloatingTooltip.tsx b/src/client/features/keywords/components/FloatingTooltip.tsx new file mode 100644 index 0000000..a6c3f3f --- /dev/null +++ b/src/client/features/keywords/components/FloatingTooltip.tsx @@ -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 ( + + {children} + + ); +} + +export function useFloatingTooltip({ + delayMs = 150, + enabled = true, +}: { + delayMs?: number; + enabled?: boolean; +} = {}) { + const tooltipId = useId(); + const triggerRef = useRef(null); + const timeoutRef = useRef | null>(null); + const [isOpen, setIsOpen] = useState(false); + const [position, setPosition] = useState({ 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, + }; +} diff --git a/src/client/features/keywords/components/IntentBadge.tsx b/src/client/features/keywords/components/IntentBadge.tsx new file mode 100644 index 0000000..bf975af --- /dev/null +++ b/src/client/features/keywords/components/IntentBadge.tsx @@ -0,0 +1,83 @@ +import { createPortal } from "react-dom"; +import type { KeywordIntent } from "@/types/keywords"; +import { FloatingTooltip, useFloatingTooltip } from "./FloatingTooltip"; + +const COLORS: Record = { + 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 = { + 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({ delayMs: 0 }); + const details = DESCRIPTIONS[intent]; + + return ( + { + if (e.key === "Escape") tooltip.close(); + }} + > + {SHORT_LABELS[intent]} + {tooltip.isOpen && typeof document !== "undefined" + ? createPortal( + + {details.label} + {details.description} + , + document.body, + ) + : null} + + ); +} diff --git a/src/client/features/keywords/components/KeywordUi.tsx b/src/client/features/keywords/components/KeywordUi.tsx index 1633fa7..8a5a861 100644 --- a/src/client/features/keywords/components/KeywordUi.tsx +++ b/src/client/features/keywords/components/KeywordUi.tsx @@ -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"; diff --git a/src/client/features/keywords/components/SerpAnalysisCard.tsx b/src/client/features/keywords/components/SerpAnalysisCard.tsx index 8809b31..1df30c2 100644 --- a/src/client/features/keywords/components/SerpAnalysisCard.tsx +++ b/src/client/features/keywords/components/SerpAnalysisCard.tsx @@ -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 [ 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[] }) { # Page - Traffic - Ref. Domains - Backlinks - Change @@ -110,7 +85,7 @@ function SerpAnalysisTable({ items }: { items: SerpResultItem[] }) { {item.rank} - + - - {formatNumber(item.etv)} - - - {formatNumber(item.referringDomains)} - - - {formatNumber(item.backlinks)} - - - - ))} @@ -209,34 +172,3 @@ function SerpAnalysisEmptyState({ keyword }: { keyword?: string | null }) { ); } - -function RankChangeBadge({ - change, - isNew, -}: { - change: number | null; - isNew?: boolean; -}) { - if (isNew) { - return new; - } - if (change == null) - return ; - if (change > 0) { - return ( - - - {change} - - ); - } - if (change < 0) { - return ( - - - {Math.abs(change)} - - ); - } - return ; -} diff --git a/src/client/features/keywords/components/index.ts b/src/client/features/keywords/components/index.ts index 5683719..346de6e 100644 --- a/src/client/features/keywords/components/index.ts +++ b/src/client/features/keywords/components/index.ts @@ -1,2 +1,3 @@ export * from "./KeywordUi"; export * from "./DisplayPrimitives"; +export * from "./IntentBadge"; diff --git a/src/client/features/keywords/page/KeywordResearchDesktopResults.tsx b/src/client/features/keywords/page/KeywordResearchDesktopResults.tsx index 5a83c96..806c5c2 100644 --- a/src/client/features/keywords/page/KeywordResearchDesktopResults.tsx +++ b/src/client/features/keywords/page/KeywordResearchDesktopResults.tsx @@ -80,7 +80,7 @@ function DesktopKeywordPanel({ controller }: Props) { } = controller; return ( -
+
{showApproximateMatchNotice ? (
+
{overviewKeyword && overviewKeyword.trend.length > 0 ? (

diff --git a/src/client/features/keywords/page/KeywordResearchDesktopTable.tsx b/src/client/features/keywords/page/KeywordResearchDesktopTable.tsx index 1be17dd..d38740b 100644 --- a/src/client/features/keywords/page/KeywordResearchDesktopTable.tsx +++ b/src/client/features/keywords/page/KeywordResearchDesktopTable.tsx @@ -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 }) => , meta: { headerClassName: "text-right", cellClassName: "text-right" }, }), + keywordColumnHelper.accessor("intent", { + header: "Intent", + cell: ({ getValue }) => , + meta: { + headerClassName: "text-center", + cellClassName: "text-center", + }, + }), ], [selectAnchorRef, sortDir, sortField, toggleSort], );