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:
AutonetSellCar Deploy
2026-01-05 18:31:22 +09:00
parent 4858965087
commit b8f0ae4d28
7 changed files with 469 additions and 3 deletions

View File

@@ -332,3 +332,102 @@ def trigger_aggregation(
if result: if result:
return {"status": "success", "date": date_str} return {"status": "success", "date": date_str}
return {"status": "no_data", "date": date_str} return {"status": "no_data", "date": date_str}
@router.get("/admin/map-data")
def get_visitor_map_data(
minutes: int = 30,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user),
):
"""
Get visitor locations for map display
Returns recent visitors with lat/lng coordinates
"""
start_time = datetime.utcnow() - timedelta(minutes=minutes)
# Get recent visitors with coordinates
visitors = db.query(
VisitorLog.latitude,
VisitorLog.longitude,
VisitorLog.country,
VisitorLog.country_code,
VisitorLog.city,
VisitorLog.visited_at,
VisitorLog.page_path,
VisitorLog.device_type,
).filter(
and_(
VisitorLog.visited_at >= start_time,
VisitorLog.latitude.isnot(None),
VisitorLog.longitude.isnot(None),
)
).order_by(
desc(VisitorLog.visited_at)
).limit(100).all()
# Group by unique location for clustering
locations = []
location_set = set()
for v in visitors:
# Round coordinates for grouping (roughly city-level)
lat_key = round(v.latitude, 2) if v.latitude else 0
lng_key = round(v.longitude, 2) if v.longitude else 0
location_key = f"{lat_key}:{lng_key}"
if location_key not in location_set:
location_set.add(location_key)
locations.append({
"lat": v.latitude,
"lng": v.longitude,
"country": v.country,
"country_code": v.country_code,
"city": v.city,
"last_visit": v.visited_at.isoformat() if v.visited_at else None,
"page": v.page_path,
"device": v.device_type,
"count": 1, # Will be aggregated
})
else:
# Increment count for existing location
for loc in locations:
if round(loc["lat"], 2) == lat_key and round(loc["lng"], 2) == lng_key:
loc["count"] += 1
break
# Get recent activity for animation (last 5 minutes)
recent_start = datetime.utcnow() - timedelta(minutes=5)
recent_visitors = db.query(
VisitorLog.latitude,
VisitorLog.longitude,
VisitorLog.country,
VisitorLog.city,
VisitorLog.visited_at,
).filter(
and_(
VisitorLog.visited_at >= recent_start,
VisitorLog.latitude.isnot(None),
VisitorLog.longitude.isnot(None),
)
).order_by(
desc(VisitorLog.visited_at)
).limit(20).all()
recent = [
{
"lat": v.latitude,
"lng": v.longitude,
"country": v.country,
"city": v.city,
"time": v.visited_at.isoformat() if v.visited_at else None,
}
for v in recent_visitors
]
return {
"locations": locations,
"recent": recent,
"total_with_coords": len(visitors),
"minutes": minutes,
}

View File

@@ -1,7 +1,7 @@
""" """
Visitor tracking models for analytics Visitor tracking models for analytics
""" """
from sqlalchemy import Column, Integer, String, DateTime, Text, Index from sqlalchemy import Column, Integer, String, DateTime, Text, Index, Float
from sqlalchemy.sql import func from sqlalchemy.sql import func
from ..database import Base from ..database import Base
@@ -41,6 +41,8 @@ class VisitorLog(Base):
country_code = Column(String(5), nullable=True) country_code = Column(String(5), nullable=True)
city = Column(String(100), nullable=True) city = Column(String(100), nullable=True)
region = Column(String(100), nullable=True) region = Column(String(100), nullable=True)
latitude = Column(Float, nullable=True)
longitude = Column(Float, nullable=True)
# UTM parameters # UTM parameters
utm_source = Column(String(100), nullable=True) utm_source = Column(String(100), nullable=True)

View File

