import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { hashIP } from '@/lib/hash'; export async function GET( request: NextRequest, { params }: { params: Promise<{ slug: string }> } ) { try { const { slug } = await params; // Fetch QR code by slug const qrCode = await db.qRCode.findUnique({ where: { slug }, select: { id: true, content: true, contentType: true, }, }); if (!qrCode) { return new NextResponse('QR Code not found', { status: 404 }); } // Track scan (fire and forget) trackScan(qrCode.id, request).catch(console.error); // Determine destination URL let destination = ''; const content = qrCode.content as any; switch (qrCode.contentType) { case 'URL': destination = content.url || 'https://example.com'; break; case 'PHONE': destination = `tel:${content.phone}`; break; case 'SMS': destination = `sms:${content.phone}${content.message ? `?body=${encodeURIComponent(content.message)}` : ''}`; break; case 'WHATSAPP': destination = `https://wa.me/${content.phone}${content.message ? `?text=${encodeURIComponent(content.message)}` : ''}`; break; case 'VCARD': // For vCard, redirect to display page const baseUrlVcard = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050'; destination = `${baseUrlVcard}/vcard?firstName=${encodeURIComponent(content.firstName || '')}&lastName=${encodeURIComponent(content.lastName || '')}&email=${encodeURIComponent(content.email || '')}&phone=${encodeURIComponent(content.phone || '')}&organization=${encodeURIComponent(content.organization || '')}&title=${encodeURIComponent(content.title || '')}`; break; case 'GEO': // For location, redirect to Google Maps (works on desktop and mobile) const lat = content.latitude || 0; const lon = content.longitude || 0; destination = `https://maps.google.com/?q=${lat},${lon}`; break; case 'TEXT': // For plain text, redirect to a display page const baseUrlText = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050'; destination = `${baseUrlText}/display?text=${encodeURIComponent(content.text || '')}`; break; case 'PDF': // Direct link to file destination = content.fileUrl || 'https://example.com/file.pdf'; break; case 'APP': // Smart device detection for app stores const userAgent = request.headers.get('user-agent') || ''; const isIOS = /iphone|ipad|ipod/i.test(userAgent); const isAndroid = /android/i.test(userAgent); if (isIOS && content.iosUrl) { destination = content.iosUrl; } else if (isAndroid && content.androidUrl) { destination = content.androidUrl; } else { destination = content.fallbackUrl || content.iosUrl || content.androidUrl || 'https://example.com'; } break; case 'COUPON': // Redirect to coupon display page const baseUrlCoupon = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050'; destination = `${baseUrlCoupon}/coupon/${slug}`; break; case 'FEEDBACK': // Redirect to feedback form page const baseUrlFeedback = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3050'; destination = `${baseUrlFeedback}/feedback/${slug}`; break; default: destination = 'https://example.com'; } // Preserve UTM parameters const searchParams = request.nextUrl.searchParams; const utmParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']; const preservedParams = new URLSearchParams(); utmParams.forEach(param => { const value = searchParams.get(param); if (value) { preservedParams.set(param, value); } }); // Add preserved params to destination if (preservedParams.toString() && destination.startsWith('http')) { const separator = destination.includes('?') ? '&' : '?'; destination = `${destination}${separator}${preservedParams.toString()}`; } // Return 307 redirect (temporary redirect that preserves method) return NextResponse.redirect(destination, { status: 307 }); } catch (error) { console.error('QR redirect error:', error); return new NextResponse('Internal server error', { status: 500 }); } } async function trackScan(qrId: string, request: NextRequest) { try { const userAgent = request.headers.get('user-agent') || ''; const referer = request.headers.get('referer') || ''; const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'; // Check DNT header const dnt = request.headers.get('dnt'); if (dnt === '1') { // Respect Do Not Track - only increment counter await db.qRScan.create({ data: { qrId, ipHash: 'dnt', isUnique: false, }, }); return; } // Hash IP for privacy const ipHash = hashIP(ip); // Device Detection Logic: // 1. Windows or Linux -> Always Desktop // 2. Explicit iPad/Tablet keywords -> Tablet // 3. Mac + Chrome browser -> Desktop (real Mac users often use Chrome) // 4. Mac + Safari + No Referrer -> Likely iPad scanning a QR code // 5. Mobile keywords -> Mobile // 6. Everything else -> Desktop const isWindows = /windows/i.test(userAgent); const isLinux = /linux/i.test(userAgent) && !/android/i.test(userAgent); const isExplicitTablet = /tablet|ipad|playbook|silk/i.test(userAgent); const isAndroidTablet = /android/i.test(userAgent) && !/mobile/i.test(userAgent); const isMacintosh = /macintosh/i.test(userAgent); const isChrome = /chrome/i.test(userAgent); const isSafari = /safari/i.test(userAgent) && !isChrome; const hasReferrer = !!referer; // iPad in desktop mode: Mac + Safari (no Chrome) + No Referrer (physical scan) const isLikelyiPadScan = isMacintosh && isSafari && !hasReferrer; let device: string; if (isWindows || isLinux) { device = 'desktop'; } else if (isExplicitTablet || isAndroidTablet || isLikelyiPadScan) { device = 'tablet'; } else if (/mobile|iphone/i.test(userAgent)) { device = 'mobile'; } else if (isMacintosh && isChrome) { device = 'desktop'; // Mac with Chrome = real desktop } else if (isMacintosh && hasReferrer) { device = 'desktop'; // Mac with referrer = probably clicked a link on desktop } else { device = 'desktop'; // Default fallback } // Detect OS let os = 'unknown'; if (/windows/i.test(userAgent)) os = 'Windows'; else if (/mac/i.test(userAgent)) os = 'macOS'; else if (/linux/i.test(userAgent)) os = 'Linux'; else if (/android/i.test(userAgent)) os = 'Android'; else if (/ios|iphone|ipad/i.test(userAgent)) os = 'iOS'; // Get country from header (Vercel/Cloudflare provide this) const country = request.headers.get('x-vercel-ip-country') || request.headers.get('cf-ipcountry') || 'unknown'; // Extract UTM parameters const searchParams = request.nextUrl.searchParams; const utmSource = searchParams.get('utm_source'); const utmMedium = searchParams.get('utm_medium'); const utmCampaign = searchParams.get('utm_campaign'); // Check if this is a unique scan (first scan from this IP + Device today) // We include a simplified device fingerprint so different devices on same IP count as unique const deviceFingerprint = hashIP(userAgent.substring(0, 100)); // Hash the user agent for privacy const today = new Date(); today.setHours(0, 0, 0, 0); const existingScan = await db.qRScan.findFirst({ where: { qrId, ipHash, userAgent: { startsWith: userAgent.substring(0, 50), // Match same device type }, ts: { gte: today, }, }, }); const isUnique = !existingScan; // Create scan record await db.qRScan.create({ data: { qrId, ipHash, userAgent: userAgent.substring(0, 255), device, os, country, referrer: referer.substring(0, 255), utmSource, utmMedium, utmCampaign, isUnique, }, }); } catch (error) { console.error('Error tracking scan:', error); // Don't throw - this is fire and forget } }