feat: Implement QR code analytics dashboard with summary, charts, and geo-mapping, alongside new signup, marketing, and QR creation pages.

This commit is contained in:
Timo
2026-01-07 15:34:21 +01:00
parent b2d83a0cd6
commit 509e5a51a7
16 changed files with 13289 additions and 13289 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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<User | null>(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: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
),
},
{
name: t('nav.create_qr'),
href: '/create',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
),
},
{
name: t('nav.bulk_creation'),
href: '/bulk-creation',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
},
{
name: t('nav.analytics'),
href: '/analytics',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
},
{
name: t('nav.pricing'),
href: '/pricing',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
{
name: t('nav.settings'),
href: '/settings',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
},
];
return (
<div className="min-h-screen bg-gray-50">
{/* Mobile sidebar backdrop */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={`fixed top-0 left-0 z-50 h-full w-64 bg-white border-r border-gray-200 transform transition-transform lg:translate-x-0 ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<Link href="/" className="flex items-center space-x-2">
<img src="/logo.svg" alt="QR Master" className="w-8 h-8" />
<span className="text-xl font-bold text-gray-900">QR Master</span>
</Link>
<button
className="lg:hidden"
onClick={() => setSidebarOpen(false)}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<nav className="p-4 space-y-1">
{navigation.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.name}
href={item.href}
className={`flex items-center space-x-3 px-3 py-2 rounded-lg transition-colors ${isActive
? 'bg-primary-50 text-primary-600'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
{item.icon}
<span className="font-medium">{item.name}</span>
</Link>
);
})}
</nav>
</aside>
{/* Main content */}
<div className="lg:ml-64">
{/* Top bar */}
<header className="bg-white border-b border-gray-200">
<div className="flex items-center justify-between px-4 py-3">
<button
className="lg:hidden"
onClick={() => setSidebarOpen(true)}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div className="flex items-center space-x-4 ml-auto">
{/* User Menu */}
<Dropdown
align="right"
trigger={
<button className="flex items-center space-x-2 text-gray-700 hover:text-gray-900">
<div className="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center">
<span className="text-sm font-medium text-primary-600">
{getUserInitials()}
</span>
</div>
<span className="hidden md:block font-medium">
{getDisplayName()}
</span>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
}
>
<DropdownItem onClick={handleSignOut}>
Sign Out
</DropdownItem>
</Dropdown>
</div>
</div>
</header>
{/* Page content */}
<main className="p-6">
{children}
</main>
{/* Footer */}
<Footer variant="dashboard" />
</div>
</div>
);
'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<User | null>(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: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
),
},
{
name: t('nav.create_qr'),
href: '/create',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
),
},
{
name: t('nav.bulk_creation'),
href: '/bulk-creation',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
},
{
name: t('nav.analytics'),
href: '/analytics',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
},
{
name: t('nav.pricing'),
href: '/pricing',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
{
name: t('nav.settings'),
href: '/settings',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
},
];
return (
<div className="min-h-screen bg-gray-50">
{/* Mobile sidebar backdrop */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={`fixed top-0 left-0 z-50 h-full w-64 bg-white border-r border-gray-200 transform transition-transform lg:translate-x-0 ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<Link href="/" className="flex items-center space-x-2">
<img src="/logo.svg" alt="QR Master" className="w-8 h-8" />
<span className="text-xl font-bold text-gray-900">QR Master</span>
</Link>
<button
className="lg:hidden"
onClick={() => setSidebarOpen(false)}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<nav className="p-4 space-y-1">
{navigation.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.name}
href={item.href}
className={`flex items-center space-x-3 px-3 py-2 rounded-lg transition-colors ${isActive
? 'bg-primary-50 text-primary-600'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
{item.icon}
<span className="font-medium">{item.name}</span>
</Link>
);
})}
</nav>
</aside>
{/* Main content */}
<div className="lg:ml-64">
{/* Top bar */}
<header className="bg-white border-b border-gray-200">
<div className="flex items-center justify-between px-4 py-3">
<button
className="lg:hidden"
onClick={() => setSidebarOpen(true)}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div className="flex items-center space-x-4 ml-auto">
{/* User Menu */}
<Dropdown
align="right"
trigger={
<button className="flex items-center space-x-2 text-gray-700 hover:text-gray-900">
<div className="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center">
<span className="text-sm font-medium text-primary-600">
{getUserInitials()}
</span>
</div>
<span className="hidden md:block font-medium">
{getDisplayName()}
</span>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
}
>
<DropdownItem onClick={handleSignOut}>
Sign Out
</DropdownItem>
</Dropdown>
</div>
</div>
</header>
{/* Page content */}
<main className="p-6">
{children}
</main>
{/* Footer */}
<Footer variant="dashboard" />
</div>
</div>
);
}

View File

