11 Commits

38 changed files with 14312 additions and 13928 deletions

41
ideen.md Normal file
View File

@@ -0,0 +1,41 @@
🚀 Neue Content-Typen
Feature Beschreibung
WiFi QR SSID, Passwort, Verschlüsselungstyp perfekt für Cafés/Hotels
Event (VEVENT) Kalendereinträge direkt ins Handy importieren
App Store Links Smart-Links die iOS/Android erkennen
PayPal/Bitcoin Zahlungsaufforderungen per QR
WhatsApp/Telegram Direkt-Chat mit vordefinierter Nachricht
📊 Analytics-Erweiterungen
Feature Beschreibung
UTM-Parameter Automatische Kampagnen-Tags für Google Analytics
Conversion Tracking Ziel-URLs definieren und Conversion messen
A/B Testing Zwei Ziel-URLs testen, welche besser performt
Scheduled Reports Wöchentliche/monatliche E-Mail-Reports
Export (CSV/PDF) Analytics-Daten exportieren
🎨 QR Design & Styling
Feature Beschreibung
Design Templates Vorgefertigte Farb-/Logo-Kombinationen
Frames & CTA "Scan me!" Rahmen um den QR Code
Dot Styles Runde Punkte, Diamanten, etc.
Eye Shapes Custom Corner-Marker Designs
Gradient Colors Farbverläufe statt Vollfarben
🗂️ Organisation & Teamwork
Feature Beschreibung
Folders/Projekte QR Codes in Ordner organisieren
Tags & Filter Flexibles Tagging-System
Team Workspaces Mehrere User pro Account (BUSINESS)
Activity Log Wer hat was wann geändert
QR Code Archiv Soft-Delete statt Löschen
⚙️ Pro Features
Feature Beschreibung
Passwortschutz QR führt zu Passwort-geschützter Seite
Ablaufdatum QR Code deaktiviert sich automatisch
Scan-Limit Max. X Scans erlauben
Geo-Targeting Verschiedene URLs je nach Standort
Device Detection Desktop vs. Mobile unterschiedliche URLs
🔌 Integrationen
Feature Beschreibung
Zapier/Make Webhooks bei Scans triggern
Google Sheets Scan-Daten automatisch exportieren
Slack Notifications Benachrichtigung bei X Scans
API für Entwickler Public API mit Token-Auth

19655
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,86 +1,87 @@
{ {
"name": "qr-master", "name": "qr-master",
"version": "1.0.0", "version": "1.0.0",
"description": "Create custom QR codes in seconds", "description": "Create custom QR codes in seconds",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3050", "dev": "next dev -p 3050",
"build": "prisma generate && next build", "build": "prisma generate && next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"db:generate": "prisma generate", "db:generate": "prisma generate",
"db:migrate": "prisma migrate dev", "db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy", "db:deploy": "prisma migrate deploy",
"db:seed": "tsx prisma/seed.ts", "db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio", "db:studio": "prisma studio",
"postinstall": "prisma generate", "postinstall": "prisma generate",
"docker:dev": "docker compose -f docker-compose.dev.yml up -d", "docker:dev": "docker compose -f docker-compose.dev.yml up -d",
"docker:dev:stop": "docker compose -f docker-compose.dev.yml down", "docker:dev:stop": "docker compose -f docker-compose.dev.yml down",
"docker:dev:clean": "docker compose -f docker-compose.dev.yml down --remove-orphans && docker container prune -f", "docker:dev:clean": "docker compose -f docker-compose.dev.yml down --remove-orphans && docker container prune -f",
"docker:prod": "docker compose up -d --build", "docker:prod": "docker compose up -d --build",
"docker:stop": "docker compose down", "docker:stop": "docker compose down",
"docker:logs": "docker compose logs -f", "docker:logs": "docker compose logs -f",
"docker:db": "docker compose exec db psql -U postgres -d qrmaster", "docker:db": "docker compose exec db psql -U postgres -d qrmaster",
"docker:redis": "docker compose exec redis redis-cli", "docker:redis": "docker compose exec redis redis-cli",
"docker:backup": "docker compose exec db pg_dump -U postgres qrmaster > backup_$(date +%Y%m%d).sql" "docker:backup": "docker compose exec db pg_dump -U postgres qrmaster > backup_$(date +%Y%m%d).sql"
}, },
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.11.1", "@auth/prisma-adapter": "^2.11.1",
"@edge-runtime/cookies": "^6.0.0", "@edge-runtime/cookies": "^6.0.0",
"@prisma/client": "^5.7.0", "@prisma/client": "^5.7.0",
"@stripe/stripe-js": "^8.0.0", "@stripe/stripe-js": "^8.0.0",
"@types/d3-scale": "^4.0.9", "@types/d3-scale": "^4.0.9",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"chart.js": "^4.4.0", "chart.js": "^4.4.0",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"d3-scale": "^4.0.2", "d3-scale": "^4.0.2",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"i18next": "^23.7.6", "framer-motion": "^12.24.10",
"ioredis": "^5.3.2", "i18next": "^23.7.6",
"jszip": "^3.10.1", "ioredis": "^5.3.2",
"lucide-react": "^0.562.0", "jszip": "^3.10.1",
"next": "^14.2.35", "lucide-react": "^0.562.0",
"next-auth": "^4.24.5", "next": "^14.2.35",
"papaparse": "^5.4.1", "next-auth": "^4.24.5",
"posthog-js": "^1.276.0", "papaparse": "^5.4.1",
"qr-code-styling": "^1.9.2", "posthog-js": "^1.276.0",
"qrcode": "^1.5.3", "qr-code-styling": "^1.9.2",
"qrcode.react": "^3.1.0", "qrcode": "^1.5.3",
"react": "^18.2.0", "qrcode.react": "^3.1.0",
"react-chartjs-2": "^5.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-chartjs-2": "^5.2.0",
"react-dropzone": "^14.2.3", "react-dom": "^18.2.0",
"react-i18next": "^13.5.0", "react-dropzone": "^14.2.3",
"react-simple-maps": "^3.0.0", "react-i18next": "^13.5.0",
"resend": "^6.4.2", "react-simple-maps": "^3.0.0",
"sharp": "^0.33.1", "resend": "^6.4.2",
"stripe": "^19.1.0", "sharp": "^0.33.1",
"tailwind-merge": "^2.2.0", "stripe": "^19.1.0",
"uuid": "^13.0.0", "tailwind-merge": "^2.2.0",
"zod": "^3.25.76" "uuid": "^13.0.0",
}, "zod": "^3.25.76"
"devDependencies": { },
"@types/bcryptjs": "^2.4.6", "devDependencies": {
"@types/file-saver": "^2.0.7", "@types/bcryptjs": "^2.4.6",
"@types/node": "^20.10.5", "@types/file-saver": "^2.0.7",
"@types/papaparse": "^5.3.14", "@types/node": "^20.10.5",
"@types/qrcode": "^1.5.5", "@types/papaparse": "^5.3.14",
"@types/react": "^18.2.45", "@types/qrcode": "^1.5.5",
"@types/react-dom": "^18.2.18", "@types/react": "^18.2.45",
"autoprefixer": "^10.4.16", "@types/react-dom": "^18.2.18",
"eslint": "^8.56.0", "autoprefixer": "^10.4.16",
"eslint-config-next": "^16.1.1", "eslint": "^8.56.0",
"next-sitemap": "^4.2.3", "eslint-config-next": "^16.1.1",
"postcss": "^8.4.32", "next-sitemap": "^4.2.3",
"prettier": "^3.1.1", "postcss": "^8.4.32",
"prisma": "^5.7.0", "prettier": "^3.1.1",
"tailwindcss": "^3.3.6", "prisma": "^5.7.0",
"tsx": "^4.7.0", "tailwindcss": "^3.3.6",
"typescript": "^5.3.3" "tsx": "^4.7.0",
}, "typescript": "^5.3.3"
"engines": { },
"node": ">=18.0.0" "engines": {
} "node": ">=18.0.0"
} }
}

View File

@@ -32,9 +32,6 @@ model User {
resetPasswordToken String? @unique resetPasswordToken String? @unique
resetPasswordExpires DateTime? resetPasswordExpires DateTime?
// White-label subdomain
subdomain String? @unique
qrCodes QRCode[] qrCodes QRCode[]
integrations Integration[] integrations Integration[]
accounts Account[] accounts Account[]

BIN
public/hero-fluid.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 518 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -44,7 +44,6 @@ export default function DashboardPage() {
uniqueScans: 0, uniqueScans: 0,
}); });
const [analyticsData, setAnalyticsData] = useState<any>(null); const [analyticsData, setAnalyticsData] = useState<any>(null);
const [userSubdomain, setUserSubdomain] = useState<string | null>(null);
const mockQRCodes = [ const mockQRCodes = [
{ {
@@ -280,13 +279,6 @@ export default function DashboardPage() {
const analytics = await analyticsResponse.json(); const analytics = await analyticsResponse.json();
setAnalyticsData(analytics); setAnalyticsData(analytics);
} }
// Fetch user subdomain for white label display
const subdomainResponse = await fetch('/api/user/subdomain');
if (subdomainResponse.ok) {
const subdomainData = await subdomainResponse.json();
setUserSubdomain(subdomainData.subdomain || null);
}
} catch (error) { } catch (error) {
console.error('Error fetching data:', error); console.error('Error fetching data:', error);
setQrCodes([]); setQrCodes([]);
@@ -457,11 +449,10 @@ export default function DashboardPage() {
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{qrCodes.map((qr) => ( {qrCodes.map((qr) => (
<QRCodeCard <QRCodeCard
key={`${qr.id}-${userSubdomain || 'default'}`} key={qr.id}
qr={qr} qr={qr}
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} onDelete={handleDelete}
userSubdomain={userSubdomain}
/> />
))} ))}
</div> </div>

View File

@@ -1,254 +1,254 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Dropdown, DropdownItem } from '@/components/ui/Dropdown'; import { Dropdown, DropdownItem } from '@/components/ui/Dropdown';
import { Footer } from '@/components/ui/Footer'; import { Footer } from '@/components/ui/Footer';
import { useTranslation } from '@/hooks/useTranslation'; import { useTranslation } from '@/hooks/useTranslation';
interface User { interface User {
id: string; id: string;
name: string | null; name: string | null;
email: string; email: string;
plan: string | null; plan: string | null;
} }
export default function AppLayout({ export default function AppLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
// Fetch user data on mount // Fetch user data on mount
useEffect(() => { useEffect(() => {
const fetchUser = async () => { const fetchUser = async () => {
try { try {
const response = await fetch('/api/user'); const response = await fetch('/api/user');
if (response.ok) { if (response.ok) {
const userData = await response.json(); const userData = await response.json();
setUser(userData); setUser(userData);
} }
} catch (error) { } catch (error) {
console.error('Error fetching user:', error); console.error('Error fetching user:', error);
} }
}; };
fetchUser(); fetchUser();
}, []); }, []);
const handleSignOut = async () => { const handleSignOut = async () => {
// Track logout event before clearing data // Track logout event before clearing data
try { try {
const { trackEvent, resetUser } = await import('@/components/PostHogProvider'); const { trackEvent, resetUser } = await import('@/components/PostHogProvider');
trackEvent('user_logout'); trackEvent('user_logout');
resetUser(); // Reset PostHog user session resetUser(); // Reset PostHog user session
} catch (error) { } catch (error) {
console.error('PostHog tracking error:', error); console.error('PostHog tracking error:', error);
} }
// Clear all cookies // Clear all cookies
document.cookie.split(";").forEach(c => { document.cookie.split(";").forEach(c => {
document.cookie = c.replace(/^ +/, "").replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/"); document.cookie = c.replace(/^ +/, "").replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/");
}); });
// Clear localStorage // Clear localStorage
localStorage.clear(); localStorage.clear();
// Redirect to home // Redirect to home
router.push('/'); router.push('/');
}; };
// Get user initials for avatar (e.g., "Timo Schmidt" -> "TS") // Get user initials for avatar (e.g., "Timo Schmidt" -> "TS")
const getUserInitials = () => { const getUserInitials = () => {
if (!user) return 'U'; if (!user) return 'U';
if (user.name) { if (user.name) {
const names = user.name.trim().split(' '); const names = user.name.trim().split(' ');
if (names.length >= 2) { if (names.length >= 2) {
return (names[0][0] + names[names.length - 1][0]).toUpperCase(); return (names[0][0] + names[names.length - 1][0]).toUpperCase();
} }
return user.name.substring(0, 2).toUpperCase(); return user.name.substring(0, 2).toUpperCase();
} }
// Fallback to email // Fallback to email
return user.email.substring(0, 1).toUpperCase(); return user.email.substring(0, 1).toUpperCase();
}; };
// Get display name (first name or full name) // Get display name (first name or full name)
const getDisplayName = () => { const getDisplayName = () => {
if (!user) return 'User'; if (!user) return 'User';
if (user.name) { if (user.name) {
return user.name; return user.name;
} }
// Fallback to email without domain // Fallback to email without domain
return user.email.split('@')[0]; return user.email.split('@')[0];
}; };
const navigation = [ const navigation = [
{ {
name: t('nav.dashboard'), name: t('nav.dashboard'),
href: '/dashboard', href: '/dashboard',
icon: ( icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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> </svg>
), ),
}, },
{ {
name: t('nav.create_qr'), name: t('nav.create_qr'),
href: '/create', href: '/create',
icon: ( icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg> </svg>
), ),
}, },
{ {
name: t('nav.bulk_creation'), name: t('nav.bulk_creation'),
href: '/bulk-creation', href: '/bulk-creation',
icon: ( icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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> </svg>
), ),
}, },
{ {
name: t('nav.analytics'), name: t('nav.analytics'),
href: '/analytics', href: '/analytics',
icon: ( icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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> </svg>
), ),
}, },
{ {
name: t('nav.pricing'), name: t('nav.pricing'),
href: '/pricing', href: '/pricing',
icon: ( icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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> </svg>
), ),
}, },
{ {
name: t('nav.settings'), name: t('nav.settings'),
href: '/settings', href: '/settings',
icon: ( icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg> </svg>
), ),
}, },
]; ];
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
{/* Mobile sidebar backdrop */} {/* Mobile sidebar backdrop */}
{sidebarOpen && ( {sidebarOpen && (
<div <div
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden" className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)} onClick={() => setSidebarOpen(false)}
/> />
)} )}
{/* Sidebar */} {/* Sidebar */}
<aside <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' 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"> <div className="flex items-center justify-between p-4 border-b border-gray-200">
<Link href="/" className="flex items-center space-x-2"> <Link href="/" className="flex items-center space-x-2">
<img src="/logo.svg" alt="QR Master" className="w-8 h-8" /> <img src="/logo.svg" alt="QR Master" className="w-8 h-8" />
<span className="text-xl font-bold text-gray-900">QR Master</span> <span className="text-xl font-bold text-gray-900">QR Master</span>
</Link> </Link>
<button <button
className="lg:hidden" className="lg:hidden"
onClick={() => setSidebarOpen(false)} onClick={() => setSidebarOpen(false)}
> >
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</div> </div>
<nav className="p-4 space-y-1"> <nav className="p-4 space-y-1">
{navigation.map((item) => { {navigation.map((item) => {
const isActive = pathname === item.href; const isActive = pathname === item.href;
return ( return (
<Link <Link
key={item.name} key={item.name}
href={item.href} href={item.href}
className={`flex items-center space-x-3 px-3 py-2 rounded-lg transition-colors ${isActive className={`flex items-center space-x-3 px-3 py-2 rounded-lg transition-colors ${isActive
? 'bg-primary-50 text-primary-600' ? 'bg-primary-50 text-primary-600'
: 'text-gray-700 hover:bg-gray-100' : 'text-gray-700 hover:bg-gray-100'
}`} }`}
> >
{item.icon} {item.icon}
<span className="font-medium">{item.name}</span> <span className="font-medium">{item.name}</span>
</Link> </Link>
); );
})} })}
</nav> </nav>
</aside> </aside>
{/* Main content */} {/* Main content */}
<div className="lg:ml-64"> <div className="lg:ml-64">
{/* Top bar */} {/* Top bar */}
<header className="bg-white border-b border-gray-200"> <header className="bg-white border-b border-gray-200">
<div className="flex items-center justify-between px-4 py-3"> <div className="flex items-center justify-between px-4 py-3">
<button <button
className="lg:hidden" className="lg:hidden"
onClick={() => setSidebarOpen(true)} onClick={() => setSidebarOpen(true)}
> >
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg> </svg>
</button> </button>
<div className="flex items-center space-x-4 ml-auto"> <div className="flex items-center space-x-4 ml-auto">
{/* User Menu */} {/* User Menu */}
<Dropdown <Dropdown
align="right" align="right"
trigger={ trigger={
<button className="flex items-center space-x-2 text-gray-700 hover:text-gray-900"> <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"> <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"> <span className="text-sm font-medium text-primary-600">
{getUserInitials()} {getUserInitials()}
</span> </span>
</div> </div>
<span className="hidden md:block font-medium"> <span className="hidden md:block font-medium">
{getDisplayName()} {getDisplayName()}
</span> </span>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg> </svg>
</button> </button>
} }
> >
<DropdownItem onClick={handleSignOut}> <DropdownItem onClick={handleSignOut}>
Sign Out Sign Out
</DropdownItem> </DropdownItem>
</Dropdown> </Dropdown>
</div> </div>
</div> </div>
</header> </header>
{/* Page content */} {/* Page content */}
<main className="p-6"> <main className="p-6">
{children} {children}
</main> </main>
{/* Footer */} {/* Footer */}
<Footer variant="dashboard" /> <Footer variant="dashboard" />
</div> </div>
</div> </div>
); );
} }

