diff --git a/public/sitemap.xml b/public/sitemap.xml deleted file mode 100644 index 024f89c..0000000 --- a/public/sitemap.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - https://www.qrmaster.net/ - 2025-10-16T00:00:00Z - daily - 0.9 - - - https://www.qrmaster.net/blog - 2025-10-16T00:00:00Z - daily - 0.7 - - - https://www.qrmaster.net/pricing - 2025-10-16T00:00:00Z - weekly - 0.8 - - - https://www.qrmaster.net/faq - 2025-10-16T00:00:00Z - weekly - 0.6 - - - https://www.qrmaster.net/blog/qr-code-analytics - 2025-10-16T00:00:00Z - weekly - 0.6 - - diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx deleted file mode 100644 index 39de12d..0000000 --- a/src/app/(auth)/layout.tsx +++ /dev/null @@ -1,11 +0,0 @@ -export default function AuthLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( -
- {children} -
- ); -} \ No newline at end of file diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx deleted file mode 100644 index cf1477d..0000000 --- a/src/app/(auth)/login/page.tsx +++ /dev/null @@ -1,187 +0,0 @@ -'use client'; - -import React, { useState, useEffect } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import Link from 'next/link'; -import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; -import { Input } from '@/components/ui/Input'; -import { Button } from '@/components/ui/Button'; -import { useTranslation } from '@/hooks/useTranslation'; -import { useCsrf } from '@/hooks/useCsrf'; - -export default function LoginPage() { - const router = useRouter(); - const searchParams = useSearchParams(); - const { t } = useTranslation(); - const { fetchWithCsrf, loading: csrfLoading } = useCsrf(); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); - setError(''); - - try { - const response = await fetchWithCsrf('/api/auth/simple-login', { - method: 'POST', - body: JSON.stringify({ email, password }), - }); - - const data = await response.json(); - - if (response.ok && data.success) { - // Store user in localStorage for client-side - localStorage.setItem('user', JSON.stringify(data.user)); - - // Track successful login with PostHog - try { - const { identifyUser, trackEvent } = await import('@/components/PostHogProvider'); - identifyUser(data.user.id, { - email: data.user.email, - name: data.user.name, - plan: data.user.plan || 'FREE', - }); - trackEvent('user_login', { - method: 'email', - email: data.user.email, - }); - } catch (error) { - console.error('PostHog tracking error:', error); - } - - // Check for redirect parameter - const redirectUrl = searchParams.get('redirect') || '/dashboard'; - router.push(redirectUrl); - router.refresh(); - } else { - setError(data.error || 'Invalid email or password'); - } - } catch (err) { - setError('An error occurred. Please try again.'); - } finally { - setLoading(false); - } - }; - - const handleGoogleSignIn = () => { - // Redirect to Google OAuth API route - window.location.href = '/api/auth/google'; - }; - - return ( -
-
-
- - QR Master - QR Master - -

Welcome Back

-

Sign in to your account

- - ← Back to Home - -
- - - -
- {error && ( -
- {error} -
- )} - - setEmail(e.target.value)} - placeholder="you@example.com" - required - /> - - setPassword(e.target.value)} - placeholder="••••••••" - required - /> - -
- - - Forgot password? - -
- - - -
-
-
-
-
- Or continue with -
-
- - -
- -
-

- Don't have an account?{' '} - - Sign up - -

-
-
-
- -

- By signing in, you agree to our{' '} - - Privacy Policy - -

