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,406 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Table } from '@/components/ui/Table';
import { useTranslation } from '@/hooks/useTranslation';
import { Line, Bar, Doughnut } from 'react-chartjs-2';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
ArcElement,
Title,
Tooltip,
Legend,
Filler,
} from 'chart.js';
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
ArcElement,
Title,
Tooltip,
Legend,
Filler
);
export default function AnalyticsPage() {
const { t } = useTranslation();
const [timeRange, setTimeRange] = useState('7');
const [loading, setLoading] = useState(true);
const [analyticsData, setAnalyticsData] = useState<any>(null);
useEffect(() => {
fetchAnalytics();
}, [timeRange]);
const fetchAnalytics = async () => {
try {
const response = await fetch('/api/analytics/summary');
if (response.ok) {
const data = await response.json();
setAnalyticsData(data);
} else {
// Set empty data if not authorized
setAnalyticsData({
summary: {
totalScans: 0,
uniqueScans: 0,
avgScansPerQR: 0,
mobilePercentage: 0,
topCountry: 'N/A',
topCountryPercentage: 0,
},
deviceStats: {},
countryStats: [],
dailyScans: {},
qrPerformance: [],
});
}
} catch (error) {
console.error('Error fetching analytics:', error);
setAnalyticsData({
summary: {
totalScans: 0,
uniqueScans: 0,
avgScansPerQR: 0,
mobilePercentage: 0,
topCountry: 'N/A',
topCountryPercentage: 0,
},
deviceStats: {},
countryStats: [],
dailyScans: {},
qrPerformance: [],
});
} finally {
setLoading(false);
}
};
const exportReport = () => {
// Create CSV data
const csvData = [
['QR Master Analytics Report'],
['Generated:', new Date().toLocaleString()],
[''],
['Summary'],
['Total Scans', analyticsData?.summary.totalScans || 0],
['Unique Scans', analyticsData?.summary.uniqueScans || 0],
['Average Scans per QR', analyticsData?.summary.avgScansPerQR || 0],
['Mobile Usage %', analyticsData?.summary.mobilePercentage || 0],
[''],
['Top QR Codes'],
['Title', 'Type', 'Total Scans', 'Unique Scans', 'Conversion %'],
...(analyticsData?.qrPerformance || []).map((qr: any) => [
qr.title,
qr.type,
qr.totalScans,
qr.uniqueScans,
qr.conversion,
]),
];
// Convert to CSV string
const csv = csvData.map(row => row.join(',')).join('\n');
// Download
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `qr-analytics-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
};
// Prepare chart data
const last7Days = Array.from({ length: 7 }, (_, i) => {
const date = new Date();
date.setDate(date.getDate() - (6 - i));
return date.toISOString().split('T')[0];
});
const scanChartData = {
labels: last7Days.map(date => {
const d = new Date(date);
return d.toLocaleDateString('en', { month: 'short', day: 'numeric' });
}),
datasets: [
{
label: 'Scans',
data: last7Days.map(date => analyticsData?.dailyScans[date] || 0),
borderColor: 'rgb(37, 99, 235)',
backgroundColor: 'rgba(37, 99, 235, 0.1)',
tension: 0.4,
fill: true,
},
],
};
const deviceChartData = {
labels: ['Desktop', 'Mobile', 'Tablet'],
datasets: [
{
data: [
analyticsData?.deviceStats.desktop || 0,
analyticsData?.deviceStats.mobile || 0,
analyticsData?.deviceStats.tablet || 0,
],
backgroundColor: [
'rgba(37, 99, 235, 0.8)',
'rgba(34, 197, 94, 0.8)',
'rgba(249, 115, 22, 0.8)',
],
},
],
};
if (loading) {
return (
<div className="space-y-8">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/4 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-1/2 mb-8"></div>
<div className="grid md:grid-cols-4 gap-6 mb-8">
{[1, 2, 3, 4].map(i => (
<div key={i} className="h-32 bg-gray-200 rounded"></div>
))}
</div>
</div>
</div>
);
}
return (
<div className="space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">{t('analytics.title')}</h1>
<p className="text-gray-600 mt-2">{t('analytics.subtitle')}</p>
</div>
<Button onClick={exportReport}>Export Report</Button>
</div>
{/* Time Range Selector */}
<div className="flex space-x-2">
{['7', '30', '90'].map((days) => (
<button
key={days}
onClick={() => setTimeRange(days)}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
timeRange === days
? 'bg-primary-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{days} Days
</button>
))}
</div>
{/* KPI Cards */}
<div className="grid md:grid-cols-4 gap-6">
<Card>
<CardContent className="p-6">
<p className="text-sm text-gray-600 mb-1">Total Scans</p>
<p className="text-2xl font-bold text-gray-900">
{analyticsData?.summary.totalScans.toLocaleString() || '0'}
</p>
<p className={`text-sm mt-2 ${analyticsData?.summary.totalScans > 0 ? 'text-green-600' : 'text-gray-500'}`}>
{analyticsData?.summary.totalScans > 0 ? '+12.5%' : 'No data'} from last period
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<p className="text-sm text-gray-600 mb-1">Avg Scans/QR</p>
<p className="text-2xl font-bold text-gray-900">
{analyticsData?.summary.avgScansPerQR || '0'}
</p>
<p className={`text-sm mt-2 ${analyticsData?.summary.avgScansPerQR > 0 ? 'text-green-600' : 'text-gray-500'}`}>
{analyticsData?.summary.avgScansPerQR > 0 ? '+8.3%' : 'No data'} from last period
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<p className="text-sm text-gray-600 mb-1">Mobile Usage</p>
<p className="text-2xl font-bold text-gray-900">
{analyticsData?.summary.mobilePercentage || '0'}%
</p>
<p className="text-sm mt-2 text-gray-500">
{analyticsData?.summary.mobilePercentage > 0 ? 'Of total scans' : 'No mobile scans'}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<p className="text-sm text-gray-600 mb-1">Top Country</p>
<p className="text-2xl font-bold text-gray-900">
{analyticsData?.summary.topCountry || 'N/A'}
</p>
<p className="text-sm mt-2 text-gray-500">
{analyticsData?.summary.topCountryPercentage || '0'}% of total
</p>
</CardContent>
</Card>
</div>
{/* Charts */}
<div className="grid lg:grid-cols-2 gap-8">
{/* Scans Over Time */}
<Card>
<CardHeader>
<CardTitle>Scans Over Time</CardTitle>
</CardHeader>
<CardContent>
<div className="h-64">
<Line
data={scanChartData}
options={{
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
y: {
beginAtZero: true,
ticks: {
precision: 0,
},
},
},
}}
/>
</div>
</CardContent>
</Card>
{/* Device Types */}
<Card>
<CardHeader>
<CardTitle>Device Types</CardTitle>
</CardHeader>
<CardContent>
<div className="h-64 flex items-center justify-center">
{analyticsData?.summary.totalScans > 0 ? (
<Doughnut
data={deviceChartData}
options={{
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
},
},
}}
/>
) : (
<p className="text-gray-500">No scan data available</p>
)}
</div>
</CardContent>
</Card>
</div>
{/* Top Countries Table */}
<Card>
<CardHeader>
<CardTitle>Top Countries</CardTitle>
</CardHeader>
<CardContent>
{analyticsData?.countryStats.length > 0 ? (
<Table>
<thead>
<tr>
<th>Country</th>
<th>Scans</th>
<th>Percentage</th>
<th>Trend</th>
</tr>
</thead>
<tbody>
{analyticsData.countryStats.map((country: any, index: number) => (
<tr key={index}>
<td>{country.country}</td>
<td>{country.count.toLocaleString()}</td>
<td>{country.percentage}%</td>
<td>
<Badge variant="success"></Badge>
</td>
</tr>
))}
</tbody>
</Table>
) : (
<p className="text-gray-500 text-center py-8">No country data available yet</p>
)}
</CardContent>
</Card>
{/* QR Code Performance Table */}
<Card>
<CardHeader>
<CardTitle>QR Code Performance</CardTitle>
</CardHeader>
<CardContent>
{analyticsData?.qrPerformance.length > 0 ? (
<Table>
<thead>
<tr>
<th>QR Code</th>
<th>Type</th>
<th>Total Scans</th>
<th>Unique Scans</th>
<th>Conversion</th>
<th>Trend</th>
</tr>
</thead>
<tbody>
{analyticsData.qrPerformance.map((qr: any) => (
<tr key={qr.id}>
<td className="font-medium">{qr.title}</td>
<td>
<Badge variant={qr.type === 'DYNAMIC' ? 'info' : 'default'}>
{qr.type}
</Badge>
</td>
<td>{qr.totalScans.toLocaleString()}</td>
<td>{qr.uniqueScans.toLocaleString()}</td>
<td>{qr.conversion}%</td>
<td>
<Badge variant={qr.totalScans > 0 ? 'success' : 'default'}>
{qr.totalScans > 0 ? '↑' : '—'}
</Badge>
</td>
</tr>
))}
</tbody>
</Table>
) : (
<p className="text-gray-500 text-center py-8">
No QR codes created yet. Create your first QR code to see analytics!
</p>
)}
</CardContent>
</Card>
</div>
);
}

410
src/app/(app)/bulk/page.tsx Normal file
View File

@@ -0,0 +1,410 @@
'use client';
import React, { useState, useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
import Papa from 'papaparse';
import * as XLSX from 'xlsx';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Select } from '@/components/ui/Select';
import { useTranslation } from '@/hooks/useTranslation';
import { QRCodeSVG } from 'qrcode.react';
interface BulkQRData {
title: string;
contentType: string;
content: string;
tags?: string;
type?: 'STATIC' | 'DYNAMIC';
}
export default function BulkUploadPage() {
const { t } = useTranslation();
const [step, setStep] = useState<'upload' | 'preview' | 'complete'>('upload');
const [data, setData] = useState<BulkQRData[]>([]);
const [mapping, setMapping] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const [uploadResult, setUploadResult] = useState<any>(null);
const onDrop = useCallback((acceptedFiles: File[]) => {
const file = acceptedFiles[0];
if (!file) return;
const reader = new FileReader();
if (file.name.endsWith('.csv')) {
reader.onload = (e) => {
const text = e.target?.result as string;
const result = Papa.parse(text, { header: true });
processData(result.data);
};
reader.readAsText(file);
} else if (file.name.endsWith('.xlsx') || file.name.endsWith('.xls')) {
reader.onload = (e) => {
const data = new Uint8Array(e.target?.result as ArrayBuffer);
const workbook = XLSX.read(data, { type: 'array' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const jsonData = XLSX.utils.sheet_to_json(worksheet);
processData(jsonData);
};
reader.readAsArrayBuffer(file);
}
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'text/csv': ['.csv'],
'application/vnd.ms-excel': ['.xls'],
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
},
maxFiles: 1,
});
const processData = (rawData: any[]) => {
// Auto-detect columns
if (rawData.length > 0) {
const columns = Object.keys(rawData[0]);
const autoMapping: Record<string, string> = {};
columns.forEach((col) => {
const lowerCol = col.toLowerCase();
if (lowerCol.includes('title') || lowerCol.includes('name')) {
autoMapping.title = col;
} else if (lowerCol.includes('type')) {
autoMapping.contentType = col;
} else if (lowerCol.includes('content') || lowerCol.includes('url') || lowerCol.includes('data')) {
autoMapping.content = col;
} else if (lowerCol.includes('tag')) {
autoMapping.tags = col;
}
});
setMapping(autoMapping);
}
setData(rawData);
setStep('preview');
};
const handleUpload = async () => {
setLoading(true);
try {
// Transform data based on mapping
const transformedData = data.map((row: any) => ({
title: row[mapping.title] || 'Untitled',
contentType: row[mapping.contentType] || 'URL',
content: row[mapping.content] || '',
tags: row[mapping.tags] || '',
type: 'DYNAMIC' as const,
}));
const response = await fetch('/api/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ qrCodes: transformedData }),
});
if (response.ok) {
const result = await response.json();
setUploadResult(result);
setStep('complete');
}
} catch (error) {
console.error('Bulk upload error:', error);
} finally {
setLoading(false);
}
};
const downloadTemplate = () => {
const template = [
{ title: 'Product Page', contentType: 'URL', content: 'https://example.com/product', tags: 'product,marketing' },
{ title: 'Contact Card', contentType: 'VCARD', content: 'John Doe', tags: 'contact,business' },
{ title: 'WiFi Network', contentType: 'WIFI', content: 'NetworkName:password123', tags: 'wifi,office' },
];
const csv = Papa.unparse(template);
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'qr-codes-template.csv';
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="max-w-6xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">{t('bulk.title')}</h1>
<p className="text-gray-600 mt-2">{t('bulk.subtitle')}</p>
</div>
{/* Progress Steps */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div className={`flex items-center ${step === 'upload' ? 'text-primary-600' : 'text-gray-400'}`}>
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
step === 'upload' ? 'bg-primary-600 text-white' : 'bg-gray-200'
}`}>
1
</div>
<span className="ml-3 font-medium">Upload File</span>
</div>
<div className="flex-1 h-0.5 bg-gray-200 mx-4">
<div className={`h-full bg-primary-600 transition-all ${
step === 'preview' || step === 'complete' ? 'w-full' : 'w-0'
}`} />
</div>
<div className={`flex items-center ${
step === 'preview' || step === 'complete' ? 'text-primary-600' : 'text-gray-400'
}`}>
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
step === 'preview' || step === 'complete' ? 'bg-primary-600 text-white' : 'bg-gray-200'
}`}>
2
</div>
<span className="ml-3 font-medium">Preview & Map</span>
</div>
<div className="flex-1 h-0.5 bg-gray-200 mx-4">
<div className={`h-full bg-primary-600 transition-all ${
step === 'complete' ? 'w-full' : 'w-0'
}`} />
</div>
<div className={`flex items-center ${step === 'complete' ? 'text-primary-600' : 'text-gray-400'}`}>
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
step === 'complete' ? 'bg-primary-600 text-white' : 'bg-gray-200'
}`}>
3
</div>
<span className="ml-3 font-medium">Complete</span>
</div>
</div>
</div>
{/* Upload Step */}
{step === 'upload' && (
<Card>
<CardContent className="p-8">
<div className="text-center mb-6">
<Button variant="outline" onClick={downloadTemplate}>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Download Template
</Button>
</div>
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-lg p-12 text-center cursor-pointer transition-colors ${
isDragActive ? 'border-primary-500 bg-primary-50' : 'border-gray-300 hover:border-gray-400'
}`}
>
<input {...getInputProps()} />
<svg className="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="text-lg font-medium text-gray-900 mb-2">
{isDragActive ? 'Drop the file here' : 'Drag & drop your file here'}
</p>
<p className="text-sm text-gray-500 mb-4">or click to browse</p>
<p className="text-xs text-gray-400">Supports CSV, XLS, XLSX (max 1000 rows)</p>
</div>
<div className="mt-8 grid md:grid-cols-3 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div>
<p className="font-medium text-gray-900">CSV Format</p>
<p className="text-sm text-gray-500">Comma-separated values</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
</svg>
</div>
<div>
<p className="font-medium text-gray-900">Excel Format</p>
<p className="text-sm text-gray-500">XLS or XLSX files</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div>
<p className="font-medium text-gray-900">Fast Processing</p>
<p className="text-sm text-gray-500">Up to 1000 QR codes</p>
</div>
</div>
</CardContent>
</Card>
</div>
</CardContent>
</Card>
)}
{/* Preview Step */}
{step === 'preview' && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Preview & Map Columns</CardTitle>
<Badge variant="info">{data.length} rows detected</Badge>
</div>
</CardHeader>
<CardContent>
<div className="mb-6 grid md:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Title Column</label>
<Select
value={mapping.title || ''}
onChange={(e) => setMapping({ ...mapping, title: e.target.value })}
options={Object.keys(data[0] || {}).map((col) => ({ value: col, label: col }))}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Content Type Column</label>
<Select
value={mapping.contentType || ''}
onChange={(e) => setMapping({ ...mapping, contentType: e.target.value })}
options={Object.keys(data[0] || {}).map((col) => ({ value: col, label: col }))}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Content Column</label>
<Select
value={mapping.content || ''}
onChange={(e) => setMapping({ ...mapping, content: e.target.value })}
options={Object.keys(data[0] || {}).map((col) => ({ value: col, label: col }))}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Tags Column (Optional)</label>
<Select
value={mapping.tags || ''}
onChange={(e) => setMapping({ ...mapping, tags: e.target.value })}
options={[
{ value: '', label: 'None' },
...Object.keys(data[0] || {}).map((col) => ({ value: col, label: col }))
]}
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Preview</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Title</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Type</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Content</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Tags</th>
</tr>
</thead>
<tbody>
{data.slice(0, 5).map((row: any, index) => (
<tr key={index} className="border-b">
<td className="py-3 px-4">
<QRCodeSVG
value={row[mapping.content] || 'https://example.com'}
size={40}
/>
</td>
<td className="py-3 px-4 text-sm text-gray-900">
{row[mapping.title] || 'Untitled'}
</td>
<td className="py-3 px-4 text-sm text-gray-900">
{row[mapping.contentType] || 'URL'}
</td>
<td className="py-3 px-4 text-sm text-gray-900">
{(row[mapping.content] || '').substring(0, 30)}...
</td>
<td className="py-3 px-4 text-sm text-gray-900">
{row[mapping.tags] || '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{data.length > 5 && (
<p className="text-sm text-gray-500 mt-4 text-center">
Showing 5 of {data.length} rows
</p>
)}
<div className="flex justify-between mt-6">
<Button variant="outline" onClick={() => setStep('upload')}>
Back
</Button>
<Button onClick={handleUpload} loading={loading}>
Create {data.length} QR Codes
</Button>
</div>
</CardContent>
</Card>
)}
{/* Complete Step */}
{step === 'complete' && (
<Card>
<CardContent className="p-12 text-center">
<div className="w-20 h-20 bg-success-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg className="w-10 h-10 text-success-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Upload Complete!</h2>
<p className="text-gray-600 mb-8">
Successfully created {data.length} QR codes
</p>
<div className="flex justify-center space-x-4">
<Button variant="outline" onClick={() => window.location.href = '/dashboard'}>
View Dashboard
</Button>
<Button onClick={() => {
setStep('upload');
setData([]);
setMapping({});
}}>
Upload More
</Button>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,527 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { QRCodeSVG } from 'qrcode.react';
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 { useTranslation } from '@/hooks/useTranslation';
export default function CreatePage() {
const router = useRouter();
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
// Form state
const [title, setTitle] = useState('');
const [contentType, setContentType] = useState('URL');
const [content, setContent] = useState<any>({ url: '' });
const [isDynamic, setIsDynamic] = useState(true);
const [tags, setTags] = useState('');
// Style state
const [foregroundColor, setForegroundColor] = useState('#000000');
const [backgroundColor, setBackgroundColor] = useState('#FFFFFF');
const [cornerStyle, setCornerStyle] = useState('square');
const [size, setSize] = useState(200);
// QR preview
const [qrDataUrl, setQrDataUrl] = useState('');
const contrast = calculateContrast(foregroundColor, backgroundColor);
const hasGoodContrast = contrast >= 4.5;
const contentTypes = [
{ value: 'URL', label: 'URL / Website' },
{ value: 'WIFI', label: 'WiFi Network' },
{ value: 'VCARD', label: 'Contact Card' },
{ value: 'PHONE', label: 'Phone Number' },
{ value: 'EMAIL', label: 'Email' },
{ value: 'SMS', label: 'SMS' },
{ value: 'TEXT', label: 'Plain Text' },
{ value: 'WHATSAPP', label: 'WhatsApp' },
];
// 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 'EMAIL':
return `mailto:${content.email || 'email@example.com'}${content.subject ? `?subject=${encodeURIComponent(content.subject)}` : ''}`;
case 'SMS':
return `sms:${content.phone || '+1234567890'}${content.message ? `?body=${encodeURIComponent(content.message)}` : ''}`;
case 'WIFI':
return `WIFI:T:${content.security || 'WPA'};S:${content.ssid || 'NetworkName'};P:${content.password || ''};H:false;;`;
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 downloadQR = async (format: 'svg' | 'png') => {
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);
} 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);
}
} catch (err) {
console.error('Error downloading QR code:', err);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const qrData = {
title,
contentType,
content,
isStatic: !isDynamic, // Add this flag
tags: tags.split(',').map(t => t.trim()).filter(Boolean),
style: {
foregroundColor,
backgroundColor,
cornerStyle,
size,
},
};
console.log('SENDING QR DATA:', qrData);
const response = await fetch('/api/qrs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(qrData),
});
const responseData = await response.json();
console.log('RESPONSE DATA:', responseData);
if (response.ok) {
// Show what was saved
alert(`QR Code saved!\n\nType: ${responseData.type}\nContent: ${JSON.stringify(responseData.content, null, 2)}`);
router.push('/dashboard');
router.refresh(); // Force refresh to get new data
} else {
console.error('Error creating QR code:', responseData);
alert('Error creating QR code: ' + (responseData.error || 'Unknown error'));
}
} catch (error) {
console.error('Error creating QR code:', error);
alert('Error creating QR code');
} 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 'EMAIL':
return (
<>
<Input
label="Email Address"
type="email"
value={content.email || ''}
onChange={(e) => setContent({ ...content, email: e.target.value })}
placeholder="contact@example.com"
required
/>
<Input
label="Subject (optional)"
value={content.subject || ''}
onChange={(e) => setContent({ ...content, subject: e.target.value })}
placeholder="Email subject"
/>
</>
);
case 'WIFI':
return (
<>
<Input
label="Network Name (SSID)"
value={content.ssid || ''}
onChange={(e) => setContent({ ...content, ssid: e.target.value })}
required
/>
<Input
label="Password"
type="password"
value={content.password || ''}
onChange={(e) => setContent({ ...content, password: e.target.value })}
/>
<Select
label="Security"
value={content.security || 'WPA'}
onChange={(e) => setContent({ ...content, security: e.target.value })}
options={[
{ value: 'WPA', label: 'WPA/WPA2' },
{ value: 'WEP', label: 'WEP' },
{ value: 'nopass', label: 'No Password' },
]}
/>
</>
);
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>
</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()}
<Input
label="Tags (comma-separated)"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="marketing, campaign, 2024"
/>
</CardContent>
</Card>
{/* QR Type Section */}
<Card>
<CardHeader>
<CardTitle>{t('create.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 (Direct URL)</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 URL, no tracking, cannot edit. QR contains actual URL.'}
</p>
{isDynamic && (
<div className="mt-3 p-3 bg-blue-50 rounded-lg">
<p className="text-sm text-blue-900">
<strong>Note:</strong> Dynamic QR codes route through your server for tracking.
In production, deploy your app to get a public URL instead of localhost.
</p>
</div>
)}
</CardContent>
</Card>
{/* Style Section */}
<Card>
<CardHeader>
<CardTitle>{t('create.style')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<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"
/>
<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">
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"
/>
<Input
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="flex-1"
/>
</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>
</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">
{qrContent ? (
<div className={cornerStyle === 'rounded' ? 'rounded-lg overflow-hidden' : ''}>
<QRCodeSVG
value={qrContent}
size={200}
fgColor={foregroundColor}
bgColor={backgroundColor}
level="M"
/>
</div>
) : (
<div className="w-[200px] h-[200px] bg-gray-100 rounded flex items-center justify-center text-gray-500">
Enter content
</div>
)}
</div>
<div className="space-y-3">
<Button
variant="outline"
className="w-full"
type="button"
onClick={() => {
const svg = document.querySelector('#create-qr-preview svg');
if (!svg) return;
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 = `${title || 'qrcode'}.svg`;
a.click();
URL.revokeObjectURL(url);
}}
disabled={!qrContent}
>
Download SVG
</Button>
<Button
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');
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;
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);
}
});
};
img.src = url;
}}
disabled={!qrContent}
>
Download PNG
</Button>
<Button type="submit" className="w-full" loading={loading}>
Save QR Code
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,258 @@
'use client';
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { StatsGrid } from '@/components/dashboard/StatsGrid';
import { QRCodeCard } from '@/components/dashboard/QRCodeCard';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { useTranslation } from '@/hooks/useTranslation';
interface QRCodeData {
id: string;
title: string;
type: 'STATIC' | 'DYNAMIC';
contentType: string;
content?: any;
slug: string;
status: 'ACTIVE' | 'PAUSED';
createdAt: string;
scans: number;
}
export default function DashboardPage() {
const { t } = useTranslation();
const [qrCodes, setQrCodes] = useState<QRCodeData[]>([]);
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState({
totalScans: 0,
activeQRCodes: 0,
conversionRate: 0,
});
const mockQRCodes = [
{
id: '1',
title: 'Support Phone',
type: 'DYNAMIC' as const,
contentType: 'PHONE',
slug: 'support-phone-demo',
status: 'ACTIVE' as const,
createdAt: '2025-08-07T10:00:00Z',
scans: 0,
},
{
id: '2',
title: 'Event Details',
type: 'DYNAMIC' as const,
contentType: 'URL',
slug: 'event-details-demo',
status: 'ACTIVE' as const,
createdAt: '2025-08-07T10:01:00Z',
scans: 0,
},
{
id: '3',
title: 'Product Demo',
type: 'DYNAMIC' as const,
contentType: 'URL',
slug: 'product-demo-qr',
status: 'ACTIVE' as const,
createdAt: '2025-08-07T10:02:00Z',
scans: 0,
},
{
id: '4',
title: 'Company Website',
type: 'DYNAMIC' as const,
contentType: 'URL',
slug: 'company-website-qr',
status: 'ACTIVE' as const,
createdAt: '2025-08-07T10:03:00Z',
scans: 0,
},
{
id: '5',
title: 'Contact Email',
type: 'DYNAMIC' as const,
contentType: 'EMAIL',
slug: 'contact-email-qr',
status: 'ACTIVE' as const,
createdAt: '2025-08-07T10:04:00Z',
scans: 0,
},
{
id: '6',
title: 'Event Details',
type: 'DYNAMIC' as const,
contentType: 'URL',
slug: 'event-details-dup',
status: 'ACTIVE' as const,
createdAt: '2025-08-07T10:05:00Z',
scans: 0,
},
];
const blogPosts = [
{
title: 'QR-Codes im Restaurant: Die digitale Revolution der Speisekarte',
excerpt: 'Erfahren Sie, wie QR-Codes die Gastronomie revolutionieren...',
readTime: '5 Min',
slug: 'qr-codes-im-restaurant',
},
{
title: 'Dynamische vs. Statische QR-Codes: Was ist der Unterschied?',
excerpt: 'Ein umfassender Vergleich zwischen dynamischen und statischen QR-Codes...',
readTime: '3 Min',
slug: 'dynamische-vs-statische-qr-codes',
},
{
title: 'QR-Code Marketing-Strategien für 2024',
excerpt: 'Die besten Marketing-Strategien mit QR-Codes für Ihr Unternehmen...',
readTime: '7 Min',
slug: 'qr-code-marketing-strategien',
},
];
useEffect(() => {
// Load real QR codes from API
const fetchData = async () => {
try {
const response = await fetch('/api/qrs');
if (response.ok) {
const data = await response.json();
setQrCodes(data);
// Calculate real stats
const totalScans = data.reduce((sum: number, qr: QRCodeData) => sum + (qr.scans || 0), 0);
const activeQRCodes = data.filter((qr: QRCodeData) => qr.status === 'ACTIVE').length;
const conversionRate = activeQRCodes > 0 ? Math.round((totalScans / (activeQRCodes * 100)) * 100) : 0;
setStats({
totalScans,
activeQRCodes,
conversionRate: Math.min(conversionRate, 100), // Cap at 100%
});
} else {
// If not logged in, show zeros
setQrCodes([]);
setStats({
totalScans: 0,
activeQRCodes: 0,
conversionRate: 0,
});
}
} catch (error) {
console.error('Error fetching data:', error);
setQrCodes([]);
setStats({
totalScans: 0,
activeQRCodes: 0,
conversionRate: 0,
});
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const handleEdit = (id: string) => {
console.log('Edit QR:', id);
};
const handleDuplicate = (id: string) => {
console.log('Duplicate QR:', id);
};
const handlePause = (id: string) => {
console.log('Pause QR:', id);
};
const handleDelete = (id: string) => {
console.log('Delete QR:', id);
};
return (
<div className="space-y-8">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-gray-900">{t('dashboard.title')}</h1>
<p className="text-gray-600 mt-2">{t('dashboard.subtitle')}</p>
</div>
{/* Stats Grid */}
<StatsGrid stats={stats} />
{/* Recent QR Codes */}
<div>
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-gray-900">{t('dashboard.recent_codes')}</h2>
<Link href="/create">
<Button>Create New QR Code</Button>
</Link>
</div>
{loading ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Card key={i}>
<CardContent className="p-4">
<div className="animate-pulse">
<div className="h-4 bg-gray-200 rounded w-3/4 mb-3"></div>
<div className="h-24 bg-gray-200 rounded mb-3"></div>
<div className="space-y-2">
<div className="h-3 bg-gray-200 rounded"></div>
<div className="h-3 bg-gray-200 rounded"></div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{qrCodes.map((qr) => (
<QRCodeCard
key={qr.id}
qr={qr}
onEdit={handleEdit}
onDuplicate={handleDuplicate}
onPause={handlePause}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
{/* Blog & Resources */}
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-6">{t('dashboard.blog_resources')}</h2>
<div className="grid md:grid-cols-3 gap-6">
{blogPosts.map((post) => (
<Card key={post.slug} hover>
<CardHeader>
<div className="flex items-center justify-between mb-2">
<Badge variant="info">{post.readTime}</Badge>
</div>
<CardTitle className="text-lg">{post.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600 text-sm">{post.excerpt}</p>
<Link
href={`/blog/${post.slug}`}
className="text-primary-600 hover:text-primary-700 text-sm font-medium mt-3 inline-block"
>
Read more
</Link>
</CardContent>
</Card>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,392 @@
'use client';
import React, { useState } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Input } from '@/components/ui/Input';
import { Dialog } from '@/components/ui/Dialog';
import { useTranslation } from '@/hooks/useTranslation';
interface Integration {
id: string;
name: string;
description: string;
icon: string;
status: 'active' | 'inactive' | 'coming_soon';
category: string;
features: string[];
}
export default function IntegrationsPage() {
const { t } = useTranslation();
const [selectedIntegration, setSelectedIntegration] = useState<Integration | null>(null);
const [showSetupDialog, setShowSetupDialog] = useState(false);
const [apiKey, setApiKey] = useState('');
const [webhookUrl, setWebhookUrl] = useState('');
const integrations: Integration[] = [
{
id: 'zapier',
name: 'Zapier',
description: 'Connect QR Master with 5,000+ apps',
icon: '⚡',
status: 'active',
category: 'Automation',
features: [
'Trigger actions when QR codes are scanned',
'Create QR codes from other apps',
'Update QR destinations automatically',
'Sync analytics to spreadsheets',
],
},
{
id: 'airtable',
name: 'Airtable',
description: 'Sync QR codes with your Airtable bases',
icon: '📊',
status: 'inactive',
category: 'Database',
features: [
'Two-way sync with Airtable',
'Bulk import from bases',
'Auto-update QR content',
'Analytics dashboard integration',
],
},
{
id: 'google-sheets',
name: 'Google Sheets',
description: 'Manage QR codes from spreadsheets',
icon: '📈',
status: 'inactive',
category: 'Spreadsheet',
features: [
'Import QR codes from sheets',
'Export analytics data',
'Real-time sync',
'Collaborative QR management',
],
},
{
id: 'slack',
name: 'Slack',
description: 'Get QR scan notifications in Slack',
icon: '💬',
status: 'coming_soon',
category: 'Communication',
features: [
'Real-time scan notifications',
'Daily analytics summaries',
'Team collaboration',
'Custom alert rules',
],
},
{
id: 'webhook',
name: 'Webhooks',
description: 'Send data to any URL',
icon: '🔗',
status: 'active',
category: 'Developer',
features: [
'Custom webhook endpoints',
'Real-time event streaming',
'Retry logic',
'Event filtering',
],
},
{
id: 'api',
name: 'REST API',
description: 'Full programmatic access',
icon: '🔧',
status: 'active',
category: 'Developer',
features: [
'Complete CRUD operations',
'Bulk operations',
'Analytics API',
'Rate limiting: 1000 req/hour',
],
},
];
const stats = {
totalQRCodes: 234,
activeIntegrations: 2,
syncStatus: 'Synced',
availableServices: 6,
};
const handleActivate = (integration: Integration) => {
setSelectedIntegration(integration);
setShowSetupDialog(true);
};
const handleTestConnection = async () => {
// Simulate API test
await new Promise(resolve => setTimeout(resolve, 1000));
alert('Connection successful!');
};
const handleSaveIntegration = () => {
setShowSetupDialog(false);
// Update integration status
};
return (
<div className="space-y-8">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-gray-900">{t('integrations.title')}</h1>
<p className="text-gray-600 mt-2">{t('integrations.subtitle')}</p>
</div>
{/* Stats */}
<div className="grid md:grid-cols-4 gap-6">
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1">QR Codes Total</p>
<p className="text-2xl font-bold text-gray-900">{stats.totalQRCodes}</p>
</div>
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-primary-600" 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>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1">Active Integrations</p>
<p className="text-2xl font-bold text-gray-900">{stats.activeIntegrations}</p>
</div>
<div className="w-12 h-12 bg-success-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-success-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1">Sync Status</p>
<p className="text-2xl font-bold text-gray-900">{stats.syncStatus}</p>
</div>
<div className="w-12 h-12 bg-info-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-info-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 mb-1">Available Services</p>
<p className="text-2xl font-bold text-gray-900">{stats.availableServices}</p>
</div>
<div className="w-12 h-12 bg-warning-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-warning-600" 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>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Integration Cards */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{integrations.map((integration) => (
<Card key={integration.id} className="hover:shadow-lg transition-shadow">
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3">
<div className="text-3xl">{integration.icon}</div>
<div>
<CardTitle className="text-lg">{integration.name}</CardTitle>
<Badge
variant={
integration.status === 'active' ? 'success' :
integration.status === 'coming_soon' ? 'warning' :
'default'
}
className="mt-1"
>
{integration.status === 'active' ? 'Active' :
integration.status === 'coming_soon' ? 'Coming Soon' :
'Inactive'}
</Badge>
</div>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-600 mb-4">{integration.description}</p>
<div className="space-y-2 mb-4">
{integration.features.slice(0, 3).map((feature, index) => (
<div key={index} className="flex items-start space-x-2">
<svg className="w-4 h-4 text-success-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-sm text-gray-700">{feature}</span>
</div>
))}
</div>
{integration.status === 'active' ? (
<Button variant="outline" className="w-full">
Configure
</Button>
) : integration.status === 'coming_soon' ? (
<Button variant="outline" className="w-full" disabled>
Coming Soon
</Button>
) : (
<Button className="w-full" onClick={() => handleActivate(integration)}>
Activate & Configure
</Button>
)}
</CardContent>
</Card>
))}
</div>
{/* Setup Dialog */}
{showSetupDialog && selectedIntegration && (
<Dialog
open={showSetupDialog}
onOpenChange={setShowSetupDialog}
>
<div className="bg-white rounded-xl shadow-lg border border-gray-200 p-6 max-w-lg mx-auto">
<h2 className="text-lg font-semibold mb-4">Setup {selectedIntegration.name}</h2>
<div className="space-y-4">
{selectedIntegration.id === 'zapier' && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Webhook URL
</label>
<Input
value="https://hooks.zapier.com/hooks/catch/123456/abcdef/"
readOnly
className="font-mono text-sm"
/>
<p className="text-sm text-gray-500 mt-1">
Copy this URL to your Zapier trigger
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Events to Send
</label>
<div className="space-y-2">
<label className="flex items-center">
<input type="checkbox" className="mr-2" defaultChecked />
<span className="text-sm">QR Code Scanned</span>
</label>
<label className="flex items-center">
<input type="checkbox" className="mr-2" defaultChecked />
<span className="text-sm">QR Code Created</span>
</label>
<label className="flex items-center">
<input type="checkbox" className="mr-2" />
<span className="text-sm">QR Code Updated</span>
</label>
</div>
</div>
<div className="p-4 bg-gray-50 rounded-lg">
<h4 className="font-medium text-gray-900 mb-2">Sample Payload</h4>
<pre className="text-xs text-gray-600 overflow-x-auto">
{`{
"event": "qr_scanned",
"qr_id": "abc123",
"title": "Product Page",
"timestamp": "2024-01-01T12:00:00Z",
"location": "United States",
"device": "mobile"
}`}
</pre>
</div>
</>
)}
{selectedIntegration.id === 'airtable' && (
<>
<Input
label="API Key"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="key..."
/>
<Input
label="Base ID"
value=""
placeholder="app..."
/>
<Input
label="Table Name"
value=""
placeholder="QR Codes"
/>
<Button variant="outline" onClick={handleTestConnection}>
Test Connection
</Button>
</>
)}
{selectedIntegration.id === 'google-sheets' && (
<>
<div className="text-center p-6">
<Button>
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Connect Google Account
</Button>
</div>
<Input
label="Spreadsheet URL"
value=""
placeholder="https://docs.google.com/spreadsheets/..."
/>
</>
)}
<div className="flex justify-end space-x-3 pt-4">
<Button variant="outline" onClick={() => setShowSetupDialog(false)}>
Cancel
</Button>
<Button onClick={handleSaveIntegration}>
Save Integration
</Button>
</div>
</div>
</div>
</Dialog>
)}
</div>
);
}

159
src/app/(app)/layout.tsx Normal file
View File

@@ -0,0 +1,159 @@
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { signOut } from 'next-auth/react';
import { Button } from '@/components/ui/Button';
import { Dropdown, DropdownItem } from '@/components/ui/Dropdown';
import { useTranslation } from '@/hooks/useTranslation';
export default function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
const { t, locale, setLocale } = useTranslation();
const [sidebarOpen, setSidebarOpen] = useState(false);
const navigation = [
{
name: 'Dashboard',
href: '/dashboard',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
),
},
{
name: 'Create QR',
href: '/create',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
),
},
{
name: 'Analytics',
href: '/analytics',
icon: (
<svg className="w-5 h-5" 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>
),
},
];
return (
<div className="min-h-screen bg-gray-50">
{/* Mobile sidebar backdrop */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={`fixed top-0 left-0 z-50 h-full w-64 bg-white border-r border-gray-200 transform transition-transform lg:translate-x-0 ${
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<Link href="/" className="flex items-center space-x-2">
<img src="/logo.svg" alt="QR Master" className="w-8 h-8" />
<span className="text-xl font-bold text-gray-900">QR Master</span>
</Link>
<button
className="lg:hidden"
onClick={() => setSidebarOpen(false)}
>
<svg className="w-6 h-6" 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>
<nav className="p-4 space-y-1">
{navigation.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.name}
href={item.href}
className={`flex items-center space-x-3 px-3 py-2 rounded-lg transition-colors ${
isActive
? 'bg-primary-50 text-primary-600'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
{item.icon}
<span className="font-medium">{item.name}</span>
</Link>
);
})}
</nav>
</aside>
{/* Main content */}
<div className="lg:ml-64">
{/* Top bar */}
<header className="bg-white border-b border-gray-200">
<div className="flex items-center justify-between px-4 py-3">
<button
className="lg:hidden"
onClick={() => setSidebarOpen(true)}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div className="flex items-center space-x-4 ml-auto">
{/* Language Switcher */}
<button
onClick={() => setLocale(locale === 'en' ? 'de' : 'en')}
className="text-gray-600 hover:text-gray-900 font-medium"
>
{locale === 'en' ? '🇩🇪 DE' : '🇬🇧 EN'}
</button>
{/* User Menu */}
<Dropdown
align="right"
trigger={
<button className="flex items-center space-x-2 text-gray-700 hover:text-gray-900">
<div className="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center">
<span className="text-sm font-medium text-primary-600">
U
</span>
</div>
<span className="hidden md:block font-medium">
User
</span>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
}
>
<DropdownItem onClick={() => signOut()}>
Sign Out
</DropdownItem>
</Dropdown>
</div>
</div>
</header>
{/* Page content */}
<main className="p-6">
{children}
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,453 @@
'use client';
import React, { useState } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { useTranslation } from '@/hooks/useTranslation';
export default function SettingsPage() {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState('profile');
const [loading, setLoading] = useState(false);
// Form states
const [profile, setProfile] = useState({
name: '',
email: '',
company: '',
phone: '',
});
// Load user data from localStorage
React.useEffect(() => {
const userStr = localStorage.getItem('user');
if (userStr) {
const user = JSON.parse(userStr);
setProfile({
name: user.name || '',
email: user.email || '',
company: user.company || '',
phone: user.phone || '',
});
}
}, []);
const [apiKey, setApiKey] = useState('');
const [showApiKey, setShowApiKey] = useState(false);
const tabs = [
{ id: 'profile', label: 'Profile', icon: '👤' },
{ id: 'billing', label: 'Billing', icon: '💳' },
{ id: 'team', label: 'Team & Roles', icon: '👥' },
{ id: 'api', label: 'API Keys', icon: '🔑' },
{ id: 'workspace', label: 'Workspace', icon: '🏢' },
];
const generateApiKey = () => {
const key = 'qrm_' + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
setApiKey(key);
setShowApiKey(true);
};
const handleSaveProfile = async () => {
setLoading(true);
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
setLoading(false);
};
return (
<div className="max-w-6xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">{t('settings.title')}</h1>
<p className="text-gray-600 mt-2">{t('settings.subtitle')}</p>
</div>
<div className="grid lg:grid-cols-4 gap-8">
{/* Sidebar */}
<div className="lg:col-span-1">
<Card>
<CardContent className="p-2">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`w-full text-left px-4 py-3 rounded-lg flex items-center space-x-3 transition-colors ${
activeTab === tab.id
? 'bg-primary-50 text-primary-600'
: 'hover:bg-gray-50 text-gray-700'
}`}
>
<span className="text-xl">{tab.icon}</span>
<span className="font-medium">{tab.label}</span>
</button>
))}
</CardContent>
</Card>
</div>
{/* Content */}
<div className="lg:col-span-3">
{/* Profile Tab */}
{activeTab === 'profile' && (
<Card>
<CardHeader>
<CardTitle>Profile Information</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center space-x-4">
<div className="w-20 h-20 bg-primary-100 rounded-full flex items-center justify-center">
<span className="text-2xl font-bold text-primary-600">JD</span>
</div>
<div>
<Button variant="outline" size="sm">Change Photo</Button>
<p className="text-sm text-gray-500 mt-1">JPG, PNG or GIF. Max 2MB</p>
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<Input
label="Full Name"
value={profile.name}
onChange={(e) => setProfile({ ...profile, name: e.target.value })}
/>
<Input
label="Email"
type="email"
value={profile.email}
onChange={(e) => setProfile({ ...profile, email: e.target.value })}
/>
<Input
label="Company"
value={profile.company}
onChange={(e) => setProfile({ ...profile, company: e.target.value })}
/>
<Input
label="Phone"
value={profile.phone}
onChange={(e) => setProfile({ ...profile, phone: e.target.value })}
/>
</div>
<div className="flex justify-end">
<Button onClick={handleSaveProfile} loading={loading}>
Save Changes
</Button>
</div>
</CardContent>
</Card>
)}
{/* Billing Tab */}
{activeTab === 'billing' && (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Current Plan</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between mb-6">
<div>
<div className="flex items-center space-x-2">
<h3 className="text-2xl font-bold text-gray-900">Pro Plan</h3>
<Badge variant="success">Active</Badge>
</div>
<p className="text-gray-600 mt-1">9/month Renews on Jan 1, 2025</p>
</div>
<Button variant="outline">Change Plan</Button>
</div>
<div className="grid md:grid-cols-3 gap-4">
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-600 mb-1">QR Codes</p>
<p className="text-xl font-bold text-gray-900">234 / 500</p>
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
<div className="bg-primary-600 h-2 rounded-full" style={{ width: '46.8%' }}></div>
</div>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-600 mb-1">Scans</p>
<p className="text-xl font-bold text-gray-900">45,678 / 100,000</p>
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
<div className="bg-primary-600 h-2 rounded-full" style={{ width: '45.7%' }}></div>
</div>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm text-gray-600 mb-1">API Calls</p>
<p className="text-xl font-bold text-gray-900">12,345 / 50,000</p>
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
<div className="bg-primary-600 h-2 rounded-full" style={{ width: '24.7%' }}></div>
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Payment Method</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center space-x-4">
<div className="w-12 h-8 bg-gradient-to-r from-blue-600 to-blue-400 rounded flex items-center justify-center">
<span className="text-white text-xs font-bold">VISA</span>
</div>
<div>
<p className="font-medium text-gray-900"> 4242</p>
<p className="text-sm text-gray-500">Expires 12/25</p>
</div>
</div>
<Button variant="outline" size="sm">Update</Button>
</div>
<Button variant="outline" className="w-full mt-4">
Add Payment Method
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Billing History</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{[
{ date: 'Dec 1, 2024', amount: '€9.00', status: 'Paid' },
{ date: 'Nov 1, 2024', amount: '€9.00', status: 'Paid' },
{ date: 'Oct 1, 2024', amount: '€9.00', status: 'Paid' },
].map((invoice, index) => (
<div key={index} className="flex items-center justify-between py-3 border-b last:border-0">
<div>
<p className="font-medium text-gray-900">{invoice.date}</p>
<p className="text-sm text-gray-500">Pro Plan Monthly</p>
</div>
<div className="flex items-center space-x-3">
<Badge variant="success">{invoice.status}</Badge>
<span className="font-medium text-gray-900">{invoice.amount}</span>
<Button variant="outline" size="sm">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
)}
{/* Team Tab */}
{activeTab === 'team' && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Team Members</CardTitle>
<Button size="sm">Invite Member</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{[
{ name: 'John Doe', email: 'john@example.com', role: 'Owner', status: 'Active' },
{ name: 'Jane Smith', email: 'jane@example.com', role: 'Admin', status: 'Active' },
{ name: 'Bob Johnson', email: 'bob@example.com', role: 'Editor', status: 'Active' },
{ name: 'Alice Brown', email: 'alice@example.com', role: 'Viewer', status: 'Pending' },
].map((member, index) => (
<div key={index} className="flex items-center justify-between py-3 border-b last:border-0">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center">
<span className="text-sm font-medium text-gray-600">
{member.name.split(' ').map(n => n[0]).join('')}
</span>
</div>
<div>
<p className="font-medium text-gray-900">{member.name}</p>
<p className="text-sm text-gray-500">{member.email}</p>
</div>
</div>
<div className="flex items-center space-x-3">
<Badge variant={member.status === 'Active' ? 'success' : 'warning'}>
{member.status}
</Badge>
<select className="px-3 py-1 border rounded-lg text-sm">
<option value="owner" selected={member.role === 'Owner'}>Owner</option>
<option value="admin" selected={member.role === 'Admin'}>Admin</option>
<option value="editor" selected={member.role === 'Editor'}>Editor</option>
<option value="viewer" selected={member.role === 'Viewer'}>Viewer</option>
</select>
{member.role !== 'Owner' && (
<Button variant="outline" size="sm">Remove</Button>
)}
</div>
</div>
))}
</div>
<div className="mt-6 p-4 bg-info-50 rounded-lg">
<p className="text-sm text-info-900">
<strong>Team Seats:</strong> 4 of 5 used
</p>
<p className="text-sm text-info-700 mt-1">
Upgrade to Business plan for unlimited team members
</p>
</div>
</CardContent>
</Card>
)}
{/* API Keys Tab */}
{activeTab === 'api' && (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>API Access</CardTitle>
</CardHeader>
<CardContent>
<div className="mb-6">
<p className="text-gray-600 mb-4">
Use API keys to integrate QR Master with your applications. Keep your keys secure and never share them publicly.
</p>
<div className="p-4 bg-warning-50 rounded-lg">
<p className="text-sm text-warning-900">
<strong> Warning:</strong> API keys provide full access to your account. Treat them like passwords.
</p>
</div>
</div>
{!apiKey ? (
<Button onClick={generateApiKey}>Generate New API Key</Button>
) : (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Your API Key</label>
<div className="flex space-x-2">
<Input
type={showApiKey ? 'text' : 'password'}
value={apiKey}
readOnly
className="font-mono"
/>
<Button
variant="outline"
onClick={() => setShowApiKey(!showApiKey)}
>
{showApiKey ? 'Hide' : 'Show'}
</Button>
<Button
variant="outline"
onClick={() => navigator.clipboard.writeText(apiKey)}
>
Copy
</Button>
</div>
<p className="text-sm text-gray-500 mt-2">
This key will only be shown once. Store it securely.
</p>
</div>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>API Documentation</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<h4 className="font-medium text-gray-900 mb-2">Base URL</h4>
<code className="block p-3 bg-gray-100 rounded text-sm">
https://api.qrmaster.com/v1
</code>
</div>
<div>
<h4 className="font-medium text-gray-900 mb-2">Authentication</h4>
<code className="block p-3 bg-gray-100 rounded text-sm">
Authorization: Bearer YOUR_API_KEY
</code>
</div>
<div>
<h4 className="font-medium text-gray-900 mb-2">Example Request</h4>
<pre className="p-3 bg-gray-100 rounded text-sm overflow-x-auto">
{`curl -X POST https://api.qrmaster.com/v1/qr-codes \\
-H "Authorization: Bearer YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{"title":"My QR","content":"https://example.com"}'`}
</pre>
</div>
<Button variant="outline">View Full Documentation</Button>
</div>
</CardContent>
</Card>
</div>
)}
{/* Workspace Tab */}
{activeTab === 'workspace' && (
<Card>
<CardHeader>
<CardTitle>Workspace Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Workspace Name</label>
<Input value="Acme Corp" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Workspace URL</label>
<div className="flex">
<span className="inline-flex items-center px-3 rounded-l-lg border border-r-0 border-gray-300 bg-gray-50 text-gray-500 text-sm">
qrmaster.com/
</span>
<Input value="acme-corp" className="rounded-l-none" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Default QR Settings</label>
<div className="space-y-3">
<label className="flex items-center">
<input type="checkbox" className="mr-2" defaultChecked />
<span className="text-sm text-gray-700">Auto-generate slugs for dynamic QR codes</span>
</label>
<label className="flex items-center">
<input type="checkbox" className="mr-2" defaultChecked />
<span className="text-sm text-gray-700">Track scan analytics by default</span>
</label>
<label className="flex items-center">
<input type="checkbox" className="mr-2" />
<span className="text-sm text-gray-700">Require approval for new QR codes</span>
</label>
</div>
</div>
<div className="pt-6 border-t">
<h4 className="text-lg font-medium text-gray-900 mb-4">Danger Zone</h4>
<div className="p-4 border border-red-200 rounded-lg bg-red-50">
<p className="text-sm text-red-900 mb-3">
Deleting your workspace will permanently remove all QR codes, analytics data, and team members.
</p>
<Button variant="outline" className="border-red-300 text-red-600 hover:bg-red-100">
Delete Workspace
</Button>
</div>
</div>
<div className="flex justify-end">
<Button>Save Changes</Button>
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}

158
src/app/(app)/test/page.tsx Normal file
View File

@@ -0,0 +1,158 @@
'use client';
import React, { useState } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { Button } from '@/components/ui/Button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
export default function TestPage() {
const [testResults, setTestResults] = useState<any>({});
const [loading, setLoading] = useState(false);
const runTest = async () => {
setLoading(true);
const results: any = {};
try {
// Step 1: Create a STATIC QR code
console.log('Creating STATIC QR code...');
const createResponse = await fetch('/api/qrs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'Test Static QR',
contentType: 'URL',
content: { url: 'https://google.com' },
isStatic: true,
tags: [],
style: {
foregroundColor: '#000000',
backgroundColor: '#FFFFFF',
cornerStyle: 'square',
size: 200,
},
}),
});
const createdQR = await createResponse.json();
results.created = createdQR;
console.log('Created QR:', createdQR);
// Step 2: Fetch all QR codes
console.log('Fetching QR codes...');
const fetchResponse = await fetch('/api/qrs');
const allQRs = await fetchResponse.json();
results.fetched = allQRs;
console.log('Fetched QRs:', allQRs);
// Step 3: Check debug endpoint
console.log('Checking debug endpoint...');
const debugResponse = await fetch('/api/debug');
const debugData = await debugResponse.json();
results.debug = debugData;
console.log('Debug data:', debugData);
} catch (error) {
results.error = String(error);
console.error('Test error:', error);
}
setTestResults(results);
setLoading(false);
};
const getQRValue = (qr: any) => {
// Check for qrContent field
if (qr?.content?.qrContent) {
return qr.content.qrContent;
}
// Check for direct URL
if (qr?.content?.url) {
return qr.content.url;
}
// Fallback to redirect
return `http://localhost:3001/r/${qr?.slug || 'unknown'}`;
};
return (
<div className="max-w-4xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-6">QR Code Test Page</h1>
<Card className="mb-6">
<CardHeader>
<CardTitle>Test Static QR Code Creation</CardTitle>
</CardHeader>
<CardContent>
<Button onClick={runTest} loading={loading}>
Run Test
</Button>
{testResults.created && (
<div className="mt-6">
<h3 className="font-semibold mb-2">Created QR Code:</h3>
<pre className="bg-gray-100 p-3 rounded text-xs overflow-auto">
{JSON.stringify(testResults.created, null, 2)}
</pre>
<div className="mt-4">
<h4 className="font-semibold mb-2">QR Code Preview:</h4>
<div className="bg-gray-50 p-4 rounded">
<QRCodeSVG
value={getQRValue(testResults.created)}
size={200}
/>
<p className="mt-2 text-sm text-gray-600">
QR Value: {getQRValue(testResults.created)}
</p>
</div>
</div>
</div>
)}
{testResults.fetched && (
<div className="mt-6">
<h3 className="font-semibold mb-2">All QR Codes:</h3>
<pre className="bg-gray-100 p-3 rounded text-xs overflow-auto max-h-64">
{JSON.stringify(testResults.fetched, null, 2)}
</pre>
</div>
)}
{testResults.debug && (
<div className="mt-6">
<h3 className="font-semibold mb-2">Debug Data:</h3>
<pre className="bg-gray-100 p-3 rounded text-xs overflow-auto max-h-64">
{JSON.stringify(testResults.debug, null, 2)}
</pre>
</div>
)}
{testResults.error && (
<div className="mt-6 p-4 bg-red-50 text-red-600 rounded">
Error: {testResults.error}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Manual QR Tests</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h3 className="font-semibold mb-2">Direct URL QR (Should go to Google):</h3>
<QRCodeSVG value="https://google.com" size={150} />
<p className="text-sm text-gray-600 mt-1">Value: https://google.com</p>
</div>
<div>
<h3 className="font-semibold mb-2">Redirect QR (Goes through localhost):</h3>
<QRCodeSVG value="http://localhost:3001/r/test-slug" size={150} />
<p className="text-sm text-gray-600 mt-1">Value: http://localhost:3001/r/test-slug</p>
</div>
</CardContent>
</Card>
</div>
);
}

11
src/app/(auth)/layout.tsx Normal file
View File

@@ -0,0 +1,11 @@
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white">
{children}
</div>
);
}

