feat: add subdomain management, comprehensive QR code creation/redirection, and dashboard UI with white-label support.

This commit is contained in:
Timo
2026-01-07 18:33:27 +01:00
parent b217e50d9f
commit af2d8f1e8f
9 changed files with 580 additions and 25 deletions

View File

@@ -4,11 +4,12 @@ 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';
type TabType = 'profile' | 'subscription' | 'whitelabel';
export default function SettingsPage() {
const { fetchWithCsrf } = useCsrf();
@@ -28,6 +29,11 @@ export default function SettingsPage() {
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 () => {
@@ -53,6 +59,14 @@ export default function SettingsPage() {
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);
}
@@ -185,24 +199,31 @@ export default function SettingsPage() {
<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'
}`}
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'
}`}
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>
@@ -373,6 +394,143 @@ export default function SettingsPage() {
</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}