-
-
- ); -} \ No newline at end of file diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx deleted file mode 100644 index c184077..0000000 --- a/src/app/(auth)/signup/page.tsx +++ /dev/null @@ -1,208 +0,0 @@ -'use client'; - -import React, { useState } from 'react'; -import { useRouter } from 'next/navigation'; -import Link from 'next/link'; -import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; -import { Input } from '@/components/ui/Input'; -import { Button } from '@/components/ui/Button'; -import { useTranslation } from '@/hooks/useTranslation'; -import { useCsrf } from '@/hooks/useCsrf'; - -export default function SignupPage() { - const router = useRouter(); - const { t } = useTranslation(); - const { fetchWithCsrf } = useCsrf(); - const [name, setName] = useState(''); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); - setError(''); - - if (password !== confirmPassword) { - setError('Passwords do not match'); - setLoading(false); - return; - } - - if (password.length < 8) { - setError('Password must be at least 8 characters'); - setLoading(false); - return; - } - - try { - const response = await fetchWithCsrf('/api/auth/signup', { - method: 'POST', - body: JSON.stringify({ name, email, password }), - }); - - const data = await response.json(); - - if (response.ok && data.success) { - // Store user in localStorage for client-side - localStorage.setItem('user', JSON.stringify(data.user)); - - // Track successful signup with PostHog - try { - const { identifyUser, trackEvent } = await import('@/components/PostHogProvider'); - identifyUser(data.user.id, { - email: data.user.email, - name: data.user.name, - plan: data.user.plan || 'FREE', - signupMethod: 'email', - }); - trackEvent('user_signup', { - method: 'email', - email: data.user.email, - }); - } catch (error) { - console.error('PostHog tracking error:', error); - } - - // Redirect to dashboard - router.push('/dashboard'); - router.refresh(); - } else { - setError(data.error || 'Failed to create account'); - } - } catch (err) { - setError('An error occurred. Please try again.'); - } finally { - setLoading(false); - } - }; - - const handleGoogleSignIn = () => { - // Redirect to Google OAuth API route - window.location.href = '/api/auth/google'; - }; - - return ( -
-
-
- - QR Master - QR Master - -

Create Account

-

Start creating QR codes in seconds

- - ← Back to Home - -
- - - -
- {error && ( -
- {error} -
- )} - - setName(e.target.value)} - placeholder="John Doe" - required - /> - - setEmail(e.target.value)} - placeholder="you@example.com" - required - /> - - setPassword(e.target.value)} - placeholder="••••••••" - required - /> - - setConfirmPassword(e.target.value)} - placeholder="••••••••" - required - /> - - - -
-
-
-
-
- Or continue with -
-
- - -
- -
-

- Already have an account?{' '} - - Sign in - -

-
-
-
- -

- By signing up, you agree to our{' '} - - Privacy Policy - -

