feat: add subdomain management, comprehensive QR code creation/redirection, and dashboard UI with white-label support.
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user