1 Commits

38 changed files with 13933 additions and 14317 deletions

View File

@@ -1,41 +0,0 @@
🚀 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

43
package-lock.json generated
View File

@@ -21,7 +21,6 @@
"dayjs": "^1.11.10",
"exceljs": "^4.4.0",
"file-saver": "^2.0.5",
"framer-motion": "^12.24.10",
"i18next": "^23.7.6",
"ioredis": "^5.3.2",
"jszip": "^3.10.1",
@@ -5099,33 +5098,6 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/framer-motion": {
"version": "12.24.10",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.24.10.tgz",
"integrity": "sha512-8yoyMkCn2RmV9UB9mfmMuzKyenQe909hRQRl0yGBhbZJjZZ9bSU87NIGAruqCXCuTNCA0qHw2LWLrcXLL9GF6A==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.24.10",
"motion-utils": "^12.24.10",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@@ -6635,21 +6607,6 @@
"mkdirp": "bin/cmd.js"
}
},
"node_modules/motion-dom": {
"version": "12.24.10",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.24.10.tgz",
"integrity": "sha512-H3HStYaJ6wANoZVNT0ZmYZHGvrpvi9pKJRzsgNEHkdITR4Qd9FFu2e9sH4e2Phr4tKCmyyloex6SOSmv0Tlq+g==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.24.10"
}
},
"node_modules/motion-utils": {
"version": "12.24.10",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.24.10.tgz",
"integrity": "sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",

View File

@@ -37,7 +37,6 @@
"dayjs": "^1.11.10",
"exceljs": "^4.4.0",
"file-saver": "^2.0.5",
"framer-motion": "^12.24.10",
"i18next": "^23.7.6",
"ioredis": "^5.3.2",
"jszip": "^3.10.1",

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 518 KiB

View File

@@ -33,9 +33,9 @@ export default function CreatePage() {
const [cornerStyle, setCornerStyle] = useState('square');
const [size, setSize] = useState(200);
// Logo state
const [logoUrl, setLogoUrl] = useState('');
const [logoSize, setLogoSize] = useState(24);
// Logo state (PRO feature)
const [logo, setLogo] = useState<string>('');
const [logoSize, setLogoSize] = useState(40);
const [excavate, setExcavate] = useState(true);
// QR preview
@@ -155,48 +155,6 @@ export default function CreatePage() {
}
};
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > 10 * 1024 * 1024) { // 10MB limit (soft limit for upload, will be resized)
showToast('Logo file size too large (max 10MB)', 'error');
return;
}
const reader = new FileReader();
reader.onload = (evt) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const maxDimension = 500; // Resize to max 500px
let width = img.width;
let height = img.height;
if (width > maxDimension || height > maxDimension) {
if (width > height) {
height = Math.round((height * maxDimension) / width);
width = maxDimension;
} else {
width = Math.round((width * maxDimension) / height);
height = maxDimension;
}
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx?.drawImage(img, 0, 0, width, height);
// Compress to JPEG/PNG with reduced quality to save space
const dataUrl = canvas.toDataURL(file.type === 'image/png' ? 'image/png' : 'image/jpeg', 0.8);
setLogoUrl(dataUrl);
};
img.src = evt.target?.result as string;
};
reader.readAsDataURL(file);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
@@ -214,12 +172,15 @@ export default function CreatePage() {
backgroundColor: canCustomizeColors ? backgroundColor : '#FFFFFF',
cornerStyle,
size,
imageSettings: (canCustomizeColors && logoUrl) ? {
src: logoUrl,
// Logo embedding (PRO only)
...(logo && canCustomizeColors ? {
imageSettings: {
src: logo,
height: logoSize,
width: logoSize,
excavate,
} : undefined,
excavate: excavate,
}
} : {}),
},
};
@@ -542,21 +503,19 @@ export default function CreatePage() {
</CardContent>
</Card>
{/* Logo Section */}
{/* Logo/Icon Section (PRO Feature) */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Logo</CardTitle>
{!canCustomizeColors && (
<Badge variant="warning">PRO Feature</Badge>
)}
<CardTitle>Logo / Icon</CardTitle>
<Badge variant="info">PRO</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{!canCustomizeColors && (
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg mb-4">
{!canCustomizeColors ? (
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-900">
<strong>Upgrade to PRO</strong> to add logos to your QR codes.
<strong>Upgrade to PRO</strong> to add your logo or icon to QR codes.
</p>
<Link href="/pricing">
<Button variant="primary" size="sm" className="mt-2">
@@ -564,65 +523,72 @@ export default function CreatePage() {
</Button>
</Link>
</div>
)}
) : (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Upload Logo
Upload Logo (PNG, JPG)
</label>
<div className="flex items-center space-x-4">
<input
type="file"
accept="image/*"
onChange={handleLogoUpload}
disabled={!canCustomizeColors}
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 disabled:opacity-50 disabled:cursor-not-allowed"
accept="image/png,image/jpeg,image/jpg"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
setLogo(reader.result as string);
};
reader.readAsDataURL(file);
}
}}
className="w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100"
/>
{logoUrl && (
</div>
{logo && (
<>
<div className="flex items-center gap-4">
<img src={logo} alt="Logo preview" className="w-12 h-12 object-contain rounded border" />
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setLogoUrl('');
setLogoSize(40);
}}
onClick={() => setLogo('')}
>
Remove
</Button>
)}
</div>
</div>
{logoUrl && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Logo Size: {logoSize}px
</label>
<input
type="range"
min="20"
max="70"
min="24"
max="80"
value={logoSize}
onChange={(e) => setLogoSize(Number(e.target.value))}
className="w-full"
/>
</div>
<div className="flex items-center">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="excavate"
checked={excavate}
onChange={(e) => setExcavate(e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
id="excavate-checkbox"
className="w-4 h-4 rounded border-gray-300"
/>
<label htmlFor="excavate-checkbox" className="ml-2 block text-sm text-gray-900">
Excavate background (remove dots behind logo)
<label htmlFor="excavate" className="text-sm text-gray-700">
Clear background behind logo (recommended)
</label>
</div>
</>
)}
</>
)}
</CardContent>
</Card>
</div>
@@ -642,9 +608,9 @@ export default function CreatePage() {
size={200}
fgColor={foregroundColor}
bgColor={backgroundColor}
level="H"
imageSettings={logoUrl ? {
src: logoUrl,
level={logo && canCustomizeColors ? 'H' : 'M'}
imageSettings={logo && canCustomizeColors ? {
src: logo,
height: logoSize,
width: logoSize,
excavate: excavate,

View File

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

View File

@@ -141,7 +141,7 @@ export default function PricingPage() {
'50 dynamic QR codes',
'Unlimited static QR codes',
'Advanced analytics (scans, devices, locations)',
'Custom branding (colors & logos)',
'Custom branding (colors)',
],
buttonText: isCurrentPlanWithInterval('PRO', selectedInterval)
? 'Current Plan'

View File

@@ -4,11 +4,12 @@ import React, { useState, useEffect } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Input } from '@/components/ui/Input';
import { useCsrf } from '@/hooks/useCsrf';
import { showToast } from '@/components/ui/Toast';
import ChangePasswordModal from '@/components/settings/ChangePasswordModal';
type TabType = 'profile' | 'subscription';
type TabType = 'profile' | 'subscription' | 'whitelabel';
export default function SettingsPage() {
const { fetchWithCsrf } = useCsrf();
@@ -28,6 +29,11 @@ export default function SettingsPage() {
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
useEffect(() => {
const fetchUserData = async () => {
@@ -53,6 +59,14 @@ export default function SettingsPage() {
const data = await statsResponse.json();
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) {
console.error('Failed to load user data:', e);
}
@@ -185,8 +199,7 @@ export default function SettingsPage() {
<nav className="-mb-px flex space-x-8">
<button
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 ${activeTab === 'profile'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
@@ -195,14 +208,22 @@ export default function SettingsPage() {
</button>
<button
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 ${activeTab === 'subscription'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Subscription
</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>
</div>
@@ -373,6 +394,143 @@ export default function SettingsPage() {
</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 */}
<ChangePasswordModal
isOpen={showPasswordModal}

View File

@@ -3,7 +3,6 @@
import React, { useState } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/Button';
import { Footer } from '@/components/ui/Footer';
import en from '@/i18n/en.json';
export default function MarketingLayout({
@@ -12,16 +11,6 @@ export default function MarketingLayout({
children: React.ReactNode;
}) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [showStickyCTA, setShowStickyCTA] = useState(false);
React.useEffect(() => {
const handleScroll = () => {
setShowStickyCTA(window.scrollY > 400);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
// Always use English for marketing pages
const t = en;
@@ -36,7 +25,7 @@ export default function MarketingLayout({
return (
<div className="min-h-screen bg-white">
{/* Header */}
<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'}`}>
<header className="sticky top-0 z-50 bg-white border-b border-gray-200">
<nav className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl py-4">
<div className="flex items-center justify-between">
{/* Logo */}
@@ -65,11 +54,7 @@ export default function MarketingLayout({
</Link>
<Link href="/signup">
{showStickyCTA ? (
<Button className="animate-in fade-in zoom-in duration-300">Create QR Code</Button>
) : (
<Button>Get Started Free</Button>
)}
</Link>
</div>
@@ -117,10 +102,63 @@ export default function MarketingLayout({
</header>
{/* Main Content */}
<main className="pt-20">{children}</main>
<main>{children}</main>
{/* Footer */}
<Footer />
<footer className="bg-gray-900 text-white py-12 mt-20">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<div className="grid md:grid-cols-4 gap-8">
<div>
<Link href="/" className="flex items-center space-x-2 mb-4 hover:opacity-80 transition-opacity">
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
<span className="text-xl font-bold">QR Master</span>
</Link>
<p className="text-gray-400">
Create custom QR codes in seconds with advanced tracking and analytics.
</p>
</div>
<div>
<h3 className="font-semibold mb-4">Product</h3>
<ul className="space-y-2 text-gray-400">
<li><Link href="/#features" className="hover:text-white">Features</Link></li>
<li><Link href="/#pricing" className="hover:text-white">Pricing</Link></li>
<li><Link href="/#faq" className="hover:text-white">FAQ</Link></li>
<li><Link href="/blog" className="hover:text-white">Blog</Link></li>
</ul>
</div>
<div>
<h3 className="font-semibold mb-4">Resources</h3>
<ul className="space-y-2 text-gray-400">
<li><Link href="/#pricing" className="hover:text-white">Full Pricing</Link></li>
<li><Link href="/faq" className="hover:text-white">All Questions</Link></li>
<li><Link href="/blog" className="hover:text-white">Blog</Link></li>
<li><Link href="/signup" className="hover:text-white">Get Started</Link></li>
</ul>
</div>
<div>
<h3 className="font-semibold mb-4">Legal</h3>
<ul className="space-y-2 text-gray-400">
<li><Link href="/privacy" className="hover:text-white">Privacy Policy</Link></li>
</ul>
</div>
</div>
<div className="border-t border-gray-800 mt-8 pt-8 flex items-center justify-between text-gray-400">
<Link
href="/newsletter"
className="text-[6px] text-gray-700 opacity-[0.25] hover:opacity-100 hover:text-white transition-opacity duration-300"
>
</Link>
<p>&copy; 2025 QR Master. All rights reserved.</p>
<div className="w-12"></div>
</div>
</div>
</footer>
</div>
);
}

View File

@@ -5,60 +5,19 @@ import { useRouter } from 'next/navigation';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import {
Mail,
Users,
QrCode,
BarChart3,
TrendingUp,
Crown,
Activity,
Loader2,
Lock,
LogOut,
Zap,
Send,
CheckCircle2,
} from 'lucide-react';
import { Mail, Users, Send, CheckCircle, AlertCircle, Loader2, Lock, LogOut } from 'lucide-react';
interface AdminStats {
users: {
total: number;
premium: number;
newThisWeek: number;
newThisMonth: number;
recent: Array<{
interface Subscriber {
email: string;
name: string | null;
plan: string;
createdAt: string;
}>;
};
qrCodes: {
total: number;
dynamic: number;
static: number;
active: number;
};
scans: {
total: number;
dynamicOnly: number;
avgPerDynamicQR: string;
};
newsletter: {
subscribers: number;
};
topQRCodes: Array<{
id: string;
title: string;
type: string;
scans: number;
owner: string;
createdAt: string;
}>;
}
export default function AdminDashboard() {
interface BroadcastInfo {
total: number;
recent: Subscriber[];
}
export default function NewsletterPage() {
const router = useRouter();
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isAuthenticating, setIsAuthenticating] = useState(true);
@@ -66,18 +25,14 @@ export default function AdminDashboard() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [stats, setStats] = useState<AdminStats | null>(null);
const [info, setInfo] = useState<BroadcastInfo | null>(null);
const [loading, setLoading] = useState(true);
// Newsletter management state
const [newsletterData, setNewsletterData] = useState<{
total: number;
recent: Array<{ email: string; createdAt: string }>;
} | null>(null);
const [sendingBroadcast, setSendingBroadcast] = useState(false);
const [broadcastResult, setBroadcastResult] = useState<{
const [broadcasting, setBroadcasting] = useState(false);
const [result, setResult] = useState<{
success: boolean;
message: string;
sent?: number;
failed?: number;
} | null>(null);
useEffect(() => {
@@ -86,14 +41,12 @@ export default function AdminDashboard() {
const checkAuth = async () => {
try {
const response = await fetch('/api/admin/stats');
const response = await fetch('/api/newsletter/broadcast');
if (response.ok) {
setIsAuthenticated(true);
const data = await response.json();
setStats(data);
setInfo(data);
setLoading(false);
// Also fetch newsletter data
fetchNewsletterData();
} else {
setIsAuthenticated(false);
}
@@ -104,54 +57,6 @@ export default function AdminDashboard() {
}
};
const fetchNewsletterData = async () => {
try {
const response = await fetch('/api/newsletter/broadcast');
if (response.ok) {
const data = await response.json();
setNewsletterData(data);
}
} catch (error) {
console.error('Failed to fetch newsletter data:', error);
}
};
const handleSendBroadcast = async () => {
if (!confirm(`Are you sure you want to send the AI Feature Launch email to all ${newsletterData?.total || 0} subscribers?`)) {
return;
}
setSendingBroadcast(true);
setBroadcastResult(null);
try {
const response = await fetch('/api/newsletter/broadcast', {
method: 'POST',
});
const data = await response.json();
if (response.ok) {
setBroadcastResult({
success: true,
message: data.message || `Successfully sent to ${data.sent} subscribers!`,
});
} else {
setBroadcastResult({
success: false,
message: data.error || 'Failed to send broadcast',
});
}
} catch (error) {
setBroadcastResult({
success: false,
message: 'Network error. Please try again.',
});
} finally {
setSendingBroadcast(false);
}
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoginError('');
@@ -185,6 +90,53 @@ export default function AdminDashboard() {
router.push('/');
};
const fetchSubscriberInfo = async () => {
try {
const response = await fetch('/api/newsletter/broadcast');
if (response.ok) {
const data = await response.json();
setInfo(data);
}
} catch (error) {
console.error('Failed to fetch subscriber info:', error);
}
};
const handleBroadcast = async () => {
if (!confirm(`Are you sure you want to send the AI feature launch email to ${info?.total} subscribers?`)) {
return;
}
setBroadcasting(true);
setResult(null);
try {
const response = await fetch('/api/newsletter/broadcast', {
method: 'POST',
});
const data = await response.json();
setResult({
success: response.ok,
message: data.message || data.error,
sent: data.sent,
failed: data.failed,
});
if (response.ok) {
await fetchSubscriberInfo();
}
} catch (error) {
setResult({
success: false,
message: 'Failed to send broadcast. Please try again.',
});
} finally {
setBroadcasting(false);
}
};
// Login Screen
if (!isAuthenticated) {
return (
@@ -194,9 +146,9 @@ export default function AdminDashboard() {
<div className="w-16 h-16 bg-gradient-to-br from-purple-100 to-pink-100 dark:from-purple-900/30 dark:to-pink-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<Lock className="w-8 h-8 text-purple-600 dark:text-purple-400" />
</div>
<h1 className="text-2xl font-bold mb-2">Admin Dashboard</h1>
<h1 className="text-2xl font-bold mb-2">Newsletter Admin</h1>
<p className="text-muted-foreground text-sm">
Sign in to access admin panel
Sign in to manage subscribers
</p>
</div>
@@ -207,7 +159,7 @@ export default function AdminDashboard() {
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@example.com"
placeholder="Email"
required
className="w-full px-4 py-3 rounded-xl bg-background border border-border focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20 outline-none transition-all"
/>
@@ -219,7 +171,7 @@ export default function AdminDashboard() {
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
placeholder="Password"
required
className="w-full px-4 py-3 rounded-xl bg-background border border-border focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20 outline-none transition-all"
/>
@@ -267,13 +219,12 @@ export default function AdminDashboard() {
// Admin Dashboard
return (
<div className="min-h-screen bg-gradient-to-br from-purple-50/30 to-pink-50/30 dark:from-purple-950/10 dark:to-pink-950/10">
<div className="container mx-auto px-4 py-8 max-w-7xl">
{/* Header */}
<div className="container mx-auto px-4 py-8 max-w-4xl">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold mb-2">Admin Dashboard</h1>
<h1 className="text-3xl font-bold mb-2">Newsletter Management</h1>
<p className="text-muted-foreground">
Platform overview and statistics
Manage AI feature launch notifications
</p>
</div>
<Button
@@ -286,305 +237,45 @@ export default function AdminDashboard() {
</Button>
</div>
{/* Main Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{/* All Time Users */}
<Card className="p-6 hover:shadow-lg transition-shadow">
<div className="flex items-start justify-between mb-4">
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900/20 rounded-lg flex items-center justify-center">
<Users className="w-6 h-6 text-blue-600 dark:text-blue-400" />
</div>
<Badge className="bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
All Time
</Badge>
</div>
<h3 className="text-3xl font-bold mb-1">{stats?.users.total || 0}</h3>
<p className="text-sm text-muted-foreground">Total Users</p>
<div className="mt-3 pt-3 border-t space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">This Month</span>
<span className="text-sm font-semibold text-green-600 dark:text-green-400">
+{stats?.users.newThisMonth || 0}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">This Week</span>
<span className="text-sm font-semibold text-green-600 dark:text-green-400">
+{stats?.users.newThisWeek || 0}
</span>
</div>
</div>
</Card>
{/* Dynamic QR Codes */}
<Card className="p-6 hover:shadow-lg transition-shadow">
<div className="flex items-start justify-between mb-4">
{/* Stats Card */}
<Card className="p-6 mb-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900/20 rounded-lg flex items-center justify-center">
<QrCode className="w-6 h-6 text-purple-600 dark:text-purple-400" />
</div>
<Badge className="bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">
Dynamic
</Badge>
</div>
<h3 className="text-3xl font-bold mb-1">{stats?.qrCodes.dynamic || 0}</h3>
<p className="text-sm text-muted-foreground">Dynamic QR Codes</p>
<div className="mt-3 pt-3 border-t flex items-center justify-between">
<span className="text-xs text-muted-foreground">Static</span>
<span className="text-sm font-semibold">{stats?.qrCodes.static || 0}</span>
</div>
</Card>
{/* Total Scans */}
<Card className="p-6 hover:shadow-lg transition-shadow">
<div className="flex items-start justify-between mb-4">
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/20 rounded-lg flex items-center justify-center">
<BarChart3 className="w-6 h-6 text-green-600 dark:text-green-400" />
</div>
<Badge className="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
All Time
</Badge>
</div>
<h3 className="text-3xl font-bold mb-1">
{stats?.scans.dynamicOnly.toLocaleString() || 0}
</h3>
<p className="text-sm text-muted-foreground">Dynamic QR Scans</p>
<div className="mt-3 pt-3 border-t flex items-center justify-between">
<span className="text-xs text-muted-foreground">Avg per QR</span>
<span className="text-sm font-semibold">{stats?.scans.avgPerDynamicQR || 0}</span>
</div>
</Card>
{/* Total QR Codes */}
<Card className="p-6 hover:shadow-lg transition-shadow">
<div className="flex items-start justify-between mb-4">
<div className="w-12 h-12 bg-amber-100 dark:bg-amber-900/20 rounded-lg flex items-center justify-center">
<QrCode className="w-6 h-6 text-amber-600 dark:text-amber-400" />
</div>
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
All Time
</Badge>
</div>
<h3 className="text-3xl font-bold mb-1">{stats?.qrCodes.total || 0}</h3>
<p className="text-sm text-muted-foreground">Total QR Codes</p>
<div className="mt-3 pt-3 border-t space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Dynamic</span>
<span className="text-sm font-semibold">{stats?.qrCodes.dynamic || 0}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Static</span>
<span className="text-sm font-semibold">{stats?.qrCodes.static || 0}</span>
</div>
</div>
</Card>
</div>
{/* Secondary Stats Row */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
{/* Total All Scans */}
<Card className="p-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-indigo-100 dark:bg-indigo-900/20 rounded-lg flex items-center justify-center">
<Zap className="w-6 h-6 text-indigo-600 dark:text-indigo-400" />
<Users className="w-6 h-6 text-purple-600 dark:text-purple-400" />
</div>
<div>
<h3 className="text-2xl font-bold">
{stats?.scans.total.toLocaleString() || 0}
</h3>
<p className="text-sm text-muted-foreground">Total All Scans</p>
<h2 className="text-2xl font-bold">{info?.total || 0}</h2>
<p className="text-sm text-muted-foreground">Total Subscribers</p>
</div>
</div>
</Card>
{/* Total QR Codes */}
<Card className="p-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-pink-100 dark:bg-pink-900/20 rounded-lg flex items-center justify-center">
<QrCode className="w-6 h-6 text-pink-600 dark:text-pink-400" />
</div>
<div>
<h3 className="text-2xl font-bold">{stats?.qrCodes.total || 0}</h3>
<p className="text-sm text-muted-foreground">Total QR Codes</p>
</div>
</div>
</Card>
{/* Premium Users */}
<Card className="p-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-amber-100 dark:bg-amber-900/20 rounded-lg flex items-center justify-center">
<Crown className="w-6 h-6 text-amber-600 dark:text-amber-400" />
</div>
<div>
<h3 className="text-2xl font-bold">{stats?.users.premium || 0}</h3>
<p className="text-sm text-muted-foreground">Premium Users</p>
</div>
</div>
</Card>
</div>
{/* Bottom Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Top QR Codes */}
<Card className="p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-gradient-to-br from-purple-100 to-pink-100 dark:from-purple-900/30 dark:to-pink-900/30 rounded-lg flex items-center justify-center">
<TrendingUp className="w-5 h-5 text-purple-600 dark:text-purple-400" />
</div>
<div>
<h3 className="font-semibold text-lg">Top QR Codes</h3>
<p className="text-xs text-muted-foreground">Most scanned</p>
</div>
</div>
{stats?.topQRCodes && stats.topQRCodes.length > 0 ? (
<div className="space-y-3">
{stats.topQRCodes.map((qr, index) => (
<div
key={qr.id}
className="flex items-center justify-between py-3 border-b border-border last:border-0"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="w-8 h-8 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center flex-shrink-0">
<span className="text-white text-sm font-bold">
#{index + 1}
</span>
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{qr.title}</p>
<p className="text-xs text-muted-foreground truncate">
{qr.owner}
</p>
</div>
</div>
<div className="text-right flex-shrink-0 ml-4">
<p className="text-lg font-bold">{qr.scans.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">scans</p>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No QR codes yet</p>
)}
</Card>
{/* Recent Users */}
<Card className="p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/20 rounded-lg flex items-center justify-center">
<Users className="w-5 h-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h3 className="font-semibold text-lg">Recent Users</h3>
<p className="text-xs text-muted-foreground">Latest signups</p>
</div>
</div>
{stats?.users.recent && stats.users.recent.length > 0 ? (
<div className="space-y-3">
{stats.users.recent.map((user, index) => (
<div
key={index}
className="flex items-center justify-between py-3 border-b border-border last:border-0"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-cyan-500 rounded-full flex items-center justify-center flex-shrink-0">
<span className="text-white text-xs font-bold">
{(user.name || user.email).charAt(0).toUpperCase()}
</span>
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">
{user.name || user.email}
</p>
<p className="text-xs text-muted-foreground truncate">
{new Date(user.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<Badge
className={
user.plan === 'FREE'
? 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300'
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
}
>
{user.plan === 'PRO' && <Crown className="w-3 h-3 mr-1" />}
{user.plan}
</Badge>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No users yet</p>
)}
</Card>
</div>
{/* Newsletter Management Section */}
<div className="mt-8">
<Card className="p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-gradient-to-br from-purple-100 to-pink-100 dark:from-purple-900/30 dark:to-pink-900/30 rounded-lg flex items-center justify-center">
<Users className="w-5 h-5 text-purple-600 dark:text-purple-400" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-lg">Newsletter Management</h3>
<p className="text-xs text-muted-foreground">Manage AI feature launch notifications</p>
</div>
<div className="text-right">
<span className="text-2xl font-bold">{newsletterData?.total || 0}</span>
<p className="text-xs text-muted-foreground">Total Subscribers</p>
</div>
<Badge className="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
Active
</Badge>
</div>
{/* Broadcast Section */}
<div className="p-4 bg-gray-50 dark:bg-gray-900/50 rounded-xl mb-6">
<div className="flex items-start gap-3 mb-3">
<Send className="w-5 h-5 text-purple-600 dark:text-purple-400 mt-0.5" />
<div>
<h4 className="font-medium">Broadcast AI Feature Launch</h4>
<p className="text-sm text-muted-foreground">
Send the AI feature launch announcement to all {newsletterData?.total || 0} subscribers.
{/* Broadcast Button */}
<div className="border-t pt-6">
<div className="mb-4">
<h3 className="font-semibold mb-2 flex items-center gap-2">
<Send className="w-4 h-4" />
Broadcast AI Feature Launch
</h3>
<p className="text-sm text-muted-foreground mb-4">
Send the AI feature launch announcement to all {info?.total} subscribers.
This will inform them that the features are now available.
</p>
</div>
</div>
{/* Resend Free Tier Warning */}
{(newsletterData?.total || 0) > 100 && (
<div className="p-3 rounded-lg mb-3 bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-200 flex items-start gap-2">
<Activity className="w-5 h-5 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<strong>Warning: Resend Free Limit</strong>
<p>You have more than 100 subscribers. The Resend Free Tier only allows 100 emails per day. Sending this broadcast might fail for some users or block your account.</p>
</div>
</div>
)}
{broadcastResult && (
<div className={`p-3 rounded-lg mb-3 flex items-center gap-2 ${broadcastResult.success
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'
}`}>
{broadcastResult.success && <CheckCircle2 className="w-4 h-4" />}
<span className="text-sm">{broadcastResult.message}</span>
</div>
)}
<Button
onClick={handleSendBroadcast}
disabled={sendingBroadcast || (newsletterData?.total || 0) === 0 || (newsletterData?.total || 0) > 100}
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white"
onClick={handleBroadcast}
disabled={broadcasting || !info?.total}
className="w-full sm:w-auto bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white"
>
{sendingBroadcast ? (
{broadcasting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Sending...
Sending Emails...
</>
) : (
<>
@@ -594,13 +285,49 @@ export default function AdminDashboard() {
)}
</Button>
</div>
</Card>
{/* Result Message */}
{result && (
<Card
className={`p-4 mb-6 ${
result.success
? 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-900'
: 'bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-900'
}`}
>
<div className="flex items-start gap-3">
{result.success ? (
<CheckCircle className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
) : (
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
)}
<div className="flex-1">
<p
className={`font-medium ${
result.success
? 'text-green-900 dark:text-green-100'
: 'text-red-900 dark:text-red-100'
}`}
>
{result.message}
</p>
{result.sent !== undefined && (
<p className="text-sm text-muted-foreground mt-1">
Sent: {result.sent} | Failed: {result.failed}
</p>
)}
</div>
</div>
</Card>
)}
{/* Recent Subscribers */}
<div>
<h4 className="font-medium mb-3">Recent Subscribers</h4>
{newsletterData?.recent && newsletterData.recent.length > 0 ? (
<div className="space-y-2">
{newsletterData.recent.map((subscriber, index) => (
<Card className="p-6">
<h3 className="font-semibold mb-4">Recent Subscribers</h3>
{info?.recent && info.recent.length > 0 ? (
<div className="space-y-3">
{info.recent.map((subscriber, index) => (
<div
key={index}
className="flex items-center justify-between py-2 border-b border-border last:border-0"
@@ -618,9 +345,7 @@ export default function AdminDashboard() {
) : (
<p className="text-sm text-muted-foreground">No subscribers yet</p>
)}
</div>
{/* Tip */}
<div className="mt-4 pt-4 border-t">
<p className="text-xs text-muted-foreground">
💡 Tip: View all subscribers in{' '}
@@ -628,7 +353,7 @@ export default function AdminDashboard() {
href="http://localhost:5555"
target="_blank"
rel="noopener noreferrer"
className="text-purple-600 dark:text-purple-400 hover:underline"
className="text-primary hover:underline"
>
Prisma Studio
</a>
@@ -638,6 +363,5 @@ export default function AdminDashboard() {
</Card>
</div>
</div>
</div>
);
}

View File

@@ -1,218 +0,0 @@
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

@@ -149,7 +149,7 @@ export async function GET(request: NextRequest) {
? Math.round((previousUniqueScans / previousTotalScans) * 100)
: 0;
const avgScansTrend = calculateTrend(avgScansPerQR, previousAvgScansPerQR);
const avgScansTrend = calculateTrend(currentConversion, previousConversion);
// Device stats
const deviceStats = qrCodes.flatMap(qr => qr.scans)
@@ -245,7 +245,7 @@ export async function GET(request: NextRequest) {
summary: {
totalScans,
uniqueScans,
avgScansPerQR,
avgScansPerQR: currentConversion, // Now sending Unique Rate instead of Avg per QR
mobilePercentage,
topCountry: topCountry ? topCountry[0] : 'N/A',
topCountryPercentage: topCountry && totalScans > 0

View File

@@ -0,0 +1,144 @@
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,8 +14,15 @@ export async function GET(
where: { slug },
select: {
id: true,
title: true,
content: true,
contentType: true,
user: {
select: {
name: true,
subdomain: true,
}
}
},
});
@@ -81,8 +88,94 @@ export async function GET(
destination = `${destination}${separator}${preservedParams.toString()}`;
}
// Return 307 redirect (temporary redirect that preserves method)
return NextResponse.redirect(destination, { status: 307 });
// Construct metadata
const siteName = qrCode.user?.subdomain
? `${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) {
console.error('QR redirect error:', error);
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">
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">
Learn more about our privacy policy
Learn more
</Link>
</p>

View File

@@ -81,37 +81,37 @@ const countryNameToCode: Record<string, string> = {
};
// ISO Alpha-2 to ISO Alpha-3 mapping (for matching with TopoJSON)
const alpha2ToNumeric: Record<string, string> = {
'US': '840',
'DE': '276',
'GB': '826',
'FR': '250',
'CA': '124',
'AU': '036',
'JP': '392',
'CN': '156',
'IN': '356',
'BR': '076',
'ES': '724',
'IT': '380',
'NL': '528',
'CH': '756',
'AT': '040',
'PL': '616',
'SE': '752',
'NO': '578',
'DK': '208',
'FI': '246',
'BE': '056',
'PT': '620',
'IE': '372',
'MX': '484',
'AR': '032',
'KR': '410',
'SG': '702',
'NZ': '554',
'RU': '643',
'ZA': '710',
const alpha2ToAlpha3: Record<string, string> = {
'US': 'USA',
'DE': 'DEU',
'GB': 'GBR',
'FR': 'FRA',
'CA': 'CAN',
'AU': 'AUS',
'JP': 'JPN',
'CN': 'CHN',
'IN': 'IND',
'BR': 'BRA',
'ES': 'ESP',
'IT': 'ITA',
'NL': 'NLD',
'CH': 'CHE',
'AT': 'AUT',
'PL': 'POL',
'SE': 'SWE',
'NO': 'NOR',
'DK': 'DNK',
'FI': 'FIN',
'BE': 'BEL',
'PT': 'PRT',
'IE': 'IRL',
'MX': 'MEX',
'AR': 'ARG',
'KR': 'KOR',
'SG': 'SGP',
'NZ': 'NZL',
'RU': 'RUS',
'ZA': 'ZAF',
};
interface CountryStat {
@@ -132,9 +132,9 @@ const GeoMap: React.FC<GeoMapProps> = ({ countryStats, totalScans }) => {
countryStats.forEach((stat) => {
const alpha2 = countryNameToCode[stat.country] || stat.country;
const numericCode = alpha2ToNumeric[alpha2];
if (numericCode) {
countryData[numericCode] = stat.count;
const alpha3 = alpha2ToAlpha3[alpha2];
if (alpha3) {
countryData[alpha3] = stat.count;
if (stat.count > maxCount) maxCount = stat.count;
}
});
@@ -144,16 +144,8 @@ const GeoMap: React.FC<GeoMapProps> = ({ countryStats, totalScans }) => {
.domain([0, maxCount || 1])
.range(['#E0F2FE', '#1E40AF']);
const [tooltipContent, setTooltipContent] = React.useState<{ name: string; count: number } | null>(null);
const [tooltipPos, setTooltipPos] = React.useState({ x: 0, y: 0 });
return (
<div
className="w-full h-full relative group"
onMouseMove={(evt) => {
setTooltipPos({ x: evt.clientX, y: evt.clientY });
}}
>
<div className="w-full h-full">
<ComposableMap
projection="geoMercator"
projectionConfig={{
@@ -166,9 +158,8 @@ const GeoMap: React.FC<GeoMapProps> = ({ countryStats, totalScans }) => {
<Geographies geography={geoUrl}>
{({ geographies }) =>
geographies.map((geo) => {
// geo.id is the numeric ISO code as a string (e.g., "840" for US)
const geoId = geo.id;
const scanCount = countryData[geoId] || 0;
const isoCode = geo.properties.ISO_A3 || geo.id;
const scanCount = countryData[isoCode] || 0;
const fillColor = scanCount > 0 ? colorScale(scanCount) : '#F1F5F9';
return (
@@ -187,13 +178,6 @@ const GeoMap: React.FC<GeoMapProps> = ({ countryStats, totalScans }) => {
},
pressed: { outline: 'none' },
}}
onMouseEnter={() => {
const { name } = geo.properties;
setTooltipContent({ name, count: scanCount });
}}
onMouseLeave={() => {
setTooltipContent(null);
}}
/>
);
})
@@ -201,24 +185,6 @@ const GeoMap: React.FC<GeoMapProps> = ({ countryStats, totalScans }) => {
</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>
);
};

View File

@@ -21,17 +21,25 @@ interface QRCodeCardProps {
};
onEdit: (id: string) => void;
onDelete: (id: string) => void;
userSubdomain?: string | null;
}
export const QRCodeCard: React.FC<QRCodeCardProps> = ({
qr,
onEdit,
onDelete,
userSubdomain,
}) => {
// For dynamic QR codes, use the redirect URL for tracking
// 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');
// White label: use subdomain URL if available
const mainDomain = process.env.NEXT_PUBLIC_MAIN_DOMAIN || 'qrmaster.net';
const brandedBaseUrl = userSubdomain
? `https://${userSubdomain}.${mainDomain}`
: baseUrl;
// Get the QR URL based on type
let qrUrl = '';
@@ -65,15 +73,17 @@ END:VCARD`;
qrUrl = qr.content.qrContent;
} else {
// Last resort fallback
qrUrl = `${baseUrl}/r/${qr.slug}`;
qrUrl = `${brandedBaseUrl}/r/${qr.slug}`;
}
console.log(`STATIC QR [${qr.title}]: ${qrUrl}`);
} else {
// DYNAMIC QR codes always use redirect for tracking
qrUrl = `${baseUrl}/r/${qr.slug}`;
console.log(`DYNAMIC QR [${qr.title}]: ${qrUrl}`);
// DYNAMIC QR codes use branded URL for white label
qrUrl = `${brandedBaseUrl}/r/${qr.slug}`;
}
// Display URL (same as qrUrl for consistency)
const displayUrl = qrUrl;
const downloadQR = (format: 'png' | 'svg') => {
const svg = document.querySelector(`#qr-${qr.id} svg`);
if (!svg) return;
@@ -196,17 +206,13 @@ END:VCARD`;
<div id={`qr-${qr.id}`} className="flex items-center justify-center bg-gray-50 rounded-lg p-4 mb-3">
<div className={qr.style?.cornerStyle === 'rounded' ? 'rounded-lg overflow-hidden' : ''}>
<QRCodeSVG
key={qrUrl}
value={qrUrl}
size={96}
fgColor={qr.style?.foregroundColor || '#000000'}
bgColor={qr.style?.backgroundColor || '#FFFFFF'}
level="H"
imageSettings={qr.style?.imageSettings ? {
src: qr.style.imageSettings.src,
height: qr.style.imageSettings.height * (96 / 200), // Scale logo for smaller QR
width: qr.style.imageSettings.width * (96 / 200),
excavate: qr.style.imageSettings.excavate,
} : undefined}
level={qr.style?.imageSettings ? 'H' : 'M'}
imageSettings={qr.style?.imageSettings}
/>
</div>
</div>
@@ -222,6 +228,11 @@ END:VCARD`;
<span className="text-gray-900">{qr.scans || 0}</span>
</div>
)}
{qr.type === 'DYNAMIC' && (
<div className="text-xs text-gray-400 break-all bg-gray-50 p-1 rounded border border-gray-100 mt-2">
{qrUrl}
</div>
)}
<div className="flex items-center justify-between">
<span className="text-gray-500">Created:</span>
<span className="text-gray-900">{formatDate(qr.createdAt)}</span>
@@ -229,7 +240,7 @@ END:VCARD`;
{qr.type === 'DYNAMIC' && (
<div className="pt-2 border-t">
<p className="text-xs text-gray-500">
📊 Dynamic QR: Tracks scans via {baseUrl}/r/{qr.slug}
📊 Dynamic QR: Tracks scans via {displayUrl}
</p>
</div>
)}

View File

@@ -3,7 +3,6 @@
import React, { useState } from 'react';
import { Sparkles, Brain, TrendingUp, MessageSquare, Palette, ArrowRight, Mail, CheckCircle2, Lock } from 'lucide-react';
import Link from 'next/link';
import { motion } from 'framer-motion';
const AIComingSoonBanner = () => {
const [email, setEmail] = useState('');
@@ -84,7 +83,7 @@ const AIComingSoonBanner = () => {
];
return (
<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">
<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">
{/* Animated Background Orbs (matching Hero) */}
<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" />
@@ -94,15 +93,8 @@ const AIComingSoonBanner = () => {
<div className="max-w-6xl mx-auto relative z-10">
{/* Header */}
{/* Header */}
<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">
<div 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">
<Sparkles className="w-4 h-4 text-blue-600" />
<span className="text-sm font-medium text-blue-700">
Coming Soon
@@ -119,18 +111,14 @@ const AIComingSoonBanner = () => {
<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
</p>
</motion.div>
</div>
{/* Features Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
{features.map((feature, index) => (
<motion.div
<div
key={index}
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"
className="bg-white/80 backdrop-blur rounded-xl p-6 border border-gray-100 hover:shadow-lg transition-all"
>
<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" />
@@ -148,18 +136,12 @@ const AIComingSoonBanner = () => {
</li>
))}
</ul>
</motion.div>
</div>
))}
</div>
{/* Email Capture */}
<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"
>
<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">
{!submitted ? (
<>
<form onSubmit={handleSubmit} className="flex flex-col sm:flex-row gap-3 mb-3">
@@ -181,12 +163,12 @@ const AIComingSoonBanner = () => {
<button
type="submit"
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 group"
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"
>
{loading ? 'Subscribing...' : (
<>
Notify Me
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
<ArrowRight className="w-4 h-4" />
</>
)}
</button>
@@ -206,9 +188,8 @@ const AIComingSoonBanner = () => {
</span>
</div>
)}
</motion.div>
</div>
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-b from-transparent to-white pointer-events-none" />
</div>
</section>
);
};

View File

@@ -1,7 +1,6 @@
'use client';
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Card } from '@/components/ui/Card';
interface FAQProps {
@@ -22,35 +21,22 @@ export const FAQ: React.FC<FAQProps> = ({ t }) => {
return (
<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">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="text-center mb-12"
>
<div className="text-center mb-12">
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
{t.faq.title}
</h2>
</motion.div>
</div>
<div className="max-w-3xl mx-auto space-y-4">
{questions.map((key, index) => (
<motion.div
key={key}
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
<Card className="cursor-pointer border-gray-200 hover:border-gray-300 transition-colors" onClick={() => setOpenIndex(openIndex === index ? null : index)}>
<Card key={key} className="cursor-pointer" onClick={() => setOpenIndex(openIndex === index ? null : index)}>
<div className="p-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">
{t.faq.questions[key].question}
</h3>
<svg
className={`w-5 h-5 text-gray-500 transition-transform duration-300 ${openIndex === index ? 'rotate-180' : ''}`}
className={`w-5 h-5 text-gray-500 transition-transform ${openIndex === index ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -60,24 +46,13 @@ export const FAQ: React.FC<FAQProps> = ({ t }) => {
</svg>
</div>
<AnimatePresence>
{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">
<div className="mt-4 text-gray-600">
{t.faq.questions[key].answer}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</Card>
</motion.div>
))}
</div>

View File

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

View File

@@ -5,8 +5,6 @@ import Link from 'next/link';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Card } from '@/components/ui/Card';
import { motion } from 'framer-motion';
import { Globe, User, MapPin, Phone, CheckCircle2, ArrowRight } from 'lucide-react';
interface HeroProps {
t: any; // i18n translation function
@@ -14,27 +12,12 @@ interface HeroProps {
export const Hero: React.FC<HeroProps> = ({ t }) => {
const templateCards = [
{ title: 'URL/Website', color: 'bg-blue-500/10 text-blue-600', icon: Globe },
{ title: 'Contact Card', color: 'bg-purple-500/10 text-purple-600', icon: User },
{ title: 'Location', color: 'bg-green-500/10 text-green-600', icon: MapPin },
{ title: 'Phone Number', color: 'bg-pink-500/10 text-pink-600', icon: Phone },
{ title: 'URL/Website', color: 'bg-blue-100', icon: '🌐' },
{ title: 'Contact Card', color: 'bg-purple-100', icon: '👤' },
{ title: 'Location', color: 'bg-green-100', icon: '📍' },
{ title: 'Phone Number', color: 'bg-pink-100', icon: '📞' },
];
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 (
<section className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-white to-purple-50 pt-12 pb-20">
{/* Animated Background Orbs */}
@@ -60,96 +43,64 @@ export const Hero: React.FC<HeroProps> = ({ t }) => {
<span>{t.hero.badge}</span>
</Badge>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="space-y-6"
>
<div className="space-y-6">
<h1 className="text-5xl lg:text-6xl font-bold text-gray-900 leading-tight">
{t.hero.title}
</h1>
<p className="text-xl text-gray-600 leading-relaxed max-w-2xl">
<p className="text-xl text-gray-600 leading-relaxed">
{t.hero.subtitle}
</p>
<div className="space-y-3 pt-2">
<div className="space-y-3">
{t.hero.features.map((feature: string, index: number) => (
<motion.div
key={index}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
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 key={index} className="flex items-center space-x-3">
<div className="flex-shrink-0 w-5 h-5 bg-success-500 rounded-full flex items-center justify-center">
<svg className="w-3 h-3 text-white" 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" />
</svg>
</div>
<span className="text-gray-700">{feature}</span>
</div>
<span className="text-gray-700 font-medium">{feature}</span>
</motion.div>
))}
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="flex flex-col sm:flex-row gap-4 pt-4"
>
<div className="flex flex-col sm:flex-row gap-4">
<Link href="/signup">
<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">
<Button size="lg" className="text-lg px-8 py-4 w-full sm:w-auto">
{t.hero.cta_primary}
</Button>
</Link>
<Link href="/#pricing">
<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">
<Button variant="outline" size="lg" className="text-lg px-8 py-4 w-full sm:w-auto">
{t.hero.cta_secondary}
</Button>
</Link>
</motion.div>
</div>
</div>
</div>
{/* Right Preview Widget */}
<div className="relative">
<motion.div
variants={containerjs}
initial="hidden"
animate="show"
className="grid grid-cols-2 gap-4"
>
<div className="grid grid-cols-2 gap-4">
{templateCards.map((card, index) => (
<motion.div key={index} variants={itemjs}>
<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`}>
<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.icon className="w-6 h-6" />
</div>
<p className="font-semibold text-gray-800 group-hover:text-gray-900">{card.title}</p>
<Card key={index} className={`${card.color} border-0 p-6 text-center hover:scale-105 transition-transform`}>
<div className="text-3xl mb-2">{card.icon}</div>
<p className="font-semibold text-gray-800">{card.title}</p>
</Card>
</motion.div>
))}
</motion.div>
</div>
{/* Floating Badge */}
<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>
<div className="absolute -top-4 -right-4 bg-success-500 text-white px-4 py-2 rounded-full text-sm font-semibold shadow-lg">
{t.hero.engagement_badge}
</motion.div>
</div>
</div>
</div>
</div>
{/* 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" />
</section >
</section>
);
};

View File

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

View File

@@ -2,7 +2,6 @@
import React, { useState } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { motion } from 'framer-motion';
import { Card } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
@@ -74,39 +73,22 @@ export const InstantGenerator: React.FC<InstantGeneratorProps> = ({ t }) => {
};
return (
<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)' }}
/>
<section className="py-16 bg-gray-50">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="text-center mb-12"
>
<div className="text-center mb-12">
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
{t.generator.title}
</h2>
</motion.div>
</div>
<div className="grid lg:grid-cols-2 gap-12 max-w-6xl mx-auto">
{/* Left Form */}
<motion.div
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<Card className="space-y-6 shadow-xl shadow-gray-200/50 border-gray-100">
<Card className="space-y-6">
<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">
@@ -120,7 +102,7 @@ export const InstantGenerator: React.FC<InstantGeneratorProps> = ({ t }) => {
type="color"
value={foregroundColor}
onChange={(e) => setForegroundColor(e.target.value)}
className="w-14 h-12 rounded border border-gray-300 cursor-pointer"
className="w-12 h-10 rounded border border-gray-300"
aria-label="Foreground color picker"
/>
<Input
@@ -143,7 +125,7 @@ export const InstantGenerator: React.FC<InstantGeneratorProps> = ({ t }) => {
type="color"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="w-14 h-12 rounded border border-gray-300 cursor-pointer"
className="w-12 h-10 rounded border border-gray-300"
aria-label="Background color picker"
/>
<Input
@@ -184,7 +166,7 @@ export const InstantGenerator: React.FC<InstantGeneratorProps> = ({ t }) => {
max="400"
value={size}
onChange={(e) => setSize(Number(e.target.value))}
className="w-full accent-primary-600"
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>
@@ -201,60 +183,29 @@ export const InstantGenerator: React.FC<InstantGeneratorProps> = ({ t }) => {
</div>
<div className="flex space-x-3">
<Button variant="outline" className="flex-1 hover:bg-gray-50" onClick={() => downloadQR('svg')}>
<Button variant="outline" className="flex-1" onClick={() => downloadQR('svg')}>
{t.generator.download_svg}
</Button>
<Button variant="outline" className="flex-1 hover:bg-gray-50" onClick={() => downloadQR('png')}>
<Button variant="outline" className="flex-1" onClick={() => downloadQR('png')}>
{t.generator.download_png}
</Button>
</div>
<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'}>
<Button className="w-full" onClick={() => window.location.href = '/login'}>
{t.generator.save_track}
</Button>
</Card>
</motion.div>
{/* Right Preview */}
<motion.div
initial={{ opacity: 0, x: 20 }}
whileInView={{ opacity: 1, x: 0 }}
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">
<div className="flex flex-col items-center justify-center">
<Card className="text-center p-8">
<h3 className="text-lg font-semibold mb-4">{t.generator.live_preview}</h3>
<div id="instant-qr-preview" className="flex justify-center mb-4">
{url ? (
<div className={`${cornerStyle === 'rounded' ? 'rounded-lg overflow-hidden' : ''} p-4 bg-white shadow-lg rounded-xl`}>
<div className={`${cornerStyle === 'rounded' ? 'rounded-lg overflow-hidden' : ''}`}>
<QRCodeSVG
value={url}
size={size}
size={Math.min(size, 200)}
fgColor={foregroundColor}
bgColor={backgroundColor}
level="M"
@@ -262,19 +213,17 @@ export const InstantGenerator: React.FC<InstantGeneratorProps> = ({ t }) => {
</div>
) : (
<div
className="bg-gray-100 rounded-xl flex items-center justify-center text-gray-500 animate-pulse"
className="bg-gray-200 flex items-center justify-center text-gray-500"
style={{ width: 200, height: 200 }}
>
Enter URL
</div>
)}
</div>
<div className="text-sm font-medium text-gray-600 mb-2 bg-gray-50 py-2 px-4 rounded-full inline-block">
{url || 'https://example.com'}
<div className="text-sm text-gray-600 mb-2">URL</div>
<div className="text-xs text-gray-500">{t.generator.demo_note}</div>
</Card>
</div>
<div className="text-xs text-gray-400 mt-2">{t.generator.demo_note}</div>
</div>
</motion.div>
</div>
</div>
</section>

View File

@@ -1,7 +1,6 @@
'use client';
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
@@ -33,44 +32,28 @@ export const Pricing: React.FC<PricingProps> = ({ t }) => {
return (
<section id="pricing" className="py-16">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="text-center mb-12"
>
<div className="text-center mb-12">
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
{t.pricing.title}
</h2>
<p className="text-xl text-gray-600">
{t.pricing.subtitle}
</p>
</motion.div>
</div>
<div className="flex justify-center mb-8">
<BillingToggle value={billingPeriod} onChange={setBillingPeriod} />
</div>
<div className="grid md:grid-cols-3 gap-8 max-w-5xl mx-auto">
{plans.map((plan, index) => (
<motion.div
key={plan.key}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
className="h-full"
>
{plans.map((plan) => (
<Card
className={`h-full flex flex-col ${plan.popular
? 'border-primary-500 shadow-xl relative scale-105 z-10'
: 'border-gray-200 hover:border-gray-300 hover:shadow-lg transition-all'
}`}
key={plan.key}
className={plan.popular ? 'border-primary-500 shadow-xl relative' : ''}
>
{plan.popular && (
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2 w-full text-center">
<Badge variant="info" className="px-4 py-1.5 shadow-sm">
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
<Badge variant="info" className="px-3 py-1">
{t.pricing[plan.key].badge}
</Badge>
</div>
@@ -107,8 +90,8 @@ export const Pricing: React.FC<PricingProps> = ({ t }) => {
</div>
</CardHeader>
<CardContent className="space-y-8 flex-1 flex flex-col">
<ul className="space-y-3 flex-1">
<CardContent className="space-y-4">
<ul className="space-y-3">
{t.pricing[plan.key].features.map((feature: string, index: number) => (
<li key={index} className="flex items-start space-x-3">
<svg className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
@@ -119,7 +102,6 @@ export const Pricing: React.FC<PricingProps> = ({ t }) => {
))}
</ul>
<div className="mt-8 pt-8 border-t border-gray-100">
<Link href="/signup">
<Button
variant={plan.popular ? 'primary' : 'outline'}
@@ -129,10 +111,8 @@ export const Pricing: React.FC<PricingProps> = ({ t }) => {
Get Started
</Button>
</Link>
</div>
</CardContent>
</Card>
</motion.div>
))}
</div>
</div>

View File

@@ -1,8 +1,6 @@
'use client';
import React from 'react';
import { motion } from 'framer-motion';
import { CheckCircle2 } from 'lucide-react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
@@ -14,83 +12,56 @@ export const StaticVsDynamic: React.FC<StaticVsDynamicProps> = ({ t }) => {
return (
<section className="py-16">
<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">
{/* Static QR Codes */}
<motion.div
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<Card className="relative h-full border-gray-200 shadow-sm hover:shadow-lg transition-all duration-300">
<Card className="relative">
<CardHeader>
<div className="flex items-center justify-between mb-2">
<CardTitle className="text-2xl font-bold text-gray-700">{t.static_vs_dynamic.static.title}</CardTitle>
<Badge variant="success" className="bg-gray-100 text-gray-700 hover:bg-gray-200">{t.static_vs_dynamic.static.subtitle}</Badge>
<div className="flex items-center justify-between">
<CardTitle className="text-2xl">{t.static_vs_dynamic.static.title}</CardTitle>
<Badge variant="success">{t.static_vs_dynamic.static.subtitle}</Badge>
</div>
<p className="text-gray-500">{t.static_vs_dynamic.static.description}</p>
<p className="text-gray-600">{t.static_vs_dynamic.static.description}</p>
</CardHeader>
<CardContent>
<ul className="space-y-4">
<ul className="space-y-3">
{t.static_vs_dynamic.static.features.map((feature: string, index: number) => (
<li key={index} className="flex items-start space-x-3">
<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" />
<li key={index} className="flex items-center space-x-3">
<div className="flex-shrink-0 w-5 h-5 bg-gray-400 rounded-full flex items-center justify-center">
<svg className="w-3 h-3 text-white" 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" />
</svg>
</div>
<span className="text-gray-600">{feature}</span>
<span className="text-gray-700">{feature}</span>
</li>
))}
</ul>
</CardContent>
</Card>
</motion.div>
{/* Dynamic QR Codes */}
<motion.div
initial={{ opacity: 0, x: 20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
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>
<Card className="relative border-primary-200 bg-primary-50">
<CardHeader>
<div className="flex items-center justify-between mb-2">
<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>
<Badge variant="info" className="bg-primary-100 text-primary-700">{t.static_vs_dynamic.dynamic.subtitle}</Badge>
<div className="flex items-center justify-between">
<CardTitle className="text-2xl">{t.static_vs_dynamic.dynamic.title}</CardTitle>
<Badge variant="info">{t.static_vs_dynamic.dynamic.subtitle}</Badge>
</div>
<p className="text-gray-600 font-medium">{t.static_vs_dynamic.dynamic.description}</p>
<p className="text-gray-600">{t.static_vs_dynamic.dynamic.description}</p>
</CardHeader>
<CardContent>
<ul className="space-y-4">
<ul className="space-y-3">
{t.static_vs_dynamic.dynamic.features.map((feature: string, index: number) => (
<li key={index} className="flex items-start space-x-3">
<div className="flex-shrink-0 w-6 h-6 bg-primary-100 rounded-full flex items-center justify-center mt-0.5">
<CheckCircle2 className="w-4 h-4 text-primary-600" />
<li key={index} className="flex items-center space-x-3">
<div className="flex-shrink-0 w-5 h-5 bg-primary-500 rounded-full flex items-center justify-center">
<svg className="w-3 h-3 text-white" 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" />
</svg>
</div>
<span className="text-gray-900 font-medium">{feature}</span>
<span className="text-gray-700">{feature}</span>
</li>
))}
</ul>
</CardContent>
</Card>
</motion.div>
</div>
</div>
</section>

View File

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

View File

@@ -26,7 +26,7 @@ export function Footer({ variant = 'marketing' }: FooterProps) {
<ul className={`space-y-2 ${isDashboard ? 'text-gray-500' : 'text-gray-400'}`}>
<li><Link href="/#features" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Features</Link></li>
<li><Link href="/#pricing" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Pricing</Link></li>
<li><Link href="/faq" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>FAQ</Link></li>
<li><Link href="/#faq" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>FAQ</Link></li>
<li><Link href="/blog" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Blog</Link></li>
</ul>
</div>
@@ -34,9 +34,9 @@ export function Footer({ variant = 'marketing' }: FooterProps) {
<div>
<h3 className={`font-semibold mb-4 ${isDashboard ? 'text-gray-900' : ''}`}>Resources</h3>
<ul className={`space-y-2 ${isDashboard ? 'text-gray-500' : 'text-gray-400'}`}>
<li><Link href="/#pricing" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Full Pricing</Link></li>
<li><Link href="/pricing" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Full Pricing</Link></li>
<li><Link href="/faq" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>All Questions</Link></li>
<li><Link href="/blog" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>All Articles</Link></li>
<li><Link href="/blog" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Blog</Link></li>
<li><Link href="/signup" className={isDashboard ? 'hover:text-primary-600' : 'hover:text-white'}>Get Started</Link></li>
</ul>
</div>
@@ -56,7 +56,6 @@ export function Footer({ variant = 'marketing' }: FooterProps) {
href="/newsletter"
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>
) : (

View File

@@ -32,7 +32,7 @@ export function ScrollToTop() {
{isVisible && (
<button
onClick={scrollToTop}
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"
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"
aria-label="Scroll to top"
>
<svg

View File

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

View File

@@ -24,6 +24,28 @@ export function middleware(req: NextRequest) {
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)
if (path.startsWith('/r/')) {
return NextResponse.next();