Initial commit - QR Master application

This commit is contained in:
Timo Knuth
2025-10-13 20:19:18 +02:00
commit 5262f9e78f
96 changed files with 18902 additions and 0 deletions

View File

@@ -0,0 +1,186 @@
'use client';
import React from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { Card, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Dropdown, DropdownItem } from '@/components/ui/Dropdown';
import { formatDate } from '@/lib/utils';
interface QRCodeCardProps {
qr: {
id: string;
title: string;
type: 'STATIC' | 'DYNAMIC';
contentType: string;
content?: any;
slug: string;
status: 'ACTIVE' | 'PAUSED';
createdAt: string;
scans?: number;
};
onEdit: (id: string) => void;
onDuplicate: (id: string) => void;
onPause: (id: string) => void;
onDelete: (id: string) => void;
}
export const QRCodeCard: React.FC<QRCodeCardProps> = ({
qr,
onEdit,
onDuplicate,
onPause,
onDelete,
}) => {
// For dynamic QR codes, use the redirect URL for tracking
// 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');
// Get the QR URL based on type
let qrUrl = '';
// SIMPLE FIX: For STATIC QR codes, ALWAYS use the direct content
if (qr.type === 'STATIC') {
// Extract the actual URL/content based on contentType
if (qr.contentType === 'URL' && qr.content?.url) {
qrUrl = qr.content.url;
} else if (qr.contentType === 'PHONE' && qr.content?.phone) {
qrUrl = `tel:${qr.content.phone}`;
} else if (qr.contentType === 'EMAIL' && qr.content?.email) {
qrUrl = `mailto:${qr.content.email}`;
} else if (qr.contentType === 'TEXT' && qr.content?.text) {
qrUrl = qr.content.text;
} else if (qr.content?.qrContent) {
// Fallback to qrContent if it exists
qrUrl = qr.content.qrContent;
} else {
// Last resort fallback
qrUrl = `${baseUrl}/r/${qr.slug}`;
}
console.log(`STATIC QR [${qr.title}]: ${qrUrl}`);
} else {
// DYNAMIC QR codes always use redirect for tracking
qrUrl = `${baseUrl}/r/${qr.slug}`;
console.log(`DYNAMIC QR [${qr.title}]: ${qrUrl}`);
}
const downloadQR = (format: 'png' | 'svg') => {
const svg = document.querySelector(`#qr-${qr.id} svg`);
if (!svg) return;
if (format === 'svg') {
const svgData = new XMLSerializer().serializeToString(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 = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.svg`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} else {
// Convert SVG to PNG
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
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 = 300;
canvas.height = 300;
ctx?.drawImage(img, 0, 0, 300, 300);
canvas.toBlob((blob) => {
if (blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${qr.title.replace(/\s+/g, '-').toLowerCase()}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
});
URL.revokeObjectURL(url);
};
img.src = url;
}
};
return (
<Card hover>
<CardContent className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h3 className="font-semibold text-gray-900 mb-1">{qr.title}</h3>
<div className="flex items-center space-x-2">
<Badge variant={qr.type === 'DYNAMIC' ? 'info' : 'default'}>
{qr.type}
</Badge>
<Badge variant={qr.status === 'ACTIVE' ? 'success' : 'warning'}>
{qr.status}
</Badge>
</div>
</div>
<Dropdown
align="right"
trigger={
<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">
<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>
</button>
}
>
<DropdownItem onClick={() => downloadQR('png')}>Download PNG</DropdownItem>
<DropdownItem onClick={() => downloadQR('svg')}>Download SVG</DropdownItem>
<DropdownItem onClick={() => onEdit(qr.id)}>Edit</DropdownItem>
<DropdownItem onClick={() => onDuplicate(qr.id)}>Duplicate</DropdownItem>
<DropdownItem onClick={() => onPause(qr.id)}>
{qr.status === 'ACTIVE' ? 'Pause' : 'Resume'}
</DropdownItem>
<DropdownItem onClick={() => onDelete(qr.id)} className="text-red-600">
Delete
</DropdownItem>
</Dropdown>
</div>
<div id={`qr-${qr.id}`} className="flex items-center justify-center bg-gray-50 rounded-lg p-4 mb-3">
<QRCodeSVG
value={qrUrl}
size={96}
fgColor="#000000"
bgColor="#FFFFFF"
level="M"
/>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-gray-500">Type:</span>
<span className="text-gray-900">{qr.contentType}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-500">Scans:</span>
<span className="text-gray-900">{qr.scans || 0}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-500">Created:</span>
<span className="text-gray-900">{formatDate(qr.createdAt)}</span>
</div>
{qr.type === 'DYNAMIC' && (
<div className="pt-2 border-t">
<p className="text-xs text-gray-500">
📊 Dynamic QR: Tracks scans via {baseUrl}/r/{qr.slug}
</p>
</div>
)}
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,82 @@
'use client';
import React from 'react';
import { Card, CardContent } from '@/components/ui/Card';
import { formatNumber } from '@/lib/utils';
interface StatsGridProps {
stats: {
totalScans: number;
activeQRCodes: number;
conversionRate: number;
};
}
export const StatsGrid: React.FC<StatsGridProps> = ({ stats }) => {
// Only show growth if there are actual scans
const showGrowth = stats.totalScans > 0;
const cards = [
{
title: 'Total Scans',
value: formatNumber(stats.totalScans),
change: showGrowth ? '+12%' : 'No data yet',
changeType: showGrowth ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
),
},
{
title: 'Active QR Codes',
value: stats.activeQRCodes.toString(),
change: stats.activeQRCodes > 0 ? `${stats.activeQRCodes} active` : 'Create your first',
changeType: stats.activeQRCodes > 0 ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
</svg>
),
},
{
title: 'Conversion Rate',
value: `${stats.conversionRate}%`,
change: stats.totalScans > 0 ? `${stats.conversionRate}% rate` : 'No scans yet',
changeType: stats.conversionRate > 0 ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
),
},
];
return (
<div className="grid md:grid-cols-3 gap-6">
{cards.map((card, index) => (
<Card key={index}>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1">{card.title}</p>
<p className="text-2xl font-bold text-gray-900">{card.value}</p>
<p className={`text-sm mt-2 ${
card.changeType === 'positive' ? 'text-success-600' :
card.changeType === 'negative' ? 'text-red-600' :
'text-gray-500'
}`}>
{card.changeType === 'neutral' ? card.change : `${card.change} from last month`}
</p>
</div>
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center text-primary-600">
{card.icon}
</div>
</div>
</CardContent>
</Card>
))}
</div>
);
};

View File

@@ -0,0 +1,157 @@
'use client';
import React, { useEffect, useState } from 'react';
import QRCode from 'qrcode';
import { Badge } from '@/components/ui/Badge';
import { calculateContrast } from '@/lib/utils';
interface QRPreviewProps {
content: string;
style: {
foregroundColor: string;
backgroundColor: string;
cornerStyle: 'square' | 'rounded';
size: number;
};
}
export const QRPreview: React.FC<QRPreviewProps> = ({ content, style }) => {
const [qrDataUrl, setQrDataUrl] = useState<string>('');
const [error, setError] = useState<string>('');
const contrast = calculateContrast(style.foregroundColor, style.backgroundColor);
const hasGoodContrast = contrast >= 4.5;
useEffect(() => {
const generateQR = async () => {
try {
if (!content) {
setQrDataUrl('');
return;
}
const options = {
width: style.size,
margin: 2,
color: {
dark: style.foregroundColor,
light: style.backgroundColor,
},
errorCorrectionLevel: 'M' as const,
};
const dataUrl = await QRCode.toDataURL(content, options);
setQrDataUrl(dataUrl);
setError('');
} catch (err) {
console.error('Error generating QR code:', err);
setError('Failed to generate QR code');
}
};
generateQR();
}, [content, style]);
const downloadQR = async (format: 'svg' | 'png') => {
if (!content) return;
try {
if (format === 'svg') {
const svg = await QRCode.toString(content, {
type: 'svg',
width: style.size,
margin: 2,
color: {
dark: style.foregroundColor,
light: style.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.svg';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} else {
// For PNG, use the canvas
const canvas = document.createElement('canvas');
await QRCode.toCanvas(canvas, content, {
width: style.size,
margin: 2,
color: {
dark: style.foregroundColor,
light: style.backgroundColor,
},
});
canvas.toBlob((blob) => {
if (blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'qrcode.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
});
}
} catch (err) {
console.error('Error downloading QR code:', err);
}
};
return (
<div className="space-y-4">
<div className="flex justify-center">
{error ? (
<div className="w-[200px] h-[200px] bg-gray-100 rounded-lg flex items-center justify-center text-gray-500">
{error}
</div>
) : qrDataUrl ? (
<img
src={qrDataUrl}
alt="QR Code Preview"
className={`border-2 border-gray-200 ${style.cornerStyle === 'rounded' ? 'rounded-lg' : ''}`}
style={{ width: Math.min(style.size, 300), height: Math.min(style.size, 300) }}
/>
) : (
<div className="w-[200px] h-[200px] bg-gray-100 rounded-lg flex items-center justify-center text-gray-500">
Enter content to generate QR code
</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: {contrast.toFixed(1)}:1
</span>
</div>
<div className="space-y-2">
<button
onClick={() => downloadQR('svg')}
disabled={!content || !qrDataUrl}
className="w-full px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Download SVG
</button>
<button
onClick={() => downloadQR('png')}
disabled={!content || !qrDataUrl}
className="w-full px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Download PNG
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,63 @@
'use client';
import React, { useState } from 'react';
import { Card } from '@/components/ui/Card';
interface FAQProps {
t: any; // i18n translation function
}
export const FAQ: React.FC<FAQProps> = ({ t }) => {
const [openIndex, setOpenIndex] = useState<number | null>(null);
const questions = [
'account',
'static_vs_dynamic',
'forever',
'file_type',
'password',
'analytics',
'privacy',
'bulk',
];
return (
<section className="py-16 bg-gray-50">
<div className="container mx-auto px-4">
<div className="text-center mb-12">
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
{t('faq.title')}
</h2>
</div>
<div className="max-w-3xl mx-auto space-y-4">
{questions.map((key, index) => (
<Card key={key} className="cursor-pointer" onClick={() => setOpenIndex(openIndex === index ? null : index)}>
<div className="p-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">
{t(`faq.questions.${key}.question`)}
</h3>
<svg
className={`w-5 h-5 text-gray-500 transition-transform ${openIndex === index ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
{openIndex === index && (
<div className="mt-4 text-gray-600">
{t(`faq.questions.${key}.answer`)}
</div>
)}
</div>
</Card>
))}
</div>
</div>
</section>
);
};

