From b8f0ae4d289c0dff421d7d51823011d797b895db Mon Sep 17 00:00:00 2001 From: AutonetSellCar Deploy Date: Mon, 5 Jan 2026 18:31:22 +0900 Subject: [PATCH] feat: Add real-time visitor map with IP geolocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/app/api/visitor.py | 99 ++++++ backend/app/models/visitor.py | 4 +- backend/app/services/visitor_service.py | 8 +- frontend/package-lock.json | 50 +++ frontend/package.json | 3 + frontend/src/app/admin/visitor-stats/page.tsx | 17 + frontend/src/components/VisitorMap.tsx | 291 ++++++++++++++++++ 7 files changed, 469 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/VisitorMap.tsx diff --git a/backend/app/api/visitor.py b/backend/app/api/visitor.py index 0036669..f6460f4 100644 --- a/backend/app/api/visitor.py +++ b/backend/app/api/visitor.py @@ -332,3 +332,102 @@ def trigger_aggregation( if result: return {"status": "success", "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, + } diff --git a/backend/app/models/visitor.py b/backend/app/models/visitor.py index 8b296fb..00ad1cb 100644 --- a/backend/app/models/visitor.py +++ b/backend/app/models/visitor.py @@ -1,7 +1,7 @@ """ 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 ..database import Base @@ -41,6 +41,8 @@ class VisitorLog(Base): country_code = Column(String(5), nullable=True) city = Column(String(100), nullable=True) region = Column(String(100), nullable=True) + latitude = Column(Float, nullable=True) + longitude = Column(Float, nullable=True) # UTM parameters utm_source = Column(String(100), nullable=True) diff --git a/backend/app/services/visitor_service.py b/backend/app/services/visitor_service.py index d43d3b9..2b58bd1 100644 --- a/backend/app/services/visitor_service.py +++ b/backend/app/services/visitor_service.py @@ -15,7 +15,7 @@ from sqlalchemy import func from ..models.visitor import VisitorLog, VisitorDailyStats, VisitorSession # 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) _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.', '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')): - return {"country": "Local", "country_code": "LO", "region": "", "city": ""} + return {"country": "Local", "country_code": "LO", "region": "", "city": "", "latitude": None, "longitude": None} try: async with httpx.AsyncClient() as client: @@ -94,6 +94,8 @@ async def get_geo_info(ip: str) -> Optional[Dict]: "country_code": data.get("countryCode", ""), "region": data.get("regionName", ""), "city": data.get("city", ""), + "latitude": data.get("lat"), + "longitude": data.get("lon"), } # Cache the result _geo_cache[ip] = result @@ -165,6 +167,8 @@ async def log_visit( country_code=geo_info.get("country_code"), city=geo_info.get("city"), region=geo_info.get("region"), + latitude=geo_info.get("latitude"), + longitude=geo_info.get("longitude"), utm_source=utm_source, utm_medium=utm_medium, utm_campaign=utm_campaign, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0af939e..a01ca8c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index ece3980..83c4528 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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": { diff --git a/frontend/src/app/admin/visitor-stats/page.tsx b/frontend/src/app/admin/visitor-stats/page.tsx index 83b083b..0b1f2bb 100644 --- a/frontend/src/app/admin/visitor-stats/page.tsx +++ b/frontend/src/app/admin/visitor-stats/page.tsx @@ -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: () => ( +
+
+
+
+
+
+ ), +}); + // Country code to name and flag mapping const countryInfo: Record = { MN: { name: 'Mongolia', flag: '🇲🇳' }, @@ -390,6 +404,9 @@ export default function VisitorStatsPage() { {/* Country Stats - Full Width */} + {/* Real-time Visitor Map */} + + {/* Device & Browser Breakdowns */}
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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastUpdate, setLastUpdate] = useState(null); + const [animatingMarkers, setAnimatingMarkers] = useState>(new Set()); + const previousLocationsRef = useRef>(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(); + 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 ( +
+
+
+
+
+
+ ); + } + + if (error) { + return ( +
+
Error: {error}
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+

+ Real-time Visitor Map +

+
+ + {mapData?.total_with_coords || 0} visitors with location + + + Updated: {lastUpdate ? formatTimeAgo(lastUpdate.toISOString()) : 'N/A'} + + +
+
+
+ + {/* Map */} +
+ {typeof window !== 'undefined' && ( + + + + {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 ( + + +
+
+ {getCountryFlag(location.country_code)} {location.city || 'Unknown City'}, {location.country} +
+
+
Visitors: {location.count}
+
Last page: {location.page}
+
Device: {location.device}
+
Last visit: {formatTimeAgo(location.last_visit)}
+
+
+
+
+ ); + })} +
+ )} +
+ + {/* Recent Activity Legend */} +
+
+
+ + Active (last 5 min) +
+
+ + Recent (last hour) +
+
+ + Auto-refresh: every {refreshInterval}s + +
+
+
+ ); +}