feat: Ultimate plan switcher in settings
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled

This commit is contained in:
2025-12-13 16:43:32 +01:00
parent 83aaca0721
commit 017b4ce1f9

View File

@ -22,12 +22,93 @@ import {
TrendingUp,
X,
Settings,
ArrowUp,
ArrowDown,
Sparkles,
Clock,
Target,
BarChart3,
MessageSquare,
Database,
} from 'lucide-react'
import Link from 'next/link'
import clsx from 'clsx'
type SettingsTab = 'profile' | 'notifications' | 'billing' | 'security'
// Plan definitions
const PLANS = [
{
id: 'scout',
name: 'Scout',
icon: Zap,
price: 0,
period: 'forever',
description: 'Perfect for getting started',
color: 'white',
features: [
{ icon: Target, text: '5 Watchlist Domains' },
{ icon: Clock, text: 'Daily Checks' },
{ icon: Bell, text: '2 Sniper Alerts' },
{ icon: BarChart3, text: 'Basic Market Data' },
],
limits: {
domains: 5,
alerts: 2,
listings: 0,
portfolio: 0,
}
},
{
id: 'trader',
name: 'Trader',
icon: TrendingUp,
price: 9,
period: '/month',
description: 'For serious domain investors',
color: 'accent',
badge: 'Popular',
features: [
{ icon: Target, text: '50 Watchlist Domains' },
{ icon: Clock, text: 'Hourly Checks' },
{ icon: Bell, text: '10 Sniper Alerts' },
{ icon: Sparkles, text: 'Domain Valuation' },
{ icon: Database, text: '25 Portfolio Domains' },
{ icon: MessageSquare, text: '5 Marketplace Listings' },
],
limits: {
domains: 50,
alerts: 10,
listings: 5,
portfolio: 25,
}
},
{
id: 'tycoon',
name: 'Tycoon',
icon: Crown,
price: 29,
period: '/month',
description: 'Maximum power & control',
color: 'amber',
badge: 'Full Power',
features: [
{ icon: Target, text: '500 Watchlist Domains' },
{ icon: Clock, text: '10-Minute Checks' },
{ icon: Bell, text: '50 Sniper Alerts' },
{ icon: Sparkles, text: 'Full SEO Metrics' },
{ icon: Database, text: 'Unlimited Portfolio' },
{ icon: MessageSquare, text: '50 Featured Listings' },
],
limits: {
domains: 500,
alerts: 50,
listings: 50,
portfolio: -1,
}
},
]
export default function SettingsPage() {
const router = useRouter()
const { user, isAuthenticated, isLoading, checkAuth, subscription } = useStore()
@ -52,6 +133,7 @@ export default function SettingsPage() {
const [priceAlerts, setPriceAlerts] = useState<PriceAlert[]>([])
const [loadingAlerts, setLoadingAlerts] = useState(false)
const [deletingAlertId, setDeletingAlertId] = useState<number | null>(null)
const [changingPlan, setChangingPlan] = useState<string | null>(null)
useEffect(() => {
checkAuth()
@ -153,6 +235,52 @@ export default function SettingsPage() {
}
}
const handlePlanChange = async (planId: string) => {
setChangingPlan(planId)
setError(null)
try {
if (planId === 'scout') {
// Downgrade to free - cancel subscription
await api.cancelSubscription()
setSuccess('Downgraded to Scout. Your premium features will remain active until the end of your billing period.')
await checkAuth()
} else {
// Upgrade to paid plan
const successUrl = `${window.location.origin}/terminal/settings?upgraded=${planId}`
const cancelUrl = `${window.location.origin}/terminal/settings?cancelled=true`
const { checkout_url } = await api.createCheckoutSession(planId, successUrl, cancelUrl)
window.location.href = checkout_url
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to change plan')
} finally {
setChangingPlan(null)
}
}
// Handle URL params for upgrade success/cancel
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const upgraded = params.get('upgraded')
const cancelled = params.get('cancelled')
if (upgraded) {
setSuccess(`🎉 Welcome to ${upgraded.charAt(0).toUpperCase() + upgraded.slice(1)}! Your account has been upgraded.`)
setActiveTab('billing')
// Clean URL
window.history.replaceState({}, '', '/terminal/settings')
// Refresh subscription data
checkAuth()
}
if (cancelled) {
setError('Checkout was cancelled. No changes were made.')
setActiveTab('billing')
window.history.replaceState({}, '', '/terminal/settings')
}
}, [])
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-[#020202]">
@ -429,85 +557,208 @@ export default function SettingsPage() {
{/* Billing Tab */}
{activeTab === 'billing' && (
<div className="space-y-6">
{/* Plan Switcher */}
<div className="border border-white/[0.08] bg-white/[0.01] p-6">
<h2 className="text-sm font-mono text-white/40 tracking-wide mb-6">Current Plan</h2>
<div className="p-5 bg-accent/5 border border-accent/20 mb-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
{tierName === 'Tycoon' ? <Crown className="w-6 h-6 text-accent" /> :
tierName === 'Trader' ? <TrendingUp className="w-6 h-6 text-accent" /> :
<Zap className="w-6 h-6 text-accent" />}
<div>
<p className="text-lg font-display text-white">{tierName}</p>
<p className="text-xs text-white/40">
{tierName === 'Scout' ? 'Free forever' : tierName === 'Trader' ? '$9/month' : '$29/month'}
</p>
</div>
</div>
<span className={clsx(
"px-2 py-1 text-[10px] font-mono",
isProOrHigher ? "bg-accent/10 text-accent" : "bg-white/5 text-white/40"
)}>
{isProOrHigher ? 'Active' : 'Free'}
</span>
</div>
{/* Plan Stats */}
<div className="grid grid-cols-3 gap-4 p-4 bg-black/30 mb-4">
<div className="text-center">
<p className="text-xl font-display text-white">{subscription?.domain_limit || 5}</p>
<p className="text-[10px] text-white/40 font-mono">Domains</p>
</div>
<div className="text-center border-x border-white/10">
<p className="text-xl font-display text-white">
{subscription?.check_frequency === 'realtime' ? '10m' :
subscription?.check_frequency === 'hourly' ? '1h' : '24h'}
</p>
<p className="text-[10px] text-white/40 font-mono">Interval</p>
</div>
<div className="text-center">
<p className="text-xl font-display text-white">
{subscription?.portfolio_limit === -1 ? '∞' : subscription?.portfolio_limit || 0}
</p>
<p className="text-[10px] text-white/40 font-mono">Portfolio</p>
</div>
</div>
{isProOrHigher ? (
<div className="flex items-center justify-between mb-6">
<h2 className="text-sm font-mono text-white/40 tracking-wide">Choose Your Plan</h2>
{isProOrHigher && (
<button
onClick={handleOpenBillingPortal}
className="w-full flex items-center justify-center gap-2 py-3 bg-black/50 text-white text-sm font-medium border border-white/10 hover:border-white/20 transition-all"
className="flex items-center gap-2 px-3 py-1.5 text-[10px] font-mono text-white/50 border border-white/10 hover:border-white/20 hover:text-white transition-all"
>
<ExternalLink className="w-4 h-4" />
Manage Subscription
<ExternalLink className="w-3 h-3" />
Manage Billing
</button>
) : (
<Link
href="/pricing"
className="w-full flex items-center justify-center gap-2 py-3 bg-accent text-black text-sm font-semibold hover:bg-white transition-all"
>
<Zap className="w-4 h-4" />
Upgrade Plan
</Link>
)}
</div>
{/* Plan Features */}
<h3 className="text-xs font-mono text-white/40 mb-3">Includes</h3>
<ul className="grid grid-cols-2 gap-2">
{[
`${subscription?.domain_limit || 5} Watchlist Domains`,
`${subscription?.check_frequency === 'realtime' ? '10-minute' : subscription?.check_frequency === 'hourly' ? 'Hourly' : 'Daily'} Scans`,
'Email Alerts',
'TLD Price Data',
].map((feature) => (
<li key={feature} className="flex items-center gap-2 text-xs">
<Check className="w-3 h-3 text-accent" />
<span className="text-white/60">{feature}</span>
</li>
))}
</ul>
{/* Plan Cards */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{PLANS.map((plan) => {
const isCurrent = tierName.toLowerCase() === plan.id
const isUpgrade =
(tierName === 'Scout' && (plan.id === 'trader' || plan.id === 'tycoon')) ||
(tierName === 'Trader' && plan.id === 'tycoon')
const isDowngrade =
(tierName === 'Tycoon' && (plan.id === 'trader' || plan.id === 'scout')) ||
(tierName === 'Trader' && plan.id === 'scout')
return (
<div
key={plan.id}
className={clsx(
"relative p-5 border transition-all",
isCurrent
? "border-accent bg-accent/5"
: "border-white/[0.08] bg-white/[0.01] hover:border-white/20"
)}
>
{/* Badge */}
{plan.badge && (
<div className={clsx(
"absolute -top-2.5 left-4 px-2 py-0.5 text-[9px] font-mono uppercase tracking-wider",
plan.id === 'trader' ? "bg-accent text-black" : "bg-amber-500 text-black"
)}>
{plan.badge}
</div>
)}
{/* Current Badge */}
{isCurrent && (
<div className="absolute -top-2.5 right-4 px-2 py-0.5 text-[9px] font-mono uppercase tracking-wider bg-white text-black">
Current
</div>
)}
{/* Header */}
<div className="flex items-center gap-3 mb-4 mt-2">
<div className={clsx(
"w-10 h-10 flex items-center justify-center border",
plan.id === 'tycoon' ? "border-amber-500/30 bg-amber-500/10" :
plan.id === 'trader' ? "border-accent/30 bg-accent/10" :
"border-white/10 bg-white/5"
)}>
<plan.icon className={clsx(
"w-5 h-5",
plan.id === 'tycoon' ? "text-amber-400" :
plan.id === 'trader' ? "text-accent" :
"text-white/60"
)} />
</div>
<div>
<h3 className="text-lg font-display text-white">{plan.name}</h3>
<p className="text-[10px] text-white/40">{plan.description}</p>
</div>
</div>
{/* Price */}
<div className="flex items-baseline gap-1 mb-5">
{plan.price === 0 ? (
<span className="text-3xl font-mono text-white">Free</span>
) : (
<>
<span className="text-3xl font-mono text-white">${plan.price}</span>
<span className="text-sm text-white/40">{plan.period}</span>
</>
)}
</div>
{/* Features */}
<ul className="space-y-2 mb-5">
{plan.features.map((feature, idx) => (
<li key={idx} className="flex items-center gap-2">
<feature.icon className="w-3.5 h-3.5 text-accent shrink-0" />
<span className="text-xs text-white/70">{feature.text}</span>
</li>
))}
</ul>
{/* Action Button */}
{isCurrent ? (
<div className="w-full py-3 text-center text-sm font-mono text-accent border border-accent/30 bg-accent/5">
<Check className="w-4 h-4 inline mr-2" />
Active Plan
</div>
) : isUpgrade ? (
<button
onClick={() => handlePlanChange(plan.id)}
disabled={changingPlan === plan.id}
className="w-full flex items-center justify-center gap-2 py-3 bg-accent text-black text-sm font-semibold hover:bg-white disabled:opacity-50 transition-all"
>
{changingPlan === plan.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
<ArrowUp className="w-4 h-4" />
Upgrade to {plan.name}
</>
)}
</button>
) : isDowngrade ? (
<button
onClick={() => handlePlanChange(plan.id)}
disabled={changingPlan === plan.id}
className="w-full flex items-center justify-center gap-2 py-3 text-white/60 text-sm font-medium border border-white/10 hover:border-white/30 hover:text-white disabled:opacity-50 transition-all"
>
{changingPlan === plan.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
<ArrowDown className="w-4 h-4" />
Downgrade
</>
)}
</button>
) : null}
</div>
)
})}
</div>
</div>
{/* Usage Stats */}
<div className="border border-white/[0.08] bg-white/[0.01] p-6">
<h2 className="text-sm font-mono text-white/40 tracking-wide mb-4">Current Usage</h2>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="p-4 bg-white/[0.02] border border-white/[0.06]">
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] font-mono text-white/40">Watchlist</span>
<Target className="w-3.5 h-3.5 text-accent" />
</div>
<p className="text-xl font-mono text-white">
{subscription?.domains_used || 0}
<span className="text-white/30">/{subscription?.domain_limit || 5}</span>
</p>
<div className="mt-2 h-1 bg-white/10 overflow-hidden">
<div
className="h-full bg-accent transition-all"
style={{ width: `${Math.min(100, ((subscription?.domains_used || 0) / (subscription?.domain_limit || 5)) * 100)}%` }}
/>
</div>
</div>
<div className="p-4 bg-white/[0.02] border border-white/[0.06]">
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] font-mono text-white/40">Check Speed</span>
<Clock className="w-3.5 h-3.5 text-accent" />
</div>
<p className="text-xl font-mono text-white">
{subscription?.check_frequency === 'realtime' ? '10m' :
subscription?.check_frequency === 'hourly' ? '1h' : '24h'}
</p>
<p className="text-[10px] text-white/30 mt-1">Interval</p>
</div>
<div className="p-4 bg-white/[0.02] border border-white/[0.06]">
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] font-mono text-white/40">Portfolio</span>
<Database className="w-3.5 h-3.5 text-accent" />
</div>
<p className="text-xl font-mono text-white">
{subscription?.portfolio_limit === -1 ? '∞' : subscription?.portfolio_limit || 0}
</p>
<p className="text-[10px] text-white/30 mt-1">Slots</p>
</div>
<div className="p-4 bg-white/[0.02] border border-white/[0.06]">
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] font-mono text-white/40">Status</span>
<Shield className="w-3.5 h-3.5 text-accent" />
</div>
<p className="text-xl font-mono text-accent capitalize">
{subscription?.status || 'active'}
</p>
<p className="text-[10px] text-white/30 mt-1">Subscription</p>
</div>
</div>
</div>
{/* Help Text */}
<div className="text-center py-4">
<p className="text-xs text-white/30">
Need help? <Link href="/contact" className="text-accent hover:underline">Contact support</Link>
</p>
</div>
</div>
)}