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: () => ( +