This commit is contained in:
knuthtimo-lab 2026-01-22 19:31:05 +01:00
parent 1251584b13
commit 3682673852
7 changed files with 161 additions and 61 deletions

View File

@ -15,7 +15,7 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useCsrf } from '@/hooks/useCsrf'; import { useCsrf } from '@/hooks/useCsrf';
import { showToast } from '@/components/ui/Toast'; import { showToast } from '@/components/ui/Toast';
import { import {
Globe, User, MapPin, Phone, FileText, Smartphone, Ticket, Star, HelpCircle Globe, User, MapPin, Phone, FileText, Smartphone, Ticket, Star, HelpCircle, Upload
} from 'lucide-react'; } from 'lucide-react';
// Tooltip component for form field help // Tooltip component for form field help
@ -66,6 +66,7 @@ export default function CreatePage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { fetchWithCsrf } = useCsrf(); const { fetchWithCsrf } = useCsrf();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const [userPlan, setUserPlan] = useState<string>('FREE'); const [userPlan, setUserPlan] = useState<string>('FREE');
const qrRef = useRef<HTMLDivElement>(null); const qrRef = useRef<HTMLDivElement>(null);

View File

@ -7,6 +7,18 @@ import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { showToast } from '@/components/ui/Toast'; import { showToast } from '@/components/ui/Toast';
import { useCsrf } from '@/hooks/useCsrf'; import { useCsrf } from '@/hooks/useCsrf';
import { Upload, FileText, HelpCircle } from 'lucide-react';
// Tooltip component for form field help
const Tooltip = ({ text }: { text: string }) => (
<div className="group relative inline-block ml-1">
<HelpCircle className="w-4 h-4 text-gray-400 cursor-help" />
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-gray-900 text-white text-xs rounded-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 w-48 text-center">
{text}
<div className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
</div>
</div>
);
export default function EditQRPage() { export default function EditQRPage() {
const router = useRouter(); const router = useRouter();
@ -16,6 +28,7 @@ export default function EditQRPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [qrCode, setQrCode] = useState<any>(null); const [qrCode, setQrCode] = useState<any>(null);
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [content, setContent] = useState<any>({}); const [content, setContent] = useState<any>({});
@ -45,6 +58,41 @@ export default function EditQRPage() {
fetchQRCode(); fetchQRCode();
}, [qrId, router]); }, [qrId, router]);
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// 10MB limit
if (file.size > 10 * 1024 * 1024) {
showToast('File size too large (max 10MB)', 'error');
return;
}
setUploading(true);
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
const data = await response.json();
if (response.ok) {
setContent({ ...content, fileUrl: data.url, fileName: data.filename });
showToast('File uploaded successfully!', 'success');
} else {
showToast(data.error || 'Upload failed', 'error');
}
} catch (error) {
console.error('Upload error:', error);
showToast('Error uploading file', 'error');
} finally {
setUploading(false);
}
};
const handleSave = async () => { const handleSave = async () => {
setSaving(true); setSaving(true);
@ -244,19 +292,58 @@ export default function EditQRPage() {
{qrCode.contentType === 'PDF' && ( {qrCode.contentType === 'PDF' && (
<> <>
<Input <div>
label="PDF/File URL" <div className="flex items-center mb-1">
value={content.fileUrl || ''} <label className="block text-sm font-medium text-gray-700">Upload Menu / PDF</label>
onChange={(e) => setContent({ ...content, fileUrl: e.target.value })} <Tooltip text="Upload your menu PDF (Max 10MB). Hosted securely." />
placeholder="https://drive.google.com/file/d/.../view" </div>
required
/> <div className="mt-2 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-lg hover:bg-gray-50 transition-colors relative">
<Input <div className="space-y-1 text-center">
label="File Name (optional)" {uploading ? (
value={content.fileName || ''} <div className="flex flex-col items-center">
onChange={(e) => setContent({ ...content, fileName: e.target.value })} <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500 mb-2"></div>
placeholder="Product Catalog 2026" <p className="text-sm text-gray-500">Uploading...</p>
/> </div>
) : content.fileUrl ? (
<div className="flex flex-col items-center">
<div className="mx-auto h-12 w-12 text-primary-500 bg-primary-50 rounded-full flex items-center justify-center mb-2">
<FileText className="h-6 w-6" />
</div>
<p className="text-sm text-green-600 font-medium mb-1">Upload Complete!</p>
<a href={content.fileUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-primary-500 hover:underline break-all max-w-xs mb-3 block">
{content.fileName || 'View File'}
</a>
<label htmlFor="file-upload" className="cursor-pointer bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
<span>Replace File</span>
<input id="file-upload" name="file-upload" type="file" className="sr-only" accept=".pdf,image/*" onChange={handleFileUpload} />
</label>
</div>
) : (
<>
<Upload className="mx-auto h-12 w-12 text-gray-400" />
<div className="flex text-sm text-gray-600 justify-center">
<label htmlFor="file-upload" className="relative cursor-pointer bg-white rounded-md font-medium text-primary-600 hover:text-primary-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-primary-500">
<span>Upload a file</span>
<input id="file-upload" name="file-upload" type="file" className="sr-only" accept=".pdf,image/*" onChange={handleFileUpload} />
</label>
<p className="pl-1">or drag and drop</p>
</div>
<p className="text-xs text-gray-500">PDF, PNG, JPG up to 10MB</p>
</>
)}
</div>
</div>
</div>
{content.fileUrl && (
<Input
label="File Name / Menu Title"
value={content.fileName || ''}
onChange={(e) => setContent({ ...content, fileName: e.target.value })}
placeholder="Product Catalog 2026"
/>
)}
</> </>
)} )}

View File

@ -63,7 +63,6 @@ export async function POST(request: NextRequest) {
} }
const userId = cookies().get('userId')?.value; const userId = cookies().get('userId')?.value;
console.log('POST /api/qrs - userId from cookie:', userId);
// Rate Limiting (user-based) // Rate Limiting (user-based)
const clientId = userId || getClientIdentifier(request); const clientId = userId || getClientIdentifier(request);
@ -90,20 +89,16 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized - no userId cookie' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized - no userId cookie' }, { status: 401 });
} }
// Check if user exists and get their plan
const user = await db.user.findUnique({ const user = await db.user.findUnique({
where: { id: userId }, where: { id: userId },
select: { plan: true }, select: { plan: true },
}); });
console.log('User exists:', !!user);
if (!user) { if (!user) {
return NextResponse.json({ error: `User not found: ${userId}` }, { status: 404 }); return NextResponse.json({ error: `User not found: ${userId}` }, { status: 404 });
} }
const body = await request.json(); const body = await request.json();
console.log('Request body:', body);
// Validate request body with Zod (only for non-static QRs or simplified validation) // 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 // Note: Static QRs have complex nested content structure, so we do basic validation