View File

@@ -0,0 +1,97 @@
'use client';
import React from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
interface FeaturesProps {
t: any; // i18n translation function
}
export const Features: React.FC<FeaturesProps> = ({ t }) => {
const features = [
{
key: 'analytics',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
color: 'text-blue-600 bg-blue-100',
},
{
key: 'customization',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
),
color: 'text-purple-600 bg-purple-100',
},
{
key: 'bulk',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
color: 'text-green-600 bg-green-100',
},
{
key: 'integrations',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
</svg>
),
color: 'text-orange-600 bg-orange-100',
},
{
key: 'api',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
),
color: 'text-indigo-600 bg-indigo-100',
},
{
key: 'support',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192l-3.536 3.536M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-5 0a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
),
color: 'text-red-600 bg-red-100',
},
];
return (
<section className="py-16 bg-gray-50">
<div className="container mx-auto px-4">
<div className="text-center mb-12">
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
{t('features.title')}
</h2>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
{features.map((feature) => (
<Card key={feature.key} hover>
<CardHeader>
<div className={`w-12 h-12 rounded-lg ${feature.color} flex items-center justify-center mb-4`}>
{feature.icon}
</div>
<CardTitle>{t(`features.${feature.key}.title`)}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600">
{t(`features.${feature.key}.description`)}
</p>
</CardContent>
</Card>
))}
</div>
</div>
</section>
);
};

