feat: redesign Search dashboard card to match Pulse design language
Add proportional impression bars, color-coded position badges, animated Queries/Pages tabs, hover percentage reveals, and searchable expand modal — bringing Search to parity with other dashboard cards.
This commit is contained in:
@@ -23,6 +23,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|||||||
|
|
||||||
### Improved
|
### 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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import Link from 'next/link'
|
import { useState, useEffect } from 'react'
|
||||||
import { MagnifyingGlass, CaretUp, CaretDown } from '@phosphor-icons/react'
|
import { motion } from 'framer-motion'
|
||||||
import { useGSCStatus, useGSCOverview, useGSCTopQueries } from '@/lib/swr/dashboard'
|
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 {
|
interface SearchPerformanceProps {
|
||||||
siteId: string
|
siteId: string
|
||||||
dateRange: { start: string; end: 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 }) {
|
function ChangeArrow({ current, previous, invert = false }: { current: number; previous: number; invert?: boolean }) {
|
||||||
if (!previous || previous === 0) return null
|
if (!previous || previous === 0) return null
|
||||||
const improved = invert ? current < previous : current > previous
|
const improved = invert ? current < previous : current > previous
|
||||||
@@ -21,41 +33,116 @@ 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) {
|
export default function SearchPerformance({ siteId, dateRange }: SearchPerformanceProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('queries')
|
||||||
|
const handleTabKeyDown = useTabListKeyboard()
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
|
const [modalSearch, setModalSearch] = useState('')
|
||||||
|
const [fullData, setFullData] = useState<GSCDataRow[]>([])
|
||||||
|
const [isLoadingFull, setIsLoadingFull] = useState(false)
|
||||||
|
|
||||||
const { data: gscStatus } = useGSCStatus(siteId)
|
const { data: gscStatus } = useGSCStatus(siteId)
|
||||||
const { data: overview, isLoading: overviewLoading } = useGSCOverview(siteId, dateRange.start, dateRange.end)
|
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
|
if (!gscStatus?.connected) return null
|
||||||
|
|
||||||
const isLoading = overviewLoading || queriesLoading
|
const isLoading = overviewLoading || queriesLoading || pagesLoading
|
||||||
const queries = queriesData?.queries ?? []
|
const queries = queriesData?.queries ?? []
|
||||||
|
const pages = pagesData?.pages ?? []
|
||||||
const hasData = overview && (overview.total_clicks > 0 || overview.total_impressions > 0)
|
const hasData = overview && (overview.total_clicks > 0 || overview.total_impressions > 0)
|
||||||
|
|
||||||
// Hide panel entirely if loaded but no data
|
// Hide panel entirely if loaded but no data
|
||||||
if (!isLoading && !hasData) return null
|
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 (
|
return (
|
||||||
|
<>
|
||||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6 h-full flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<MagnifyingGlass className="w-5 h-5 text-neutral-400 dark:text-neutral-500" weight="bold" />
|
<MagnifyingGlass className="w-5 h-5 text-neutral-400 dark:text-neutral-500" weight="bold" />
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">Search</h3>
|
||||||
Search
|
{showViewAll && (
|
||||||
</h3>
|
<button
|
||||||
</div>
|
onClick={() => setIsModalOpen(true)}
|
||||||
<Link
|
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"
|
||||||
href={`/sites/${siteId}/search`}
|
aria-label="View all search data"
|
||||||
className="text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors"
|
|
||||||
>
|
>
|
||||||
View all →
|
<FrameCornersIcon className="w-4 h-4" weight="bold" />
|
||||||
</Link>
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 overflow-x-auto scrollbar-hide" role="tablist" aria-label="Search data tabs" onKeyDown={handleTabKeyDown}>
|
||||||
|
{(['queries', 'pages'] as Tab[]).map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => 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 && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="searchTab"
|
||||||
|
className="absolute inset-x-0 -bottom-px h-0.5 bg-brand-orange"
|
||||||
|
transition={{ type: 'spring', stiffness: 500, damping: 35 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
/* Loading skeleton */
|
|
||||||
<div className="flex-1 space-y-4">
|
<div className="flex-1 space-y-4">
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
<div className="h-4 w-20 bg-neutral-100 dark:bg-neutral-800 rounded animate-pulse" />
|
<div className="h-4 w-20 bg-neutral-100 dark:bg-neutral-800 rounded animate-pulse" />
|
||||||
@@ -63,9 +150,7 @@ export default function SearchPerformance({ siteId, dateRange }: SearchPerforman
|
|||||||
<div className="h-4 w-20 bg-neutral-100 dark:bg-neutral-800 rounded animate-pulse" />
|
<div className="h-4 w-20 bg-neutral-100 dark:bg-neutral-800 rounded animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 mt-4">
|
<div className="space-y-2 mt-4">
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
<ListSkeleton rows={LIMIT} />
|
||||||
<div key={i} className="h-9 bg-neutral-100 dark:bg-neutral-800 rounded-lg animate-pulse" />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -75,14 +160,14 @@ export default function SearchPerformance({ siteId, dateRange }: SearchPerforman
|
|||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-xs text-neutral-500 dark:text-neutral-400">Clicks</span>
|
<span className="text-xs text-neutral-500 dark:text-neutral-400">Clicks</span>
|
||||||
<span className="text-sm font-semibold text-neutral-900 dark:text-white">
|
<span className="text-sm font-semibold text-neutral-900 dark:text-white">
|
||||||
{(overview?.total_clicks ?? 0).toLocaleString()}
|
{formatNumber(overview?.total_clicks ?? 0)}
|
||||||
</span>
|
</span>
|
||||||
<ChangeArrow current={overview?.total_clicks ?? 0} previous={overview?.prev_clicks ?? 0} />
|
<ChangeArrow current={overview?.total_clicks ?? 0} previous={overview?.prev_clicks ?? 0} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-xs text-neutral-500 dark:text-neutral-400">Impressions</span>
|
<span className="text-xs text-neutral-500 dark:text-neutral-400">Impressions</span>
|
||||||
<span className="text-sm font-semibold text-neutral-900 dark:text-white">
|
<span className="text-sm font-semibold text-neutral-900 dark:text-white">
|
||||||
{(overview?.total_impressions ?? 0).toLocaleString()}
|
{formatNumber(overview?.total_impressions ?? 0)}
|
||||||
</span>
|
</span>
|
||||||
<ChangeArrow current={overview?.total_impressions ?? 0} previous={overview?.prev_impressions ?? 0} />
|
<ChangeArrow current={overview?.total_impressions ?? 0} previous={overview?.prev_impressions ?? 0} />
|
||||||
</div>
|
</div>
|
||||||
@@ -95,27 +180,44 @@ export default function SearchPerformance({ siteId, dateRange }: SearchPerforman
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top 5 queries list */}
|
{/* Data list */}
|
||||||
<div className="space-y-1 flex-1">
|
<div className="space-y-2 flex-1 min-h-[270px]">
|
||||||
{queries.length > 0 ? (
|
{displayedData.length > 0 ? (
|
||||||
queries.map((q) => (
|
<>
|
||||||
|
{displayedData.map((row) => {
|
||||||
|
const maxImpressions = displayedData[0]?.impressions ?? 0
|
||||||
|
const barWidth = maxImpressions > 0 ? (row.impressions / maxImpressions) * 75 : 0
|
||||||
|
const label = getLabel(row)
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={q.query}
|
key={label}
|
||||||
className="flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors"
|
className="relative flex items-center justify-between h-9 group hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors"
|
||||||
>
|
>
|
||||||
<span className="text-sm text-neutral-900 dark:text-white truncate flex-1 min-w-0" title={q.query}>
|
<div
|
||||||
{q.query}
|
className="absolute inset-y-0.5 left-0.5 bg-brand-orange/15 dark:bg-brand-orange/40 rounded-md transition-all"
|
||||||
|
style={{ width: `${barWidth}%` }}
|
||||||
|
/>
|
||||||
|
<span className="relative text-sm text-neutral-900 dark:text-white truncate flex-1 min-w-0" title={label}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<div className="relative flex items-center gap-3 ml-4 shrink-0">
|
||||||
|
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||||
|
{totalImpressions > 0 ? `${Math.round((row.impressions / totalImpressions) * 100)}%` : ''}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-3 ml-4 shrink-0">
|
|
||||||
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||||
{q.clicks.toLocaleString()}
|
{formatNumber(row.clicks)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-neutral-400 dark:text-neutral-500 bg-neutral-100 dark:bg-neutral-800 px-1.5 py-0.5 rounded font-medium">
|
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${getPositionBadgeClasses(row.position)}`}>
|
||||||
{q.position.toFixed(1)}
|
{row.position.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
)
|
||||||
|
})}
|
||||||
|
{Array.from({ length: emptySlots }).map((_, i) => (
|
||||||
|
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center justify-center py-6">
|
<div className="flex-1 flex items-center justify-center py-6">
|
||||||
<p className="text-sm text-neutral-400 dark:text-neutral-500">No search data yet</p>
|
<p className="text-sm text-neutral-400 dark:text-neutral-500">No search data yet</p>
|
||||||
@@ -125,5 +227,69 @@ export default function SearchPerformance({ siteId, dateRange }: SearchPerforman
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Expand modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={() => { setIsModalOpen(false); setModalSearch('') }}
|
||||||
|
title={`Search ${getTabLabel(activeTab)}`}
|
||||||
|
className="max-w-2xl"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={modalSearch}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[80vh]">
|
||||||
|
{isLoadingFull ? (
|
||||||
|
<div className="py-4">
|
||||||
|
<ListSkeleton rows={10} />
|
||||||
|
</div>
|
||||||
|
) : (() => {
|
||||||
|
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 (
|
||||||
|
<VirtualList
|
||||||
|
items={modalData}
|
||||||
|
estimateSize={36}
|
||||||
|
className="max-h-[80vh] overflow-y-auto pr-2"
|
||||||
|
renderItem={(row) => {
|
||||||
|
const label = getLabel(row)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={label}
|
||||||
|
className="flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate text-sm text-neutral-900 dark:text-white" title={label}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-3 ml-4">
|
||||||
|
<span className="text-xs font-medium text-brand-orange opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200">
|
||||||
|
{modalTotal > 0 ? `${Math.round((row.impressions / modalTotal) * 100)}%` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||||
|
{formatNumber(row.clicks)}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${getPositionBadgeClasses(row.position)}`}>
|
||||||
|
{row.position.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user