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