545 lines
19 KiB
TypeScript
545 lines
19 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { Badge } from '@/components/ui/Badge';
|
|
import { Input } from '@/components/ui/Input';
|
|
import { useCsrf } from '@/hooks/useCsrf';
|
|
import { showToast } from '@/components/ui/Toast';
|
|
import ChangePasswordModal from '@/components/settings/ChangePasswordModal';
|
|
|
|
type TabType = 'profile' | 'subscription' | 'whitelabel';
|
|
|
|
export default function SettingsPage() {
|
|
const { fetchWithCsrf } = useCsrf();
|
|
const [activeTab, setActiveTab] = useState<TabType>('profile');
|
|
const [loading, setLoading] = useState(false);
|
|
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
|
|
|
// Profile states
|
|
const [name, setName] = useState('');
|
|
const [email, setEmail] = useState('');
|
|
|
|
// Subscription states
|
|
const [plan, setPlan] = useState('FREE');
|
|
const [usageStats, setUsageStats] = useState({
|
|
dynamicUsed: 0,
|
|
dynamicLimit: 3,
|
|
staticUsed: 0,
|
|
});
|
|
|
|
// White Label Subdomain states
|
|
const [subdomain, setSubdomain] = useState('');
|
|
const [savedSubdomain, setSavedSubdomain] = useState<string | null>(null);
|
|
const [subdomainLoading, setSubdomainLoading] = useState(false);
|
|
|
|
// Load user data
|
|
useEffect(() => {
|
|
const fetchUserData = async () => {
|
|
try {
|
|
// Load from localStorage
|
|
const userStr = localStorage.getItem('user');
|
|
if (userStr) {
|
|
const user = JSON.parse(userStr);
|
|
setName(user.name || '');
|
|
setEmail(user.email || '');
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
// Fetch subdomain
|
|
const subdomainResponse = await fetch('/api/user/subdomain');
|
|
if (subdomainResponse.ok) {
|
|
const data = await subdomainResponse.json();
|
|
setSavedSubdomain(data.subdomain);
|
|
setSubdomain(data.subdomain || '');
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load user data:', e);
|
|
}
|
|
};
|
|
|
|
fetchUserData();
|
|
}, []);
|
|
|
|
const handleSaveProfile = async () => {
|
|
setLoading(true);
|
|
|
|
try {
|
|
// Save to backend API
|
|
const response = await fetchWithCsrf('/api/user/profile', {
|
|
method: 'PATCH',
|
|
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');
|
|
if (userStr) {
|
|
const user = JSON.parse(userStr);
|
|
user.name = name;
|
|
localStorage.setItem('user', JSON.stringify(user));
|
|
}
|
|
|
|
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 fetchWithCsrf('/api/stripe/portal', {
|
|
method: 'POST',
|
|
});
|
|
|
|
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 fetchWithCsrf('/api/user/delete', {
|
|
method: 'DELETE',
|
|
});
|
|
|
|
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-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>
|
|
|
|
{/* 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'
|
|
}`}
|
|
>
|
|
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>
|
|
<button
|
|
onClick={() => setActiveTab('whitelabel')}
|
|
className={`py-4 px-1 border-b-2 font-medium text-sm ${activeTab === 'whitelabel'
|
|
? 'border-primary-500 text-primary-600'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
|
}`}
|
|
>
|
|
White Label
|
|
</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={() => window.location.href = '/pricing'}
|
|
>
|
|
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>
|
|
)}
|
|
|
|
{activeTab === 'whitelabel' && (
|
|
<div className="space-y-6">
|
|
{/* White Label Subdomain */}
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle>White Label Subdomain</CardTitle>
|
|
<Badge variant="success">FREE</Badge>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<p className="text-gray-600 text-sm">
|
|
Create your own branded QR code URL. Your QR codes will be accessible via your custom subdomain.
|
|
</p>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
value={subdomain}
|
|
onChange={(e) => setSubdomain(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
|
|
placeholder="your-brand"
|
|
className="flex-1 max-w-xs"
|
|
/>
|
|
<span className="text-gray-600 font-medium">.qrmaster.net</span>
|
|
</div>
|
|
|
|
<div className="text-sm text-gray-500">
|
|
<ul className="list-disc list-inside space-y-1">
|
|
<li>3-30 characters</li>
|
|
<li>Only lowercase letters, numbers, and hyphens</li>
|
|
<li>Cannot start or end with a hyphen</li>
|
|
</ul>
|
|
</div>
|
|
|
|
{savedSubdomain && (
|
|
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
|
|
<p className="text-green-800 font-medium">
|
|
✅ Your white label URL is active:
|
|
</p>
|
|
<a
|
|
href={`https://${savedSubdomain}.qrmaster.net`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-green-700 underline"
|
|
>
|
|
https://{savedSubdomain}.qrmaster.net
|
|
</a>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-3">
|
|
<Button
|
|
onClick={async () => {
|
|
if (!subdomain.trim()) {
|
|
showToast('Please enter a subdomain', 'error');
|
|
return;
|
|
}
|
|
setSubdomainLoading(true);
|
|
try {
|
|
const response = await fetchWithCsrf('/api/user/subdomain', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ subdomain: subdomain.trim().toLowerCase() }),
|
|
});
|
|
const data = await response.json();
|
|
if (response.ok) {
|
|
setSavedSubdomain(subdomain.trim().toLowerCase());
|
|
showToast('Subdomain saved successfully!', 'success');
|
|
} else {
|
|
showToast(data.error || 'Error saving subdomain', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Error saving subdomain', 'error');
|
|
} finally {
|
|
setSubdomainLoading(false);
|
|
}
|
|
}}
|
|
loading={subdomainLoading}
|
|
disabled={!subdomain.trim() || subdomain === savedSubdomain}
|
|
>
|
|
{savedSubdomain ? 'Update Subdomain' : 'Save Subdomain'}
|
|
</Button>
|
|
{savedSubdomain && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={async () => {
|
|
setSubdomainLoading(true);
|
|
try {
|
|
const response = await fetchWithCsrf('/api/user/subdomain', {
|
|
method: 'DELETE',
|
|
});
|
|
if (response.ok) {
|
|
setSavedSubdomain(null);
|
|
setSubdomain('');
|
|
showToast('Subdomain removed', 'success');
|
|
}
|
|
} catch (error) {
|
|
showToast('Error removing subdomain', 'error');
|
|
} finally {
|
|
setSubdomainLoading(false);
|
|
}
|
|
}}
|
|
disabled={subdomainLoading}
|
|
>
|
|
Remove
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* How it works */}
|
|
{savedSubdomain && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>How it works</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4 text-sm">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="p-3 bg-gray-100 rounded-lg">
|
|
<p className="text-gray-500 mb-1">Before (default)</p>
|
|
<code className="text-gray-800">qrmaster.net/r/your-qr</code>
|
|
</div>
|
|
<div className="p-3 bg-primary-50 rounded-lg border border-primary-200">
|
|
<p className="text-primary-600 mb-1">After (your brand)</p>
|
|
<code className="text-primary-800">{savedSubdomain}.qrmaster.net/r/your-qr</code>
|
|
</div>
|
|
</div>
|
|
<p className="text-gray-600">
|
|
All your QR codes will work with both URLs. Share the branded version with your clients!
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Change Password Modal */}
|
|
<ChangePasswordModal
|
|
isOpen={showPasswordModal}
|
|
onClose={() => setShowPasswordModal(false)}
|
|
onSuccess={() => {
|
|
setShowPasswordModal(false);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|