Files
QR-master/src/app/(app)/create/page.tsx
Timo Knuth eb2faec952 feat: Add 19 free QR code tools with SEO optimization
- Added PayPal, Zoom, Teams QR generators
- Added lazy loading for html-to-image (performance)
- Created 19 OG images for social sharing
- Added robots.txt and updated sitemap
- Fixed mobile navigation with accordion menu
- Added 7 color options per generator
- Fixed crypto QR with universal/wallet mode toggle
- Hero QR codes all point to qrmaster.net
2026-01-10 00:22:07 +01:00

771 lines
30 KiB
TypeScript

'use client';
import React, { useState, useEffect, useRef } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { QRCodeSVG } from 'qrcode.react';
import { toPng } from 'html-to-image';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { calculateContrast, cn } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation';
import { useCsrf } from '@/hooks/useCsrf';
import { showToast } from '@/components/ui/Toast';
// Content-type specific frame options
const getFrameOptionsForContentType = (contentType: string) => {
const baseOptions = [{ id: 'none', label: 'No Frame' }, { id: 'scanme', label: 'Scan Me' }];
switch (contentType) {
case 'URL':
return [...baseOptions, { id: 'website', label: 'Website' }, { id: 'visit', label: 'Visit' }];
case 'PHONE':
return [...baseOptions, { id: 'callme', label: 'Call Me' }, { id: 'call', label: 'Call' }];
case 'GEO':
return [...baseOptions, { id: 'findus', label: 'Find Us' }, { id: 'navigate', label: 'Navigate' }];
case 'VCARD':
return [...baseOptions, { id: 'contact', label: 'Contact' }, { id: 'save', label: 'Save' }];
case 'SMS':
return [...baseOptions, { id: 'textme', label: 'Text Me' }, { id: 'message', label: 'Message' }];
case 'WHATSAPP':
return [...baseOptions, { id: 'chatme', label: 'Chat Me' }, { id: 'whatsapp', label: 'WhatsApp' }];
case 'TEXT':
return [...baseOptions, { id: 'read', label: 'Read' }, { id: 'info', label: 'Info' }];
default:
return [...baseOptions, { id: 'website', label: 'Website' }, { id: 'visit', label: 'Visit' }];
}
};
export default function CreatePage() {
const router = useRouter();
const { t } = useTranslation();
const { fetchWithCsrf } = useCsrf();
const [loading, setLoading] = useState(false);
const [userPlan, setUserPlan] = useState<string>('FREE');
const qrRef = useRef<HTMLDivElement>(null);
// Form state
const [title, setTitle] = useState('');
const [contentType, setContentType] = useState('URL');
const [content, setContent] = useState<any>({ url: '' });
const [isDynamic, setIsDynamic] = useState(true);
// Style state
const [foregroundColor, setForegroundColor] = useState('#000000');
const [backgroundColor, setBackgroundColor] = useState('#FFFFFF');
const [cornerStyle, setCornerStyle] = useState('square');
const [size, setSize] = useState(200);
const [frameType, setFrameType] = useState('none');
// Get frame options for current content type
const frameOptions = getFrameOptionsForContentType(contentType);
// Reset frame type when content type changes (if current frame is not valid)
useEffect(() => {
const validIds = frameOptions.map(f => f.id);
if (!validIds.includes(frameType)) {
setFrameType('none');
}
}, [contentType, frameOptions, frameType]);
// Logo state
const [logoUrl, setLogoUrl] = useState('');
const [logoSize, setLogoSize] = useState(24);
const [excavate, setExcavate] = useState(true);
// QR preview
const [qrDataUrl, setQrDataUrl] = useState('');
// Check if user can customize colors (PRO+ only)
const canCustomizeColors = userPlan === 'PRO' || userPlan === 'BUSINESS';
// Load user plan
useEffect(() => {
const fetchUserPlan = async () => {
try {
const response = await fetch('/api/user/plan');
if (response.ok) {
const data = await response.json();
setUserPlan(data.plan || 'FREE');
}
} catch (error) {
console.error('Error fetching user plan:', error);
}
};
fetchUserPlan();
}, []);
const contrast = calculateContrast(foregroundColor, backgroundColor);
const hasGoodContrast = contrast >= 4.5;
const contentTypes = [
{ value: 'URL', label: 'URL / Website' },
{ value: 'VCARD', label: 'Contact Card' },
{ value: 'GEO', label: 'Location/Maps' },
{ value: 'PHONE', label: 'Phone Number' },
];
// Get QR content based on content type
const getQRContent = () => {
switch (contentType) {
case 'URL':
return content.url || 'https://example.com';
case 'PHONE':
return `tel:${content.phone || '+1234567890'}`;
case 'SMS':
return `sms:${content.phone || '+1234567890'}${content.message ? `?body=${encodeURIComponent(content.message)}` : ''}`;
case 'VCARD':
return `BEGIN:VCARD\nVERSION:3.0\nFN:${content.firstName || 'John'} ${content.lastName || 'Doe'}\nORG:${content.organization || 'Company'}\nTITLE:${content.title || 'Position'}\nEMAIL:${content.email || 'email@example.com'}\nTEL:${content.phone || '+1234567890'}\nEND:VCARD`;
case 'GEO':
const lat = content.latitude || 37.7749;
const lon = content.longitude || -122.4194;
const label = content.label ? `?q=${encodeURIComponent(content.label)}` : '';
return `geo:${lat},${lon}${label}`;
case 'TEXT':
return content.text || 'Sample text';
case 'WHATSAPP':
return `https://wa.me/${content.phone || '+1234567890'}${content.message ? `?text=${encodeURIComponent(content.message)}` : ''}`;
default:
return 'https://example.com';
}
};
const qrContent = getQRContent();
const getFrameLabel = () => {
const frame = frameOptions.find((f: { id: string; label: string }) => f.id === frameType);
return frame?.id !== 'none' ? frame?.label : null;
};
const downloadQR = async (format: 'svg' | 'png') => {
if (!qrRef.current) return;
try {
if (format === 'png') {
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' });
const link = document.createElement('a');
link.download = `qrcode-${title || 'download'}.png`;
link.href = dataUrl;
link.click();
} else {
// For SVG, we might still want to use the library or just toPng if SVG export of HTML is not needed
// Simplest is to check if we can export the SVG element directly but that misses the frame HTML.
// html-to-image can generate SVG too.
// But usually for SVG users want the vector. Capturing HTML to SVG is possible but complex.
// For now, let's just stick to the SVG code export if NO FRAME is selected,
// otherwise warn or use toPng (as SVG).
// Actually, the previous implementation was good for pure QR.
// If frame is selected, we MUST use a raster export (PNG) or complex HTML-to-SVG.
// Let's rely on toPng for consistency with frames.
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' });
// Wait, exporting HTML to valid vector SVG is hard.
// Let's just offer PNG for frames for now to be safe, or just use the same PNG download for both buttons if frame is active?
// No, let's try to grab the INNER SVG if no frame, else...
if (frameType === 'none') {
const svgElement = qrRef.current.querySelector('svg');
if (svgElement) {
const svgData = new XMLSerializer().serializeToString(svgElement);
const blob = new Blob([svgData], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `qrcode-${title || 'download'}.svg`;
a.click();
URL.revokeObjectURL(url);
}
} else {
showToast('SVG download not available with frames yet. Downloading PNG instead.', 'info');
const dataUrl = await toPng(qrRef.current, { cacheBust: true, pixelRatio: 3, backgroundColor: 'transparent' });
const link = document.createElement('a');
link.download = `qrcode-${title || 'download'}.png`;
link.href = dataUrl;
link.click();
}
}
} catch (err) {
console.error('Error downloading QR code:', err);
showToast('Error downloading QR code', 'error');
}
};
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > 10 * 1024 * 1024) { // 10MB limit (soft limit for upload, will be resized)
showToast('Logo file size too large (max 10MB)', 'error');
return;
}
const reader = new FileReader();
reader.onload = (evt) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const maxDimension = 500; // Resize to max 500px
let width = img.width;
let height = img.height;
if (width > maxDimension || height > maxDimension) {
if (width > height) {
height = Math.round((height * maxDimension) / width);
width = maxDimension;
} else {
width = Math.round((width * maxDimension) / height);
height = maxDimension;
}
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx?.drawImage(img, 0, 0, width, height);
// Compress to JPEG/PNG with reduced quality to save space
const dataUrl = canvas.toDataURL(file.type === 'image/png' ? 'image/png' : 'image/jpeg', 0.8);
setLogoUrl(dataUrl);
};
img.src = evt.target?.result as string;
};
reader.readAsDataURL(file);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const qrData = {
title,
contentType,
content,
isStatic: !isDynamic,
tags: [],
style: {
// FREE users can only use black/white
foregroundColor: canCustomizeColors ? foregroundColor : '#000000',
backgroundColor: canCustomizeColors ? backgroundColor : '#FFFFFF',
cornerStyle,
size,
imageSettings: (canCustomizeColors && logoUrl) ? {
src: logoUrl,
height: logoSize,
width: logoSize,
excavate,
} : undefined,
frameType, // Save frame type
},
};
console.log('SENDING QR DATA:', qrData);
const response = await fetchWithCsrf('/api/qrs', {
method: 'POST',
body: JSON.stringify(qrData),
});
const responseData = await response.json();
console.log('RESPONSE DATA:', responseData);
if (response.ok) {
showToast(`QR Code "${title}" created successfully!`, 'success');
// Wait a moment so user sees the toast, then redirect
setTimeout(() => {
router.push('/dashboard');
router.refresh();
}, 1000);
} else {
console.error('Error creating QR code:', responseData);
showToast(responseData.error || 'Error creating QR code', 'error');
}
} catch (error) {
console.error('Error creating QR code:', error);
showToast('Error creating QR code. Please try again.', 'error');
} finally {
setLoading(false);
}
};
const renderContentFields = () => {
switch (contentType) {
case 'URL':
return (
<Input
label="URL"
value={content.url || ''}
onChange={(e) => setContent({ url: e.target.value })}
placeholder="https://example.com"
required
/>
);
case 'PHONE':
return (
<Input
label="Phone Number"
value={content.phone || ''}
onChange={(e) => setContent({ phone: e.target.value })}
placeholder="+1234567890"
required
/>
);
case 'VCARD':
return (
<>
<Input
label="First Name"
value={content.firstName || ''}
onChange={(e) => setContent({ ...content, firstName: e.target.value })}
placeholder="John"
required
/>
<Input
label="Last Name"
value={content.lastName || ''}
onChange={(e) => setContent({ ...content, lastName: e.target.value })}
placeholder="Doe"
required
/>
<Input
label="Email Address"
type="email"
value={content.email || ''}
onChange={(e) => setContent({ ...content, email: e.target.value })}
placeholder="john@example.com"
/>
<Input
label="Phone Number"
value={content.phone || ''}
onChange={(e) => setContent({ ...content, phone: e.target.value })}
placeholder="+1234567890"
/>
<Input
label="Company/Organization"
value={content.organization || ''}
onChange={(e) => setContent({ ...content, organization: e.target.value })}
placeholder="Company Name"
/>
<Input
label="Job Title"
value={content.title || ''}
onChange={(e) => setContent({ ...content, title: e.target.value })}
placeholder="CEO"
/>
</>
);
case 'GEO':
return (
<>
<Input
label="Latitude"
type="number"
step="any"
value={content.latitude || ''}
onChange={(e) => setContent({ ...content, latitude: parseFloat(e.target.value) || 0 })}
placeholder="37.7749"
required
/>
<Input
label="Longitude"
type="number"
step="any"
value={content.longitude || ''}
onChange={(e) => setContent({ ...content, longitude: parseFloat(e.target.value) || 0 })}
placeholder="-122.4194"
required
/>
<Input
label="Location Label (optional)"
value={content.label || ''}
onChange={(e) => setContent({ ...content, label: e.target.value })}
placeholder="Golden Gate Bridge"
/>
</>
);
case 'TEXT':
return (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Text</label>
<textarea
value={content.text || ''}
onChange={(e) => setContent({ text: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
rows={4}
placeholder="Enter your text here..."
required
/>
</div>
);
default:
return null;
}
};
return (
<div className="max-w-6xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">{t('create.title')}</h1>
<p className="text-gray-600 mt-2">{t('create.subtitle')}</p>
</div>
<form onSubmit={handleSubmit}>
<div className="grid lg:grid-cols-3 gap-8">
{/* Left: Form */}
<div className="lg:col-span-2 space-y-6">
{/* Content Section */}
<Card>
<CardHeader>
<CardTitle>{t('create.content')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Input
label="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="My QR Code"
required
/>
<Select
label="Content Type"
value={contentType}
onChange={(e) => setContentType(e.target.value)}
options={contentTypes}
/>
{renderContentFields()}
</CardContent>
</Card>
{/* QR Type Section */}
<Card>
<CardHeader>
<CardTitle>QR Code Type</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center space-x-4">
<label className="flex items-center cursor-pointer">
<input
type="radio"
checked={isDynamic}
onChange={() => setIsDynamic(true)}
className="mr-2"
/>
<span className="font-medium">Dynamic</span>
<Badge variant="info" className="ml-2">Recommended</Badge>
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
checked={!isDynamic}
onChange={() => setIsDynamic(false)}
className="mr-2"
/>
<span className="font-medium">Static</span>
</label>
</div>
<p className="text-sm text-gray-600 mt-2">
{isDynamic
? '✅ Dynamic: Track scans, edit URL later, view analytics. QR contains tracking link.'
: '⚡ Static: Direct to content, no tracking, cannot edit. QR contains actual content.'}
</p>
</CardContent>
</Card>
{/* Style Section */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{t('create.style')}</CardTitle>
{!canCustomizeColors && (
<Badge variant="warning">PRO Feature</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-6">
{!canCustomizeColors && (
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg mb-4">
<p className="text-sm text-blue-900">
<strong>Upgrade to PRO</strong> to customize colors, add logos, and brand your QR codes.
</p>
<Link href="/pricing">
<Button variant="primary" size="sm" className="mt-2">
Upgrade Now
</Button>
</Link>
</div>
)}
{/* Frame Options */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">Frame</label>
<div className="grid grid-cols-4 gap-2">
{frameOptions.map((frame: { id: string; label: string }) => (
<button
key={frame.id}
type="button"
onClick={() => setFrameType(frame.id)}
className={cn(
"py-2 px-3 rounded-lg text-sm font-medium transition-all border",
frameType === frame.id
? "bg-slate-900 text-white border-slate-900"
: "bg-gray-50 text-gray-600 border-gray-200 hover:border-gray-300"
)}
>
{frame.label}
</button>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Foreground Color
</label>
<div className="flex items-center space-x-2">
<input
type="color"
value={foregroundColor}
onChange={(e) => setForegroundColor(e.target.value)}
className="w-12 h-10 rounded border border-gray-300"
disabled={!canCustomizeColors}
/>
<Input
value={foregroundColor}
onChange={(e) => setForegroundColor(e.target.value)}
className="flex-1"
disabled={!canCustomizeColors}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Background Color
</label>
<div className="flex items-center space-x-2">
<input
type="color"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="w-12 h-10 rounded border border-gray-300"
disabled={!canCustomizeColors}
/>
<Input
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="flex-1"
disabled={!canCustomizeColors}
/>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<Select
label="Corner Style"
value={cornerStyle}
onChange={(e) => setCornerStyle(e.target.value)}
options={[
{ value: 'square', label: 'Square' },
{ value: 'rounded', label: 'Rounded' },
]}
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Size: {size}px
</label>
<input
type="range"
min="100"
max="400"
value={size}
onChange={(e) => setSize(Number(e.target.value))}
className="w-full"
/>
</div>
</div>
<div className="flex items-center justify-between">
<Badge variant={hasGoodContrast ? 'success' : 'warning'}>
{hasGoodContrast ? 'Good contrast' : 'Low contrast'}
</Badge>
<span className="text-sm text-gray-500">
Contrast ratio: {contrast.toFixed(1)}:1
</span>
</div>
</CardContent>
</Card>
{/* Logo Section */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Logo</CardTitle>
{!canCustomizeColors && (
<Badge variant="warning">PRO Feature</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{!canCustomizeColors && (
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg mb-4">
<p className="text-sm text-blue-900">
<strong>Upgrade to PRO</strong> to add logos to your QR codes.
</p>
<Link href="/pricing">
<Button variant="primary" size="sm" className="mt-2">
Upgrade Now
</Button>
</Link>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Upload Logo
</label>
<div className="flex items-center space-x-4">
<input
type="file"
accept="image/*"
onChange={handleLogoUpload}
disabled={!canCustomizeColors}
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 disabled:opacity-50 disabled:cursor-not-allowed"
/>
{logoUrl && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setLogoUrl('');
setLogoSize(40);
}}
>
Remove
</Button>
)}
</div>
</div>
{logoUrl && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Logo Size: {logoSize}px
</label>
<input
type="range"
min="20"
max="70"
value={logoSize}
onChange={(e) => setLogoSize(Number(e.target.value))}
className="w-full"
/>
</div>
<div className="flex items-center">
<input
type="checkbox"
checked={excavate}
onChange={(e) => setExcavate(e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
id="excavate-checkbox"
/>
<label htmlFor="excavate-checkbox" className="ml-2 block text-sm text-gray-900">
Excavate background (remove dots behind logo)
</label>
</div>
</>
)}
</CardContent>
</Card>
</div>
{/* Right: Preview */}
<div className="lg:col-span-1">
<Card className="sticky top-6">
<CardHeader>
<CardTitle>{t('create.preview')}</CardTitle>
</CardHeader>
<CardContent className="text-center">
<div id="create-qr-preview" className="flex justify-center mb-4">
{/* WRAPPER FOR REF AND FRAME */}
<div
ref={qrRef}
className="relative bg-white rounded-xl p-4 flex flex-col items-center justify-center transition-all duration-300"
style={{
minWidth: '280px',
minHeight: '280px',
}}
>
{/* Frame Label */}
{getFrameLabel() && (
<div
className="mb-4 px-6 py-2 rounded-full font-bold text-sm tracking-widest uppercase shadow-md text-white"
style={{ backgroundColor: foregroundColor }}
>
{getFrameLabel()}
</div>
)}
{qrContent ? (
<div className={cornerStyle === 'rounded' ? 'rounded-lg overflow-hidden' : ''}>
<QRCodeSVG
value={qrContent}
size={size}
fgColor={foregroundColor}
bgColor={backgroundColor}
level="H"
includeMargin={false}
imageSettings={logoUrl ? {
src: logoUrl,
height: logoSize,
width: logoSize,
excavate: excavate,
} : undefined}
/>
</div>
) : (
<div className="w-[200px] h-[200px] bg-gray-100 rounded flex items-center justify-center text-gray-500">
Enter content
</div>
)}
</div>
</div>
<div className="space-y-3">
<Button
variant="outline"
className="w-full"
type="button"
onClick={() => downloadQR('svg')}
disabled={!qrContent}
>
Download SVG
</Button>
<Button
variant="outline"
className="w-full"
type="button"
onClick={() => downloadQR('png')}
disabled={!qrContent}
>
Download PNG
</Button>
<Button type="submit" className="w-full" loading={loading}>
Save QR Code
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
</form>
</div>
);
}