This commit is contained in:
Timo Knuth
2025-10-18 17:55:32 +02:00
parent 254e6490b8
commit 91b78cb284
65 changed files with 4481 additions and 1078 deletions

View File

@@ -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,

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

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

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

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

View File

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