@@ -1,164 +1,164 @@
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/Button';
import en from '@/i18n/en.json';
export default function MarketingLayout({
children,
}: {
children: React.ReactNode;
}) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
// Always use English for marketing pages
const t = en;
const navigation = [
{ name: t.nav.features, href: '/#features' },
{ name: t.nav.pricing, href: '/#pricing' },
{ name: t.nav.faq, href: '/#faq' },
{ name: t.nav.blog, href: '/blog' },
];
return (
<div className="min-h-screen bg-white">
{/* Header */}
<header className="sticky top-0 z-50 bg-white border-b border-gray-200">
<nav className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl py-4">
<div className="flex items-center justify-between">
{/* Logo */}
<Link href="/" className="flex items-center space-x-2">
<img src="/favicon.svg" alt="QR Master" className="w-8 h-8" />
<span className="text-xl font-bold text-gray-900">QR Master</span>
</Link>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center space-x-8">
{navigation.map((item) => (
<Link
key={item.name}
href={item.href}
className="text-gray-600 hover:text-gray-900 font-medium transition-colors"
>
{item.name}
</Link>
))}
</div>
{/* Right Actions */}
<div className="hidden md:flex items-center space-x-4">
<Link href="/login">
<Button variant="outline">{t.nav.login}</Button>
</Link>
<Link href="/signup">
<Button>Get Started Free</Button>
</Link>
</div>
{/* Mobile Menu Button */}
<button
className="md:hidden text-gray-900"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'}
aria-expanded={mobileMenuOpen}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
{mobileMenuOpen ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
)}
</svg>
</button>
</div>
{/* Mobile Menu */}
{mobileMenuOpen && (
<div className="md:hidden mt-4 pb-4 border-t border-gray-200 pt-4">
<div className="flex flex-col space-y-4">
{navigation.map((item) => (
<Link
key={item.name}
href={item.href}
className="text-gray-600 hover:text-gray-900 font-medium"
onClick={() => setMobileMenuOpen(false)}
>
{item.name}
</Link>
))}
<Link href="/login" onClick={() => setMobileMenuOpen(false)}>
<Button variant="outline" className="w-full">{t.nav.login}</Button>
</Link>
<Link href="/signup" onClick={() => setMobileMenuOpen(false)}>
<Button className="w-full">Get Started Free</Button>
</Link>
</div>
</div>
)}
</nav>
</header>
{/* Main Content */}
<main>{children}</main>
{/* Footer */}
<footer className="bg-gray-900 text-white py-12 mt-20">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="grid md:grid-cols-4 gap-8">
<div>
<Link href="/" className="flex items-center space-x-2 mb-4 hover:opacity-80 transition-opacity">
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
<span className="text-xl font-bold">QR Master</span>
</Link>
<p className="text-gray-400">
Create custom QR codes in seconds with advanced tracking and analytics.
</p>
</div>
<div>
<h3 className="font-semibold mb-4">Product</h3>
<ul className="space-y-2 text-gray-400">
<li><Link href="/#features" className="hover:text-white">Features</Link></li>
<li><Link href="/#pricing" className="hover:text-white">Pricing</Link></li>
<li><Link href="/#faq" className="hover:text-white">FAQ</Link></li>
<li><Link href="/blog" className="hover:text-white">Blog</Link></li>
</ul>
</div>
<div>
<h3 className="font-semibold mb-4">Resources</h3>
<ul className="space-y-2 text-gray-400">
<li><Link href="/#pricing" className="hover:text-white">Full Pricing</Link></li>
<li><Link href="/faq" className="hover:text-white">All Questions</Link></li>
<li><Link href="/blog" className="hover:text-white">Blog</Link></li>
<li><Link href="/signup" className="hover:text-white">Get Started</Link></li>
</ul>
</div>
<div>
<h3 className="font-semibold mb-4">Legal</h3>
<ul className="space-y-2 text-gray-400">
<li><Link href="/privacy" className="hover:text-white">Privacy Policy</Link></li>
</ul>
</div>
</div>
<div className="border-t border-gray-800 mt-8 pt-8 flex items-center justify-between text-gray-400">
<Link
href="/newsletter"
className="text-[6px] text-gray-700 opacity-[0.25] hover:opacity-100 hover:text-white transition-opacity duration-300"
>
</Link>
<p>&copy; 2025 QR Master. All rights reserved.</p>
<div className="w-12"></div>
</div>
</div>
</footer>
</div>
);
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/Button';
import en from '@/i18n/en.json';
export default function MarketingLayout({
children,
}: {
children: React.ReactNode;
}) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
// Always use English for marketing pages
const t = en;
const navigation = [
{ name: t.nav.features, href: '/#features' },
{ name: t.nav.pricing, href: '/#pricing' },
{ name: t.nav.faq, href: '/#faq' },
{ name: t.nav.blog, href: '/blog' },
];
return (
<div className="min-h-screen bg-white">
{/* Header */}
<header className="sticky top-0 z-50 bg-white border-b border-gray-200">
<nav className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl py-4">
<div className="flex items-center justify-between">
{/* Logo */}
<Link href="/" className="flex items-center space-x-2">
<img src="/favicon.svg" alt="QR Master" className="w-8 h-8" />
<span className="text-xl font-bold text-gray-900">QR Master</span>
</Link>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center space-x-8">
{navigation.map((item) => (
<Link
key={item.name}
href={item.href}
className="text-gray-600 hover:text-gray-900 font-medium transition-colors"
>
{item.name}
</Link>
))}
</div>
{/* Right Actions */}
<div className="hidden md:flex items-center space-x-4">
<Link href="/login">
<Button variant="outline">{t.nav.login}</Button>
</Link>
<Link href="/signup">
<Button>Get Started Free</Button>
</Link>
</div>
{/* Mobile Menu Button */}
<button
className="md:hidden text-gray-900"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'}
aria-expanded={mobileMenuOpen}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
{mobileMenuOpen ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
)}
</svg>
</button>
</div>
{/* Mobile Menu */}
{mobileMenuOpen && (
<div className="md:hidden mt-4 pb-4 border-t border-gray-200 pt-4">
<div className="flex flex-col space-y-4">
{navigation.map((item) => (
<Link
key={item.name}
href={item.href}
className="text-gray-600 hover:text-gray-900 font-medium"
onClick={() => setMobileMenuOpen(false)}
>
{item.name}
</Link>
))}
<Link href="/login" onClick={() => setMobileMenuOpen(false)}>
<Button variant="outline" className="w-full">{t.nav.login}</Button>
</Link>
<Link href="/signup" onClick={() => setMobileMenuOpen(false)}>
<Button className="w-full">Get Started Free</Button>
</Link>
</div>
</div>
)}
</nav>
</header>
{/* Main Content */}
<main>{children}</main>
{/* Footer */}
<footer className="bg-gray-900 text-white py-12 mt-20">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="grid md:grid-cols-4 gap-8">
<div>
<Link href="/" className="flex items-center space-x-2 mb-4 hover:opacity-80 transition-opacity">
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
<span className="text-xl font-bold">QR Master</span>
</Link>
<p className="text-gray-400">
Create custom QR codes in seconds with advanced tracking and analytics.
</p>
</div>
<div>
<h3 className="font-semibold mb-4">Product</h3>
<ul className="space-y-2 text-gray-400">
<li><Link href="/#features" className="hover:text-white">Features</Link></li>
<li><Link href="/#pricing" className="hover:text-white">Pricing</Link></li>
<li><Link href="/#faq" className="hover:text-white">FAQ</Link></li>
<li><Link href="/blog" className="hover:text-white">Blog</Link></li>
</ul>
</div>
<div>
<h3 className="font-semibold mb-4">Resources</h3>
<ul className="space-y-2 text-gray-400">
<li><Link href="/#pricing" className="hover:text-white">Full Pricing</Link></li>
<li><Link href="/faq" className="hover:text-white">All Questions</Link></li>
<li><Link href="/blog" className="hover:text-white">Blog</Link></li>
<li><Link href="/signup" className="hover:text-white">Get Started</Link></li>
</ul>
</div>
<div>
<h3 className="font-semibold mb-4">Legal</h3>
<ul className="space-y-2 text-gray-400">
<li><Link href="/privacy" className="hover:text-white">Privacy Policy</Link></li>
</ul>
</div>
</div>
<div className="border-t border-gray-800 mt-8 pt-8 flex items-center justify-between text-gray-400">
<Link
href="/newsletter"
className="text-[6px] text-gray-700 opacity-[0.25] hover:opacity-100 hover:text-white transition-opacity duration-300"
>
</Link>
<p>&copy; 2025 QR Master. All rights reserved.</p>
<div className="w-12"></div>
</div>
</div>
</footer>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,216 +1,216 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
export async function GET(request: NextRequest) {
try {
// Check newsletter-admin cookie authentication
const cookieStore = cookies();
const adminCookie = cookieStore.get('newsletter-admin');
if (!adminCookie || adminCookie.value !== 'authenticated') {
return NextResponse.json(
{ error: 'Unauthorized - Admin login required' },
{ status: 401 }
);
}
// Get 30 days ago date
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
// Get 7 days ago date
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
// Get start of current month
const startOfMonth = new Date();
startOfMonth.setDate(1);
startOfMonth.setHours(0, 0, 0, 0);
// Fetch all statistics in parallel
const [
totalUsers,
premiumUsers,
newUsersThisWeek,
newUsersThisMonth,
totalQRCodes,
dynamicQRCodes,
staticQRCodes,
totalScans,
dynamicQRCodesWithScans,
activeQRCodes,
newsletterSubscribers,
] = await Promise.all([
// Total users
db.user.count(),
// Premium users (PRO or BUSINESS)
db.user.count({
where: {
plan: {
in: ['PRO', 'BUSINESS'],
},
},
}),
// New users this week
db.user.count({
where: {
createdAt: {
gte: sevenDaysAgo,
},
},
}),
// New users this month
db.user.count({
where: {
createdAt: {
gte: startOfMonth,
},
},
}),
// Total QR codes
db.qRCode.count(),
// Dynamic QR codes
db.qRCode.count({
where: {
type: 'DYNAMIC',
},
}),
// Static QR codes
db.qRCode.count({
where: {
type: 'STATIC',
},
}),
// Total scans
db.qRScan.count(),
// Get all dynamic QR codes with their scan counts
db.qRCode.findMany({
where: {
type: 'DYNAMIC',
},
include: {
_count: {
select: {
scans: true,
},
},
},
}),
// Active QR codes (scanned in last 30 days)
db.qRCode.findMany({
where: {
scans: {
some: {
ts: {
gte: thirtyDaysAgo,
},
},
},
},
distinct: ['id'],
}),
// Newsletter subscribers
db.newsletterSubscription.count({
where: {
status: 'subscribed',
},
}),
]);
// Calculate dynamic QR scans
const dynamicQRScans = dynamicQRCodesWithScans.reduce(
(total, qr) => total + qr._count.scans,
0
);
// Calculate average scans per dynamic QR
const avgScansPerDynamicQR =
dynamicQRCodes > 0 ? (dynamicQRScans / dynamicQRCodes).toFixed(1) : '0';
// Get top 5 most scanned QR codes
const topQRCodes = await db.qRCode.findMany({
take: 5,
include: {
_count: {
select: {
scans: true,
},
},
user: {
select: {
email: true,
name: true,
},
},
},
orderBy: {
scans: {
_count: 'desc',
},
},
});
// Get recent users
const recentUsers = await db.user.findMany({
take: 5,
orderBy: {
createdAt: 'desc',
},
select: {
email: true,
name: true,
plan: true,
createdAt: true,
},
});
return NextResponse.json({
users: {
total: totalUsers,
premium: premiumUsers,
newThisWeek: newUsersThisWeek,
newThisMonth: newUsersThisMonth,
recent: recentUsers,
},
qrCodes: {
total: totalQRCodes,
dynamic: dynamicQRCodes,
static: staticQRCodes,
active: activeQRCodes.length,
},
scans: {
total: totalScans,
dynamicOnly: dynamicQRScans,
avgPerDynamicQR: avgScansPerDynamicQR,
},
newsletter: {
subscribers: newsletterSubscribers,
},
topQRCodes: topQRCodes.map((qr) => ({
id: qr.id,
title: qr.title,
type: qr.type,
scans: qr._count.scans,
owner: qr.user.name || qr.user.email,
createdAt: qr.createdAt,
})),
});
} catch (error) {
console.error('Error fetching admin stats:', error);
return NextResponse.json(
{ error: 'Failed to fetch statistics' },
{ status: 500 }
);
}
}
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
export async function GET(request: NextRequest) {
try {
// Check newsletter-admin cookie authentication
const cookieStore = cookies();
const adminCookie = cookieStore.get('newsletter-admin');
if (!adminCookie || adminCookie.value !== 'authenticated') {
return NextResponse.json(
{ error: 'Unauthorized - Admin login required' },
{ status: 401 }
);
}
// Get 30 days ago date
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
// Get 7 days ago date
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
// Get start of current month
const startOfMonth = new Date();
startOfMonth.setDate(1);
startOfMonth.setHours(0, 0, 0, 0);
// Fetch all statistics in parallel
const [
totalUsers,
premiumUsers,
newUsersThisWeek,
newUsersThisMonth,
totalQRCodes,
dynamicQRCodes,
staticQRCodes,
totalScans,
dynamicQRCodesWithScans,
activeQRCodes,
newsletterSubscribers,
] = await Promise.all([
// Total users
db.user.count(),
// Premium users (PRO or BUSINESS)
db.user.count({
where: {
plan: {
in: ['PRO', 'BUSINESS'],
},
},
}),
// New users this week
db.user.count({
where: {
createdAt: {
gte: sevenDaysAgo,
},
},
}),
// New users this month
db.user.count({
where: {
createdAt: {
gte: startOfMonth,
},
},
}),
// Total QR codes
db.qRCode.count(),
// Dynamic QR codes
db.qRCode.count({
where: {
type: 'DYNAMIC',
},
}),
// Static QR codes
db.qRCode.count({
where: {
type: 'STATIC',
},
}),
// Total scans
db.qRScan.count(),
// Get all dynamic QR codes with their scan counts
db.qRCode.findMany({
where: {
type: 'DYNAMIC',
},
include: {
_count: {
select: {
scans: true,
},
},
},
}),
// Active QR codes (scanned in last 30 days)
db.qRCode.findMany({
where: {
scans: {
some: {
ts: {
gte: thirtyDaysAgo,
},
},
},
},
distinct: ['id'],
}),
// Newsletter subscribers
db.newsletterSubscription.count({
where: {
status: 'subscribed',
},
}),
]);
// Calculate dynamic QR scans
const dynamicQRScans = dynamicQRCodesWithScans.reduce(
(total, qr) => total + qr._count.scans,
0
);
// Calculate average scans per dynamic QR
const avgScansPerDynamicQR =
dynamicQRCodes > 0 ? (dynamicQRScans / dynamicQRCodes).toFixed(1) : '0';
// Get top 5 most scanned QR codes
const topQRCodes = await db.qRCode.findMany({
take: 5,
include: {
_count: {
select: {
scans: true,
},
},
user: {
select: {
email: true,
name: true,
},
},
},
orderBy: {
scans: {
_count: 'desc',
},
},
});
// Get recent users
const recentUsers = await db.user.findMany({
take: 5,
orderBy: {
createdAt: 'desc',
},
select: {
email: true,
name: true,
plan: true,
createdAt: true,
},
});
return NextResponse.json({
users: {
total: totalUsers,
premium: premiumUsers,
newThisWeek: newUsersThisWeek,
newThisMonth: newUsersThisMonth,
recent: recentUsers,
},
qrCodes: {
total: totalQRCodes,
dynamic: dynamicQRCodes,
static: staticQRCodes,
active: activeQRCodes.length,
},
scans: {
total: totalScans,
dynamicOnly: dynamicQRScans,
avgPerDynamicQR: avgScansPerDynamicQR,
},
newsletter: {
subscribers: newsletterSubscribers,
},
topQRCodes: topQRCodes.map((qr) => ({
id: qr.id,
title: qr.title,
type: qr.type,
scans: qr._count.scans,
owner: qr.user.name || qr.user.email,
createdAt: qr.createdAt,
})),
});
} catch (error) {
console.error('Error fetching admin stats:', error);
return NextResponse.json(
{ error: 'Failed to fetch statistics' },
{ status: 500 }
);
}
}