-
-
- ); -} \ No newline at end of file diff --git a/src/app/(app)/AppLayout.tsx b/src/app/(main)/(app)/AppLayout.tsx similarity index 97% rename from src/app/(app)/AppLayout.tsx rename to src/app/(main)/(app)/AppLayout.tsx index e6b9249..b1e5ffd 100644 --- a/src/app/(app)/AppLayout.tsx +++ b/src/app/(main)/(app)/AppLayout.tsx @@ -1,254 +1,254 @@ -'use client'; - -import React, { useState, useEffect } from 'react'; -import Link from 'next/link'; -import { usePathname, useRouter } from 'next/navigation'; -import { Button } from '@/components/ui/Button'; -import { Dropdown, DropdownItem } from '@/components/ui/Dropdown'; -import { Footer } from '@/components/ui/Footer'; -import { useTranslation } from '@/hooks/useTranslation'; - -interface User { - id: string; - name: string | null; - email: string; - plan: string | null; -} - -export default function AppLayout({ - children, -}: { - children: React.ReactNode; -}) { - const pathname = usePathname(); - const router = useRouter(); - const { t } = useTranslation(); - const [sidebarOpen, setSidebarOpen] = useState(false); - const [user, setUser] = useState(null); - - // Fetch user data on mount - useEffect(() => { - const fetchUser = async () => { - try { - const response = await fetch('/api/user'); - if (response.ok) { - const userData = await response.json(); - setUser(userData); - } - } catch (error) { - console.error('Error fetching user:', error); - } - }; - - fetchUser(); - }, []); - - const handleSignOut = async () => { - // Track logout event before clearing data - try { - const { trackEvent, resetUser } = await import('@/components/PostHogProvider'); - trackEvent('user_logout'); - resetUser(); // Reset PostHog user session - } catch (error) { - console.error('PostHog tracking error:', error); - } - - // Clear all cookies - document.cookie.split(";").forEach(c => { - document.cookie = c.replace(/^ +/, "").replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/"); - }); - // Clear localStorage - localStorage.clear(); - // Redirect to home - router.push('/'); - }; - - // Get user initials for avatar (e.g., "Timo Schmidt" -> "TS") - const getUserInitials = () => { - if (!user) return 'U'; - - if (user.name) { - const names = user.name.trim().split(' '); - if (names.length >= 2) { - return (names[0][0] + names[names.length - 1][0]).toUpperCase(); - } - return user.name.substring(0, 2).toUpperCase(); - } - - // Fallback to email - return user.email.substring(0, 1).toUpperCase(); - }; - - // Get display name (first name or full name) - const getDisplayName = () => { - if (!user) return 'User'; - - if (user.name) { - return user.name; - } - - // Fallback to email without domain - return user.email.split('@')[0]; - }; - - const navigation = [ - { - name: t('nav.dashboard'), - href: '/dashboard', - icon: ( - - - - ), - }, - { - name: t('nav.create_qr'), - href: '/create', - icon: ( - - - - ), - }, - { - name: t('nav.bulk_creation'), - href: '/bulk-creation', - icon: ( - - - - ), - }, - { - name: t('nav.analytics'), - href: '/analytics', - icon: ( - - - - ), - }, - { - name: t('nav.pricing'), - href: '/pricing', - icon: ( - - - - ), - }, - { - name: t('nav.settings'), - href: '/settings', - icon: ( - - - - - ), - }, - ]; - - return ( -
- {/* Mobile sidebar backdrop */} - {sidebarOpen && ( -
setSidebarOpen(false)} - /> - )} - - {/* Sidebar */} - - - {/* Main content */} -
- {/* Top bar */} -
-
- - -
- {/* User Menu */} - -
- - {getUserInitials()} - -
- - {getDisplayName()} - - - - - - } - > - - Sign Out - -
-
-
-
- - {/* Page content */} -
- {children} -
- - {/* Footer */} -
-
-
- ); +'use client'; + +import React, { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { usePathname, useRouter } from 'next/navigation'; +import { Button } from '@/components/ui/Button'; +import { Dropdown, DropdownItem } from '@/components/ui/Dropdown'; +import { Footer } from '@/components/ui/Footer'; +import { useTranslation } from '@/hooks/useTranslation'; + +interface User { + id: string; + name: string | null; + email: string; + plan: string | null; +} + +export default function AppLayout({ + children, +}: { + children: React.ReactNode; +}) { + const pathname = usePathname(); + const router = useRouter(); + const { t } = useTranslation(); + const [sidebarOpen, setSidebarOpen] = useState(false); + const [user, setUser] = useState(null); + + // Fetch user data on mount + useEffect(() => { + const fetchUser = async () => { + try { + const response = await fetch('/api/user'); + if (response.ok) { + const userData = await response.json(); + setUser(userData); + } + } catch (error) { + console.error('Error fetching user:', error); + } + }; + + fetchUser(); + }, []); + + const handleSignOut = async () => { + // Track logout event before clearing data + try { + const { trackEvent, resetUser } = await import('@/components/PostHogProvider'); + trackEvent('user_logout'); + resetUser(); // Reset PostHog user session + } catch (error) { + console.error('PostHog tracking error:', error); + } + + // Clear all cookies + document.cookie.split(";").forEach(c => { + document.cookie = c.replace(/^ +/, "").replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/"); + }); + // Clear localStorage + localStorage.clear(); + // Redirect to home + router.push('/'); + }; + + // Get user initials for avatar (e.g., "Timo Schmidt" -> "TS") + const getUserInitials = () => { + if (!user) return 'U'; + + if (user.name) { + const names = user.name.trim().split(' '); + if (names.length >= 2) { + return (names[0][0] + names[names.length - 1][0]).toUpperCase(); + } + return user.name.substring(0, 2).toUpperCase(); + } + + // Fallback to email + return user.email.substring(0, 1).toUpperCase(); + }; + + // Get display name (first name or full name) + const getDisplayName = () => { + if (!user) return 'User'; + + if (user.name) { + return user.name; + } + + // Fallback to email without domain + return user.email.split('@')[0]; + }; + + const navigation = [ + { + name: t('nav.dashboard'), + href: '/dashboard', + icon: ( + + + + ), + }, + { + name: t('nav.create_qr'), + href: '/create', + icon: ( + + + + ), + }, + { + name: t('nav.bulk_creation'), + href: '/bulk-creation', + icon: ( + + + + ), + }, + { + name: t('nav.analytics'), + href: '/analytics', + icon: ( + + + + ), + }, + { + name: t('nav.pricing'), + href: '/pricing', + icon: ( + + + + ), + }, + { + name: t('nav.settings'), + href: '/settings', + icon: ( + + + + + ), + }, + ]; + + return ( +
+ {/* Mobile sidebar backdrop */} + {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + + {/* Sidebar */} + + + {/* Main content */} +
+ {/* Top bar */} +
+
+ + +
+ {/* User Menu */} + +
+ + {getUserInitials()} + +
+ + {getDisplayName()} + + + + + + } + > + + Sign Out + +
+
+
+
+ + {/* Page content */} +
+ {children} +
+ + {/* Footer */} +
+
+
+ ); } \ No newline at end of file diff --git a/src/app/(app)/analytics/page.tsx b/src/app/(main)/(app)/analytics/page.tsx similarity index 97% rename from src/app/(app)/analytics/page.tsx rename to src/app/(main)/(app)/analytics/page.tsx index b930248..61f5710 100644 --- a/src/app/(app)/analytics/page.tsx +++ b/src/app/(main)/(app)/analytics/page.tsx @@ -1,594 +1,594 @@ -'use client'; - -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 { StatCard, Sparkline } from '@/components/analytics'; -import { Line, Doughnut } from 'react-chartjs-2'; -import { - Chart as ChartJS, - CategoryScale, - LinearScale, - PointElement, - LineElement, - BarElement, - ArcElement, - Title, - Tooltip, - 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, - LinearScale, - PointElement, - LineElement, - BarElement, - ArcElement, - Title, - Tooltip, - Legend, - 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 fetchAnalytics = useCallback(async () => { - setLoading(true); - try { - const response = await fetch(`/api/analytics/summary?range=${timeRange}`); - if (response.ok) { - const data = await response.json(); - setAnalyticsData(data); - } else { - setAnalyticsData(null); - } - } catch (error) { - console.error('Error fetching analytics:', error); - setAnalyticsData(null); - } finally { - setLoading(false); - } - }, [timeRange]); - - useEffect(() => { - fetchAnalytics(); - }, [fetchAnalytics]); - - const exportReport = () => { - if (!analyticsData) return; - - const csvData = [ - ['QR Master Analytics Report'], - ['Generated:', new Date().toLocaleString()], - ['Time Range:', `Last ${timeRange} days`], - [''], - ['Summary'], - ['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 %', 'Last Scanned'], - ...analyticsData.qrPerformance.map((qr) => [ - qr.title, - qr.type, - qr.totalScans, - qr.uniqueScans, - qr.conversion, - qr.lastScanned ? new Date(qr.lastScanned).toLocaleString() : 'Never', - ]), - ]; - - 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'); - a.href = url; - a.download = `qr-analytics-${new Date().toISOString().split('T')[0]}.csv`; - a.click(); - URL.revokeObjectURL(url); - }; - - // Prepare chart data - const daysToShow = parseInt(timeRange); - const dateRange = Array.from({ length: daysToShow }, (_, i) => { - const date = new Date(); - date.setDate(date.getDate() - (daysToShow - 1 - i)); - return date.toISOString().split('T')[0]; - }); - - const scanChartData = { - 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(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, - }, - ], - }; - - const deviceChartData = { - labels: ['Desktop', 'Mobile', 'Tablet'], - datasets: [ - { - data: [ - analyticsData?.deviceStats.desktop || 0, - analyticsData?.deviceStats.mobile || 0, - analyticsData?.deviceStats.tablet || 0, - ], - backgroundColor: [ - '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) => ( -
- ))} -
-
-
-
-
-
-
- ); - } - - return ( -
- {/* Header */} -
-
-

QR Code Analytics

-

Track and analyze your QR code performance

-
- -
- {/* Date Range Selector */} -
- {[ - { value: '7', label: '7 Days' }, - { value: '30', label: '30 Days' }, - { value: '90', label: '90 Days' }, - ].map((range) => ( - - ))} -
- - -
-
- - {/* KPI Cards */} -
- } - /> - - } - /> - - } - /> - - } - /> -
- - {/* 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, - grid: { color: 'rgba(156, 163, 175, 0.1)' }, - ticks: { color: '#9CA3AF', precision: 0 }, - }, - }, - }} - /> -
-
-
- - {/* Device Types Donut */} - - - Device Types - - -
- {(analyticsData?.summary.totalScans || 0) > 0 ? ( - - ) : ( -

No scan data available

- )} -
-
-
-
- - {/* Geographic & Country Stats Row */} -
- {/* Geographic Insights with Map */} - - - Geographic Insights - - -
- -
-
-
- - {/* 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) > 0 ? ( -
- - - - - - - - - - - - - {analyticsData!.qrPerformance.map((qr) => ( - - - - - - - - - ))} - -
- QR Code - - Type - - Total Scans - - Unique Scans - -
- Conversions -
- -
-
Conversion Rate
-
- Percentage of unique scans vs total scans. Formula: (Unique Scans / Total Scans) × 100% -
-
-
-
-
-
- Trend -
- {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! -

-
- )} -
-
-
- ); +'use client'; + +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 { StatCard, Sparkline } from '@/components/analytics'; +import { Line, Doughnut } from 'react-chartjs-2'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + ArcElement, + Title, + Tooltip, + 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, + LinearScale, + PointElement, + LineElement, + BarElement, + ArcElement, + Title, + Tooltip, + Legend, + 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 fetchAnalytics = useCallback(async () => { + setLoading(true); + try { + const response = await fetch(`/api/analytics/summary?range=${timeRange}`); + if (response.ok) { + const data = await response.json(); + setAnalyticsData(data); + } else { + setAnalyticsData(null); + } + } catch (error) { + console.error('Error fetching analytics:', error); + setAnalyticsData(null); + } finally { + setLoading(false); + } + }, [timeRange]); + + useEffect(() => { + fetchAnalytics(); + }, [fetchAnalytics]); + + const exportReport = () => { + if (!analyticsData) return; + + const csvData = [ + ['QR Master Analytics Report'], + ['Generated:', new Date().toLocaleString()], + ['Time Range:', `Last ${timeRange} days`], + [''], + ['Summary'], + ['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 %', 'Last Scanned'], + ...analyticsData.qrPerformance.map((qr) => [ + qr.title, + qr.type, + qr.totalScans, + qr.uniqueScans, + qr.conversion, + qr.lastScanned ? new Date(qr.lastScanned).toLocaleString() : 'Never', + ]), + ]; + + 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'); + a.href = url; + a.download = `qr-analytics-${new Date().toISOString().split('T')[0]}.csv`; + a.click(); + URL.revokeObjectURL(url); + }; + + // Prepare chart data + const daysToShow = parseInt(timeRange); + const dateRange = Array.from({ length: daysToShow }, (_, i) => { + const date = new Date(); + date.setDate(date.getDate() - (daysToShow - 1 - i)); + return date.toISOString().split('T')[0]; + }); + + const scanChartData = { + 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(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, + }, + ], + }; + + const deviceChartData = { + labels: ['Desktop', 'Mobile', 'Tablet'], + datasets: [ + { + data: [ + analyticsData?.deviceStats.desktop || 0, + analyticsData?.deviceStats.mobile || 0, + analyticsData?.deviceStats.tablet || 0, + ], + backgroundColor: [ + '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) => ( +
+ ))} +
+
+
+
+
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

QR Code Analytics

+

Track and analyze your QR code performance

+
+ +
+ {/* Date Range Selector */} +
+ {[ + { value: '7', label: '7 Days' }, + { value: '30', label: '30 Days' }, + { value: '90', label: '90 Days' }, + ].map((range) => ( + + ))} +
+ + +
+
+ + {/* KPI Cards */} +
+ } + /> + + } + /> + + } + /> + + } + /> +
+ + {/* 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, + grid: { color: 'rgba(156, 163, 175, 0.1)' }, + ticks: { color: '#9CA3AF', precision: 0 }, + }, + }, + }} + /> +
+
+
+ + {/* Device Types Donut */} + + + Device Types + + +
+ {(analyticsData?.summary.totalScans || 0) > 0 ? ( + + ) : ( +

No scan data available

+ )} +
+
+
+
+ + {/* Geographic & Country Stats Row */} +
+ {/* Geographic Insights with Map */} + + + Geographic Insights + + +
+ +
+
+
+ + {/* 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) > 0 ? ( +
+ + + + + + + + + + + + + {analyticsData!.qrPerformance.map((qr) => ( + + + + + + + + + ))} + +
+ QR Code + + Type + + Total Scans + + Unique Scans + +
+ Conversions +
+ +
+
Conversion Rate
+
+ Percentage of unique scans vs total scans. Formula: (Unique Scans / Total Scans) × 100% +
+
+
+
+
+
+ Trend +
+ {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 newline at end of file diff --git a/src/app/(app)/bulk-creation/page.tsx b/src/app/(main)/(app)/bulk-creation/page.tsx similarity index 100% rename from src/app/(app)/bulk-creation/page.tsx rename to src/app/(main)/(app)/bulk-creation/page.tsx diff --git a/src/app/(app)/create/page.tsx b/src/app/(main)/(app)/create/page.tsx similarity index 97% rename from src/app/(app)/create/page.tsx rename to src/app/(main)/(app)/create/page.tsx index 888bf09..50611c8 100644 --- a/src/app/(app)/create/page.tsx +++ b/src/app/(main)/(app)/create/page.tsx @@ -1,1027 +1,1027 @@ -'use client'; - -import React, { useState, useEffect, useRef } from 'react'; -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; -import { QRCodeSVG } from 'qrcode.react'; -import { toPng } from 'html-to-image'; -import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; -import { Input } from '@/components/ui/Input'; -import { Select } from '@/components/ui/Select'; -import { Button } from '@/components/ui/Button'; -import { Badge } from '@/components/ui/Badge'; -import { calculateContrast, cn } from '@/lib/utils'; -import { useTranslation } from '@/hooks/useTranslation'; -import { useCsrf } from '@/hooks/useCsrf'; -import { showToast } from '@/components/ui/Toast'; -import { - Globe, User, MapPin, Phone, FileText, Smartphone, Ticket, Star, HelpCircle, Upload -} from 'lucide-react'; - -// Tooltip component for form field help -const Tooltip = ({ text }: { text: string }) => ( -
- -
- {text} -
-
-
-); - -// Content-type specific frame options -const getFrameOptionsForContentType = (contentType: string) => { - const baseOptions = [{ id: 'none', label: 'No Frame' }, { id: 'scanme', label: 'Scan Me' }]; - - switch (contentType) { - case 'URL': - return [...baseOptions, { id: 'website', label: 'Website' }, { id: 'visit', label: 'Visit' }]; - case 'PHONE': - return [...baseOptions, { id: 'callme', label: 'Call Me' }, { id: 'call', label: 'Call' }]; - case 'GEO': - return [...baseOptions, { id: 'findus', label: 'Find Us' }, { id: 'navigate', label: 'Navigate' }]; - case 'VCARD': - return [...baseOptions, { id: 'contact', label: 'Contact' }, { id: 'save', label: 'Save' }]; - case 'SMS': - return [...baseOptions, { id: 'textme', label: 'Text Me' }, { id: 'message', label: 'Message' }]; - case 'WHATSAPP': - return [...baseOptions, { id: 'chatme', label: 'Chat Me' }, { id: 'whatsapp', label: 'WhatsApp' }]; - case 'TEXT': - return [...baseOptions, { id: 'read', label: 'Read' }, { id: 'info', label: 'Info' }]; - case 'PDF': - return [...baseOptions, { id: 'download', label: 'Download' }, { id: 'view', label: 'View PDF' }]; - case 'APP': - return [...baseOptions, { id: 'getapp', label: 'Get App' }, { id: 'download', label: 'Download' }]; - case 'COUPON': - return [...baseOptions, { id: 'redeem', label: 'Redeem' }, { id: 'save', label: 'Save Offer' }]; - case 'FEEDBACK': - return [...baseOptions, { id: 'review', label: 'Review' }, { id: 'feedback', label: 'Feedback' }]; - default: - return [...baseOptions, { id: 'website', label: 'Website' }, { id: 'visit', label: 'Visit' }]; - } -}; - -export default function CreatePage() { - const router = useRouter(); - const { t } = useTranslation(); - const { fetchWithCsrf } = useCsrf(); - const [loading, setLoading] = useState(false); - const [uploading, setUploading] = useState(false); - const [userPlan, setUserPlan] = useState('FREE'); - const qrRef = useRef(null); - - // Form state - const [title, setTitle] = useState(''); - const [contentType, setContentType] = useState('URL'); - const [content, setContent] = useState({ url: '' }); - const [isDynamic, setIsDynamic] = useState(true); - - // Style state - const [foregroundColor, setForegroundColor] = useState('#000000'); - const [backgroundColor, setBackgroundColor] = useState('#FFFFFF'); - const [cornerStyle, setCornerStyle] = useState('square'); - const [size, setSize] = useState(200); - const [frameType, setFrameType] = useState('none'); - - // Get frame options for current content type - const frameOptions = getFrameOptionsForContentType(contentType); - - // Reset frame type when content type changes (if current frame is not valid) - useEffect(() => { - const validIds = frameOptions.map(f => f.id); - if (!validIds.includes(frameType)) { - setFrameType('none'); - } - }, [contentType, frameOptions, frameType]); - - // Logo state - const [logoUrl, setLogoUrl] = useState(''); - const [logoSize, setLogoSize] = useState(24); - const [excavate, setExcavate] = useState(true); - - // QR preview - const [qrDataUrl, setQrDataUrl] = useState(''); - - // Check if user can customize colors (PRO+ only) - const canCustomizeColors = userPlan === 'PRO' || userPlan === 'BUSINESS'; - - // Load user plan - useEffect(() => { - const fetchUserPlan = async () => { - try { - const response = await fetch('/api/user/plan'); - if (response.ok) { - const data = await response.json(); - setUserPlan(data.plan || 'FREE'); - } - } catch (error) { - console.error('Error fetching user plan:', error); - } - }; - fetchUserPlan(); - }, []); - - const contrast = calculateContrast(foregroundColor, backgroundColor); - const hasGoodContrast = contrast >= 4.5; - - const contentTypes = [ - { value: 'URL', label: 'URL / Website', icon: Globe }, - { value: 'VCARD', label: 'Contact Card', icon: User }, - { value: 'GEO', label: 'Location / Maps', icon: MapPin }, - { value: 'PHONE', label: 'Phone Number', icon: Phone }, - { value: 'PDF', label: 'PDF / File', icon: FileText }, - { value: 'APP', label: 'App Download', icon: Smartphone }, - { value: 'COUPON', label: 'Coupon / Discount', icon: Ticket }, - { value: 'FEEDBACK', label: 'Feedback / Review', icon: Star }, - ]; - - // Get QR content based on content type - const getQRContent = () => { - switch (contentType) { - case 'URL': - return content.url || 'https://example.com'; - case 'PHONE': - return `tel:${content.phone || '+1234567890'}`; - case 'SMS': - return `sms:${content.phone || '+1234567890'}${content.message ? `?body=${encodeURIComponent(content.message)}` : ''}`; - case 'VCARD': - return `BEGIN:VCARD\nVERSION:3.0\nFN:${content.firstName || 'John'} ${content.lastName || 'Doe'}\nORG:${content.organization || 'Company'}\nTITLE:${content.title || 'Position'}\nEMAIL:${content.email || 'email@example.com'}\nTEL:${content.phone || '+1234567890'}\nEND:VCARD`; - case 'GEO': - const lat = content.latitude || 37.7749; - const lon = content.longitude || -122.4194; - const label = content.label ? `?q=${encodeURIComponent(content.label)}` : ''; - return `geo:${lat},${lon}${label}`; - case 'TEXT': - return content.text || 'Sample text'; - case 'WHATSAPP': - return `https://wa.me/${content.phone || '+1234567890'}${content.message ? `?text=${encodeURIComponent(content.message)}` : ''}`; - case 'PDF': - return content.fileUrl || 'https://example.com/file.pdf'; - case 'APP': - return content.fallbackUrl || content.iosUrl || content.androidUrl || 'https://example.com/app'; - case 'COUPON': - return `Coupon: ${content.code || 'SAVE20'} - ${content.discount || '20% OFF'}`; - case 'FEEDBACK': - return content.feedbackUrl || 'https://example.com/feedback'; - default: - return 'https://example.com'; - } - }; - - const qrContent = getQRContent(); - - const getFrameLabel = () => { - const frame = frameOptions.find((f: { id: string; label: string }) => f.id === frameType); - return frame?.id !== 'none' ? frame?.label : null; - }; - - const downloadQR = async (format: 'svg' | 'png') => { - if (!qrRef.current) return; - try { - if (format === 'png') { - const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' }); - const link = document.createElement('a'); - link.download = `qrcode-${title || 'download'}.png`; - link.href = dataUrl; - link.click(); - } else { - // For SVG, we might still want to use the library or just toPng if SVG export of HTML is not needed - // Simplest is to check if we can export the SVG element directly but that misses the frame HTML. - // html-to-image can generate SVG too. - // But usually for SVG users want the vector. Capturing HTML to SVG is possible but complex. - // For now, let's just stick to the SVG code export if NO FRAME is selected, - // otherwise warn or use toPng (as SVG). - // Actually, the previous implementation was good for pure QR. - // If frame is selected, we MUST use a raster export (PNG) or complex HTML-to-SVG. - // Let's rely on toPng for consistency with frames. - const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' }); - // Wait, exporting HTML to valid vector SVG is hard. - // Let's just offer PNG for frames for now to be safe, or just use the same PNG download for both buttons if frame is active? - // No, let's try to grab the INNER SVG if no frame, else... - if (frameType === 'none') { - const svgElement = qrRef.current.querySelector('svg'); - if (svgElement) { - const svgData = new XMLSerializer().serializeToString(svgElement); - const blob = new Blob([svgData], { type: 'image/svg+xml' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `qrcode-${title || 'download'}.svg`; - a.click(); - URL.revokeObjectURL(url); - } - } else { - showToast('SVG download not available with frames yet. Downloading PNG instead.', 'info'); - const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' }); - const link = document.createElement('a'); - link.download = `qrcode-${title || 'download'}.png`; - link.href = dataUrl; - link.click(); - } - } - } catch (err) { - console.error('Error downloading QR code:', err); - showToast('Error downloading QR code', 'error'); - } - }; - - const handleLogoUpload = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - if (file.size > 10 * 1024 * 1024) { // 10MB limit (soft limit for upload, will be resized) - showToast('Logo file size too large (max 10MB)', 'error'); - return; - } - - const reader = new FileReader(); - reader.onload = (evt) => { - const img = new Image(); - img.onload = () => { - const canvas = document.createElement('canvas'); - const maxDimension = 500; // Resize to max 500px - let width = img.width; - let height = img.height; - - if (width > maxDimension || height > maxDimension) { - if (width > height) { - height = Math.round((height * maxDimension) / width); - width = maxDimension; - } else { - width = Math.round((width * maxDimension) / height); - height = maxDimension; - } - } - - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext('2d'); - ctx?.drawImage(img, 0, 0, width, height); - - // Compress to JPEG/PNG with reduced quality to save space - const dataUrl = canvas.toDataURL(file.type === 'image/png' ? 'image/png' : 'image/jpeg', 0.8); - setLogoUrl(dataUrl); - }; - img.src = evt.target?.result as string; - }; - reader.readAsDataURL(file); - } - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); - - try { - const qrData = { - title, - contentType, - content, - isStatic: !isDynamic, - tags: [], - style: { - // FREE users can only use black/white - foregroundColor: canCustomizeColors ? foregroundColor : '#000000', - backgroundColor: canCustomizeColors ? backgroundColor : '#FFFFFF', - cornerStyle, - size, - imageSettings: (canCustomizeColors && logoUrl) ? { - src: logoUrl, - height: logoSize, - width: logoSize, - excavate, - } : undefined, - frameType, // Save frame type - }, - }; - - console.log('SENDING QR DATA:', qrData); - - const response = await fetchWithCsrf('/api/qrs', { - method: 'POST', - body: JSON.stringify(qrData), - }); - - const responseData = await response.json(); - console.log('RESPONSE DATA:', responseData); - - if (response.ok) { - showToast(`QR Code "${title}" created successfully!`, 'success'); - - // Wait a moment so user sees the toast, then redirect - setTimeout(() => { - router.push('/dashboard'); - router.refresh(); - }, 1000); - } else { - console.error('Error creating QR code:', responseData); - showToast(responseData.error || 'Error creating QR code', 'error'); - } - } catch (error) { - console.error('Error creating QR code:', error); - showToast('Error creating QR code. Please try again.', 'error'); - } finally { - setLoading(false); - } - }; - - const renderContentFields = () => { - switch (contentType) { - case 'URL': - return ( - setContent({ url: e.target.value })} - placeholder="https://example.com" - required - /> - ); - case 'PHONE': - return ( - setContent({ phone: e.target.value })} - placeholder="+1234567890" - required - /> - ); - case 'VCARD': - return ( - <> - setContent({ ...content, firstName: e.target.value })} - placeholder="John" - required - /> - setContent({ ...content, lastName: e.target.value })} - placeholder="Doe" - required - /> - setContent({ ...content, email: e.target.value })} - placeholder="john@example.com" - /> - setContent({ ...content, phone: e.target.value })} - placeholder="+1234567890" - /> - setContent({ ...content, organization: e.target.value })} - placeholder="Company Name" - /> - setContent({ ...content, title: e.target.value })} - placeholder="CEO" - /> - - ); - case 'GEO': - return ( - <> - setContent({ ...content, latitude: parseFloat(e.target.value) || 0 })} - placeholder="37.7749" - required - /> - setContent({ ...content, longitude: parseFloat(e.target.value) || 0 })} - placeholder="-122.4194" - required - /> - setContent({ ...content, label: e.target.value })} - placeholder="Golden Gate Bridge" - /> - - ); - case 'TEXT': - return ( -
- -