QR-master/src/app/(app)/settings/page.tsx

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