1 Commits

23 changed files with 13372 additions and 13494 deletions

View File

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

View File

@@ -33,9 +33,9 @@ export default function CreatePage() {
const [cornerStyle, setCornerStyle] = useState('square'); const [cornerStyle, setCornerStyle] = useState('square');
const [size, setSize] = useState(200); const [size, setSize] = useState(200);
// Logo state // Logo state (PRO feature)
const [logoUrl, setLogoUrl] = useState(''); const [logo, setLogo] = useState<string>('');
const [logoSize, setLogoSize] = useState(24); const [logoSize, setLogoSize] = useState(40);
const [excavate, setExcavate] = useState(true); const [excavate, setExcavate] = useState(true);
// QR preview // 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
@@ -214,12 +172,15 @@ export default function CreatePage() {
backgroundColor: canCustomizeColors ? backgroundColor : '#FFFFFF', backgroundColor: canCustomizeColors ? backgroundColor : '#FFFFFF',
cornerStyle, cornerStyle,
size, size,
imageSettings: (canCustomizeColors && logoUrl) ? { // Logo embedding (PRO only)
src: logoUrl, ...(logo && canCustomizeColors ? {
height: logoSize, imageSettings: {
width: logoSize, src: logo,
excavate, height: logoSize,
} : undefined, width: logoSize,
excavate: excavate,
}
} : {}),
}, },
}; };
@@ -542,21 +503,19 @@ export default function CreatePage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Logo Section */} {/* Logo/Icon Section (PRO Feature) */}
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle>Logo</CardTitle> <CardTitle>Logo / Icon</CardTitle>
{!canCustomizeColors && ( <Badge variant="info">PRO</Badge>
<Badge variant="warning">PRO Feature</Badge>
)}
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{!canCustomizeColors && ( {!canCustomizeColors ? (
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg mb-4"> <div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-900"> <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> </p>
<Link href="/pricing"> <Link href="/pricing">
<Button variant="primary" size="sm" className="mt-2"> <Button variant="primary" size="sm" className="mt-2">
@@ -564,63 +523,70 @@ export default function CreatePage() {
</Button> </Button>
</Link> </Link>
</div> </div>
)} ) : (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Upload Logo
</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"
/>
{logoUrl && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setLogoUrl('');
setLogoSize(40);
}}
>
Remove
</Button>
)}
</div>
</div>
{logoUrl && (
<> <>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Logo Size: {logoSize}px Upload Logo (PNG, JPG)
</label> </label>
<input <input
type="range" type="file"
min="20" accept="image/png,image/jpeg,image/jpg"
max="70" onChange={(e) => {
value={logoSize} const file = e.target.files?.[0];
onChange={(e) => setLogoSize(Number(e.target.value))} if (file) {
className="w-full" 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"
/> />
</div> </div>
<div className="flex items-center"> {logo && (
<input <>
type="checkbox" <div className="flex items-center gap-4">
checked={excavate} <img src={logo} alt="Logo preview" className="w-12 h-12 object-contain rounded border" />
onChange={(e) => setExcavate(e.target.checked)} <Button
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" variant="outline"
id="excavate-checkbox" size="sm"
/> onClick={() => setLogo('')}
<label htmlFor="excavate-checkbox" className="ml-2 block text-sm text-gray-900"> >
Excavate background (remove dots behind logo) Remove
</label> </Button>
</div> </div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Logo Size: {logoSize}px
</label>
<input
type="range"
min="24"
max="80"
value={logoSize}
onChange={(e) => setLogoSize(Number(e.target.value))}
className="w-full"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="excavate"
checked={excavate}
onChange={(e) => setExcavate(e.target.checked)}
className="w-4 h-4 rounded border-gray-300"
/>
<label htmlFor="excavate" className="text-sm text-gray-700">
Clear background behind logo (recommended)
</label>
</div>
</>
)}
</> </>
)} )}
</CardContent> </CardContent>
@@ -642,9 +608,9 @@ export default function CreatePage() {
size={200} size={200}
fgColor={foregroundColor} fgColor={foregroundColor}
bgColor={backgroundColor} bgColor={backgroundColor}
level="H" level={logo && canCustomizeColors ? 'H' : 'M'}
imageSettings={logoUrl ? { imageSettings={logo && canCustomizeColors ? {
src: logoUrl, src: logo,
height: logoSize, height: logoSize,
width: logoSize, width: logoSize,
excavate: excavate, excavate: excavate,

View File

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

View File

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

View File

@@ -5,60 +5,19 @@ import { useRouter } from 'next/navigation';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { import { Mail, Users, Send, CheckCircle, AlertCircle, Loader2, Lock, LogOut } from 'lucide-react';
Mail,
Users,
QrCode,
BarChart3,
TrendingUp,
Crown,
Activity,
Loader2,
Lock,
LogOut,
Zap,
Send,
CheckCircle2,
} from 'lucide-react';
interface AdminStats { interface Subscriber {
users: { email: string;
total: number; createdAt: string;
premium: number;
newThisWeek: number;
newThisMonth: number;
recent: Array<{
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 router = useRouter();
const [isAuthenticated, setIsAuthenticated] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isAuthenticating, setIsAuthenticating] = useState(true); const [isAuthenticating, setIsAuthenticating] = useState(true);
@@ -66,18 +25,14 @@ export default function AdminDashboard() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [stats, setStats] = useState<AdminStats | null>(null); const [info, setInfo] = useState<BroadcastInfo | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [broadcasting, setBroadcasting] = useState(false);
// Newsletter management state const [result, setResult] = useState<{
const [newsletterData, setNewsletterData] = useState<{
total: number;
recent: Array<{ email: string; createdAt: string }>;
} | null>(null);
const [sendingBroadcast, setSendingBroadcast] = useState(false);
const [broadcastResult, setBroadcastResult] = useState<{
success: boolean; success: boolean;
message: string; message: string;
sent?: number;
failed?: number;
} | null>(null); } | null>(null);
useEffect(() => { useEffect(() => {
@@ -86,14 +41,12 @@ export default function AdminDashboard() {
const checkAuth = async () => { const checkAuth = async () => {
try { try {
const response = await fetch('/api/admin/stats'); const response = await fetch('/api/newsletter/broadcast');
if (response.ok) { if (response.ok) {
setIsAuthenticated(true); setIsAuthenticated(true);
const data = await response.json(); const data = await response.json();
setStats(data); setInfo(data);
setLoading(false); setLoading(false);
// Also fetch newsletter data
fetchNewsletterData();
} else { } else {
setIsAuthenticated(false); 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) => { const handleLogin = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setLoginError(''); setLoginError('');
@@ -185,6 +90,53 @@ export default function AdminDashboard() {
router.push('/'); 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 // Login Screen
if (!isAuthenticated) { if (!isAuthenticated) {
return ( 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"> <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" /> <Lock className="w-8 h-8 text-purple-600 dark:text-purple-400" />
</div> </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"> <p className="text-muted-foreground text-sm">
Sign in to access admin panel Sign in to manage subscribers
</p> </p>
</div> </div>
@@ -207,7 +159,7 @@ export default function AdminDashboard() {
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="admin@example.com" placeholder="Email"
required 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" 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" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••" placeholder="Password"
required 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" 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 // Admin Dashboard
return ( 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="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"> <div className="container mx-auto px-4 py-8 max-w-4xl">
{/* Header */}
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-8">
<div> <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"> <p className="text-muted-foreground">
Platform overview and statistics Manage AI feature launch notifications
</p> </p>
</div> </div>
<Button <Button
@@ -286,357 +237,130 @@ export default function AdminDashboard() {
</Button> </Button>
</div> </div>
{/* Main Stats Grid */} {/* Stats Card */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8"> <Card className="p-6 mb-6">
{/* All Time Users */} <div className="flex items-center justify-between mb-6">
<Card className="p-6 hover:shadow-lg transition-shadow"> <div className="flex items-center gap-3">
<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">
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900/20 rounded-lg flex items-center justify-center"> <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" /> <Users 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" />
</div> </div>
<div> <div>
<h3 className="text-2xl font-bold"> <h2 className="text-2xl font-bold">{info?.total || 0}</h2>
{stats?.scans.total.toLocaleString() || 0} <p className="text-sm text-muted-foreground">Total Subscribers</p>
</h3>
<p className="text-sm text-muted-foreground">Total All Scans</p>
</div> </div>
</div> </div>
</Card> <Badge className="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
Active
</Badge>
</div>
{/* Total QR Codes */} {/* Broadcast Button */}
<Card className="p-6"> <div className="border-t pt-6">
<div className="flex items-center gap-4"> <div className="mb-4">
<div className="w-12 h-12 bg-pink-100 dark:bg-pink-900/20 rounded-lg flex items-center justify-center"> <h3 className="font-semibold mb-2 flex items-center gap-2">
<QrCode className="w-6 h-6 text-pink-600 dark:text-pink-400" /> <Send className="w-4 h-4" />
</div> Broadcast AI Feature Launch
<div> </h3>
<h3 className="text-2xl font-bold">{stats?.qrCodes.total || 0}</h3> <p className="text-sm text-muted-foreground mb-4">
<p className="text-sm text-muted-foreground">Total QR Codes</p> Send the AI feature launch announcement to all {info?.total} subscribers.
</div> This will inform them that the features are now available.
</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.
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"
>
{sendingBroadcast ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Sending...
</>
) : (
<>
<Mail className="w-4 h-4 mr-2" />
Send Launch Notification to All
</>
)}
</Button>
</div>
{/* 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) => (
<div
key={index}
className="flex items-center justify-between py-2 border-b border-border last:border-0"
>
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-muted-foreground" />
<span className="text-sm">{subscriber.email}</span>
</div>
<span className="text-xs text-muted-foreground">
{new Date(subscriber.createdAt).toLocaleDateString()}
</span>
</div>
))}
</div>
) : (
<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{' '}
<a
href="http://localhost:5555"
target="_blank"
rel="noopener noreferrer"
className="text-purple-600 dark:text-purple-400 hover:underline"
>
Prisma Studio
</a>
{' '}(NewsletterSubscription table)
</p> </p>
</div> </div>
<Button
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"
>
{broadcasting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Sending Emails...
</>
) : (
<>
<Mail className="w-4 h-4 mr-2" />
Send Launch Notification to All
</>
)}
</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> </Card>
</div> )}
{/* Recent Subscribers */}
<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"
>
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-muted-foreground" />
<span className="text-sm">{subscriber.email}</span>
</div>
<span className="text-xs text-muted-foreground">
{new Date(subscriber.createdAt).toLocaleDateString()}
</span>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No subscribers yet</p>
)}
<div className="mt-4 pt-4 border-t">
<p className="text-xs text-muted-foreground">
💡 Tip: View all subscribers in{' '}
<a
href="http://localhost:5555"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Prisma Studio
</a>
{' '}(NewsletterSubscription table)
</p>
</div>
</Card>
</div> </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) ? Math.round((previousUniqueScans / previousTotalScans) * 100)
: 0; : 0;
const avgScansTrend = calculateTrend(avgScansPerQR, previousAvgScansPerQR); const avgScansTrend = calculateTrend(currentConversion, previousConversion);
// Device stats // Device stats
const deviceStats = qrCodes.flatMap(qr => qr.scans) const deviceStats = qrCodes.flatMap(qr => qr.scans)
@@ -245,7 +245,7 @@ export async function GET(request: NextRequest) {
summary: { summary: {
totalScans, totalScans,
uniqueScans, uniqueScans,
avgScansPerQR, avgScansPerQR: currentConversion, // Now sending Unique Rate instead of Avg per QR
mobilePercentage, mobilePercentage,
topCountry: topCountry ? topCountry[0] : 'N/A', topCountry: topCountry ? topCountry[0] : 'N/A',
topCountryPercentage: topCountry && totalScans > 0 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 }, where: { slug },
select: { select: {
id: true, id: true,
title: true,
content: true, content: true,
contentType: true, contentType: true,
user: {
select: {
name: true,
subdomain: true,
}
}
}, },
}); });
@@ -81,8 +88,94 @@ export async function GET(
destination = `${destination}${separator}${preservedParams.toString()}`; destination = `${destination}${separator}${preservedParams.toString()}`;
} }
// Return 307 redirect (temporary redirect that preserves method) // Construct metadata
return NextResponse.redirect(destination, { status: 307 }); 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) { } catch (error) {
console.error('QR redirect error:', error); console.error('QR redirect error:', error);
return new NextResponse('Internal server error', { status: 500 }); return new NextResponse('Internal server error', { status: 500 });

View File

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

View File

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

View File

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