View File

@ -3,12 +3,30 @@ import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth'; import { authOptions } from '@/lib/auth';
import { uploadFileToR2 } from '@/lib/r2'; import { uploadFileToR2 } from '@/lib/r2';
import { env } from '@/lib/env'; import { env } from '@/lib/env';
import { db } from '@/lib/db';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
// 1. Authentication Check // 1. Authentication Check
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session || !session.user) { let userId = session?.user?.id;
// Fallback: Check for simple-login cookie if no NextAuth session
if (!userId) {
const cookieUserId = request.cookies.get('userId')?.value;
if (cookieUserId) {
// Verify user exists
const user = await db.user.findUnique({
where: { id: cookieUserId },
select: { id: true }
});
if (user) {
userId = user.id;
}
}
}
if (!userId) {
return new NextResponse('Unauthorized', { status: 401 }); return new NextResponse('Unauthorized', { status: 401 });
} }

View File

@ -69,30 +69,30 @@ export default function CouponPage() {
const isExpired = coupon.expiryDate && new Date(coupon.expiryDate) < new Date(); const isExpired = coupon.expiryDate && new Date(coupon.expiryDate) < new Date();
return ( return (<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#FAF8F5] via-[#C6C0B3] to-[#4C5F4E] px-6 py-12">
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-rose-50 via-pink-50 to-orange-50 px-6 py-12"> <div className="max-w-sm w-full">
<div className="max-w-sm w-full"> {/* Card */}
{/* Card */} <div className="bg-white rounded-3xl shadow-xl overflow-hidden">
<div className="bg-white rounded-3xl shadow-xl overflow-hidden"> {/* Colorful Header */}
{/* Colorful Header */} <div className="bg-gradient-to-br from-[#DB5375] to-[#B3FFB3] text-gray-900 p-8 text-center relative overflow-hidden">
<div className="bg-gradient-to-r from-rose-500 to-orange-500 text-white p-8 text-center relative overflow-hidden"> {/* Decorative circles */}
{/* Decorative circles */} <div className="absolute top-0 right-0 w-32 h-32 bg-white/10 rounded-full -translate-y-1/2 translate-x-1/2"></div>
<div className="absolute top-0 right-0 w-32 h-32 bg-white/10 rounded-full -translate-y-1/2 translate-x-1/2"></div> <div className="absolute bottom-0 left-0 w-24 h-24 bg-white/10 rounded-full translate-y-1/2 -translate-x-1/2"></div>
<div className="absolute bottom-0 left-0 w-24 h-24 bg-white/10 rounded-full translate-y-1/2 -translate-x-1/2"></div>
<div className="relative"> <div className="relative">
<div className="w-14 h-14 bg-white/20 rounded-2xl flex items-center justify-center mx-auto mb-4"> <div className="w-14 h-14 bg-[#DB5375]/10 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Gift className="w-7 h-7 text-white" /> <Gift className="w-7 h-7 text-[#DB5375]" />
</div>
<p className="text-white/80 text-sm mb-1">{coupon.title || 'Special Offer'}</p>
<p className="text-4xl font-bold tracking-tight">{coupon.discount}</p>
</div> </div>
<p className="text-gray-700 text-sm mb-1">{coupon.title || 'Special Offer'}</p>
<p className="text-4xl font-bold tracking-tight text-gray-900">{coupon.discount}</p>
</div> </div>
</div>
{/* Dotted line with circles */} {/* Dotted line with circles */}
<div className="relative py-4">
<div className="relative py-4"> <div className="relative py-4">
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-5 h-10 bg-gradient-to-br from-rose-50 via-pink-50 to-orange-50 rounded-r-full"></div> <div className="absolute left-0 top-1/2 -translate-y-1/2 w-5 h-10 bg-gradient-to-br from-[#FAF8F5] via-[#C6C0B3] to-[#4C5F4E] rounded-r-full"></div>
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-5 h-10 bg-gradient-to-br from-rose-50 via-pink-50 to-orange-50 rounded-l-full"></div> <div className="absolute right-0 top-1/2 -translate-y-1/2 w-5 h-10 bg-gradient-to-br from-[#FAF8F5] via-[#C6C0B3] to-[#4C5F4E] rounded-l-full"></div>
<div className="border-t-2 border-dashed border-gray-200 mx-8"></div> <div className="border-t-2 border-dashed border-gray-200 mx-8"></div>
</div> </div>
@ -103,8 +103,8 @@ export default function CouponPage() {
)} )}
{/* Code Box */} {/* Code Box */}
<div className="bg-gradient-to-r from-rose-50 to-orange-50 rounded-2xl p-5 mb-4 border border-rose-100"> <div className="bg-gray-50 rounded-2xl p-5 mb-4 border border-emerald-100">
<p className="text-xs text-rose-400 text-center mb-2 font-medium uppercase tracking-wider">Your Code</p> <p className="text-xs text-emerald-600 text-center mb-2 font-medium uppercase tracking-wider">Your Code</p>
<div className="flex items-center justify-center gap-3"> <div className="flex items-center justify-center gap-3">
<code className="text-2xl font-mono font-bold text-gray-900 tracking-wider"> <code className="text-2xl font-mono font-bold text-gray-900 tracking-wider">
{coupon.code} {coupon.code}
@ -112,8 +112,8 @@ export default function CouponPage() {
<button <button
onClick={copyCode} onClick={copyCode}
className={`p-2.5 rounded-xl transition-all ${copied className={`p-2.5 rounded-xl transition-all ${copied
? 'bg-emerald-100 text-emerald-600' ? 'bg-emerald-100 text-emerald-600'
: 'bg-white text-gray-500 hover:text-rose-500 shadow-sm hover:shadow' : 'bg-white text-gray-500 hover:text-rose-500 shadow-sm hover:shadow'
}`} }`}
aria-label="Copy code" aria-label="Copy code"
> >
@ -145,7 +145,7 @@ export default function CouponPage() {
href={coupon.redeemUrl} href={coupon.redeemUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="block w-full py-4 rounded-xl font-semibold text-center bg-gradient-to-r from-rose-500 to-orange-500 text-white hover:from-rose-600 hover:to-orange-600 transition-all shadow-lg shadow-rose-200" className="block w-full py-4 rounded-xl font-semibold text-center bg-gradient-to-r from-[#076653] to-[#0C342C] text-white hover:from-[#087861] hover:to-[#0E4036] transition-all shadow-lg shadow-emerald-200"
> >
<span className="flex items-center justify-center gap-2"> <span className="flex items-center justify-center gap-2">
Redeem Now Redeem Now
@ -157,10 +157,11 @@ export default function CouponPage() {
</div> </div>
{/* Footer */} {/* Footer */}
<p className="text-center text-sm text-rose-300 mt-6"> <p className="text-center text-sm text-white/60 mt-6">
Powered by QR Master Powered by QR Master
</p> </p>
</div> </div>
</div> </div>
</div>
); );
} }

View File

@ -68,7 +68,7 @@ export default function FeedbackPage() {
// Loading // Loading
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100"> <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#FAF8F5] via-[#C6C0B3] to-[#4C5F4E]">
<div className="w-10 h-10 border-3 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div> <div className="w-10 h-10 border-3 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
</div> </div>
); );
@ -77,7 +77,7 @@ export default function FeedbackPage() {
// Not found // Not found
if (!feedback) { if (!feedback) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 px-6"> <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#FAF8F5] via-[#C6C0B3] to-[#4C5F4E] px-6">
<div className="text-center bg-white rounded-2xl p-8 shadow-lg"> <div className="text-center bg-white rounded-2xl p-8 shadow-lg">
<p className="text-gray-500 text-lg">This feedback form is not available.</p> <p className="text-gray-500 text-lg">This feedback form is not available.</p>
</div> </div>
@ -88,9 +88,9 @@ export default function FeedbackPage() {
// Success // Success
if (submitted) { if (submitted) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-emerald-50 to-teal-100 px-6"> <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#FAF8F5] via-[#C6C0B3] to-[#4C5F4E] px-6">
<div className="max-w-sm w-full bg-white rounded-3xl shadow-xl p-10 text-center"> <div className="max-w-sm w-full bg-white rounded-3xl shadow-xl p-10 text-center">
<div className="w-20 h-20 bg-gradient-to-br from-emerald-400 to-teal-500 rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg"> <div className="w-20 h-20 bg-gradient-to-br from-[#4C5F4E] to-[#FAF8F5] rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg">
<Check className="w-10 h-10 text-white" strokeWidth={2.5} /> <Check className="w-10 h-10 text-white" strokeWidth={2.5} />
</div> </div>
@ -114,17 +114,17 @@ export default function FeedbackPage() {
// Rating Form // Rating Form
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 px-6 py-12"> <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#FAF8F5] via-[#C6C0B3] to-[#4C5F4E] px-6 py-12">
<div className="max-w-md w-full"> <div className="max-w-md w-full">
{/* Card */} {/* Card */}
<div className="bg-white rounded-3xl shadow-xl overflow-hidden"> <div className="bg-white rounded-3xl shadow-xl overflow-hidden">
{/* Colored Header */} {/* Colored Header */}
<div className="bg-gradient-to-r from-indigo-500 to-purple-600 text-white p-8 text-center"> <div className="bg-gradient-to-r from-[#4C5F4E] via-[#C6C0B3] to-[#FAF8F5] p-8 text-center">
<div className="w-14 h-14 bg-white/20 rounded-2xl flex items-center justify-center mx-auto mb-4"> <div className="w-14 h-14 bg-white/20 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Star className="w-7 h-7 text-white" /> <Star className="w-7 h-7 text-white" />
</div> </div>
<h1 className="text-2xl font-bold mb-1">How was your experience?</h1> <h1 className="text-2xl font-bold mb-1 text-gray-900">How was your experience?</h1>
<p className="text-white/80">{feedback.businessName}</p> <p className="text-gray-700">{feedback.businessName}</p>
</div> </div>
{/* Content */} {/* Content */}
@ -142,8 +142,8 @@ export default function FeedbackPage() {
> >
<Star <Star
className={`w-11 h-11 transition-all ${star <= (hoverRating || rating) className={`w-11 h-11 transition-all ${star <= (hoverRating || rating)
? 'text-amber-400 fill-amber-400 drop-shadow-sm' ? 'text-amber-400 fill-amber-400 drop-shadow-sm'
: 'text-gray-200' : 'text-gray-200'
}`} }`}
/> />
</button> </button>
@ -175,8 +175,8 @@ export default function FeedbackPage() {
onClick={handleSubmit} onClick={handleSubmit}
disabled={rating === 0 || submitting} disabled={rating === 0 || submitting}
className={`w-full py-4 rounded-xl font-semibold flex items-center justify-center gap-2 transition-all ${rating === 0 className={`w-full py-4 rounded-xl font-semibold flex items-center justify-center gap-2 transition-all ${rating === 0
? 'bg-gray-100 text-gray-400 cursor-not-allowed' ? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gradient-to-r from-indigo-500 to-purple-600 text-white hover:from-indigo-600 hover:to-purple-700 shadow-lg shadow-indigo-200' : 'bg-gradient-to-r from-[#4C5F4E] to-[#0C342C] text-white hover:from-[#5a705c] hover:to-[#0E4036] shadow-lg shadow-emerald-200'
}`} }`}
> >
<Send className="w-4 h-4" /> <Send className="w-4 h-4" />
@ -186,7 +186,7 @@ export default function FeedbackPage() {
</div> </div>
{/* Footer */} {/* Footer */}
<p className="text-center text-sm text-indigo-300 mt-6"> <p className="text-center text-sm text-white/60 mt-6">
Powered by QR Master Powered by QR Master
</p> </p>
</div> </div>

View File

@ -6,8 +6,6 @@ const globalForPrisma = globalThis as unknown as {
export const db = export const db =
globalForPrisma.prisma ?? globalForPrisma.prisma ??
new PrismaClient({ new PrismaClient();
log: ['query'],
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db; if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;