feat: Add real-time visitor map with IP geolocation
- Add latitude/longitude columns to visitor_logs model - Update visitor_service to fetch and store coordinates from ip-api.com - Add /admin/map-data API endpoint for map visualization - Create VisitorMap component using Leaflet/OpenStreetMap - Integrate map into visitor-stats admin page - 30-second auto-refresh with animation for new visitors - Color-coded markers (red: active, blue: recent) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { visitorApi, VisitorStatsOverview, TopPage, TopReferrer, ChartData, RealtimeStats } from '@/lib/api';
|
||||
|
||||
// Dynamic import for VisitorMap (no SSR - Leaflet requires browser)
|
||||
const VisitorMap = dynamic(() => import('@/components/VisitorMap'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-6 bg-gray-200 rounded w-48 mb-4"></div>
|
||||
<div className="h-96 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
// Country code to name and flag mapping
|
||||
const countryInfo: Record<string, { name: string; flag: string }> = {
|
||||
MN: { name: 'Mongolia', flag: '🇲🇳' },
|
||||
@@ -390,6 +404,9 @@ export default function VisitorStatsPage() {
|
||||
{/* Country Stats - Full Width */}
|
||||
<CountryStatsCard data={overview?.country_breakdown || {}} />
|
||||
|
||||
{/* Real-time Visitor Map */}
|
||||
<VisitorMap refreshInterval={30} />
|
||||
|
||||
{/* Device & Browser Breakdowns */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<BreakdownCard
|
||||
|
||||
291
frontend/src/components/VisitorMap.tsx
Normal file
291
frontend/src/components/VisitorMap.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
// Types for map data
|
||||
interface VisitorLocation {
|
||||
lat: number;
|
||||
lng: number;
|
||||
country: string;
|
||||
country_code: string;
|
||||
city: string;
|
||||
last_visit: string;
|
||||
page: string;
|
||||
device: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface RecentVisitor {
|
||||
lat: number;
|
||||
lng: number;
|
||||
country: string;
|
||||
city: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
interface MapData {
|
||||
locations: VisitorLocation[];
|
||||
recent: RecentVisitor[];
|
||||
total_with_coords: number;
|
||||
minutes: number;
|
||||
}
|
||||
|
||||
interface VisitorMapProps {
|
||||
refreshInterval?: number; // in seconds, default 30
|
||||
}
|
||||
|
||||
// Dynamic import for Leaflet components (no SSR)
|
||||
const MapContainer = dynamic(
|
||||
() => import('react-leaflet').then((mod) => mod.MapContainer),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const TileLayer = dynamic(
|
||||
() => import('react-leaflet').then((mod) => mod.TileLayer),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const CircleMarker = dynamic(
|
||||
() => import('react-leaflet').then((mod) => mod.CircleMarker),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const Popup = dynamic(
|
||||
() => import('react-leaflet').then((mod) => mod.Popup),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
// Country flag emoji helper
|
||||
function getCountryFlag(countryCode: string): string {
|
||||
if (!countryCode || countryCode.length !== 2) return '';
|
||||
const codePoints = countryCode
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map((char) => 127397 + char.charCodeAt(0));
|
||||
return String.fromCodePoint(...codePoints);
|
||||
}
|
||||
|
||||
// Time ago formatter
|
||||
function formatTimeAgo(isoString: string): string {
|
||||
if (!isoString) return 'N/A';
|
||||
const date = new Date(isoString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins === 1) return '1 min ago';
|
||||
if (diffMins < 60) return `${diffMins} mins ago`;
|
||||
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours === 1) return '1 hour ago';
|
||||
return `${diffHours} hours ago`;
|
||||
}
|
||||
|
||||
export default function VisitorMap({ refreshInterval = 30 }: VisitorMapProps) {
|
||||
const [mapData, setMapData] = useState<MapData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||||
const [animatingMarkers, setAnimatingMarkers] = useState<Set<string>>(new Set());
|
||||
const previousLocationsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Fetch map data
|
||||
const fetchMapData = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
setError('Not authenticated');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/visitor/admin/map-data?minutes=60', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch map data');
|
||||
}
|
||||
|
||||
const data: MapData = await response.json();
|
||||
|
||||
// Detect new locations for animation
|
||||
const newLocationKeys = new Set<string>();
|
||||
const currentKeys = new Set(data.locations.map(l => `${l.lat.toFixed(2)}:${l.lng.toFixed(2)}`));
|
||||
|
||||
currentKeys.forEach(key => {
|
||||
if (!previousLocationsRef.current.has(key)) {
|
||||
newLocationKeys.add(key);
|
||||
}
|
||||
});
|
||||
|
||||
if (newLocationKeys.size > 0) {
|
||||
setAnimatingMarkers(newLocationKeys);
|
||||
// Clear animation after 2 seconds
|
||||
setTimeout(() => setAnimatingMarkers(new Set()), 2000);
|
||||
}
|
||||
|
||||
previousLocationsRef.current = currentKeys;
|
||||
setMapData(data);
|
||||
setLastUpdate(new Date());
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial fetch and polling
|
||||
useEffect(() => {
|
||||
fetchMapData();
|
||||
|
||||
const interval = setInterval(fetchMapData, refreshInterval * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshInterval]);
|
||||
|
||||
// Load Leaflet CSS
|
||||
useEffect(() => {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
||||
document.head.appendChild(link);
|
||||
|
||||
return () => {
|
||||
document.head.removeChild(link);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (loading && !mapData) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-6 bg-gray-200 rounded w-48 mb-4"></div>
|
||||
<div className="h-96 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="text-red-500">Error: {error}</div>
|
||||
<button
|
||||
onClick={fetchMapData}
|
||||
className="mt-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Real-time Visitor Map
|
||||
</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-500">
|
||||
{mapData?.total_with_coords || 0} visitors with location
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
Updated: {lastUpdate ? formatTimeAgo(lastUpdate.toISOString()) : 'N/A'}
|
||||
</span>
|
||||
<button
|
||||
onClick={fetchMapData}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors"
|
||||
title="Refresh"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map */}
|
||||
<div className="h-[500px] relative">
|
||||
{typeof window !== 'undefined' && (
|
||||
<MapContainer
|
||||
center={[35, 105]}
|
||||
zoom={3}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
scrollWheelZoom={true}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
|
||||
{mapData?.locations.map((location, index) => {
|
||||
const key = `${location.lat.toFixed(2)}:${location.lng.toFixed(2)}`;
|
||||
const isAnimating = animatingMarkers.has(key);
|
||||
const isRecent = mapData.recent.some(
|
||||
r => Math.abs(r.lat - location.lat) < 0.01 && Math.abs(r.lng - location.lng) < 0.01
|
||||
);
|
||||
|
||||
// Size based on visitor count
|
||||
const radius = Math.min(15, 6 + Math.log2(location.count + 1) * 3);
|
||||
|
||||
return (
|
||||
<CircleMarker
|
||||
key={`${location.lat}-${location.lng}-${index}`}
|
||||
center={[location.lat, location.lng]}
|
||||
radius={radius}
|
||||
pathOptions={{
|
||||
color: isRecent ? '#ef4444' : '#3b82f6',
|
||||
fillColor: isRecent ? '#ef4444' : '#3b82f6',
|
||||
fillOpacity: isAnimating ? 0.9 : 0.6,
|
||||
weight: isAnimating ? 3 : 2,
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div className="text-sm">
|
||||
<div className="font-semibold mb-1">
|
||||
{getCountryFlag(location.country_code)} {location.city || 'Unknown City'}, {location.country}
|
||||
</div>
|
||||
<div className="text-gray-600 space-y-1">
|
||||
<div>Visitors: {location.count}</div>
|
||||
<div>Last page: {location.page}</div>
|
||||
<div>Device: {location.device}</div>
|
||||
<div>Last visit: {formatTimeAgo(location.last_visit)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</CircleMarker>
|
||||
);
|
||||
})}
|
||||
</MapContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent Activity Legend */}
|
||||
<div className="px-6 py-3 bg-gray-50 border-t border-gray-200">
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-red-500"></span>
|
||||
<span className="text-gray-600">Active (last 5 min)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-blue-500"></span>
|
||||
<span className="text-gray-600">Recent (last hour)</span>
|
||||
</div>
|
||||
<div className="flex-1"></div>
|
||||
<span className="text-gray-400">
|
||||
Auto-refresh: every {refreshInterval}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user