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:
50
frontend/package-lock.json
generated
50
frontend/package-lock.json
generated
@@ -11,12 +11,15 @@
|
||||
"@heroicons/react": "^2.1.1",
|
||||
"@stripe/react-stripe-js": "^5.4.1",
|
||||
"@stripe/stripe-js": "^8.6.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"axios": "^1.6.5",
|
||||
"framer-motion": "^12.23.25",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "14.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.49.3",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"zustand": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -278,6 +281,17 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-leaflet/core": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
|
||||
"integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@stripe/react-stripe-js": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-5.4.1.tgz",
|
||||
@@ -311,6 +325,21 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.25",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz",
|
||||
@@ -1083,6 +1112,13 @@
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -1631,6 +1667,20 @@
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
|
||||
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"dependencies": {
|
||||
"@react-leaflet/core": "^2.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
|
||||
@@ -14,12 +14,15 @@
|
||||
"@heroicons/react": "^2.1.1",
|
||||
"@stripe/react-stripe-js": "^5.4.1",
|
||||
"@stripe/stripe-js": "^8.6.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"axios": "^1.6.5",
|
||||
"framer-motion": "^12.23.25",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "14.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.49.3",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"zustand": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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