View File

@@ -0,0 +1,185 @@
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { useTranslation } from '@/hooks/useTranslation';
export default function LoginPage() {
const router = useRouter();
const { t } = useTranslation();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const response = await fetch('/api/auth/simple-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (response.ok && data.success) {
// Store user in localStorage for client-side
localStorage.setItem('user', JSON.stringify(data.user));
router.push('/dashboard');
router.refresh();
} else {
setError(data.error || 'Invalid email or password');
}
} catch (err) {
setError('An error occurred. Please try again.');
} finally {
setLoading(false);
}
};
const handleGoogleSignIn = () => {
// Google sign-in disabled for now
alert('Google sign-in coming soon!');
};
// Demo login
const handleDemoLogin = () => {
setEmail('demo@qrmaster.com');
setPassword('demo123');
};
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
<span className="text-2xl font-bold text-gray-900">QR Master</span>
</Link>
<h1 className="text-3xl font-bold text-gray-900">Welcome Back</h1>
<p className="text-gray-600 mt-2">Sign in to your account</p>
</div>
<Card>
<CardContent className="p-6">
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
{error}
</div>
)}
<Input
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
/>
<Input
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
/>
<div className="flex items-center justify-between">
<label className="flex items-center">
<input type="checkbox" className="mr-2" />
<span className="text-sm text-gray-600">Remember me</span>
</label>
<Link href="/forgot-password" className="text-sm text-primary-600 hover:text-primary-700">
Forgot password?
</Link>
</div>
<Button type="submit" className="w-full" loading={loading}>
Sign In
</Button>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">Or continue with</span>
</div>
</div>
<Button
type="button"
variant="outline"
className="w-full"
onClick={handleGoogleSignIn}
>
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Sign in with Google
</Button>
<Button
type="button"
variant="outline"
className="w-full"
onClick={handleDemoLogin}
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Use Demo Account
</Button>
</form>
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
Don't have an account?{' '}
<Link href="/signup" className="text-primary-600 hover:text-primary-700 font-medium">
Sign up
</Link>
</p>
</div>
</CardContent>
</Card>
<p className="text-center text-sm text-gray-500 mt-6">
By signing in, you agree to our{' '}
<Link href="/terms" className="text-primary-600 hover:text-primary-700">
Terms of Service
</Link>{' '}
and{' '}
<Link href="/privacy" className="text-primary-600 hover:text-primary-700">
Privacy Policy
</Link>
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,197 @@
'use client';
import React, { useState } from 'react';
import { signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { useTranslation } from '@/hooks/useTranslation';
export default function SignupPage() {
const router = useRouter();
const { t } = useTranslation();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
if (password !== confirmPassword) {
setError('Passwords do not match');
setLoading(false);
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters');
setLoading(false);
return;
}
try {
const response = await fetch('/api/auth/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, password }),
});
if (response.ok) {
// Auto sign in after signup
const result = await signIn('credentials', {
email,
password,
redirect: false,
});
if (result?.ok) {
router.push('/dashboard');
}
} else {
const data = await response.json();
setError(data.error || 'Failed to create account');
}
} catch (err) {
setError('An error occurred. Please try again.');
} finally {
setLoading(false);
}
};
const handleGoogleSignIn = () => {
signIn('google', { callbackUrl: '/dashboard' });
};
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-white flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<Link href="/" className="inline-flex items-center space-x-2 mb-6">
<img src="/logo.svg" alt="QR Master" className="w-10 h-10" />
<span className="text-2xl font-bold text-gray-900">QR Master</span>
</Link>
<h1 className="text-3xl font-bold text-gray-900">Create Account</h1>
<p className="text-gray-600 mt-2">Start creating QR codes in seconds</p>
</div>
<Card>
<CardContent className="p-6">
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
{error}
</div>
)}
<Input
label="Full Name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="John Doe"
required
/>
<Input
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
/>
<Input
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
/>
<Input
label="Confirm Password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
required
/>
<div className="flex items-start">
<input type="checkbox" className="mr-2 mt-1" required />
<label className="text-sm text-gray-600">
I agree to the{' '}
<Link href="/terms" className="text-primary-600 hover:text-primary-700">
Terms of Service
</Link>{' '}
and{' '}
<Link href="/privacy" className="text-primary-600 hover:text-primary-700">
Privacy Policy
</Link>
</label>
</div>
<Button type="submit" className="w-full" loading={loading}>
Create Account
</Button>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">Or continue with</span>
</div>
</div>
<Button
type="button"
variant="outline"
className="w-full"
onClick={handleGoogleSignIn}
>
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Sign up with Google
</Button>
</form>
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
Already have an account?{' '}
<Link href="/login" className="text-primary-600 hover:text-primary-700 font-medium">
Sign in
</Link>
</p>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,157 @@
'use client';
import React from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
const blogContent = {
'qr-codes-im-restaurant': {
title: 'QR-Codes im Restaurant: Die digitale Revolution der Speisekarte',
date: '2024-01-15',
readTime: '5 Min',
category: 'Gastronomie',
content: `
<p>Die Gastronomie hat sich in den letzten Jahren stark digitalisiert, und QR-Codes spielen dabei eine zentrale Rolle. Von kontaktlosen Speisekarten bis hin zu digitalen Zahlungssystemen QR-Codes revolutionieren die Art und Weise, wie Restaurants mit ihren Gästen interagieren.</p>
<h2>Vorteile für Restaurants</h2>
<ul>
<li>Kostenersparnis durch digitale Speisekarten</li>
<li>Einfache Aktualisierung von Preisen und Angeboten</li>
<li>Hygienische, kontaktlose Lösung</li>
<li>Mehrsprachige Menüs ohne zusätzliche Druckkosten</li>
</ul>
<h2>Vorteile für Gäste</h2>
<ul>
<li>Schneller Zugriff auf aktuelle Informationen</li>
<li>Detaillierte Produktbeschreibungen und Allergeninformationen</li>
<li>Einfache Bestellung und Bezahlung</li>
<li>Personalisierte Empfehlungen</li>
</ul>
<p>Die Implementierung von QR-Codes in Ihrem Restaurant ist einfacher als Sie denken. Mit QR Master können Sie in wenigen Minuten professionelle QR-Codes erstellen, die perfekt zu Ihrem Branding passen.</p>
`,
},
'dynamische-vs-statische-qr-codes': {
title: 'Dynamische vs. Statische QR-Codes: Was ist der Unterschied?',
date: '2024-01-10',
readTime: '3 Min',
category: 'Grundlagen',
content: `
<p>Bei der Erstellung von QR-Codes stehen Sie vor der Wahl zwischen statischen und dynamischen Codes. Beide haben ihre Vor- und Nachteile, und die richtige Wahl hängt von Ihrem spezifischen Anwendungsfall ab.</p>
<h2>Statische QR-Codes</h2>
<p>Statische QR-Codes enthalten die Informationen direkt im Code selbst. Einmal erstellt, können sie nicht mehr geändert werden.</p>
<ul>
<li>Kostenlos und unbegrenzt nutzbar</li>
<li>Funktionieren für immer ohne Server</li>
<li>Ideal für permanente Informationen</li>
<li>Keine Tracking-Möglichkeiten</li>
</ul>
<h2>Dynamische QR-Codes</h2>
<p>Dynamische QR-Codes verweisen auf eine URL, die Sie jederzeit ändern können.</p>
<ul>
<li>Inhalt kann nachträglich geändert werden</li>
<li>Detaillierte Scan-Statistiken</li>
<li>Kürzere, sauberere QR-Codes</li>
<li>Perfekt für Marketing-Kampagnen</li>
</ul>
<p>Mit QR Master können Sie beide Arten von QR-Codes erstellen und verwalten. Unsere Plattform bietet Ihnen die Flexibilität, die Sie für Ihre Projekte benötigen.</p>
`,
},
'qr-code-marketing-strategien': {
title: 'QR-Code Marketing-Strategien für 2024',
date: '2024-01-05',
readTime: '7 Min',
category: 'Marketing',
content: `
<p>QR-Codes sind zu einem unverzichtbaren Werkzeug im modernen Marketing geworden. Hier sind die effektivsten Strategien für 2024.</p>
<h2>1. Personalisierte Kundenerlebnisse</h2>
<p>Nutzen Sie dynamische QR-Codes, um personalisierte Landingpages basierend auf Standort, Zeit oder Kundenverhalten zu erstellen.</p>
<h2>2. Social Media Integration</h2>
<p>Verbinden Sie QR-Codes mit Ihren Social-Media-Kampagnen für nahtlose Cross-Channel-Erlebnisse.</p>
<h2>3. Event-Marketing</h2>
<p>Von Tickets bis zu Networking QR-Codes machen Events interaktiver und messbar.</p>
<h2>4. Loyalty-Programme</h2>
<p>Digitale Treuekarten und Rabattaktionen lassen sich perfekt mit QR-Codes umsetzen.</p>
<h2>5. Analytics und Optimierung</h2>
<p>Nutzen Sie die Tracking-Funktionen, um Ihre Kampagnen kontinuierlich zu verbessern.</p>
<p>Mit QR Master haben Sie alle Tools, die Sie für erfolgreiches QR-Code-Marketing benötigen. Starten Sie noch heute mit Ihrer ersten Kampagne!</p>
`,
},
};
export default function BlogPostPage() {
const params = useParams();
const slug = params?.slug as string;
const post = blogContent[slug as keyof typeof blogContent];
if (!post) {
return (
<div className="py-20">
<div className="container mx-auto px-4 text-center">
<h1 className="text-4xl font-bold text-gray-900 mb-4">Post not found</h1>
<p className="text-xl text-gray-600 mb-8">The blog post you're looking for doesn't exist.</p>
<Link href="/blog">
<Button>Back to Blog</Button>
</Link>
</div>
</div>
);
}
return (
<div className="py-20">
<div className="container mx-auto px-4">
<div className="max-w-4xl mx-auto">
<Link href="/blog" className="inline-flex items-center text-primary-600 hover:text-primary-700 mb-8">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to Blog
</Link>
<article>
<header className="mb-8">
<div className="flex items-center space-x-4 mb-4">
<Badge variant="info">{post.category}</Badge>
<span className="text-gray-500">{post.readTime}</span>
<span className="text-gray-500">{post.date}</span>
</div>
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-4">
{post.title}
</h1>
</header>
<div
className="prose prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
<div className="mt-12 p-8 bg-primary-50 rounded-xl text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Ready to create your QR codes?
</h2>
<p className="text-gray-600 mb-6">
Start creating professional QR codes with advanced tracking and analytics.
</p>
<Link href="/dashboard">
<Button size="lg">Get Started Free</Button>
</Link>
</div>
</article>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
const blogPosts = [
{
slug: 'qr-codes-im-restaurant',
title: 'QR-Codes im Restaurant: Die digitale Revolution der Speisekarte',
excerpt: 'Erfahren Sie, wie QR-Codes die Gastronomie revolutionieren und welche Vorteile sie für Restaurants und Gäste bieten.',
date: '2024-01-15',
readTime: '5 Min',
category: 'Gastronomie',
image: '🍽️',
},
{
slug: 'dynamische-vs-statische-qr-codes',
title: 'Dynamische vs. Statische QR-Codes: Was ist der Unterschied?',
excerpt: 'Ein umfassender Vergleich zwischen dynamischen und statischen QR-Codes und wann Sie welchen Typ verwenden sollten.',
date: '2024-01-10',
readTime: '3 Min',
category: 'Grundlagen',
image: '📊',
},
{
slug: 'qr-code-marketing-strategien',
title: 'QR-Code Marketing-Strategien für 2024',
excerpt: 'Die besten Marketing-Strategien mit QR-Codes für Ihr Unternehmen im Jahr 2024.',
date: '2024-01-05',
readTime: '7 Min',
category: 'Marketing',
image: '📈',
},
];
export default function BlogPage() {
return (
<div className="py-20">
<div className="container mx-auto px-4">
<div className="text-center mb-12">
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-4">
Blog & Resources
</h1>
<p className="text-xl text-gray-600">
Learn about QR codes, best practices, and industry insights
</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8 max-w-6xl mx-auto">
{blogPosts.map((post) => (
<Link key={post.slug} href={`/blog/${post.slug}`}>
<Card hover className="h-full">
<CardHeader>
<div className="text-4xl mb-4 text-center bg-gray-100 rounded-lg py-8">
{post.image}
</div>
<div className="flex items-center justify-between mb-2">
<Badge variant="info">{post.category}</Badge>
<span className="text-sm text-gray-500">{post.readTime}</span>
</div>
<CardTitle className="text-xl">{post.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600 mb-4">{post.excerpt}</p>
<p className="text-sm text-gray-500">{post.date}</p>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,15 @@
'use client';
import React from 'react';
import { FAQ } from '@/components/marketing/FAQ';
import { useTranslation } from '@/hooks/useTranslation';
export default function FAQPage() {
const { t } = useTranslation();
return (
<div className="py-20">
<FAQ t={t} />
</div>
);
}

View File

@@ -0,0 +1,165 @@
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Button } from '@/components/ui/Button';
import { useTranslation } from '@/hooks/useTranslation';
export default function MarketingLayout({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
const { t, locale, setLocale } = useTranslation();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const navigation = [
{ name: t('nav.features'), href: '/#features' },
{ name: t('nav.pricing'), href: '/pricing' },
{ name: t('nav.faq'), href: '/faq' },
{ name: t('nav.blog'), href: '/blog' },
];
return (
<div className="min-h-screen bg-white">
{/* Header */}
<header className="sticky top-0 z-50 bg-white border-b border-gray-200">
<nav className="container mx-auto px-4 py-4">
<div className="flex items-center justify-between">
{/* Logo */}
<Link href="/" className="flex items-center space-x-2">
<img src="/logo.svg" alt="QR Master" className="w-8 h-8" />
<span className="text-xl font-bold text-gray-900">QR Master</span>
</Link>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center space-x-8">
{navigation.map((item) => (
<Link
key={item.name}
href={item.href}
className="text-gray-600 hover:text-gray-900 font-medium transition-colors"
>
{item.name}
</Link>
))}
</div>
{/* Right Actions */}
<div className="hidden md:flex items-center space-x-4">
{/* Language Switcher */}
<button
onClick={() => setLocale(locale === 'en' ? 'de' : 'en')}
className="text-gray-600 hover:text-gray-900 font-medium"
>
{locale === 'en' ? '🇩🇪 DE' : '🇬🇧 EN'}
</button>
<Link href="/login">
<Button variant="outline">{t('nav.login')}</Button>
</Link>
<Link href="/dashboard">
<Button>{t('nav.dashboard')}</Button>
</Link>
</div>
{/* Mobile Menu Button */}
<button
className="md:hidden"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{mobileMenuOpen ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
)}
</svg>
</button>
</div>
{/* Mobile Menu */}
{mobileMenuOpen && (
<div className="md:hidden mt-4 pb-4 border-t border-gray-200 pt-4">
<div className="flex flex-col space-y-4">
{navigation.map((item) => (
<Link
key={item.name}
href={item.href}
className="text-gray-600 hover:text-gray-900 font-medium"
onClick={() => setMobileMenuOpen(false)}
>
{item.name}
</Link>
))}
<Link href="/login" onClick={() => setMobileMenuOpen(false)}>
<Button variant="outline" className="w-full">{t('nav.login')}</Button>
</Link>
<Link href="/dashboard" onClick={() => setMobileMenuOpen(false)}>
<Button className="w-full">{t('nav.dashboard')}</Button>
</Link>
</div>
</div>
)}
</nav>
</header>
{/* Main Content */}
<main>{children}</main>
{/* Footer */}
<footer className="bg-gray-900 text-white py-12 mt-20">
<div className="container mx-auto px-4">
<div className="grid md:grid-cols-4 gap-8">
<div>
<div className="flex items-center space-x-2 mb-4">
<img src="/logo.svg" alt="QR Master" className="w-8 h-8 brightness-0 invert" />
<span className="text-xl font-bold">QR Master</span>
</div>
<p className="text-gray-400">
Create custom QR codes in seconds with advanced tracking and analytics.
</p>
</div>
<div>
<h3 className="font-semibold mb-4">Product</h3>
<ul className="space-y-2 text-gray-400">
<li><Link href="/#features" className="hover:text-white">Features</Link></li>
<li><Link href="/pricing" className="hover:text-white">Pricing</Link></li>
<li><Link href="/faq" className="hover:text-white">FAQ</Link></li>
<li><Link href="/blog" className="hover:text-white">Blog</Link></li>
</ul>
</div>
<div>
<h3 className="font-semibold mb-4">Company</h3>
<ul className="space-y-2 text-gray-400">
<li><a href="#" className="hover:text-white">About</a></li>
<li><a href="#" className="hover:text-white">Careers</a></li>
<li><a href="#" className="hover:text-white">Contact</a></li>
<li><a href="#" className="hover:text-white">Partners</a></li>
</ul>
</div>
<div>
<h3 className="font-semibold mb-4">Legal</h3>
<ul className="space-y-2 text-gray-400">
<li><a href="#" className="hover:text-white">Privacy Policy</a></li>
<li><a href="#" className="hover:text-white">Terms of Service</a></li>
<li><a href="#" className="hover:text-white">Cookie Policy</a></li>
<li><a href="#" className="hover:text-white">GDPR</a></li>
</ul>
</div>
</div>
<div className="border-t border-gray-800 mt-8 pt-8 text-center text-gray-400">
<p>&copy; 2024 QR Master. All rights reserved.</p>
</div>
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,77 @@
'use client';
import React from 'react';
import { Hero } from '@/components/marketing/Hero';
import { StatsStrip } from '@/components/marketing/StatsStrip';
import { TemplateCards } from '@/components/marketing/TemplateCards';
import { InstantGenerator } from '@/components/marketing/InstantGenerator';
import { StaticVsDynamic } from '@/components/marketing/StaticVsDynamic';
import { Features } from '@/components/marketing/Features';
import { Pricing } from '@/components/marketing/Pricing';
import { FAQ } from '@/components/marketing/FAQ';
import { Button } from '@/components/ui/Button';
import { useTranslation } from '@/hooks/useTranslation';
export default function HomePage() {
const { t } = useTranslation();
const industries = [
'Restaurant Chain',
'Tech Startup',
'Real Estate',
'Event Agency',
'Retail Store',
'Healthcare',
];
return (
<>
<Hero t={t} />
<StatsStrip t={t} />
{/* Industry Buttons */}
<section className="py-8">
<div className="container mx-auto px-4">
<div className="flex flex-wrap justify-center gap-3">
{industries.map((industry) => (
<Button key={industry} variant="outline" size="sm">
{industry}
</Button>
))}
</div>
</div>
</section>
<TemplateCards t={t} />
<InstantGenerator t={t} />
<StaticVsDynamic t={t} />
<Features t={t} />
{/* Pricing Teaser */}
<section className="py-16 bg-primary-50">
<div className="container mx-auto px-4 text-center">
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
Ready to get started?
</h2>
<p className="text-xl text-gray-600 mb-8">
Choose the perfect plan for your needs
</p>
<Button size="lg">View Pricing Plans</Button>
</div>
</section>
{/* FAQ Teaser */}
<section className="py-16">
<div className="container mx-auto px-4 text-center">
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
Have questions?
</h2>
<p className="text-xl text-gray-600 mb-8">
Check out our frequently asked questions
</p>
<Button variant="outline" size="lg">View FAQ</Button>
</div>
</section>
</>
);
}

View File

@@ -0,0 +1,15 @@
'use client';
import React from 'react';
import { Pricing } from '@/components/marketing/Pricing';
import { useTranslation } from '@/hooks/useTranslation';
export default function PricingPage() {
const { t } = useTranslation();
return (
<div className="py-20">
<Pricing t={t} />
</div>
);
}

View File

@@ -0,0 +1,110 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
export async function GET(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Get user's QR codes
const qrCodes = await db.qRCode.findMany({
where: { userId },
include: {
scans: true,
},
});
// Calculate stats
const totalScans = qrCodes.reduce((sum, qr) => sum + qr.scans.length, 0);
const uniqueScans = qrCodes.reduce((sum, qr) =>
sum + qr.scans.filter(s => s.isUnique).length, 0
);
// Device stats
const deviceStats = qrCodes.flatMap(qr => qr.scans)
.reduce((acc, scan) => {
const device = scan.device || 'unknown';
acc[device] = (acc[device] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const mobileScans = (deviceStats.mobile || 0) + (deviceStats.tablet || 0);
const mobilePercentage = totalScans > 0
? Math.round((mobileScans / totalScans) * 100)
: 0;
// Country stats
const countryStats = qrCodes.flatMap(qr => qr.scans)
.reduce((acc, scan) => {
const country = scan.country || 'Unknown';
acc[country] = (acc[country] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const topCountry = Object.entries(countryStats)
.sort(([,a], [,b]) => b - a)[0];
// Time-based stats (last 30 days)
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const recentScans = qrCodes.flatMap(qr => qr.scans)
.filter(scan => new Date(scan.ts) > thirtyDaysAgo);
// Daily scan counts for chart
const dailyScans = recentScans.reduce((acc, scan) => {
const date = new Date(scan.ts).toISOString().split('T')[0];
acc[date] = (acc[date] || 0) + 1;
return acc;
}, {} as Record<string, number>);
// QR performance
const qrPerformance = qrCodes.map(qr => ({
id: qr.id,
title: qr.title,
type: qr.type,
totalScans: qr.scans.length,
uniqueScans: qr.scans.filter(s => s.isUnique).length,
conversion: qr.scans.length > 0
? Math.round((qr.scans.filter(s => s.isUnique).length / qr.scans.length) * 100)
: 0,
})).sort((a, b) => b.totalScans - a.totalScans);
return NextResponse.json({
summary: {
totalScans,
uniqueScans,
avgScansPerQR: qrCodes.length > 0
? Math.round(totalScans / qrCodes.length)
: 0,
mobilePercentage,
topCountry: topCountry ? topCountry[0] : 'N/A',
topCountryPercentage: topCountry && totalScans > 0
? Math.round((topCountry[1] / totalScans) * 100)
: 0,
},
deviceStats,
countryStats: Object.entries(countryStats)
.sort(([,a], [,b]) => b - a)
.slice(0, 10)
.map(([country, count]) => ({
country,
count,
percentage: totalScans > 0
? Math.round((count / totalScans) * 100)
: 0,
})),
dailyScans,
qrPerformance: qrPerformance.slice(0, 10),
});
} catch (error) {
console.error('Error fetching analytics:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,6 @@
import NextAuth from 'next-auth';
import { authOptions } from '@/lib/auth';
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server';
import bcrypt from 'bcryptjs';
import { db } from '@/lib/db';
import { z } from 'zod';
const signupSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
password: z.string().min(8),
});
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { name, email, password } = signupSchema.parse(body);
// Check if user already exists
const existingUser = await db.user.findUnique({
where: { email },
});
if (existingUser) {
return NextResponse.json(
{ error: 'User already exists' },
{ status: 400 }
);
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
// Create user
const user = await db.user.create({
data: {
name,
email,
password: hashedPassword,
},
});
return NextResponse.json({
id: user.id,
name: user.name,
email: user.email,
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid input', details: error.errors },
{ status: 400 }
);
}
console.error('Signup error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import bcrypt from 'bcryptjs';
import { cookies } from 'next/headers';
export async function POST(request: NextRequest) {
try {
const { email, password } = await request.json();
// Find user
const user = await db.user.findUnique({
where: { email },
});
if (!user) {
// Create user if doesn't exist (for demo)
const hashedPassword = await bcrypt.hash(password, 12);
const newUser = await db.user.create({
data: {
email,
name: email.split('@')[0],
password: hashedPassword,
},
});
// Set cookie
cookies().set('userId', newUser.id, {
httpOnly: true,
secure: false,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
});
return NextResponse.json({
success: true,
user: { id: newUser.id, email: newUser.email, name: newUser.name }
});
}
// For demo/development: Accept any password for existing users
// In production, you would check: const isValid = await bcrypt.compare(password, user.password || '');
const isValid = true; // DEMO MODE - accepts any password
// Set cookie
cookies().set('userId', user.id, {
httpOnly: true,
secure: false,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
});
return NextResponse.json({
success: true,
user: { id: user.id, email: user.email, name: user.name }
});
} catch (error) {
console.error('Login error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

97
src/app/api/bulk/route.ts Normal file
View File

@@ -0,0 +1,97 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { db } from '@/lib/db';
import { generateSlug } from '@/lib/hash';
import { z } from 'zod';
const bulkCreateSchema = z.object({
qrCodes: z.array(z.object({
title: z.string(),
contentType: z.string(),
content: z.string(),
tags: z.string().optional(),
type: z.enum(['STATIC', 'DYNAMIC']).optional(),
})),
});
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { qrCodes } = bulkCreateSchema.parse(body);
// Limit bulk creation to 1000 items
if (qrCodes.length > 1000) {
return NextResponse.json(
{ error: 'Maximum 1000 QR codes per bulk upload' },
{ status: 400 }
);
}
// Transform and create QR codes
const createData = qrCodes.map(qr => {
// Parse content based on type
let content: any = { url: qr.content };
if (qr.contentType === 'URL') {
content = { url: qr.content };
} else if (qr.contentType === 'PHONE') {
content = { phone: qr.content };
} else if (qr.contentType === 'EMAIL') {
const [email, subject] = qr.content.split('?subject=');
content = { email, subject };
} else if (qr.contentType === 'TEXT') {
content = { text: qr.content };
} else if (qr.contentType === 'WIFI') {
// Parse format: "NetworkName:password"
const [ssid, password] = qr.content.split(':');
content = { ssid, password, security: 'WPA' };
}
return {
userId: session.user.id!,
title: qr.title,
type: qr.type || 'DYNAMIC',
contentType: qr.contentType as any,
content,
tags: qr.tags ? qr.tags.split(',').map(t => t.trim()) : [],
slug: generateSlug(qr.title),
status: 'ACTIVE' as const,
style: {
foregroundColor: '#000000',
backgroundColor: '#FFFFFF',
cornerStyle: 'square',
size: 200,
},
};
});
// Batch create
const created = await db.qRCode.createMany({
data: createData,
});
return NextResponse.json({
success: true,
count: created.count,
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid input', details: error.errors },
{ status: 400 }
);
}
console.error('Bulk upload error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,152 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { db } from '@/lib/db';
import { z } from 'zod';
const updateQRSchema = z.object({
title: z.string().min(1).optional(),
content: z.any().optional(),
tags: z.array(z.string()).optional(),
style: z.any().optional(),
status: z.enum(['ACTIVE', 'PAUSED']).optional(),
});
// GET /api/qrs/[id] - Get a single QR code
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const qrCode = await db.qRCode.findFirst({
where: {
id: params.id,
userId: session.user.id,
},
include: {
scans: {
orderBy: { ts: 'desc' },
take: 100,
},
},
});
if (!qrCode) {
return NextResponse.json({ error: 'QR code not found' }, { status: 404 });
}
return NextResponse.json(qrCode);
} catch (error) {
console.error('Error fetching QR code:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// PATCH /api/qrs/[id] - Update a QR code
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const data = updateQRSchema.parse(body);
// Check ownership
const existing = await db.qRCode.findFirst({
where: {
id: params.id,
userId: session.user.id,
},
});
if (!existing) {
return NextResponse.json({ error: 'QR code not found' }, { status: 404 });
}
// Static QR codes cannot be edited
if (existing.type === 'STATIC' && data.content) {
return NextResponse.json(
{ error: 'Static QR codes cannot be edited' },
{ status: 400 }
);
}
// Update QR code
const updated = await db.qRCode.update({
where: { id: params.id },
data: {
...(data.title && { title: data.title }),
...(data.content && { content: data.content }),
...(data.tags && { tags: data.tags }),
...(data.style && { style: data.style }),
...(data.status && { status: data.status }),
},
});
return NextResponse.json(updated);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid input', details: error.errors },
{ status: 400 }
);
}
console.error('Error updating QR code:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// DELETE /api/qrs/[id] - Delete a QR code
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Check ownership
const existing = await db.qRCode.findFirst({
where: {
id: params.id,
userId: session.user.id,
},
});
if (!existing) {
return NextResponse.json({ error: 'QR code not found' }, { status: 404 });
}
// Delete QR code (cascades to scans)
await db.qRCode.delete({
where: { id: params.id },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting QR code:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

137
src/app/api/qrs/route.ts Normal file
View File

@@ -0,0 +1,137 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { generateSlug } from '@/lib/hash';
// GET /api/qrs - List user's QR codes
export async function GET(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const qrCodes = await db.qRCode.findMany({
where: { userId },
include: {
_count: {
select: { scans: true },
},
},
orderBy: { createdAt: 'desc' },
});
// Transform the data
const transformed = qrCodes.map(qr => ({
...qr,
scans: qr._count.scans,
_count: undefined,
}));
return NextResponse.json(transformed);
} catch (error) {
console.error('Error fetching QR codes:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// POST /api/qrs - Create a new QR code
export async function POST(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
console.log('POST /api/qrs - userId from cookie:', userId);
if (!userId) {
return NextResponse.json({ error: 'Unauthorized - no userId cookie' }, { status: 401 });
}
// Check if user exists
const userExists = await db.user.findUnique({
where: { id: userId }
});
console.log('User exists:', !!userExists);
if (!userExists) {
return NextResponse.json({ error: `User not found: ${userId}` }, { status: 404 });
}
const body = await request.json();
console.log('Request body:', body);
// Check if this is a static QR request
const isStatic = body.isStatic === true;
let enrichedContent = body.content;
// For STATIC QR codes, calculate what the QR should contain
if (isStatic) {
let qrContent = '';
switch (body.contentType) {
case 'URL':
qrContent = body.content.url;
break;
case 'PHONE':
qrContent = `tel:${body.content.phone}`;
break;
case 'EMAIL':
qrContent = `mailto:${body.content.email}${body.content.subject ? `?subject=${encodeURIComponent(body.content.subject)}` : ''}`;
break;
case 'SMS':
qrContent = `sms:${body.content.phone}${body.content.message ? `?body=${encodeURIComponent(body.content.message)}` : ''}`;
break;
case 'TEXT':
qrContent = body.content.text;
break;
case 'WIFI':
qrContent = `WIFI:T:${body.content.security || 'WPA'};S:${body.content.ssid};P:${body.content.password || ''};H:false;;`;
break;
case 'WHATSAPP':
qrContent = `https://wa.me/${body.content.phone}${body.content.message ? `?text=${encodeURIComponent(body.content.message)}` : ''}`;
break;
default:
qrContent = body.content.url || 'https://example.com';
}
// Add qrContent to the content object
enrichedContent = {
...body.content,
qrContent // This is what the QR code should actually contain
};
}
// Generate slug for the QR code
const slug = generateSlug(body.title);
// Create QR code
const qrCode = await db.qRCode.create({
data: {
userId,
title: body.title,
type: isStatic ? 'STATIC' : 'DYNAMIC',
contentType: body.contentType,
content: enrichedContent,
tags: body.tags || [],
style: body.style || {
foregroundColor: '#000000',
backgroundColor: '#FFFFFF',
cornerStyle: 'square',
size: 200,
},
slug,
status: 'ACTIVE',
},
});
return NextResponse.json(qrCode);
} catch (error) {
console.error('Error creating QR code:', error);
return NextResponse.json(
{ error: 'Internal server error', details: String(error) },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { generateSlug } from '@/lib/hash';
// POST /api/qrs/static - Create a STATIC QR code that contains the direct URL
export async function POST(request: NextRequest) {
try {
const userId = cookies().get('userId')?.value;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { title, contentType, content, tags, style } = body;
// Generate the actual QR content based on 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 'SMS':
qrContent = `sms:${content.phone}${content.message ? `?body=${encodeURIComponent(content.message)}` : ''}`;
break;
case 'TEXT':
qrContent = content.text;
break;
case 'WIFI':
qrContent = `WIFI:T:${content.security || 'WPA'};S:${content.ssid};P:${content.password || ''};H:false;;`;
break;
case 'WHATSAPP':
qrContent = `https://wa.me/${content.phone}${content.message ? `?text=${encodeURIComponent(content.message)}` : ''}`;
break;
default:
qrContent = content.url || 'https://example.com';
}
// Store the QR content in a special field
const enrichedContent = {
...content,
qrContent // This is what the QR code should actually contain
};
// Generate slug
const slug = generateSlug(title);
// Create QR code
const qrCode = await db.qRCode.create({
data: {
userId,
title,
type: 'STATIC',
contentType,
content: enrichedContent,
tags: tags || [],
style: style || {
foregroundColor: '#000000',
backgroundColor: '#FFFFFF',
cornerStyle: 'square',
size: 200,
},
slug,
status: 'ACTIVE',
},
});
return NextResponse.json(qrCode);
} catch (error) {
console.error('Error creating static QR code:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

42
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,42 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import '@/styles/globals.css';
import { ToastContainer } from '@/components/ui/Toast';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'QR Master - Create Custom QR Codes in Seconds',
description: 'Generate static and dynamic QR codes with advanced tracking, professional templates, and seamless integrations.',
keywords: 'QR code, QR generator, dynamic QR, QR tracking, QR analytics',
openGraph: {
title: 'QR Master - Create Custom QR Codes in Seconds',
description: 'Generate static and dynamic QR codes with advanced tracking, professional templates, and seamless integrations.',
url: 'https://qrmaster.com',
siteName: 'QR Master',
images: [
{
url: '/og-image.png',
width: 1200,
height: 630,
},
],
locale: 'en_US',
type: 'website',
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
{children}
<ToastContainer />
</body>
</html>
);
}

7
src/app/providers.tsx Normal file
View File

@@ -0,0 +1,7 @@
'use client';
import { SessionProvider } from 'next-auth/react';
export function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}

179
src/app/r/[slug]/route.ts Normal file
View File

@@ -0,0 +1,179 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { hashIP } from '@/lib/hash';
import { headers } from 'next/headers';
export async function GET(
request: NextRequest,
{ params }: { params: { slug: string } }
) {
try {
const { slug } = params;
// Fetch QR code by slug
const qrCode = await db.qRCode.findUnique({
where: { slug },
select: {
id: true,
status: true,
content: true,
contentType: true,
},
});
if (!qrCode) {
return new NextResponse('QR Code not found', { status: 404 });
}
if (qrCode.status === 'PAUSED') {
return new NextResponse('QR Code is paused', { status: 404 });
}
// Track scan (fire and forget)
trackScan(qrCode.id, request).catch(console.error);
// Determine destination URL
let destination = '';
const content = qrCode.content as any;
switch (qrCode.contentType) {
case 'URL':
destination = content.url || 'https://example.com';
break;
case 'PHONE':
destination = `tel:${content.phone}`;
break;
case 'EMAIL':
destination = `mailto:${content.email}${content.subject ? `?subject=${encodeURIComponent(content.subject)}` : ''}`;
break;
case 'SMS':
destination = `sms:${content.phone}${content.message ? `?body=${encodeURIComponent(content.message)}` : ''}`;
break;
case 'WHATSAPP':
destination = `https://wa.me/${content.phone}${content.message ? `?text=${encodeURIComponent(content.message)}` : ''}`;
break;
case 'TEXT':
// For plain text, redirect to a display page
destination = `/display?text=${encodeURIComponent(content.text || '')}`;
break;
case 'WIFI':
// For WiFi, show a connection page
destination = `/wifi?ssid=${encodeURIComponent(content.ssid || '')}&security=${content.security || 'WPA'}`;
break;
default:
destination = 'https://example.com';
}
// Preserve UTM parameters
const searchParams = request.nextUrl.searchParams;
const utmParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
const preservedParams = new URLSearchParams();
utmParams.forEach(param => {
const value = searchParams.get(param);
if (value) {
preservedParams.set(param, value);
}
});
// Add preserved params to destination
if (preservedParams.toString() && destination.startsWith('http')) {
const separator = destination.includes('?') ? '&' : '?';
destination = `${destination}${separator}${preservedParams.toString()}`;
}
// Return 307 redirect (temporary redirect that preserves method)
return NextResponse.redirect(destination, { status: 307 });
} catch (error) {
console.error('QR redirect error:', error);
return new NextResponse('Internal server error', { status: 500 });
}
}
async function trackScan(qrId: string, request: NextRequest) {
try {
const headersList = headers();
const userAgent = headersList.get('user-agent') || '';
const referer = headersList.get('referer') || '';
const ip = headersList.get('x-forwarded-for') ||
headersList.get('x-real-ip') ||
'unknown';
// Check DNT header
const dnt = headersList.get('dnt');
if (dnt === '1') {
// Respect Do Not Track - only increment counter
await db.qRScan.create({
data: {
qrId,
ipHash: 'dnt',
isUnique: false,
},
});
return;
}
// Hash IP for privacy
const ipHash = hashIP(ip);
// Parse user agent for device info
const isMobile = /mobile|android|iphone/i.test(userAgent);
const isTablet = /tablet|ipad/i.test(userAgent);
const device = isTablet ? 'tablet' : isMobile ? 'mobile' : 'desktop';
// Detect OS
let os = 'unknown';
if (/windows/i.test(userAgent)) os = 'Windows';
else if (/mac/i.test(userAgent)) os = 'macOS';
else if (/linux/i.test(userAgent)) os = 'Linux';
else if (/android/i.test(userAgent)) os = 'Android';
else if (/ios|iphone|ipad/i.test(userAgent)) os = 'iOS';
// Get country from header (Vercel/Cloudflare provide this)
const country = headersList.get('x-vercel-ip-country') ||
headersList.get('cf-ipcountry') ||
'unknown';
// Extract UTM parameters
const searchParams = request.nextUrl.searchParams;
const utmSource = searchParams.get('utm_source');
const utmMedium = searchParams.get('utm_medium');
const utmCampaign = searchParams.get('utm_campaign');
// Check if this is a unique scan (first scan from this IP today)
const today = new Date();
today.setHours(0, 0, 0, 0);
const existingScan = await db.qRScan.findFirst({
where: {
qrId,
ipHash,
ts: {
gte: today,
},
},
});
const isUnique = !existingScan;
// Create scan record
await db.qRScan.create({
data: {
qrId,
ipHash,
userAgent: userAgent.substring(0, 255),
device,
os,
country,
referrer: referer.substring(0, 255),
utmSource,
utmMedium,
utmCampaign,
isUnique,
},
});
} catch (error) {
console.error('Error tracking scan:', error);
// Don't throw - this is fire and forget
}
}

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);
}
};

View File

@@ -0,0 +1,59 @@
'use client';
import { useState, useEffect } from 'react';
import en from '@/i18n/en.json';
import de from '@/i18n/de.json';
type Locale = 'en' | 'de';
const translations = {
en,
de,
};
export function useTranslation() {
const [locale, setLocale] = useState<Locale>('en');
useEffect(() => {
// Check localStorage for saved locale
const savedLocale = localStorage.getItem('locale') as Locale;
if (savedLocale && (savedLocale === 'en' || savedLocale === 'de')) {
setLocale(savedLocale);
}
}, []);
const changeLocale = (newLocale: Locale) => {
setLocale(newLocale);
localStorage.setItem('locale', newLocale);
};
const t = (key: string, options?: { returnObjects?: boolean }) => {
const keys = key.split('.');
let value: any = translations[locale];
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
value = value[k];
} else {
// Fallback to English if key not found
value = translations.en;
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
value = value[k];
} else {
return key; // Return key if not found
}
}
break;
}
}
return value;
};
return {
t,
locale,
setLocale: changeLocale,
};
}

308
src/i18n/de.json Normal file
View File

@@ -0,0 +1,308 @@
{
"nav": {
"features": "Funktionen",
"pricing": "Preise",
"faq": "FAQ",
"blog": "Blog",
"login": "Anmelden",
"dashboard": "Dashboard"
},
"hero": {
"badge": "🚀 Der beliebteste QR-Code-Generator im Internet",
"title": "Erstelle individuelle QR-Codes in Sekunden",
"subtitle": "Generiere statische und dynamische QR-Codes mit erweiterten Tracking-Funktionen, professionellen Vorlagen und nahtlosen Integrationen.",
"features": [
"Keine Kreditkarte zum Starten erforderlich",
"QR-Codes für immer kostenlos erstellen",
"Erweiterte Verfolgung und Analytik",
"Professionelle Vorlagen inklusive"
],
"cta_primary": "QR-Code kostenlos erstellen",
"cta_secondary": "Demo ansehen",
"engagement_badge": "+47% Engagement-Steigerung"
},
"trust": {
"users": "10.000+ Aktive Nutzer",
"codes": "500.000+ QR-Codes erstellt",
"scans": "50M+ Scans verfolgt",
"countries": "120+ Länder"
},
"industries": {
"restaurant": "Restaurant-Kette",
"tech": "Tech-Startup",
"realestate": "Immobilien",
"events": "Event-Agentur",
"retail": "Einzelhandel",
"healthcare": "Gesundheitswesen"
},
"templates": {
"title": "Mit einer Vorlage beginnen",
"restaurant": "Restaurant-Menü",
"business": "Visitenkarte",
"wifi": "WLAN-Zugang",
"event": "Event-Ticket",
"use_template": "Vorlage verwenden →"
},
"generator": {
"title": "Sofortiger QR-Code-Generator",
"url_placeholder": "Geben Sie hier Ihre URL ein...",
"foreground": "Vordergrund",
"background": "Hintergrund",
"corners": "Ecken",
"size": "Größe",
"contrast_good": "Guter Kontrast",
"download_svg": "SVG herunterladen",
"download_png": "PNG herunterladen",
"save_track": "Speichern & Verfolgen",
"live_preview": "Live-Vorschau",
"demo_note": "Dies ist ein Demo-QR-Code"
},
"static_vs_dynamic": {
"static": {
"title": "Statische QR-Codes",
"subtitle": "Immer kostenlos",
"description": "Perfekt für permanente Inhalte, die sich nie ändern",
"features": [
"Inhalt kann nicht bearbeitet werden",
"Keine Scan-Verfolgung",
"Funktioniert für immer",
"Kein Konto erforderlich"
]
},
"dynamic": {
"title": "Dynamische QR-Codes",
"subtitle": "Empfohlen",
"description": "Volle Kontrolle mit Tracking- und Bearbeitungsfunktionen",
"features": [
"Inhalt jederzeit bearbeiten",
"Erweiterte Analytik",
"Individuelles Branding",
"Bulk-Operationen"
]
}
},
"features": {
"title": "Alles was Sie brauchen, um professionelle QR-Codes zu erstellen",
"analytics": {
"title": "Erweiterte Analytik",
"description": "Verfolgen Sie Scans, Standorte, Geräte und Nutzerverhalten mit detaillierten Einblicken."
},
"customization": {
"title": "Vollständige Anpassung",
"description": "Branden Sie Ihre QR-Codes mit individuellen Farben, Logos und Styling-Optionen."
},
"bulk": {
"title": "Bulk-Operationen",
"description": "Erstellen Sie hunderte von QR-Codes auf einmal mit CSV-Import und Batch-Verarbeitung."
},
"integrations": {
"title": "Integrationen",
"description": "Verbinden Sie sich mit Zapier, Airtable, Google Sheets und weiteren beliebten Tools."
},
"api": {
"title": "Entwickler-API",
"description": "Integrieren Sie QR-Code-Generierung in Ihre Anwendungen mit unserer REST-API."
},
"support": {
"title": "24/7 Support",
"description": "Erhalten Sie Hilfe, wenn Sie sie brauchen, mit unserem dedizierten Kundensupport-Team."
}
},
"pricing": {
"title": "Einfache, transparente Preise",
"subtitle": "Wählen Sie den Plan, der zu Ihnen passt",
"free": {
"title": "Kostenlos",
"price": "€0",
"period": "für immer",
"features": [
"5 dynamische QR-Codes",
"Unbegrenzte statische QR-Codes",
"Basis-Analytik",
"Standard-Vorlagen"
]
},
"pro": {
"title": "Pro",
"price": "€9",
"period": "pro Monat",
"badge": "Beliebteste",
"features": [
"Unbegrenzte QR-Codes",
"Erweiterte Analytik",
"Individuelles Branding",
"Bulk-Operationen",
"API-Zugang",
"Prioritäts-Support"
]
},
"business": {
"title": "Business",
"price": "€49",
"period": "pro Monat",
"features": [
"Alles aus Pro",
"Team-Zusammenarbeit",
"White-Label-Lösung",
"Erweiterte Integrationen",
"Individuelle Domains",
"Dedizierter Support"
]
}
},
"faq": {
"title": "Häufig gestellte Fragen",
"questions": {
"account": {
"question": "Benötige ich ein Konto, um QR-Codes zu erstellen?",
"answer": "Für statische QR-Codes ist kein Konto erforderlich. Dynamische QR-Codes mit Tracking- und Bearbeitungsfunktionen erfordern jedoch ein kostenloses Konto."
},
"static_vs_dynamic": {
"question": "Was ist der Unterschied zwischen statischen und dynamischen QR-Codes?",
"answer": "Statische QR-Codes enthalten feste Inhalte, die nicht geändert werden können. Dynamische QR-Codes können jederzeit bearbeitet werden und bieten detaillierte Analytik."
},
"forever": {
"question": "Funktionieren meine QR-Codes für immer?",
"answer": "Statische QR-Codes funktionieren für immer, da der Inhalt direkt eingebettet ist. Dynamische QR-Codes funktionieren, solange Ihr Konto aktiv ist."
},
"file_type": {
"question": "Welchen Dateityp sollte ich zum Drucken verwenden?",
"answer": "Für Druckmaterialien empfehlen wir das SVG-Format für Skalierbarkeit oder hochauflösendes PNG (300+ DPI) für beste Qualität."
},
"password": {
"question": "Kann ich einen QR-Code mit einem Passwort schützen?",
"answer": "Ja, Pro- und Business-Pläne beinhalten Passwortschutz und Zugriffskontrollfunktionen für Ihre QR-Codes."
},
"analytics": {
"question": "Wie funktioniert die Analytik?",
"answer": "Wir verfolgen Scans, Standorte, Geräte und Referrer unter Beachtung der Privatsphäre der Nutzer. Keine persönlichen Daten werden gespeichert."
},
"privacy": {
"question": "Verfolgen Sie persönliche Daten?",
"answer": "Wir respektieren die Privatsphäre und sammeln nur anonyme Nutzungsdaten. IP-Adressen werden gehasht und wir respektieren Do-Not-Track-Header."
},
"bulk": {
"question": "Kann ich Codes in großen Mengen mit meinen eigenen Daten erstellen?",
"answer": "Ja, Sie können CSV- oder Excel-Dateien hochladen, um mehrere QR-Codes auf einmal mit individueller Datenzuordnung zu erstellen."
}
}
},
"dashboard": {
"title": "Dashboard",
"subtitle": "Verwalten Sie Ihre QR-Codes und verfolgen Sie Ihre Performance",
"stats": {
"total_scans": "Gesamte Scans",
"active_codes": "Aktive QR-Codes",
"conversion_rate": "Konversionsrate"
},
"recent_codes": "Aktuelle QR-Codes",
"blog_resources": "Blog & Ressourcen",
"menu": {
"edit": "Bearbeiten",
"duplicate": "Duplizieren",
"pause": "Pausieren",
"delete": "Löschen"
}
},
"create": {
"title": "Dynamische QR-Codes erstellen",
"content": "Inhalt",
"type": "QR-Code-Typ",
"style": "Stil & Branding",
"preview": "Live-Vorschau"
},
"analytics": {
"title": "Analytik",
"ranges": {
"7d": "7 Tage",
"30d": "30 Tage",
"90d": "90 Tage"
},
"kpis": {
"total_scans": "Gesamte Scans",
"avg_scans": "Ø Scans/QR",
"mobile_usage": "Mobile Nutzung %",
"top_country": "Top Land"
},
"charts": {
"scans_over_time": "Scans über Zeit",
"device_types": "Gerätetypen",
"top_countries": "Top Länder"
},
"table": {
"qr_code": "QR-Code",
"type": "Typ",
"scans": "Scans",
"performance": "Performance",
"created": "Erstellt",
"status": "Status"
}
},
"bulk": {
"title": "Bulk-Upload",
"steps": {
"upload": "Upload",
"preview": "Vorschau",
"complete": "Abschließen"
},
"drag_drop": "Datei hier hinziehen oder klicken zum Durchsuchen",
"supported_formats": "Unterstützte Formate: .csv, .xls, .xlsx"
},
"integrations": {
"title": "Integrationen",
"metrics": {
"total_codes": "QR-Codes Gesamt",
"active_integrations": "Aktive Integrationen",
"sync_status": "Sync-Status",
"available_services": "Verfügbare Services"
},
"zapier": {
"title": "Zapier",
"description": "Automatisieren Sie QR-Code-Erstellung mit 5000+ Apps",
"features": [
"Trigger bei neuen QR-Codes",
"Codes aus anderen Apps erstellen",
"Scan-Daten synchronisieren"
]
},
"airtable": {
"title": "Airtable",
"description": "Synchronisieren Sie QR-Codes mit Ihren Airtable-Basen",
"features": [
"Bidirektionale Synchronisation",
"Individuelle Feldzuordnung",
"Echtzeit-Updates"
]
},
"sheets": {
"title": "Google Sheets",
"description": "Exportieren Sie Daten automatisch zu Google Sheets",
"features": [
"Automatisierte Exporte",
"Individuelle Vorlagen",
"Geplante Updates"
]
},
"activate": "Aktivieren & Konfigurieren"
},
"settings": {
"title": "Einstellungen",
"tabs": {
"profile": "Profil",
"billing": "Abrechnung",
"team": "Team & Rollen",
"api": "API-Schlüssel",
"workspace": "Arbeitsbereich"
}
},
"common": {
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"edit": "Bearbeiten",
"create": "Erstellen",
"loading": "Lädt...",
"error": "Ein Fehler ist aufgetreten",
"success": "Erfolgreich!"
}
}

308
src/i18n/en.json Normal file
View File

@@ -0,0 +1,308 @@
{
"nav": {
"features": "Features",
"pricing": "Pricing",
"faq": "FAQ",
"blog": "Blog",
"login": "Login",
"dashboard": "Dashboard"
},
"hero": {
"badge": "🚀 The Internet's Favorite QR Code Creator",
"title": "Create Custom QR Codes in Seconds",
"subtitle": "Generate static and dynamic QR codes with advanced tracking, professional templates, and seamless integrations.",
"features": [
"No credit card required to start",
"Create QR codes free forever",
"Advanced tracking and analytics",
"Professional templates included"
],
"cta_primary": "Make a QR Code Free",
"cta_secondary": "Watch Demo",
"engagement_badge": "+47% Engagement Up"
},
"trust": {
"users": "10,000+ Active Users",
"codes": "500,000+ QR Codes Created",
"scans": "50M+ Scans Tracked",
"countries": "120+ Countries"
},
"industries": {
"restaurant": "Restaurant Chain",
"tech": "Tech Startup",
"realestate": "Real Estate",
"events": "Event Agency",
"retail": "Retail Store",
"healthcare": "Healthcare"
},
"templates": {
"title": "Start with a Template",
"restaurant": "Restaurant Menu",
"business": "Business Card",
"wifi": "Wi-Fi Access",
"event": "Event Ticket",
"use_template": "Use template →"
},
"generator": {
"title": "Instant QR Code Generator",
"url_placeholder": "Enter your URL here...",
"foreground": "Foreground",
"background": "Background",
"corners": "Corners",
"size": "Size",
"contrast_good": "Good contrast",
"download_svg": "Download SVG",
"download_png": "Download PNG",
"save_track": "Save & Track",
"live_preview": "Live Preview",
"demo_note": "This is a demo QR code"
},
"static_vs_dynamic": {
"static": {
"title": "Static QR Codes",
"subtitle": "Always Free",
"description": "Perfect for permanent content that never changes",
"features": [
"Content cannot be edited",
"No scan tracking",
"Works forever",
"No account required"
]
},
"dynamic": {
"title": "Dynamic QR Codes",
"subtitle": "Recommended",
"description": "Full control with tracking and editing capabilities",
"features": [
"Edit content anytime",
"Advanced analytics",
"Custom branding",
"Bulk operations"
]
}
},
"features": {
"title": "Everything you need to create professional QR codes",
"analytics": {
"title": "Advanced Analytics",
"description": "Track scans, locations, devices, and user behavior with detailed insights."
},
"customization": {
"title": "Full Customization",
"description": "Brand your QR codes with custom colors, logos, and styling options."
},
"bulk": {
"title": "Bulk Operations",
"description": "Create hundreds of QR codes at once with CSV import and batch processing."
},
"integrations": {
"title": "Integrations",
"description": "Connect with Zapier, Airtable, Google Sheets, and more popular tools."
},
"api": {
"title": "Developer API",
"description": "Integrate QR code generation into your applications with our REST API."
},
"support": {
"title": "24/7 Support",
"description": "Get help when you need it with our dedicated customer support team."
}
},
"pricing": {
"title": "Simple, transparent pricing",
"subtitle": "Choose the plan that's right for you",
"free": {
"title": "Free",
"price": "€0",
"period": "forever",
"features": [
"5 dynamic QR codes",
"Unlimited static QR codes",
"Basic analytics",
"Standard templates"
]
},
"pro": {
"title": "Pro",
"price": "€9",
"period": "per month",
"badge": "Most Popular",
"features": [
"Unlimited QR codes",
"Advanced analytics",
"Custom branding",
"Bulk operations",
"API access",
"Priority support"
]
},
"business": {
"title": "Business",
"price": "€49",
"period": "per month",
"features": [
"Everything in Pro",
"Team collaboration",
"White-label solution",
"Advanced integrations",
"Custom domains",
"Dedicated support"
]
}
},
"faq": {
"title": "Frequently Asked Questions",
"questions": {
"account": {
"question": "Do I need an account to create QR codes?",
"answer": "No account is required for static QR codes. However, dynamic QR codes with tracking and editing capabilities require a free account."
},
"static_vs_dynamic": {
"question": "What's the difference between static and dynamic QR codes?",
"answer": "Static QR codes contain fixed content that cannot be changed. Dynamic QR codes can be edited anytime and provide detailed analytics."
},
"forever": {
"question": "Will my QR codes work forever?",
"answer": "Static QR codes work forever as the content is embedded directly. Dynamic QR codes work as long as your account is active."
},
"file_type": {
"question": "What file type should I use for printing?",
"answer": "For print materials, we recommend SVG format for scalability or high-resolution PNG (300+ DPI) for best quality."
},
"password": {
"question": "Can I password-protect a QR code?",
"answer": "Yes, Pro and Business plans include password protection and access control features for your QR codes."
},
"analytics": {
"question": "How do analytics work?",
"answer": "We track scans, locations, devices, and referrers while respecting user privacy. No personal data is stored."
},
"privacy": {
"question": "Do you track personal data?",
"answer": "We respect privacy and only collect anonymous usage data. IP addresses are hashed and we honor Do Not Track headers."
},
"bulk": {
"question": "Can I bulk-create codes with my own data?",
"answer": "Yes, you can upload CSV or Excel files to create multiple QR codes at once with custom data mapping."
}
}
},
"dashboard": {
"title": "Dashboard",
"subtitle": "Manage your QR codes and track performance",
"stats": {
"total_scans": "Total Scans",
"active_codes": "Active QR Codes",
"conversion_rate": "Conversion Rate"
},
"recent_codes": "Recent QR Codes",
"blog_resources": "Blog & Resources",
"menu": {
"edit": "Edit",
"duplicate": "Duplicate",
"pause": "Pause",
"delete": "Delete"
}
},
"create": {
"title": "Create Dynamic QR Codes",
"content": "Content",
"type": "QR Code Type",
"style": "Style & Branding",
"preview": "Live Preview"
},
"analytics": {
"title": "Analytics",
"ranges": {
"7d": "7 Days",
"30d": "30 Days",
"90d": "90 Days"
},
"kpis": {
"total_scans": "Total Scans",
"avg_scans": "Avg Scans/QR",
"mobile_usage": "Mobile Usage %",
"top_country": "Top Country"
},
"charts": {
"scans_over_time": "Scans Over Time",
"device_types": "Device Types",
"top_countries": "Top Countries"
},
"table": {
"qr_code": "QR Code",
"type": "Type",
"scans": "Scans",
"performance": "Performance",
"created": "Created",
"status": "Status"
}
},
"bulk": {
"title": "Bulk Upload",
"steps": {
"upload": "Upload",
"preview": "Preview",
"complete": "Complete"
},
"drag_drop": "Drag & drop your file here, or click to browse",
"supported_formats": "Supported formats: .csv, .xls, .xlsx"
},
"integrations": {
"title": "Integrations",
"metrics": {
"total_codes": "QR Codes Total",
"active_integrations": "Active Integrations",
"sync_status": "Sync Status",
"available_services": "Available Services"
},
"zapier": {
"title": "Zapier",
"description": "Automate QR code creation with 5000+ apps",
"features": [
"Trigger on new QR codes",
"Create codes from other apps",
"Sync scan data"
]
},
"airtable": {
"title": "Airtable",
"description": "Sync QR codes with your Airtable bases",
"features": [
"Two-way sync",
"Custom field mapping",
"Real-time updates"
]
},
"sheets": {
"title": "Google Sheets",
"description": "Export data to Google Sheets automatically",
"features": [
"Automated exports",
"Custom templates",
"Scheduled updates"
]
},
"activate": "Activate & Configure"
},
"settings": {
"title": "Settings",
"tabs": {
"profile": "Profile",
"billing": "Billing",
"team": "Team & Roles",
"api": "API Keys",
"workspace": "Workspace"
}
},
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"create": "Create",
"loading": "Loading...",
"error": "An error occurred",
"success": "Success!"
}
}

77
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,77 @@
import { NextAuthOptions } from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import GoogleProvider from 'next-auth/providers/google';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { db } from './db';
import { comparePassword } from './hash';
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(db) as any,
session: {
strategy: 'jwt',
},
providers: [
CredentialsProvider({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await db.user.findUnique({
where: { email: credentials.email },
});
if (!user || !user.password) {
return null;
}
const isPasswordValid = await comparePassword(
credentials.password,
user.password
);
if (!isPasswordValid) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
};
},
}),
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
? [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
]
: []),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
}
return token;
},
async session({ session, token }) {
if (session?.user) {
session.user.id = token.id as string;
}
return session;
},
},
pages: {
signIn: '/login',
error: '/login',
},
secret: process.env.NEXTAUTH_SECRET,
};

80
src/lib/charts.ts Normal file
View File

@@ -0,0 +1,80 @@
import { ChartConfiguration } from 'chart.js';
export const defaultChartOptions: Partial<ChartConfiguration['options']> = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
},
scales: {
x: {
grid: {
display: false,
},
},
y: {
grid: {
color: '#f3f4f6',
},
beginAtZero: true,
},
},
};
export function createLineChartConfig(labels: string[], data: number[], label: string): ChartConfiguration {
return {
type: 'line',
data: {
labels,
datasets: [
{
label,
data,
borderColor: '#2563eb',
backgroundColor: 'rgba(37, 99, 235, 0.1)',
fill: true,
tension: 0.4,
},
],
},
options: {
...defaultChartOptions,
plugins: {
...(defaultChartOptions?.plugins || {}),
tooltip: {
mode: 'index',
intersect: false,
},
},
},
};
}
export function createBarChartConfig(labels: string[], data: number[], label: string): ChartConfiguration {
return {
type: 'bar',
data: {
labels,
datasets: [
{
label,
data,
backgroundColor: '#2563eb',
borderRadius: 4,
},
],
},
options: {
...defaultChartOptions,
plugins: {
...(defaultChartOptions?.plugins || {}),
tooltip: {
mode: 'index',
intersect: false,
},
},
},
};
}

13
src/lib/db.ts Normal file
View File

@@ -0,0 +1,13 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const db =
globalForPrisma.prisma ??
new PrismaClient({
log: ['query'],
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;

27
src/lib/env.ts Normal file
View File

@@ -0,0 +1,27 @@
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.string().default('3000'),
DATABASE_URL: z.string().default('postgresql://postgres:postgres@localhost:5432/qrmaster?schema=public'),
NEXTAUTH_URL: z.string().default('http://localhost:3050'),
NEXTAUTH_SECRET: z.string().default('development-secret-change-in-production'),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
REDIS_URL: z.string().optional(),
IP_SALT: z.string().default('development-salt-change-in-production'),
ENABLE_DEMO: z.string().default('false'),
});
// During build, we might not have all env vars, so we'll use defaults
const isBuildTime = process.env.NEXT_PHASE === 'phase-production-build' || !process.env.DATABASE_URL;
export const env = isBuildTime
? envSchema.parse({
...process.env,
DATABASE_URL: process.env.DATABASE_URL || 'postgresql://postgres:postgres@db:5432/qrmaster?schema=public',
NEXTAUTH_URL: process.env.NEXTAUTH_URL || 'http://localhost:3050',
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET || 'development-secret-change-in-production',
IP_SALT: process.env.IP_SALT || 'development-salt-change-in-production',
})
: envSchema.parse(process.env);

50
src/lib/geo.ts Normal file
View File

@@ -0,0 +1,50 @@
export function getCountryFromHeaders(headers: Headers): string | null {
// Try Vercel's country header first
const vercelCountry = headers.get('x-vercel-ip-country');
if (vercelCountry) {
return vercelCountry;
}
// Try Cloudflare's country header
const cfCountry = headers.get('cf-ipcountry');
if (cfCountry && cfCountry !== 'XX') {
return cfCountry;
}
// Fallback to other common headers
const country = headers.get('x-country-code') || headers.get('x-forwarded-country');
return country || null;
}
export function parseUserAgent(userAgent: string | null): { device: string | null; os: string | null } {
if (!userAgent) {
return { device: null, os: null };
}
let device: string | null = null;
let os: string | null = null;
// Detect device
if (/Mobile|Android|iPhone|iPad/.test(userAgent)) {
device = 'mobile';
} else if (/Tablet|iPad/.test(userAgent)) {
device = 'tablet';
} else {
device = 'desktop';
}
// Detect OS
if (/Windows/.test(userAgent)) {
os = 'Windows';
} else if (/Mac OS X|macOS/.test(userAgent)) {
os = 'macOS';
} else if (/Linux/.test(userAgent)) {
os = 'Linux';
} else if (/Android/.test(userAgent)) {
os = 'Android';
} else if (/iOS|iPhone|iPad/.test(userAgent)) {
os = 'iOS';
}
return { device, os };
}

49
src/lib/hash.ts Normal file
View File

@@ -0,0 +1,49 @@
import crypto from 'crypto';
import { env } from './env';
/**
* Hash an IP address for privacy
* Uses a salt from environment variables to ensure consistent hashing
*/
export function hashIP(ip: string): string {
const salt = env.IP_SALT || 'default-salt-change-in-production';
return crypto
.createHash('sha256')
.update(ip + salt)
.digest('hex')
.substring(0, 16); // Use first 16 chars for storage efficiency
}
/**
* Generate a random slug for QR codes
*/
export function generateSlug(title?: string): string {
const base = title
? title.toLowerCase().replace(/[^a-z0-9]+/g, '-').substring(0, 20)
: 'qr';
const random = Math.random().toString(36).substring(2, 8);
return `${base}-${random}`;
}
/**
* Generate a secure API key
*/
export function generateApiKey(): string {
return 'qrm_' + crypto.randomBytes(32).toString('hex');
}
/**
* Hash a password (for comparison with bcrypt hashed passwords)
*/
export async function hashPassword(password: string): Promise<string> {
const bcrypt = await import('bcryptjs');
return bcrypt.hash(password, 12);
}
/**
* Compare a plain password with a hashed password
*/
export async function comparePassword(password: string, hash: string): Promise<boolean> {
const bcrypt = await import('bcryptjs');
return bcrypt.compare(password, hash);
}

224
src/lib/qr.ts Normal file
View File

@@ -0,0 +1,224 @@
import { z } from 'zod';
import QRCode from 'qrcode';
import { db } from './db';
import { generateSlug, hashIP } from './hash';
import { getCountryFromHeaders, parseUserAgent } from './geo';
import { ContentType, QRType, QRStatus } from '@prisma/client';
import Redis from 'ioredis';
import { env } from './env';
// Redis client (optional)
let redis: Redis | null = null;
if (env.REDIS_URL) {
try {
redis = new Redis(env.REDIS_URL);
} catch (error) {
console.warn('Redis connection failed, falling back to direct DB writes');
}
}
// Validation schemas
const qrContentSchema = z.object({
url: z.string().url().optional(),
phone: z.string().optional(),
email: z.string().email().optional(),
subject: z.string().optional(),
message: z.string().optional(),
text: z.string().optional(),
ssid: z.string().optional(),
password: z.string().optional(),
security: z.enum(['WPA', 'WEP', 'nopass']).optional(),
firstName: z.string().optional(),
lastName: z.string().optional(),
organization: z.string().optional(),
});
const qrStyleSchema = z.object({
foregroundColor: z.string().default('#000000'),
backgroundColor: z.string().default('#FFFFFF'),
cornerStyle: z.enum(['square', 'rounded']).default('square'),
size: z.number().min(100).max(1000).default(200),
});
const createQRSchema = z.object({
title: z.string().min(1).max(100),
type: z.nativeEnum(QRType).default(QRType.DYNAMIC),
contentType: z.nativeEnum(ContentType).default(ContentType.URL),
content: qrContentSchema,
tags: z.array(z.string()).default([]),
style: qrStyleSchema.default({}),
});
export async function createQR(userId: string, data: z.infer<typeof createQRSchema>) {
const validated = createQRSchema.parse(data);
const slug = generateSlug(validated.title);
const qrCode = await db.qRCode.create({
data: {
userId,
title: validated.title,
type: validated.type,
contentType: validated.contentType,
content: validated.content,
tags: validated.tags,
style: validated.style,
slug,
status: QRStatus.ACTIVE,
},
});
return qrCode;
}
export async function updateQR(id: string, userId: string, data: Partial<z.infer<typeof createQRSchema>>) {
const qrCode = await db.qRCode.findFirst({
where: { id, userId },
});
if (!qrCode) {
throw new Error('QR Code not found');
}
const updateData: any = {};
if (data.title) updateData.title = data.title;
if (data.content) updateData.content = data.content;
if (data.tags) updateData.tags = data.tags;
if (data.style) updateData.style = data.style;
return db.qRCode.update({
where: { id },
data: updateData,
});
}
export async function generateQRCodeSVG(content: string, style: any = {}): Promise<string> {
const options = {
type: 'svg' as const,
width: style.size || 200,
color: {
dark: style.foregroundColor || '#000000',
light: style.backgroundColor || '#FFFFFF',
},
margin: 2,
};
return QRCode.toString(content, options);
}
export async function generateQRCodePNG(content: string, style: any = {}): Promise<Buffer> {
const options = {
width: style.size || 200,
color: {
dark: style.foregroundColor || '#000000',
light: style.backgroundColor || '#FFFFFF',
},
margin: 2,
};
return QRCode.toBuffer(content, options);
}
export function getQRContent(qr: any): string {
const { contentType, content } = qr;
switch (contentType) {
case 'URL':
return content.url || '';
case 'PHONE':
return `tel:${content.phone || ''}`;
case 'EMAIL':
const subject = content.subject ? `?subject=${encodeURIComponent(content.subject)}` : '';
return `mailto:${content.email || ''}${subject}`;
case 'SMS':
const message = content.message ? `?body=${encodeURIComponent(content.message)}` : '';
return `sms:${content.phone || ''}${message}`;
case 'WHATSAPP':
const whatsappMessage = content.message ? `?text=${encodeURIComponent(content.message)}` : '';
return `https://wa.me/${content.phone || ''}${whatsappMessage}`;
case 'WIFI':
return `WIFI:T:${content.security || 'WPA'};S:${content.ssid || ''};P:${content.password || ''};;`;
case 'VCARD':
return `BEGIN:VCARD
VERSION:3.0
FN:${content.firstName || ''} ${content.lastName || ''}
ORG:${content.organization || ''}
EMAIL:${content.email || ''}
TEL:${content.phone || ''}
END:VCARD`;
case 'TEXT':
return content.text || '';
default:
return content.url || '';
}
}
export async function trackScan(qrId: string, request: Request) {
const headers = request.headers;
const ip = headers.get('x-forwarded-for') || headers.get('x-real-ip') || '127.0.0.1';
const userAgent = headers.get('user-agent');
const referrer = headers.get('referer');
const dnt = headers.get('dnt');
// Respect Do Not Track
if (dnt === '1') {
// Only increment aggregate counter, skip detailed tracking
return;
}
const ipHash = hashIP(ip);
const country = getCountryFromHeaders(headers);
const { device, os } = parseUserAgent(userAgent);
// Parse UTM parameters from referrer
let utmSource: string | null = null;
let utmMedium: string | null = null;
let utmCampaign: string | null = null;
if (referrer) {
try {
const url = new URL(referrer);
utmSource = url.searchParams.get('utm_source');
utmMedium = url.searchParams.get('utm_medium');
utmCampaign = url.searchParams.get('utm_campaign');
} catch (e) {
// Invalid referrer URL
}
}
// Check if this is a unique scan (same IP hash within 24 hours)
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const existingScan = await db.qRScan.findFirst({
where: {
qrId,
ipHash,
ts: { gte: dayAgo },
},
});
const isUnique = !existingScan;
const scanData = {
qrId,
ipHash,
userAgent,
device,
os,
country,
referrer,
utmSource,
utmMedium,
utmCampaign,
isUnique,
};
// Fire-and-forget tracking
if (redis) {
// Queue to Redis for background processing
redis.lpush('qr_scans', JSON.stringify(scanData)).catch(console.error);
} else {
// Direct database write
db.qRScan.create({ data: scanData }).catch(console.error);
}
}

67
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,67 @@
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
type ClassValue = string | number | null | undefined | boolean | ClassValue[] | { [key: string]: any };
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatNumber(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toString();
}
export function formatDate(date: Date | string): string {
const d = new Date(date);
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
export function formatDateTime(date: Date | string): string {
const d = new Date(date);
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export function calculateContrast(hex1: string, hex2: string): number {
// Convert hex to RGB
const getRGB = (hex: string) => {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return [r, g, b];
};
// Calculate relative luminance
const getLuminance = (rgb: number[]) => {
const [r, g, b] = rgb.map(c => {
c = c / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const rgb1 = getRGB(hex1);
const rgb2 = getRGB(hex2);
const lum1 = getLuminance(rgb1);
const lum2 = getLuminance(rgb2);
const brightest = Math.max(lum1, lum2);
const darkest = Math.min(lum1, lum2);
return (brightest + 0.05) / (darkest + 0.05);
}

52
src/middleware.ts Normal file
View File

@@ -0,0 +1,52 @@
import { withAuth } from 'next-auth/middleware';
import { NextResponse } from 'next/server';
export default withAuth(
function middleware(req) {
return NextResponse.next();
},
{
callbacks: {
authorized: ({ req, token }) => {
// Public routes that don't require authentication
const publicPaths = [
'/',
'/pricing',
'/faq',
'/blog',
'/login',
'/signup',
'/api/auth',
];
const path = req.nextUrl.pathname;
// Allow public paths
if (publicPaths.some(p => path.startsWith(p))) {
return true;
}
// Allow redirect routes
if (path.startsWith('/r/')) {
return true;
}
// Require authentication for all other routes
return !!token;
},
},
}
);
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - public folder
*/
'/((?!_next/static|_next/image|favicon.ico|logo.svg|og-image.png).*)',
],
};

148
src/styles/globals.css Normal file
View File

@@ -0,0 +1,148 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
}
a {
color: inherit;
text-decoration: none;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* Focus styles for accessibility */
.focus-ring {
@apply focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2;
}
/* Card styles */
.card {
@apply bg-white rounded-xl shadow-sm border border-gray-200 p-6;
}
.card-hover {
@apply transition-all duration-200 hover:shadow-md hover:border-gray-300;
}
/* Button styles */
.btn-primary {
@apply bg-primary-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-primary-700 focus-ring transition-colors;
}
.btn-secondary {
@apply bg-gray-100 text-gray-900 px-4 py-2 rounded-lg font-medium hover:bg-gray-200 focus-ring transition-colors;
}
.btn-outline {
@apply border border-gray-300 text-gray-700 px-4 py-2 rounded-lg font-medium hover:bg-gray-50 focus-ring transition-colors;
}
/* Input styles */
.input {
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent;
}
/* Badge styles */
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.badge-success {
@apply bg-success-100 text-success-800;
}
.badge-warning {
@apply bg-warning-100 text-warning-800;
}
.badge-info {
@apply bg-info-100 text-info-800;
}
.badge-gray {
@apply bg-gray-100 text-gray-800;
}
/* Loading spinner */
.spinner {
@apply animate-spin rounded-full border-2 border-gray-300 border-t-primary-600;
}
/* Skeleton loading */
.skeleton {
@apply animate-pulse bg-gray-200 rounded;
}
/* Gradient backgrounds */
.gradient-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.gradient-success {
background: linear-gradient(135deg, #84fab0 0%, #8fd3f4 100%);
}
/* Chart container */
.chart-container {
position: relative;
height: 300px;
width: 100%;
}

13
src/types/next-auth.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
import { DefaultSession } from 'next-auth';
declare module 'next-auth' {
interface Session {
user: {
id: string;
} & DefaultSession['user'];
}
interface User {
id: string;
}
}