Clarify keyword intent badges (#157)
This commit is contained in:
parent
9e5afc9d48
commit
b9ad31704f
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
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 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";
|
||||||
|
|||||||
@ -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" />;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
export * from "./KeywordUi";
|
export * from "./KeywordUi";
|
||||||
export * from "./DisplayPrimitives";
|
export * from "./DisplayPrimitives";
|
||||||
|
export * from "./IntentBadge";
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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],
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user