View File

@@ -141,13 +141,13 @@ export default function PricingPage() {
'50 dynamic QR codes', '50 dynamic QR codes',
'Unlimited static QR codes', 'Unlimited static QR codes',
'Advanced analytics (scans, devices, locations)', 'Advanced analytics (scans, devices, locations)',
'Custom branding (colors)', 'Custom branding (colors & logos)',
], ],
buttonText: isCurrentPlanWithInterval('PRO', selectedInterval) buttonText: isCurrentPlanWithInterval('PRO', selectedInterval)
? 'Current Plan' ? 'Current Plan'
: hasPlanDifferentInterval('PRO') : hasPlanDifferentInterval('PRO')
? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}` ? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}`
: 'Upgrade to Pro', : 'Upgrade to Pro',
buttonVariant: 'primary' as const, buttonVariant: 'primary' as const,
disabled: isCurrentPlanWithInterval('PRO', selectedInterval), disabled: isCurrentPlanWithInterval('PRO', selectedInterval),
popular: true, popular: true,
@@ -170,8 +170,8 @@ export default function PricingPage() {
buttonText: isCurrentPlanWithInterval('BUSINESS', selectedInterval) buttonText: isCurrentPlanWithInterval('BUSINESS', selectedInterval)
? 'Current Plan' ? 'Current Plan'
: hasPlanDifferentInterval('BUSINESS') : hasPlanDifferentInterval('BUSINESS')
? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}` ? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}`
: 'Upgrade to Business', : 'Upgrade to Business',
buttonVariant: 'primary' as const, buttonVariant: 'primary' as const,
disabled: isCurrentPlanWithInterval('BUSINESS', selectedInterval), disabled: isCurrentPlanWithInterval('BUSINESS', selectedInterval),
popular: false, popular: false,

View File