View File

@@ -0,0 +1,83 @@
'use client';
import React from 'react';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Card } from '@/components/ui/Card';
interface HeroProps {
t: any; // i18n translation function
}
export const Hero: React.FC<HeroProps> = ({ t }) => {
const templateCards = [
{ title: 'Restaurant Menu', color: 'bg-pink-100', icon: '🍽️' },
{ title: 'Business Card', color: 'bg-blue-100', icon: '💼' },
{ title: 'Event Tickets', color: 'bg-green-100', icon: '🎫' },
{ title: 'WiFi Access', color: 'bg-purple-100', icon: '📶' },
];
return (
<section className="relative overflow-hidden bg-gradient-to-br from-blue-50 via-white to-purple-50 py-20">
<div className="container mx-auto px-4">
<div className="grid lg:grid-cols-2 gap-12 items-center">
{/* Left Content */}
<div className="space-y-8">
<Badge variant="info" className="inline-flex items-center space-x-2">
<span>{t('hero.badge')}</span>
</Badge>
<div className="space-y-6">
<h1 className="text-5xl lg:text-6xl font-bold text-gray-900 leading-tight">
{t('hero.title')}
</h1>
<p className="text-xl text-gray-600 leading-relaxed">
{t('hero.subtitle')}
</p>
<div className="space-y-3">
{t('hero.features', { returnObjects: true }).map((feature: string, index: number) => (
<div key={index} className="flex items-center space-x-3">
<div className="flex-shrink-0 w-5 h-5 bg-success-500 rounded-full flex items-center justify-center">
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
<span className="text-gray-700">{feature}</span>
</div>
))}
</div>
<div className="flex flex-col sm:flex-row gap-4">
<Button size="lg" className="text-lg px-8 py-4">
{t('hero.cta_primary')}
</Button>
<Button variant="outline" size="lg" className="text-lg px-8 py-4">
{t('hero.cta_secondary')}
</Button>
</div>
</div>
</div>
{/* Right Preview Widget */}
<div className="relative">
<div className="grid grid-cols-2 gap-4">
{templateCards.map((card, index) => (
<Card key={index} className={`${card.color} border-0 p-6 text-center hover:scale-105 transition-transform`}>
<div className="text-3xl mb-2">{card.icon}</div>
<h3 className="font-semibold text-gray-800">{card.title}</h3>
</Card>
))}
</div>
{/* Floating Badge */}
<div className="absolute -top-4 -right-4 bg-success-500 text-white px-4 py-2 rounded-full text-sm font-semibold shadow-lg">
{t('hero.engagement_badge')}
</div>
</div>
</div>
</div>
</section>
);
};

