feat: Add QR code card component with download functionality and a new pricing page with internationalization.

This commit is contained in:
Timo 2026-01-07 19:59:04 +01:00
parent e7478a4af7
commit d04e7a1f70
4 changed files with 263 additions and 261 deletions

View File

@ -141,13 +141,13 @@ export default function PricingPage() {
'50 dynamic QR codes', '50 dynamic QR codes',
'Unlimited static QR codes', 'Unlimited static QR codes',
'Advanced analytics (scans, devices, locations)', 'Advanced analytics (scans, devices, locations)',
'Custom branding (colors)', 'Custom branding (colors & logos)',
], ],
buttonText: isCurrentPlanWithInterval('PRO', selectedInterval) buttonText: isCurrentPlanWithInterval('PRO', selectedInterval)
? 'Current Plan' ? 'Current Plan'
: hasPlanDifferentInterval('PRO') : hasPlanDifferentInterval('PRO')
? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}` ? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}`
: 'Upgrade to Pro', : 'Upgrade to Pro',
buttonVariant: 'primary' as const, buttonVariant: 'primary' as const,
disabled: isCurrentPlanWithInterval('PRO', selectedInterval), disabled: isCurrentPlanWithInterval('PRO', selectedInterval),
popular: true, popular: true,
@ -170,8 +170,8 @@ export default function PricingPage() {
buttonText: isCurrentPlanWithInterval('BUSINESS', selectedInterval) buttonText: isCurrentPlanWithInterval('BUSINESS', selectedInterval)
? 'Current Plan' ? 'Current Plan'
: hasPlanDifferentInterval('BUSINESS') : hasPlanDifferentInterval('BUSINESS')
? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}` ? `Switch to ${billingPeriod === 'month' ? 'Monthly' : 'Yearly'}`
: 'Upgrade to Business', : 'Upgrade to Business',
buttonVariant: 'primary' as const, buttonVariant: 'primary' as const,
disabled: isCurrentPlanWithInterval('BUSINESS', selectedInterval), disabled: isCurrentPlanWithInterval('BUSINESS', selectedInterval),
popular: false, popular: false,

View File

@ -1,240 +1,240 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { QRCodeSVG } from 'qrcode.react'; import { QRCodeSVG } from 'qrcode.react';
import { Card, CardContent } from '@/components/ui/Card'; import { Card, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { Dropdown, DropdownItem } from '@/components/ui/Dropdown'; import { Dropdown, DropdownItem } from '@/components/ui/Dropdown';
import { formatDate } from '@/lib/utils'; import { formatDate } from '@/lib/utils';
interface QRCodeCardProps { interface QRCodeCardProps {
qr: { qr: {
id: string; id: string;
title: string; title: string;
type: 'STATIC' | 'DYNAMIC'; type: 'STATIC' | 'DYNAMIC';
contentType: string; contentType: string;
content?: any; content?: any;
slug: string; slug: string;
createdAt: string; createdAt: string;
scans?: number; scans?: number;
style?: any; style?: any;
}; };
onEdit: (id: string) => void; onEdit: (id: string) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
} }
export const QRCodeCard: React.FC<QRCodeCardProps> = ({ export const QRCodeCard: React.FC<QRCodeCardProps> = ({
qr, qr,
onEdit, onEdit,
onDelete, onDelete,
}) => { }) => {
// For dynamic QR codes, use the redirect URL for tracking // For dynamic QR codes, use the redirect URL for tracking
// For static QR codes, use the direct URL from content // For static QR codes, use the direct URL from content
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || (typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3050'); const baseUrl = process.env.NEXT_PUBLIC_APP_URL || (typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3050');
// Get the QR URL based on type // Get the QR URL based on type
let qrUrl = ''; let qrUrl = '';
// SIMPLE FIX: For STATIC QR codes, ALWAYS use the direct content // SIMPLE FIX: For STATIC QR codes, ALWAYS use the direct content
if (qr.type === 'STATIC') { if (qr.type === 'STATIC') {
// Extract the actual URL/content based on contentType // Extract the actual URL/content based on contentType
if (qr.contentType === 'URL' && qr.content?.url) { if (qr.contentType === 'URL' && qr.content?.url) {
qrUrl = qr.content.url; qrUrl = qr.content.url;
} else if (qr.contentType === 'PHONE' && qr.content?.phone) { } else if (qr.contentType === 'PHONE' && qr.content?.phone) {
qrUrl = `tel:${qr.content.phone}`; qrUrl = `tel:${qr.content.phone}`;
} else if (qr.contentType === 'VCARD') { } else if (qr.contentType === 'VCARD') {
// VCARD content needs to be formatted properly // VCARD content needs to be formatted properly
qrUrl = `BEGIN:VCARD qrUrl = `BEGIN:VCARD
VERSION:3.0 VERSION:3.0
FN:${qr.content.firstName || ''} ${qr.content.lastName || ''} FN:${qr.content.firstName || ''} ${qr.content.lastName || ''}
N:${qr.content.lastName || ''};${qr.content.firstName || ''};;; N:${qr.content.lastName || ''};${qr.content.firstName || ''};;;
${qr.content.organization ? `ORG:${qr.content.organization}` : ''} ${qr.content.organization ? `ORG:${qr.content.organization}` : ''}
${qr.content.title ? `TITLE:${qr.content.title}` : ''} ${qr.content.title ? `TITLE:${qr.content.title}` : ''}
${qr.content.email ? `EMAIL:${qr.content.email}` : ''} ${qr.content.email ? `EMAIL:${qr.content.email}` : ''}
${qr.content.phone ? `TEL:${qr.content.phone}` : ''} ${qr.content.phone ? `TEL:${qr.content.phone}` : ''}
END:VCARD`; END:VCARD`;
} else if (qr.contentType === 'GEO' && qr.content) { } else if (qr.contentType === 'GEO' && qr.content) {
const lat = qr.content.latitude || 0; const lat = qr.content.latitude || 0;
const lon = qr.content.longitude || 0; const lon = qr.content.longitude || 0;
const label = qr.content.label ? `?q=${encodeURIComponent(qr.content.label)}` : ''; const label = qr.content.label ? `?q=${encodeURIComponent(qr.content.label)}` : '';
qrUrl = `geo:${lat},${lon}${label}`; qrUrl = `geo:${lat},${lon}${label}`;
} else if (qr.contentType === 'TEXT' && qr.content?.text) { } else if (qr.contentType === 'TEXT' && qr.content?.text) {
qrUrl = qr.content.text; qrUrl = qr.content.text;
} else if (qr.content?.qrContent) { } else if (qr.content?.qrContent) {
// Fallback to qrContent if it exists // Fallback to qrContent if it exists
qrUrl = qr.content.qrContent; qrUrl = qr.content.qrContent;
} else { } else {
// Last resort fallback // Last resort fallback
qrUrl = `${baseUrl}/r/${qr.slug}`; qrUrl = `${baseUrl}/r/${qr.slug}`;
} }
console.log(`STATIC QR [${qr.title}]: ${qrUrl}`); console.log(`STATIC QR [${qr.title}]: ${qrUrl}`);
} else { } else {
// DYNAMIC QR codes always use redirect for tracking // DYNAMIC QR codes always use redirect for tracking
qrUrl = `${baseUrl}/r/${qr.slug}`; qrUrl = `${baseUrl}/r/${qr.slug}`;
console.log(`DYNAMIC QR [${qr.title}]: ${qrUrl}`); console.log(`DYNAMIC QR [${qr.title}]: ${qrUrl}`);
} }
const downloadQR = (format: 'png' | 'svg') => { const downloadQR = (format: 'png' | 'svg') => {
const svg = document.querySelector(`#qr-${qr.id} svg`); const svg = document.querySelector(`#qr-${qr.id} svg`);
if (!svg) return; if (!svg) return;
if (format === 'svg') { if (format === 'svg') {
let svgData = new XMLSerializer().serializeToString(svg); let svgData = new XMLSerializer().serializeToString(svg);
// If rounded corners, wrap in a clipped SVG // If rounded corners, wrap in a clipped SVG
if (qr.style?.cornerStyle === 'rounded') { if (qr.style?.cornerStyle === 'rounded') {
const width = svg.getAttribute('width') || '96'; const width = svg.getAttribute('width') || '96';
const height = svg.getAttribute('height') || '96'; const height = svg.getAttribute('height') || '96';
const borderRadius = 10; // Smaller radius for dashboard const borderRadius = 10; // Smaller radius for dashboard
svgData = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"> svgData = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<defs> <defs>
<clipPath id="rounded-corners-${qr.id}"> <clipPath id="rounded-corners-${qr.id}">
<rect x="0" y="0" width="${width}" height="${height}" rx="${borderRadius}" ry="${borderRadius}"/> <rect x="0" y="0" width="${width}" height="${height}" rx="${borderRadius}" ry="${borderRadius}"/>
</clipPath> </clipPath>
</defs> </defs>
<g clip-path="url(#rounded-corners-${qr.id})"> <g clip-path="url(#rounded-corners-${qr.id})">
${svgData} ${svgData}
</g> </g>
</svg>`; </svg>`;
} }
const blob = new Blob([svgData], { type: 'image/svg+xml' }); const blob = new Blob([svgData], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.svg`; a.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.svg`;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} else { } else {
// Convert SVG to PNG // Convert SVG to PNG
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
if (!ctx) return; if (!ctx) return;
const img = new Image(); const img = new Image();
const svgData = new XMLSerializer().serializeToString(svg); const svgData = new XMLSerializer().serializeToString(svg);
const blob = new Blob([svgData], { type: 'image/svg+xml' }); const blob = new Blob([svgData], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
img.onload = () => { img.onload = () => {
canvas.width = 300; canvas.width = 300;
canvas.height = 300; canvas.height = 300;
// Apply rounded corners if needed // Apply rounded corners if needed
if (qr.style?.cornerStyle === 'rounded') { if (qr.style?.cornerStyle === 'rounded') {
const borderRadius = 30; // Scale up for 300px canvas const borderRadius = 30; // Scale up for 300px canvas
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(borderRadius, 0); ctx.moveTo(borderRadius, 0);
ctx.lineTo(300 - borderRadius, 0); ctx.lineTo(300 - borderRadius, 0);
ctx.quadraticCurveTo(300, 0, 300, borderRadius); ctx.quadraticCurveTo(300, 0, 300, borderRadius);
ctx.lineTo(300, 300 - borderRadius); ctx.lineTo(300, 300 - borderRadius);
ctx.quadraticCurveTo(300, 300, 300 - borderRadius, 300); ctx.quadraticCurveTo(300, 300, 300 - borderRadius, 300);
ctx.lineTo(borderRadius, 300); ctx.lineTo(borderRadius, 300);
ctx.quadraticCurveTo(0, 300, 0, 300 - borderRadius); ctx.quadraticCurveTo(0, 300, 0, 300 - borderRadius);
ctx.lineTo(0, borderRadius); ctx.lineTo(0, borderRadius);
ctx.quadraticCurveTo(0, 0, borderRadius, 0); ctx.quadraticCurveTo(0, 0, borderRadius, 0);
ctx.closePath(); ctx.closePath();
ctx.clip(); ctx.clip();
} }
ctx.drawImage(img, 0, 0, 300, 300); ctx.drawImage(img, 0, 0, 300, 300);
canvas.toBlob((blob) => { canvas.toBlob((blob) => {
if (blob) { if (blob) {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.png`; a.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.png`;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
}); });
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
img.src = url; img.src = url;
} }
}; };
return ( return (
<Card hover> <Card hover>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<div className="flex-1"> <div className="flex-1">
<h3 className="font-semibold text-gray-900 mb-1">{qr.title}</h3> <h3 className="font-semibold text-gray-900 mb-1">{qr.title}</h3>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Badge variant={qr.type === 'DYNAMIC' ? 'info' : 'default'}> <Badge variant={qr.type === 'DYNAMIC' ? 'info' : 'default'}>
{qr.type} {qr.type}
</Badge> </Badge>
</div> </div>
</div> </div>
<Dropdown <Dropdown
align="right" align="right"
trigger={ trigger={
<button className="p-1 hover:bg-gray-100 rounded"> <button className="p-1 hover:bg-gray-100 rounded">
<svg className="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</svg> </svg>
</button> </button>
} }
> >
<DropdownItem onClick={() => downloadQR('png')}>Download PNG</DropdownItem> <DropdownItem onClick={() => downloadQR('png')}>Download PNG</DropdownItem>
<DropdownItem onClick={() => downloadQR('svg')}>Download SVG</DropdownItem> <DropdownItem onClick={() => downloadQR('svg')}>Download SVG</DropdownItem>
{qr.type === 'DYNAMIC' && ( {qr.type === 'DYNAMIC' && (
<DropdownItem onClick={() => onEdit(qr.id)}>Edit</DropdownItem> <DropdownItem onClick={() => onEdit(qr.id)}>Edit</DropdownItem>
)} )}
<DropdownItem onClick={() => onDelete(qr.id)} className="text-red-600"> <DropdownItem onClick={() => onDelete(qr.id)} className="text-red-600">
Delete Delete
</DropdownItem> </DropdownItem>
</Dropdown> </Dropdown>
</div> </div>
<div id={`qr-${qr.id}`} className="flex items-center justify-center bg-gray-50 rounded-lg p-4 mb-3"> <div id={`qr-${qr.id}`} className="flex items-center justify-center bg-gray-50 rounded-lg p-4 mb-3">
<div className={qr.style?.cornerStyle === 'rounded' ? 'rounded-lg overflow-hidden' : ''}> <div className={qr.style?.cornerStyle === 'rounded' ? 'rounded-lg overflow-hidden' : ''}>
<QRCodeSVG <QRCodeSVG
value={qrUrl} value={qrUrl}
size={96} size={96}
fgColor={qr.style?.foregroundColor || '#000000'} fgColor={qr.style?.foregroundColor || '#000000'}
bgColor={qr.style?.backgroundColor || '#FFFFFF'} bgColor={qr.style?.backgroundColor || '#FFFFFF'}
level="H" level="H"
imageSettings={qr.style?.imageSettings ? { imageSettings={qr.style?.imageSettings ? {
src: qr.style.imageSettings.src, src: qr.style.imageSettings.src,
height: qr.style.imageSettings.height * (96 / 200), // Scale logo for smaller QR height: qr.style.imageSettings.height * (96 / 200), // Scale logo for smaller QR
width: qr.style.imageSettings.width * (96 / 200), width: qr.style.imageSettings.width * (96 / 200),
excavate: qr.style.imageSettings.excavate, excavate: qr.style.imageSettings.excavate,
} : undefined} } : undefined}
/> />
</div> </div>
</div> </div>
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-gray-500">Type:</span> <span className="text-gray-500">Type:</span>
<span className="text-gray-900">{qr.contentType}</span> <span className="text-gray-900">{qr.contentType}</span>
</div> </div>
{qr.type === 'DYNAMIC' && ( {qr.type === 'DYNAMIC' && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-gray-500">Scans:</span> <span className="text-gray-500">Scans:</span>
<span className="text-gray-900">{qr.scans || 0}</span> <span className="text-gray-900">{qr.scans || 0}</span>
</div> </div>
)} )}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-gray-500">Created:</span> <span className="text-gray-500">Created:</span>
<span className="text-gray-900">{formatDate(qr.createdAt)}</span> <span className="text-gray-900">{formatDate(qr.createdAt)}</span>
</div> </div>
{qr.type === 'DYNAMIC' && ( {qr.type === 'DYNAMIC' && (
<div className="pt-2 border-t"> <div className="pt-2 border-t">
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
📊 Dynamic QR: Tracks scans via {baseUrl}/r/{qr.slug} 📊 Dynamic QR: Tracks scans via {baseUrl}/r/{qr.slug}
</p> </p>
</div> </div>
)} )}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
); );
}; };

View File

@ -69,17 +69,17 @@ export const Pricing: React.FC<PricingProps> = ({ t }) => {
{plan.key === 'free' {plan.key === 'free'
? t.pricing[plan.key].price ? t.pricing[plan.key].price
: billingPeriod === 'month' : billingPeriod === 'month'
? t.pricing[plan.key].price ? t.pricing[plan.key].price
: plan.key === 'pro' : plan.key === 'pro'
? '€90' ? '€90'
: '€290'} : '€290'}
</span> </span>
<span className="text-gray-600 ml-2"> <span className="text-gray-600 ml-2">
{plan.key === 'free' {plan.key === 'free'
? t.pricing[plan.key].period ? t.pricing[plan.key].period
: billingPeriod === 'month' : billingPeriod === 'month'
? t.pricing[plan.key].period ? t.pricing[plan.key].period
: 'per year'} : 'per year'}
</span> </span>
</div> </div>
{billingPeriod === 'year' && plan.key !== 'free' && ( {billingPeriod === 'year' && plan.key !== 'free' && (
@ -90,7 +90,7 @@ export const Pricing: React.FC<PricingProps> = ({ t }) => {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-8">
<ul className="space-y-3"> <ul className="space-y-3">
{t.pricing[plan.key].features.map((feature: string, index: number) => ( {t.pricing[plan.key].features.map((feature: string, index: number) => (
<li key={index} className="flex items-start space-x-3"> <li key={index} className="flex items-start space-x-3">
@ -102,15 +102,17 @@ export const Pricing: React.FC<PricingProps> = ({ t }) => {
))} ))}
</ul> </ul>
<Link href="/signup"> <div className="mt-8">
<Button <Link href="/signup">
variant={plan.popular ? 'primary' : 'outline'} <Button
className="w-full" variant={plan.popular ? 'primary' : 'outline'}
size="lg" className="w-full"
> size="lg"
Get Started >
</Button> Get Started
</Link> </Button>
</Link>
</div>
</CardContent> </CardContent>
</Card> </Card>
))} ))}

View File

@ -149,7 +149,7 @@
"50 dynamic QR codes", "50 dynamic QR codes",
"Unlimited static QR codes", "Unlimited static QR codes",
"Advanced analytics (scans, devices, locations)", "Advanced analytics (scans, devices, locations)",
"Custom branding (colors)" "Custom branding (colors & logos)"
] ]
}, },
"business": { "business": {