Remove expand icon from all panel headers. Add a subtle "View all ›" link at the bottom of each data list that appears when there's more data than shown. Headers now only contain title and tabs.
179 lines
7.7 KiB
TypeScript
179 lines
7.7 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import { logger } from '@/lib/utils/logger'
|
|
import Image from 'next/image'
|
|
import { formatNumber } from '@ciphera-net/ui'
|
|
import { getReferrerDisplayName, getReferrerFavicon, getReferrerIcon, mergeReferrersByDisplayName } from '@/lib/utils/icons'
|
|
import { Modal, GlobeIcon } from '@ciphera-net/ui'
|
|
import { ListSkeleton } from '@/components/skeletons'
|
|
import { getTopReferrers, TopReferrer } from '@/lib/api/stats'
|
|
import { type DimensionFilter } from '@/lib/filters'
|
|
|
|
interface TopReferrersProps {
|
|
referrers: Array<{ referrer: string; pageviews: number }>
|
|
collectReferrers?: boolean
|
|
siteId: string
|
|
dateRange: { start: string, end: string }
|
|
onFilter?: (filter: DimensionFilter) => void
|
|
}
|
|
|
|
const LIMIT = 7
|
|
|
|
export default function TopReferrers({ referrers, collectReferrers = true, siteId, dateRange, onFilter }: TopReferrersProps) {
|
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
|
const [fullData, setFullData] = useState<TopReferrer[]>([])
|
|
const [isLoadingFull, setIsLoadingFull] = useState(false)
|
|
const [faviconFailed, setFaviconFailed] = useState<Set<string>>(new Set())
|
|
|
|
// Filter out empty/unknown referrers
|
|
const filteredReferrers = (referrers || []).filter(
|
|
ref => ref.referrer && ref.referrer !== 'Unknown' && ref.referrer !== ''
|
|
)
|
|
|
|
const mergedReferrers = mergeReferrersByDisplayName(filteredReferrers)
|
|
|
|
const totalPageviews = mergedReferrers.reduce((sum, r) => sum + r.pageviews, 0)
|
|
const hasData = mergedReferrers.length > 0
|
|
const displayedReferrers = hasData ? mergedReferrers.slice(0, LIMIT) : []
|
|
const emptySlots = Math.max(0, LIMIT - displayedReferrers.length)
|
|
const showViewAll = hasData && mergedReferrers.length > LIMIT
|
|
|
|
function renderReferrerIcon(referrer: string) {
|
|
const faviconUrl = getReferrerFavicon(referrer)
|
|
const useFavicon = faviconUrl && !faviconFailed.has(referrer)
|
|
if (useFavicon) {
|
|
return (
|
|
<Image
|
|
src={faviconUrl}
|
|
alt=""
|
|
width={20}
|
|
height={20}
|
|
className="w-5 h-5 flex-shrink-0 rounded object-contain"
|
|
onError={() => setFaviconFailed((prev) => new Set(prev).add(referrer))}
|
|
unoptimized
|
|
/>
|
|
)
|
|
}
|
|
return <span className="text-lg flex-shrink-0">{getReferrerIcon(referrer)}</span>
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (isModalOpen) {
|
|
const fetchData = async () => {
|
|
setIsLoadingFull(true)
|
|
try {
|
|
const data = await getTopReferrers(siteId, dateRange.start, dateRange.end, 100)
|
|
// Filter fetched data too
|
|
const filtered = (data || []).filter(
|
|
ref => ref.referrer && ref.referrer !== 'Unknown' && ref.referrer !== ''
|
|
)
|
|
setFullData(filtered)
|
|
} catch (e) {
|
|
logger.error(e)
|
|
} finally {
|
|
setIsLoadingFull(false)
|
|
}
|
|
}
|
|
fetchData()
|
|
} else {
|
|
setFullData([])
|
|
}
|
|
}, [isModalOpen, siteId, dateRange])
|
|
|
|
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="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
|
Top Referrers
|
|
</h3>
|
|
</div>
|
|
|
|
<div className="space-y-2 flex-1 min-h-[270px]">
|
|
{!collectReferrers ? (
|
|
<div className="h-full flex flex-col items-center justify-center text-center px-4">
|
|
<p className="text-neutral-500 dark:text-neutral-400 text-sm">Referrer tracking is disabled in site settings</p>
|
|
</div>
|
|
) : hasData ? (
|
|
<>
|
|
{displayedReferrers.map((ref) => (
|
|
<div
|
|
key={ref.referrer}
|
|
onClick={() => onFilter?.({ dimension: 'referrer', operator: 'is', values: [ref.referrer] })}
|
|
className={`flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`}
|
|
>
|
|
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
|
{renderReferrerIcon(ref.referrer)}
|
|
<span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 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">
|
|
{totalPageviews > 0 ? `${Math.round((ref.pageviews / totalPageviews) * 100)}%` : ''}
|
|
</span>
|
|
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
|
{formatNumber(ref.pageviews)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{showViewAll ? (
|
|
<button
|
|
onClick={() => setIsModalOpen(true)}
|
|
className="flex items-center justify-center gap-1.5 h-9 w-full text-xs font-medium text-neutral-400 dark:text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange transition-colors cursor-pointer rounded-lg px-2 -mx-2"
|
|
>
|
|
View all
|
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
|
</svg>
|
|
</button>
|
|
) : (
|
|
Array.from({ length: emptySlots }).map((_, i) => (
|
|
<div key={`empty-${i}`} className="h-9 px-2 -mx-2" aria-hidden="true" />
|
|
))
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="h-full flex flex-col items-center justify-center text-center px-6 py-8 gap-3">
|
|
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4">
|
|
<GlobeIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400" />
|
|
</div>
|
|
<h4 className="font-semibold text-neutral-900 dark:text-white">
|
|
No referrers yet
|
|
</h4>
|
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
|
Traffic sources will appear here when visitors come from external sites.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<Modal
|
|
isOpen={isModalOpen}
|
|
onClose={() => setIsModalOpen(false)}
|
|
title="Top Referrers"
|
|
>
|
|
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
|
|
{isLoadingFull ? (
|
|
<div className="py-4">
|
|
<ListSkeleton rows={10} />
|
|
</div>
|
|
) : (
|
|
mergeReferrersByDisplayName(fullData.length > 0 ? fullData : filteredReferrers).map((ref) => (
|
|
<div key={ref.referrer} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
|
|
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
|
{renderReferrerIcon(ref.referrer)}
|
|
<span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span>
|
|
</div>
|
|
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
|
{formatNumber(ref.pageviews)}
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
</>
|
|
)
|
|
} |