View File

@@ -0,0 +1,220 @@
'use client';
import React, { useState } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { Card } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { calculateContrast } from '@/lib/utils';
interface InstantGeneratorProps {
t: any; // i18n translation function
}
export const InstantGenerator: React.FC<InstantGeneratorProps> = ({ t }) => {
const [url, setUrl] = useState('https://example.com');
const [foregroundColor, setForegroundColor] = useState('#000000');
const [backgroundColor, setBackgroundColor] = useState('#FFFFFF');
const [cornerStyle, setCornerStyle] = useState('square');
const [size, setSize] = useState(200);
const contrast = calculateContrast(foregroundColor, backgroundColor);
const hasGoodContrast = contrast >= 4.5;
const downloadQR = (format: 'svg' | 'png') => {
const svg = document.querySelector('#instant-qr-preview svg');
if (!svg || !url) return;
if (format === 'svg') {
const svgData = new XMLSerializer().serializeToString(svg);
const blob = new Blob([svgData], { type: 'image/svg+xml' });
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = 'qrcode.svg';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(downloadUrl);
} else {
// Convert SVG to PNG using Canvas
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
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 = size;
canvas.height = size;
if (ctx) {
ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, size, size);
ctx.drawImage(img, 0, 0, size, size);
}
canvas.toBlob((blob) => {
if (blob) {
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = 'qrcode.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(downloadUrl);
}
});
URL.revokeObjectURL(url);
};
img.src = url;
}
};
return (
<section className="py-16 bg-gray-50">
<div className="container mx-auto px-4">
<div className="text-center mb-12">
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
{t('generator.title')}
</h2>
</div>
<div className="grid lg:grid-cols-2 gap-12 max-w-6xl mx-auto">
{/* Left Form */}
<Card className="space-y-6">
<Input
label="URL"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={t('generator.url_placeholder')}
/>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('generator.foreground')}
</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"
/>
<Input
value={foregroundColor}
onChange={(e) => setForegroundColor(e.target.value)}
className="flex-1"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('generator.background')}
</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"
/>
<Input
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="flex-1"
/>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('generator.corners')}
</label>
<select
value={cornerStyle}
onChange={(e) => setCornerStyle(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"
>
<option value="square">Square</option>
<option value="rounded">Rounded</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('generator.size')}
</label>
<input
type="range"
min="100"
max="400"
value={size}
onChange={(e) => setSize(Number(e.target.value))}
className="w-full"
/>
<div className="text-sm text-gray-500 text-center mt-1">{size}px</div>
</div>
</div>
<div className="flex items-center justify-between">
<Badge variant={hasGoodContrast ? 'success' : 'warning'}>
{hasGoodContrast ? t('generator.contrast_good') : 'Low contrast'}
</Badge>
<div className="text-sm text-gray-500">
Contrast: {contrast.toFixed(1)}:1
</div>
</div>
<div className="flex space-x-3">
<Button variant="outline" className="flex-1" onClick={() => downloadQR('svg')}>
{t('generator.download_svg')}
</Button>
<Button variant="outline" className="flex-1" onClick={() => downloadQR('png')}>
{t('generator.download_png')}
</Button>
</div>
<Button className="w-full">
{t('generator.save_track')}
</Button>
</Card>
{/* Right Preview */}
<div className="flex flex-col items-center justify-center">
<Card className="text-center p-8">
<h3 className="text-lg font-semibold mb-4">{t('generator.live_preview')}</h3>
<div id="instant-qr-preview" className="flex justify-center mb-4">
{url ? (
<div className={`${cornerStyle === 'rounded' ? 'rounded-lg overflow-hidden' : ''}`}>
<QRCodeSVG
value={url}
size={Math.min(size, 200)}
fgColor={foregroundColor}
bgColor={backgroundColor}
level="M"
/>
</div>
) : (
<div
className="bg-gray-200 flex items-center justify-center text-gray-500"
style={{ width: 200, height: 200 }}
>
Enter URL
</div>
)}
</div>
<div className="text-sm text-gray-600 mb-2">URL</div>
<div className="text-xs text-gray-500">{t('generator.demo_note')}</div>
</Card>
</div>
</div>
</div>
</section>
);
};

