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
This commit is contained in:
Timo Knuth
2026-01-10 00:22:07 +01:00
parent e539aaf9a1
commit eb2faec952
70 changed files with 12803 additions and 592 deletions

View File

@@ -1,25 +1,51 @@
'use client';
import React, { useState, useEffect } from 'react';
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 } from '@/lib/utils';
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('');
@@ -32,6 +58,18 @@ export default function CreatePage() {
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('');
@@ -97,61 +135,58 @@ export default function CreatePage() {
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 {
// Get the content based on content type
let qrContent = '';
switch (contentType) {
case 'URL':
qrContent = content.url || '';
break;
case 'PHONE':
qrContent = `tel:${content.phone || ''}`;
break;
case 'EMAIL':
qrContent = `mailto:${content.email || ''}${content.subject ? `?subject=${encodeURIComponent(content.subject)}` : ''}`;
break;
case 'TEXT':
qrContent = content.text || '';
break;
default:
qrContent = content.url || '';
}
if (!qrContent) return;
const QRCode = (await import('qrcode')).default;
if (format === 'svg') {
const svg = await QRCode.toString(qrContent, {
type: 'svg',
width: size,
margin: 2,
color: {
dark: foregroundColor,
light: backgroundColor,
},
});
const blob = new Blob([svg], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `qrcode-${title || 'download'}.svg`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
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 {
const a = document.createElement('a');
a.href = qrDataUrl;
a.download = `qrcode-${title || 'download'}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// 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');
}
};
@@ -220,6 +255,7 @@ export default function CreatePage() {
width: logoSize,
excavate,
} : undefined,
frameType, // Save frame type
},
};
@@ -448,7 +484,7 @@ export default function CreatePage() {
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<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">
@@ -461,6 +497,29 @@ export default function CreatePage() {
</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">
@@ -635,27 +694,48 @@ export default function CreatePage() {
</CardHeader>
<CardContent className="text-center">
<div id="create-qr-preview" className="flex justify-center mb-4">
{qrContent ? (
<div className={cornerStyle === 'rounded' ? 'rounded-lg overflow-hidden' : ''}>
<QRCodeSVG
value={qrContent}
size={200}
fgColor={foregroundColor}
bgColor={backgroundColor}
level="H"
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>
)}
{/* 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">
@@ -663,38 +743,7 @@ export default function CreatePage() {
variant="outline"
className="w-full"
type="button"
onClick={() => {
const svg = document.querySelector('#create-qr-preview svg');
if (!svg) return;
let svgData = new XMLSerializer().serializeToString(svg);
// If rounded corners, wrap in a clipped SVG
if (cornerStyle === 'rounded') {
const width = svg.getAttribute('width') || '200';
const height = svg.getAttribute('height') || '200';
const borderRadius = 20;
svgData = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<defs>
<clipPath id="rounded-corners">
<rect x="0" y="0" width="${width}" height="${height}" rx="${borderRadius}" ry="${borderRadius}"/>
</clipPath>
</defs>
<g clip-path="url(#rounded-corners)">
${svgData}
</g>
</svg>`;
}
const blob = new Blob([svgData], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title || 'qrcode'}.svg`;
a.click();
URL.revokeObjectURL(url);
}}
onClick={() => downloadQR('svg')}
disabled={!qrContent}
>
Download SVG
@@ -703,54 +752,7 @@ export default function CreatePage() {
variant="outline"
className="w-full"
type="button"
onClick={() => {
const svg = document.querySelector('#create-qr-preview svg');
if (!svg) return;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return;
const img = new Image();
const svgData = new XMLSerializer().serializeToString(svg);
const blob = new Blob([svgData], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
img.onload = () => {
canvas.width = 200;
canvas.height = 200;
// Apply rounded corners if needed
if (cornerStyle === 'rounded') {
const borderRadius = 20;
ctx.beginPath();
ctx.moveTo(borderRadius, 0);
ctx.lineTo(200 - borderRadius, 0);
ctx.quadraticCurveTo(200, 0, 200, borderRadius);
ctx.lineTo(200, 200 - borderRadius);
ctx.quadraticCurveTo(200, 200, 200 - borderRadius, 200);
ctx.lineTo(borderRadius, 200);
ctx.quadraticCurveTo(0, 200, 0, 200 - borderRadius);
ctx.lineTo(0, borderRadius);
ctx.quadraticCurveTo(0, 0, borderRadius, 0);
ctx.closePath();
ctx.clip();
}
ctx.drawImage(img, 0, 0);
canvas.toBlob((blob) => {
if (blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title || 'qrcode'}.png`;
a.click();
URL.revokeObjectURL(url);
}
});
URL.revokeObjectURL(url);
};
img.src = url;
}}
onClick={() => downloadQR('png')}
disabled={!qrContent}
>
Download PNG