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:
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
50
frontend/package-lock.json
generated
50
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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