View File

@@ -0,0 +1,94 @@
'use client';
import React from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
interface PricingProps {
t: any; // i18n translation function
}
export const Pricing: React.FC<PricingProps> = ({ t }) => {
const plans = [
{
key: 'free',
popular: false,
},
{
key: 'pro',
popular: true,
},
{
key: 'business',
popular: false,
},
];
return (
<section className="py-16">
<div className="container mx-auto px-4">
<div className="text-center mb-12">
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
{t('pricing.title')}
</h2>
<p className="text-xl text-gray-600">
{t('pricing.subtitle')}
</p>
</div>
<div className="grid md:grid-cols-3 gap-8 max-w-5xl mx-auto">
{plans.map((plan) => (
<Card
key={plan.key}
className={plan.popular ? 'border-primary-500 shadow-xl relative' : ''}
>
{plan.popular && (
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
<Badge variant="info" className="px-3 py-1">
{t(`pricing.${plan.key}.badge`)}
</Badge>
</div>
)}
<CardHeader className="text-center pb-8">
<CardTitle className="text-2xl mb-4">
{t(`pricing.${plan.key}.title`)}
</CardTitle>
<div className="flex items-baseline justify-center">
<span className="text-4xl font-bold">
{t(`pricing.${plan.key}.price`)}
</span>
<span className="text-gray-600 ml-2">
{t(`pricing.${plan.key}.period`)}
</span>
</div>
</CardHeader>
<CardContent className="space-y-4">
<ul className="space-y-3">
{t(`pricing.${plan.key}.features`, { returnObjects: true }).map((feature: string, index: number) => (
<li key={index} className="flex items-start space-x-3">
<svg className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
<span className="text-gray-700">{feature}</span>
</li>
))}
</ul>
<Button
variant={plan.popular ? 'primary' : 'outline'}
className="w-full"
size="lg"
>
Get Started
</Button>
</CardContent>
</Card>
))}
</div>
</div>
</section>
);
};

View File

