Compare commits
1 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
af2d8f1e8f |
|
|
@ -32,6 +32,9 @@ model User {
|
||||||
resetPasswordToken String? @unique
|
resetPasswordToken String? @unique
|
||||||
resetPasswordExpires DateTime?
|
resetPasswordExpires DateTime?
|
||||||
|
|
||||||
|
// White-label subdomain
|
||||||
|
subdomain String? @unique
|
||||||
|
|
||||||
qrCodes QRCode[]
|
qrCodes QRCode[]
|
||||||
integrations Integration[]
|
integrations Integration[]
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,11 @@ 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)
|
||||||
|
const [logo, setLogo] = useState<string>('');
|
||||||
|
const [logoSize, setLogoSize] = useState(40);
|
||||||
|
const [excavate, setExcavate] = useState(true);
|
||||||
|
|
||||||
// QR preview
|
// QR preview
|
||||||
const [qrDataUrl, setQrDataUrl] = useState('');
|
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||||
|
|
||||||
|
|
@ -167,6 +172,15 @@ export default function CreatePage() {
|
||||||
backgroundColor: canCustomizeColors ? backgroundColor : '#FFFFFF',
|
backgroundColor: canCustomizeColors ? backgroundColor : '#FFFFFF',
|
||||||
cornerStyle,
|
cornerStyle,
|
||||||
size,
|
size,
|
||||||
|
// Logo embedding (PRO only)
|
||||||
|
...(logo && canCustomizeColors ? {
|
||||||
|
imageSettings: {
|
||||||
|
src: logo,
|
||||||
|
height: logoSize,
|
||||||
|
width: logoSize,
|
||||||
|
excavate: excavate,
|
||||||
|
}
|
||||||
|
} : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -488,6 +502,95 @@ export default function CreatePage() {
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Logo/Icon Section (PRO Feature) */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle>Logo / Icon</CardTitle>
|
||||||
|
<Badge variant="info">PRO</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{!canCustomizeColors ? (
|
||||||
|
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<p className="text-sm text-blue-900">
|
||||||
|
<strong>Upgrade to PRO</strong> to add your logo or icon to QR codes.
|
||||||
|
</p>
|
||||||
|
<Link href="/pricing">
|
||||||
|
<Button variant="primary" size="sm" className="mt-2">
|
||||||
|
Upgrade Now
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Upload Logo (PNG, JPG)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/jpg"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
setLogo(reader.result as string);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{logo && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<img src={logo} alt="Logo preview" className="w-12 h-12 object-contain rounded border" />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setLogo('')}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Logo Size: {logoSize}px
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="24"
|
||||||
|
max="80"
|
||||||
|
value={logoSize}
|
||||||
|
onChange={(e) => setLogoSize(Number(e.target.value))}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="excavate"
|
||||||
|
checked={excavate}
|
||||||
|
onChange={(e) => setExcavate(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<label htmlFor="excavate" className="text-sm text-gray-700">
|
||||||
|
Clear background behind logo (recommended)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Preview */}
|
{/* Right: Preview */}
|
||||||
|
|
@ -505,7 +608,13 @@ export default function CreatePage() {
|
||||||
size={200}
|
size={200}
|
||||||
fgColor={foregroundColor}
|
fgColor={foregroundColor}
|
||||||
bgColor={backgroundColor}
|
bgColor={backgroundColor}
|
||||||
level="M"
|
level={logo && canCustomizeColors ? 'H' : 'M'}
|
||||||
|
imageSettings={logo && canCustomizeColors ? {
|
||||||
|
src: logo,
|
||||||
|
height: logoSize,
|
||||||
|
width: logoSize,
|
||||||
|
excavate: excavate,
|
||||||
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ export default function DashboardPage() {
|
||||||
uniqueScans: 0,
|
uniqueScans: 0,
|
||||||
});
|
});
|
||||||
const [analyticsData, setAnalyticsData] = useState<any>(null);
|
const [analyticsData, setAnalyticsData] = useState<any>(null);
|
||||||
|
const [userSubdomain, setUserSubdomain] = useState<string | null>(null);
|
||||||
|
|
||||||
const mockQRCodes = [
|
const mockQRCodes = [
|
||||||
{
|
{
|
||||||
|
|
@ -279,6 +280,13 @@ export default function DashboardPage() {
|
||||||
const analytics = await analyticsResponse.json();
|
const analytics = await analyticsResponse.json();
|
||||||
setAnalyticsData(analytics);
|
setAnalyticsData(analytics);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch user subdomain for white label display
|
||||||
|
const subdomainResponse = await fetch('/api/user/subdomain');
|
||||||
|
if (subdomainResponse.ok) {
|
||||||
|
const subdomainData = await subdomainResponse.json();
|
||||||
|
setUserSubdomain(subdomainData.subdomain || null);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching data:', error);
|
console.error('Error fetching data:', error);
|
||||||
setQrCodes([]);
|
setQrCodes([]);
|
||||||
|
|
@ -449,10 +457,11 @@ export default function DashboardPage() {
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{qrCodes.map((qr) => (
|
{qrCodes.map((qr) => (
|
||||||
<QRCodeCard
|
<QRCodeCard
|
||||||
key={qr.id}
|
key={`${qr.id}-${userSubdomain || 'default'}`}
|
||||||
qr={qr}
|
qr={qr}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
userSubdomain={userSubdomain}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,12 @@ import React, { useState, useEffect } from 'react';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
import { useCsrf } from '@/hooks/useCsrf';
|
import { useCsrf } from '@/hooks/useCsrf';
|
||||||
import { showToast } from '@/components/ui/Toast';
|
import { showToast } from '@/components/ui/Toast';
|
||||||
import ChangePasswordModal from '@/components/settings/ChangePasswordModal';
|
import ChangePasswordModal from '@/components/settings/ChangePasswordModal';
|
||||||
|
|
||||||
type TabType = 'profile' | 'subscription';
|
type TabType = 'profile' | 'subscription' | 'whitelabel';
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { fetchWithCsrf } = useCsrf();
|
const { fetchWithCsrf } = useCsrf();
|
||||||
|
|
@ -28,6 +29,11 @@ export default function SettingsPage() {
|
||||||
staticUsed: 0,
|
staticUsed: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// White Label Subdomain states
|
||||||
|
const [subdomain, setSubdomain] = useState('');
|
||||||
|
const [savedSubdomain, setSavedSubdomain] = useState<string | null>(null);
|
||||||
|
const [subdomainLoading, setSubdomainLoading] = useState(false);
|
||||||
|
|
||||||
// Load user data
|
// Load user data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUserData = async () => {
|
const fetchUserData = async () => {
|
||||||
|
|
@ -53,6 +59,14 @@ export default function SettingsPage() {
|
||||||
const data = await statsResponse.json();
|
const data = await statsResponse.json();
|
||||||
setUsageStats(data);
|
setUsageStats(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch subdomain
|
||||||
|
const subdomainResponse = await fetch('/api/user/subdomain');
|
||||||
|
if (subdomainResponse.ok) {
|
||||||
|
const data = await subdomainResponse.json();
|
||||||
|
setSavedSubdomain(data.subdomain);
|
||||||
|
setSubdomain(data.subdomain || '');
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load user data:', e);
|
console.error('Failed to load user data:', e);
|
||||||
}
|
}
|
||||||
|
|
@ -185,24 +199,31 @@ export default function SettingsPage() {
|
||||||
<nav className="-mb-px flex space-x-8">
|
<nav className="-mb-px flex space-x-8">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('profile')}
|
onClick={() => setActiveTab('profile')}
|
||||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
className={`py-4 px-1 border-b-2 font-medium text-sm ${activeTab === 'profile'
|
||||||
activeTab === 'profile'
|
? 'border-primary-500 text-primary-600'
|
||||||
? 'border-primary-500 text-primary-600'
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
Profile
|
Profile
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('subscription')}
|
onClick={() => setActiveTab('subscription')}
|
||||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
className={`py-4 px-1 border-b-2 font-medium text-sm ${activeTab === 'subscription'
|
||||||
activeTab === 'subscription'
|
? 'border-primary-500 text-primary-600'
|
||||||
? 'border-primary-500 text-primary-600'
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
Subscription
|
Subscription
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('whitelabel')}
|
||||||
|
className={`py-4 px-1 border-b-2 font-medium text-sm ${activeTab === 'whitelabel'
|
||||||
|
? 'border-primary-500 text-primary-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
White Label
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -373,6 +394,143 @@ export default function SettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'whitelabel' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* White Label Subdomain */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle>White Label Subdomain</CardTitle>
|
||||||
|
<Badge variant="success">FREE</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-gray-600 text-sm">
|
||||||
|
Create your own branded QR code URL. Your QR codes will be accessible via your custom subdomain.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={subdomain}
|
||||||
|
onChange={(e) => setSubdomain(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
|
||||||
|
placeholder="your-brand"
|
||||||
|
className="flex-1 max-w-xs"
|
||||||
|
/>
|
||||||
|
<span className="text-gray-600 font-medium">.qrmaster.net</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
<ul className="list-disc list-inside space-y-1">
|
||||||
|
<li>3-30 characters</li>
|
||||||
|
<li>Only lowercase letters, numbers, and hyphens</li>
|
||||||
|
<li>Cannot start or end with a hyphen</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{savedSubdomain && (
|
||||||
|
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||||
|
<p className="text-green-800 font-medium">
|
||||||
|
✅ Your white label URL is active:
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={`https://${savedSubdomain}.qrmaster.net`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-green-700 underline"
|
||||||
|
>
|
||||||
|
https://{savedSubdomain}.qrmaster.net
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!subdomain.trim()) {
|
||||||
|
showToast('Please enter a subdomain', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubdomainLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetchWithCsrf('/api/user/subdomain', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ subdomain: subdomain.trim().toLowerCase() }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
setSavedSubdomain(subdomain.trim().toLowerCase());
|
||||||
|
showToast('Subdomain saved successfully!', 'success');
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Error saving subdomain', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Error saving subdomain', 'error');
|
||||||
|
} finally {
|
||||||
|
setSubdomainLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
loading={subdomainLoading}
|
||||||
|
disabled={!subdomain.trim() || subdomain === savedSubdomain}
|
||||||
|
>
|
||||||
|
{savedSubdomain ? 'Update Subdomain' : 'Save Subdomain'}
|
||||||
|
</Button>
|
||||||
|
{savedSubdomain && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={async () => {
|
||||||
|
setSubdomainLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetchWithCsrf('/api/user/subdomain', {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
setSavedSubdomain(null);
|
||||||
|
setSubdomain('');
|
||||||
|
showToast('Subdomain removed', 'success');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Error removing subdomain', 'error');
|
||||||
|
} finally {
|
||||||
|
setSubdomainLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={subdomainLoading}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* How it works */}
|
||||||
|
{savedSubdomain && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>How it works</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4 text-sm">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="p-3 bg-gray-100 rounded-lg">
|
||||||
|
<p className="text-gray-500 mb-1">Before (default)</p>
|
||||||
|
<code className="text-gray-800">qrmaster.net/r/your-qr</code>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-primary-50 rounded-lg border border-primary-200">
|
||||||
|
<p className="text-primary-600 mb-1">After (your brand)</p>
|
||||||
|
<code className="text-primary-800">{savedSubdomain}.qrmaster.net/r/your-qr</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
All your QR codes will work with both URLs. Share the branded version with your clients!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Change Password Modal */}
|
{/* Change Password Modal */}
|
||||||
<ChangePasswordModal
|
<ChangePasswordModal
|
||||||
isOpen={showPasswordModal}
|
isOpen={showPasswordModal}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
// Reserved subdomains that cannot be used
|
||||||
|
const RESERVED_SUBDOMAINS = [
|
||||||
|
'www', 'app', 'api', 'admin', 'mail', 'email',
|
||||||
|
'ftp', 'smtp', 'pop', 'imap', 'dns', 'ns1', 'ns2',
|
||||||
|
'blog', 'shop', 'store', 'help', 'support', 'dashboard',
|
||||||
|
'login', 'signup', 'auth', 'cdn', 'static', 'assets',
|
||||||
|
'dev', 'staging', 'test', 'demo', 'beta', 'alpha'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Validate subdomain format
|
||||||
|
function isValidSubdomain(subdomain: string): { valid: boolean; error?: string } {
|
||||||
|
if (!subdomain) {
|
||||||
|
return { valid: false, error: 'Subdomain is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be lowercase
|
||||||
|
if (subdomain !== subdomain.toLowerCase()) {
|
||||||
|
return { valid: false, error: 'Subdomain must be lowercase' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Length check
|
||||||
|
if (subdomain.length < 3 || subdomain.length > 30) {
|
||||||
|
return { valid: false, error: 'Subdomain must be 3-30 characters' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alphanumeric and hyphens only, no leading/trailing hyphens
|
||||||
|
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(subdomain)) {
|
||||||
|
return { valid: false, error: 'Only lowercase letters, numbers, and hyphens allowed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// No consecutive hyphens
|
||||||
|
if (/--/.test(subdomain)) {
|
||||||
|
return { valid: false, error: 'No consecutive hyphens allowed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check reserved
|
||||||
|
if (RESERVED_SUBDOMAINS.includes(subdomain)) {
|
||||||
|
return { valid: false, error: 'This subdomain is reserved' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/user/subdomain - Get current subdomain
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const userId = cookies().get('userId')?.value;
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await db.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { subdomain: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ subdomain: user.subdomain });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching subdomain:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/user/subdomain - Set subdomain
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const userId = cookies().get('userId')?.value;
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const subdomain = body.subdomain?.trim().toLowerCase();
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
const validation = isValidSubdomain(subdomain);
|
||||||
|
if (!validation.valid) {
|
||||||
|
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already taken by another user
|
||||||
|
const existing = await db.user.findFirst({
|
||||||
|
where: {
|
||||||
|
subdomain,
|
||||||
|
NOT: { id: userId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return NextResponse.json({ error: 'This subdomain is already taken' }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user
|
||||||
|
try {
|
||||||
|
const updatedUser = await db.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { subdomain },
|
||||||
|
select: { subdomain: true } // Only select needed fields
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
subdomain: updatedUser.subdomain,
|
||||||
|
url: `https://${updatedUser.subdomain}.qrmaster.net`
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'P2025') {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error setting subdomain:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/user/subdomain - Remove subdomain
|
||||||
|
export async function DELETE() {
|
||||||
|
try {
|
||||||
|
const userId = cookies().get('userId')?.value;
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { subdomain: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing subdomain:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,8 +14,15 @@ export async function GET(
|
||||||
where: { slug },
|
where: { slug },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
title: true,
|
||||||
content: true,
|
content: true,
|
||||||
contentType: true,
|
contentType: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
subdomain: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -81,8 +88,94 @@ export async function GET(
|
||||||
destination = `${destination}${separator}${preservedParams.toString()}`;
|
destination = `${destination}${separator}${preservedParams.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return 307 redirect (temporary redirect that preserves method)
|
// Construct metadata
|
||||||
return NextResponse.redirect(destination, { status: 307 });
|
const siteName = qrCode.user?.subdomain
|
||||||
|
? `${qrCode.user.subdomain.charAt(0).toUpperCase() + qrCode.user.subdomain.slice(1)}`
|
||||||
|
: 'QR Master';
|
||||||
|
|
||||||
|
const title = qrCode.title || siteName;
|
||||||
|
const description = `Redirecting to content...`;
|
||||||
|
|
||||||
|
// Determine if we should show a preview (bots) or redirect immediately
|
||||||
|
const userAgent = request.headers.get('user-agent') || '';
|
||||||
|
const isBot = /facebookexternalhit|twitterbot|whatsapp|discordbot|telegrambot|slackbot|linkedinbot/i.test(userAgent);
|
||||||
|
|
||||||
|
// HTML response with metadata and redirect
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${title}</title>
|
||||||
|
|
||||||
|
<!-- Open Graph Metadata -->
|
||||||
|
<meta property="og:title" content="${title}" />
|
||||||
|
<meta property="og:site_name" content="${siteName}" />
|
||||||
|
<meta property="og:description" content="${description}" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="${destination}" />
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary" />
|
||||||
|
<meta name="twitter:title" content="${title}" />
|
||||||
|
<meta name="twitter:description" content="${description}" />
|
||||||
|
|
||||||
|
<!-- No-cache headers to ensure fresh Redirects -->
|
||||||
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||||
|
<meta http-equiv="Pragma" content="no-cache" />
|
||||||
|
<meta http-equiv="Expires" content="0" />
|
||||||
|
|
||||||
|
<!-- Fallback Redirect -->
|
||||||
|
<meta http-equiv="refresh" content="0;url=${JSON.stringify(destination).slice(1, -1)}" />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background-color: #f9fafb;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
.loader {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.spinner {
|
||||||
|
border: 3px solid #f3f3f3;
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top: 3px solid #3b82f6;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
-webkit-animation: spin 1s linear infinite; /* Safari */
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="loader">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Redirecting to ${siteName}...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Immediate redirect
|
||||||
|
window.location.replace("${destination}");
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
return new NextResponse(html, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/html',
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('QR redirect error:', error);
|
console.error('QR redirect error:', error);
|
||||||
return new NextResponse('Internal server error', { status: 500 });
|
return new NextResponse('Internal server error', { status: 500 });
|
||||||
|
|
|
||||||
|
|
@ -21,17 +21,25 @@ interface QRCodeCardProps {
|
||||||
};
|
};
|
||||||
onEdit: (id: string) => void;
|
onEdit: (id: string) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
|
userSubdomain?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const QRCodeCard: React.FC<QRCodeCardProps> = ({
|
export const QRCodeCard: React.FC<QRCodeCardProps> = ({
|
||||||
qr,
|
qr,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
userSubdomain,
|
||||||
}) => {
|
}) => {
|
||||||
// For dynamic QR codes, use the redirect URL for tracking
|
// For dynamic QR codes, use the redirect URL for tracking
|
||||||
// For static QR codes, use the direct URL from content
|
// For static QR codes, use the direct URL from content
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || (typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3050');
|
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || (typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3050');
|
||||||
|
|
||||||
|
// White label: use subdomain URL if available
|
||||||
|
const mainDomain = process.env.NEXT_PUBLIC_MAIN_DOMAIN || 'qrmaster.net';
|
||||||
|
const brandedBaseUrl = userSubdomain
|
||||||
|
? `https://${userSubdomain}.${mainDomain}`
|
||||||
|
: baseUrl;
|
||||||
|
|
||||||
// Get the QR URL based on type
|
// Get the QR URL based on type
|
||||||
let qrUrl = '';
|
let qrUrl = '';
|
||||||
|
|
||||||
|
|
@ -65,15 +73,17 @@ END:VCARD`;
|
||||||
qrUrl = qr.content.qrContent;
|
qrUrl = qr.content.qrContent;
|
||||||
} else {
|
} else {
|
||||||
// Last resort fallback
|
// Last resort fallback
|
||||||
qrUrl = `${baseUrl}/r/${qr.slug}`;
|
qrUrl = `${brandedBaseUrl}/r/${qr.slug}`;
|
||||||
}
|
}
|
||||||
console.log(`STATIC QR [${qr.title}]: ${qrUrl}`);
|
console.log(`STATIC QR [${qr.title}]: ${qrUrl}`);
|
||||||
} else {
|
} else {
|
||||||
// DYNAMIC QR codes always use redirect for tracking
|
// DYNAMIC QR codes use branded URL for white label
|
||||||
qrUrl = `${baseUrl}/r/${qr.slug}`;
|
qrUrl = `${brandedBaseUrl}/r/${qr.slug}`;
|
||||||
console.log(`DYNAMIC QR [${qr.title}]: ${qrUrl}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Display URL (same as qrUrl for consistency)
|
||||||
|
const displayUrl = qrUrl;
|
||||||
|
|
||||||
const downloadQR = (format: 'png' | 'svg') => {
|
const downloadQR = (format: 'png' | 'svg') => {
|
||||||
const svg = document.querySelector(`#qr-${qr.id} svg`);
|
const svg = document.querySelector(`#qr-${qr.id} svg`);
|
||||||
if (!svg) return;
|
if (!svg) return;
|
||||||
|
|
@ -196,11 +206,13 @@ END:VCARD`;
|
||||||
<div id={`qr-${qr.id}`} className="flex items-center justify-center bg-gray-50 rounded-lg p-4 mb-3">
|
<div id={`qr-${qr.id}`} className="flex items-center justify-center bg-gray-50 rounded-lg p-4 mb-3">
|
||||||
<div className={qr.style?.cornerStyle === 'rounded' ? 'rounded-lg overflow-hidden' : ''}>
|
<div className={qr.style?.cornerStyle === 'rounded' ? 'rounded-lg overflow-hidden' : ''}>
|
||||||
<QRCodeSVG
|
<QRCodeSVG
|
||||||
|
key={qrUrl}
|
||||||
value={qrUrl}
|
value={qrUrl}
|
||||||
size={96}
|
size={96}
|
||||||
fgColor={qr.style?.foregroundColor || '#000000'}
|
fgColor={qr.style?.foregroundColor || '#000000'}
|
||||||
bgColor={qr.style?.backgroundColor || '#FFFFFF'}
|
bgColor={qr.style?.backgroundColor || '#FFFFFF'}
|
||||||
level="M"
|
level={qr.style?.imageSettings ? 'H' : 'M'}
|
||||||
|
imageSettings={qr.style?.imageSettings}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -216,6 +228,11 @@ END:VCARD`;
|
||||||
<span className="text-gray-900">{qr.scans || 0}</span>
|
<span className="text-gray-900">{qr.scans || 0}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{qr.type === 'DYNAMIC' && (
|
||||||
|
<div className="text-xs text-gray-400 break-all bg-gray-50 p-1 rounded border border-gray-100 mt-2">
|
||||||
|
{qrUrl}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-gray-500">Created:</span>
|
<span className="text-gray-500">Created:</span>
|
||||||
<span className="text-gray-900">{formatDate(qr.createdAt)}</span>
|
<span className="text-gray-900">{formatDate(qr.createdAt)}</span>
|
||||||
|
|
@ -223,7 +240,7 @@ END:VCARD`;
|
||||||
{qr.type === 'DYNAMIC' && (
|
{qr.type === 'DYNAMIC' && (
|
||||||
<div className="pt-2 border-t">
|
<div className="pt-2 border-t">
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
📊 Dynamic QR: Tracks scans via {baseUrl}/r/{qr.slug}
|
📊 Dynamic QR: Tracks scans via {displayUrl}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,28 @@ export function middleware(req: NextRequest) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle White Label Subdomains
|
||||||
|
// Check if this is a subdomain request (e.g., kunde.qrmaster.de)
|
||||||
|
const host = req.headers.get('host') || '';
|
||||||
|
const isLocalhost = host.includes('localhost') || host.includes('127.0.0.1');
|
||||||
|
const mainDomain = process.env.NEXT_PUBLIC_MAIN_DOMAIN || 'qrmaster.net';
|
||||||
|
|
||||||
|
// Extract subdomain if present (e.g., "kunde" from "kunde.qrmaster.de")
|
||||||
|
let subdomain: string | null = null;
|
||||||
|
if (!isLocalhost && host.endsWith(mainDomain) && host !== mainDomain && host !== `www.${mainDomain}`) {
|
||||||
|
const parts = host.replace(`.${mainDomain}`, '').split('.');
|
||||||
|
if (parts.length === 1 && parts[0]) {
|
||||||
|
subdomain = parts[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For subdomain requests to /r/*, pass subdomain info via header
|
||||||
|
if (subdomain && path.startsWith('/r/')) {
|
||||||
|
const response = NextResponse.next();
|
||||||
|
response.headers.set('x-subdomain', subdomain);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
// Allow redirect routes (QR code redirects)
|
// Allow redirect routes (QR code redirects)
|
||||||
if (path.startsWith('/r/')) {
|
if (path.startsWith('/r/')) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue