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

@@ -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

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>
);
}