@@ -4,12 +4,11 @@ import React, { useState, useEffect } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { Input } from '@/components/ui/Input';
import { useCsrf } from '@/hooks/useCsrf'; import { useCsrf } from '@/hooks/useCsrf';
import { showToast } from '@/components/ui/Toast'; import { showToast } from '@/components/ui/Toast';
import ChangePasswordModal from '@/components/settings/ChangePasswordModal'; import ChangePasswordModal from '@/components/settings/ChangePasswordModal';
type TabType = 'profile' | 'subscription' | 'whitelabel'; type TabType = 'profile' | 'subscription';
export default function SettingsPage() { export default function SettingsPage() {
const { fetchWithCsrf } = useCsrf(); const { fetchWithCsrf } = useCsrf();
@@ -29,11 +28,6 @@ export default function SettingsPage() {
staticUsed: 0, staticUsed: 0,
}); });
// White Label Subdomain states
const [subdomain, setSubdomain] = useState('');
const [savedSubdomain, setSavedSubdomain] = useState<string | null>(null);
const [subdomainLoading, setSubdomainLoading] = useState(false);
// Load user data // Load user data
useEffect(() => { useEffect(() => {
const fetchUserData = async () => { const fetchUserData = async () => {
@@ -59,14 +53,6 @@ export default function SettingsPage() {
const data = await statsResponse.json(); const data = await statsResponse.json();
setUsageStats(data); setUsageStats(data);
} }
// Fetch subdomain
const subdomainResponse = await fetch('/api/user/subdomain');
if (subdomainResponse.ok) {
const data = await subdomainResponse.json();
setSavedSubdomain(data.subdomain);
setSubdomain(data.subdomain || '');
}
} catch (e) { } catch (e) {
console.error('Failed to load user data:', e); console.error('Failed to load user data:', e);
} }
@@ -199,31 +185,24 @@ export default function SettingsPage() {
<nav className="-mb-px flex space-x-8"> <nav className="-mb-px flex space-x-8">
<button <button
onClick={() => setActiveTab('profile')} onClick={() => setActiveTab('profile')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${activeTab === 'profile' className={`py-4 px-1 border-b-2 font-medium text-sm ${
? 'border-primary-500 text-primary-600' activeTab === 'profile'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' ? 'border-primary-500 text-primary-600'
}`} : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
> >
Profile Profile
</button> </button>
<button <button
onClick={() => setActiveTab('subscription')} onClick={() => setActiveTab('subscription')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${activeTab === 'subscription' className={`py-4 px-1 border-b-2 font-medium text-sm ${
? 'border-primary-500 text-primary-600' activeTab === 'subscription'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' ? 'border-primary-500 text-primary-600'
}`} : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
> >
Subscription Subscription
</button> </button>
<button
onClick={() => setActiveTab('whitelabel')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${activeTab === 'whitelabel'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
White Label
</button>
</nav> </nav>
</div> </div>
@@ -394,143 +373,6 @@ export default function SettingsPage() {
</div> </div>
)} )}
{activeTab === 'whitelabel' && (
<div className="space-y-6">
{/* White Label Subdomain */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>White Label Subdomain</CardTitle>
<Badge variant="success">FREE</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-gray-600 text-sm">
Create your own branded QR code URL. Your QR codes will be accessible via your custom subdomain.
</p>
<div className="flex items-center gap-2">
<Input
value={subdomain}
onChange={(e) => setSubdomain(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
placeholder="your-brand"
className="flex-1 max-w-xs"
/>
<span className="text-gray-600 font-medium">.qrmaster.net</span>
</div>
<div className="text-sm text-gray-500">
<ul className="list-disc list-inside space-y-1">
<li>3-30 characters</li>
<li>Only lowercase letters, numbers, and hyphens</li>
<li>Cannot start or end with a hyphen</li>
</ul>
</div>
{savedSubdomain && (
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
<p className="text-green-800 font-medium">
Your white label URL is active:
</p>
<a
href={`https://${savedSubdomain}.qrmaster.net`}
target="_blank"
rel="noopener noreferrer"
className="text-green-700 underline"
>
https://{savedSubdomain}.qrmaster.net
</a>
</div>
)}
<div className="flex gap-3">
<Button
onClick={async () => {
if (!subdomain.trim()) {
showToast('Please enter a subdomain', 'error');
return;
}
setSubdomainLoading(true);
try {
const response = await fetchWithCsrf('/api/user/subdomain', {
method: 'POST',
body: JSON.stringify({ subdomain: subdomain.trim().toLowerCase() }),
});
const data = await response.json();
if (response.ok) {
setSavedSubdomain(subdomain.trim().toLowerCase());
showToast('Subdomain saved successfully!', 'success');
} else {
showToast(data.error || 'Error saving subdomain', 'error');
}
} catch (error) {
showToast('Error saving subdomain', 'error');
} finally {
setSubdomainLoading(false);
}
}}
loading={subdomainLoading}
disabled={!subdomain.trim() || subdomain === savedSubdomain}
>
{savedSubdomain ? 'Update Subdomain' : 'Save Subdomain'}
</Button>
{savedSubdomain && (
<Button
variant="outline"
onClick={async () => {
setSubdomainLoading(true);
try {
const response = await fetchWithCsrf('/api/user/subdomain', {
method: 'DELETE',
});
if (response.ok) {
setSavedSubdomain(null);
setSubdomain('');
showToast('Subdomain removed', 'success');
}
} catch (error) {
showToast('Error removing subdomain', 'error');
} finally {
setSubdomainLoading(false);
}
}}
disabled={subdomainLoading}
>
Remove
</Button>
)}
</div>
</CardContent>
</Card>
{/* How it works */}
{savedSubdomain && (
<Card>
<CardHeader>
<CardTitle>How it works</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4 text-sm">
<div className="grid grid-cols-2 gap-4">
<div className="p-3 bg-gray-100 rounded-lg">
<p className="text-gray-500 mb-1">Before (default)</p>
<code className="text-gray-800">qrmaster.net/r/your-qr</code>
</div>
<div className="p-3 bg-primary-50 rounded-lg border border-primary-200">
<p className="text-primary-600 mb-1">After (your brand)</p>
<code className="text-primary-800">{savedSubdomain}.qrmaster.net/r/your-qr</code>
</div>
</div>
<p className="text-gray-600">
All your QR codes will work with both URLs. Share the branded version with your clients!
</p>
</div>
</CardContent>
</Card>
)}
</div>
)}
{/* Change Password Modal */} {/* Change Password Modal */}
<ChangePasswordModal <ChangePasswordModal
isOpen={showPasswordModal} isOpen={showPasswordModal}

View File

@@ -1,164 +1,126 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import en from '@/i18n/en.json'; import { Footer } from '@/components/ui/Footer';
import en from '@/i18n/en.json';
export default function MarketingLayout({
children, export default function MarketingLayout({
}: { children,
children: React.ReactNode; }: {
}) { children: React.ReactNode;
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); }) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
// Always use English for marketing pages const [showStickyCTA, setShowStickyCTA] = useState(false);
const t = en;
React.useEffect(() => {
const navigation = [ const handleScroll = () => {
{ name: t.nav.features, href: '/#features' }, setShowStickyCTA(window.scrollY > 400);
{ name: t.nav.pricing, href: '/#pricing' }, };
{ name: t.nav.faq, href: '/#faq' },
{ name: t.nav.blog, href: '/blog' }, window.addEventListener('scroll', handleScroll);
]; return () => window.removeEventListener('scroll', handleScroll);
}, []);
return (
<div className="min-h-screen bg-white"> // Always use English for marketing pages
{/* Header */} const t = en;
<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"> const navigation = [
<div className="flex items-center justify-between"> { name: t.nav.features, href: '/#features' },
{/* Logo */} { name: t.nav.pricing, href: '/#pricing' },
<Link href="/" className="flex items-center space-x-2"> { name: t.nav.faq, href: '/#faq' },
<img src="/favicon.svg" alt="QR Master" className="w-8 h-8" /> { name: t.nav.blog, href: '/blog' },
<span className="text-xl font-bold text-gray-900">QR Master</span> ];
</Link>
return (
{/* Desktop Navigation */} <div className="min-h-screen bg-white">
<div className="hidden md:flex items-center space-x-8"> {/* Header */}
{navigation.map((item) => ( <header className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${showStickyCTA ? 'bg-white/60 backdrop-blur-xl shadow-sm border-b border-gray-200/40 supports-[backdrop-filter]:bg-white/60' : 'bg-transparent border-b border-transparent'}`}>
<Link <nav className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl py-4">
key={item.name} <div className="flex items-center justify-between">
href={item.href} {/* Logo */}
className="text-gray-600 hover:text-gray-900 font-medium transition-colors" <Link href="/" className="flex items-center space-x-2">
> <img src="/favicon.svg" alt="QR Master" className="w-8 h-8" />
{item.name} <span className="text-xl font-bold text-gray-900">QR Master</span>
</Link> </Link>
))}
</div> {/* Desktop Navigation */}
<div className="hidden md:flex items-center space-x-8">
{/* Right Actions */} {navigation.map((item) => (
<div className="hidden md:flex items-center space-x-4"> <Link
<Link href="/login"> key={item.name}
<Button variant="outline">{t.nav.login}</Button> href={item.href}
</Link> className="text-gray-600 hover:text-gray-900 font-medium transition-colors"
>
<Link href="/signup"> {item.name}
<Button>Get Started Free</Button> </Link>
</Link> ))}
</div> </div>
{/* Mobile Menu Button */} {/* Right Actions */}
<button <div className="hidden md:flex items-center space-x-4">
className="md:hidden text-gray-900" <Link href="/login">
onClick={() => setMobileMenuOpen(!mobileMenuOpen)} <Button variant="outline">{t.nav.login}</Button>
aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'} </Link>
aria-expanded={mobileMenuOpen}
> <Link href="/signup">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"> {showStickyCTA ? (
{mobileMenuOpen ? ( <Button className="animate-in fade-in zoom-in duration-300">Create QR Code</Button>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> ) : (
) : ( <Button>Get Started Free</Button>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /> )}
)} </Link>
</svg> </div>
</button>
</div> {/* Mobile Menu Button */}
<button
{/* Mobile Menu */} className="md:hidden text-gray-900"
{mobileMenuOpen && ( onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
<div className="md:hidden mt-4 pb-4 border-t border-gray-200 pt-4"> aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'}
<div className="flex flex-col space-y-4"> aria-expanded={mobileMenuOpen}
{navigation.map((item) => ( >
<Link <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
key={item.name} {mobileMenuOpen ? (
href={item.href} <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
className="text-gray-600 hover:text-gray-900 font-medium" ) : (
onClick={() => setMobileMenuOpen(false)} <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
> )}
{item.name} </svg>
</Link> </button>
))} </div>
<Link href="/login" onClick={() => setMobileMenuOpen(false)}>
<Button variant="outline" className="w-full">{t.nav.login}</Button> {/* Mobile Menu */}
</Link> {mobileMenuOpen && (
<Link href="/signup" onClick={() => setMobileMenuOpen(false)}> <div className="md:hidden mt-4 pb-4 border-t border-gray-200 pt-4">
<Button className="w-full">Get Started Free</Button> <div className="flex flex-col space-y-4">
</Link> {navigation.map((item) => (
</div> <Link
</div> key={item.name}
)} href={item.href}
</nav> className="text-gray-600 hover:text-gray-900 font-medium"
</header> onClick={() => setMobileMenuOpen(false)}
>
{/* Main Content */} {item.name}
<main>{children}</main> </Link>
))}
{/* Footer */} <Link href="/login" onClick={() => setMobileMenuOpen(false)}>
<footer className="bg-gray-900 text-white py-12 mt-20"> <Button variant="outline" className="w-full">{t.nav.login}</Button>
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl"> </Link>
<div className="grid md:grid-cols-4 gap-8"> <Link href="/signup" onClick={() => setMobileMenuOpen(false)}>
<div> <Button className="w-full">Get Started Free</Button>
<Link href="/" className="flex items-center space-x-2 mb-4 hover:opacity-80 transition-opacity"> </Link>
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" /> </div>
<span className="text-xl font-bold">QR Master</span> </div>
</Link> )}
<p className="text-gray-400"> </nav>
Create custom QR codes in seconds with advanced tracking and analytics. </header>
</p>
</div> {/* Main Content */}
<main className="pt-20">{children}</main>
<div>
<h3 className="font-semibold mb-4">Product</h3> {/* Footer */}
<ul className="space-y-2 text-gray-400"> <Footer />
<li><Link href="/#features" className="hover:text-white">Features</Link></li> </div>
<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

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

View File

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

View File

@@ -1,144 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
// Reserved subdomains that cannot be used
const RESERVED_SUBDOMAINS = [
'www', 'app', 'api', 'admin', 'mail', 'email',
'ftp', 'smtp', 'pop', 'imap', 'dns', 'ns1', 'ns2',
'blog', 'shop', 'store', 'help', 'support', 'dashboard',
'login', 'signup', 'auth', 'cdn', 'static', 'assets',
'dev', 'staging', 'test', 'demo', 'beta', 'alpha'
];
// Validate subdomain format
function isValidSubdomain(subdomain: string): { valid: boolean; error?: string } {
if (!subdomain) {
return { valid: false, error: 'Subdomain is required' };
}
// Must be lowercase
if (subdomain !== subdomain.toLowerCase()) {
return { valid: false, error: 'Subdomain must be lowercase' };
}
// Length check
if (subdomain.length < 3 || subdomain.length > 30) {
return { valid: false, error: 'Subdomain must be 3-30 characters' };
}
// Alphanumeric and hyphens only, no leading/trailing hyphens
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(subdomain)) {
return { valid: false, error: 'Only lowercase letters, numbers, and hyphens allowed' };
}
// No consecutive hyphens
if (/--/.test(subdomain)) {
return { valid: false, error: 'No consecutive hyphens allowed' };
}
// Check reserved
if (RESERVED_SUBDOMAINS.includes(subdomain)) {
return { valid: false, error: 'This subdomain is reserved' };
}
return { valid: true };
}
// GET /api/user/subdomain - Get current subdomain
export async function GET() {
try {
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await db.user.findUnique({
where: { id: userId },
select: { subdomain: true },
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
return NextResponse.json({ subdomain: user.subdomain });
} catch (error) {
console.error('Error fetching subdomain:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// POST /api/user/subdomain - Set subdomain
export async function POST(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const subdomain = body.subdomain?.trim().toLowerCase();
// Validate
const validation = isValidSubdomain(subdomain);
if (!validation.valid) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
// Check if already taken by another user
const existing = await db.user.findFirst({
where: {
subdomain,
NOT: { id: userId },
},
});
if (existing) {
return NextResponse.json({ error: 'This subdomain is already taken' }, { status: 409 });
}
// Update user
try {
const updatedUser = await db.user.update({
where: { id: userId },
data: { subdomain },
select: { subdomain: true } // Only select needed fields
});
return NextResponse.json({
success: true,
subdomain: updatedUser.subdomain,
url: `https://${updatedUser.subdomain}.qrmaster.net`
});
} catch (error: any) {
if (error.code === 'P2025') {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
throw error;
}
} catch (error) {
console.error('Error setting subdomain:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// DELETE /api/user/subdomain - Remove subdomain
export async function DELETE() {
try {
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
await db.user.update({
where: { id: userId },
data: { subdomain: null },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error removing subdomain:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -14,15 +14,8 @@ export async function GET(
where: { slug }, where: { slug },
select: { select: {
id: true, id: true,
title: true,
content: true, content: true,
contentType: true, contentType: true,
user: {
select: {
name: true,
subdomain: true,
}
}
}, },
}); });
@@ -88,94 +81,8 @@ export async function GET(
destination = `${destination}${separator}${preservedParams.toString()}`; destination = `${destination}${separator}${preservedParams.toString()}`;
} }
// Construct metadata // Return 307 redirect (temporary redirect that preserves method)
const siteName = qrCode.user?.subdomain return NextResponse.redirect(destination, { status: 307 });
? `${qrCode.user.subdomain.charAt(0).toUpperCase() + qrCode.user.subdomain.slice(1)}`
: 'QR Master';
const title = qrCode.title || siteName;
const description = `Redirecting to content...`;
// Determine if we should show a preview (bots) or redirect immediately
const userAgent = request.headers.get('user-agent') || '';
const isBot = /facebookexternalhit|twitterbot|whatsapp|discordbot|telegrambot|slackbot|linkedinbot/i.test(userAgent);
// HTML response with metadata and redirect
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title}</title>
<!-- Open Graph Metadata -->
<meta property="og:title" content="${title}" />
<meta property="og:site_name" content="${siteName}" />
<meta property="og:description" content="${description}" />
<meta property="og:type" content="website" />
<meta property="og:url" content="${destination}" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="${title}" />
<meta name="twitter:description" content="${description}" />
<!-- No-cache headers to ensure fresh Redirects -->
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<!-- Fallback Redirect -->
<meta http-equiv="refresh" content="0;url=${JSON.stringify(destination).slice(1, -1)}" />
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background-color: #f9fafb;
color: #4b5563;
}
.loader {
text-align: center;
}
.spinner {
border: 3px solid #f3f3f3;
border-radius: 50%;
border-top: 3px solid #3b82f6;
width: 24px;
height: 24px;
-webkit-animation: spin 1s linear infinite; /* Safari */
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="loader">
<div class="spinner"></div>
<p>Redirecting to ${siteName}...</p>
</div>
<script>
// Immediate redirect
window.location.replace("${destination}");
</script>
</body>
</html>`;
return new NextResponse(html, {
headers: {
'Content-Type': 'text/html',
},
});
} catch (error) { } catch (error) {
console.error('QR redirect error:', error); console.error('QR redirect error:', error);
return new NextResponse('Internal server error', { status: 500 }); return new NextResponse('Internal server error', { status: 500 });

View File

@@ -51,7 +51,7 @@ export default function CookieBanner() {
<p className="text-gray-600 text-sm leading-relaxed mb-3"> <p className="text-gray-600 text-sm leading-relaxed mb-3">
We use essential cookies for authentication and analytics cookies to improve your experience.{' '} We use essential cookies for authentication and analytics cookies to improve your experience.{' '}
<Link href="/privacy" className="text-primary-600 hover:text-primary-700 font-medium underline"> <Link href="/privacy" className="text-primary-600 hover:text-primary-700 font-medium underline">
Learn more Learn more about our privacy policy
</Link> </Link>
</p> </p>

View File

@@ -1,192 +1,226 @@
'use client'; 'use client';
import React, { memo } from 'react'; import React, { memo } from 'react';
import { import {
ComposableMap, ComposableMap,
Geographies, Geographies,
Geography, Geography,
ZoomableGroup, ZoomableGroup,
} from 'react-simple-maps'; } from 'react-simple-maps';
import { scaleLinear } from 'd3-scale'; import { scaleLinear } from 'd3-scale';
// TopoJSON world map // TopoJSON world map
const geoUrl = 'https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json'; const geoUrl = 'https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json';
// ISO Alpha-2 to country name mapping for common countries // ISO Alpha-2 to country name mapping for common countries
const countryNameToCode: Record<string, string> = { const countryNameToCode: Record<string, string> = {
'United States': 'US', 'United States': 'US',
'USA': 'US', 'USA': 'US',
'US': 'US', 'US': 'US',
'Germany': 'DE', 'Germany': 'DE',
'DE': 'DE', 'DE': 'DE',
'United Kingdom': 'GB', 'United Kingdom': 'GB',
'UK': 'GB', 'UK': 'GB',
'GB': 'GB', 'GB': 'GB',
'France': 'FR', 'France': 'FR',
'FR': 'FR', 'FR': 'FR',
'Canada': 'CA', 'Canada': 'CA',
'CA': 'CA', 'CA': 'CA',
'Australia': 'AU', 'Australia': 'AU',
'AU': 'AU', 'AU': 'AU',
'Japan': 'JP', 'Japan': 'JP',
'JP': 'JP', 'JP': 'JP',
'China': 'CN', 'China': 'CN',
'CN': 'CN', 'CN': 'CN',
'India': 'IN', 'India': 'IN',
'IN': 'IN', 'IN': 'IN',
'Brazil': 'BR', 'Brazil': 'BR',
'BR': 'BR', 'BR': 'BR',
'Spain': 'ES', 'Spain': 'ES',
'ES': 'ES', 'ES': 'ES',
'Italy': 'IT', 'Italy': 'IT',
'IT': 'IT', 'IT': 'IT',
'Netherlands': 'NL', 'Netherlands': 'NL',
'NL': 'NL', 'NL': 'NL',
'Switzerland': 'CH', 'Switzerland': 'CH',
'CH': 'CH', 'CH': 'CH',
'Austria': 'AT', 'Austria': 'AT',
'AT': 'AT', 'AT': 'AT',
'Poland': 'PL', 'Poland': 'PL',
'PL': 'PL', 'PL': 'PL',
'Sweden': 'SE', 'Sweden': 'SE',
'SE': 'SE', 'SE': 'SE',
'Norway': 'NO', 'Norway': 'NO',
'NO': 'NO', 'NO': 'NO',
'Denmark': 'DK', 'Denmark': 'DK',
'DK': 'DK', 'DK': 'DK',
'Finland': 'FI', 'Finland': 'FI',
'FI': 'FI', 'FI': 'FI',
'Belgium': 'BE', 'Belgium': 'BE',
'BE': 'BE', 'BE': 'BE',
'Portugal': 'PT', 'Portugal': 'PT',
'PT': 'PT', 'PT': 'PT',
'Ireland': 'IE', 'Ireland': 'IE',
'IE': 'IE', 'IE': 'IE',
'Mexico': 'MX', 'Mexico': 'MX',
'MX': 'MX', 'MX': 'MX',
'Argentina': 'AR', 'Argentina': 'AR',
'AR': 'AR', 'AR': 'AR',
'South Korea': 'KR', 'South Korea': 'KR',
'KR': 'KR', 'KR': 'KR',
'Singapore': 'SG', 'Singapore': 'SG',
'SG': 'SG', 'SG': 'SG',
'New Zealand': 'NZ', 'New Zealand': 'NZ',
'NZ': 'NZ', 'NZ': 'NZ',
'Russia': 'RU', 'Russia': 'RU',
'RU': 'RU', 'RU': 'RU',
'South Africa': 'ZA', 'South Africa': 'ZA',
'ZA': 'ZA', 'ZA': 'ZA',
'Unknown Location': 'UNKNOWN', 'Unknown Location': 'UNKNOWN',
'unknown': 'UNKNOWN', 'unknown': 'UNKNOWN',
}; };
// ISO Alpha-2 to ISO Alpha-3 mapping (for matching with TopoJSON) // ISO Alpha-2 to ISO Alpha-3 mapping (for matching with TopoJSON)
const alpha2ToAlpha3: Record<string, string> = { const alpha2ToNumeric: Record<string, string> = {
'US': 'USA', 'US': '840',
'DE': 'DEU', 'DE': '276',
'GB': 'GBR', 'GB': '826',
'FR': 'FRA', 'FR': '250',
'CA': 'CAN', 'CA': '124',
'AU': 'AUS', 'AU': '036',
'JP': 'JPN', 'JP': '392',
'CN': 'CHN', 'CN': '156',
'IN': 'IND', 'IN': '356',
'BR': 'BRA', 'BR': '076',
'ES': 'ESP', 'ES': '724',
'IT': 'ITA', 'IT': '380',
'NL': 'NLD', 'NL': '528',
'CH': 'CHE', 'CH': '756',
'AT': 'AUT', 'AT': '040',
'PL': 'POL', 'PL': '616',
'SE': 'SWE', 'SE': '752',
'NO': 'NOR', 'NO': '578',
'DK': 'DNK', 'DK': '208',
'FI': 'FIN', 'FI': '246',
'BE': 'BEL', 'BE': '056',
'PT': 'PRT', 'PT': '620',
'IE': 'IRL', 'IE': '372',
'MX': 'MEX', 'MX': '484',
'AR': 'ARG', 'AR': '032',
'KR': 'KOR', 'KR': '410',
'SG': 'SGP', 'SG': '702',
'NZ': 'NZL', 'NZ': '554',
'RU': 'RUS', 'RU': '643',
'ZA': 'ZAF', 'ZA': '710',
}; };
interface CountryStat { interface CountryStat {
country: string; country: string;
count: number; count: number;
percentage: number; percentage: number;
} }
interface GeoMapProps { interface GeoMapProps {
countryStats: CountryStat[]; countryStats: CountryStat[];
totalScans: number; totalScans: number;
} }
const GeoMap: React.FC<GeoMapProps> = ({ countryStats, totalScans }) => { const GeoMap: React.FC<GeoMapProps> = ({ countryStats, totalScans }) => {
// Build a map of ISO Alpha-3 codes to scan counts // Build a map of ISO Alpha-3 codes to scan counts
const countryData: Record<string, number> = {}; const countryData: Record<string, number> = {};
let maxCount = 0; let maxCount = 0;
countryStats.forEach((stat) => { countryStats.forEach((stat) => {
const alpha2 = countryNameToCode[stat.country] || stat.country; const alpha2 = countryNameToCode[stat.country] || stat.country;
const alpha3 = alpha2ToAlpha3[alpha2]; const numericCode = alpha2ToNumeric[alpha2];
if (alpha3) { if (numericCode) {
countryData[alpha3] = stat.count; countryData[numericCode] = stat.count;
if (stat.count > maxCount) maxCount = stat.count; if (stat.count > maxCount) maxCount = stat.count;
} }
}); });
// Color scale: light blue to dark blue based on scan count // Color scale: light blue to dark blue based on scan count
const colorScale = scaleLinear<string>() const colorScale = scaleLinear<string>()
.domain([0, maxCount || 1]) .domain([0, maxCount || 1])
.range(['#E0F2FE', '#1E40AF']); .range(['#E0F2FE', '#1E40AF']);
return ( const [tooltipContent, setTooltipContent] = React.useState<{ name: string; count: number } | null>(null);
<div className="w-full h-full"> const [tooltipPos, setTooltipPos] = React.useState({ x: 0, y: 0 });
<ComposableMap
projection="geoMercator" return (
projectionConfig={{ <div
scale: 120, className="w-full h-full relative group"
center: [0, 30], onMouseMove={(evt) => {
}} setTooltipPos({ x: evt.clientX, y: evt.clientY });
style={{ width: '100%', height: '100%' }} }}
> >
<ZoomableGroup center={[0, 30]} zoom={1}> <ComposableMap
<Geographies geography={geoUrl}> projection="geoMercator"
{({ geographies }) => projectionConfig={{
geographies.map((geo) => { scale: 120,
const isoCode = geo.properties.ISO_A3 || geo.id; center: [0, 30],
const scanCount = countryData[isoCode] || 0; }}
const fillColor = scanCount > 0 ? colorScale(scanCount) : '#F1F5F9'; style={{ width: '100%', height: '100%' }}
>
return ( <ZoomableGroup center={[0, 30]} zoom={1}>
<Geography <Geographies geography={geoUrl}>
key={geo.rsmKey} {({ geographies }) =>
geography={geo} geographies.map((geo) => {
fill={fillColor} // geo.id is the numeric ISO code as a string (e.g., "840" for US)
stroke="#CBD5E1" const geoId = geo.id;
strokeWidth={0.5} const scanCount = countryData[geoId] || 0;
style={{ const fillColor = scanCount > 0 ? colorScale(scanCount) : '#F1F5F9';
default: { outline: 'none' },
hover: { return (
fill: scanCount > 0 ? '#3B82F6' : '#E2E8F0', <Geography
outline: 'none', key={geo.rsmKey}
cursor: 'pointer', geography={geo}
}, fill={fillColor}
pressed: { outline: 'none' }, stroke="#CBD5E1"
}} strokeWidth={0.5}
/> style={{
); default: { outline: 'none' },
}) hover: {
} fill: scanCount > 0 ? '#3B82F6' : '#E2E8F0',
</Geographies> outline: 'none',
</ZoomableGroup> cursor: 'pointer',
</ComposableMap> },
</div> pressed: { outline: 'none' },
); }}
}; onMouseEnter={() => {
const { name } = geo.properties;
export default memo(GeoMap); setTooltipContent({ name, count: scanCount });
}}
onMouseLeave={() => {
setTooltipContent(null);
}}
/>
);
})
}
</Geographies>
</ZoomableGroup>
</ComposableMap>
{tooltipContent && (
<div
className="fixed z-50 px-3 py-2 text-sm font-medium text-white bg-gray-900 rounded-lg shadow-xl pointer-events-none transform -translate-x-1/2 -translate-y-full"
style={{
left: tooltipPos.x,
top: tooltipPos.y - 10,
}}
>
<div className="flex items-center gap-2">
<span>{tooltipContent.name}</span>
<span className="text-gray-400">|</span>
<span className="font-bold text-blue-400">{tooltipContent.count} scans</span>
</div>
{/* Arrow */}
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-full w-0 h-0 border-l-4 border-r-4 border-t-4 border-l-transparent border-r-transparent border-t-gray-900"></div>
</div>
)}
</div>
);
};
export default memo(GeoMap);

View File

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

View File

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

View File

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

View File

@@ -1,251 +1,240 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { QRCodeSVG } from 'qrcode.react'; import { QRCodeSVG } from 'qrcode.react';
import { Card, CardContent } from '@/components/ui/Card'; import { Card, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { Dropdown, DropdownItem } from '@/components/ui/Dropdown'; import { Dropdown, DropdownItem } from '@/components/ui/Dropdown';
import { formatDate } from '@/lib/utils'; import { formatDate } from '@/lib/utils';
interface QRCodeCardProps { interface QRCodeCardProps {
qr: { qr: {
id: string; id: string;
title: string; title: string;
type: 'STATIC' | 'DYNAMIC'; type: 'STATIC' | 'DYNAMIC';
contentType: string; contentType: string;
content?: any; content?: any;
slug: string; slug: string;
createdAt: string; createdAt: string;
scans?: number; scans?: number;
style?: any; style?: any;
}; };
onEdit: (id: string) => void; onEdit: (id: string) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
userSubdomain?: string | null; }
}
export const QRCodeCard: React.FC<QRCodeCardProps> = ({
export const QRCodeCard: React.FC<QRCodeCardProps> = ({ qr,
qr, onEdit,
onEdit, onDelete,
onDelete, }) => {
userSubdomain, // For dynamic QR codes, use the redirect URL for tracking
}) => { // For static QR codes, use the direct URL from content
// For dynamic QR codes, use the redirect URL for tracking const baseUrl = process.env.NEXT_PUBLIC_APP_URL || (typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3050');
// For static QR codes, use the direct URL from content
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || (typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3050'); // Get the QR URL based on type
let qrUrl = '';
// White label: use subdomain URL if available
const mainDomain = process.env.NEXT_PUBLIC_MAIN_DOMAIN || 'qrmaster.net'; // SIMPLE FIX: For STATIC QR codes, ALWAYS use the direct content
const brandedBaseUrl = userSubdomain if (qr.type === 'STATIC') {
? `https://${userSubdomain}.${mainDomain}` // Extract the actual URL/content based on contentType
: baseUrl; if (qr.contentType === 'URL' && qr.content?.url) {
qrUrl = qr.content.url;
// Get the QR URL based on type } else if (qr.contentType === 'PHONE' && qr.content?.phone) {
let qrUrl = ''; qrUrl = `tel:${qr.content.phone}`;
} else if (qr.contentType === 'VCARD') {
// SIMPLE FIX: For STATIC QR codes, ALWAYS use the direct content // VCARD content needs to be formatted properly
if (qr.type === 'STATIC') { qrUrl = `BEGIN:VCARD
// Extract the actual URL/content based on contentType VERSION:3.0
if (qr.contentType === 'URL' && qr.content?.url) { FN:${qr.content.firstName || ''} ${qr.content.lastName || ''}
qrUrl = qr.content.url; N:${qr.content.lastName || ''};${qr.content.firstName || ''};;;
} else if (qr.contentType === 'PHONE' && qr.content?.phone) { ${qr.content.organization ? `ORG:${qr.content.organization}` : ''}
qrUrl = `tel:${qr.content.phone}`; ${qr.content.title ? `TITLE:${qr.content.title}` : ''}
} else if (qr.contentType === 'VCARD') { ${qr.content.email ? `EMAIL:${qr.content.email}` : ''}
// VCARD content needs to be formatted properly ${qr.content.phone ? `TEL:${qr.content.phone}` : ''}
qrUrl = `BEGIN:VCARD END:VCARD`;
VERSION:3.0 } else if (qr.contentType === 'GEO' && qr.content) {
FN:${qr.content.firstName || ''} ${qr.content.lastName || ''} const lat = qr.content.latitude || 0;
N:${qr.content.lastName || ''};${qr.content.firstName || ''};;; const lon = qr.content.longitude || 0;
${qr.content.organization ? `ORG:${qr.content.organization}` : ''} const label = qr.content.label ? `?q=${encodeURIComponent(qr.content.label)}` : '';
${qr.content.title ? `TITLE:${qr.content.title}` : ''} qrUrl = `geo:${lat},${lon}${label}`;
${qr.content.email ? `EMAIL:${qr.content.email}` : ''} } else if (qr.contentType === 'TEXT' && qr.content?.text) {
${qr.content.phone ? `TEL:${qr.content.phone}` : ''} qrUrl = qr.content.text;
END:VCARD`; } else if (qr.content?.qrContent) {
} else if (qr.contentType === 'GEO' && qr.content) { // Fallback to qrContent if it exists
const lat = qr.content.latitude || 0; qrUrl = qr.content.qrContent;
const lon = qr.content.longitude || 0; } else {
const label = qr.content.label ? `?q=${encodeURIComponent(qr.content.label)}` : ''; // Last resort fallback
qrUrl = `geo:${lat},${lon}${label}`; qrUrl = `${baseUrl}/r/${qr.slug}`;
} else if (qr.contentType === 'TEXT' && qr.content?.text) { }
qrUrl = qr.content.text; console.log(`STATIC QR [${qr.title}]: ${qrUrl}`);
} else if (qr.content?.qrContent) { } else {
// Fallback to qrContent if it exists // DYNAMIC QR codes always use redirect for tracking
qrUrl = qr.content.qrContent; qrUrl = `${baseUrl}/r/${qr.slug}`;
} else { console.log(`DYNAMIC QR [${qr.title}]: ${qrUrl}`);
// Last resort fallback }
qrUrl = `${brandedBaseUrl}/r/${qr.slug}`;
} const downloadQR = (format: 'png' | 'svg') => {
console.log(`STATIC QR [${qr.title}]: ${qrUrl}`); const svg = document.querySelector(`#qr-${qr.id} svg`);
} else { if (!svg) return;
// DYNAMIC QR codes use branded URL for white label
qrUrl = `${brandedBaseUrl}/r/${qr.slug}`; if (format === 'svg') {
} let svgData = new XMLSerializer().serializeToString(svg);
// Display URL (same as qrUrl for consistency) // If rounded corners, wrap in a clipped SVG
const displayUrl = qrUrl; if (qr.style?.cornerStyle === 'rounded') {
const width = svg.getAttribute('width') || '96';
const downloadQR = (format: 'png' | 'svg') => { const height = svg.getAttribute('height') || '96';
const svg = document.querySelector(`#qr-${qr.id} svg`); const borderRadius = 10; // Smaller radius for dashboard
if (!svg) return;
svgData = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
if (format === 'svg') { <defs>
let svgData = new XMLSerializer().serializeToString(svg); <clipPath id="rounded-corners-${qr.id}">
<rect x="0" y="0" width="${width}" height="${height}" rx="${borderRadius}" ry="${borderRadius}"/>
// If rounded corners, wrap in a clipped SVG </clipPath>
if (qr.style?.cornerStyle === 'rounded') { </defs>
const width = svg.getAttribute('width') || '96'; <g clip-path="url(#rounded-corners-${qr.id})">
const height = svg.getAttribute('height') || '96'; ${svgData}
const borderRadius = 10; // Smaller radius for dashboard </g>
</svg>`;
svgData = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"> }
<defs>
<clipPath id="rounded-corners-${qr.id}"> const blob = new Blob([svgData], { type: 'image/svg+xml' });
<rect x="0" y="0" width="${width}" height="${height}" rx="${borderRadius}" ry="${borderRadius}"/> const url = URL.createObjectURL(blob);
</clipPath> const a = document.createElement('a');
</defs> a.href = url;
<g clip-path="url(#rounded-corners-${qr.id})"> a.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.svg`;
${svgData} document.body.appendChild(a);
</g> a.click();
</svg>`; document.body.removeChild(a);
} URL.revokeObjectURL(url);
} else {
const blob = new Blob([svgData], { type: 'image/svg+xml' }); // Convert SVG to PNG
const url = URL.createObjectURL(blob); const canvas = document.createElement('canvas');
const a = document.createElement('a'); const ctx = canvas.getContext('2d');
a.href = url; if (!ctx) return;
a.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.svg`;
document.body.appendChild(a); const img = new Image();
a.click(); const svgData = new XMLSerializer().serializeToString(svg);
document.body.removeChild(a); const blob = new Blob([svgData], { type: 'image/svg+xml' });
URL.revokeObjectURL(url); const url = URL.createObjectURL(blob);
} else {
// Convert SVG to PNG img.onload = () => {
const canvas = document.createElement('canvas'); canvas.width = 300;
const ctx = canvas.getContext('2d'); canvas.height = 300;
if (!ctx) return;
// Apply rounded corners if needed
const img = new Image(); if (qr.style?.cornerStyle === 'rounded') {
const svgData = new XMLSerializer().serializeToString(svg); const borderRadius = 30; // Scale up for 300px canvas
const blob = new Blob([svgData], { type: 'image/svg+xml' }); ctx.beginPath();
const url = URL.createObjectURL(blob); ctx.moveTo(borderRadius, 0);
ctx.lineTo(300 - borderRadius, 0);
img.onload = () => { ctx.quadraticCurveTo(300, 0, 300, borderRadius);
canvas.width = 300; ctx.lineTo(300, 300 - borderRadius);
canvas.height = 300; ctx.quadraticCurveTo(300, 300, 300 - borderRadius, 300);
ctx.lineTo(borderRadius, 300);
// Apply rounded corners if needed ctx.quadraticCurveTo(0, 300, 0, 300 - borderRadius);
if (qr.style?.cornerStyle === 'rounded') { ctx.lineTo(0, borderRadius);
const borderRadius = 30; // Scale up for 300px canvas ctx.quadraticCurveTo(0, 0, borderRadius, 0);
ctx.beginPath(); ctx.closePath();
ctx.moveTo(borderRadius, 0); ctx.clip();
ctx.lineTo(300 - borderRadius, 0); }
ctx.quadraticCurveTo(300, 0, 300, borderRadius);
ctx.lineTo(300, 300 - borderRadius); ctx.drawImage(img, 0, 0, 300, 300);
ctx.quadraticCurveTo(300, 300, 300 - borderRadius, 300); canvas.toBlob((blob) => {
ctx.lineTo(borderRadius, 300); if (blob) {
ctx.quadraticCurveTo(0, 300, 0, 300 - borderRadius); const url = URL.createObjectURL(blob);
ctx.lineTo(0, borderRadius); const a = document.createElement('a');
ctx.quadraticCurveTo(0, 0, borderRadius, 0); a.href = url;
ctx.closePath(); a.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.png`;
ctx.clip(); document.body.appendChild(a);
} a.click();
document.body.removeChild(a);
ctx.drawImage(img, 0, 0, 300, 300); URL.revokeObjectURL(url);
canvas.toBlob((blob) => { }
if (blob) { });
const url = URL.createObjectURL(blob); URL.revokeObjectURL(url);
const a = document.createElement('a'); };
a.href = url; img.src = url;
a.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.png`; }
document.body.appendChild(a); };
a.click();
document.body.removeChild(a); return (
URL.revokeObjectURL(url); <Card hover>
} <CardContent className="p-4">
}); <div className="flex items-start justify-between mb-3">
URL.revokeObjectURL(url); <div className="flex-1">
}; <h3 className="font-semibold text-gray-900 mb-1">{qr.title}</h3>
img.src = url; <div className="flex items-center space-x-2">
} <Badge variant={qr.type === 'DYNAMIC' ? 'info' : 'default'}>
}; {qr.type}
</Badge>
return ( </div>
<Card hover> </div>
<CardContent className="p-4">
<div className="flex items-start justify-between mb-3"> <Dropdown
<div className="flex-1"> align="right"
<h3 className="font-semibold text-gray-900 mb-1">{qr.title}</h3> trigger={
<div className="flex items-center space-x-2"> <button className="p-1 hover:bg-gray-100 rounded">
<Badge variant={qr.type === 'DYNAMIC' ? 'info' : 'default'}> <svg className="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{qr.type} <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</Badge> </svg>
</div> </button>
</div> }
>
<Dropdown <DropdownItem onClick={() => downloadQR('png')}>Download PNG</DropdownItem>
align="right" <DropdownItem onClick={() => downloadQR('svg')}>Download SVG</DropdownItem>
trigger={ {qr.type === 'DYNAMIC' && (
<button className="p-1 hover:bg-gray-100 rounded"> <DropdownItem onClick={() => onEdit(qr.id)}>Edit</DropdownItem>
<svg className="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> )}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" /> <DropdownItem onClick={() => onDelete(qr.id)} className="text-red-600">
</svg> Delete
</button> </DropdownItem>
} </Dropdown>
> </div>
<DropdownItem onClick={() => downloadQR('png')}>Download PNG</DropdownItem>
<DropdownItem onClick={() => downloadQR('svg')}>Download SVG</DropdownItem> <div id={`qr-${qr.id}`} className="flex items-center justify-center bg-gray-50 rounded-lg p-4 mb-3">
{qr.type === 'DYNAMIC' && ( <div className={qr.style?.cornerStyle === 'rounded' ? 'rounded-lg overflow-hidden' : ''}>
<DropdownItem onClick={() => onEdit(qr.id)}>Edit</DropdownItem> <QRCodeSVG
)} value={qrUrl}
<DropdownItem onClick={() => onDelete(qr.id)} className="text-red-600"> size={96}
Delete fgColor={qr.style?.foregroundColor || '#000000'}
</DropdownItem> bgColor={qr.style?.backgroundColor || '#FFFFFF'}
</Dropdown> level="H"
</div> imageSettings={qr.style?.imageSettings ? {
src: qr.style.imageSettings.src,
<div id={`qr-${qr.id}`} className="flex items-center justify-center bg-gray-50 rounded-lg p-4 mb-3"> height: qr.style.imageSettings.height * (96 / 200), // Scale logo for smaller QR
<div className={qr.style?.cornerStyle === 'rounded' ? 'rounded-lg overflow-hidden' : ''}> width: qr.style.imageSettings.width * (96 / 200),
<QRCodeSVG excavate: qr.style.imageSettings.excavate,
key={qrUrl} } : undefined}
value={qrUrl} />
size={96} </div>
fgColor={qr.style?.foregroundColor || '#000000'} </div>
bgColor={qr.style?.backgroundColor || '#FFFFFF'}
level={qr.style?.imageSettings ? 'H' : 'M'} <div className="space-y-2 text-sm">
imageSettings={qr.style?.imageSettings} <div className="flex items-center justify-between">
/> <span className="text-gray-500">Type:</span>
</div> <span className="text-gray-900">{qr.contentType}</span>
</div> </div>
{qr.type === 'DYNAMIC' && (
<div className="space-y-2 text-sm"> <div className="flex items-center justify-between">
<div className="flex items-center justify-between"> <span className="text-gray-500">Scans:</span>
<span className="text-gray-500">Type:</span> <span className="text-gray-900">{qr.scans || 0}</span>
<span className="text-gray-900">{qr.contentType}</span> </div>
</div> )}
{qr.type === 'DYNAMIC' && ( <div className="flex items-center justify-between">
<div className="flex items-center justify-between"> <span className="text-gray-500">Created:</span>
<span className="text-gray-500">Scans:</span> <span className="text-gray-900">{formatDate(qr.createdAt)}</span>
<span className="text-gray-900">{qr.scans || 0}</span> </div>
</div> {qr.type === 'DYNAMIC' && (
)} <div className="pt-2 border-t">
{qr.type === 'DYNAMIC' && ( <p className="text-xs text-gray-500">
<div className="text-xs text-gray-400 break-all bg-gray-50 p-1 rounded border border-gray-100 mt-2"> 📊 Dynamic QR: Tracks scans via {baseUrl}/r/{qr.slug}
{qrUrl} </p>
</div> </div>
)} )}
<div className="flex items-center justify-between"> </div>
<span className="text-gray-500">Created:</span> </CardContent>
<span className="text-gray-900">{formatDate(qr.createdAt)}</span> </Card>
</div> );
{qr.type === 'DYNAMIC' && (
<div className="pt-2 border-t">
<p className="text-xs text-gray-500">
📊 Dynamic QR: Tracks scans via {displayUrl}
</p>
</div>
)}
</div>
</CardContent>
</Card>
);
}; };

View File

@@ -3,6 +3,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Sparkles, Brain, TrendingUp, MessageSquare, Palette, ArrowRight, Mail, CheckCircle2, Lock } from 'lucide-react'; import { Sparkles, Brain, TrendingUp, MessageSquare, Palette, ArrowRight, Mail, CheckCircle2, Lock } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { motion } from 'framer-motion';
const AIComingSoonBanner = () => { const AIComingSoonBanner = () => {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
@@ -83,7 +84,7 @@ const AIComingSoonBanner = () => {
]; ];
return ( return (
<section className="relative overflow-hidden py-20 px-4 sm:px-6 lg:px-8 bg-gradient-to-br from-blue-50 via-white to-purple-50"> <section className="relative overflow-hidden pt-12 pb-20 px-4 sm:px-6 lg:px-8 bg-gradient-to-br from-blue-50 via-white to-purple-50">
{/* Animated Background Orbs (matching Hero) */} {/* Animated Background Orbs (matching Hero) */}
<div className="absolute inset-0 overflow-hidden pointer-events-none"> <div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-0 left-1/4 w-96 h-96 bg-blue-400/20 rounded-full blur-3xl animate-blob" /> <div className="absolute top-0 left-1/4 w-96 h-96 bg-blue-400/20 rounded-full blur-3xl animate-blob" />
@@ -93,8 +94,15 @@ const AIComingSoonBanner = () => {
<div className="max-w-6xl mx-auto relative z-10"> <div className="max-w-6xl mx-auto relative z-10">
{/* Header */} {/* Header */}
<div className="text-center mb-12"> {/* Header */}
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-blue-100 mb-4"> <motion.div
initial={{ opacity: 0, scale: 0.95 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="text-center mb-12"
>
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-blue-100 mb-4 animate-pulse">
<Sparkles className="w-4 h-4 text-blue-600" /> <Sparkles className="w-4 h-4 text-blue-600" />
<span className="text-sm font-medium text-blue-700"> <span className="text-sm font-medium text-blue-700">
Coming Soon Coming Soon
@@ -111,14 +119,18 @@ const AIComingSoonBanner = () => {
<p className="text-gray-600 text-lg max-w-2xl mx-auto"> <p className="text-gray-600 text-lg max-w-2xl mx-auto">
Revolutionary AI features to transform how you create, manage, and optimize QR codes Revolutionary AI features to transform how you create, manage, and optimize QR codes
</p> </p>
</div> </motion.div>
{/* Features Grid */} {/* Features Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
{features.map((feature, index) => ( {features.map((feature, index) => (
<div <motion.div
key={index} key={index}
className="bg-white/80 backdrop-blur rounded-xl p-6 border border-gray-100 hover:shadow-lg transition-all" initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
className="bg-white/80 backdrop-blur rounded-xl p-6 border border-gray-100 hover:shadow-lg transition-all hover:scale-105"
> >
<div className="w-12 h-12 bg-gradient-to-br from-blue-100 to-purple-100 rounded-lg flex items-center justify-center mb-4"> <div className="w-12 h-12 bg-gradient-to-br from-blue-100 to-purple-100 rounded-lg flex items-center justify-center mb-4">
<feature.icon className="w-6 h-6 text-blue-600" /> <feature.icon className="w-6 h-6 text-blue-600" />
@@ -136,12 +148,18 @@ const AIComingSoonBanner = () => {
</li> </li>
))} ))}
</ul> </ul>
</div> </motion.div>
))} ))}
</div> </div>
{/* Email Capture */} {/* Email Capture */}
<div className="max-w-2xl mx-auto bg-gradient-to-br from-blue-50 to-purple-50 rounded-2xl p-8 border border-gray-100"> <motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.4 }}
className="max-w-2xl mx-auto bg-gradient-to-br from-blue-50 to-purple-50 rounded-2xl p-8 border border-gray-100"
>
{!submitted ? ( {!submitted ? (
<> <>
<form onSubmit={handleSubmit} className="flex flex-col sm:flex-row gap-3 mb-3"> <form onSubmit={handleSubmit} className="flex flex-col sm:flex-row gap-3 mb-3">
@@ -163,12 +181,12 @@ const AIComingSoonBanner = () => {
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-xl transition-all disabled:opacity-50 whitespace-nowrap flex items-center justify-center gap-2" className="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-xl transition-all disabled:opacity-50 whitespace-nowrap flex items-center justify-center gap-2 group"
> >
{loading ? 'Subscribing...' : ( {loading ? 'Subscribing...' : (
<> <>
Notify Me Notify Me
<ArrowRight className="w-4 h-4" /> <ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</> </>
)} )}
</button> </button>
@@ -188,8 +206,9 @@ const AIComingSoonBanner = () => {
</span> </span>
</div> </div>
)} )}
</div> </motion.div>
</div> </div>
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-b from-transparent to-white pointer-events-none" />
</section> </section>
); );
}; };

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
interface FAQProps { interface FAQProps {
@@ -21,38 +22,62 @@ export const FAQ: React.FC<FAQProps> = ({ t }) => {
return ( return (
<section id="faq" className="py-16 bg-gray-50"> <section id="faq" className="py-16 bg-gray-50">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl"> <div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="text-center mb-12"> <motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="text-center mb-12"
>
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4"> <h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
{t.faq.title} {t.faq.title}
</h2> </h2>
</div> </motion.div>
<div className="max-w-3xl mx-auto space-y-4"> <div className="max-w-3xl mx-auto space-y-4">
{questions.map((key, index) => ( {questions.map((key, index) => (
<Card key={key} className="cursor-pointer" onClick={() => setOpenIndex(openIndex === index ? null : index)}> <motion.div
<div className="p-6"> key={key}
<div className="flex items-center justify-between"> initial={{ opacity: 0, x: -20 }}
<h3 className="text-lg font-semibold text-gray-900"> whileInView={{ opacity: 1, x: 0 }}
{t.faq.questions[key].question} viewport={{ once: true }}
</h3> transition={{ duration: 0.5, delay: index * 0.1 }}
<svg >
className={`w-5 h-5 text-gray-500 transition-transform ${openIndex === index ? 'rotate-180' : ''}`} <Card className="cursor-pointer border-gray-200 hover:border-gray-300 transition-colors" onClick={() => setOpenIndex(openIndex === index ? null : index)}>
fill="none" <div className="p-6">
stroke="currentColor" <div className="flex items-center justify-between">
viewBox="0 0 24 24" <h3 className="text-lg font-semibold text-gray-900">
aria-hidden="true" {t.faq.questions[key].question}
> </h3>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <svg
</svg> className={`w-5 h-5 text-gray-500 transition-transform duration-300 ${openIndex === index ? 'rotate-180' : ''}`}
</div> fill="none"
stroke="currentColor"
{openIndex === index && ( viewBox="0 0 24 24"
<div className="mt-4 text-gray-600"> aria-hidden="true"
{t.faq.questions[key].answer} >
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div> </div>
)}
</div> <AnimatePresence>
</Card> {openIndex === index && (
<motion.div
initial={{ height: 0, opacity: 0, marginTop: 0 }}
animate={{ height: 'auto', opacity: 1, marginTop: 16 }}
exit={{ height: 0, opacity: 0, marginTop: 0 }}
transition={{ duration: 0.3 }}
className="overflow-hidden"
>
<div className="text-gray-600">
{t.faq.questions[key].answer}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</Card>
</motion.div>
))} ))}
</div> </div>

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { motion } from 'framer-motion';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
interface FeaturesProps { interface FeaturesProps {
@@ -41,27 +42,41 @@ export const Features: React.FC<FeaturesProps> = ({ t }) => {
return ( return (
<section className="py-16 bg-gray-50"> <section className="py-16 bg-gray-50">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl"> <div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="text-center mb-12"> <motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="text-center mb-12"
>
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4"> <h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
{t.features.title} {t.features.title}
</h2> </h2>
</div> </motion.div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto"> <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
{features.map((feature) => ( {features.map((feature, index) => (
<Card key={feature.key} hover> <motion.div
<CardHeader> key={feature.key}
<div className={`w-12 h-12 rounded-lg ${feature.color} flex items-center justify-center mb-4`}> initial={{ opacity: 0, y: 20 }}
{feature.icon} whileInView={{ opacity: 1, y: 0 }}
</div> viewport={{ once: true }}
<CardTitle>{t.features[feature.key].title}</CardTitle> transition={{ duration: 0.5, delay: index * 0.1 }}
</CardHeader> >
<CardContent> <Card hover className="h-full border-gray-100 hover:border-primary-100 hover:shadow-lg transition-all">
<p className="text-gray-600"> <CardHeader>
{t.features[feature.key].description} <div className={`w-12 h-12 rounded-lg ${feature.color} flex items-center justify-center mb-4`}>
</p> {feature.icon}
</CardContent> </div>
</Card> <CardTitle>{t.features[feature.key].title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600">
{t.features[feature.key].description}
</p>
</CardContent>
</Card>
</motion.div>
))} ))}
</div> </div>
</div> </div>

View File

@@ -5,6 +5,8 @@ import Link from 'next/link';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { motion } from 'framer-motion';
import { Globe, User, MapPin, Phone, CheckCircle2, ArrowRight } from 'lucide-react';
interface HeroProps { interface HeroProps {
t: any; // i18n translation function t: any; // i18n translation function
@@ -12,12 +14,27 @@ interface HeroProps {
export const Hero: React.FC<HeroProps> = ({ t }) => { export const Hero: React.FC<HeroProps> = ({ t }) => {
const templateCards = [ const templateCards = [
{ title: 'URL/Website', color: 'bg-blue-100', icon: '🌐' }, { title: 'URL/Website', color: 'bg-blue-500/10 text-blue-600', icon: Globe },
{ title: 'Contact Card', color: 'bg-purple-100', icon: '👤' }, { title: 'Contact Card', color: 'bg-purple-500/10 text-purple-600', icon: User },
{ title: 'Location', color: 'bg-green-100', icon: '📍' }, { title: 'Location', color: 'bg-green-500/10 text-green-600', icon: MapPin },
{ title: 'Phone Number', color: 'bg-pink-100', icon: '📞' }, { title: 'Phone Number', color: 'bg-pink-500/10 text-pink-600', icon: Phone },
]; ];
const containerjs = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.1
}
}
};
const itemjs = {
hidden: { opacity: 0, y: 20 },
show: { opacity: 1, y: 0 }
};
return ( return (
<section className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-white to-purple-50 pt-12 pb-20"> <section className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-white to-purple-50 pt-12 pb-20">
{/* Animated Background Orbs */} {/* Animated Background Orbs */}
@@ -43,64 +60,96 @@ export const Hero: React.FC<HeroProps> = ({ t }) => {
<span>{t.hero.badge}</span> <span>{t.hero.badge}</span>
</Badge> </Badge>
<div className="space-y-6"> <motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="space-y-6"
>
<h1 className="text-5xl lg:text-6xl font-bold text-gray-900 leading-tight"> <h1 className="text-5xl lg:text-6xl font-bold text-gray-900 leading-tight">
{t.hero.title} {t.hero.title}
</h1> </h1>
<p className="text-xl text-gray-600 leading-relaxed"> <p className="text-xl text-gray-600 leading-relaxed max-w-2xl">
{t.hero.subtitle} {t.hero.subtitle}
</p> </p>
<div className="space-y-3"> <div className="space-y-3 pt-2">
{t.hero.features.map((feature: string, index: number) => ( {t.hero.features.map((feature: string, index: number) => (
<div key={index} className="flex items-center space-x-3"> <motion.div
<div className="flex-shrink-0 w-5 h-5 bg-success-500 rounded-full flex items-center justify-center"> key={index}
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20"> initial={{ opacity: 0, x: -20 }}
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /> animate={{ opacity: 1, x: 0 }}
</svg> transition={{ delay: 0.2 + (index * 0.1) }}
className="flex items-center space-x-3"
>
<div className="flex-shrink-0 w-6 h-6 bg-emerald-100 rounded-full flex items-center justify-center">
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
</div> </div>
<span className="text-gray-700">{feature}</span> <span className="text-gray-700 font-medium">{feature}</span>
</div> </motion.div>
))} ))}
</div> </div>
</motion.div>
<div className="flex flex-col sm:flex-row gap-4"> <motion.div
<Link href="/signup"> initial={{ opacity: 0, y: 20 }}
<Button size="lg" className="text-lg px-8 py-4 w-full sm:w-auto"> animate={{ opacity: 1, y: 0 }}
{t.hero.cta_primary} transition={{ delay: 0.5 }}
</Button> className="flex flex-col sm:flex-row gap-4 pt-4"
</Link> >
<Link href="/#pricing"> <Link href="/signup">
<Button variant="outline" size="lg" className="text-lg px-8 py-4 w-full sm:w-auto"> <Button size="lg" className="text-lg px-8 py-6 w-full sm:w-auto shadow-lg shadow-blue-500/25 hover:shadow-blue-500/40 transition-all duration-300">
{t.hero.cta_secondary} {t.hero.cta_primary}
</Button> </Button>
</Link> </Link>
</div> <Link href="/#pricing">
</div> <Button variant="outline" size="lg" className="text-lg px-8 py-6 w-full sm:w-auto backdrop-blur-sm bg-white/50 border-gray-200 hover:bg-white/80 transition-all duration-300">
{t.hero.cta_secondary}
</Button>
</Link>
</motion.div>
</div> </div>
{/* Right Preview Widget */} {/* Right Preview Widget */}
<div className="relative"> <div className="relative">
<div className="grid grid-cols-2 gap-4"> <motion.div
variants={containerjs}
initial="hidden"
animate="show"
className="grid grid-cols-2 gap-4"
>
{templateCards.map((card, index) => ( {templateCards.map((card, index) => (
<Card key={index} className={`${card.color} border-0 p-6 text-center hover:scale-105 transition-transform`}> <motion.div key={index} variants={itemjs}>
<div className="text-3xl mb-2">{card.icon}</div> <Card className={`backdrop-blur-xl bg-white/70 border-white/50 shadow-xl shadow-gray-200/50 p-6 text-center hover:scale-105 transition-all duration-300 group cursor-pointer`}>
<p className="font-semibold text-gray-800">{card.title}</p> <div className={`w-12 h-12 mx-auto mb-4 rounded-xl ${card.color} flex items-center justify-center group-hover:scale-110 transition-transform duration-300`}>
</Card> <card.icon className="w-6 h-6" />
</div>
<p className="font-semibold text-gray-800 group-hover:text-gray-900">{card.title}</p>
</Card>
</motion.div>
))} ))}
</div> </motion.div>
{/* Floating Badge */} {/* Floating Badge */}
<div className="absolute -top-4 -right-4 bg-success-500 text-white px-4 py-2 rounded-full text-sm font-semibold shadow-lg"> <motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.8 }}
className="absolute -top-4 -right-4 bg-gradient-to-r from-success-500 to-emerald-500 text-white px-4 py-2 rounded-full text-sm font-semibold shadow-lg shadow-success-500/30 flex items-center gap-2"
>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-white"></span>
</span>
{t.hero.engagement_badge} {t.hero.engagement_badge}
</div> </motion.div>
</div> </div>
</div> </div>
</div> </div>
{/* Smooth Gradient Fade Transition */} {/* Smooth Gradient Fade Transition */}
<div className="absolute bottom-0 left-0 w-full h-32 bg-gradient-to-b from-transparent to-gray-50 pointer-events-none" /> <div className="absolute bottom-0 left-0 w-full h-32 bg-gradient-to-b from-transparent to-gray-50 pointer-events-none" />
</section> </section >
); );
}; };

View File

@@ -30,8 +30,12 @@ export default function HomePageClient() {
return ( return (
<> <>
<Hero t={t} /> <Hero t={t} />
<AIComingSoonBanner />
{/* Main Interaction: Generator */}
<InstantGenerator t={t} /> <InstantGenerator t={t} />
<AIComingSoonBanner />
<StaticVsDynamic t={t} /> <StaticVsDynamic t={t} />
<Features t={t} /> <Features t={t} />

View File

@@ -2,6 +2,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { QRCodeSVG } from 'qrcode.react'; import { QRCodeSVG } from 'qrcode.react';
import { motion } from 'framer-motion';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
@@ -73,139 +74,187 @@ export const InstantGenerator: React.FC<InstantGeneratorProps> = ({ t }) => {
}; };
return ( return (
<section className="py-16 bg-gray-50"> <section className="pt-16 pb-32 bg-gray-50 border-t border-gray-100 relative">
<div
className="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-r from-blue-50 to-white pointer-events-none"
style={{ maskImage: 'linear-gradient(to bottom, transparent, black)', WebkitMaskImage: 'linear-gradient(to bottom, transparent, black)' }}
/>
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl"> <div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="text-center mb-12"> <motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="text-center mb-12"
>
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4"> <h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
{t.generator.title} {t.generator.title}
</h2> </h2>
</div> </motion.div>
<div className="grid lg:grid-cols-2 gap-12 max-w-6xl mx-auto"> <div className="grid lg:grid-cols-2 gap-12 max-w-6xl mx-auto">
{/* Left Form */} {/* Left Form */}
<Card className="space-y-6"> <motion.div
<Input initial={{ opacity: 0, x: -20 }}
label="URL" whileInView={{ opacity: 1, x: 0 }}
value={url} viewport={{ once: true }}
onChange={(e) => setUrl(e.target.value)} transition={{ duration: 0.5, delay: 0.2 }}
placeholder={t.generator.url_placeholder} >
/> <Card className="space-y-6 shadow-xl shadow-gray-200/50 border-gray-100">
<Input
label="URL"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={t.generator.url_placeholder}
className="transition-all focus:ring-2 focus:ring-primary-500/20"
/>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label htmlFor="foreground-color" className="block text-sm font-medium text-gray-700 mb-2"> <label htmlFor="foreground-color" className="block text-sm font-medium text-gray-700 mb-2">
{t.generator.foreground} {t.generator.foreground}
</label> </label>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<input <input
id="foreground-color" id="foreground-color"
type="color" type="color"
value={foregroundColor} value={foregroundColor}
onChange={(e) => setForegroundColor(e.target.value)} onChange={(e) => setForegroundColor(e.target.value)}
className="w-12 h-10 rounded border border-gray-300" className="w-14 h-12 rounded border border-gray-300 cursor-pointer"
aria-label="Foreground color picker" aria-label="Foreground color picker"
/> />
<Input <Input
id="foreground-color-text" id="foreground-color-text"
value={foregroundColor} value={foregroundColor}
onChange={(e) => setForegroundColor(e.target.value)} onChange={(e) => setForegroundColor(e.target.value)}
className="flex-1" className="flex-1"
aria-label="Foreground color hex value" aria-label="Foreground color hex value"
/> />
</div>
</div>
<div>
<label htmlFor="background-color" className="block text-sm font-medium text-gray-700 mb-2">
{t.generator.background}
</label>
<div className="flex items-center space-x-2">
<input
id="background-color"
type="color"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="w-14 h-12 rounded border border-gray-300 cursor-pointer"
aria-label="Background color picker"
/>
<Input
id="background-color-text"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="flex-1"
aria-label="Background color hex value"
/>
</div>
</div> </div>
</div> </div>
<div> <div className="grid grid-cols-2 gap-4">
<label htmlFor="background-color" className="block text-sm font-medium text-gray-700 mb-2"> <div>
{t.generator.background} <label htmlFor="corner-style" className="block text-sm font-medium text-gray-700 mb-2">
</label> {t.generator.corners}
<div className="flex items-center space-x-2"> </label>
<select
id="corner-style"
value={cornerStyle}
onChange={(e) => setCornerStyle(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="square">Square</option>
<option value="rounded">Rounded</option>
</select>
</div>
<div>
<label htmlFor="qr-size" className="block text-sm font-medium text-gray-700 mb-2">
{t.generator.size}
</label>
<input <input
id="background-color" id="qr-size"
type="color" type="range"
value={backgroundColor} min="100"
onChange={(e) => setBackgroundColor(e.target.value)} max="400"
className="w-12 h-10 rounded border border-gray-300" value={size}
aria-label="Background color picker" onChange={(e) => setSize(Number(e.target.value))}
/> className="w-full accent-primary-600"
<Input aria-label={`QR code size: ${size} pixels`}
id="background-color-text"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="flex-1"
aria-label="Background color hex value"
/> />
<div className="text-sm text-gray-500 text-center mt-1" aria-hidden="true">{size}px</div>
</div> </div>
</div> </div>
</div>
<div className="grid grid-cols-2 gap-4"> <div className="flex items-center justify-between">
<div> <Badge variant={hasGoodContrast ? 'success' : 'warning'}>
<label htmlFor="corner-style" className="block text-sm font-medium text-gray-700 mb-2"> {hasGoodContrast ? t.generator.contrast_good : 'Low contrast'}
{t.generator.corners} </Badge>
</label> <div className="text-sm text-gray-500">
<select Contrast: {contrast.toFixed(1)}:1
id="corner-style" </div>
value={cornerStyle}
onChange={(e) => setCornerStyle(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="square">Square</option>
<option value="rounded">Rounded</option>
</select>
</div> </div>
<div> <div className="flex space-x-3">
<label htmlFor="qr-size" className="block text-sm font-medium text-gray-700 mb-2"> <Button variant="outline" className="flex-1 hover:bg-gray-50" onClick={() => downloadQR('svg')}>
{t.generator.size} {t.generator.download_svg}
</label> </Button>
<input <Button variant="outline" className="flex-1 hover:bg-gray-50" onClick={() => downloadQR('png')}>
id="qr-size" {t.generator.download_png}
type="range" </Button>
min="100"
max="400"
value={size}
onChange={(e) => setSize(Number(e.target.value))}
className="w-full"
aria-label={`QR code size: ${size} pixels`}
/>
<div className="text-sm text-gray-500 text-center mt-1" aria-hidden="true">{size}px</div>
</div> </div>
</div>
<div className="flex items-center justify-between"> <Button className="w-full text-lg py-6 shadow-lg shadow-primary-500/20 hover:shadow-primary-500/40 transition-all" onClick={() => window.location.href = '/login'}>
<Badge variant={hasGoodContrast ? 'success' : 'warning'}> {t.generator.save_track}
{hasGoodContrast ? t.generator.contrast_good : 'Low contrast'}
</Badge>
<div className="text-sm text-gray-500">
Contrast: {contrast.toFixed(1)}:1
</div>
</div>
<div className="flex space-x-3">
<Button variant="outline" className="flex-1" onClick={() => downloadQR('svg')}>
{t.generator.download_svg}
</Button> </Button>
<Button variant="outline" className="flex-1" onClick={() => downloadQR('png')}> </Card>
{t.generator.download_png} </motion.div>
</Button>
</div>
<Button className="w-full" onClick={() => window.location.href = '/login'}>
{t.generator.save_track}
</Button>
</Card>
{/* Right Preview */} {/* Right Preview */}
<div className="flex flex-col items-center justify-center"> <motion.div
<Card className="text-center p-8"> initial={{ opacity: 0, x: 20 }}
<h3 className="text-lg font-semibold mb-4">{t.generator.live_preview}</h3> whileInView={{ opacity: 1, x: 0 }}
<div id="instant-qr-preview" className="flex justify-center mb-4"> viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.4 }}
className="flex flex-col items-center justify-center p-8 bg-gradient-to-br from-blue-50/50 to-purple-50/50 rounded-2xl border border-blue-100/50 shadow-lg shadow-blue-500/5 relative overflow-hidden backdrop-blur-sm"
>
{/* Artistic Curved Lines Background */}
<div className="absolute inset-0 opacity-[0.4]">
<svg className="h-full w-full" viewBox="0 0 100 100" preserveAspectRatio="none">
<defs>
<linearGradient id="gradient1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#60a5fa" stopOpacity="0.4" />
<stop offset="100%" stopColor="#c084fc" stopOpacity="0.4" />
</linearGradient>
<linearGradient id="gradient2" x1="100%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#818cf8" stopOpacity="0.4" />
<stop offset="100%" stopColor="#38bdf8" stopOpacity="0.4" />
</linearGradient>
</defs>
<path d="M0 100 Q 25 30 50 70 T 100 0" fill="none" stroke="url(#gradient1)" strokeWidth="0.8" className="opacity-60" />
<path d="M0 50 Q 40 80 70 30 T 100 50" fill="none" stroke="url(#gradient2)" strokeWidth="0.8" className="opacity-60" />
<path d="M0 0 Q 30 60 60 20 T 100 80" fill="none" stroke="url(#gradient1)" strokeWidth="0.6" className="opacity-40" />
</svg>
<div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-15 brightness-100 contrast-150 mix-blend-overlay"></div>
</div>
{/* Decorative Orbs */}
<div className="absolute -top-20 -right-20 w-64 h-64 bg-purple-200/30 rounded-full blur-3xl animate-blob"></div>
<div className="absolute -bottom-20 -left-20 w-64 h-64 bg-blue-200/30 rounded-full blur-3xl animate-blob animation-delay-2000"></div>
<div className="text-center w-full relative z-10">
<h3 className="text-xl font-bold mb-8 text-gray-800">{t.generator.live_preview}</h3>
<div id="instant-qr-preview" className="flex justify-center mb-8 transform hover:scale-105 transition-transform duration-300">
{url ? ( {url ? (
<div className={`${cornerStyle === 'rounded' ? 'rounded-lg overflow-hidden' : ''}`}> <div className={`${cornerStyle === 'rounded' ? 'rounded-lg overflow-hidden' : ''} p-4 bg-white shadow-lg rounded-xl`}>
<QRCodeSVG <QRCodeSVG
value={url} value={url}
size={Math.min(size, 200)} size={size}
fgColor={foregroundColor} fgColor={foregroundColor}
bgColor={backgroundColor} bgColor={backgroundColor}
level="M" level="M"
@@ -213,17 +262,19 @@ export const InstantGenerator: React.FC<InstantGeneratorProps> = ({ t }) => {
</div> </div>
) : ( ) : (
<div <div
className="bg-gray-200 flex items-center justify-center text-gray-500" className="bg-gray-100 rounded-xl flex items-center justify-center text-gray-500 animate-pulse"
style={{ width: 200, height: 200 }} style={{ width: 200, height: 200 }}
> >
Enter URL Enter URL
</div> </div>
)} )}
</div> </div>
<div className="text-sm text-gray-600 mb-2">URL</div> <div className="text-sm font-medium text-gray-600 mb-2 bg-gray-50 py-2 px-4 rounded-full inline-block">
<div className="text-xs text-gray-500">{t.generator.demo_note}</div> {url || 'https://example.com'}
</Card> </div>
</div> <div className="text-xs text-gray-400 mt-2">{t.generator.demo_note}</div>
</div>
</motion.div>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { motion } from 'framer-motion';
import Link from 'next/link'; import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
@@ -32,87 +33,106 @@ export const Pricing: React.FC<PricingProps> = ({ t }) => {
return ( return (
<section id="pricing" className="py-16"> <section id="pricing" className="py-16">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl"> <div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="text-center mb-12"> <motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="text-center mb-12"
>
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4"> <h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
{t.pricing.title} {t.pricing.title}
</h2> </h2>
<p className="text-xl text-gray-600"> <p className="text-xl text-gray-600">
{t.pricing.subtitle} {t.pricing.subtitle}
</p> </p>
</div> </motion.div>
<div className="flex justify-center mb-8"> <div className="flex justify-center mb-8">
<BillingToggle value={billingPeriod} onChange={setBillingPeriod} /> <BillingToggle value={billingPeriod} onChange={setBillingPeriod} />
</div> </div>
<div className="grid md:grid-cols-3 gap-8 max-w-5xl mx-auto"> <div className="grid md:grid-cols-3 gap-8 max-w-5xl mx-auto">
{plans.map((plan) => ( {plans.map((plan, index) => (
<Card <motion.div
key={plan.key} key={plan.key}
className={plan.popular ? 'border-primary-500 shadow-xl relative' : ''} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
className="h-full"
> >
{plan.popular && ( <Card
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2"> className={`h-full flex flex-col ${plan.popular
<Badge variant="info" className="px-3 py-1"> ? 'border-primary-500 shadow-xl relative scale-105 z-10'
{t.pricing[plan.key].badge} : 'border-gray-200 hover:border-gray-300 hover:shadow-lg transition-all'
</Badge> }`}
</div> >
)} {plan.popular && (
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2 w-full text-center">
<CardHeader className="text-center pb-8"> <Badge variant="info" className="px-4 py-1.5 shadow-sm">
<CardTitle className="text-2xl mb-4"> {t.pricing[plan.key].badge}
{t.pricing[plan.key].title}
</CardTitle>
<div className="flex flex-col items-center">
<div className="flex items-baseline justify-center">
<span className="text-4xl font-bold">
{plan.key === 'free'
? t.pricing[plan.key].price
: billingPeriod === 'month'
? t.pricing[plan.key].price
: plan.key === 'pro'
? '€90'
: '€290'}
</span>
<span className="text-gray-600 ml-2">
{plan.key === 'free'
? t.pricing[plan.key].period
: billingPeriod === 'month'
? t.pricing[plan.key].period
: 'per year'}
</span>
</div>
{billingPeriod === 'year' && plan.key !== 'free' && (
<Badge variant="success" className="mt-2">
Save 16%
</Badge> </Badge>
)} </div>
</div> )}
</CardHeader>
<CardContent className="space-y-4"> <CardHeader className="text-center pb-8">
<ul className="space-y-3"> <CardTitle className="text-2xl mb-4">
{t.pricing[plan.key].features.map((feature: string, index: number) => ( {t.pricing[plan.key].title}
<li key={index} className="flex items-start space-x-3"> </CardTitle>
<svg className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20"> <div className="flex flex-col items-center">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /> <div className="flex items-baseline justify-center">
</svg> <span className="text-4xl font-bold">
<span className="text-gray-700">{feature}</span> {plan.key === 'free'
</li> ? t.pricing[plan.key].price
))} : billingPeriod === 'month'
</ul> ? t.pricing[plan.key].price
: plan.key === 'pro'
? '€90'
: '€290'}
</span>
<span className="text-gray-600 ml-2">
{plan.key === 'free'
? t.pricing[plan.key].period
: billingPeriod === 'month'
? t.pricing[plan.key].period
: 'per year'}
</span>
</div>
{billingPeriod === 'year' && plan.key !== 'free' && (
<Badge variant="success" className="mt-2">
Save 16%
</Badge>
)}
</div>
</CardHeader>
<Link href="/signup"> <CardContent className="space-y-8 flex-1 flex flex-col">
<Button <ul className="space-y-3 flex-1">
variant={plan.popular ? 'primary' : 'outline'} {t.pricing[plan.key].features.map((feature: string, index: number) => (
className="w-full" <li key={index} className="flex items-start space-x-3">
size="lg" <svg className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
> <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
Get Started </svg>
</Button> <span className="text-gray-700">{feature}</span>
</Link> </li>
</CardContent> ))}
</Card> </ul>
<div className="mt-8 pt-8 border-t border-gray-100">
<Link href="/signup">
<Button
variant={plan.popular ? 'primary' : 'outline'}
className="w-full"
size="lg"
>
Get Started
</Button>
</Link>
</div>
</CardContent>
</Card>
</motion.div>
))} ))}
</div> </div>
</div> </div>

View File

@@ -1,6 +1,8 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { motion } from 'framer-motion';
import { CheckCircle2 } from 'lucide-react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
@@ -12,56 +14,83 @@ export const StaticVsDynamic: React.FC<StaticVsDynamicProps> = ({ t }) => {
return ( return (
<section className="py-16"> <section className="py-16">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl"> <div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="text-center mb-12">
<h2 className="text-3xl font-bold text-gray-900 sm:text-4xl mb-4">
{t.static_vs_dynamic.title}
</h2>
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
{t.static_vs_dynamic.description}
</p>
</div>
<div className="grid lg:grid-cols-2 gap-8 max-w-6xl mx-auto"> <div className="grid lg:grid-cols-2 gap-8 max-w-6xl mx-auto">
{/* Static QR Codes */} {/* Static QR Codes */}
<Card className="relative"> <motion.div
<CardHeader> initial={{ opacity: 0, x: -20 }}
<div className="flex items-center justify-between"> whileInView={{ opacity: 1, x: 0 }}
<CardTitle className="text-2xl">{t.static_vs_dynamic.static.title}</CardTitle> viewport={{ once: true }}
<Badge variant="success">{t.static_vs_dynamic.static.subtitle}</Badge> transition={{ duration: 0.5, delay: 0.2 }}
</div> >
<p className="text-gray-600">{t.static_vs_dynamic.static.description}</p> <Card className="relative h-full border-gray-200 shadow-sm hover:shadow-lg transition-all duration-300">
</CardHeader> <CardHeader>
<CardContent> <div className="flex items-center justify-between mb-2">
<ul className="space-y-3"> <CardTitle className="text-2xl font-bold text-gray-700">{t.static_vs_dynamic.static.title}</CardTitle>
{t.static_vs_dynamic.static.features.map((feature: string, index: number) => ( <Badge variant="success" className="bg-gray-100 text-gray-700 hover:bg-gray-200">{t.static_vs_dynamic.static.subtitle}</Badge>
<li key={index} className="flex items-center space-x-3"> </div>
<div className="flex-shrink-0 w-5 h-5 bg-gray-400 rounded-full flex items-center justify-center"> <p className="text-gray-500">{t.static_vs_dynamic.static.description}</p>
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20"> </CardHeader>
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /> <CardContent>
</svg> <ul className="space-y-4">
</div> {t.static_vs_dynamic.static.features.map((feature: string, index: number) => (
<span className="text-gray-700">{feature}</span> <li key={index} className="flex items-start space-x-3">
</li> <div className="flex-shrink-0 w-6 h-6 bg-gray-100 rounded-full flex items-center justify-center mt-0.5">
))} <CheckCircle2 className="w-4 h-4 text-gray-500" />
</ul> </div>
</CardContent> <span className="text-gray-600">{feature}</span>
</Card> </li>
))}
</ul>
</CardContent>
</Card>
</motion.div>
{/* Dynamic QR Codes */} {/* Dynamic QR Codes */}
<Card className="relative border-primary-200 bg-primary-50"> <motion.div
<CardHeader> initial={{ opacity: 0, x: 20 }}
<div className="flex items-center justify-between"> whileInView={{ opacity: 1, x: 0 }}
<CardTitle className="text-2xl">{t.static_vs_dynamic.dynamic.title}</CardTitle> viewport={{ once: true }}
<Badge variant="info">{t.static_vs_dynamic.dynamic.subtitle}</Badge> transition={{ duration: 0.5, delay: 0.2 }}
>
<Card className="relative h-full border-2 border-primary-500/20 bg-gradient-to-br from-white to-primary-50/50 shadow-xl shadow-primary-500/10 hover:shadow-2xl hover:shadow-primary-500/20 transition-all duration-300">
<div className="absolute top-0 right-0 p-4">
<div className="absolute -top-3 -right-3">
<span className="relative flex h-4 w-4">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-4 w-4 bg-primary-500"></span>
</span>
</div>
</div> </div>
<p className="text-gray-600">{t.static_vs_dynamic.dynamic.description}</p> <CardHeader>
</CardHeader> <div className="flex items-center justify-between mb-2">
<CardContent> <CardTitle className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary-600 to-purple-600">{t.static_vs_dynamic.dynamic.title}</CardTitle>
<ul className="space-y-3"> <Badge variant="info" className="bg-primary-100 text-primary-700">{t.static_vs_dynamic.dynamic.subtitle}</Badge>
{t.static_vs_dynamic.dynamic.features.map((feature: string, index: number) => ( </div>
<li key={index} className="flex items-center space-x-3"> <p className="text-gray-600 font-medium">{t.static_vs_dynamic.dynamic.description}</p>
<div className="flex-shrink-0 w-5 h-5 bg-primary-500 rounded-full flex items-center justify-center"> </CardHeader>
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20"> <CardContent>
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /> <ul className="space-y-4">
</svg> {t.static_vs_dynamic.dynamic.features.map((feature: string, index: number) => (
</div> <li key={index} className="flex items-start space-x-3">
<span className="text-gray-700">{feature}</span> <div className="flex-shrink-0 w-6 h-6 bg-primary-100 rounded-full flex items-center justify-center mt-0.5">
</li> <CheckCircle2 className="w-4 h-4 text-primary-600" />
))} </div>
</ul> <span className="text-gray-900 font-medium">{feature}</span>
</CardContent> </li>
</Card> ))}
</ul>
</CardContent>
</Card>
</motion.div>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -8,22 +8,22 @@ interface StatsStripProps {
export const StatsStrip: React.FC<StatsStripProps> = ({ t }) => { export const StatsStrip: React.FC<StatsStripProps> = ({ t }) => {
const stats = [ const stats = [
{ key: 'users', value: '10,000+', label: t.trust.users }, { key: 'users', value: '1,240+', label: t.trust.users },
{ key: 'codes', value: '500,000+', label: t.trust.codes }, { key: 'codes', value: '8,500+', label: t.trust.codes },
{ key: 'scans', value: '50M+', label: t.trust.scans }, { key: 'scans', value: '1.2M+', label: t.trust.scans },
{ key: 'countries', value: '120+', label: t.trust.countries }, { key: 'countries', value: '120+', label: t.trust.countries },
]; ];
return ( return (
<section className="py-16 bg-gray-50"> <section className="py-20 bg-white border-y border-gray-100">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl"> <div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
{stats.map((stat, index) => ( {stats.map((stat, index) => (
<div key={stat.key} className="text-center"> <div key={stat.key} className="text-center group hover:-translate-y-1 transition-transform duration-300">
<div className="text-3xl lg:text-4xl font-bold text-primary-600 mb-2"> <div className="text-4xl lg:text-5xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-600 to-purple-600 mb-2">
{stat.value} {stat.value}
</div> </div>
<div className="text-gray-600 font-medium"> <div className="text-gray-500 font-medium uppercase tracking-wider text-sm">
{stat.label} {stat.label}
</div> </div>
</div> </div>

View File

@@ -1,70 +1,71 @@
import Link from 'next/link'; import Link from 'next/link';
interface FooterProps { interface FooterProps {
variant?: 'marketing' | 'dashboard'; variant?: 'marketing' | 'dashboard';
} }
export function Footer({ variant = 'marketing' }: FooterProps) { export function Footer({ variant = 'marketing' }: FooterProps) {
const isDashboard = variant === 'dashboard'; const isDashboard = variant === 'dashboard';
return ( 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`}> <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="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="grid md:grid-cols-4 gap-8"> <div className="grid md:grid-cols-4 gap-8">
<div> <div>
<Link href="/" className="flex items-center space-x-2 mb-4 hover:opacity-80 transition-opacity"> <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" /> <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> <span className={`text-xl font-bold ${isDashboard ? 'text-gray-900' : ''}`}>QR Master</span>
</Link> </Link>
<p className={isDashboard ? 'text-gray-500' : 'text-gray-400'}> <p className={isDashboard ? 'text-gray-500' : 'text-gray-400'}>
Create custom QR codes in seconds with advanced tracking and analytics. Create custom QR codes in seconds with advanced tracking and analytics.
</p> </p>
</div> </div>
<div> <div>
<h3 className={`font-semibold mb-4 ${isDashboard ? 'text-gray-900' : ''}`}>Product</h3> <h3 className={`font-semibold mb-4 ${isDashboard ? 'text-gray-900' : ''}`}>Product</h3>
<ul className={`space-y-2 ${isDashboard ? 'text-gray-500' : 'text-gray-400'}`}> <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="/#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="/#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="/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> <li><Link href="/blog" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Blog</Link></li>
</ul> </ul>
</div> </div>
<div> <div>
<h3 className={`font-semibold mb-4 ${isDashboard ? 'text-gray-900' : ''}`}>Resources</h3> <h3 className={`font-semibold mb-4 ${isDashboard ? 'text-gray-900' : ''}`}>Resources</h3>
<ul className={`space-y-2 ${isDashboard ? 'text-gray-500' : 'text-gray-400'}`}> <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="/#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="/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="/blog" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>All Articles</Link></li>
<li><Link href="/signup" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Get Started</Link></li> <li><Link href="/signup" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Get Started</Link></li>
</ul> </ul>
</div> </div>
<div> <div>
<h3 className={`font-semibold mb-4 ${isDashboard ? 'text-gray-900' : ''}`}>Legal</h3> <h3 className={`font-semibold mb-4 ${isDashboard ? 'text-gray-900' : ''}`}>Legal</h3>
<ul className={`space-y-2 ${isDashboard ? 'text-gray-500' : 'text-gray-400'}`}> <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> <li><Link href="/privacy" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Privacy Policy</Link></li>
</ul> </ul>
</div> </div>
</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'}`}> <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 ? ( {!isDashboard ? (
<Link <Link
href="/newsletter" href="/newsletter"
className="text-[6px] text-gray-700 opacity-[0.03] hover:opacity-100 hover:text-white transition-opacity duration-300" className="text-[6px] text-gray-700 opacity-[0.03] hover:opacity-100 hover:text-white transition-opacity duration-300"
> >
<span className="sr-only">Newsletter signup</span>
</Link>
) : ( </Link>
<div></div> ) : (
)} <div></div>
<p>&copy; 2025 QR Master. All rights reserved.</p> )}
<div className="w-12"></div> <p>&copy; 2025 QR Master. All rights reserved.</p>
</div> <div className="w-12"></div>
</div> </div>
</footer> </div>
); </footer>
} );
}

View File

@@ -32,7 +32,7 @@ export function ScrollToTop() {
{isVisible && ( {isVisible && (
<button <button
onClick={scrollToTop} onClick={scrollToTop}
className="fixed bottom-8 right-8 z-50 p-3 bg-primary-600 hover:bg-primary-700 text-white rounded-full shadow-lg transition-all duration-300 hover:scale-110 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2" className="fixed bottom-8 right-8 z-50 p-4 bg-primary-600 hover:bg-primary-700 text-white rounded-full shadow-lg transition-all duration-300 hover:scale-110 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
aria-label="Scroll to top" aria-label="Scroll to top"
> >
<svg <svg

View File

@@ -26,10 +26,10 @@
"engagement_badge": "Free Forever" "engagement_badge": "Free Forever"
}, },
"trust": { "trust": {
"users": "Trusted by small businesses", "users": "Happy Users",
"codes": "Simple QR code creation", "codes": "Active QR Codes",
"scans": "Track every scan", "scans": "Total Scans",
"countries": "Works worldwide" "countries": "Countries"
}, },
"industries": { "industries": {
"restaurant": "Restaurant Chain", "restaurant": "Restaurant Chain",
@@ -62,6 +62,8 @@
"demo_note": "This is a demo QR code" "demo_note": "This is a demo QR code"
}, },
"static_vs_dynamic": { "static_vs_dynamic": {
"title": "Why Dynamic QR Codes Save You Money",
"description": "Stop re-printing materials. Switch destinations instantly and track every scan.",
"static": { "static": {
"title": "Static QR Codes", "title": "Static QR Codes",
"subtitle": "Always Free", "subtitle": "Always Free",
@@ -149,7 +151,7 @@
"50 dynamic QR codes", "50 dynamic QR codes",
"Unlimited static QR codes", "Unlimited static QR codes",
"Advanced analytics (scans, devices, locations)", "Advanced analytics (scans, devices, locations)",
"Custom branding (colors)" "Custom branding (colors & logos)"
] ]
}, },
"business": { "business": {

View File

@@ -24,28 +24,6 @@ export function middleware(req: NextRequest) {
return NextResponse.next(); return NextResponse.next();
} }
// Handle White Label Subdomains
// Check if this is a subdomain request (e.g., kunde.qrmaster.de)
const host = req.headers.get('host') || '';
const isLocalhost = host.includes('localhost') || host.includes('127.0.0.1');
const mainDomain = process.env.NEXT_PUBLIC_MAIN_DOMAIN || 'qrmaster.net';
// Extract subdomain if present (e.g., "kunde" from "kunde.qrmaster.de")
let subdomain: string | null = null;
if (!isLocalhost && host.endsWith(mainDomain) && host !== mainDomain && host !== `www.${mainDomain}`) {
const parts = host.replace(`.${mainDomain}`, '').split('.');
if (parts.length === 1 && parts[0]) {
subdomain = parts[0];
}
}
// For subdomain requests to /r/*, pass subdomain info via header
if (subdomain && path.startsWith('/r/')) {
const response = NextResponse.next();
response.headers.set('x-subdomain', subdomain);
return response;
}
// Allow redirect routes (QR code redirects) // Allow redirect routes (QR code redirects)
if (path.startsWith('/r/')) { if (path.startsWith('/r/')) {
return NextResponse.next(); return NextResponse.next();

View File

@@ -1,58 +1,58 @@
declare module 'react-simple-maps' { declare module 'react-simple-maps' {
import { ComponentType, ReactNode, CSSProperties } from 'react'; import { ComponentType, ReactNode, CSSProperties } from 'react';
export interface ComposableMapProps { export interface ComposableMapProps {
projection?: string; projection?: string;
projectionConfig?: { projectionConfig?: {
scale?: number; scale?: number;
center?: [number, number]; center?: [number, number];
rotate?: [number, number, number]; rotate?: [number, number, number];
}; };
width?: number; width?: number;
height?: number; height?: number;
style?: CSSProperties; style?: CSSProperties;
children?: ReactNode; children?: ReactNode;
} }
export interface GeographiesProps { export interface GeographiesProps {
geography: string | object; geography: string | object;
children: (data: { geographies: any[] }) => ReactNode; children: (data: { geographies: any[] }) => ReactNode;
} }
export interface GeographyProps { export interface GeographyProps {
geography: any; geography: any;
style?: { style?: {
default?: CSSProperties; default?: CSSProperties;
hover?: CSSProperties; hover?: CSSProperties;
pressed?: CSSProperties; pressed?: CSSProperties;
}; };
fill?: string; fill?: string;
stroke?: string; stroke?: string;
strokeWidth?: number; strokeWidth?: number;
onClick?: (event: React.MouseEvent) => void; onClick?: (event: React.MouseEvent) => void;
onMouseEnter?: (event: React.MouseEvent) => void; onMouseEnter?: (event: React.MouseEvent) => void;
onMouseLeave?: (event: React.MouseEvent) => void; onMouseLeave?: (event: React.MouseEvent) => void;
} }
export interface ZoomableGroupProps { export interface ZoomableGroupProps {
center?: [number, number]; center?: [number, number];
zoom?: number; zoom?: number;
minZoom?: number; minZoom?: number;
maxZoom?: number; maxZoom?: number;
translateExtent?: [[number, number], [number, number]]; translateExtent?: [[number, number], [number, number]];
onMoveStart?: (event: any) => void; onMoveStart?: (event: any) => void;
onMove?: (event: any) => void; onMove?: (event: any) => void;
onMoveEnd?: (event: any) => void; onMoveEnd?: (event: any) => void;
children?: ReactNode; children?: ReactNode;
} }
export const ComposableMap: ComponentType<ComposableMapProps>; export const ComposableMap: ComponentType<ComposableMapProps>;
export const Geographies: ComponentType<GeographiesProps>; export const Geographies: ComponentType<GeographiesProps>;
export const Geography: ComponentType<GeographyProps>; export const Geography: ComponentType<GeographyProps>;
export const ZoomableGroup: ComponentType<ZoomableGroupProps>; export const ZoomableGroup: ComponentType<ZoomableGroupProps>;
export const Marker: ComponentType<any>; export const Marker: ComponentType<any>;
export const Line: ComponentType<any>; export const Line: ComponentType<any>;
export const Annotation: ComponentType<any>; export const Annotation: ComponentType<any>;
export const Graticule: ComponentType<any>; export const Graticule: ComponentType<any>;
export const Sphere: ComponentType<any>; export const Sphere: ComponentType<any>;
} }