fix: Optimize flipping card animation backface and timing
This commit is contained in:
@@ -1,239 +1,239 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
import { generateSlug } from '@/lib/hash';
|
||||
import { createQRSchema, validateRequest } from '@/lib/validationSchemas';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
|
||||
// GET /api/qrs - List user's QR codes
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const userId = cookies().get('userId')?.value;
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const qrCodes = await db.qRCode.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
_count: {
|
||||
select: { scans: true },
|
||||
},
|
||||
scans: {
|
||||
where: { isUnique: true },
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// Transform the data
|
||||
const transformed = qrCodes.map(qr => ({
|
||||
...qr,
|
||||
scans: qr._count.scans,
|
||||
uniqueScans: qr.scans.length, // Count of scans where isUnique=true
|
||||
_count: undefined,
|
||||
}));
|
||||
|
||||
return NextResponse.json(transformed);
|
||||
} catch (error) {
|
||||
console.error('Error fetching QR codes:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Plan limits
|
||||
const PLAN_LIMITS = {
|
||||
FREE: 3,
|
||||
PRO: 50,
|
||||
BUSINESS: 500,
|
||||
};
|
||||
|
||||
// POST /api/qrs - Create a new QR code
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// CSRF Protection
|
||||
const csrfCheck = csrfProtection(request);
|
||||
if (!csrfCheck.valid) {
|
||||
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
|
||||
}
|
||||
|
||||
const userId = cookies().get('userId')?.value;
|
||||
console.log('POST /api/qrs - userId from cookie:', userId);
|
||||
|
||||
// Rate Limiting (user-based)
|
||||
const clientId = userId || getClientIdentifier(request);
|
||||
const rateLimitResult = rateLimit(clientId, RateLimits.QR_CREATE);
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many requests. Please try again later.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized - no userId cookie' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check if user exists and get their plan
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { plan: true },
|
||||
});
|
||||
|
||||
console.log('User exists:', !!user);
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: `User not found: ${userId}` }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
console.log('Request body:', body);
|
||||
|
||||
// Validate request body with Zod (only for non-static QRs or simplified validation)
|
||||
// Note: Static QRs have complex nested content structure, so we do basic validation
|
||||
if (!body.isStatic) {
|
||||
const validation = await validateRequest(createQRSchema, body);
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(validation.error, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a static QR request
|
||||
const isStatic = body.isStatic === true;
|
||||
|
||||
// Only check limits for DYNAMIC QR codes (static QR codes are unlimited)
|
||||
if (!isStatic) {
|
||||
// Count existing dynamic QR codes
|
||||
const dynamicQRCount = await db.qRCode.count({
|
||||
where: {
|
||||
userId,
|
||||
type: 'DYNAMIC',
|
||||
},
|
||||
});
|
||||
|
||||
const userPlan = user.plan || 'FREE';
|
||||
const limit = PLAN_LIMITS[userPlan as keyof typeof PLAN_LIMITS] || PLAN_LIMITS.FREE;
|
||||
|
||||
if (dynamicQRCount >= limit) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Limit reached',
|
||||
message: `You have reached the limit of ${limit} dynamic QR codes for your ${userPlan} plan. Please upgrade to create more.`,
|
||||
currentCount: dynamicQRCount,
|
||||
limit,
|
||||
plan: userPlan,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let enrichedContent = body.content;
|
||||
|
||||
// For STATIC QR codes, calculate what the QR should contain
|
||||
if (isStatic) {
|
||||
let qrContent = '';
|
||||
switch (body.contentType) {
|
||||
case 'URL':
|
||||
qrContent = body.content.url;
|
||||
break;
|
||||
case 'PHONE':
|
||||
qrContent = `tel:${body.content.phone}`;
|
||||
break;
|
||||
case 'SMS':
|
||||
qrContent = `sms:${body.content.phone}${body.content.message ? `?body=${encodeURIComponent(body.content.message)}` : ''}`;
|
||||
break;
|
||||
case 'VCARD':
|
||||
qrContent = `BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:${body.content.firstName || ''} ${body.content.lastName || ''}
|
||||
N:${body.content.lastName || ''};${body.content.firstName || ''};;;
|
||||
${body.content.organization ? `ORG:${body.content.organization}` : ''}
|
||||
${body.content.title ? `TITLE:${body.content.title}` : ''}
|
||||
${body.content.email ? `EMAIL:${body.content.email}` : ''}
|
||||
${body.content.phone ? `TEL:${body.content.phone}` : ''}
|
||||
END:VCARD`;
|
||||
break;
|
||||
case 'GEO':
|
||||
const lat = body.content.latitude || 0;
|
||||
const lon = body.content.longitude || 0;
|
||||
const label = body.content.label ? `?q=${encodeURIComponent(body.content.label)}` : '';
|
||||
qrContent = `geo:${lat},${lon}${label}`;
|
||||
break;
|
||||
case 'TEXT':
|
||||
qrContent = body.content.text;
|
||||
break;
|
||||
case 'WHATSAPP':
|
||||
qrContent = `https://wa.me/${body.content.phone}${body.content.message ? `?text=${encodeURIComponent(body.content.message)}` : ''}`;
|
||||
break;
|
||||
case 'PDF':
|
||||
qrContent = body.content.fileUrl || 'https://example.com/file.pdf';
|
||||
break;
|
||||
case 'APP':
|
||||
qrContent = body.content.fallbackUrl || body.content.iosUrl || body.content.androidUrl || 'https://example.com';
|
||||
break;
|
||||
case 'COUPON':
|
||||
qrContent = `Coupon: ${body.content.code || 'CODE'} - ${body.content.discount || 'Discount'}`;
|
||||
break;
|
||||
case 'FEEDBACK':
|
||||
qrContent = body.content.feedbackUrl || 'https://example.com/feedback';
|
||||
break;
|
||||
default:
|
||||
qrContent = body.content.url || 'https://example.com';
|
||||
}
|
||||
|
||||
// Add qrContent to the content object
|
||||
enrichedContent = {
|
||||
...body.content,
|
||||
qrContent // This is what the QR code should actually contain
|
||||
};
|
||||
}
|
||||
|
||||
// Generate slug for the QR code
|
||||
const slug = generateSlug(body.title);
|
||||
|
||||
// Create QR code
|
||||
const qrCode = await db.qRCode.create({
|
||||
data: {
|
||||
userId,
|
||||
title: body.title,
|
||||
type: isStatic ? 'STATIC' : 'DYNAMIC',
|
||||
contentType: body.contentType,
|
||||
content: enrichedContent,
|
||||
tags: body.tags || [],
|
||||
style: body.style || {
|
||||
foregroundColor: '#000000',
|
||||
backgroundColor: '#FFFFFF',
|
||||
cornerStyle: 'square',
|
||||
size: 200,
|
||||
},
|
||||
slug,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(qrCode);
|
||||
} catch (error) {
|
||||
console.error('Error creating QR code:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error', details: String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
import { generateSlug } from '@/lib/hash';
|
||||
import { createQRSchema, validateRequest } from '@/lib/validationSchemas';
|
||||
import { csrfProtection } from '@/lib/csrf';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
|
||||
// GET /api/qrs - List user's QR codes
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const userId = cookies().get('userId')?.value;
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const qrCodes = await db.qRCode.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
_count: {
|
||||
select: { scans: true },
|
||||
},
|
||||
scans: {
|
||||
where: { isUnique: true },
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// Transform the data
|
||||
const transformed = qrCodes.map(qr => ({
|
||||
...qr,
|
||||
scans: qr._count.scans,
|
||||
uniqueScans: qr.scans.length, // Count of scans where isUnique=true
|
||||
_count: undefined,
|
||||
}));
|
||||
|
||||
return NextResponse.json(transformed);
|
||||
} catch (error) {
|
||||
console.error('Error fetching QR codes:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Plan limits
|
||||
const PLAN_LIMITS = {
|
||||
FREE: 3,
|
||||
PRO: 50,
|
||||
BUSINESS: 500,
|
||||
};
|
||||
|
||||
// POST /api/qrs - Create a new QR code
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// CSRF Protection
|
||||
const csrfCheck = csrfProtection(request);
|
||||
if (!csrfCheck.valid) {
|
||||
return NextResponse.json({ error: csrfCheck.error }, { status: 403 });
|
||||
}
|
||||
|
||||
const userId = cookies().get('userId')?.value;
|
||||
console.log('POST /api/qrs - userId from cookie:', userId);
|
||||
|
||||
// Rate Limiting (user-based)
|
||||
const clientId = userId || getClientIdentifier(request);
|
||||
const rateLimitResult = rateLimit(clientId, RateLimits.QR_CREATE);
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many requests. Please try again later.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized - no userId cookie' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check if user exists and get their plan
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { plan: true },
|
||||
});
|
||||
|
||||
console.log('User exists:', !!user);
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: `User not found: ${userId}` }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
console.log('Request body:', body);
|
||||
|
||||
// Validate request body with Zod (only for non-static QRs or simplified validation)
|
||||
// Note: Static QRs have complex nested content structure, so we do basic validation
|
||||
if (!body.isStatic) {
|
||||
const validation = await validateRequest(createQRSchema, body);
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(validation.error, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a static QR request
|
||||
const isStatic = body.isStatic === true;
|
||||
|
||||
// Only check limits for DYNAMIC QR codes (static QR codes are unlimited)
|
||||
if (!isStatic) {
|
||||
// Count existing dynamic QR codes
|
||||
const dynamicQRCount = await db.qRCode.count({
|
||||
where: {
|
||||
userId,
|
||||
type: 'DYNAMIC',
|
||||
},
|
||||
});
|
||||
|
||||
const userPlan = user.plan || 'FREE';
|
||||
const limit = PLAN_LIMITS[userPlan as keyof typeof PLAN_LIMITS] || PLAN_LIMITS.FREE;
|
||||
|
||||
if (dynamicQRCount >= limit) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Limit reached',
|
||||
message: `You have reached the limit of ${limit} dynamic QR codes for your ${userPlan} plan. Please upgrade to create more.`,
|
||||
currentCount: dynamicQRCount,
|
||||
limit,
|
||||
plan: userPlan,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let enrichedContent = body.content;
|
||||
|
||||
// For STATIC QR codes, calculate what the QR should contain
|
||||
if (isStatic) {
|
||||
let qrContent = '';
|
||||
switch (body.contentType) {
|
||||
case 'URL':
|
||||
qrContent = body.content.url;
|
||||
break;
|
||||
case 'PHONE':
|
||||
qrContent = `tel:${body.content.phone}`;
|
||||
break;
|
||||
case 'SMS':
|
||||
qrContent = `sms:${body.content.phone}${body.content.message ? `?body=${encodeURIComponent(body.content.message)}` : ''}`;
|
||||
break;
|
||||
case 'VCARD':
|
||||
qrContent = `BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:${body.content.firstName || ''} ${body.content.lastName || ''}
|
||||
N:${body.content.lastName || ''};${body.content.firstName || ''};;;
|
||||
${body.content.organization ? `ORG:${body.content.organization}` : ''}
|
||||
${body.content.title ? `TITLE:${body.content.title}` : ''}
|
||||
${body.content.email ? `EMAIL:${body.content.email}` : ''}
|
||||
${body.content.phone ? `TEL:${body.content.phone}` : ''}
|
||||
END:VCARD`;
|
||||
break;
|
||||
case 'GEO':
|
||||
const lat = body.content.latitude || 0;
|
||||
const lon = body.content.longitude || 0;
|
||||
const label = body.content.label ? `?q=${encodeURIComponent(body.content.label)}` : '';
|
||||
qrContent = `geo:${lat},${lon}${label}`;
|
||||
break;
|
||||
case 'TEXT':
|
||||
qrContent = body.content.text;
|
||||
break;
|
||||
case 'WHATSAPP':
|
||||
qrContent = `https://wa.me/${body.content.phone}${body.content.message ? `?text=${encodeURIComponent(body.content.message)}` : ''}`;
|
||||
break;
|
||||
case 'PDF':
|
||||
qrContent = body.content.fileUrl || 'https://example.com/file.pdf';
|
||||
break;
|
||||
case 'APP':
|
||||
qrContent = body.content.fallbackUrl || body.content.iosUrl || body.content.androidUrl || 'https://example.com';
|
||||
break;
|
||||
case 'COUPON':
|
||||
qrContent = `Coupon: ${body.content.code || 'CODE'} - ${body.content.discount || 'Discount'}`;
|
||||
break;
|
||||
case 'FEEDBACK':
|
||||
qrContent = body.content.feedbackUrl || 'https://example.com/feedback';
|
||||
break;
|
||||
default:
|
||||
qrContent = body.content.url || 'https://example.com';
|
||||
}
|
||||
|
||||
// Add qrContent to the content object
|
||||
enrichedContent = {
|
||||
...body.content,
|
||||
qrContent // This is what the QR code should actually contain
|
||||
};
|
||||
}
|
||||
|
||||
// Generate slug for the QR code
|
||||
const slug = generateSlug(body.title);
|
||||
|
||||
// Create QR code
|
||||
const qrCode = await db.qRCode.create({
|
||||
data: {
|
||||
userId,
|
||||
title: body.title,
|
||||
type: isStatic ? 'STATIC' : 'DYNAMIC',
|
||||
contentType: body.contentType,
|
||||
content: enrichedContent,
|
||||
tags: body.tags || [],
|
||||
style: body.style || {
|
||||
foregroundColor: '#000000',
|
||||
backgroundColor: '#FFFFFF',
|
||||
cornerStyle: 'square',
|
||||
size: 200,
|
||||
},
|
||||
slug,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(qrCode);
|
||||
} catch (error) {
|
||||
console.error('Error creating QR code:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error', details: String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user