View File

@@ -1,288 +1,288 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { TrendData } from '@/types/analytics';
export const dynamic = 'force-dynamic';
// Helper function to calculate trend with proper edge case handling
function calculateTrend(current: number, previous: number): TrendData {
// Handle edge case: no data in either period
if (previous === 0 && current === 0) {
return { trend: 'flat', percentage: 0 };
}
// Handle new growth from zero - mark as "new" to distinguish from actual 100% growth
if (previous === 0 && current > 0) {
return { trend: 'up', percentage: 100, isNew: true };
}
// Calculate actual percentage change
const change = ((current - previous) / previous) * 100;
const roundedChange = Math.round(change);
// Determine trend direction (use threshold of 5% to filter noise)
let trend: 'up' | 'down' | 'flat';
if (roundedChange > 5) {
trend = 'up';
} else if (roundedChange < -5) {
trend = 'down';
} else {
trend = 'flat';
}
return {
trend,
percentage: Math.abs(roundedChange),
isNegative: roundedChange < 0
};
}
export async function GET(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.ANALYTICS);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Get date range from query params (default: last 30 days)
const { searchParams } = request.nextUrl;
const range = searchParams.get('range') || '30';
const daysInRange = parseInt(range, 10);
// Standardize to week (7 days) or month (30 days) for clear comparison labels
const comparisonDays = daysInRange <= 7 ? 7 : 30;
const comparisonPeriod: 'week' | 'month' = comparisonDays === 7 ? 'week' : 'month';
// Calculate current and previous period dates
const now = new Date();
const currentPeriodStart = new Date();
currentPeriodStart.setDate(now.getDate() - comparisonDays);
const previousPeriodEnd = new Date(currentPeriodStart);
const previousPeriodStart = new Date(previousPeriodEnd);
previousPeriodStart.setDate(previousPeriodEnd.getDate() - comparisonDays);
// Get user's QR codes with scans filtered by period
const qrCodes = await db.qRCode.findMany({
where: { userId },
include: {
scans: {
where: {
ts: {
gte: currentPeriodStart,
},
},
},
},
});
// Get previous period scans for comparison
const qrCodesWithPreviousScans = await db.qRCode.findMany({
where: { userId },
include: {
scans: {
where: {
ts: {
gte: previousPeriodStart,
lt: previousPeriodEnd,
},
},
},
},
});
// Calculate current period stats
const totalScans = qrCodes.reduce((sum, qr) => sum + qr.scans.length, 0);
const uniqueScans = qrCodes.reduce((sum, qr) =>
sum + qr.scans.filter(s => s.isUnique).length, 0
);
// Calculate previous period stats for comparison
const previousTotalScans = qrCodesWithPreviousScans.reduce((sum, qr) => sum + qr.scans.length, 0);
const previousUniqueScans = qrCodesWithPreviousScans.reduce((sum, qr) =>
sum + qr.scans.filter(s => s.isUnique).length, 0
);
// Calculate average scans per QR code (only count QR codes with scans)
const qrCodesWithScans = qrCodes.filter(qr => qr.scans.length > 0).length;
const avgScansPerQR = qrCodesWithScans > 0
? Math.round(totalScans / qrCodesWithScans)
: 0;
// Calculate previous period average scans per QR
const previousQrCodesWithScans = qrCodesWithPreviousScans.filter(qr => qr.scans.length > 0).length;
const previousAvgScansPerQR = previousQrCodesWithScans > 0
? Math.round(previousTotalScans / previousQrCodesWithScans)
: 0;
// Calculate trends
const scansTrend = calculateTrend(totalScans, previousTotalScans);
// New Conversion Rate Logic: (Unique Scans / Total Scans) * 100
// This represents "Engagement Efficiency" - how many scans are from fresh users
const currentConversion = totalScans > 0 ? Math.round((uniqueScans / totalScans) * 100) : 0;
const previousConversion = previousTotalScans > 0
? Math.round((previousUniqueScans / previousTotalScans) * 100)
: 0;
const avgScansTrend = calculateTrend(currentConversion, previousConversion);
// Device stats
const deviceStats = qrCodes.flatMap(qr => qr.scans)
.reduce((acc, scan) => {
const device = scan.device || 'unknown';
acc[device] = (acc[device] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const mobileScans = (deviceStats.mobile || 0) + (deviceStats.tablet || 0);
const mobilePercentage = totalScans > 0
? Math.round((mobileScans / totalScans) * 100)
: 0;
// Country stats (current period)
const countryStats = qrCodes.flatMap(qr => qr.scans)
.reduce((acc, scan) => {
const country = scan.country ?? 'Unknown Location';
acc[country] = (acc[country] || 0) + 1;
return acc;
}, {} as Record<string, number>);
// Country stats (previous period)
const previousCountryStats = qrCodesWithPreviousScans.flatMap(qr => qr.scans)
.reduce((acc, scan) => {
const country = scan.country ?? 'Unknown Location';
acc[country] = (acc[country] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const topCountry = Object.entries(countryStats)
.sort(([, a], [, b]) => b - a)[0];
// Daily scan counts for chart (current period)
const dailyScans = qrCodes.flatMap(qr => qr.scans).reduce((acc, scan) => {
const date = new Date(scan.ts).toISOString().split('T')[0];
acc[date] = (acc[date] || 0) + 1;
return acc;
}, {} as Record<string, number>);
// 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')
.map(qr => {
const currentTotal = qr.scans.length;
const currentUnique = qr.scans.filter(s => s.isUnique).length;
// Find previous period data for this QR code
const previousQR = qrCodesWithPreviousScans.find(prev => prev.id === qr.id);
const previousTotal = previousQR ? previousQR.scans.length : 0;
// 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,
type: qr.type,
totalScans: currentTotal,
uniqueScans: currentUnique,
conversion: currentTotal > 0
? Math.round((currentUnique / currentTotal) * 100)
: 0,
trend: trendData.trend,
trendPercentage: trendData.percentage,
sparkline: sparklineData,
lastScanned: lastScanned?.toISOString() || null,
...(trendData.isNew && { isNew: true }),
};
})
.sort((a, b) => b.totalScans - a.totalScans);
return NextResponse.json({
summary: {
totalScans,
uniqueScans,
avgScansPerQR: currentConversion, // Now sending Unique Rate instead of Avg per QR
mobilePercentage,
topCountry: topCountry ? topCountry[0] : 'N/A',
topCountryPercentage: topCountry && totalScans > 0
? Math.round((topCountry[1] / totalScans) * 100)
: 0,
scansTrend,
avgScansTrend,
comparisonPeriod,
comparisonDays,
},
deviceStats,
countryStats: Object.entries(countryStats)
.sort(([, a], [, b]) => b - a)
.slice(0, 10)
.map(([country, count]) => {
const previousCount = previousCountryStats[country] || 0;
const trendData = calculateTrend(count, previousCount);
return {
country,
count,
percentage: totalScans > 0
? Math.round((count / totalScans) * 100)
: 0,
trend: trendData.trend,
trendPercentage: trendData.percentage,
...(trendData.isNew && { isNew: true }),
};
}),
dailyScans,
qrPerformance: qrPerformance.slice(0, 10),
});
} catch (error) {
console.error('Error fetching analytics:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { TrendData } from '@/types/analytics';
export const dynamic = 'force-dynamic';
// Helper function to calculate trend with proper edge case handling
function calculateTrend(current: number, previous: number): TrendData {
// Handle edge case: no data in either period
if (previous === 0 && current === 0) {
return { trend: 'flat', percentage: 0 };
}
// Handle new growth from zero - mark as "new" to distinguish from actual 100% growth
if (previous === 0 && current > 0) {
return { trend: 'up', percentage: 100, isNew: true };
}
// Calculate actual percentage change
const change = ((current - previous) / previous) * 100;
const roundedChange = Math.round(change);
// Determine trend direction (use threshold of 5% to filter noise)
let trend: 'up' | 'down' | 'flat';
if (roundedChange > 5) {
trend = 'up';
} else if (roundedChange < -5) {
trend = 'down';
} else {
trend = 'flat';
}
return {
trend,
percentage: Math.abs(roundedChange),
isNegative: roundedChange < 0
};
}
export async function GET(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
// Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.ANALYTICS);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many requests. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Get date range from query params (default: last 30 days)
const { searchParams } = request.nextUrl;
const range = searchParams.get('range') || '30';
const daysInRange = parseInt(range, 10);
// Standardize to week (7 days) or month (30 days) for clear comparison labels
const comparisonDays = daysInRange <= 7 ? 7 : 30;
const comparisonPeriod: 'week' | 'month' = comparisonDays === 7 ? 'week' : 'month';
// Calculate current and previous period dates
const now = new Date();
const currentPeriodStart = new Date();
currentPeriodStart.setDate(now.getDate() - comparisonDays);
const previousPeriodEnd = new Date(currentPeriodStart);
const previousPeriodStart = new Date(previousPeriodEnd);
previousPeriodStart.setDate(previousPeriodEnd.getDate() - comparisonDays);
// Get user's QR codes with scans filtered by period
const qrCodes = await db.qRCode.findMany({
where: { userId },
include: {
scans: {
where: {
ts: {
gte: currentPeriodStart,
},
},
},
},
});
// Get previous period scans for comparison
const qrCodesWithPreviousScans = await db.qRCode.findMany({
where: { userId },
include: {
scans: {
where: {
ts: {
gte: previousPeriodStart,
lt: previousPeriodEnd,
},
},
},
},
});
// Calculate current period stats
const totalScans = qrCodes.reduce((sum, qr) => sum + qr.scans.length, 0);
const uniqueScans = qrCodes.reduce((sum, qr) =>
sum + qr.scans.filter(s => s.isUnique).length, 0
);
// Calculate previous period stats for comparison
const previousTotalScans = qrCodesWithPreviousScans.reduce((sum, qr) => sum + qr.scans.length, 0);
const previousUniqueScans = qrCodesWithPreviousScans.reduce((sum, qr) =>
sum + qr.scans.filter(s => s.isUnique).length, 0
);
// Calculate average scans per QR code (only count QR codes with scans)
const qrCodesWithScans = qrCodes.filter(qr => qr.scans.length > 0).length;
const avgScansPerQR = qrCodesWithScans > 0
? Math.round(totalScans / qrCodesWithScans)
: 0;
// Calculate previous period average scans per QR
const previousQrCodesWithScans = qrCodesWithPreviousScans.filter(qr => qr.scans.length > 0).length;
const previousAvgScansPerQR = previousQrCodesWithScans > 0
? Math.round(previousTotalScans / previousQrCodesWithScans)
: 0;
// Calculate trends
const scansTrend = calculateTrend(totalScans, previousTotalScans);
// New Conversion Rate Logic: (Unique Scans / Total Scans) * 100
// This represents "Engagement Efficiency" - how many scans are from fresh users
const currentConversion = totalScans > 0 ? Math.round((uniqueScans / totalScans) * 100) : 0;
const previousConversion = previousTotalScans > 0
? Math.round((previousUniqueScans / previousTotalScans) * 100)
: 0;
const avgScansTrend = calculateTrend(currentConversion, previousConversion);
// Device stats
const deviceStats = qrCodes.flatMap(qr => qr.scans)
.reduce((acc, scan) => {
const device = scan.device || 'unknown';
acc[device] = (acc[device] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const mobileScans = (deviceStats.mobile || 0) + (deviceStats.tablet || 0);
const mobilePercentage = totalScans > 0
? Math.round((mobileScans / totalScans) * 100)
: 0;
// Country stats (current period)
const countryStats = qrCodes.flatMap(qr => qr.scans)
.reduce((acc, scan) => {
const country = scan.country ?? 'Unknown Location';
acc[country] = (acc[country] || 0) + 1;
return acc;
}, {} as Record<string, number>);
// Country stats (previous period)
const previousCountryStats = qrCodesWithPreviousScans.flatMap(qr => qr.scans)
.reduce((acc, scan) => {
const country = scan.country ?? 'Unknown Location';
acc[country] = (acc[country] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const topCountry = Object.entries(countryStats)
.sort(([, a], [, b]) => b - a)[0];
// Daily scan counts for chart (current period)
const dailyScans = qrCodes.flatMap(qr => qr.scans).reduce((acc, scan) => {
const date = new Date(scan.ts).toISOString().split('T')[0];
acc[date] = (acc[date] || 0) + 1;
return acc;
}, {} as Record<string, number>);
// 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')
.map(qr => {
const currentTotal = qr.scans.length;
const currentUnique = qr.scans.filter(s => s.isUnique).length;
// Find previous period data for this QR code
const previousQR = qrCodesWithPreviousScans.find(prev => prev.id === qr.id);
const previousTotal = previousQR ? previousQR.scans.length : 0;
// 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,
type: qr.type,
totalScans: currentTotal,
uniqueScans: currentUnique,
conversion: currentTotal > 0
? Math.round((currentUnique / currentTotal) * 100)
: 0,
trend: trendData.trend,
trendPercentage: trendData.percentage,
sparkline: sparklineData,
lastScanned: lastScanned?.toISOString() || null,
...(trendData.isNew && { isNew: true }),
};
})
.sort((a, b) => b.totalScans - a.totalScans);
return NextResponse.json({
summary: {
totalScans,
uniqueScans,
avgScansPerQR: currentConversion, // Now sending Unique Rate instead of Avg per QR
mobilePercentage,
topCountry: topCountry ? topCountry[0] : 'N/A',
topCountryPercentage: topCountry && totalScans > 0
? Math.round((topCountry[1] / totalScans) * 100)
: 0,
scansTrend,
avgScansTrend,
comparisonPeriod,
comparisonDays,
},
deviceStats,
countryStats: Object.entries(countryStats)
.sort(([, a], [, b]) => b - a)
.slice(0, 10)
.map(([country, count]) => {
const previousCount = previousCountryStats[country] || 0;
const trendData = calculateTrend(count, previousCount);
return {
country,
count,
percentage: totalScans > 0
? Math.round((count / totalScans) * 100)
: 0,
trend: trendData.trend,
trendPercentage: trendData.percentage,
...(trendData.isNew && { isNew: true }),
};
}),
dailyScans,
qrPerformance: qrPerformance.slice(0, 10),
});
} catch (error) {
console.error('Error fetching analytics:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -1,106 +1,106 @@
import { NextRequest, NextResponse } from 'next/server';
import bcrypt from 'bcryptjs';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { z } from 'zod';
import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { getAuthCookieOptions } from '@/lib/cookieConfig';
import { signupSchema, validateRequest } from '@/lib/validationSchemas';
export async function POST(request: NextRequest) {
try {
// CSRF Protection
const csrfCheck = csrfProtection(request);
if (!csrfCheck.valid) {
return NextResponse.json(
{ error: csrfCheck.error },
{ status: 403 }
);
}
// Rate Limiting
const clientId = getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.SIGNUP);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many signup attempts. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
const body = await request.json();
// Validate request body
const validation = await validateRequest(signupSchema, body);
if (!validation.success) {
return NextResponse.json(validation.error, { status: 400 });
}
const { name, email, password } = validation.data;
// Check if user already exists
const existingUser = await db.user.findUnique({
where: { email },
});
if (existingUser) {
return NextResponse.json(
{ error: 'User already exists' },
{ status: 400 }
);
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
// Create user
const user = await db.user.create({
data: {
name,
email,
password: hashedPassword,
},
});
// Create response
const response = NextResponse.json({
success: true,
user: {
id: user.id,
name: user.name,
email: user.email,
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(
{ error: 'Invalid input', details: error.errors },
{ status: 400 }
);
}
console.error('Signup error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
import { NextRequest, NextResponse } from 'next/server';
import bcrypt from 'bcryptjs';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { z } from 'zod';
import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { getAuthCookieOptions } from '@/lib/cookieConfig';
import { signupSchema, validateRequest } from '@/lib/validationSchemas';
export async function POST(request: NextRequest) {
try {
// CSRF Protection
const csrfCheck = csrfProtection(request);
if (!csrfCheck.valid) {
return NextResponse.json(
{ error: csrfCheck.error },
{ status: 403 }
);
}
// Rate Limiting
const clientId = getClientIdentifier(request);
const rateLimitResult = rateLimit(clientId, RateLimits.SIGNUP);
if (!rateLimitResult.success) {
return NextResponse.json(
{
error: 'Too many signup attempts. Please try again later.',
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
},
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
}
}
);
}
const body = await request.json();
// Validate request body
const validation = await validateRequest(signupSchema, body);
if (!validation.success) {
return NextResponse.json(validation.error, { status: 400 });
}
const { name, email, password } = validation.data;
// Check if user already exists
const existingUser = await db.user.findUnique({
where: { email },
});
if (existingUser) {
return NextResponse.json(
{ error: 'User already exists' },
{ status: 400 }
);
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
// Create user
const user = await db.user.create({
data: {
name,
email,
password: hashedPassword,
},
});
// Create response
const response = NextResponse.json({
success: true,
user: {
id: user.id,
name: user.name,
email: user.email,
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(
{ error: 'Invalid input', details: error.errors },
{ status: 400 }
);
}
console.error('Signup error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -1,192 +1,192 @@
'use client';
import React, { memo } from 'react';
import {
ComposableMap,
Geographies,
Geography,
ZoomableGroup,
} from 'react-simple-maps';
import { scaleLinear } from 'd3-scale';
// TopoJSON world map
const geoUrl = 'https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json';
// ISO Alpha-2 to country name mapping for common countries
const countryNameToCode: Record<string, string> = {
'United States': 'US',
'USA': 'US',
'US': 'US',
'Germany': 'DE',
'DE': 'DE',
'United Kingdom': 'GB',
'UK': 'GB',
'GB': 'GB',
'France': 'FR',
'FR': 'FR',
'Canada': 'CA',
'CA': 'CA',
'Australia': 'AU',
'AU': 'AU',
'Japan': 'JP',
'JP': 'JP',
'China': 'CN',
'CN': 'CN',
'India': 'IN',
'IN': 'IN',
'Brazil': 'BR',
'BR': 'BR',
'Spain': 'ES',
'ES': 'ES',
'Italy': 'IT',
'IT': 'IT',
'Netherlands': 'NL',
'NL': 'NL',
'Switzerland': 'CH',
'CH': 'CH',
'Austria': 'AT',
'AT': 'AT',
'Poland': 'PL',
'PL': 'PL',
'Sweden': 'SE',
'SE': 'SE',
'Norway': 'NO',
'NO': 'NO',
'Denmark': 'DK',
'DK': 'DK',
'Finland': 'FI',
'FI': 'FI',
'Belgium': 'BE',
'BE': 'BE',
'Portugal': 'PT',
'PT': 'PT',
'Ireland': 'IE',
'IE': 'IE',
'Mexico': 'MX',
'MX': 'MX',
'Argentina': 'AR',
'AR': 'AR',
'South Korea': 'KR',
'KR': 'KR',
'Singapore': 'SG',
'SG': 'SG',
'New Zealand': 'NZ',
'NZ': 'NZ',
'Russia': 'RU',
'RU': 'RU',
'South Africa': 'ZA',
'ZA': 'ZA',
'Unknown Location': 'UNKNOWN',
'unknown': 'UNKNOWN',
};
// ISO Alpha-2 to ISO Alpha-3 mapping (for matching with TopoJSON)
const alpha2ToAlpha3: Record<string, string> = {
'US': 'USA',
'DE': 'DEU',
'GB': 'GBR',
'FR': 'FRA',
'CA': 'CAN',
'AU': 'AUS',
'JP': 'JPN',
'CN': 'CHN',
'IN': 'IND',
'BR': 'BRA',
'ES': 'ESP',
'IT': 'ITA',
'NL': 'NLD',
'CH': 'CHE',
'AT': 'AUT',
'PL': 'POL',
'SE': 'SWE',
'NO': 'NOR',
'DK': 'DNK',
'FI': 'FIN',
'BE': 'BEL',
'PT': 'PRT',
'IE': 'IRL',
'MX': 'MEX',
'AR': 'ARG',
'KR': 'KOR',
'SG': 'SGP',
'NZ': 'NZL',
'RU': 'RUS',
'ZA': 'ZAF',
};
interface CountryStat {
country: string;
count: number;
percentage: number;
}
interface GeoMapProps {
countryStats: CountryStat[];
totalScans: number;
}
const GeoMap: React.FC<GeoMapProps> = ({ countryStats, totalScans }) => {
// Build a map of ISO Alpha-3 codes to scan counts
const countryData: Record<string, number> = {};
let maxCount = 0;
countryStats.forEach((stat) => {
const alpha2 = countryNameToCode[stat.country] || stat.country;
const alpha3 = alpha2ToAlpha3[alpha2];
if (alpha3) {
countryData[alpha3] = stat.count;
if (stat.count > maxCount) maxCount = stat.count;
}
});
// Color scale: light blue to dark blue based on scan count
const colorScale = scaleLinear<string>()
.domain([0, maxCount || 1])
.range(['#E0F2FE', '#1E40AF']);
return (
<div className="w-full h-full">
<ComposableMap
projection="geoMercator"
projectionConfig={{
scale: 120,
center: [0, 30],
}}
style={{ width: '100%', height: '100%' }}
>
<ZoomableGroup center={[0, 30]} zoom={1}>
<Geographies geography={geoUrl}>
{({ geographies }) =>
geographies.map((geo) => {
const isoCode = geo.properties.ISO_A3 || geo.id;
const scanCount = countryData[isoCode] || 0;
const fillColor = scanCount > 0 ? colorScale(scanCount) : '#F1F5F9';
return (
<Geography
key={geo.rsmKey}
geography={geo}
fill={fillColor}
stroke="#CBD5E1"
strokeWidth={0.5}
style={{
default: { outline: 'none' },
hover: {
fill: scanCount > 0 ? '#3B82F6' : '#E2E8F0',
outline: 'none',
cursor: 'pointer',
},
pressed: { outline: 'none' },
}}
/>
);
})
}
</Geographies>
</ZoomableGroup>
</ComposableMap>
</div>
);
};
export default memo(GeoMap);
'use client';
import React, { memo } from 'react';
import {
ComposableMap,
Geographies,
Geography,
ZoomableGroup,
} from 'react-simple-maps';
import { scaleLinear } from 'd3-scale';
// TopoJSON world map
const geoUrl = 'https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json';
// ISO Alpha-2 to country name mapping for common countries
const countryNameToCode: Record<string, string> = {
'United States': 'US',
'USA': 'US',
'US': 'US',
'Germany': 'DE',
'DE': 'DE',
'United Kingdom': 'GB',
'UK': 'GB',
'GB': 'GB',
'France': 'FR',
'FR': 'FR',
'Canada': 'CA',
'CA': 'CA',
'Australia': 'AU',
'AU': 'AU',
'Japan': 'JP',
'JP': 'JP',
'China': 'CN',
'CN': 'CN',
'India': 'IN',
'IN': 'IN',
'Brazil': 'BR',
'BR': 'BR',
'Spain': 'ES',
'ES': 'ES',
'Italy': 'IT',
'IT': 'IT',
'Netherlands': 'NL',
'NL': 'NL',
'Switzerland': 'CH',
'CH': 'CH',
'Austria': 'AT',
'AT': 'AT',
'Poland': 'PL',
'PL': 'PL',
'Sweden': 'SE',
'SE': 'SE',
'Norway': 'NO',
'NO': 'NO',
'Denmark': 'DK',
'DK': 'DK',
'Finland': 'FI',
'FI': 'FI',
'Belgium': 'BE',
'BE': 'BE',
'Portugal': 'PT',
'PT': 'PT',
'Ireland': 'IE',
'IE': 'IE',
'Mexico': 'MX',
'MX': 'MX',
'Argentina': 'AR',
'AR': 'AR',
'South Korea': 'KR',
'KR': 'KR',
'Singapore': 'SG',
'SG': 'SG',
'New Zealand': 'NZ',
'NZ': 'NZ',
'Russia': 'RU',
'RU': 'RU',
'South Africa': 'ZA',
'ZA': 'ZA',
'Unknown Location': 'UNKNOWN',
'unknown': 'UNKNOWN',
};
// ISO Alpha-2 to ISO Alpha-3 mapping (for matching with TopoJSON)
const alpha2ToAlpha3: Record<string, string> = {
'US': 'USA',
'DE': 'DEU',
'GB': 'GBR',
'FR': 'FRA',
'CA': 'CAN',
'AU': 'AUS',
'JP': 'JPN',
'CN': 'CHN',
'IN': 'IND',
'BR': 'BRA',
'ES': 'ESP',
'IT': 'ITA',
'NL': 'NLD',
'CH': 'CHE',
'AT': 'AUT',
'PL': 'POL',
'SE': 'SWE',
'NO': 'NOR',
'DK': 'DNK',
'FI': 'FIN',
'BE': 'BEL',
'PT': 'PRT',
'IE': 'IRL',
'MX': 'MEX',
'AR': 'ARG',
'KR': 'KOR',
'SG': 'SGP',
'NZ': 'NZL',
'RU': 'RUS',
'ZA': 'ZAF',
};
interface CountryStat {
country: string;
count: number;
percentage: number;
}
interface GeoMapProps {
countryStats: CountryStat[];
totalScans: number;
}
const GeoMap: React.FC<GeoMapProps> = ({ countryStats, totalScans }) => {
// Build a map of ISO Alpha-3 codes to scan counts
const countryData: Record<string, number> = {};
let maxCount = 0;
countryStats.forEach((stat) => {
const alpha2 = countryNameToCode[stat.country] || stat.country;
const alpha3 = alpha2ToAlpha3[alpha2];
if (alpha3) {
countryData[alpha3] = stat.count;
if (stat.count > maxCount) maxCount = stat.count;
}
});
// Color scale: light blue to dark blue based on scan count
const colorScale = scaleLinear<string>()
.domain([0, maxCount || 1])
.range(['#E0F2FE', '#1E40AF']);
return (
<div className="w-full h-full">
<ComposableMap
projection="geoMercator"
projectionConfig={{
scale: 120,
center: [0, 30],
}}
style={{ width: '100%', height: '100%' }}
>
<ZoomableGroup center={[0, 30]} zoom={1}>
<Geographies geography={geoUrl}>
{({ geographies }) =>
geographies.map((geo) => {
const isoCode = geo.properties.ISO_A3 || geo.id;
const scanCount = countryData[isoCode] || 0;
const fillColor = scanCount > 0 ? colorScale(scanCount) : '#F1F5F9';
return (
<Geography
key={geo.rsmKey}
geography={geo}
fill={fillColor}
stroke="#CBD5E1"
strokeWidth={0.5}
style={{
default: { outline: 'none' },
hover: {
fill: scanCount > 0 ? '#3B82F6' : '#E2E8F0',
outline: 'none',
cursor: 'pointer',
},
pressed: { outline: 'none' },
}}
/>
);
})
}
</Geographies>
</ZoomableGroup>
</ComposableMap>
</div>
);
};
export default memo(GeoMap);

View File

@@ -1,86 +1,86 @@
'use client';
import React from 'react';
import { Line } from 'react-chartjs-2';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Filler,
} from 'chart.js';
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Filler);
interface SparklineProps {
data: number[];
color?: 'blue' | 'green' | 'red';
width?: number;
height?: number;
}
const colorMap = {
blue: {
border: 'rgb(59, 130, 246)',
background: 'rgba(59, 130, 246, 0.1)',
},
green: {
border: 'rgb(34, 197, 94)',
background: 'rgba(34, 197, 94, 0.1)',
},
red: {
border: 'rgb(239, 68, 68)',
background: 'rgba(239, 68, 68, 0.1)',
},
};
const Sparkline: React.FC<SparklineProps> = ({
data,
color = 'blue',
width = 100,
height = 30,
}) => {
const colors = colorMap[color];
const chartData = {
labels: data.map((_, i) => i.toString()),
datasets: [
{
data,
borderColor: colors.border,
backgroundColor: colors.background,
borderWidth: 1.5,
pointRadius: 0,
tension: 0.4,
fill: true,
},
],
};
const options = {
responsive: false,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { enabled: false },
},
scales: {
x: { display: false },
y: { display: false },
},
elements: {
line: {
borderJoinStyle: 'round' as const,
},
},
};
return (
<div style={{ width, height }}>
<Line data={chartData} options={options} width={width} height={height} />
</div>
);
};
export default Sparkline;
'use client';
import React from 'react';
import { Line } from 'react-chartjs-2';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Filler,
} from 'chart.js';
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Filler);
interface SparklineProps {
data: number[];
color?: 'blue' | 'green' | 'red';
width?: number;
height?: number;
}
const colorMap = {
blue: {
border: 'rgb(59, 130, 246)',
background: 'rgba(59, 130, 246, 0.1)',
},
green: {
border: 'rgb(34, 197, 94)',
background: 'rgba(34, 197, 94, 0.1)',
},
red: {
border: 'rgb(239, 68, 68)',
background: 'rgba(239, 68, 68, 0.1)',
},
};
const Sparkline: React.FC<SparklineProps> = ({
data,
color = 'blue',
width = 100,
height = 30,
}) => {
const colors = colorMap[color];
const chartData = {
labels: data.map((_, i) => i.toString()),
datasets: [
{
data,
borderColor: colors.border,
backgroundColor: colors.background,
borderWidth: 1.5,
pointRadius: 0,
tension: 0.4,
fill: true,
},
],
};
const options = {
responsive: false,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { enabled: false },
},
scales: {
x: { display: false },
y: { display: false },
},
elements: {
line: {
borderJoinStyle: 'round' as const,
},
},
};
return (
<div style={{ width, height }}>
<Line data={chartData} options={options} width={width} height={height} />
</div>
);
};
export default Sparkline;

View File

@@ -1,103 +1,103 @@
'use client';
import React from 'react';
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
interface StatCardProps {
title: string;
value: string | number;
subtitle?: string;
trend?: {
direction: 'up' | 'down' | 'flat';
percentage: number;
isNew?: boolean;
period?: string;
};
icon?: React.ReactNode;
variant?: 'default' | 'highlight';
}
const StatCard: React.FC<StatCardProps> = ({
title,
value,
subtitle,
trend,
icon,
variant = 'default',
}) => {
const getTrendColor = () => {
if (!trend) return 'text-gray-500';
if (trend.direction === 'up') return 'text-emerald-600';
if (trend.direction === 'down') return 'text-red-500';
return 'text-gray-500';
};
const getTrendIcon = () => {
if (!trend) return null;
if (trend.direction === 'up') return <TrendingUp className="w-4 h-4" />;
if (trend.direction === 'down') return <TrendingDown className="w-4 h-4" />;
return <Minus className="w-4 h-4" />;
};
return (
<div
className={`rounded-xl p-6 transition-all duration-200 ${variant === 'highlight'
? 'bg-gradient-to-br from-primary-500 to-primary-600 text-white shadow-lg shadow-primary-500/25'
: 'bg-white border border-gray-200 hover:shadow-md'
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<p
className={`text-sm font-medium ${variant === 'highlight' ? 'text-primary-100' : 'text-gray-500'
}`}
>
{title}
</p>
<p
className={`text-3xl font-bold mt-2 ${variant === 'highlight' ? 'text-white' : 'text-gray-900'
}`}
>
{typeof value === 'number' ? value.toLocaleString() : value}
</p>
{trend && (
<div className={`flex items-center gap-1 mt-3 ${getTrendColor()}`}>
{getTrendIcon()}
<span className="text-sm font-medium">
{trend.direction === 'up' ? '+' : trend.direction === 'down' ? '-' : ''}
{trend.percentage}%
{trend.isNew && ' (new)'}
</span>
{trend.period && (
<span
className={`text-sm ${variant === 'highlight' ? 'text-primary-200' : 'text-gray-400'
}`}
>
vs last {trend.period}
</span>
)}
</div>
)}
{subtitle && !trend && (
<p
className={`text-sm mt-2 ${variant === 'highlight' ? 'text-primary-200' : 'text-gray-500'
}`}
>
{subtitle}
</p>
)}
</div>
{icon && (
<div
className={`p-3 rounded-lg ${variant === 'highlight' ? 'bg-white/20' : 'bg-gray-100'
}`}
>
{icon}
</div>
)}
</div>
</div>
);
};
export default StatCard;
'use client';
import React from 'react';
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
interface StatCardProps {
title: string;
value: string | number;
subtitle?: string;
trend?: {
direction: 'up' | 'down' | 'flat';
percentage: number;
isNew?: boolean;
period?: string;
};
icon?: React.ReactNode;
variant?: 'default' | 'highlight';
}
const StatCard: React.FC<StatCardProps> = ({
title,
value,
subtitle,
trend,
icon,
variant = 'default',
}) => {
const getTrendColor = () => {
if (!trend) return 'text-gray-500';
if (trend.direction === 'up') return 'text-emerald-600';
if (trend.direction === 'down') return 'text-red-500';
return 'text-gray-500';
};
const getTrendIcon = () => {
if (!trend) return null;
if (trend.direction === 'up') return <TrendingUp className="w-4 h-4" />;
if (trend.direction === 'down') return <TrendingDown className="w-4 h-4" />;
return <Minus className="w-4 h-4" />;
};
return (
<div
className={`rounded-xl p-6 transition-all duration-200 ${variant === 'highlight'
? 'bg-gradient-to-br from-primary-500 to-primary-600 text-white shadow-lg shadow-primary-500/25'
: 'bg-white border border-gray-200 hover:shadow-md'
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<p
className={`text-sm font-medium ${variant === 'highlight' ? 'text-primary-100' : 'text-gray-500'
}`}
>
{title}
</p>
<p
className={`text-3xl font-bold mt-2 ${variant === 'highlight' ? 'text-white' : 'text-gray-900'
}`}
>
{typeof value === 'number' ? value.toLocaleString() : value}
</p>
{trend && (
<div className={`flex items-center gap-1 mt-3 ${getTrendColor()}`}>
{getTrendIcon()}
<span className="text-sm font-medium">
{trend.direction === 'up' ? '+' : trend.direction === 'down' ? '-' : ''}
{trend.percentage}%
{trend.isNew && ' (new)'}
</span>
{trend.period && (
<span
className={`text-sm ${variant === 'highlight' ? 'text-primary-200' : 'text-gray-400'
}`}
>
vs last {trend.period}
</span>
)}
</div>
)}
{subtitle && !trend && (
<p
className={`text-sm mt-2 ${variant === 'highlight' ? 'text-primary-200' : 'text-gray-500'
}`}
>
{subtitle}
</p>
)}
</div>
{icon && (
<div
className={`p-3 rounded-lg ${variant === 'highlight' ? 'bg-white/20' : 'bg-gray-100'
}`}
>
{icon}
</div>
)}
</div>
</div>
);
};
export default StatCard;

View File

@@ -1,3 +1,3 @@
export { default as GeoMap } from './GeoMap';
export { default as Sparkline } from './Sparkline';
export { default as StatCard } from './StatCard';
export { default as GeoMap } from './GeoMap';
export { default as Sparkline } from './Sparkline';
export { default as StatCard } from './StatCard';

View File

@@ -1,70 +1,70 @@
import Link from 'next/link';
interface FooterProps {
variant?: 'marketing' | 'dashboard';
}
export function Footer({ variant = 'marketing' }: FooterProps) {
const isDashboard = variant === 'dashboard';
return (
<footer className={`${isDashboard ? 'bg-gray-50 text-gray-600 border-t border-gray-200 mt-12' : 'bg-gray-900 text-white mt-20'} py-12`}>
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="grid md:grid-cols-4 gap-8">
<div>
<Link href="/" className="flex items-center space-x-2 mb-4 hover:opacity-80 transition-opacity">
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
<span className={`text-xl font-bold ${isDashboard ? 'text-gray-900' : ''}`}>QR Master</span>
</Link>
<p className={isDashboard ? 'text-gray-500' : 'text-gray-400'}>
Create custom QR codes in seconds with advanced tracking and analytics.
</p>
</div>
<div>
<h3 className={`font-semibold mb-4 ${isDashboard ? 'text-gray-900' : ''}`}>Product</h3>
<ul className={`space-y-2 ${isDashboard ? 'text-gray-500' : 'text-gray-400'}`}>
<li><Link href="/#features" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Features</Link></li>
<li><Link href="/#pricing" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Pricing</Link></li>
<li><Link href="/#faq" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>FAQ</Link></li>
<li><Link href="/blog" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Blog</Link></li>
</ul>
</div>
<div>
<h3 className={`font-semibold mb-4 ${isDashboard ? 'text-gray-900' : ''}`}>Resources</h3>
<ul className={`space-y-2 ${isDashboard ? 'text-gray-500' : 'text-gray-400'}`}>
<li><Link href="/pricing" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Full Pricing</Link></li>
<li><Link href="/faq" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>All Questions</Link></li>
<li><Link href="/blog" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Blog</Link></li>
<li><Link href="/signup" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Get Started</Link></li>
</ul>
</div>
<div>
<h3 className={`font-semibold mb-4 ${isDashboard ? 'text-gray-900' : ''}`}>Legal</h3>
<ul className={`space-y-2 ${isDashboard ? 'text-gray-500' : 'text-gray-400'}`}>
<li><Link href="/privacy" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Privacy Policy</Link></li>
</ul>
</div>
</div>
<div className={`border-t mt-8 pt-8 flex items-center justify-between ${isDashboard ? 'border-gray-200 text-gray-500' : 'border-gray-800 text-gray-400'}`}>
{!isDashboard ? (
<Link
href="/newsletter"
className="text-[6px] text-gray-700 opacity-[0.03] hover:opacity-100 hover:text-white transition-opacity duration-300"
>
</Link>
) : (
<div></div>
)}
<p>&copy; 2025 QR Master. All rights reserved.</p>
<div className="w-12"></div>
</div>
</div>
</footer>
);
}
import Link from 'next/link';
interface FooterProps {
variant?: 'marketing' | 'dashboard';
}
export function Footer({ variant = 'marketing' }: FooterProps) {
const isDashboard = variant === 'dashboard';
return (
<footer className={`${isDashboard ? 'bg-gray-50 text-gray-600 border-t border-gray-200 mt-12' : 'bg-gray-900 text-white mt-20'} py-12`}>
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="grid md:grid-cols-4 gap-8">
<div>
<Link href="/" className="flex items-center space-x-2 mb-4 hover:opacity-80 transition-opacity">
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
<span className={`text-xl font-bold ${isDashboard ? 'text-gray-900' : ''}`}>QR Master</span>
</Link>
<p className={isDashboard ? 'text-gray-500' : 'text-gray-400'}>
Create custom QR codes in seconds with advanced tracking and analytics.
</p>
</div>
<div>
<h3 className={`font-semibold mb-4 ${isDashboard ? 'text-gray-900' : ''}`}>Product</h3>
<ul className={`space-y-2 ${isDashboard ? 'text-gray-500' : 'text-gray-400'}`}>
<li><Link href="/#features" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Features</Link></li>
<li><Link href="/#pricing" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Pricing</Link></li>
<li><Link href="/#faq" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>FAQ</Link></li>
<li><Link href="/blog" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Blog</Link></li>
</ul>
</div>
<div>
<h3 className={`font-semibold mb-4 ${isDashboard ? 'text-gray-900' : ''}`}>Resources</h3>
<ul className={`space-y-2 ${isDashboard ? 'text-gray-500' : 'text-gray-400'}`}>
<li><Link href="/pricing" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Full Pricing</Link></li>
<li><Link href="/faq" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>All Questions</Link></li>
<li><Link href="/blog" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Blog</Link></li>
<li><Link href="/signup" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Get Started</Link></li>
</ul>
</div>
<div>
<h3 className={`font-semibold mb-4 ${isDashboard ? 'text-gray-900' : ''}`}>Legal</h3>
<ul className={`space-y-2 ${isDashboard ? 'text-gray-500' : 'text-gray-400'}`}>
<li><Link href="/privacy" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Privacy Policy</Link></li>
</ul>
</div>
</div>
<div className={`border-t mt-8 pt-8 flex items-center justify-between ${isDashboard ? 'border-gray-200 text-gray-500' : 'border-gray-800 text-gray-400'}`}>
{!isDashboard ? (
<Link
href="/newsletter"
className="text-[6px] text-gray-700 opacity-[0.03] hover:opacity-100 hover:text-white transition-opacity duration-300"
>
</Link>
) : (
<div></div>
)}
<p>&copy; 2025 QR Master. All rights reserved.</p>
<div className="w-12"></div>
</div>
</div>
</footer>
);
}

View File

@@ -1,58 +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<ComposableMapProps>;
export const Geographies: ComponentType<GeographiesProps>;
export const Geography: ComponentType<GeographyProps>;
export const ZoomableGroup: ComponentType<ZoomableGroupProps>;
export const Marker: ComponentType<any>;
export const Line: ComponentType<any>;
export const Annotation: ComponentType<any>;
export const Graticule: ComponentType<any>;
export const Sphere: ComponentType<any>;
}
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<ComposableMapProps>;
export const Geographies: ComponentType<GeographiesProps>;
export const Geography: ComponentType<GeographyProps>;
export const ZoomableGroup: ComponentType<ZoomableGroupProps>;
export const Marker: ComponentType<any>;
export const Line: ComponentType<any>;
export const Annotation: ComponentType<any>;
export const Graticule: ComponentType<any>;
export const Sphere: ComponentType<any>;
}