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
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:
@ -22,12 +22,93 @@ import {
|
|||||||
TrendingUp,
|
TrendingUp,
|
||||||
X,
|
X,
|
||||||
Settings,
|
Settings,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
Sparkles,
|
||||||
|
Clock,
|
||||||
|
Target,
|
||||||
|
BarChart3,
|
||||||
|
MessageSquare,
|
||||||
|
Database,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
type SettingsTab = 'profile' | 'notifications' | 'billing' | 'security'
|
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() {
|
export default function SettingsPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { user, isAuthenticated, isLoading, checkAuth, subscription } = useStore()
|
const { user, isAuthenticated, isLoading, checkAuth, subscription } = useStore()
|
||||||
@ -52,6 +133,7 @@ export default function SettingsPage() {
|
|||||||
const [priceAlerts, setPriceAlerts] = useState<PriceAlert[]>([])
|
const [priceAlerts, setPriceAlerts] = useState<PriceAlert[]>([])
|
||||||
const [loadingAlerts, setLoadingAlerts] = useState(false)
|
const [loadingAlerts, setLoadingAlerts] = useState(false)
|
||||||
const [deletingAlertId, setDeletingAlertId] = useState<number | null>(null)
|
const [deletingAlertId, setDeletingAlertId] = useState<number | null>(null)
|
||||||
|
const [changingPlan, setChangingPlan] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkAuth()
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-[#020202]">
|
<div className="min-h-screen flex items-center justify-center bg-[#020202]">
|
||||||
@ -429,85 +557,208 @@ export default function SettingsPage() {
|
|||||||
{/* Billing Tab */}
|
{/* Billing Tab */}
|
||||||
{activeTab === 'billing' && (
|
{activeTab === 'billing' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Plan Switcher */}
|
||||||
<div className="border border-white/[0.08] bg-white/[0.01] p-6">
|
<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="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-sm font-mono text-white/40 tracking-wide">Choose Your Plan</h2>
|
||||||
<div className="p-5 bg-accent/5 border border-accent/20 mb-6">
|
{isProOrHigher && (
|
||||||
<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 ? (
|
|
||||||
<button
|
<button
|
||||||
onClick={handleOpenBillingPortal}
|
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" />
|
<ExternalLink className="w-3 h-3" />
|
||||||
Manage Subscription
|
Manage Billing
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Plan Features */}
|
{/* Plan Cards */}
|
||||||
<h3 className="text-xs font-mono text-white/40 mb-3">Includes</h3>
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
<ul className="grid grid-cols-2 gap-2">
|
{PLANS.map((plan) => {
|
||||||
{[
|
const isCurrent = tierName.toLowerCase() === plan.id
|
||||||
`${subscription?.domain_limit || 5} Watchlist Domains`,
|
const isUpgrade =
|
||||||
`${subscription?.check_frequency === 'realtime' ? '10-minute' : subscription?.check_frequency === 'hourly' ? 'Hourly' : 'Daily'} Scans`,
|
(tierName === 'Scout' && (plan.id === 'trader' || plan.id === 'tycoon')) ||
|
||||||
'Email Alerts',
|
(tierName === 'Trader' && plan.id === 'tycoon')
|
||||||
'TLD Price Data',
|
const isDowngrade =
|
||||||
].map((feature) => (
|
(tierName === 'Tycoon' && (plan.id === 'trader' || plan.id === 'scout')) ||
|
||||||
<li key={feature} className="flex items-center gap-2 text-xs">
|
(tierName === 'Trader' && plan.id === 'scout')
|
||||||
<Check className="w-3 h-3 text-accent" />
|
|
||||||
<span className="text-white/60">{feature}</span>
|
return (
|
||||||
</li>
|
<div
|
||||||
))}
|
key={plan.id}
|
||||||
</ul>
|
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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user