@@ -0,0 +1,69 @@
'use client';
import React from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
interface StaticVsDynamicProps {
t: any; // i18n translation function
}
export const StaticVsDynamic: React.FC<StaticVsDynamicProps> = ({ t }) => {
return (
<section className="py-16">
<div className="container mx-auto px-4">
<div className="grid lg:grid-cols-2 gap-8 max-w-6xl mx-auto">
{/* Static QR Codes */}
<Card className="relative">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-2xl">{t('static_vs_dynamic.static.title')}</CardTitle>
<Badge variant="success">{t('static_vs_dynamic.static.subtitle')}</Badge>
</div>
<p className="text-gray-600">{t('static_vs_dynamic.static.description')}</p>
</CardHeader>
<CardContent>
<ul className="space-y-3">
{t('static_vs_dynamic.static.features', { returnObjects: true }).map((feature: string, index: number) => (
<li key={index} className="flex items-center space-x-3">
<div className="flex-shrink-0 w-5 h-5 bg-gray-400 rounded-full flex items-center justify-center">
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
<span className="text-gray-700">{feature}</span>
</li>
))}
</ul>
</CardContent>
</Card>
{/* Dynamic QR Codes */}
<Card className="relative border-primary-200 bg-primary-50">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-2xl">{t('static_vs_dynamic.dynamic.title')}</CardTitle>
<Badge variant="info">{t('static_vs_dynamic.dynamic.subtitle')}</Badge>
</div>
<p className="text-gray-600">{t('static_vs_dynamic.dynamic.description')}</p>
</CardHeader>
<CardContent>
<ul className="space-y-3">
{t('static_vs_dynamic.dynamic.features', { returnObjects: true }).map((feature: string, index: number) => (
<li key={index} className="flex items-center space-x-3">
<div className="flex-shrink-0 w-5 h-5 bg-primary-500 rounded-full flex items-center justify-center">
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
<span className="text-gray-700">{feature}</span>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</div>
</section>
);
};

View File

@@ -0,0 +1,35 @@
'use client';
import React from 'react';
interface StatsStripProps {
t: any; // i18n translation function
}
export const StatsStrip: React.FC<StatsStripProps> = ({ t }) => {
const stats = [
{ key: 'users', value: '10,000+', label: t('trust.users') },
{ key: 'codes', value: '500,000+', label: t('trust.codes') },
{ key: 'scans', value: '50M+', label: t('trust.scans') },
{ key: 'countries', value: '120+', label: t('trust.countries') },
];
return (
<section className="py-16 bg-gray-50">
<div className="container mx-auto px-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
{stats.map((stat, index) => (
<div key={stat.key} className="text-center">
<div className="text-3xl lg:text-4xl font-bold text-primary-600 mb-2">
{stat.value}
</div>
<div className="text-gray-600 font-medium">
{stat.label}
</div>
</div>
))}
</div>
</div>
</section>
);
};

View File

@@ -0,0 +1,70 @@
'use client';
import React from 'react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
interface TemplateCardsProps {
t: any; // i18n translation function
}
export const TemplateCards: React.FC<TemplateCardsProps> = ({ t }) => {
const templates = [
{
key: 'restaurant',
title: t('templates.restaurant'),
icon: '🍽️',
color: 'bg-red-50 border-red-200',
iconBg: 'bg-red-100',
},
{
key: 'business',
title: t('templates.business'),
icon: '💼',
color: 'bg-blue-50 border-blue-200',
iconBg: 'bg-blue-100',
},
{
key: 'wifi',
title: t('templates.wifi'),
icon: '📶',
color: 'bg-purple-50 border-purple-200',
iconBg: 'bg-purple-100',
},
{
key: 'event',
title: t('templates.event'),
icon: '🎫',
color: 'bg-green-50 border-green-200',
iconBg: 'bg-green-100',
},
];
return (
<section className="py-16">
<div className="container mx-auto px-4">
<div className="text-center mb-12">
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
{t('templates.title')}
</h2>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
{templates.map((template) => (
<Card key={template.key} className={`${template.color} text-center hover:scale-105 transition-transform cursor-pointer`}>
<div className={`w-16 h-16 ${template.iconBg} rounded-full flex items-center justify-center mx-auto mb-4`}>
<span className="text-2xl">{template.icon}</span>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{template.title}
</h3>
<Button variant="outline" size="sm" className="w-full">
{t('templates.use_template')}
</Button>
</Card>
))}
</div>
</div>
</section>
);
};

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { cn } from '@/lib/utils';
interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: 'default' | 'success' | 'warning' | 'info' | 'error';
}
export const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
({ className, variant = 'default', ...props }, ref) => {
const variants = {
default: 'bg-gray-100 text-gray-800',
success: 'bg-success-100 text-success-800',
warning: 'bg-warning-100 text-warning-800',
info: 'bg-info-100 text-info-800',
error: 'bg-red-100 text-red-800',
};
return (
<div
ref={ref}
className={cn(
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
variants[variant],
className
)}
{...props}
/>
);
}
);
Badge.displayName = 'Badge';

View File

@@ -0,0 +1,46 @@
import React from 'react';
import { cn } from '@/lib/utils';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'primary', size = 'md', loading, children, disabled, ...props }, ref) => {
const baseClasses = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
const variants = {
primary: 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500',
outline: 'border border-gray-300 text-gray-700 hover:bg-gray-50 focus:ring-gray-500',
ghost: 'text-gray-700 hover:bg-gray-100 focus:ring-gray-500',
};
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
};
return (
<button
ref={ref}
className={cn(baseClasses, variants[variant], sizes[size], className)}
disabled={disabled || loading}
{...props}
>
{loading && (
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
)}
{children}
</button>
);
}
);
Button.displayName = 'Button';

View File

@@ -0,0 +1,94 @@
import React from 'react';
import { cn } from '@/lib/utils';
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
hover?: boolean;
}
export const Card = React.forwardRef<HTMLDivElement, CardProps>(
({ className, hover = false, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(
'bg-white rounded-xl shadow-sm border border-gray-200 p-6',
hover && 'transition-all duration-200 hover:shadow-md hover:border-gray-300',
className
)}
{...props}
/>
);
}
);
Card.displayName = 'Card';
export const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
return (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 pb-4', className)}
{...props}
/>
);
}
);
CardHeader.displayName = 'CardHeader';
export const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => {
return (
<h3
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
);
}
);
CardTitle.displayName = 'CardTitle';
export const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => {
return (
<p
ref={ref}
className={cn('text-sm text-gray-600', className)}
{...props}
/>
);
}
);
CardDescription.displayName = 'CardDescription';
export const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
return (
<div
ref={ref}
className={cn('pt-0', className)}
{...props}
/>
);
}
);
CardContent.displayName = 'CardContent';
export const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
return (
<div
ref={ref}
className={cn('flex items-center pt-4', className)}
{...props}
/>
);
}
);
CardFooter.displayName = 'CardFooter';

