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.
338 lines
14 KiB
TypeScript
338 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import { logger } from '@/lib/utils/logger'
|
|
import { formatNumber } from '@ciphera-net/ui'
|
|
import { useTabListKeyboard } from '@/lib/hooks/useTabListKeyboard'
|
|
import * as Flags from 'country-flag-icons/react/3x2'
|
|
import iso3166 from 'iso-3166-2'
|
|
import WorldMap from './WorldMap'
|
|
import { Modal, GlobeIcon } from '@ciphera-net/ui'
|
|
import { ListSkeleton } from '@/components/skeletons'
|
|
import { SiTorproject } from 'react-icons/si'
|
|
import { FaUserSecret, FaSatellite } from 'react-icons/fa'
|
|
import { getCountries, getCities, getRegions } from '@/lib/api/stats'
|
|
import { type DimensionFilter } from '@/lib/filters'
|
|
|
|
interface LocationProps {
|
|
countries: Array<{ country: string; pageviews: number }>
|
|
cities: Array<{ city: string; country: string; pageviews: number }>
|
|
regions: Array<{ region: string; country: string; pageviews: number }>
|
|
geoDataLevel?: 'full' | 'country' | 'none'
|
|
siteId: string
|
|
dateRange: { start: string, end: string }
|
|
onFilter?: (filter: DimensionFilter) => void
|
|
}
|
|
|
|
type Tab = 'map' | 'countries' | 'regions' | 'cities'
|
|
|
|
const LIMIT = 7
|
|
|
|
const TAB_TO_DIMENSION: Record<string, string> = { countries: 'country', regions: 'region', cities: 'city' }
|
|
|
|
export default function Locations({ countries, cities, regions, geoDataLevel = 'full', siteId, dateRange, onFilter }: LocationProps) {
|
|
const [activeTab, setActiveTab] = useState<Tab>('map')
|
|
const handleTabKeyDown = useTabListKeyboard()
|
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
|
type LocationItem = { country?: string; city?: string; region?: string; pageviews: number }
|
|
const [fullData, setFullData] = useState<LocationItem[]>([])
|
|
const [isLoadingFull, setIsLoadingFull] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (isModalOpen) {
|
|
const fetchData = async () => {
|
|
setIsLoadingFull(true)
|
|
try {
|
|
let data: LocationItem[] = []
|
|
if (activeTab === 'countries') {
|
|
data = await getCountries(siteId, dateRange.start, dateRange.end, 250)
|
|
} else if (activeTab === 'regions') {
|
|
data = await getRegions(siteId, dateRange.start, dateRange.end, 250)
|
|
} else if (activeTab === 'cities') {
|
|
data = await getCities(siteId, dateRange.start, dateRange.end, 250)
|
|
}
|
|
setFullData(data)
|
|
} catch (e) {
|
|
logger.error(e)
|
|
} finally {
|
|
setIsLoadingFull(false)
|
|
}
|
|
}
|
|
fetchData()
|
|
} else {
|
|
setFullData([])
|
|
}
|
|
}, [isModalOpen, activeTab, siteId, dateRange])
|
|
|
|
const getFlagComponent = (countryCode: string) => {
|
|
if (!countryCode || countryCode === 'Unknown') return null
|
|
|
|
switch (countryCode) {
|
|
case 'T1':
|
|
return <SiTorproject className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
|
case 'A1':
|
|
return <FaUserSecret className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
|
|
case 'A2':
|
|
return <FaSatellite className="w-5 h-5 text-blue-500 dark:text-blue-400" />
|
|
case 'O1':
|
|
case 'EU':
|
|
case 'AP':
|
|
return <GlobeIcon className="w-5 h-5 text-neutral-500 dark:text-neutral-400" />
|
|
}
|
|
|
|
const FlagComponent = (Flags as Record<string, React.ComponentType<{ className?: string }>>)[countryCode]
|
|
return FlagComponent ? <FlagComponent className="w-5 h-5 rounded-sm shadow-sm" /> : null
|
|
}
|
|
|
|
const getCountryName = (code: string) => {
|
|
if (!code || code === 'Unknown') return 'Unknown'
|
|
|
|
switch (code) {
|
|
case 'T1': return 'Tor Network'
|
|
case 'A1': return 'Anonymous Proxy'
|
|
case 'A2': return 'Satellite Provider'
|
|
case 'O1': return 'Other'
|
|
case 'EU': return 'Europe'
|
|
case 'AP': return 'Asia/Pacific'
|
|
}
|
|
|
|
try {
|
|
const regionNames = new Intl.DisplayNames(['en'], { type: 'region' })
|
|
return regionNames.of(code) || code
|
|
} catch (e) {
|
|
return code
|
|
}
|
|
}
|
|
|
|
const getRegionName = (regionCode: string, countryCode: string) => {
|
|
// Check for special country codes first
|
|
switch (countryCode) {
|
|
case 'T1': return 'Tor Network'
|
|
case 'A1': return 'Anonymous Proxy'
|
|
case 'A2': return 'Satellite Provider'
|
|
case 'O1': return 'Other'
|
|
case 'EU': return 'Europe'
|
|
case 'AP': return 'Asia/Pacific'
|
|
}
|
|
|
|
if (!regionCode || regionCode === 'Unknown' || !countryCode || countryCode === 'Unknown') return 'Unknown'
|
|
|
|
try {
|
|
const countryData = iso3166.data[countryCode]
|
|
if (!countryData || !countryData.sub) return regionCode
|
|
|
|
// ISO 3166-2 structure keys are typically "US-OR"
|
|
const fullCode = `${countryCode}-${regionCode}`
|
|
const regionData = countryData.sub[fullCode]
|
|
|
|
if (regionData && regionData.name) {
|
|
return regionData.name
|
|
}
|
|
|
|
return regionCode
|
|
} catch (e) {
|
|
return regionCode
|
|
}
|
|
}
|
|
|
|
const getCityName = (city: string) => {
|
|
// Check for special codes that might appear in city field
|
|
switch (city) {
|
|
case 'T1': return 'Tor Network'
|
|
case 'A1': return 'Anonymous Proxy'
|
|
case 'A2': return 'Satellite Provider'
|
|
case 'O1': return 'Other'
|
|
}
|
|
|
|
if (!city || city === 'Unknown') return 'Unknown'
|
|
return city
|
|
}
|
|
|
|
const getData = () => {
|
|
switch (activeTab) {
|
|
case 'countries': return countries
|
|
case 'regions': return regions
|
|
case 'cities': return cities
|
|
default: return []
|
|
}
|
|
}
|
|
|
|
// Check if the current tab's data is disabled by privacy settings
|
|
const isTabDisabled = () => {
|
|
if (geoDataLevel === 'none') return true
|
|
if (geoDataLevel === 'country' && (activeTab === 'regions' || activeTab === 'cities')) return true
|
|
return false
|
|
}
|
|
|
|
// Filter out "Unknown" entries that result from disabled collection
|
|
const filterUnknown = (data: LocationItem[]) => {
|
|
return data.filter(item => {
|
|
if (activeTab === 'countries') return item.country && item.country !== 'Unknown' && item.country !== ''
|
|
if (activeTab === 'regions') return item.region && item.region !== 'Unknown' && item.region !== ''
|
|
if (activeTab === 'cities') return item.city && item.city !== 'Unknown' && item.city !== ''
|
|
return true
|
|
})
|
|
}
|
|
|
|
const rawData = activeTab === 'map' ? [] : getData()
|
|
const data = filterUnknown(rawData)
|
|
const totalPageviews = data.reduce((sum, item) => sum + item.pageviews, 0)
|
|
const hasData = activeTab === 'map'
|
|
? (countries && filterUnknown(countries).length > 0)
|
|
: (data && data.length > 0)
|
|
const displayedData = (activeTab !== 'map' && hasData) ? data.slice(0, LIMIT) : []
|
|
const emptySlots = Math.max(0, LIMIT - displayedData.length)
|
|
const showViewAll = activeTab !== 'map' && hasData && data.length > LIMIT
|
|
|
|
const getDisabledMessage = () => {
|
|
if (geoDataLevel === 'none') {
|
|
return 'Geographic data collection is disabled in site settings'
|
|
}
|
|
if (geoDataLevel === 'country' && (activeTab === 'regions' || activeTab === 'cities')) {
|
|
return `${activeTab === 'regions' ? 'Region' : 'City'} tracking is disabled. Only country-level data is collected.`
|
|
}
|
|
return 'No data available'
|
|
}
|
|
|
|
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-6">
|
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
|
Locations
|
|
</h3>
|
|
<div className="flex gap-1" role="tablist" aria-label="Location view tabs" onKeyDown={handleTabKeyDown}>
|
|
{(['map', 'countries', 'regions', 'cities'] as Tab[]).map((tab) => (
|
|
<button
|
|
key={tab}
|
|
onClick={() => setActiveTab(tab)}
|
|
role="tab"
|
|
aria-selected={activeTab === tab}
|
|
className={`px-2.5 py-1 text-xs font-medium transition-colors capitalize focus:outline-none focus:ring-2 focus:ring-brand-orange rounded cursor-pointer border-b-2 ${
|
|
activeTab === tab
|
|
? 'border-brand-orange text-neutral-900 dark:text-white'
|
|
: 'border-transparent text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
|
|
}`}
|
|
>
|
|
{tab}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2 flex-1 min-h-[270px]">
|
|
{isTabDisabled() ? (
|
|
<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">{getDisabledMessage()}</p>
|
|
</div>
|
|
) : activeTab === 'map' ? (
|
|
hasData ? <WorldMap data={filterUnknown(countries) as { country: string; pageviews: number }[]} /> : (
|
|
<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 location data yet
|
|
</h4>
|
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
|
Visitor locations will appear here based on anonymous geographic data.
|
|
</p>
|
|
</div>
|
|
)
|
|
) : (
|
|
hasData ? (
|
|
<>
|
|
{displayedData.map((item) => {
|
|
const dim = TAB_TO_DIMENSION[activeTab]
|
|
const filterValue = activeTab === 'countries' ? item.country : activeTab === 'regions' ? item.region : item.city
|
|
const canFilter = onFilter && dim && filterValue
|
|
return (
|
|
<div
|
|
key={`${item.country ?? ''}-${item.region ?? ''}-${item.city ?? ''}`}
|
|
onClick={() => canFilter && onFilter({ dimension: dim, operator: 'is', values: [filterValue!] })}
|
|
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${canFilter ? ' cursor-pointer' : ''}`}
|
|
>
|
|
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
|
<span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>
|
|
<span className="truncate">
|
|
{activeTab === 'countries' ? getCountryName(item.country ?? '') :
|
|
activeTab === 'regions' ? getRegionName(item.region ?? '', item.country ?? '') :
|
|
getCityName(item.city ?? '')}
|
|
</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((item.pageviews / totalPageviews) * 100)}%` : ''}
|
|
</span>
|
|
<span className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
|
{formatNumber(item.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 location data yet
|
|
</h4>
|
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 max-w-xs">
|
|
Visitor locations will appear here based on anonymous geographic data.
|
|
</p>
|
|
</div>
|
|
)
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<Modal
|
|
isOpen={isModalOpen}
|
|
onClose={() => setIsModalOpen(false)}
|
|
title={`Locations - ${activeTab.charAt(0).toUpperCase() + activeTab.slice(1)}`}
|
|
>
|
|
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-2">
|
|
{isLoadingFull ? (
|
|
<div className="py-4">
|
|
<ListSkeleton rows={10} />
|
|
</div>
|
|
) : (
|
|
(fullData.length > 0 ? fullData : data).map((item) => (
|
|
<div key={`${item.country ?? ''}-${item.region ?? ''}-${item.city ?? ''}`} 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">
|
|
<span className="shrink-0">{getFlagComponent(item.country ?? '')}</span>
|
|
<span className="truncate">
|
|
{activeTab === 'countries' ? getCountryName(item.country ?? '') :
|
|
activeTab === 'regions' ? getRegionName(item.region ?? '', item.country ?? '') :
|
|
getCityName(item.city ?? '')}
|
|
</span>
|
|
</div>
|
|
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
|
{formatNumber(item.pageviews)}
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
</>
|
|
)
|
|
} |