diff --git a/package-lock.json b/package-lock.json
index f4b0dd3..445145e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,9 +13,11 @@
"@edge-runtime/cookies": "^6.0.0",
"@prisma/client": "^5.7.0",
"@stripe/stripe-js": "^8.0.0",
+ "@types/d3-scale": "^4.0.9",
"bcryptjs": "^2.4.3",
"chart.js": "^4.4.0",
"clsx": "^2.0.0",
+ "d3-scale": "^4.0.2",
"dayjs": "^1.11.10",
"exceljs": "^4.4.0",
"file-saver": "^2.0.5",
@@ -35,6 +37,7 @@
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-i18next": "^13.5.0",
+ "react-simple-maps": "^3.0.0",
"resend": "^6.4.2",
"sharp": "^0.33.1",
"stripe": "^19.1.0",
@@ -1931,6 +1934,21 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+ "license": "MIT"
+ },
"node_modules/@types/file-saver": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz",
@@ -3652,6 +3670,205 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/d3-array": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+ "license": "ISC",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dispatch": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz",
+ "integrity": "sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/d3-drag": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-2.0.0.tgz",
+ "integrity": "sha512-g9y9WbMnF5uqB9qKqwIIa/921RYWzlUDv9Jl1/yONQwxbOfszAWTCm8u7HOTgJgRDXiRZN56cHT9pd24dmXs8w==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-dispatch": "1 - 2",
+ "d3-selection": "2"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-2.0.0.tgz",
+ "integrity": "sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/d3-format": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
+ "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-geo": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-2.0.2.tgz",
+ "integrity": "sha512-8pM1WGMLGFuhq9S+FpPURxic+gKzjluCD/CHTuUF3mXMeiCo0i6R0tO1s4+GArRFde96SLcW/kOFRjoAosPsFA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-array": "^2.5.0"
+ }
+ },
+ "node_modules/d3-geo/node_modules/d3-array": {
+ "version": "2.12.1",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz",
+ "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "internmap": "^1.0.0"
+ }
+ },
+ "node_modules/d3-geo/node_modules/internmap": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz",
+ "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==",
+ "license": "ISC"
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-selection": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-2.0.0.tgz",
+ "integrity": "sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz",
+ "integrity": "sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/d3-transition": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-2.0.0.tgz",
+ "integrity": "sha512-42ltAGgJesfQE3u9LuuBHNbGrI/AJjNL2OAUdclE70UE6Vy239GCBEYD38uBPoLeNsOhFStGpPI0BAOV+HMxog==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-color": "1 - 2",
+ "d3-dispatch": "1 - 2",
+ "d3-ease": "1 - 2",
+ "d3-interpolate": "1 - 2",
+ "d3-timer": "1 - 2"
+ },
+ "peerDependencies": {
+ "d3-selection": "2"
+ }
+ },
+ "node_modules/d3-transition/node_modules/d3-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz",
+ "integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/d3-transition/node_modules/d3-interpolate": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz",
+ "integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-color": "1 - 2"
+ }
+ },
+ "node_modules/d3-zoom": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-2.0.0.tgz",
+ "integrity": "sha512-fFg7aoaEm9/jf+qfstak0IYpnesZLiMX6GZvXtUSdv8RH2o4E2qeelgdU09eKS6wGuiGMfcnMI0nTIqWzRHGpw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-dispatch": "1 - 2",
+ "d3-drag": "2",
+ "d3-interpolate": "1 - 2",
+ "d3-selection": "2",
+ "d3-transition": "2"
+ }
+ },
+ "node_modules/d3-zoom/node_modules/d3-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz",
+ "integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/d3-zoom/node_modules/d3-interpolate": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz",
+ "integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-color": "1 - 2"
+ }
+ },
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -5449,6 +5666,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/ioredis": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.2.tgz",
@@ -7504,6 +7730,23 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
+ "node_modules/react-simple-maps": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/react-simple-maps/-/react-simple-maps-3.0.0.tgz",
+ "integrity": "sha512-vKNFrvpPG8Vyfdjnz5Ne1N56rZlDfHXv5THNXOVZMqbX1rWZA48zQuYT03mx6PAKanqarJu/PDLgshIZAfHHqw==",
+ "license": "MIT",
+ "dependencies": {
+ "d3-geo": "^2.0.2",
+ "d3-selection": "^2.0.0",
+ "d3-zoom": "^2.0.0",
+ "topojson-client": "^3.1.0"
+ },
+ "peerDependencies": {
+ "prop-types": "^15.7.2",
+ "react": "^16.8.0 || 17.x || 18.x",
+ "react-dom": "^16.8.0 || 17.x || 18.x"
+ }
+ },
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -8701,6 +8944,26 @@
"node": ">=8.0"
}
},
+ "node_modules/topojson-client": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
+ "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
+ "license": "ISC",
+ "dependencies": {
+ "commander": "2"
+ },
+ "bin": {
+ "topo2geo": "bin/topo2geo",
+ "topomerge": "bin/topomerge",
+ "topoquantize": "bin/topoquantize"
+ }
+ },
+ "node_modules/topojson-client/node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "license": "MIT"
+ },
"node_modules/traverse": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz",
diff --git a/package.json b/package.json
index 39ecd7c..bd0aaa0 100644
--- a/package.json
+++ b/package.json
@@ -29,9 +29,11 @@
"@edge-runtime/cookies": "^6.0.0",
"@prisma/client": "^5.7.0",
"@stripe/stripe-js": "^8.0.0",
+ "@types/d3-scale": "^4.0.9",
"bcryptjs": "^2.4.3",
"chart.js": "^4.4.0",
"clsx": "^2.0.0",
+ "d3-scale": "^4.0.2",
"dayjs": "^1.11.10",
"exceljs": "^4.4.0",
"file-saver": "^2.0.5",
@@ -51,6 +53,7 @@
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-i18next": "^13.5.0",
+ "react-simple-maps": "^3.0.0",
"resend": "^6.4.2",
"sharp": "^0.33.1",
"stripe": "^19.1.0",
diff --git a/src/app/(app)/analytics/page.tsx b/src/app/(app)/analytics/page.tsx
index db67862..61f5710 100644
--- a/src/app/(app)/analytics/page.tsx
+++ b/src/app/(app)/analytics/page.tsx
@@ -1,12 +1,14 @@
'use client';
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useCallback } from 'react';
+import dynamic from 'next/dynamic';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Table } from '@/components/ui/Table';
import { useTranslation } from '@/hooks/useTranslation';
-import { Line, Bar, Doughnut } from 'react-chartjs-2';
+import { StatCard, Sparkline } from '@/components/analytics';
+import { Line, Doughnut } from 'react-chartjs-2';
import {
Chart as ChartJS,
CategoryScale,
@@ -20,6 +22,27 @@ import {
Legend,
Filler,
} from 'chart.js';
+import {
+ BarChart3,
+ Users,
+ Smartphone,
+ Globe,
+ Calendar,
+ Download,
+ TrendingUp,
+ QrCode,
+ HelpCircle,
+} from 'lucide-react';
+
+// Dynamically import GeoMap to avoid SSR issues with d3
+const GeoMap = dynamic(() => import('@/components/analytics/GeoMap'), {
+ ssr: false,
+ loading: () => (
+
+ Loading map...
+
+ ),
+});
ChartJS.register(
CategoryScale,
@@ -34,87 +57,102 @@ ChartJS.register(
Filler
);
+interface QRPerformance {
+ id: string;
+ title: string;
+ type: string;
+ totalScans: number;
+ uniqueScans: number;
+ conversion: number;
+ trend: 'up' | 'down' | 'flat';
+ trendPercentage: number;
+ sparkline: number[];
+ lastScanned: string | null;
+ isNew?: boolean;
+}
+
+interface CountryStat {
+ country: string;
+ count: number;
+ percentage: number;
+ trend: 'up' | 'down' | 'flat';
+ trendPercentage: number;
+ isNew?: boolean;
+}
+
+interface AnalyticsData {
+ summary: {
+ totalScans: number;
+ uniqueScans: number;
+ avgScansPerQR: number;
+ mobilePercentage: number;
+ topCountry: string;
+ topCountryPercentage: number;
+ scansTrend?: { trend: 'up' | 'down' | 'flat'; percentage: number; isNew?: boolean };
+ avgScansTrend?: { trend: 'up' | 'down' | 'flat'; percentage: number; isNew?: boolean };
+ comparisonPeriod?: string;
+ };
+ deviceStats: Record;
+ countryStats: CountryStat[];
+ dailyScans: Record;
+ qrPerformance: QRPerformance[];
+}
+
export default function AnalyticsPage() {
const { t } = useTranslation();
const [timeRange, setTimeRange] = useState('7');
const [loading, setLoading] = useState(true);
- const [analyticsData, setAnalyticsData] = useState(null);
+ const [analyticsData, setAnalyticsData] = useState(null);
- useEffect(() => {
- fetchAnalytics();
- }, [timeRange]);
-
- const fetchAnalytics = async () => {
+ const fetchAnalytics = useCallback(async () => {
+ setLoading(true);
try {
- const response = await fetch('/api/analytics/summary');
+ const response = await fetch(`/api/analytics/summary?range=${timeRange}`);
if (response.ok) {
const data = await response.json();
setAnalyticsData(data);
} else {
- // Set empty data if not authorized
- setAnalyticsData({
- summary: {
- totalScans: 0,
- uniqueScans: 0,
- avgScansPerQR: 0,
- mobilePercentage: 0,
- topCountry: 'N/A',
- topCountryPercentage: 0,
- },
- deviceStats: {},
- countryStats: [],
- dailyScans: {},
- qrPerformance: [],
- });
+ setAnalyticsData(null);
}
} catch (error) {
console.error('Error fetching analytics:', error);
- setAnalyticsData({
- summary: {
- totalScans: 0,
- uniqueScans: 0,
- avgScansPerQR: 0,
- mobilePercentage: 0,
- topCountry: 'N/A',
- topCountryPercentage: 0,
- },
- deviceStats: {},
- countryStats: [],
- dailyScans: {},
- qrPerformance: [],
- });
+ setAnalyticsData(null);
} finally {
setLoading(false);
}
- };
+ }, [timeRange]);
+
+ useEffect(() => {
+ fetchAnalytics();
+ }, [fetchAnalytics]);
const exportReport = () => {
- // Create CSV data
+ if (!analyticsData) return;
+
const csvData = [
['QR Master Analytics Report'],
['Generated:', new Date().toLocaleString()],
+ ['Time Range:', `Last ${timeRange} days`],
[''],
['Summary'],
- ['Total Scans', analyticsData?.summary.totalScans || 0],
- ['Unique Scans', analyticsData?.summary.uniqueScans || 0],
- ['Average Scans per QR', analyticsData?.summary.avgScansPerQR || 0],
- ['Mobile Usage %', analyticsData?.summary.mobilePercentage || 0],
+ ['Total Scans', analyticsData.summary.totalScans],
+ ['Unique Scans', analyticsData.summary.uniqueScans],
+ ['Mobile Usage %', analyticsData.summary.mobilePercentage],
+ ['Top Country', analyticsData.summary.topCountry],
[''],
['Top QR Codes'],
- ['Title', 'Type', 'Total Scans', 'Unique Scans', 'Conversion %'],
- ...(analyticsData?.qrPerformance || []).map((qr: any) => [
+ ['Title', 'Type', 'Total Scans', 'Unique Scans', 'Conversion %', 'Last Scanned'],
+ ...analyticsData.qrPerformance.map((qr) => [
qr.title,
qr.type,
qr.totalScans,
qr.uniqueScans,
qr.conversion,
+ qr.lastScanned ? new Date(qr.lastScanned).toLocaleString() : 'Never',
]),
];
- // Convert to CSV string
- const csv = csvData.map(row => row.join(',')).join('\n');
-
- // Download
+ const csv = csvData.map((row) => row.join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -124,7 +162,7 @@ export default function AnalyticsPage() {
URL.revokeObjectURL(url);
};
- // Prepare chart data based on selected time range
+ // Prepare chart data
const daysToShow = parseInt(timeRange);
const dateRange = Array.from({ length: daysToShow }, (_, i) => {
const date = new Date();
@@ -133,18 +171,32 @@ export default function AnalyticsPage() {
});
const scanChartData = {
- labels: dateRange.map(date => {
+ labels: dateRange.map((date) => {
const d = new Date(date);
return d.toLocaleDateString('en', { month: 'short', day: 'numeric' });
}),
datasets: [
{
label: 'Scans',
- data: dateRange.map(date => analyticsData?.dailyScans[date] || 0),
- borderColor: 'rgb(37, 99, 235)',
- backgroundColor: 'rgba(37, 99, 235, 0.1)',
+ data: dateRange.map((date) => analyticsData?.dailyScans[date] || 0),
+ borderColor: 'rgb(59, 130, 246)',
+ backgroundColor: (context: any) => {
+ const chart = context.chart;
+ const { ctx, chartArea } = chart;
+ if (!chartArea) return 'rgba(59, 130, 246, 0.1)';
+
+ const gradient = ctx.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
+ gradient.addColorStop(0, 'rgba(59, 130, 246, 0.3)');
+ gradient.addColorStop(1, 'rgba(59, 130, 246, 0.01)');
+ return gradient;
+ },
tension: 0.4,
fill: true,
+ pointRadius: 4,
+ pointBackgroundColor: 'rgb(59, 130, 246)',
+ pointBorderColor: '#fff',
+ pointBorderWidth: 2,
+ pointHoverRadius: 6,
},
],
};
@@ -159,25 +211,34 @@ export default function AnalyticsPage() {
analyticsData?.deviceStats.tablet || 0,
],
backgroundColor: [
- 'rgba(37, 99, 235, 0.8)',
- 'rgba(34, 197, 94, 0.8)',
- 'rgba(249, 115, 22, 0.8)',
+ 'rgba(59, 130, 246, 0.85)',
+ 'rgba(34, 197, 94, 0.85)',
+ 'rgba(249, 115, 22, 0.85)',
],
+ borderWidth: 0,
+ hoverOffset: 4,
},
],
};
+ // Find top performing QR code
+ const topQR = analyticsData?.qrPerformance[0];
+
if (loading) {
return (
-
-
+
+
- {[1, 2, 3, 4].map(i => (
-
+ {[1, 2, 3, 4].map((i) => (
+
))}
+
);
@@ -186,117 +247,136 @@ export default function AnalyticsPage() {
return (
{/* Header */}
-
+
-
{t('analytics.title')}
-
{t('analytics.subtitle')}
+
QR Code Analytics
+
Track and analyze your QR code performance
-
-
- {/* Time Range Selector */}
-
- {['7', '30', '90'].map((days) => (
-
- ))}
+
+ {/* Date Range Selector */}
+
+ {[
+ { value: '7', label: '7 Days' },
+ { value: '30', label: '30 Days' },
+ { value: '90', label: '90 Days' },
+ ].map((range) => (
+
+ ))}
+
+
+
+
{/* KPI Cards */}
-
-
-
- Total Scans
-
- {analyticsData?.summary.totalScans.toLocaleString() || '0'}
-
-
- {analyticsData?.summary.scansTrend
- ? `${analyticsData.summary.scansTrend.isNegative ? '-' : '+'}${analyticsData.summary.scansTrend.percentage}%${analyticsData.summary.scansTrend.isNew ? ' (new)' : ''} from last ${analyticsData.summary.comparisonPeriod || 'period'}`
- : 'No data'}
-
-
-
+
+
}
+ />
-
-
- Avg Scans/QR
-
- {analyticsData?.summary.avgScansPerQR || '0'}
-
-
- {analyticsData?.summary.avgScansTrend
- ? `${analyticsData.summary.avgScansTrend.isNegative ? '-' : '+'}${analyticsData.summary.avgScansTrend.percentage}%${analyticsData.summary.avgScansTrend.isNew ? ' (new)' : ''} from last ${analyticsData.summary.comparisonPeriod || 'period'}`
- : 'No data'}
-
-
-
+
}
+ />
-
-
- Mobile Usage
-
- {analyticsData?.summary.mobilePercentage || '0'}%
-
-
- {analyticsData?.summary.mobilePercentage > 0 ? 'Of total scans' : 'No mobile scans'}
-
-
-
+
}
+ />
-
-
- Top Country
-
- {analyticsData?.summary.topCountry || 'N/A'}
-
-
- {analyticsData?.summary.topCountryPercentage || '0'}% of total
-
-
-
+
}
+ />
- {/* Charts */}
-
- {/* Scans Over Time */}
-
-
- Scans Over Time
+ {/* Main Chart Row */}
+
+ {/* Scans Over Time - Takes 2 columns */}
+
+
+ Scan Trends Over Time
+
+
+ {timeRange} Days
+
-
+
items[0]?.label || '',
+ label: (item) => `${item.formattedValue} scans`,
+ },
+ },
},
scales: {
+ x: {
+ grid: { display: false },
+ ticks: { color: '#9CA3AF' },
+ },
y: {
beginAtZero: true,
- ticks: {
- precision: 0,
- },
+ grid: { color: 'rgba(156, 163, 175, 0.1)' },
+ ticks: { color: '#9CA3AF', precision: 0 },
},
},
}}
@@ -305,122 +385,207 @@ export default function AnalyticsPage() {
- {/* Device Types */}
+ {/* Device Types Donut */}
- Device Types
+ Device Types
- {analyticsData?.summary.totalScans > 0 ? (
+ {(analyticsData?.summary.totalScans || 0) > 0 ? (
) : (
-
No scan data available
+
No scan data available
)}
- {/* Top Countries Table */}
-
-
- Top Countries
-
-
- {analyticsData?.countryStats.length > 0 ? (
-
-
-
- | Country |
- Scans |
- Percentage |
- Trend |
-
-
-
- {analyticsData.countryStats.map((country: any, index: number) => (
-
- | {country.country} |
- {country.count.toLocaleString()} |
- {country.percentage}% |
-
-
- {country.trend === 'up' ? '↑' : country.trend === 'down' ? '↓' : '→'} {country.trendPercentage}%{country.isNew ? ' (new)' : ''}
-
- |
-
- ))}
-
-
- ) : (
- No country data available yet
- )}
-
-
+ {/* Geographic & Country Stats Row */}
+
+ {/* Geographic Insights with Map */}
+
+
+ Geographic Insights
+
+
+
+
+
+
+
- {/* QR Code Performance Table */}
-
-
- QR Code Performance
+ {/* Top Countries Table */}
+
+
+ Top Countries
+
+
+ {(analyticsData?.countryStats?.length || 0) > 0 ? (
+
+ {analyticsData!.countryStats.slice(0, 5).map((country, index) => (
+
+
+
+ {index + 1}
+
+ {country.country}
+
+
+ {country.count.toLocaleString()}
+
+ {country.percentage}%
+
+
+ {country.trend === 'up' ? '↑' : country.trend === 'down' ? '↓' : '→'}
+ {country.trendPercentage}%{country.isNew ? ' (new)' : ''}
+
+
+
+ ))}
+
+ ) : (
+ No country data available yet
+ )}
+
+
+
+
+ {/* Top Performing QR Codes with Sparklines */}
+
+
+
+
+ Top Performing QR Codes
+
- {analyticsData?.qrPerformance.length > 0 ? (
-
-
-
- | QR Code |
- Type |
- Total Scans |
- Unique Scans |
- Conversion |
- Trend |
-
-
-
- {analyticsData.qrPerformance.map((qr: any) => (
-
- | {qr.title} |
-
-
- {qr.type}
-
- |
- {qr.totalScans.toLocaleString()} |
- {qr.uniqueScans.toLocaleString()} |
- {qr.conversion}% |
-
-
- {qr.trend === 'up' ? '↑' : qr.trend === 'down' ? '↓' : '→'} {qr.trendPercentage}%{qr.isNew ? ' (new)' : ''}
-
- |
+ {(analyticsData?.qrPerformance?.length || 0) > 0 ? (
+
+
+
+
+ |
+ QR Code
+ |
+
+ Type
+ |
+
+ Total Scans
+ |
+
+ Unique Scans
+ |
+
+
+ Conversions
+
+
+
+ Conversion Rate
+
+ Percentage of unique scans vs total scans. Formula: (Unique Scans / Total Scans) × 100%
+
+
+
+
+
+ |
+
+ Trend
+ |
- ))}
-
-
+
+
+ {analyticsData!.qrPerformance.map((qr) => (
+
+ |
+ {qr.title}
+ |
+
+
+ {qr.type}
+
+ |
+
+ {qr.totalScans.toLocaleString()}
+ |
+ {qr.uniqueScans.toLocaleString()} |
+ {qr.conversion}% |
+
+
+
+
+ {qr.trend === 'up' ? '↑' : qr.trend === 'down' ? '↓' : '→'}
+ {qr.trendPercentage}%{qr.isNew ? ' (new)' : ''}
+
+
+ |
+
+ ))}
+
+
+
) : (
-
- No QR codes created yet. Create your first QR code to see analytics!
-
+
+
+
+ No QR codes created yet. Create your first QR code to see analytics!
+
+
)}
diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx
index bbf4363..b1e5ffd 100644
--- a/src/app/(app)/layout.tsx
+++ b/src/app/(app)/layout.tsx
@@ -161,9 +161,8 @@ export default function AppLayout({
{/* Sidebar */}
);
diff --git a/src/app/(marketing)/layout.tsx b/src/app/(marketing)/layout.tsx
index cd54449..e7e2a80 100644
--- a/src/app/(marketing)/layout.tsx
+++ b/src/app/(marketing)/layout.tsx
@@ -150,12 +150,9 @@ export default function MarketingLayout({
-
- Admin
+ •
© 2025 QR Master. All rights reserved.
diff --git a/src/app/api/analytics/summary/route.ts b/src/app/api/analytics/summary/route.ts
index b41ba1c..0a76552 100644
--- a/src/app/api/analytics/summary/route.ts
+++ b/src/app/api/analytics/summary/route.ts
@@ -190,6 +190,13 @@ export async function GET(request: NextRequest) {
return acc;
}, {} as Record
);
+ // Generate last 7 days for sparkline
+ const last7Days = Array.from({ length: 7 }, (_, i) => {
+ const date = new Date();
+ date.setDate(date.getDate() - (6 - i));
+ return date.toISOString().split('T')[0];
+ });
+
// QR performance (only show DYNAMIC QR codes since STATIC don't track scans)
const qrPerformance = qrCodes
.filter(qr => qr.type === 'DYNAMIC')
@@ -204,6 +211,18 @@ export async function GET(request: NextRequest) {
// Calculate trend
const trendData = calculateTrend(currentTotal, previousTotal);
+ // Calculate sparkline data (scans per day for last 7 days)
+ const sparklineData = last7Days.map(date => {
+ return qr.scans.filter(s =>
+ new Date(s.ts).toISOString().split('T')[0] === date
+ ).length;
+ });
+
+ // Find last scanned date
+ const lastScanned = qr.scans.length > 0
+ ? new Date(Math.max(...qr.scans.map(s => new Date(s.ts).getTime())))
+ : null;
+
return {
id: qr.id,
title: qr.title,
@@ -215,6 +234,8 @@ export async function GET(request: NextRequest) {
: 0,
trend: trendData.trend,
trendPercentage: trendData.percentage,
+ sparkline: sparklineData,
+ lastScanned: lastScanned?.toISOString() || null,
...(trendData.isNew && { isNew: true }),
};
})
diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts
index 49c3740..9e9dd31 100644
--- a/src/app/api/auth/signup/route.ts
+++ b/src/app/api/auth/signup/route.ts
@@ -74,10 +74,8 @@ export async function POST(request: NextRequest) {
},
});
- // Set cookie for auto-login after signup
- cookies().set('userId', user.id, getAuthCookieOptions());
-
- return NextResponse.json({
+ // Create response
+ const response = NextResponse.json({
success: true,
user: {
id: user.id,
@@ -86,6 +84,11 @@ export async function POST(request: NextRequest) {
plan: 'FREE',
},
});
+
+ // Set cookie for auto-login after signup
+ response.cookies.set('userId', user.id, getAuthCookieOptions());
+
+ return response;
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
diff --git a/src/components/analytics/GeoMap.tsx b/src/components/analytics/GeoMap.tsx
new file mode 100644
index 0000000..b11460b
--- /dev/null
+++ b/src/components/analytics/GeoMap.tsx
@@ -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 = {
+ '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 = {
+ '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 = ({ countryStats, totalScans }) => {
+ // Build a map of ISO Alpha-3 codes to scan counts
+ const countryData: Record = {};
+ 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()
+ .domain([0, maxCount || 1])
+ .range(['#E0F2FE', '#1E40AF']);
+
+ return (
+
+
+
+
+ {({ 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 (
+ 0 ? '#3B82F6' : '#E2E8F0',
+ outline: 'none',
+ cursor: 'pointer',
+ },
+ pressed: { outline: 'none' },
+ }}
+ />
+ );
+ })
+ }
+
+
+
+
+ );
+};
+
+export default memo(GeoMap);
diff --git a/src/components/analytics/Sparkline.tsx b/src/components/analytics/Sparkline.tsx
new file mode 100644
index 0000000..9127667
--- /dev/null
+++ b/src/components/analytics/Sparkline.tsx
@@ -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 = ({
+ 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 (
+
+
+
+ );
+};
+
+export default Sparkline;
diff --git a/src/components/analytics/StatCard.tsx b/src/components/analytics/StatCard.tsx
new file mode 100644
index 0000000..35836b5
--- /dev/null
+++ b/src/components/analytics/StatCard.tsx
@@ -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 = ({
+ 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 ;
+ if (trend.direction === 'down') return ;
+ return ;
+ };
+
+ return (
+
+
+
+
+ {title}
+
+
+ {typeof value === 'number' ? value.toLocaleString() : value}
+
+ {trend && (
+
+ {getTrendIcon()}
+
+ {trend.direction === 'up' ? '+' : trend.direction === 'down' ? '-' : ''}
+ {trend.percentage}%
+ {trend.isNew && ' (new)'}
+
+ {trend.period && (
+
+ vs last {trend.period}
+
+ )}
+
+ )}
+ {subtitle && !trend && (
+
+ {subtitle}
+
+ )}
+
+ {icon && (
+
+ {icon}
+
+ )}
+
+
+ );
+};
+
+export default StatCard;
diff --git a/src/components/analytics/index.ts b/src/components/analytics/index.ts
new file mode 100644
index 0000000..d6e3304
--- /dev/null
+++ b/src/components/analytics/index.ts
@@ -0,0 +1,3 @@
+export { default as GeoMap } from './GeoMap';
+export { default as Sparkline } from './Sparkline';
+export { default as StatCard } from './StatCard';
diff --git a/src/components/ui/Footer.tsx b/src/components/ui/Footer.tsx
index 5d85d7a..5e77b2b 100644
--- a/src/components/ui/Footer.tsx
+++ b/src/components/ui/Footer.tsx
@@ -1,59 +1,66 @@
import Link from 'next/link';
-export function Footer() {
+interface FooterProps {
+ variant?: 'marketing' | 'dashboard';
+}
+
+export function Footer({ variant = 'marketing' }: FooterProps) {
+ const isDashboard = variant === 'dashboard';
+
return (
-