From 2a51e432e8a2db97c58baf735f5524150a43e9e5 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Wed, 7 Jan 2026 11:07:55 +0100 Subject: [PATCH] feat: Implement user signup API and analytics dashboard with summary API, map, and chart components, updating dependencies. --- package-lock.json | 263 +++++++++++ package.json | 3 + src/app/(app)/analytics/page.tsx | 622 +++++++++++++++---------- src/app/api/analytics/summary/route.ts | 21 + src/app/api/auth/signup/route.ts | 11 +- src/components/analytics/GeoMap.tsx | 192 ++++++++ src/components/analytics/Sparkline.tsx | 86 ++++ src/components/analytics/StatCard.tsx | 103 ++++ src/components/analytics/index.ts | 3 + src/types/react-simple-maps.d.ts | 58 +++ tsc_errors.txt | Bin 0 -> 1228 bytes 11 files changed, 1123 insertions(+), 239 deletions(-) create mode 100644 src/components/analytics/GeoMap.tsx create mode 100644 src/components/analytics/Sparkline.tsx create mode 100644 src/components/analytics/StatCard.tsx create mode 100644 src/components/analytics/index.ts create mode 100644 src/types/react-simple-maps.d.ts create mode 100644 tsc_errors.txt 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..ba9ff58 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,26 @@ import { Legend, Filler, } from 'chart.js'; +import { + BarChart3, + Users, + Smartphone, + Globe, + Calendar, + Download, + TrendingUp, + QrCode, +} 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 +56,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 +161,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 +170,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 +210,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 +246,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 +
+ + Daily · Weekly · Monthly +
-
+
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 +384,195 @@ 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 ? ( - - - - - - - - - - - {analyticsData.countryStats.map((country: any, index: number) => ( - - - - - - - ))} - -
CountryScansPercentageTrend
{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 */} + {/* 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 */} - - QR Code Performance + + + + Top Performing QR Codes + - {analyticsData?.qrPerformance.length > 0 ? ( - - - - - - - - - - - - - {analyticsData.qrPerformance.map((qr: any) => ( - - - - - - - + {(analyticsData?.qrPerformance?.length || 0) > 0 ? ( +
+
QR CodeTypeTotal ScansUnique ScansConversionTrend
{qr.title} - - {qr.type} - - {qr.totalScans.toLocaleString()}{qr.uniqueScans.toLocaleString()}{qr.conversion}% - - {qr.trend === 'up' ? '↑' : qr.trend === 'down' ? '↓' : '→'} {qr.trendPercentage}%{qr.isNew ? ' (new)' : ''} - -
+ + + + + + + + - ))} - -
+ QR Code + + Type + + Total Scans + + Unique Scans + + Conversions + + 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/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/types/react-simple-maps.d.ts b/src/types/react-simple-maps.d.ts new file mode 100644 index 0000000..7818520 --- /dev/null +++ b/src/types/react-simple-maps.d.ts @@ -0,0 +1,58 @@ +declare module 'react-simple-maps' { + import { ComponentType, ReactNode, CSSProperties } from 'react'; + + export interface ComposableMapProps { + projection?: string; + projectionConfig?: { + scale?: number; + center?: [number, number]; + rotate?: [number, number, number]; + }; + width?: number; + height?: number; + style?: CSSProperties; + children?: ReactNode; + } + + export interface GeographiesProps { + geography: string | object; + children: (data: { geographies: any[] }) => ReactNode; + } + + export interface GeographyProps { + geography: any; + style?: { + default?: CSSProperties; + hover?: CSSProperties; + pressed?: CSSProperties; + }; + fill?: string; + stroke?: string; + strokeWidth?: number; + onClick?: (event: React.MouseEvent) => void; + onMouseEnter?: (event: React.MouseEvent) => void; + onMouseLeave?: (event: React.MouseEvent) => void; + } + + export interface ZoomableGroupProps { + center?: [number, number]; + zoom?: number; + minZoom?: number; + maxZoom?: number; + translateExtent?: [[number, number], [number, number]]; + onMoveStart?: (event: any) => void; + onMove?: (event: any) => void; + onMoveEnd?: (event: any) => void; + children?: ReactNode; + } + + export const ComposableMap: ComponentType; + export const Geographies: ComponentType; + export const Geography: ComponentType; + export const ZoomableGroup: ComponentType; + export const Marker: ComponentType; + export const Line: ComponentType; + export const Annotation: ComponentType; + export const Graticule: ComponentType; + export const Sphere: ComponentType; +} diff --git a/tsc_errors.txt b/tsc_errors.txt new file mode 100644 index 0000000000000000000000000000000000000000..de726dd9c3cad36c7b1c73b6be4bf6ee6045c95e GIT binary patch literal 1228 zcmb`HNlyY%5QOV&;(wT%nusGBal5$2lZoqDCNSz483^FQpI5)?5toBY0wHhfSKU+f zx|`RRf%KJ=ZOM!dVyf(F{4J~L(3HN<| zT}Gk{ooijos;d!q``90d*ygTeKV-cob{Uq8Qyc#lhz$QMzH3eu>Yh9As@NUYc0{vv z4t$PB3!fTHi_l|_8auNcfLRLUK3q~=VlmfB>84hL<){OY6qxT7xKA#Q_cxam?gif-g0A%EdWPR!@Jv4V``rsr&O)MFlnhv%8Ef`BVogAd z(3lfpRx_Oa_Wkf_g6YA~F%w?HuaoH!Y%N~<5$n!}|Nq!GBieP$&Y`(iYRs<6Peyd- z*;#c}J+c0j-FbMo8LqnpaIT3e+pbJ!Aj49|xZ%F}Et2#9H3R>xhHc{bwWietb*)7w z)TyDLd%<<)KG_R3u7Xn5l!xMR#aY7*wl1?fp-1iu&aUD~t7Ce48}#-fowM?b-sSh+ TKS70+SmBW2|A(>0$u+$J5P8&i literal 0 HcmV?d00001