MVP
This commit is contained in:
@@ -124,22 +124,23 @@ export default function AnalyticsPage() {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Prepare chart data
|
||||
const last7Days = Array.from({ length: 7 }, (_, i) => {
|
||||
// Prepare chart data based on selected time range
|
||||
const daysToShow = parseInt(timeRange);
|
||||
const dateRange = Array.from({ length: daysToShow }, (_, i) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - (6 - i));
|
||||
date.setDate(date.getDate() - (daysToShow - 1 - i));
|
||||
return date.toISOString().split('T')[0];
|
||||
});
|
||||
|
||||
const scanChartData = {
|
||||
labels: last7Days.map(date => {
|
||||
labels: dateRange.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),
|
||||
data: dateRange.map(date => analyticsData?.dailyScans[date] || 0),
|
||||
borderColor: 'rgb(37, 99, 235)',
|
||||
backgroundColor: 'rgba(37, 99, 235, 0.1)',
|
||||
tension: 0.4,
|
||||
|
||||
594
src/app/(app)/bulk-creation/page.tsx
Normal file
594
src/app/(app)/bulk-creation/page.tsx
Normal file
@@ -0,0 +1,594 @@
|
||||
'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 { QRCodeSVG } from 'qrcode.react';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import JSZip from 'jszip';
|
||||
import { saveAs } from 'file-saver';
|
||||
|
||||
interface BulkQRData {
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface GeneratedQR {
|
||||
title: string;
|
||||
content: string; // Original URL
|
||||
svg: string; // SVG markup
|
||||
}
|
||||
|
||||
export default function BulkCreationPage() {
|
||||
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 [generatedQRs, setGeneratedQRs] = useState<GeneratedQR[]>([]);
|
||||
const [userPlan, setUserPlan] = useState<string>('FREE');
|
||||
|
||||
// Check user plan on mount
|
||||
React.useEffect(() => {
|
||||
const checkPlan = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/user/plan');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUserPlan(data.plan || 'FREE');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking plan:', error);
|
||||
}
|
||||
};
|
||||
checkPlan();
|
||||
}, []);
|
||||
|
||||
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[]) => {
|
||||
// Limit to 1000 rows
|
||||
const limitedData = rawData.slice(0, 1000);
|
||||
|
||||
// Auto-detect columns
|
||||
if (limitedData.length > 0) {
|
||||
const columns = Object.keys(limitedData[0]);
|
||||
const autoMapping: Record<string, string> = {};
|
||||
|
||||
columns.forEach((col) => {
|
||||
const lowerCol = col.toLowerCase();
|
||||
if (lowerCol.includes('title') || lowerCol.includes('name') || lowerCol === 'test') {
|
||||
autoMapping.title = col;
|
||||
} else if (lowerCol.includes('content') || lowerCol.includes('url') || lowerCol.includes('data') || lowerCol.includes('link')) {
|
||||
autoMapping.content = col;
|
||||
}
|
||||
});
|
||||
|
||||
// If no title column found, use first column
|
||||
if (!autoMapping.title && columns.length > 0) {
|
||||
autoMapping.title = columns[0];
|
||||
}
|
||||
// If no content column found, use second column
|
||||
if (!autoMapping.content && columns.length > 1) {
|
||||
autoMapping.content = columns[1];
|
||||
}
|
||||
|
||||
setMapping(autoMapping);
|
||||
}
|
||||
|
||||
setData(limitedData);
|
||||
setStep('preview');
|
||||
};
|
||||
|
||||
const generateStaticQRCodes = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const qrCodes: GeneratedQR[] = [];
|
||||
|
||||
// Generate all QR codes client-side (Static QR Codes)
|
||||
for (const row of data) {
|
||||
const title = row[mapping.title as keyof typeof row] || 'Untitled';
|
||||
const content = row[mapping.content as keyof typeof row] || 'https://example.com';
|
||||
|
||||
// Create a temporary div to render QR code
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.style.display = 'none';
|
||||
document.body.appendChild(tempDiv);
|
||||
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('width', '300');
|
||||
svg.setAttribute('height', '300');
|
||||
tempDiv.appendChild(svg);
|
||||
|
||||
// Use qrcode library to generate SVG
|
||||
const QRCode = require('qrcode');
|
||||
const qrSvg = await QRCode.toString(content, {
|
||||
type: 'svg',
|
||||
width: 300,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF',
|
||||
},
|
||||
});
|
||||
|
||||
qrCodes.push({
|
||||
title: String(title),
|
||||
content: String(content), // Store the original URL
|
||||
svg: qrSvg,
|
||||
});
|
||||
|
||||
document.body.removeChild(tempDiv);
|
||||
}
|
||||
|
||||
setGeneratedQRs(qrCodes);
|
||||
setStep('complete');
|
||||
showToast(`Successfully generated ${qrCodes.length} static QR codes!`, 'success');
|
||||
} catch (error) {
|
||||
console.error('QR generation error:', error);
|
||||
showToast('Failed to generate QR codes', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadAllQRCodes = async () => {
|
||||
const zip = new JSZip();
|
||||
|
||||
generatedQRs.forEach((qr, index) => {
|
||||
const fileName = `${qr.title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_${index + 1}.svg`;
|
||||
zip.file(fileName, qr.svg);
|
||||
});
|
||||
|
||||
const blob = await zip.generateAsync({ type: 'blob' });
|
||||
saveAs(blob, 'qr-codes-bulk.zip');
|
||||
showToast('Download started!', 'success');
|
||||
};
|
||||
|
||||
const saveQRCodesToDatabase = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const qrCodesToSave = generatedQRs.map((qr) => ({
|
||||
title: qr.title,
|
||||
isStatic: true, // This tells the API it's a static QR code
|
||||
contentType: 'URL',
|
||||
content: { url: qr.content }, // Content needs to be an object with url property
|
||||
status: 'ACTIVE',
|
||||
}));
|
||||
|
||||
// Save each QR code to the database
|
||||
const savePromises = qrCodesToSave.map((qr) =>
|
||||
fetch('/api/qrs', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(qr),
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(savePromises);
|
||||
const failedCount = results.filter((r) => !r.ok).length;
|
||||
|
||||
if (failedCount === 0) {
|
||||
showToast(`Successfully saved ${qrCodesToSave.length} QR codes!`, 'success');
|
||||
// Redirect to dashboard after 1 second
|
||||
setTimeout(() => {
|
||||
window.location.href = '/dashboard';
|
||||
}, 1000);
|
||||
} else {
|
||||
showToast(`Saved ${qrCodesToSave.length - failedCount} QR codes, ${failedCount} failed`, 'warning');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving QR codes:', error);
|
||||
showToast('Failed to save QR codes', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const template = [
|
||||
{ title: 'Product Page', content: 'https://example.com/product' },
|
||||
{ title: 'Landing Page', content: 'https://example.com/landing' },
|
||||
{ title: 'Contact Form', content: 'https://example.com/contact' },
|
||||
];
|
||||
|
||||
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 = 'bulk-qr-template.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Show upgrade prompt if not Business plan
|
||||
if (userPlan !== 'BUSINESS') {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Card className="mt-12">
|
||||
<CardContent className="p-12 text-center">
|
||||
<div className="w-20 h-20 bg-warning-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<svg className="w-10 h-10 text-warning-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Business Plan Required</h2>
|
||||
<p className="text-gray-600 mb-8 max-w-md mx-auto">
|
||||
Bulk QR code creation is exclusively available for Business plan subscribers.
|
||||
Upgrade now to generate up to 1,000 static QR codes at once.
|
||||
</p>
|
||||
<div className="flex justify-center space-x-4">
|
||||
<Button variant="outline" onClick={() => window.location.href = '/dashboard'}>
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
<Button onClick={() => window.location.href = '/pricing'}>
|
||||
Upgrade to Business
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{/* Template Warning Banner */}
|
||||
<Card className="mb-6 bg-warning-50 border-warning-200">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<svg className="w-6 h-6 text-warning-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h3 className="font-semibold text-warning-900 mb-1">Please Follow the Template Format</h3>
|
||||
<p className="text-sm text-warning-800">
|
||||
Download the template below and follow the format exactly. Your CSV must include columns for <strong>title</strong> and <strong>content</strong> (URL).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Info Banner */}
|
||||
<Card className="mb-6 bg-blue-50 border-blue-200">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<svg className="w-6 h-6 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h3 className="font-semibold text-blue-900 mb-1">Static QR Codes Only</h3>
|
||||
<p className="text-sm text-blue-800">
|
||||
Bulk creation generates <strong>static QR codes</strong> that cannot be edited after creation.
|
||||
These QR codes do not include tracking or analytics. Perfect for print materials and offline use.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 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">Download</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 1,000 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">Simple Format</p>
|
||||
<p className="text-sm text-gray-500">Just title & URL</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="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>
|
||||
<p className="font-medium text-gray-900">Static QR Codes</p>
|
||||
<p className="text-sm text-gray-500">No tracking included</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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Instant Download</p>
|
||||
<p className="text-sm text-gray-500">Get ZIP with all SVGs</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-2 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/URL 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>
|
||||
|
||||
<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">Content</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.content] || '').substring(0, 50)}...
|
||||
</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={generateStaticQRCodes} loading={loading}>
|
||||
Generate {data.length} Static 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">Generation Complete!</h2>
|
||||
<p className="text-gray-600 mb-8">
|
||||
Successfully generated {generatedQRs.length} static QR codes
|
||||
</p>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8 max-w-6xl mx-auto">
|
||||
{generatedQRs.slice(0, 8).map((qr, index) => (
|
||||
<div key={index} className="bg-white border border-gray-200 rounded-lg p-6 flex flex-col items-center shadow-sm hover:shadow-md transition-shadow">
|
||||
<div className="w-full flex items-center justify-center mb-4" style={{ height: '160px' }}>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: qr.svg }}
|
||||
className="qr-code-container"
|
||||
style={{ maxWidth: '160px', maxHeight: '160px' }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-gray-900 font-medium text-center break-words w-full">{qr.title}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.qr-code-container :global(svg) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
max-width: 160px !important;
|
||||
max-height: 160px !important;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="flex justify-center space-x-4">
|
||||
<Button variant="outline" onClick={() => {
|
||||
setStep('upload');
|
||||
setData([]);
|
||||
setMapping({});
|
||||
setGeneratedQRs([]);
|
||||
}}>
|
||||
Create More
|
||||
</Button>
|
||||
<Button variant="outline" onClick={downloadAllQRCodes}>
|
||||
<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 All as ZIP
|
||||
</Button>
|
||||
<Button onClick={saveQRCodesToDatabase} loading={loading}>
|
||||
<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="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
|
||||
</svg>
|
||||
Save QR Codes
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,7 +53,7 @@ export default function CreatePage() {
|
||||
};
|
||||
fetchUserPlan();
|
||||
}, []);
|
||||
|
||||
|
||||
const contrast = calculateContrast(foregroundColor, backgroundColor);
|
||||
const hasGoodContrast = contrast >= 4.5;
|
||||
|
||||
@@ -123,7 +123,7 @@ export default function CreatePage() {
|
||||
light: backgroundColor,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const blob = new Blob([svg], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -165,9 +165,9 @@ export default function CreatePage() {
|
||||
size,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
console.log('SENDING QR DATA:', qrData);
|
||||
|
||||
|
||||
const response = await fetch('/api/qrs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -287,7 +287,8 @@ export default function CreatePage() {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Create QR Code</h1>
|
||||
<h1 className="text-3xl font-bold text-gray-900">{t('create.title')}</h1>
|
||||
<p className="text-gray-600 mt-2">{t('create.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
@@ -307,21 +308,21 @@ export default function CreatePage() {
|
||||
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"
|
||||
placeholder="marketing, campaign, 2025"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -427,7 +428,7 @@ export default function CreatePage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Select
|
||||
label="Corner Style"
|
||||
@@ -438,7 +439,7 @@ export default function CreatePage() {
|
||||
{ value: 'rounded', label: 'Rounded' },
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Size: {size}px
|
||||
@@ -453,7 +454,7 @@ export default function CreatePage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant={hasGoodContrast ? 'success' : 'warning'}>
|
||||
{hasGoodContrast ? 'Good contrast' : 'Low contrast'}
|
||||
@@ -490,11 +491,11 @@ export default function CreatePage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const svg = document.querySelector('#create-qr-preview svg');
|
||||
@@ -512,9 +513,9 @@ export default function CreatePage() {
|
||||
>
|
||||
Download SVG
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const svg = document.querySelector('#create-qr-preview svg');
|
||||
@@ -525,7 +526,7 @@ export default function CreatePage() {
|
||||
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;
|
||||
|
||||
@@ -9,6 +9,7 @@ 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';
|
||||
import { useCsrf } from '@/hooks/useCsrf';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/Dialog';
|
||||
|
||||
@@ -22,17 +23,20 @@ interface QRCodeData {
|
||||
status: 'ACTIVE' | 'PAUSED';
|
||||
createdAt: string;
|
||||
scans: number;
|
||||
style?: any;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { fetchWithCsrf } = useCsrf();
|
||||
const [qrCodes, setQrCodes] = useState<QRCodeData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [userPlan, setUserPlan] = useState<string>('FREE');
|
||||
const [showUpgradeDialog, setShowUpgradeDialog] = useState(false);
|
||||
const [upgradedPlan, setUpgradedPlan] = useState<string>('');
|
||||
const [deletingAll, setDeletingAll] = useState(false);
|
||||
const [stats, setStats] = useState({
|
||||
totalScans: 0,
|
||||
activeQRCodes: 0,
|
||||
@@ -116,7 +120,7 @@ export default function DashboardPage() {
|
||||
slug: 'dynamische-vs-statische-qr-codes',
|
||||
},
|
||||
{
|
||||
title: 'QR-Code Marketing-Strategien für 2024',
|
||||
title: 'QR-Code Marketing-Strategien für 2025',
|
||||
excerpt: 'Die besten Marketing-Strategien mit QR-Codes für Ihr Unternehmen...',
|
||||
readTime: '7 Min',
|
||||
slug: 'qr-code-marketing-strategien',
|
||||
@@ -205,19 +209,98 @@ export default function DashboardPage() {
|
||||
}, []);
|
||||
|
||||
const handleEdit = (id: string) => {
|
||||
console.log('Edit QR:', id);
|
||||
// Redirect to edit page
|
||||
router.push(`/qr/${id}/edit`);
|
||||
};
|
||||
|
||||
const handleDuplicate = (id: string) => {
|
||||
console.log('Duplicate QR:', id);
|
||||
const handlePause = async (id: string) => {
|
||||
try {
|
||||
const qr = qrCodes.find(q => q.id === id);
|
||||
if (!qr) return;
|
||||
|
||||
const newStatus = qr.status === 'ACTIVE' ? 'PAUSED' : 'ACTIVE';
|
||||
|
||||
const response = await fetch(`/api/qrs/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Update local state
|
||||
setQrCodes(qrCodes.map(q =>
|
||||
q.id === id ? { ...q, status: newStatus } : q
|
||||
));
|
||||
showToast(`QR code ${newStatus === 'ACTIVE' ? 'resumed' : 'paused'}!`, 'success');
|
||||
} else {
|
||||
throw new Error('Failed to update status');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating QR status:', error);
|
||||
showToast('Failed to update QR code status', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = (id: string) => {
|
||||
console.log('Pause QR:', id);
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this QR code? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/qrs/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Remove from local state
|
||||
setQrCodes(qrCodes.filter(q => q.id !== id));
|
||||
showToast('QR code deleted successfully!', 'success');
|
||||
} else {
|
||||
throw new Error('Failed to delete');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting QR:', error);
|
||||
showToast('Failed to delete QR code', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
console.log('Delete QR:', id);
|
||||
const handleDeleteAll = async () => {
|
||||
if (!confirm('Are you sure you want to delete ALL QR codes? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Double confirmation
|
||||
if (!confirm('This will permanently delete ALL your QR codes. Are you absolutely sure?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingAll(true);
|
||||
|
||||
try {
|
||||
const response = await fetchWithCsrf('/api/qrs/delete-all', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setQrCodes([]);
|
||||
setStats({
|
||||
totalScans: 0,
|
||||
activeQRCodes: 0,
|
||||
conversionRate: 0,
|
||||
});
|
||||
showToast(`Successfully deleted ${data.deletedCount} QR code${data.deletedCount !== 1 ? 's' : ''}`, 'success');
|
||||
} else {
|
||||
throw new Error('Failed to delete all QR codes');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting all QR codes:', error);
|
||||
showToast('Failed to delete QR codes', 'error');
|
||||
} finally {
|
||||
setDeletingAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getPlanBadgeColor = (plan: string) => {
|
||||
@@ -263,9 +346,21 @@ export default function DashboardPage() {
|
||||
<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 className="flex gap-3">
|
||||
{qrCodes.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDeleteAll}
|
||||
disabled={deletingAll}
|
||||
className="border-red-600 text-red-600 hover:bg-red-50"
|
||||
>
|
||||
{deletingAll ? 'Deleting...' : 'Delete All'}
|
||||
</Button>
|
||||
)}
|
||||
<Link href="/create">
|
||||
<Button>Create New QR Code</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
@@ -292,7 +387,6 @@ export default function DashboardPage() {
|
||||
key={qr.id}
|
||||
qr={qr}
|
||||
onEdit={handleEdit}
|
||||
onDuplicate={handleDuplicate}
|
||||
onPause={handlePause}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
@@ -351,11 +445,11 @@ export default function DashboardPage() {
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
<span>Branding (Logo, Farben anpassen)</span>
|
||||
<span>Branding (Farben anpassen)</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
<span>Detaillierte Analytics</span>
|
||||
<span>Detaillierte Analytics (Devices, Locations, Time-Series)</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
@@ -363,7 +457,7 @@ export default function DashboardPage() {
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
<span>Passwortschutz für QR-Codes</span>
|
||||
<span>SVG/PNG Download</span>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
@@ -375,15 +469,11 @@ export default function DashboardPage() {
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
<span>Team-Zugänge (bis zu 3 User)</span>
|
||||
<span>Alles von Pro</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
<span>Benutzerdefinierte Domains</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
<span>White-Label</span>
|
||||
<span>Bulk QR-Generierung (bis 1,000)</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
|
||||
@@ -220,17 +220,17 @@ export default function IntegrationsPage() {
|
||||
<div className="text-3xl">{integration.icon}</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg">{integration.name}</CardTitle>
|
||||
<Badge
|
||||
<Badge
|
||||
variant={
|
||||
integration.status === 'active' ? 'success' :
|
||||
integration.status === 'coming_soon' ? 'warning' :
|
||||
'default'
|
||||
integration.status === 'active' ? 'success' :
|
||||
integration.status === 'coming_soon' ? 'warning' :
|
||||
'default'
|
||||
}
|
||||
className="mt-1"
|
||||
>
|
||||
{integration.status === 'active' ? 'Active' :
|
||||
integration.status === 'coming_soon' ? 'Coming Soon' :
|
||||
'Inactive'}
|
||||
{integration.status === 'active' ? 'Active' :
|
||||
integration.status === 'coming_soon' ? 'Coming Soon' :
|
||||
'Inactive'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -238,7 +238,7 @@ export default function IntegrationsPage() {
|
||||
</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">
|
||||
@@ -277,112 +277,112 @@ export default function IntegrationsPage() {
|
||||
<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>
|
||||
{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>
|
||||
|
||||
<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">
|
||||
{`{
|
||||
<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",
|
||||
"timestamp": "2025-01-01T12:00:00Z",
|
||||
"location": "United States",
|
||||
"device": "mobile"
|
||||
}`}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</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
|
||||
{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>
|
||||
</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>
|
||||
{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>
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function AppLayout({
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
name: 'Dashboard',
|
||||
name: t('nav.dashboard'),
|
||||
href: '/dashboard',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -39,7 +39,7 @@ export default function AppLayout({
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Create QR',
|
||||
name: t('nav.create_qr'),
|
||||
href: '/create',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -48,7 +48,16 @@ export default function AppLayout({
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Analytics',
|
||||
name: t('nav.bulk_creation'),
|
||||
href: '/bulk-creation',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: t('nav.analytics'),
|
||||
href: '/analytics',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -57,7 +66,7 @@ export default function AppLayout({
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Pricing',
|
||||
name: t('nav.pricing'),
|
||||
href: '/pricing',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -66,7 +75,7 @@ export default function AppLayout({
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
name: t('nav.settings'),
|
||||
href: '/settings',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -1,357 +1,229 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function PricingPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [user, setUser] = useState<any>(null);
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
const [billingInterval, setBillingInterval] = useState<'monthly' | 'yearly'>('monthly');
|
||||
const [hasTriggeredCheckout, setHasTriggeredCheckout] = useState(false);
|
||||
const [currentPlan, setCurrentPlan] = useState<string>('FREE');
|
||||
|
||||
// Check for user in localStorage
|
||||
useEffect(() => {
|
||||
const storedUser = localStorage.getItem('user');
|
||||
if (storedUser) {
|
||||
setUser(JSON.parse(storedUser));
|
||||
}
|
||||
// Fetch current user plan
|
||||
const fetchUserPlan = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/user/plan');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCurrentPlan(data.plan || 'FREE');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user plan:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserPlan();
|
||||
}, []);
|
||||
|
||||
const plans = [
|
||||
{
|
||||
id: 'FREE',
|
||||
name: 'Free / Starter',
|
||||
icon: '',
|
||||
price: 0,
|
||||
priceYearly: 0,
|
||||
description: 'Privatnutzer & Testkunden',
|
||||
features: [
|
||||
'3 dynamische QR-Codes',
|
||||
'Unbegrenzte statische QR-Codes',
|
||||
'Basis-Scan-Tracking',
|
||||
'Standard QR-Design-Vorlagen',
|
||||
],
|
||||
cta: 'Get Started',
|
||||
popular: false,
|
||||
priceIdMonthly: null,
|
||||
priceIdYearly: null,
|
||||
},
|
||||
{
|
||||
id: 'PRO',
|
||||
name: 'Pro',
|
||||
icon: '',
|
||||
price: 9,
|
||||
priceYearly: 90,
|
||||
description: 'Selbstständige / kleine Firmen',
|
||||
features: [
|
||||
'50 dynamische QR-Codes',
|
||||
'Unbegrenzte statische QR-Codes',
|
||||
'Erweiterte Analytik (Scans, Geräte, Standorte)',
|
||||
'Individuelles Branding (Farben & Logo)',
|
||||
'Download als SVG/PNG',
|
||||
],
|
||||
cta: 'Upgrade to Pro',
|
||||
popular: true,
|
||||
priceIdMonthly: process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_MONTHLY,
|
||||
priceIdYearly: process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_YEARLY,
|
||||
},
|
||||
{
|
||||
id: 'BUSINESS',
|
||||
name: 'Business',
|
||||
icon: '',
|
||||
price: 29,
|
||||
priceYearly: 290,
|
||||
description: 'Agenturen / Startups',
|
||||
features: [
|
||||
'500 dynamische QR-Codes',
|
||||
'Unbegrenzte statische QR-Codes',
|
||||
'Alles aus Pro',
|
||||
'Prioritäts-E-Mail-Support',
|
||||
'Erweiterte Tracking & Insights',
|
||||
],
|
||||
cta: 'Upgrade to Business',
|
||||
popular: false,
|
||||
priceIdMonthly: process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_BUSINESS_MONTHLY,
|
||||
priceIdYearly: process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_BUSINESS_YEARLY,
|
||||
},
|
||||
];
|
||||
|
||||
const handleSubscribe = async (planId: string, priceId: string | null | undefined) => {
|
||||
console.log('🔵 handleSubscribe called:', { planId, priceId, hasUser: !!user });
|
||||
|
||||
if (!user) {
|
||||
// Save the plan selection in localStorage so we can continue after login
|
||||
const pendingPlan = {
|
||||
planId,
|
||||
interval: billingInterval,
|
||||
};
|
||||
console.log('💾 Saving pending plan to localStorage:', pendingPlan);
|
||||
localStorage.setItem('pendingPlan', JSON.stringify(pendingPlan));
|
||||
|
||||
// Verify it was saved
|
||||
const saved = localStorage.getItem('pendingPlan');
|
||||
console.log('✅ Verified saved:', saved);
|
||||
|
||||
// Use window.location instead of router.push to ensure localStorage is written
|
||||
console.log('🔄 Redirecting to login...');
|
||||
window.location.href = '/login?redirect=/pricing';
|
||||
return;
|
||||
}
|
||||
|
||||
if (planId === 'FREE') {
|
||||
showToast('Sie nutzen bereits den kostenlosen Plan!', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!priceId) {
|
||||
showToast('Preisdetails nicht verfügbar', 'error');
|
||||
return;
|
||||
}
|
||||
const handleUpgrade = async (plan: 'PRO' | 'BUSINESS') => {
|
||||
setLoading(plan);
|
||||
|
||||
try {
|
||||
setLoading(planId);
|
||||
|
||||
const response = await fetch('/api/stripe/checkout', {
|
||||
const response = await fetch('/api/stripe/create-checkout-session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
priceId,
|
||||
plan: planId,
|
||||
userEmail: user.email,
|
||||
plan,
|
||||
billingInterval: 'month',
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.url) {
|
||||
window.location.href = data.url;
|
||||
} else {
|
||||
showToast(data.error || 'Fehler beim Erstellen der Checkout-Session', 'error');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create checkout session');
|
||||
}
|
||||
|
||||
const { url } = await response.json();
|
||||
window.location.href = url;
|
||||
} catch (error) {
|
||||
console.error('Error creating checkout session:', error);
|
||||
showToast('Ein Fehler ist aufgetreten', 'error');
|
||||
} finally {
|
||||
showToast('Failed to start checkout. Please try again.', 'error');
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-trigger checkout after login if plan is selected
|
||||
useEffect(() => {
|
||||
console.log('Pricing useEffect triggered:', {
|
||||
hasUser: !!user,
|
||||
hasTriggeredCheckout,
|
||||
});
|
||||
const handleDowngrade = async () => {
|
||||
// Show confirmation dialog
|
||||
const confirmed = window.confirm(
|
||||
'Are you sure you want to downgrade to the Free plan? Your subscription will be canceled immediately and you will lose access to premium features.'
|
||||
);
|
||||
|
||||
// Only run once and only when authenticated
|
||||
if (hasTriggeredCheckout) {
|
||||
console.log('Already triggered checkout, skipping...');
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
console.log('Not authenticated - no user in localStorage');
|
||||
return;
|
||||
}
|
||||
setLoading('FREE');
|
||||
|
||||
// Check for pending plan in localStorage
|
||||
const pendingPlanStr = localStorage.getItem('pendingPlan');
|
||||
if (pendingPlanStr) {
|
||||
try {
|
||||
const pendingPlan = JSON.parse(pendingPlanStr);
|
||||
console.log('✅ Found pending plan:', pendingPlan);
|
||||
try {
|
||||
const response = await fetch('/api/stripe/cancel-subscription', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Clear pending plan immediately
|
||||
localStorage.removeItem('pendingPlan');
|
||||
|
||||
// Mark as triggered to prevent re-runs
|
||||
setHasTriggeredCheckout(true);
|
||||
|
||||
// Set the billing interval
|
||||
setBillingInterval(pendingPlan.interval);
|
||||
|
||||
// Find the plan
|
||||
const selectedPlan = plans.find((p) => p.id === pendingPlan.planId);
|
||||
if (selectedPlan) {
|
||||
const priceId =
|
||||
pendingPlan.interval === 'yearly'
|
||||
? selectedPlan.priceIdYearly
|
||||
: selectedPlan.priceIdMonthly;
|
||||
|
||||
console.log('✅ Found plan and priceId:', selectedPlan.name, priceId);
|
||||
|
||||
// Trigger checkout after a short delay
|
||||
setTimeout(() => {
|
||||
console.log('🚀 Calling handleSubscribe now...');
|
||||
handleSubscribe(selectedPlan.id, priceId);
|
||||
}, 500);
|
||||
} else {
|
||||
console.error('❌ Plan not found:', pendingPlan.planId);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing pending plan:', e);
|
||||
localStorage.removeItem('pendingPlan');
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to cancel subscription');
|
||||
}
|
||||
} else {
|
||||
console.log('No pending plan in localStorage');
|
||||
|
||||
showToast('Successfully downgraded to Free plan', 'success');
|
||||
|
||||
// Refresh to update the plan
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} catch (error: any) {
|
||||
console.error('Error canceling subscription:', error);
|
||||
showToast(error.message || 'Failed to downgrade. Please try again.', 'error');
|
||||
setLoading(null);
|
||||
}
|
||||
}, [user, hasTriggeredCheckout]);
|
||||
};
|
||||
|
||||
const plans = [
|
||||
{
|
||||
key: 'free',
|
||||
name: 'Free',
|
||||
price: '€0',
|
||||
period: 'forever',
|
||||
features: [
|
||||
'3 dynamic QR codes',
|
||||
'Unlimited static QR codes',
|
||||
'Basic scan tracking',
|
||||
'Standard QR design templates',
|
||||
],
|
||||
buttonText: currentPlan === 'FREE' ? 'Current Plan' : 'Downgrade to Free',
|
||||
buttonVariant: 'outline' as const,
|
||||
disabled: currentPlan === 'FREE',
|
||||
popular: false,
|
||||
onDowngrade: handleDowngrade,
|
||||
},
|
||||
{
|
||||
key: 'pro',
|
||||
name: 'Pro',
|
||||
price: '€9',
|
||||
period: 'per month',
|
||||
features: [
|
||||
'50 dynamic QR codes',
|
||||
'Unlimited static QR codes',
|
||||
'Advanced analytics (scans, devices, locations)',
|
||||
'Custom branding (colors)',
|
||||
'Download as SVG/PNG',
|
||||
],
|
||||
buttonText: currentPlan === 'PRO' ? 'Current Plan' : 'Upgrade to Pro',
|
||||
buttonVariant: 'primary' as const,
|
||||
disabled: currentPlan === 'PRO',
|
||||
popular: true,
|
||||
onUpgrade: () => handleUpgrade('PRO'),
|
||||
},
|
||||
{
|
||||
key: 'business',
|
||||
name: 'Business',
|
||||
price: '€29',
|
||||
period: 'per month',
|
||||
features: [
|
||||
'500 dynamic QR codes',
|
||||
'Unlimited static QR codes',
|
||||
'Everything from Pro',
|
||||
'Bulk QR Creation (up to 1,000)',
|
||||
'Priority email support',
|
||||
'Advanced tracking & insights',
|
||||
],
|
||||
buttonText: currentPlan === 'BUSINESS' ? 'Current Plan' : 'Upgrade to Business',
|
||||
buttonVariant: 'primary' as const,
|
||||
disabled: currentPlan === 'BUSINESS',
|
||||
popular: false,
|
||||
onUpgrade: () => handleUpgrade('BUSINESS'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-4">
|
||||
Wählen Sie Ihren Plan
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
Choose Your Plan
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 mb-8">
|
||||
Starten Sie kostenlos. Upgraden Sie jederzeit.
|
||||
<p className="text-xl text-gray-600">
|
||||
Select the perfect plan for your QR code needs
|
||||
</p>
|
||||
|
||||
{/* Billing Toggle */}
|
||||
<div className="inline-flex items-center space-x-4 bg-gray-100 p-1 rounded-lg">
|
||||
<button
|
||||
onClick={() => setBillingInterval('monthly')}
|
||||
className={`px-6 py-2 rounded-md font-medium transition-colors ${
|
||||
billingInterval === 'monthly'
|
||||
? 'bg-white text-gray-900 shadow'
|
||||
: 'text-gray-600'
|
||||
}`}
|
||||
>
|
||||
Monatlich
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBillingInterval('yearly')}
|
||||
className={`px-6 py-2 rounded-md font-medium transition-colors ${
|
||||
billingInterval === 'yearly'
|
||||
? 'bg-white text-gray-900 shadow'
|
||||
: 'text-gray-600'
|
||||
}`}
|
||||
>
|
||||
Jährlich
|
||||
<Badge variant="success" className="ml-2">
|
||||
Spare 17%
|
||||
</Badge>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8 max-w-6xl mx-auto">
|
||||
{plans.map((plan) => {
|
||||
const price = billingInterval === 'yearly' ? plan.priceYearly : plan.price;
|
||||
const priceId =
|
||||
billingInterval === 'yearly' ? plan.priceIdYearly : plan.priceIdMonthly;
|
||||
const isLoading = loading === plan.id;
|
||||
{plans.map((plan) => (
|
||||
<Card
|
||||
key={plan.key}
|
||||
className={plan.popular ? 'border-primary-500 shadow-xl relative' : ''}
|
||||
>
|
||||
{plan.popular && (
|
||||
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
||||
<Badge variant="info" className="px-3 py-1">
|
||||
Most Popular
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={plan.id}
|
||||
className={`relative ${
|
||||
plan.popular
|
||||
? 'border-primary-500 border-2 shadow-xl scale-105'
|
||||
: 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
{plan.popular && (
|
||||
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
||||
<Badge variant="info" className="px-4 py-1 text-sm">
|
||||
Beliebteste Wahl
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
<CardHeader className="text-center pb-8">
|
||||
<CardTitle className="text-2xl mb-4">
|
||||
{plan.name}
|
||||
</CardTitle>
|
||||
<div className="flex items-baseline justify-center">
|
||||
<span className="text-4xl font-bold">
|
||||
{plan.price}
|
||||
</span>
|
||||
<span className="text-gray-600 ml-2">
|
||||
{plan.period}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardHeader className="text-center pb-6">
|
||||
{plan.icon && <div className="text-4xl mb-4">{plan.icon}</div>}
|
||||
<CardTitle className="text-2xl mb-2">{plan.name}</CardTitle>
|
||||
<p className="text-sm text-gray-600">{plan.description}</p>
|
||||
<CardContent className="space-y-6">
|
||||
<ul className="space-y-3">
|
||||
{plan.features.map((feature: string, index: number) => (
|
||||
<li key={index} className="flex items-start space-x-3">
|
||||
<svg className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-gray-700">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="flex items-baseline justify-center">
|
||||
<span className="text-5xl font-bold">{price}€</span>
|
||||
<span className="text-gray-600 ml-2">
|
||||
/{billingInterval === 'yearly' ? 'Jahr' : 'Monat'}
|
||||
</span>
|
||||
</div>
|
||||
{billingInterval === 'yearly' && plan.price > 0 && (
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
{(price / 12).toFixed(2)}€ pro Monat
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<ul className="space-y-3">
|
||||
{plan.features.map((feature, index) => (
|
||||
<li key={index} className="flex items-start space-x-3">
|
||||
<svg
|
||||
className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-gray-700">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
variant={plan.popular ? 'primary' : 'outline'}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onClick={() => handleSubscribe(plan.id, priceId)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Lädt...' : plan.cta}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
variant={plan.buttonVariant}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={plan.disabled || loading === plan.key.toUpperCase()}
|
||||
onClick={plan.key === 'free' ? (plan as any).onDowngrade : (plan as any).onUpgrade}
|
||||
>
|
||||
{loading === plan.key.toUpperCase() ? 'Processing...' : plan.buttonText}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* FAQ Section */}
|
||||
<div className="mt-20 max-w-3xl mx-auto">
|
||||
<h2 className="text-3xl font-bold text-center mb-8">Häufige Fragen</h2>
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<h3 className="font-semibold mb-2">Kann ich jederzeit kündigen?</h3>
|
||||
<p className="text-gray-600">
|
||||
Ja, Sie können Ihr Abo jederzeit kündigen. Es läuft dann bis zum Ende des
|
||||
bezahlten Zeitraums weiter.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<h3 className="font-semibold mb-2">Welche Zahlungsmethoden akzeptieren Sie?</h3>
|
||||
<p className="text-gray-600">
|
||||
Wir akzeptieren alle gängigen Kreditkarten und SEPA-Lastschrift über Stripe.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<h3 className="font-semibold mb-2">Was passiert mit meinen QR-Codes bei Downgrade?</h3>
|
||||
<p className="text-gray-600">
|
||||
Ihre QR-Codes bleiben erhalten, Sie können nur keine neuen mehr erstellen, wenn das Limit erreicht ist.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="text-center mt-12">
|
||||
<p className="text-gray-600">
|
||||
All plans include unlimited static QR codes and basic customization.
|
||||
</p>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Need help choosing? <a href="mailto:support@qrmaster.com" className="text-primary-600 hover:text-primary-700 underline">Contact our team</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
210
src/app/(app)/qr/[id]/edit/page.tsx
Normal file
210
src/app/(app)/qr/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { showToast } from '@/components/ui/Toast';
|
||||
|
||||
export default function EditQRPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const qrId = params.id as string;
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [qrCode, setQrCode] = useState<any>(null);
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState<any>({});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchQRCode = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/qrs/${qrId}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setQrCode(data);
|
||||
setTitle(data.title);
|
||||
setContent(data.content || {});
|
||||
} else {
|
||||
showToast('Failed to load QR code', 'error');
|
||||
router.push('/dashboard');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching QR code:', error);
|
||||
showToast('Failed to load QR code', 'error');
|
||||
router.push('/dashboard');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchQRCode();
|
||||
}, [qrId, router]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/qrs/${qrId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
content,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showToast('QR code updated successfully!', 'success');
|
||||
router.push('/dashboard');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(error.error || 'Failed to update QR code', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating QR code:', error);
|
||||
showToast('Failed to update QR code', 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Loading QR code...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!qrCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Static QR codes cannot be edited
|
||||
if (qrCode.type === 'STATIC') {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto mt-12">
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<div className="w-20 h-20 bg-warning-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<svg className="w-10 h-10 text-warning-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Static QR Code</h2>
|
||||
<p className="text-gray-600 mb-8">
|
||||
Static QR codes cannot be edited because their content is embedded directly in the QR code image.
|
||||
</p>
|
||||
<Button onClick={() => router.push('/dashboard')}>
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Edit QR Code</h1>
|
||||
<p className="text-gray-600 mt-2">Update your dynamic QR code content</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>QR Code Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<Input
|
||||
label="Title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Enter QR code title"
|
||||
required
|
||||
/>
|
||||
|
||||
{qrCode.contentType === 'URL' && (
|
||||
<Input
|
||||
label="URL"
|
||||
type="url"
|
||||
value={content.url || ''}
|
||||
onChange={(e) => setContent({ ...content, url: e.target.value })}
|
||||
placeholder="https://example.com"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
|
||||
{qrCode.contentType === 'PHONE' && (
|
||||
<Input
|
||||
label="Phone Number"
|
||||
type="tel"
|
||||
value={content.phone || ''}
|
||||
onChange={(e) => setContent({ ...content, phone: e.target.value })}
|
||||
placeholder="+1234567890"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
|
||||
{qrCode.contentType === 'EMAIL' && (
|
||||
<>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={content.email || ''}
|
||||
onChange={(e) => setContent({ ...content, email: e.target.value })}
|
||||
placeholder="email@example.com"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Subject (Optional)"
|
||||
value={content.subject || ''}
|
||||
onChange={(e) => setContent({ ...content, subject: e.target.value })}
|
||||
placeholder="Email subject"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{qrCode.contentType === 'TEXT' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Text Content
|
||||
</label>
|
||||
<textarea
|
||||
value={content.text || ''}
|
||||
onChange={(e) => setContent({ ...content, text: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
rows={4}
|
||||
placeholder="Enter your text content"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-4 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push('/dashboard')}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,44 +3,78 @@
|
||||
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 { showToast } from '@/components/ui/Toast';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import ChangePasswordModal from '@/components/settings/ChangePasswordModal';
|
||||
|
||||
type TabType = 'profile' | 'subscription';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { t, setLanguage, language } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('profile');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
|
||||
// Form states
|
||||
// Profile states
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [selectedLanguage, setSelectedLanguage] = useState(language || 'en');
|
||||
|
||||
// Load user data and language from localStorage
|
||||
// Subscription states
|
||||
const [plan, setPlan] = useState('FREE');
|
||||
const [usageStats, setUsageStats] = useState({
|
||||
dynamicUsed: 0,
|
||||
dynamicLimit: 3,
|
||||
staticUsed: 0,
|
||||
});
|
||||
|
||||
// Load user data
|
||||
useEffect(() => {
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (userStr) {
|
||||
const fetchUserData = async () => {
|
||||
try {
|
||||
const user = JSON.parse(userStr);
|
||||
setName(user.name || '');
|
||||
setEmail(user.email || '');
|
||||
} catch (e) {
|
||||
console.error('Failed to parse user data:', e);
|
||||
}
|
||||
}
|
||||
// Load from localStorage
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (userStr) {
|
||||
const user = JSON.parse(userStr);
|
||||
setName(user.name || '');
|
||||
setEmail(user.email || '');
|
||||
}
|
||||
|
||||
// Load saved language preference
|
||||
const savedLocale = localStorage.getItem('locale');
|
||||
if (savedLocale && (savedLocale === 'en' || savedLocale === 'de')) {
|
||||
setSelectedLanguage(savedLocale);
|
||||
}
|
||||
// Fetch plan from API
|
||||
const planResponse = await fetch('/api/user/plan');
|
||||
if (planResponse.ok) {
|
||||
const data = await planResponse.json();
|
||||
setPlan(data.plan || 'FREE');
|
||||
}
|
||||
|
||||
// Fetch usage stats from API
|
||||
const statsResponse = await fetch('/api/user/stats');
|
||||
if (statsResponse.ok) {
|
||||
const data = await statsResponse.json();
|
||||
setUsageStats(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load user data:', e);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserData();
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
const handleSaveProfile = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Update language
|
||||
setLanguage(selectedLanguage);
|
||||
// Save to backend API
|
||||
const response = await fetch('/api/user/profile', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to update profile');
|
||||
}
|
||||
|
||||
// Update user data in localStorage
|
||||
const userStr = localStorage.getItem('user');
|
||||
@@ -50,97 +84,305 @@ export default function SettingsPage() {
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
}
|
||||
|
||||
showToast('Settings saved successfully!', 'success');
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error);
|
||||
showToast('Failed to save settings', 'error');
|
||||
showToast('Profile updated successfully!', 'success');
|
||||
} catch (error: any) {
|
||||
console.error('Error saving profile:', error);
|
||||
showToast(error.message || 'Failed to update profile', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleManageSubscription = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/stripe/portal', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to open subscription management');
|
||||
}
|
||||
|
||||
// Redirect to Stripe Customer Portal
|
||||
window.location.href = data.url;
|
||||
} catch (error: any) {
|
||||
console.error('Error opening portal:', error);
|
||||
showToast(error.message || 'Failed to open subscription management', 'error');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
const confirmed = window.confirm(
|
||||
'Are you sure you want to delete your account? This will permanently delete all your data, including all QR codes and analytics. This action cannot be undone.'
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
// Double confirmation for safety
|
||||
const doubleConfirmed = window.confirm(
|
||||
'This is your last warning. Are you absolutely sure you want to permanently delete your account?'
|
||||
);
|
||||
|
||||
if (!doubleConfirmed) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/user/delete', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to delete account');
|
||||
}
|
||||
|
||||
// Clear local storage and redirect to login
|
||||
localStorage.clear();
|
||||
showToast('Account deleted successfully', 'success');
|
||||
|
||||
// Redirect to home page after a short delay
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 1500);
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting account:', error);
|
||||
showToast(error.message || 'Failed to delete account', 'error');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getPlanLimits = () => {
|
||||
switch (plan) {
|
||||
case 'PRO':
|
||||
return { dynamic: 50, price: '€9', period: 'per month' };
|
||||
case 'BUSINESS':
|
||||
return { dynamic: 500, price: '€29', period: 'per month' };
|
||||
default:
|
||||
return { dynamic: 3, price: '€0', period: 'forever' };
|
||||
}
|
||||
};
|
||||
|
||||
const planLimits = getPlanLimits();
|
||||
const usagePercentage = (usageStats.dynamicUsed / usageStats.dynamicLimit) * 100;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Settings</h1>
|
||||
<p className="text-gray-600 mt-2">Manage your account settings and preferences</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Profile Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profile Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Enter your name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
disabled
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed"
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Email cannot be changed
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Language Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Language Preferences</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Display Language
|
||||
</label>
|
||||
<select
|
||||
value={selectedLanguage}
|
||||
onChange={(e) => setSelectedLanguage(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="de">Deutsch (German)</option>
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Choose your preferred language for the interface
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
size="lg"
|
||||
variant="primary"
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200 mb-6">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('profile')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'profile'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{loading ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
Profile
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('subscription')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'subscription'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Subscription
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'profile' && (
|
||||
<div className="space-y-6">
|
||||
{/* Profile Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profile Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Enter your name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
disabled
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Email cannot be changed
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Security */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Security</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900">Password</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Update your password to keep your account secure
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowPasswordModal(true)}
|
||||
>
|
||||
Change Password
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Account Deletion */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-red-600">Delete Account</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900">Delete your account</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Permanently delete your account and all data. This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-red-600 text-red-600 hover:bg-red-50"
|
||||
onClick={handleDeleteAccount}
|
||||
>
|
||||
Delete Account
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleSaveProfile}
|
||||
disabled={loading}
|
||||
size="lg"
|
||||
variant="primary"
|
||||
>
|
||||
{loading ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'subscription' && (
|
||||
<div className="space-y-6">
|
||||
{/* Current Plan */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Current Plan</CardTitle>
|
||||
<Badge variant={plan === 'FREE' ? 'default' : plan === 'PRO' ? 'info' : 'warning'}>
|
||||
{plan}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-baseline">
|
||||
<span className="text-4xl font-bold">{planLimits.price}</span>
|
||||
<span className="text-gray-600 ml-2">{planLimits.period}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600">Dynamic QR Codes</span>
|
||||
<span className="font-medium">
|
||||
{usageStats.dynamicUsed} of {usageStats.dynamicLimit} used
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${Math.min(usagePercentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-600">Static QR Codes</span>
|
||||
<span className="font-medium">Unlimited ∞</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div className="bg-success-600 h-2 rounded-full" style={{ width: '100%' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{plan !== 'FREE' && (
|
||||
<div className="pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={handleManageSubscription}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Loading...' : 'Manage Subscription'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{plan === 'FREE' && (
|
||||
<div className="pt-4 border-t">
|
||||
<Button variant="primary" className="w-full" onClick={() => window.location.href = '/pricing'}>
|
||||
Upgrade Plan
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Change Password Modal */}
|
||||
<ChangePasswordModal
|
||||
isOpen={showPasswordModal}
|
||||
onClose={() => setShowPasswordModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowPasswordModal(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user