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