fix: Optimize flipping card animation backface and timing
This commit is contained in:
@@ -1,240 +1,240 @@
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user