11 Commits

38 changed files with 14312 additions and 13928 deletions

41
ideen.md Normal file
View File

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

43
package-lock.json generated
View File

@@ -21,6 +21,7 @@
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"framer-motion": "^12.24.10",
"i18next": "^23.7.6", "i18next": "^23.7.6",
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"jszip": "^3.10.1", "jszip": "^3.10.1",
@@ -5098,6 +5099,33 @@
"url": "https://github.com/sponsors/rawify" "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": { "node_modules/fs-constants": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@@ -6607,6 +6635,21 @@
"mkdirp": "bin/cmd.js" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",

View File

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

View File

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

BIN
public/hero-fluid.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 518 KiB

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Footer } from '@/components/ui/Footer';
import en from '@/i18n/en.json'; import en from '@/i18n/en.json';
export default function MarketingLayout({ export default function MarketingLayout({
@@ -11,6 +12,16 @@ export default function MarketingLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); 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 // Always use English for marketing pages
const t = en; const t = en;
@@ -25,7 +36,7 @@ export default function MarketingLayout({
return ( return (
<div className="min-h-screen bg-white"> <div className="min-h-screen bg-white">
{/* Header */} {/* Header */}
<header className="sticky top-0 z-50 bg-white border-b border-gray-200"> <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'}`}>
<nav className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl py-4"> <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"> <div className="flex items-center justify-between">
{/* Logo */} {/* Logo */}
@@ -54,7 +65,11 @@ export default function MarketingLayout({
</Link> </Link>
<Link href="/signup"> <Link href="/signup">
{showStickyCTA ? (
<Button className="animate-in fade-in zoom-in duration-300">Create QR Code</Button>
) : (
<Button>Get Started Free</Button> <Button>Get Started Free</Button>
)}
</Link> </Link>
</div> </div>
@@ -102,63 +117,10 @@ export default function MarketingLayout({
</header> </header>
{/* Main Content */} {/* Main Content */}
<main>{children}</main> <main className="pt-20">{children}</main>
{/* Footer */} {/* Footer */}
<footer className="bg-gray-900 text-white py-12 mt-20"> <Footer />
<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> </div>
); );
} }

View File

@@ -5,19 +5,60 @@ 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 { Mail, Users, Send, CheckCircle, AlertCircle, Loader2, Lock, LogOut } from 'lucide-react'; import {
Mail,
Users,
QrCode,
BarChart3,
TrendingUp,
Crown,
Activity,
Loader2,
Lock,
LogOut,
Zap,
Send,
CheckCircle2,
} from 'lucide-react';
interface Subscriber { interface AdminStats {
email: string; users: {
createdAt: string;
}
interface BroadcastInfo {
total: number; total: number;
recent: Subscriber[]; 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 NewsletterPage() { export default function AdminDashboard() {
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);
@@ -25,14 +66,18 @@ export default function NewsletterPage() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [info, setInfo] = useState<BroadcastInfo | null>(null); const [stats, setStats] = useState<AdminStats | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [broadcasting, setBroadcasting] = useState(false);
const [result, setResult] = useState<{ // 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<{
success: boolean; success: boolean;
message: string; message: string;
sent?: number;
failed?: number;
} | null>(null); } | null>(null);
useEffect(() => { useEffect(() => {
@@ -41,12 +86,14 @@ export default function NewsletterPage() {
const checkAuth = async () => { const checkAuth = async () => {
try { try {
const response = await fetch('/api/newsletter/broadcast'); const response = await fetch('/api/admin/stats');
if (response.ok) { if (response.ok) {
setIsAuthenticated(true); setIsAuthenticated(true);
const data = await response.json(); const data = await response.json();
setInfo(data); setStats(data);
setLoading(false); setLoading(false);
// Also fetch newsletter data
fetchNewsletterData();
} else { } else {
setIsAuthenticated(false); setIsAuthenticated(false);
} }
@@ -57,6 +104,54 @@ export default function NewsletterPage() {
} }
}; };
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('');
@@ -90,53 +185,6 @@ export default function NewsletterPage() {
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 (
@@ -146,9 +194,9 @@ export default function NewsletterPage() {
<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">Newsletter Admin</h1> <h1 className="text-2xl font-bold mb-2">Admin Dashboard</h1>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
Sign in to manage subscribers Sign in to access admin panel
</p> </p>
</div> </div>
@@ -159,7 +207,7 @@ export default function NewsletterPage() {
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="Email" placeholder="admin@example.com"
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"
/> />
@@ -171,7 +219,7 @@ export default function NewsletterPage() {
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="Password" placeholder="••••••••"
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,12 +267,13 @@ export default function NewsletterPage() {
// 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-4xl"> <div className="container mx-auto px-4 py-8 max-w-7xl">
{/* 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">Newsletter Management</h1> <h1 className="text-3xl font-bold mb-2">Admin Dashboard</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Manage AI feature launch notifications Platform overview and statistics
</p> </p>
</div> </div>
<Button <Button
@@ -237,45 +286,305 @@ export default function NewsletterPage() {
</Button> </Button>
</div> </div>
{/* Stats Card */} {/* Main Stats Grid */}
<Card className="p-6 mb-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div className="flex items-center justify-between mb-6"> {/* All Time Users */}
<div className="flex items-center gap-3"> <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">
<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">
<Users className="w-6 h-6 text-purple-600 dark:text-purple-400" /> <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" />
</div> </div>
<div> <div>
<h2 className="text-2xl font-bold">{info?.total || 0}</h2> <h3 className="text-2xl font-bold">
<p className="text-sm text-muted-foreground">Total Subscribers</p> {stats?.scans.total.toLocaleString() || 0}
</h3>
<p className="text-sm text-muted-foreground">Total All Scans</p>
</div> </div>
</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"> <Badge className="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
Active Active
</Badge> </Badge>
</div> </div>
{/* Broadcast Button */} {/* Broadcast Section */}
<div className="border-t pt-6"> <div className="p-4 bg-gray-50 dark:bg-gray-900/50 rounded-xl mb-6">
<div className="mb-4"> <div className="flex items-start gap-3 mb-3">
<h3 className="font-semibold mb-2 flex items-center gap-2"> <Send className="w-5 h-5 text-purple-600 dark:text-purple-400 mt-0.5" />
<Send className="w-4 h-4" /> <div>
Broadcast AI Feature Launch <h4 className="font-medium">Broadcast AI Feature Launch</h4>
</h3> <p className="text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground mb-4"> Send the AI feature launch announcement to all {newsletterData?.total || 0} subscribers.
Send the AI feature launch announcement to all {info?.total} subscribers.
This will inform them that the features are now available. This will inform them that the features are now available.
</p> </p>
</div> </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 <Button
onClick={handleBroadcast} onClick={handleSendBroadcast}
disabled={broadcasting || !info?.total} disabled={sendingBroadcast || (newsletterData?.total || 0) === 0 || (newsletterData?.total || 0) > 100}
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" className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white"
> >
{broadcasting ? ( {sendingBroadcast ? (
<> <>
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> <Loader2 className="w-4 h-4 mr-2 animate-spin" />
Sending Emails... Sending...
</> </>
) : ( ) : (
<> <>
@@ -285,49 +594,13 @@ export default function NewsletterPage() {
)} )}
</Button> </Button>
</div> </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 */} {/* Recent Subscribers */}
<Card className="p-6"> <div>
<h3 className="font-semibold mb-4">Recent Subscribers</h3> <h4 className="font-medium mb-3">Recent Subscribers</h4>
{info?.recent && info.recent.length > 0 ? ( {newsletterData?.recent && newsletterData.recent.length > 0 ? (
<div className="space-y-3"> <div className="space-y-2">
{info.recent.map((subscriber, index) => ( {newsletterData.recent.map((subscriber, index) => (
<div <div
key={index} key={index}
className="flex items-center justify-between py-2 border-b border-border last:border-0" className="flex items-center justify-between py-2 border-b border-border last:border-0"
@@ -345,7 +618,9 @@ export default function NewsletterPage() {
) : ( ) : (
<p className="text-sm text-muted-foreground">No subscribers yet</p> <p className="text-sm text-muted-foreground">No subscribers yet</p>
)} )}
</div>
{/* Tip */}
<div className="mt-4 pt-4 border-t"> <div className="mt-4 pt-4 border-t">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
💡 Tip: View all subscribers in{' '} 💡 Tip: View all subscribers in{' '}
@@ -353,7 +628,7 @@ export default function NewsletterPage() {
href="http://localhost:5555" href="http://localhost:5555"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-primary hover:underline" className="text-purple-600 dark:text-purple-400 hover:underline"
> >
Prisma Studio Prisma Studio
</a> </a>
@@ -363,5 +638,6 @@ export default function NewsletterPage() {
</Card> </Card>
</div> </div>
</div> </div>
</div>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 alpha2ToAlpha3: Record<string, string> = { const alpha2ToNumeric: Record<string, string> = {
'US': 'USA', 'US': '840',
'DE': 'DEU', 'DE': '276',
'GB': 'GBR', 'GB': '826',
'FR': 'FRA', 'FR': '250',
'CA': 'CAN', 'CA': '124',
'AU': 'AUS', 'AU': '036',
'JP': 'JPN', 'JP': '392',
'CN': 'CHN', 'CN': '156',
'IN': 'IND', 'IN': '356',
'BR': 'BRA', 'BR': '076',
'ES': 'ESP', 'ES': '724',
'IT': 'ITA', 'IT': '380',
'NL': 'NLD', 'NL': '528',
'CH': 'CHE', 'CH': '756',
'AT': 'AUT', 'AT': '040',
'PL': 'POL', 'PL': '616',
'SE': 'SWE', 'SE': '752',
'NO': 'NOR', 'NO': '578',
'DK': 'DNK', 'DK': '208',
'FI': 'FIN', 'FI': '246',
'BE': 'BEL', 'BE': '056',
'PT': 'PRT', 'PT': '620',
'IE': 'IRL', 'IE': '372',
'MX': 'MEX', 'MX': '484',
'AR': 'ARG', 'AR': '032',
'KR': 'KOR', 'KR': '410',
'SG': 'SGP', 'SG': '702',
'NZ': 'NZL', 'NZ': '554',
'RU': 'RUS', 'RU': '643',
'ZA': 'ZAF', 'ZA': '710',
}; };
interface CountryStat { interface CountryStat {
@@ -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 alpha3 = alpha2ToAlpha3[alpha2]; const numericCode = alpha2ToNumeric[alpha2];
if (alpha3) { if (numericCode) {
countryData[alpha3] = stat.count; countryData[numericCode] = stat.count;
if (stat.count > maxCount) maxCount = stat.count; if (stat.count > maxCount) maxCount = stat.count;
} }
}); });
@@ -144,8 +144,16 @@ 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 className="w-full h-full"> <div
className="w-full h-full relative group"
onMouseMove={(evt) => {
setTooltipPos({ x: evt.clientX, y: evt.clientY });
}}
>
<ComposableMap <ComposableMap
projection="geoMercator" projection="geoMercator"
projectionConfig={{ projectionConfig={{
@@ -158,8 +166,9 @@ const GeoMap: React.FC<GeoMapProps> = ({ countryStats, totalScans }) => {
<Geographies geography={geoUrl}> <Geographies geography={geoUrl}>
{({ geographies }) => {({ geographies }) =>
geographies.map((geo) => { geographies.map((geo) => {
const isoCode = geo.properties.ISO_A3 || geo.id; // geo.id is the numeric ISO code as a string (e.g., "840" for US)
const scanCount = countryData[isoCode] || 0; const geoId = geo.id;
const scanCount = countryData[geoId] || 0;
const fillColor = scanCount > 0 ? colorScale(scanCount) : '#F1F5F9'; const fillColor = scanCount > 0 ? colorScale(scanCount) : '#F1F5F9';
return ( return (
@@ -178,6 +187,13 @@ 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);
}}
/> />
); );
}) })
@@ -185,6 +201,24 @@ 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,25 +21,17 @@ 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 = '';
@@ -73,17 +65,15 @@ END:VCARD`;
qrUrl = qr.content.qrContent; qrUrl = qr.content.qrContent;
} else { } else {
// Last resort fallback // Last resort fallback
qrUrl = `${brandedBaseUrl}/r/${qr.slug}`; qrUrl = `${baseUrl}/r/${qr.slug}`;
} }
console.log(`STATIC QR [${qr.title}]: ${qrUrl}`); console.log(`STATIC QR [${qr.title}]: ${qrUrl}`);
} else { } else {
// DYNAMIC QR codes use branded URL for white label // DYNAMIC QR codes always use redirect for tracking
qrUrl = `${brandedBaseUrl}/r/${qr.slug}`; qrUrl = `${baseUrl}/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;
@@ -206,13 +196,17 @@ 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={qr.style?.imageSettings ? 'H' : 'M'} level="H"
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>
@@ -228,11 +222,6 @@ 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>
@@ -240,7 +229,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 {displayUrl} 📊 Dynamic QR: Tracks scans via {baseUrl}/r/{qr.slug}
</p> </p>
</div> </div>
)} )}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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