Initial commit - QR Master application
This commit is contained in:
406
src/app/(app)/analytics/page.tsx
Normal file
406
src/app/(app)/analytics/page.tsx
Normal 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
410
src/app/(app)/bulk/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
527
src/app/(app)/create/page.tsx
Normal file
527
src/app/(app)/create/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
258
src/app/(app)/dashboard/page.tsx
Normal file
258
src/app/(app)/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
392
src/app/(app)/integrations/page.tsx
Normal file
392
src/app/(app)/integrations/page.tsx
Normal 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
159
src/app/(app)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
453
src/app/(app)/settings/page.tsx
Normal file
453
src/app/(app)/settings/page.tsx
Normal 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
158
src/app/(app)/test/page.tsx
Normal 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
11
src/app/(auth)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
185
src/app/(auth)/login/page.tsx
Normal file
185
src/app/(auth)/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
197
src/app/(auth)/signup/page.tsx
Normal file
197
src/app/(auth)/signup/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
src/app/(marketing)/blog/[slug]/page.tsx
Normal file
157
src/app/(marketing)/blog/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
src/app/(marketing)/blog/page.tsx
Normal file
76
src/app/(marketing)/blog/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
src/app/(marketing)/faq/page.tsx
Normal file
15
src/app/(marketing)/faq/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
165
src/app/(marketing)/layout.tsx
Normal file
165
src/app/(marketing)/layout.tsx
Normal 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>© 2024 QR Master. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
src/app/(marketing)/page.tsx
Normal file
77
src/app/(marketing)/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
15
src/app/(marketing)/pricing/page.tsx
Normal file
15
src/app/(marketing)/pricing/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
src/app/api/analytics/summary/route.ts
Normal file
110
src/app/api/analytics/summary/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
6
src/app/api/auth/[...nextauth]/route.ts
Normal file
6
src/app/api/auth/[...nextauth]/route.ts
Normal 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 };
|
||||
60
src/app/api/auth/signup/route.ts
Normal file
60
src/app/api/auth/signup/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
60
src/app/api/auth/simple-login/route.ts
Normal file
60
src/app/api/auth/simple-login/route.ts
Normal 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
97
src/app/api/bulk/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
152
src/app/api/qrs/[id]/route.ts
Normal file
152
src/app/api/qrs/[id]/route.ts
Normal 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
137
src/app/api/qrs/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
82
src/app/api/qrs/static/route.ts
Normal file
82
src/app/api/qrs/static/route.ts
Normal 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
42
src/app/layout.tsx
Normal 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
7
src/app/providers.tsx
Normal 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
179
src/app/r/[slug]/route.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user