@@ -15,7 +15,7 @@ from sqlalchemy import func
from ..models.visitor import VisitorLog, VisitorDailyStats, VisitorSession from ..models.visitor import VisitorLog, VisitorDailyStats, VisitorSession
# IP Geolocation service (free, 45 req/min limit) # IP Geolocation service (free, 45 req/min limit)
IP_API_URL = "http://ip-api.com/json/{ip}?fields=status,country,countryCode,regionName,city" IP_API_URL = "http://ip-api.com/json/{ip}?fields=status,country,countryCode,regionName,city,lat,lon"
# Cache for IP geolocation results (in-memory, simple) # Cache for IP geolocation results (in-memory, simple)
_geo_cache: Dict[str, Dict] = {} _geo_cache: Dict[str, Dict] = {}
@@ -77,7 +77,7 @@ async def get_geo_info(ip: str) -> Optional[Dict]:
if ip.startswith(('127.', '192.168.', '10.', '172.16.', '172.17.', '172.18.', '172.19.', if ip.startswith(('127.', '192.168.', '10.', '172.16.', '172.17.', '172.18.', '172.19.',
'172.20.', '172.21.', '172.22.', '172.23.', '172.24.', '172.25.', '172.20.', '172.21.', '172.22.', '172.23.', '172.24.', '172.25.',
'172.26.', '172.27.', '172.28.', '172.29.', '172.30.', '172.31.', 'localhost', '::1')): '172.26.', '172.27.', '172.28.', '172.29.', '172.30.', '172.31.', 'localhost', '::1')):
return {"country": "Local", "country_code": "LO", "region": "", "city": ""} return {"country": "Local", "country_code": "LO", "region": "", "city": "", "latitude": None, "longitude": None}
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
@@ -94,6 +94,8 @@ async def get_geo_info(ip: str) -> Optional[Dict]:
"country_code": data.get("countryCode", ""), "country_code": data.get("countryCode", ""),
"region": data.get("regionName", ""), "region": data.get("regionName", ""),
"city": data.get("city", ""), "city": data.get("city", ""),
"latitude": data.get("lat"),
"longitude": data.get("lon"),
} }
# Cache the result # Cache the result
_geo_cache[ip] = result _geo_cache[ip] = result
@@ -165,6 +167,8 @@ async def log_visit(
country_code=geo_info.get("country_code"), country_code=geo_info.get("country_code"),
city=geo_info.get("city"), city=geo_info.get("city"),
region=geo_info.get("region"), region=geo_info.get("region"),
latitude=geo_info.get("latitude"),
longitude=geo_info.get("longitude"),
utm_source=utm_source, utm_source=utm_source,
utm_medium=utm_medium, utm_medium=utm_medium,
utm_campaign=utm_campaign, utm_campaign=utm_campaign,

View File

@@ -11,12 +11,15 @@
"@heroicons/react": "^2.1.1", "@heroicons/react": "^2.1.1",
"@stripe/react-stripe-js": "^5.4.1", "@stripe/react-stripe-js": "^5.4.1",
"@stripe/stripe-js": "^8.6.0", "@stripe/stripe-js": "^8.6.0",
"@types/leaflet": "^1.9.21",
"axios": "^1.6.5", "axios": "^1.6.5",
"framer-motion": "^12.23.25", "framer-motion": "^12.23.25",
"leaflet": "^1.9.4",
"next": "14.1.0", "next": "14.1.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.49.3", "react-hook-form": "^7.49.3",
"react-leaflet": "^4.2.1",
"zustand": "^4.5.0" "zustand": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
@@ -278,6 +281,17 @@
"node": ">= 8" "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": { "node_modules/@stripe/react-stripe-js": {
"version": "5.4.1", "version": "5.4.1",
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-5.4.1.tgz", "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-5.4.1.tgz",
@@ -311,6 +325,21 @@
"tslib": "^2.4.0" "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": { "node_modules/@types/node": {
"version": "20.19.25", "version": "20.19.25",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz",
@@ -1083,6 +1112,13 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT" "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": { "node_modules/lilconfig": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -1631,6 +1667,20 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT" "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": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",

View File

@@ -14,12 +14,15 @@
"@heroicons/react": "^2.1.1", "@heroicons/react": "^2.1.1",
"@stripe/react-stripe-js": "^5.4.1", "@stripe/react-stripe-js": "^5.4.1",
"@stripe/stripe-js": "^8.6.0", "@stripe/stripe-js": "^8.6.0",
"@types/leaflet": "^1.9.21",
"axios": "^1.6.5", "axios": "^1.6.5",
"framer-motion": "^12.23.25", "framer-motion": "^12.23.25",
"leaflet": "^1.9.4",
"next": "14.1.0", "next": "14.1.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.49.3", "react-hook-form": "^7.49.3",
"react-leaflet": "^4.2.1",
"zustand": "^4.5.0" "zustand": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,8 +1,22 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import dynamic from 'next/dynamic';
import { visitorApi, VisitorStatsOverview, TopPage, TopReferrer, ChartData, RealtimeStats } from '@/lib/api'; 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 // Country code to name and flag mapping
const countryInfo: Record<string, { name: string; flag: string }> = { const countryInfo: Record<string, { name: string; flag: string }> = {
MN: { name: 'Mongolia', flag: '🇲🇳' }, MN: { name: 'Mongolia', flag: '🇲🇳' },
@@ -390,6 +404,9 @@ export default function VisitorStatsPage() {
{/* Country Stats - Full Width */} {/* Country Stats - Full Width */}
<CountryStatsCard data={overview?.country_breakdown || {}} /> <CountryStatsCard data={overview?.country_breakdown || {}} />
{/* Real-time Visitor Map */}
<VisitorMap refreshInterval={30} />
{/* Device & Browser Breakdowns */} {/* Device & Browser Breakdowns */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<BreakdownCard <BreakdownCard

View 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='&copy; <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>
);
}