diff --git a/package.json b/package.json index bd0aaa0..d2adcf1 100644 --- a/package.json +++ b/package.json @@ -83,4 +83,4 @@ "engines": { "node": ">=18.0.0" } -} +} \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index da00a83..8570af9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -32,6 +32,9 @@ model User { resetPasswordToken String? @unique resetPasswordExpires DateTime? + // White-label subdomain + subdomain String? @unique + qrCodes QRCode[] integrations Integration[] accounts Account[] diff --git a/src/app/(app)/create/page.tsx b/src/app/(app)/create/page.tsx index e4bec4d..56b8295 100644 --- a/src/app/(app)/create/page.tsx +++ b/src/app/(app)/create/page.tsx @@ -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(''); + 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() { + + {/* Logo/Icon Section (PRO Feature) */} + + +
+ Logo / Icon + PRO +
+
+ + {!canCustomizeColors ? ( +
+

+ Upgrade to PRO to add your logo or icon to QR codes. +

+ + + +
+ ) : ( + <> +
+ + { + 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" + /> +
+ + {logo && ( + <> +
+ Logo preview + +
+ +
+ + setLogoSize(Number(e.target.value))} + className="w-full" + /> +
+ +
+ setExcavate(e.target.checked)} + className="w-4 h-4 rounded border-gray-300" + /> + +
+ + )} + + )} +
+
{/* 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} /> ) : ( diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index 214daef..fb37f5a 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -44,6 +44,7 @@ export default function DashboardPage() { uniqueScans: 0, }); const [analyticsData, setAnalyticsData] = useState(null); + const [userSubdomain, setUserSubdomain] = useState(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() {
{qrCodes.map((qr) => ( ))}
diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index cf7e9b4..387f682 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -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(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() { @@ -373,6 +394,143 @@ export default function SettingsPage() { )} + {activeTab === 'whitelabel' && ( +
+ {/* White Label Subdomain */} + + +
+ White Label Subdomain + FREE +
+
+ +

+ Create your own branded QR code URL. Your QR codes will be accessible via your custom subdomain. +

+ +
+ setSubdomain(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))} + placeholder="your-brand" + className="flex-1 max-w-xs" + /> + .qrmaster.net +
+ +
+
    +
  • 3-30 characters
  • +
  • Only lowercase letters, numbers, and hyphens
  • +
  • Cannot start or end with a hyphen
  • +
+
+ + {savedSubdomain && ( +
+

+ ✅ Your white label URL is active: +

+ + https://{savedSubdomain}.qrmaster.net + +
+ )} + +
+ + {savedSubdomain && ( + + )} +
+
+
+ + {/* How it works */} + {savedSubdomain && ( + + + How it works + + +
+
+
+

Before (default)

+ qrmaster.net/r/your-qr +
+
+

After (your brand)

+ {savedSubdomain}.qrmaster.net/r/your-qr +
+
+

+ All your QR codes will work with both URLs. Share the branded version with your clients! +

+
+
+
+ )} +
+ )} + {/* Change Password Modal */} 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 }); + } +} diff --git a/src/app/r/[slug]/route.ts b/src/app/r/[slug]/route.ts index 41338ae..85cf67b 100644 --- a/src/app/r/[slug]/route.ts +++ b/src/app/r/[slug]/route.ts @@ -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 = ` + + + + + ${title} + + + + + + + + + + + + + + + + + + + + + + + + +
+
+

Redirecting to ${siteName}...

+
+ + + +`; + + 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 }); diff --git a/src/components/dashboard/QRCodeCard.tsx b/src/components/dashboard/QRCodeCard.tsx index bf385bd..63caf35 100644 --- a/src/components/dashboard/QRCodeCard.tsx +++ b/src/components/dashboard/QRCodeCard.tsx @@ -21,20 +21,28 @@ interface QRCodeCardProps { }; onEdit: (id: string) => void; onDelete: (id: string) => void; + userSubdomain?: string | null; } export const QRCodeCard: React.FC = ({ 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 = ''; - + // SIMPLE FIX: For STATIC QR codes, ALWAYS use the direct content if (qr.type === 'STATIC') { // Extract the actual URL/content based on contentType @@ -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; @@ -171,7 +181,7 @@ END:VCARD`; - +
@@ -216,6 +228,11 @@ END:VCARD`; {qr.scans || 0} )} + {qr.type === 'DYNAMIC' && ( +
+ {qrUrl} +
+ )}
Created: {formatDate(qr.createdAt)} @@ -223,7 +240,7 @@ END:VCARD`; {qr.type === 'DYNAMIC' && (

- 📊 Dynamic QR: Tracks scans via {baseUrl}/r/{qr.slug} + 📊 Dynamic QR: Tracks scans via {displayUrl}

)} diff --git a/src/middleware.ts b/src/middleware.ts index 2acd6ab..ffdc8a6 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -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();