'use client' import { useState, useEffect } from 'react' import { motion } from 'framer-motion' import { logger } from '@/lib/utils/logger' import { formatNumber, Modal } from '@ciphera-net/ui' import { MagnifyingGlass, CaretUp, CaretDown, FrameCornersIcon } from '@phosphor-icons/react' import { useGSCStatus, useGSCOverview, useGSCTopQueries, useGSCTopPages } from '@/lib/swr/dashboard' import { getGSCTopQueries, getGSCTopPages } from '@/lib/api/gsc' import type { GSCDataRow } from '@/lib/api/gsc' import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard' import { ListSkeleton } from '@/components/skeletons' import VirtualList from './VirtualList' interface SearchPerformanceProps { siteId: string dateRange: { start: string; end: string } } type Tab = 'queries' | 'pages' const LIMIT = 7 function ChangeArrow({ current, previous, invert = false }: { current: number; previous: number; invert?: boolean }) { if (!previous || previous === 0) return null const improved = invert ? current < previous : current > previous const same = current === previous if (same) return null return improved ? ( ) : ( ) } function getPositionBadgeClasses(position: number): string { if (position <= 10) return 'text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 dark:bg-emerald-500/20' if (position <= 20) return 'text-brand-orange dark:text-brand-orange bg-brand-orange/10 dark:bg-brand-orange/20' if (position <= 50) return 'text-neutral-400 dark:text-neutral-500 bg-neutral-100 dark:bg-neutral-800' return 'text-red-500 dark:text-red-400 bg-red-500/10 dark:bg-red-500/20' } export default function SearchPerformance({ siteId, dateRange }: SearchPerformanceProps) { const [activeTab, setActiveTab] = useState('queries') const handleTabKeyDown = useTabListKeyboard() const [isModalOpen, setIsModalOpen] = useState(false) const [modalSearch, setModalSearch] = useState('') const [fullData, setFullData] = useState([]) const [isLoadingFull, setIsLoadingFull] = useState(false) const { data: gscStatus } = useGSCStatus(siteId) const { data: overview, isLoading: overviewLoading } = useGSCOverview(siteId, dateRange.start, dateRange.end) const { data: queriesData, isLoading: queriesLoading } = useGSCTopQueries(siteId, dateRange.start, dateRange.end, LIMIT, 0) const { data: pagesData, isLoading: pagesLoading } = useGSCTopPages(siteId, dateRange.start, dateRange.end, LIMIT, 0) // Fetch full data when modal opens (matches ContentStats/TopReferrers pattern) useEffect(() => { if (isModalOpen) { const fetchData = async () => { setIsLoadingFull(true) try { if (activeTab === 'queries') { const data = await getGSCTopQueries(siteId, dateRange.start, dateRange.end, 100, 0) setFullData(data.queries ?? []) } else { const data = await getGSCTopPages(siteId, dateRange.start, dateRange.end, 100, 0) setFullData(data.pages ?? []) } } catch (e) { logger.error(e) } finally { setIsLoadingFull(false) } } fetchData() } else { setFullData([]) } }, [isModalOpen, activeTab, siteId, dateRange]) // Don't render if GSC is not connected if (!gscStatus?.connected) return null const isLoading = overviewLoading || queriesLoading || pagesLoading const queries = queriesData?.queries ?? [] const pages = pagesData?.pages ?? [] const hasData = overview && (overview.total_clicks > 0 || overview.total_impressions > 0) // Hide panel entirely if loaded but no data if (!isLoading && !hasData) return null const data = activeTab === 'queries' ? queries : pages const totalImpressions = data.reduce((sum, d) => sum + d.impressions, 0) const displayedData = data.slice(0, LIMIT) const emptySlots = Math.max(0, LIMIT - displayedData.length) const showViewAll = data.length >= LIMIT const getLabel = (row: GSCDataRow) => activeTab === 'queries' ? row.query : row.page const getTabLabel = (tab: Tab) => tab === 'queries' ? 'Queries' : 'Pages' return ( <> {/* Header */} Search {showViewAll && ( setIsModalOpen(true)} className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-all cursor-pointer rounded-lg" aria-label="View all search data" > )} {(['queries', 'pages'] as Tab[]).map((tab) => ( setActiveTab(tab)} role="tab" aria-selected={activeTab === tab} className={`relative px-2.5 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange rounded cursor-pointer ${ activeTab === tab ? 'text-neutral-900 dark:text-white' : 'text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300' }`} > {getTabLabel(tab)} {activeTab === tab && ( )} ))} {isLoading ? ( ) : ( <> {/* Inline stats row */} Clicks {formatNumber(overview?.total_clicks ?? 0)} Impressions {formatNumber(overview?.total_impressions ?? 0)} Avg Position {(overview?.avg_position ?? 0).toFixed(1)} {/* Data list */} {displayedData.length > 0 ? ( <> {displayedData.map((row) => { const maxImpressions = displayedData[0]?.impressions ?? 0 const barWidth = maxImpressions > 0 ? (row.impressions / maxImpressions) * 75 : 0 const label = getLabel(row) return ( {label} {totalImpressions > 0 ? `${Math.round((row.impressions / totalImpressions) * 100)}%` : ''} {formatNumber(row.clicks)} {row.position.toFixed(1)} ) })} {Array.from({ length: emptySlots }).map((_, i) => ( ))} > ) : ( No search data yet )} > )} {/* Expand modal */} { setIsModalOpen(false); setModalSearch('') }} title={`Search ${getTabLabel(activeTab)}`} className="max-w-2xl" > setModalSearch(e.target.value)} placeholder={`Search ${activeTab}...`} className="w-full px-3 py-2 mb-3 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-brand-orange/50" /> {isLoadingFull ? ( ) : (() => { const source = fullData.length > 0 ? fullData : data const modalData = source.filter(row => { if (!modalSearch) return true return getLabel(row).toLowerCase().includes(modalSearch.toLowerCase()) }) const modalTotal = modalData.reduce((sum, r) => sum + r.impressions, 0) return ( { const label = getLabel(row) return ( {label} {modalTotal > 0 ? `${Math.round((row.impressions / modalTotal) * 100)}%` : ''} {formatNumber(row.clicks)} {row.position.toFixed(1)} ) }} /> ) })()} > ) }
No search data yet