View File

@@ -0,0 +1,92 @@
import React from 'react';
import { cn } from '@/lib/utils';
interface DialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
}
export const Dialog: React.FC<DialogProps> = ({ open, onOpenChange, children }) => {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="fixed inset-0 bg-black/50"
onClick={() => onOpenChange(false)}
/>
<div className="relative z-50 w-full max-w-lg mx-4">
{children}
</div>
</div>
);
};
interface DialogContentProps extends React.HTMLAttributes<HTMLDivElement> {}
export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'bg-white rounded-xl shadow-lg border border-gray-200 p-6',
className
)}
{...props}
/>
)
);
DialogContent.displayName = 'DialogContent';
interface DialogHeaderProps extends React.HTMLAttributes<HTMLDivElement> {}
export const DialogHeader = React.forwardRef<HTMLDivElement, DialogHeaderProps>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
{...props}
/>
)
);
DialogHeader.displayName = 'DialogHeader';
interface DialogTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {}
export const DialogTitle = React.forwardRef<HTMLHeadingElement, DialogTitleProps>(
({ className, ...props }, ref) => (
<h2
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
)
);
DialogTitle.displayName = 'DialogTitle';
interface DialogDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {}
export const DialogDescription = React.forwardRef<HTMLParagraphElement, DialogDescriptionProps>(
({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-gray-600', className)}
{...props}
/>
)
);
DialogDescription.displayName = 'DialogDescription';
interface DialogFooterProps extends React.HTMLAttributes<HTMLDivElement> {}
export const DialogFooter = React.forwardRef<HTMLDivElement, DialogFooterProps>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 pt-4', className)}
{...props}
/>
)
);
DialogFooter.displayName = 'DialogFooter';

View File

@@ -0,0 +1,63 @@
import React, { useState, useRef, useEffect } from 'react';
import { cn } from '@/lib/utils';
interface DropdownProps {
trigger: React.ReactNode;
children: React.ReactNode;
align?: 'left' | 'right';
}
export const Dropdown: React.FC<DropdownProps> = ({ trigger, children, align = 'left' }) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div className="relative" ref={dropdownRef}>
<div onClick={() => setIsOpen(!isOpen)}>
{trigger}
</div>
{isOpen && (
<div
className={cn(
'absolute top-full mt-1 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50',
align === 'right' ? 'right-0' : 'left-0'
)}
>
{children}
</div>
)}
</div>
);
};
interface DropdownItemProps extends React.HTMLAttributes<HTMLDivElement> {
icon?: React.ReactNode;
}
export const DropdownItem = React.forwardRef<HTMLDivElement, DropdownItemProps>(
({ className, icon, children, ...props }, ref) => (
<div
ref={ref}
className={cn(
'flex items-center px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer',
className
)}
{...props}
>
{icon && <span className="mr-2">{icon}</span>}
{children}
</div>
)
);
DropdownItem.displayName = 'DropdownItem';

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { cn } from '@/lib/utils';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, label, error, ...props }, ref) => {
return (
<div className="space-y-1">
{label && (
<label className="block text-sm font-medium text-gray-700">
{label}
</label>
)}
<input
type={type}
className={cn(
'flex h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
error && 'border-red-500 focus-visible:ring-red-500',
className
)}
ref={ref}
{...props}
/>
{error && (
<p className="text-sm text-red-600">{error}</p>
)}
</div>
);
}
);
Input.displayName = 'Input';

View File

