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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user