import { Sparkles } from "lucide-react"; import { toast } from "sonner"; import { buildCsv, downloadCsv } from "@/client/lib/csv"; import { exportTableToSheets } from "@/client/lib/exportToSheets"; import { captureClientEvent } from "@/client/lib/posthog"; import type { RankTrackingDeviceResult, RankTrackingRow, } from "@/types/schemas/rank-tracking"; const FEATURE_SHORT_LABELS: Record = { featured_snippet: "FS", people_also_ask: "PAA", ai_overview: "AI", local_pack: "Local", knowledge_panel: "KP", video: "Video", images: "Img", shopping: "Shop", top_stories: "News", }; const FEATURE_TOOLTIPS: Record = { featured_snippet: "Featured Snippet — highlighted answer box at top of results", people_also_ask: "People Also Ask — expandable related questions", ai_overview: "AI Overview — AI-generated summary at top of search", local_pack: "Local Pack — map with local business listings", knowledge_panel: "Knowledge Panel — info box about an entity", video: "Video — video results shown in the SERP", images: "Images — image results shown in the SERP", shopping: "Shopping — product listings with prices", top_stories: "Top Stories — news articles carousel", }; export function SerpFeatureTags({ features }: { features: string[] }) { const notable = features.filter((f) => f in FEATURE_SHORT_LABELS); if (notable.length === 0) return null; return (
{notable.map((f) => ( {f === "ai_overview" && } {FEATURE_SHORT_LABELS[f]} ))}
); } export function DeviceRankCell({ result, }: { result: RankTrackingDeviceResult; }) { const { position, previousPosition } = result; // Nothing at all if (position === null && previousPosition === null) { return -; } // Was ranking, now lost if (position === null && previousPosition !== null) { return ( {previousPosition} lost ); } // First check — no previous data if (previousPosition === null) { return {position}; } // Both exist — show old → new with colored badge const change = previousPosition - position!; let badgeClass = "bg-base-200 text-base-content"; if (change > 0) badgeClass = "bg-success/20 text-success"; if (change < 0) badgeClass = "bg-warning/20 text-warning"; return ( {previousPosition} {position} ); } export function DeviceUrlCell({ result, domain, }: { result: RankTrackingDeviceResult; domain: string; }) { if (!result.rankingUrl) { return -; } return ( {toPath(result.rankingUrl)} ); } const compactFormatter = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1, }); export function VolumeCell({ value }: { value: number | null }) { if (value == null) return -; return ( {compactFormatter.format(value)} ); } export function DifficultyCell({ value }: { value: number | null }) { if (value == null) return -; let badgeClass = "bg-success/20 text-success"; if (value > 60) badgeClass = "bg-error/20 text-error"; else if (value > 30) badgeClass = "bg-warning/20 text-warning"; return ( {value} ); } export function CpcCell({ value }: { value: number | null }) { if (value == null) return -; return ${value.toFixed(2)}; } export function comparePositions(a: number | null, b: number | null): number { if (a === null && b === null) return 0; if (a === null) return 1; // nulls sort last if (b === null) return -1; return a - b; } /** Numeric change for CSV export — numbers bypass the CSV formula-injection sanitizer */ function csvChange( current: number | null, previous: number | null, ): number | string { if (previous === null) return current !== null ? "new" : ""; if (current === null) return "lost"; return previous - current; } function buildRankTrackingExport( sorted: RankTrackingRow[], showDesktop: boolean, showMobile: boolean, ): { headers: string[]; rows: (string | number)[][] } { const headers = [ "Keyword", "Volume", "KD", "CPC", ...(showDesktop ? [ "Desktop Position", "Desktop Change", "Desktop URL", "Desktop SERP Features", ] : []), ...(showMobile ? [ "Mobile Position", "Mobile Change", "Mobile URL", "Mobile SERP Features", ] : []), ]; // Emit empty cells (not "Not ranking" strings) so Sheets infers a numeric // column type and the user can sort by position. const rows = sorted.map((row) => [ row.keyword, row.searchVolume ?? "", row.keywordDifficulty ?? "", row.cpc ?? "", ...(showDesktop ? [ row.desktop.position ?? "", csvChange(row.desktop.position, row.desktop.previousPosition), row.desktop.rankingUrl ?? "", row.desktop.serpFeatures.join(", "), ] : []), ...(showMobile ? [ row.mobile.position ?? "", csvChange(row.mobile.position, row.mobile.previousPosition), row.mobile.rankingUrl ?? "", row.mobile.serpFeatures.join(", "), ] : []), ]); return { headers, rows }; } export function exportRankTrackingToSheets( sorted: RankTrackingRow[], showDesktop: boolean, showMobile: boolean, ) { const { headers, rows } = buildRankTrackingExport( sorted, showDesktop, showMobile, ); void exportTableToSheets({ headers, rows, feature: "rank_tracking" }); } export function exportRankTrackingCsv( sorted: RankTrackingRow[], showDesktop: boolean, showMobile: boolean, domain: string, ) { if (sorted.length === 0) { toast.error("No data to export"); return; } const { headers, rows } = buildRankTrackingExport( sorted, showDesktop, showMobile, ); // CSV file download keeps cents-formatted CPC for human readability; // clipboard/Sheets export uses raw numbers (see buildRankTrackingExport). const csvRows = rows.map((row) => row.map((cell, idx) => idx === 3 && typeof cell === "number" ? cell.toFixed(2) : cell, ), ); downloadCsv(`rank-tracking-${domain}.csv`, buildCsv(headers, csvRows)); captureClientEvent("rank_tracking:export_csv"); } function toPath(url: string): string { try { return new URL(url).pathname; } catch { return url; } } function toFullUrl(url: string, domain: string): string { if (url.startsWith("http")) return url; return `https://${domain}${url}`; }