@@ -0,0 +1,54 @@
'use client';
import React from 'react';
import { QRCodeSVG } from 'qrcode.react';
interface QRCodeProps {
value: string;
size?: number;
fgColor?: string;
bgColor?: string;
level?: 'L' | 'M' | 'Q' | 'H';
includeMargin?: boolean;
imageSettings?: {
src: string;
height: number;
width: number;
excavate: boolean;
};
}
export const QRCode: React.FC<QRCodeProps> = ({
value,
size = 128,
fgColor = '#000000',
bgColor = '#FFFFFF',
level = 'M',
includeMargin = false,
imageSettings,
}) => {
if (!value) {
return (
<div
className="bg-gray-200 flex items-center justify-center text-gray-500"
style={{ width: size, height: size }}
>
No data
</div>
);
}
return (
<QRCodeSVG
value={value}
size={size}
fgColor={fgColor}
bgColor={bgColor}
level={level}
includeMargin={includeMargin}
imageSettings={imageSettings}
/>
);
};
export default QRCode;

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { cn } from '@/lib/utils';
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
error?: string;
options: { value: string; label: string }[];
}
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
({ className, label, error, options, ...props }, ref) => {
return (
<div className="space-y-1">
{label && (
<label className="block text-sm font-medium text-gray-700">
{label}
</label>
)}
<select
className={cn(
'flex h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
error && 'border-red-500 focus-visible:ring-red-500',
className
)}
ref={ref}
{...props}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{error && (
<p className="text-sm text-red-600">{error}</p>
)}
</div>
);
}
);
Select.displayName = 'Select';

105
src/components/ui/Table.tsx Normal file
View File

@@ -0,0 +1,105 @@
import React from 'react';
import { cn } from '@/lib/utils';
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
)
);
Table.displayName = 'Table';
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
)
);
TableHeader.displayName = 'TableHeader';
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
)
);
TableBody.displayName = 'TableBody';
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn('border-t bg-gray-50/50 font-medium [&>tr]:last:border-b-0', className)}
{...props}
/>
)
);
TableFooter.displayName = 'TableFooter';
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'border-b transition-colors hover:bg-gray-50/50 data-[state=selected]:bg-gray-50',
className
)}
{...props}
/>
)
);
TableRow.displayName = 'TableRow';
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-12 px-4 text-left align-middle font-medium text-gray-500 [&:has([role=checkbox])]:pr-0',
className
)}
{...props}
/>
)
);
TableHead.displayName = 'TableHead';
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<td
ref={ref}
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
{...props}
/>
)
);
TableCell.displayName = 'TableCell';
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn('mt-4 text-sm text-gray-500', className)}
{...props}
/>
)
);
TableCaption.displayName = 'TableCaption';
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

140
src/components/ui/Toast.tsx Normal file
View File

@@ -0,0 +1,140 @@
'use client';
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
export interface ToastProps {
id: string;
message: string;
type?: 'success' | 'error' | 'info' | 'warning';
duration?: number;
onClose?: () => void;
}
export const Toast: React.FC<ToastProps> = ({
id,
message,
type = 'info',
duration = 3000,
onClose,
}) => {
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(false);
setTimeout(() => onClose?.(), 300);
}, duration);
return () => clearTimeout(timer);
}, [duration, onClose]);
const icons = {
success: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
),
error: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
),
warning: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
),
info: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
};
const colors = {
success: 'bg-success-50 text-success-900 border-success-200',
error: 'bg-red-50 text-red-900 border-red-200',
warning: 'bg-warning-50 text-warning-900 border-warning-200',
info: 'bg-info-50 text-info-900 border-info-200',
};
return (
<div
className={`
flex items-center space-x-3 px-4 py-3 rounded-lg border shadow-lg
${colors[type]}
transition-all duration-300 transform
${isVisible ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'}
`}
>
<div className="flex-shrink-0">{icons[type]}</div>
<p className="text-sm font-medium">{message}</p>
<button
onClick={() => {
setIsVisible(false);
setTimeout(() => onClose?.(), 300);
}}
className="ml-auto flex-shrink-0 hover:opacity-70"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
};
// Toast Container
export const ToastContainer: React.FC = () => {
const [toasts, setToasts] = useState<ToastProps[]>([]);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
const handleToast = (event: CustomEvent<Omit<ToastProps, 'id'>>) => {
const newToast: ToastProps = {
...event.detail,
id: Date.now().toString(),
};
setToasts(prev => [...prev, newToast]);
};
window.addEventListener('toast' as any, handleToast);
return () => window.removeEventListener('toast' as any, handleToast);
}, []);
const removeToast = (id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id));
};
if (!isMounted) return null;
return createPortal(
<div className="fixed top-4 right-4 z-50 space-y-2">
{toasts.map(toast => (
<Toast
key={toast.id}
{...toast}
onClose={() => removeToast(toast.id)}
/>
))}
</div>,
document.body
);
};
// Helper function to show toast
export const showToast = (
message: string,
type: 'success' | 'error' | 'info' | 'warning' = 'info',
duration = 3000
) => {
if (typeof window !== 'undefined') {
const event = new CustomEvent('toast', {
detail: { message, type, duration },
});
window.dispatchEvent(event);
}
};