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