diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a68ccb..5a0cc3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Improved +- **Redesigned Search card on the dashboard.** The Search section of the dashboard has been completely refreshed to match the rest of Pulse. Search queries now show proportional bars so you can visually compare which queries get the most impressions. Hovering a row reveals the impression share percentage. Position badges are now color-coded — green for page 1 rankings, orange for page 2, and red for queries buried beyond page 5. You can switch between your top search queries and top pages using tabs, and expand the full list in a searchable popup without leaving the dashboard. - **Smaller, faster tracking script.** The tracking script is now about 20% smaller. Logic like page path cleaning, referrer filtering, error page detection, and input validation has been moved from your browser to the Pulse server. This means the script loads faster on every page, and Pulse can improve these features without needing you to update anything. - **Automatic 404 page detection.** Pulse now detects error pages (404 / "Page Not Found") automatically on the server by reading your page title — no extra setup needed. Previously this ran in the browser and couldn't be improved without updating the script. Now Pulse can recognize more error page patterns over time, including pages in other languages, without any changes on your end. - **Smarter bot filtering.** Pulse now catches more types of automated traffic that were slipping through — like headless browsers with default screen sizes, bot farms that rotate through different locations, and bots that fire duplicate events within milliseconds. Bot detection checks have also been moved from the tracking script to the server, making the script smaller and faster for real visitors. diff --git a/components/dashboard/SearchPerformance.tsx b/components/dashboard/SearchPerformance.tsx index 0938a6d..949dcd8 100644 --- a/components/dashboard/SearchPerformance.tsx +++ b/components/dashboard/SearchPerformance.tsx @@ -1,14 +1,26 @@ 'use client' -import Link from 'next/link' -import { MagnifyingGlass, CaretUp, CaretDown } from '@phosphor-icons/react' -import { useGSCStatus, useGSCOverview, useGSCTopQueries } from '@/lib/swr/dashboard' +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 @@ -21,109 +33,263 @@ function ChangeArrow({ current, previous, invert = false }: { current: number; p ) } +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, 5, 0) + 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) - // Don't render if GSC is not connected or no data + // 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 + 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 - return ( -
- {/* Header */} -
-
- -

- Search -

-
- - View all → - -
+ 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 - {isLoading ? ( - /* Loading skeleton */ -
-
-
-
-
+ const getLabel = (row: GSCDataRow) => activeTab === 'queries' ? row.query : row.page + const getTabLabel = (tab: Tab) => tab === 'queries' ? 'Queries' : 'Pages' + + return ( + <> +
+ {/* Header */} +
+
+ +

Search

+ {showViewAll && ( + + )}
-
- {Array.from({ length: 5 }).map((_, i) => ( -
+
+ {(['queries', 'pages'] as Tab[]).map((tab) => ( + ))}
- ) : ( - <> - {/* Inline stats row */} -
-
- Clicks - - {(overview?.total_clicks ?? 0).toLocaleString()} - - -
-
- Impressions - - {(overview?.total_impressions ?? 0).toLocaleString()} - - -
-
- Avg Position - - {(overview?.avg_position ?? 0).toFixed(1)} - - -
-
- {/* Top 5 queries list */} -
- {queries.length > 0 ? ( - queries.map((q) => ( -
- - {q.query} - -
- - {q.clicks.toLocaleString()} - - - {q.position.toFixed(1)} - -
-
- )) - ) : ( -
-

No search data yet

-
- )} + {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) => ( + + + )} +
+ + {/* 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)} + +
+
+ ) + }} + /> + ) + })()} +
+
+ ) }