feat: Implement user signup API and analytics dashboard with summary API, map, and chart components, updating dependencies.
This commit is contained in:
192
src/components/analytics/GeoMap.tsx
Normal file
192
src/components/analytics/GeoMap.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
'use client';
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import {
|
||||
ComposableMap,
|
||||
Geographies,
|
||||
Geography,
|
||||
ZoomableGroup,
|
||||
} from 'react-simple-maps';
|
||||
import { scaleLinear } from 'd3-scale';
|
||||
|
||||
// TopoJSON world map
|
||||
const geoUrl = 'https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json';
|
||||
|
||||
// ISO Alpha-2 to country name mapping for common countries
|
||||
const countryNameToCode: Record<string, string> = {
|
||||
'United States': 'US',
|
||||
'USA': 'US',
|
||||
'US': 'US',
|
||||
'Germany': 'DE',
|
||||
'DE': 'DE',
|
||||
'United Kingdom': 'GB',
|
||||
'UK': 'GB',
|
||||
'GB': 'GB',
|
||||
'France': 'FR',
|
||||
'FR': 'FR',
|
||||
'Canada': 'CA',
|
||||
'CA': 'CA',
|
||||
'Australia': 'AU',
|
||||
'AU': 'AU',
|
||||
'Japan': 'JP',
|
||||
'JP': 'JP',
|
||||
'China': 'CN',
|
||||
'CN': 'CN',
|
||||
'India': 'IN',
|
||||
'IN': 'IN',
|
||||
'Brazil': 'BR',
|
||||
'BR': 'BR',
|
||||
'Spain': 'ES',
|
||||
'ES': 'ES',
|
||||
'Italy': 'IT',
|
||||
'IT': 'IT',
|
||||
'Netherlands': 'NL',
|
||||
'NL': 'NL',
|
||||
'Switzerland': 'CH',
|
||||
'CH': 'CH',
|
||||
'Austria': 'AT',
|
||||
'AT': 'AT',
|
||||
'Poland': 'PL',
|
||||
'PL': 'PL',
|
||||
'Sweden': 'SE',
|
||||
'SE': 'SE',
|
||||
'Norway': 'NO',
|
||||
'NO': 'NO',
|
||||
'Denmark': 'DK',
|
||||
'DK': 'DK',
|
||||
'Finland': 'FI',
|
||||
'FI': 'FI',
|
||||
'Belgium': 'BE',
|
||||
'BE': 'BE',
|
||||
'Portugal': 'PT',
|
||||
'PT': 'PT',
|
||||
'Ireland': 'IE',
|
||||
'IE': 'IE',
|
||||
'Mexico': 'MX',
|
||||
'MX': 'MX',
|
||||
'Argentina': 'AR',
|
||||
'AR': 'AR',
|
||||
'South Korea': 'KR',
|
||||
'KR': 'KR',
|
||||
'Singapore': 'SG',
|
||||
'SG': 'SG',
|
||||
'New Zealand': 'NZ',
|
||||
'NZ': 'NZ',
|
||||
'Russia': 'RU',
|
||||
'RU': 'RU',
|
||||
'South Africa': 'ZA',
|
||||
'ZA': 'ZA',
|
||||
'Unknown Location': 'UNKNOWN',
|
||||
'unknown': 'UNKNOWN',
|
||||
};
|
||||
|
||||
// ISO Alpha-2 to ISO Alpha-3 mapping (for matching with TopoJSON)
|
||||
const alpha2ToAlpha3: Record<string, string> = {
|
||||
'US': 'USA',
|
||||
'DE': 'DEU',
|
||||
'GB': 'GBR',
|
||||
'FR': 'FRA',
|
||||
'CA': 'CAN',
|
||||
'AU': 'AUS',
|
||||
'JP': 'JPN',
|
||||
'CN': 'CHN',
|
||||
'IN': 'IND',
|
||||
'BR': 'BRA',
|
||||
'ES': 'ESP',
|
||||
'IT': 'ITA',
|
||||
'NL': 'NLD',
|
||||
'CH': 'CHE',
|
||||
'AT': 'AUT',
|
||||
'PL': 'POL',
|
||||
'SE': 'SWE',
|
||||
'NO': 'NOR',
|
||||
'DK': 'DNK',
|
||||
'FI': 'FIN',
|
||||
'BE': 'BEL',
|
||||
'PT': 'PRT',
|
||||
'IE': 'IRL',
|
||||
'MX': 'MEX',
|
||||
'AR': 'ARG',
|
||||
'KR': 'KOR',
|
||||
'SG': 'SGP',
|
||||
'NZ': 'NZL',
|
||||
'RU': 'RUS',
|
||||
'ZA': 'ZAF',
|
||||
};
|
||||
|
||||
interface CountryStat {
|
||||
country: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
interface GeoMapProps {
|
||||
countryStats: CountryStat[];
|
||||
totalScans: number;
|
||||
}
|
||||
|
||||
const GeoMap: React.FC<GeoMapProps> = ({ countryStats, totalScans }) => {
|
||||
// Build a map of ISO Alpha-3 codes to scan counts
|
||||
const countryData: Record<string, number> = {};
|
||||
let maxCount = 0;
|
||||
|
||||
countryStats.forEach((stat) => {
|
||||
const alpha2 = countryNameToCode[stat.country] || stat.country;
|
||||
const alpha3 = alpha2ToAlpha3[alpha2];
|
||||
if (alpha3) {
|
||||
countryData[alpha3] = stat.count;
|
||||
if (stat.count > maxCount) maxCount = stat.count;
|
||||
}
|
||||
});
|
||||
|
||||
// Color scale: light blue to dark blue based on scan count
|
||||
const colorScale = scaleLinear<string>()
|
||||
.domain([0, maxCount || 1])
|
||||
.range(['#E0F2FE', '#1E40AF']);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<ComposableMap
|
||||
projection="geoMercator"
|
||||
projectionConfig={{
|
||||
scale: 120,
|
||||
center: [0, 30],
|
||||
}}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
<ZoomableGroup center={[0, 30]} zoom={1}>
|
||||
<Geographies geography={geoUrl}>
|
||||
{({ geographies }) =>
|
||||
geographies.map((geo) => {
|
||||
const isoCode = geo.properties.ISO_A3 || geo.id;
|
||||
const scanCount = countryData[isoCode] || 0;
|
||||
const fillColor = scanCount > 0 ? colorScale(scanCount) : '#F1F5F9';
|
||||
|
||||
return (
|
||||
<Geography
|
||||
key={geo.rsmKey}
|
||||
geography={geo}
|
||||
fill={fillColor}
|
||||
stroke="#CBD5E1"
|
||||
strokeWidth={0.5}
|
||||
style={{
|
||||
default: { outline: 'none' },
|
||||
hover: {
|
||||
fill: scanCount > 0 ? '#3B82F6' : '#E2E8F0',
|
||||
outline: 'none',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
pressed: { outline: 'none' },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</Geographies>
|
||||
</ZoomableGroup>
|
||||
</ComposableMap>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(GeoMap);
|
||||
86
src/components/analytics/Sparkline.tsx
Normal file
86
src/components/analytics/Sparkline.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Filler,
|
||||
} from 'chart.js';
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Filler);
|
||||
|
||||
interface SparklineProps {
|
||||
data: number[];
|
||||
color?: 'blue' | 'green' | 'red';
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const colorMap = {
|
||||
blue: {
|
||||
border: 'rgb(59, 130, 246)',
|
||||
background: 'rgba(59, 130, 246, 0.1)',
|
||||
},
|
||||
green: {
|
||||
border: 'rgb(34, 197, 94)',
|
||||
background: 'rgba(34, 197, 94, 0.1)',
|
||||
},
|
||||
red: {
|
||||
border: 'rgb(239, 68, 68)',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
},
|
||||
};
|
||||
|
||||
const Sparkline: React.FC<SparklineProps> = ({
|
||||
data,
|
||||
color = 'blue',
|
||||
width = 100,
|
||||
height = 30,
|
||||
}) => {
|
||||
const colors = colorMap[color];
|
||||
|
||||
const chartData = {
|
||||
labels: data.map((_, i) => i.toString()),
|
||||
datasets: [
|
||||
{
|
||||
data,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.background,
|
||||
borderWidth: 1.5,
|
||||
pointRadius: 0,
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const options = {
|
||||
responsive: false,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { enabled: false },
|
||||
},
|
||||
scales: {
|
||||
x: { display: false },
|
||||
y: { display: false },
|
||||
},
|
||||
elements: {
|
||||
line: {
|
||||
borderJoinStyle: 'round' as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ width, height }}>
|
||||
<Line data={chartData} options={options} width={width} height={height} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sparkline;
|
||||
103
src/components/analytics/StatCard.tsx
Normal file
103
src/components/analytics/StatCard.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
subtitle?: string;
|
||||
trend?: {
|
||||
direction: 'up' | 'down' | 'flat';
|
||||
percentage: number;
|
||||
isNew?: boolean;
|
||||
period?: string;
|
||||
};
|
||||
icon?: React.ReactNode;
|
||||
variant?: 'default' | 'highlight';
|
||||
}
|
||||
|
||||
const StatCard: React.FC<StatCardProps> = ({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
trend,
|
||||
icon,
|
||||
variant = 'default',
|
||||
}) => {
|
||||
const getTrendColor = () => {
|
||||
if (!trend) return 'text-gray-500';
|
||||
if (trend.direction === 'up') return 'text-emerald-600';
|
||||
if (trend.direction === 'down') return 'text-red-500';
|
||||
return 'text-gray-500';
|
||||
};
|
||||
|
||||
const getTrendIcon = () => {
|
||||
if (!trend) return null;
|
||||
if (trend.direction === 'up') return <TrendingUp className="w-4 h-4" />;
|
||||
if (trend.direction === 'down') return <TrendingDown className="w-4 h-4" />;
|
||||
return <Minus className="w-4 h-4" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl p-6 transition-all duration-200 ${variant === 'highlight'
|
||||
? 'bg-gradient-to-br from-primary-500 to-primary-600 text-white shadow-lg shadow-primary-500/25'
|
||||
: 'bg-white border border-gray-200 hover:shadow-md'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p
|
||||
className={`text-sm font-medium ${variant === 'highlight' ? 'text-primary-100' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
<p
|
||||
className={`text-3xl font-bold mt-2 ${variant === 'highlight' ? 'text-white' : 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{typeof value === 'number' ? value.toLocaleString() : value}
|
||||
</p>
|
||||
{trend && (
|
||||
<div className={`flex items-center gap-1 mt-3 ${getTrendColor()}`}>
|
||||
{getTrendIcon()}
|
||||
<span className="text-sm font-medium">
|
||||
{trend.direction === 'up' ? '+' : trend.direction === 'down' ? '-' : ''}
|
||||
{trend.percentage}%
|
||||
{trend.isNew && ' (new)'}
|
||||
</span>
|
||||
{trend.period && (
|
||||
<span
|
||||
className={`text-sm ${variant === 'highlight' ? 'text-primary-200' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
vs last {trend.period}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{subtitle && !trend && (
|
||||
<p
|
||||
className={`text-sm mt-2 ${variant === 'highlight' ? 'text-primary-200' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{icon && (
|
||||
<div
|
||||
className={`p-3 rounded-lg ${variant === 'highlight' ? 'bg-white/20' : 'bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatCard;
|
||||
3
src/components/analytics/index.ts
Normal file
3
src/components/analytics/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as GeoMap } from './GeoMap';
|
||||
export { default as Sparkline } from './Sparkline';
|
||||
export { default as StatCard } from './StatCard';
|
||||
Reference in New Issue
Block a user