Wichige änderung an DB
This commit is contained in:
@@ -331,19 +331,19 @@ export default function AnalyticsPage() {
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Country</th>
|
||||
<th>Scans</th>
|
||||
<th>Percentage</th>
|
||||
<th>Trend</th>
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">Country</th>
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">Scans</th>
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">Percentage</th>
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">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>
|
||||
<tr key={index} className="border-b transition-colors hover:bg-gray-50/50">
|
||||
<td className="px-4 py-4 align-middle">{country.country}</td>
|
||||
<td className="px-4 py-4 align-middle">{country.count.toLocaleString()}</td>
|
||||
<td className="px-4 py-4 align-middle">{country.percentage}%</td>
|
||||
<td className="px-4 py-4 align-middle">
|
||||
<Badge variant="success">↑</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -366,27 +366,27 @@ export default function AnalyticsPage() {
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>QR Code</th>
|
||||
<th>Type</th>
|
||||
<th>Total Scans</th>
|
||||
<th>Unique Scans</th>
|
||||
<th>Conversion</th>
|
||||
<th>Trend</th>
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">QR Code</th>
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">Type</th>
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">Total Scans</th>
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">Unique Scans</th>
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">Conversion</th>
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">Trend</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{analyticsData.qrPerformance.map((qr: any) => (
|
||||
<tr key={qr.id}>
|
||||
<td className="font-medium">{qr.title}</td>
|
||||
<td>
|
||||
<tr key={qr.id} className="border-b transition-colors hover:bg-gray-50/50">
|
||||
<td className="px-4 py-4 align-middle font-medium">{qr.title}</td>
|
||||
<td className="px-4 py-4 align-middle">
|
||||
<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>
|
||||
<td className="px-4 py-4 align-middle">{qr.totalScans.toLocaleString()}</td>
|
||||
<td className="px-4 py-4 align-middle">{qr.uniqueScans.toLocaleString()}</td>
|
||||
<td className="px-4 py-4 align-middle">{qr.conversion}%</td>
|
||||
<td className="px-4 py-4 align-middle">
|
||||
<Badge variant={qr.totalScans > 0 ? 'success' : 'default'}>
|
||||
{qr.totalScans > 0 ? '↑' : '—'}
|
||||
</Badge>
|
||||
|
||||
@@ -233,6 +233,14 @@ export default function BulkCreationPage() {
|
||||
{ title: 'Product Page', content: 'https://example.com/product' },
|
||||
{ title: 'Landing Page', content: 'https://example.com/landing' },
|
||||
{ title: 'Contact Form', content: 'https://example.com/contact' },
|
||||
{ title: 'About Us', content: 'https://example.com/about' },
|
||||
{ title: 'Pricing Page', content: 'https://example.com/pricing' },
|
||||
{ title: 'FAQ Page', content: 'https://example.com/faq' },
|
||||
{ title: 'Blog Article', content: 'https://example.com/blog/article-1' },
|
||||
{ title: 'Support Portal', content: 'https://example.com/support' },
|
||||
{ title: 'Download Page', content: 'https://example.com/download' },
|
||||
{ title: 'Social Media', content: 'https://instagram.com/yourcompany' },
|
||||
{ title: 'YouTube Video', content: 'https://youtube.com/watch?v=example' },
|
||||
];
|
||||
|
||||
const csv = Papa.unparse(template);
|
||||
@@ -321,41 +329,35 @@ export default function BulkCreationPage() {
|
||||
<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'
|
||||
}`}>
|
||||
<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 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'
|
||||
<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 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'
|
||||
}`}>
|
||||
<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">Download</span>
|
||||
@@ -378,9 +380,8 @@ export default function BulkCreationPage() {
|
||||
|
||||
<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'
|
||||
}`}
|
||||
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">
|
||||
@@ -442,6 +443,110 @@ export default function BulkCreationPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Supported QR Code Types Section */}
|
||||
<div className="mt-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">📋 Supported QR Code Types</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 mb-6">
|
||||
This bulk generator creates <strong>static QR codes</strong> for multiple content types. Choose the format that matches your needs:
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="border-l-4 border-blue-500 pl-4">
|
||||
<p className="font-semibold text-gray-900 mb-1">🌐 URL - Website Links</p>
|
||||
<p className="text-sm text-gray-600 mb-1">Format: <code className="bg-gray-100 px-2 py-1 rounded text-xs">https://example.com</code></p>
|
||||
<p className="text-xs text-gray-500">Example: Product Page,https://example.com/product</p>
|
||||
</div>
|
||||
|
||||
<div className="border-l-4 border-purple-500 pl-4">
|
||||
<p className="font-semibold text-gray-900 mb-1">👤 VCARD - Contact Cards</p>
|
||||
<p className="text-sm text-gray-600 mb-1">Format: <code className="bg-gray-100 px-2 py-1 rounded text-xs">FirstName,LastName,Email,Phone,Organization,Title</code></p>
|
||||
<p className="text-xs text-gray-500">Example: John Doe,"John,Doe,john@example.com,+1234567890,Company,CEO"</p>
|
||||
</div>
|
||||
|
||||
<div className="border-l-4 border-green-500 pl-4">
|
||||
<p className="font-semibold text-gray-900 mb-1">📍 GEO - Locations</p>
|
||||
<p className="text-sm text-gray-600 mb-1">Format: <code className="bg-gray-100 px-2 py-1 rounded text-xs">latitude,longitude,label</code></p>
|
||||
<p className="text-xs text-gray-500">Example: Office Location,"37.7749,-122.4194,Main Office"</p>
|
||||
</div>
|
||||
|
||||
<div className="border-l-4 border-pink-500 pl-4">
|
||||
<p className="font-semibold text-gray-900 mb-1">📞 PHONE - Phone Numbers</p>
|
||||
<p className="text-sm text-gray-600 mb-1">Format: <code className="bg-gray-100 px-2 py-1 rounded text-xs">+1234567890</code></p>
|
||||
<p className="text-xs text-gray-500">Example: Support Hotline,+1234567890</p>
|
||||
</div>
|
||||
|
||||
<div className="border-l-4 border-yellow-500 pl-4">
|
||||
<p className="font-semibold text-gray-900 mb-1">📝 TEXT - Plain Text</p>
|
||||
<p className="text-sm text-gray-600 mb-1">Format: <code className="bg-gray-100 px-2 py-1 rounded text-xs">Any text content</code></p>
|
||||
<p className="text-xs text-gray-500">Example: Serial Number,SN-12345-ABCDE</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-r from-blue-50 to-purple-50 rounded-lg p-6 mt-6">
|
||||
<h4 className="font-semibold text-gray-900 mb-3">📥 CSV File Format:</h4>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Your file needs <strong>two columns</strong>: <code className="bg-white px-2 py-1 rounded">title</code> and <code className="bg-white px-2 py-1 rounded">content</code>
|
||||
</p>
|
||||
<div className="bg-white rounded-lg p-4 shadow-sm overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-gray-300">
|
||||
<th className="text-left py-2 px-3 font-semibold text-gray-700">title</th>
|
||||
<th className="text-left py-2 px-3 font-semibold text-gray-700">content</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="font-mono text-xs">
|
||||
<tr className="border-b border-gray-200">
|
||||
<td className="py-2 px-3">Product Page</td>
|
||||
<td className="py-2 px-3">https://example.com/product</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-200">
|
||||
<td className="py-2 px-3">John Doe</td>
|
||||
<td className="py-2 px-3">John,Doe,john@example.com,+1234567890,Company,CEO</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-200">
|
||||
<td className="py-2 px-3">Office Location</td>
|
||||
<td className="py-2 px-3">37.7749,-122.4194,Main Office</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-200">
|
||||
<td className="py-2 px-3">Support Hotline</td>
|
||||
<td className="py-2 px-3">+1234567890</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3">Serial Number</td>
|
||||
<td className="py-2 px-3">SN-12345-ABCDE</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-l-4 border-yellow-500 pl-4">
|
||||
<p className="font-semibold text-gray-900 mb-1">ℹ️ Important Notes</p>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>• <strong>Static QR codes</strong> - Cannot be edited after creation</li>
|
||||
<li>• <strong>No tracking or analytics</strong> - Scans are not tracked</li>
|
||||
<li>• <strong>Maximum 1,000 QR codes</strong> per upload</li>
|
||||
<li>• <strong>Download as ZIP</strong> or save to your dashboard</li>
|
||||
<li>• <strong>All QR types supported</strong> - URLs, vCards, locations, phone numbers, and text</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-900">
|
||||
<strong>💡 Tip:</strong> Download the template above to see examples of all 5 QR code types with 11 ready-to-use examples!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -1,410 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,6 @@ export default function CreatePage() {
|
||||
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');
|
||||
@@ -61,8 +60,8 @@ export default function CreatePage() {
|
||||
|
||||
const contentTypes = [
|
||||
{ value: 'URL', label: 'URL / Website' },
|
||||
{ value: 'WIFI', label: 'WiFi Network' },
|
||||
{ value: 'EMAIL', label: 'Email' },
|
||||
{ value: 'VCARD', label: 'Contact Card' },
|
||||
{ value: 'GEO', label: 'Location/Maps' },
|
||||
{ value: 'PHONE', label: 'Phone Number' },
|
||||
];
|
||||
|
||||
@@ -73,12 +72,15 @@ export default function CreatePage() {
|
||||
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 'VCARD':
|
||||
return `BEGIN:VCARD\nVERSION:3.0\nFN:${content.firstName || 'John'} ${content.lastName || 'Doe'}\nORG:${content.organization || 'Company'}\nTITLE:${content.title || 'Position'}\nEMAIL:${content.email || 'email@example.com'}\nTEL:${content.phone || '+1234567890'}\nEND:VCARD`;
|
||||
case 'GEO':
|
||||
const lat = content.latitude || 37.7749;
|
||||
const lon = content.longitude || -122.4194;
|
||||
const label = content.label ? `?q=${encodeURIComponent(content.label)}` : '';
|
||||
return `geo:${lat},${lon}${label}`;
|
||||
case 'TEXT':
|
||||
return content.text || 'Sample text';
|
||||
case 'WHATSAPP':
|
||||
@@ -158,7 +160,7 @@ export default function CreatePage() {
|
||||
contentType,
|
||||
content,
|
||||
isStatic: !isDynamic,
|
||||
tags: tags.split(',').map(t => t.trim()).filter(Boolean),
|
||||
tags: [],
|
||||
style: {
|
||||
// FREE users can only use black/white
|
||||
foregroundColor: canCustomizeColors ? foregroundColor : '#000000',
|
||||
@@ -220,49 +222,76 @@ export default function CreatePage() {
|
||||
required
|
||||
/>
|
||||
);
|
||||
case 'EMAIL':
|
||||
case 'VCARD':
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
label="First Name"
|
||||
value={content.firstName || ''}
|
||||
onChange={(e) => setContent({ ...content, firstName: e.target.value })}
|
||||
placeholder="John"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Last Name"
|
||||
value={content.lastName || ''}
|
||||
onChange={(e) => setContent({ ...content, lastName: e.target.value })}
|
||||
placeholder="Doe"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Email Address"
|
||||
type="email"
|
||||
value={content.email || ''}
|
||||
onChange={(e) => setContent({ ...content, email: e.target.value })}
|
||||
placeholder="contact@example.com"
|
||||
required
|
||||
placeholder="john@example.com"
|
||||
/>
|
||||
<Input
|
||||
label="Subject (optional)"
|
||||
value={content.subject || ''}
|
||||
onChange={(e) => setContent({ ...content, subject: e.target.value })}
|
||||
placeholder="Email subject"
|
||||
label="Phone Number"
|
||||
value={content.phone || ''}
|
||||
onChange={(e) => setContent({ ...content, phone: e.target.value })}
|
||||
placeholder="+1234567890"
|
||||
/>
|
||||
<Input
|
||||
label="Company/Organization"
|
||||
value={content.organization || ''}
|
||||
onChange={(e) => setContent({ ...content, organization: e.target.value })}
|
||||
placeholder="Company Name"
|
||||
/>
|
||||
<Input
|
||||
label="Job Title"
|
||||
value={content.title || ''}
|
||||
onChange={(e) => setContent({ ...content, title: e.target.value })}
|
||||
placeholder="CEO"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case 'WIFI':
|
||||
case 'GEO':
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
label="Network Name (SSID)"
|
||||
value={content.ssid || ''}
|
||||
onChange={(e) => setContent({ ...content, ssid: e.target.value })}
|
||||
label="Latitude"
|
||||
type="number"
|
||||
step="any"
|
||||
value={content.latitude || ''}
|
||||
onChange={(e) => setContent({ ...content, latitude: parseFloat(e.target.value) || 0 })}
|
||||
placeholder="37.7749"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={content.password || ''}
|
||||
onChange={(e) => setContent({ ...content, password: e.target.value })}
|
||||
label="Longitude"
|
||||
type="number"
|
||||
step="any"
|
||||
value={content.longitude || ''}
|
||||
onChange={(e) => setContent({ ...content, longitude: parseFloat(e.target.value) || 0 })}
|
||||
placeholder="-122.4194"
|
||||
required
|
||||
/>
|
||||
<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' },
|
||||
]}
|
||||
<Input
|
||||
label="Location Label (optional)"
|
||||
value={content.label || ''}
|
||||
onChange={(e) => setContent({ ...content, label: e.target.value })}
|
||||
placeholder="Golden Gate Bridge"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -318,13 +347,6 @@ export default function CreatePage() {
|
||||
/>
|
||||
|
||||
{renderContentFields()}
|
||||
|
||||
<Input
|
||||
label="Tags (comma-separated)"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="marketing, campaign, 2025"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -86,10 +86,10 @@ export default function DashboardPage() {
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'Contact Email',
|
||||
title: 'Contact Card',
|
||||
type: 'DYNAMIC' as const,
|
||||
contentType: 'EMAIL',
|
||||
slug: 'contact-email-qr',
|
||||
contentType: 'VCARD',
|
||||
slug: 'contact-card-qr',
|
||||
status: 'ACTIVE' as const,
|
||||
createdAt: '2025-08-07T10:04:00Z',
|
||||
scans: 0,
|
||||
@@ -255,7 +255,7 @@ export default function DashboardPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/qrs/${id}`, {
|
||||
const response = await fetchWithCsrf(`/api/qrs/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
|
||||
@@ -154,21 +154,75 @@ export default function EditQRPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{qrCode.contentType === 'EMAIL' && (
|
||||
{qrCode.contentType === 'VCARD' && (
|
||||
<>
|
||||
<Input
|
||||
label="First Name"
|
||||
value={content.firstName || ''}
|
||||
onChange={(e) => setContent({ ...content, firstName: e.target.value })}
|
||||
placeholder="John"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Last Name"
|
||||
value={content.lastName || ''}
|
||||
onChange={(e) => setContent({ ...content, lastName: e.target.value })}
|
||||
placeholder="Doe"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={content.email || ''}
|
||||
onChange={(e) => setContent({ ...content, email: e.target.value })}
|
||||
placeholder="email@example.com"
|
||||
placeholder="john@example.com"
|
||||
/>
|
||||
<Input
|
||||
label="Phone"
|
||||
value={content.phone || ''}
|
||||
onChange={(e) => setContent({ ...content, phone: e.target.value })}
|
||||
placeholder="+1234567890"
|
||||
/>
|
||||
<Input
|
||||
label="Organization"
|
||||
value={content.organization || ''}
|
||||
onChange={(e) => setContent({ ...content, organization: e.target.value })}
|
||||
placeholder="Company Name"
|
||||
/>
|
||||
<Input
|
||||
label="Job Title"
|
||||
value={content.title || ''}
|
||||
onChange={(e) => setContent({ ...content, title: e.target.value })}
|
||||
placeholder="CEO"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{qrCode.contentType === 'GEO' && (
|
||||
<>
|
||||
<Input
|
||||
label="Latitude"
|
||||
type="number"
|
||||
step="any"
|
||||
value={content.latitude || ''}
|
||||
onChange={(e) => setContent({ ...content, latitude: parseFloat(e.target.value) || 0 })}
|
||||
placeholder="37.7749"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Subject (Optional)"
|
||||
value={content.subject || ''}
|
||||
onChange={(e) => setContent({ ...content, subject: e.target.value })}
|
||||
placeholder="Email subject"
|
||||
label="Longitude"
|
||||
type="number"
|
||||
step="any"
|
||||
value={content.longitude || ''}
|
||||
onChange={(e) => setContent({ ...content, longitude: parseFloat(e.target.value) || 0 })}
|
||||
placeholder="-122.4194"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Location Label (Optional)"
|
||||
value={content.label || ''}
|
||||
onChange={(e) => setContent({ ...content, label: e.target.value })}
|
||||
placeholder="Golden Gate Bridge"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user