Major tier overhaul: Scout gets Portfolio+Listing, new limits, Yield live
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-17 09:16:34 +01:00
parent 1ceb6bf5a8
commit 891d17362e
9 changed files with 87 additions and 147 deletions

View File

@ -675,16 +675,18 @@ async def create_listing(
)
listing_count = user_listings.scalar() or 0
# Listing limits by tier (from pounce_pricing.md)
# Load subscription separately to avoid async lazy loading issues
from app.models.subscription import Subscription
# Listing limits by tier - using TIER_CONFIG
from app.models.subscription import Subscription, TIER_CONFIG, SubscriptionTier
sub_result = await db.execute(
select(Subscription).where(Subscription.user_id == current_user.id)
)
subscription = sub_result.scalar_one_or_none()
tier = subscription.tier if subscription else "scout"
limits = {"scout": 0, "trader": 5, "tycoon": 50}
max_listings = limits.get(tier, 0)
tier = subscription.tier if subscription else SubscriptionTier.SCOUT
tier_config = TIER_CONFIG.get(tier, TIER_CONFIG[SubscriptionTier.SCOUT])
max_listings = tier_config.get("listing_limit", 0)
# -1 means unlimited
if max_listings == -1:
max_listings = 999999
if listing_count >= max_listings:
raise HTTPException(

View File

@ -187,9 +187,10 @@ async def create_sniper_alert(
)
alert_count = user_alerts.scalar() or 0
tier = current_user.subscription.tier if current_user.subscription else "scout"
limits = {"scout": 2, "trader": 10, "tycoon": 50}
max_alerts = limits.get(tier, 2)
from app.models.subscription import TIER_CONFIG, SubscriptionTier
tier = current_user.subscription.tier if current_user.subscription else SubscriptionTier.SCOUT
tier_config = TIER_CONFIG.get(tier, TIER_CONFIG[SubscriptionTier.SCOUT])
max_alerts = tier_config.get("sniper_limit", 2)
if alert_count >= max_alerts:
raise HTTPException(

View File

@ -343,7 +343,12 @@ async def activate_domain_for_yield(
tier = subscription.tier if subscription else SubscriptionTier.SCOUT
tier_value = tier.value if hasattr(tier, "value") else str(tier)
if tier_value == "scout":
# Check if tier has yield feature
from app.models.subscription import TIER_CONFIG
tier_config = TIER_CONFIG.get(tier, {})
has_yield = tier_config.get("features", {}).get("yield", False)
if not has_yield:
raise HTTPException(
status_code=403,
detail="Yield is not available on Scout plan. Upgrade to Trader or Tycoon.",

View File

@ -12,13 +12,13 @@ class SubscriptionTier(str, Enum):
"""
Subscription tiers for pounce.ch
Scout (Free): 5 domains, daily checks, email alerts
Trader (€19/mo): 50 domains, hourly checks, portfolio, valuation
Tycoon (€49/mo): 500+ domains, 10-min checks, API, bulk tools
Scout (Free): 10 watchlist, 3 portfolio, 1 listing, daily checks
Trader ($9/mo): 100 watchlist, 50 portfolio, 10 listings, hourly checks
Tycoon ($29/mo): Unlimited, 5-min checks, API, bulk tools, exclusive drops
"""
SCOUT = "scout" # Free tier
TRADER = "trader" # €19/month
TYCOON = "tycoon" # €49/month
TRADER = "trader" # $9/month
TYCOON = "tycoon" # $29/month
class SubscriptionStatus(str, Enum):
@ -31,35 +31,42 @@ class SubscriptionStatus(str, Enum):
# Plan configuration - matches frontend pricing page
# Updated 2024: Better conversion funnel with taste-before-pay model
TIER_CONFIG = {
SubscriptionTier.SCOUT: {
"name": "Scout",
"price": 0,
"currency": "USD",
"domain_limit": 5,
"portfolio_limit": 0,
"domain_limit": 10, # Watchlist: 10 (was 5)
"portfolio_limit": 3, # Portfolio: 3 (was 0) - taste the feature
"listing_limit": 1, # Listings: 1 (was 0) - try selling
"sniper_limit": 2, # Sniper alerts
"check_frequency": "daily",
"history_days": 0,
"history_days": 7, # 7 days history (was 0)
"features": {
"email_alerts": True,
"sms_alerts": False,
"priority_alerts": False,
"full_whois": False,
"expiration_tracking": False,
"domain_valuation": False,
"domain_valuation": True, # Basic score enabled
"market_insights": False,
"api_access": False,
"webhooks": False,
"bulk_tools": False,
"seo_metrics": False,
"yield": False,
"exclusive_drops": False,
}
},
SubscriptionTier.TRADER: {
"name": "Trader",
"price": 9,
"currency": "USD",
"domain_limit": 50,
"portfolio_limit": 25,
"domain_limit": 100, # Watchlist: 100 (was 50)
"portfolio_limit": 50, # Portfolio: 50 (was 25)
"listing_limit": 10, # Listings: 10 (was 5)
"sniper_limit": 10, # Sniper alerts
"check_frequency": "hourly",
"history_days": 90,
"features": {
@ -74,15 +81,19 @@ TIER_CONFIG = {
"webhooks": False,
"bulk_tools": False,
"seo_metrics": False,
"yield": True,
"exclusive_drops": False,
}
},
SubscriptionTier.TYCOON: {
"name": "Tycoon",
"price": 29,
"currency": "USD",
"domain_limit": 500,
"portfolio_limit": -1, # Unlimited
"check_frequency": "realtime", # Every 10 minutes
"domain_limit": -1, # Unlimited watchlist
"portfolio_limit": -1, # Unlimited portfolio
"listing_limit": -1, # Unlimited listings
"sniper_limit": 50, # Sniper alerts
"check_frequency": "5min", # Every 5 minutes (was 10min)
"history_days": -1, # Unlimited
"features": {
"email_alerts": True,
@ -96,6 +107,8 @@ TIER_CONFIG = {
"webhooks": True,
"bulk_tools": True,
"seo_metrics": True,
"yield": True,
"exclusive_drops": True, # Tycoon exclusive: 24h early access
}
},
}

View File

@ -17,16 +17,16 @@ const tiers = [
icon: Zap,
price: '0',
period: '',
description: 'Recon access. No commitment.',
description: 'Taste the system. No commitment.',
features: [
{ text: 'Market Feed', highlight: false, available: true, sublabel: 'Raw' },
{ text: 'Alert Speed', highlight: false, available: true, sublabel: 'Daily' },
{ text: '5 Watchlist Domains', highlight: false, available: true },
{ text: '10 Watchlist Domains', highlight: false, available: true },
{ text: '3 Portfolio Domains', highlight: false, available: true },
{ text: '1 Listing', highlight: false, available: true, sublabel: 'Try it' },
{ text: '2 Sniper Alerts', highlight: false, available: true },
{ text: 'Pounce Score', highlight: false, available: true, sublabel: 'Basic' },
{ text: 'TLD Intel', highlight: false, available: true, sublabel: 'Public' },
{ text: 'Pounce Score', highlight: false, available: false },
{ text: 'Marketplace', highlight: false, available: true, sublabel: 'Buy Only' },
{ text: 'Yield (Beta)', highlight: false, available: false },
],
cta: 'Enter Terminal',
highlighted: false,
@ -43,13 +43,13 @@ const tiers = [
features: [
{ text: 'Market Feed', highlight: true, available: true, sublabel: 'Curated' },
{ text: 'Alert Speed', highlight: true, available: true, sublabel: 'Hourly' },
{ text: '50 Watchlist Domains', highlight: true, available: true },
{ text: '100 Watchlist Domains', highlight: true, available: true },
{ text: '50 Portfolio Domains', highlight: true, available: true },
{ text: '10 Listings', highlight: true, available: true, sublabel: '0% Fee' },
{ text: '10 Sniper Alerts', highlight: true, available: true },
{ text: 'Pounce Score', highlight: true, available: true, sublabel: 'Full' },
{ text: 'TLD Intel', highlight: true, available: true, sublabel: 'Renewal Prices' },
{ text: 'Pounce Score', highlight: true, available: true },
{ text: '5 Listings', highlight: true, available: true, sublabel: '0% Fee' },
{ text: 'Portfolio', highlight: true, available: true, sublabel: '25 Domains' },
{ text: 'Yield (Beta)', highlight: false, available: true, sublabel: 'Optional' },
{ text: 'Yield', highlight: true, available: true },
],
cta: 'Upgrade to Trader',
highlighted: true,
@ -62,17 +62,17 @@ const tiers = [
icon: Crown,
price: '29',
period: '/mo',
description: 'Full firepower. Priority alerts.',
description: 'Full firepower. No limits.',
features: [
{ text: 'Market Feed', highlight: true, available: true, sublabel: 'Priority' },
{ text: 'Alert Speed', highlight: true, available: true, sublabel: '10 min' },
{ text: '500 Watchlist Domains', highlight: true, available: true },
{ text: '50 Sniper Alerts', highlight: true, available: true },
{ text: 'TLD Intel', highlight: true, available: true, sublabel: 'Full History' },
{ text: 'Score + SEO Data', highlight: true, available: true },
{ text: '50 Listings', highlight: true, available: true, sublabel: 'Featured' },
{ text: 'Alert Speed', highlight: true, available: true, sublabel: '5 min' },
{ text: 'Unlimited Watchlist', highlight: true, available: true },
{ text: 'Unlimited Portfolio', highlight: true, available: true },
{ text: 'Yield (Beta)', highlight: false, available: true, sublabel: 'Optional' },
{ text: 'Unlimited Listings', highlight: true, available: true, sublabel: 'Featured' },
{ text: '50 Sniper Alerts', highlight: true, available: true },
{ text: 'Score + SEO Data', highlight: true, available: true },
{ text: 'API + Webhooks', highlight: true, available: true },
{ text: 'Exclusive Drops', highlight: true, available: true, sublabel: '24h Early' },
],
cta: 'Go Tycoon',
highlighted: false,
@ -82,21 +82,23 @@ const tiers = [
]
const comparisonFeatures = [
{ name: 'Market Feed', scout: 'Raw (Unfiltered)', trader: 'Curated (Spam-Free)', tycoon: 'Curated + Priority' },
{ name: 'Alert Speed', scout: 'Daily', trader: 'Hourly', tycoon: 'Every 10 minutes' },
{ name: 'Watchlist', scout: '5 Domains', trader: '50 Domains', tycoon: '500 Domains' },
{ name: 'Market Feed', scout: 'Raw', trader: 'Curated', tycoon: 'Priority + Early' },
{ name: 'Alert Speed', scout: 'Daily', trader: 'Hourly', tycoon: 'Every 5 min' },
{ name: 'Watchlist', scout: '10 Domains', trader: '100 Domains', tycoon: 'Unlimited' },
{ name: 'Portfolio', scout: '3 Domains', trader: '50 Domains', tycoon: 'Unlimited' },
{ name: 'Listings', scout: '1 (Try it)', trader: '10 (0% Fee)', tycoon: 'Unlimited + Featured' },
{ name: 'Sniper Alerts', scout: '2', trader: '10', tycoon: '50' },
{ name: 'TLD Intel', scout: 'Public Trends', trader: 'Renewal Prices', tycoon: 'Full History' },
{ name: 'Valuation', scout: 'Locked', trader: 'Pounce Score', tycoon: 'Score + SEO' },
{ name: 'Marketplace', scout: 'Buy Only', trader: '5 Listings (0% Fee)', tycoon: '50 Featured' },
{ name: 'Portfolio', scout: '—', trader: '25 Domains', tycoon: 'Unlimited' },
{ name: 'Yield (Beta)', scout: '—', trader: 'Optional', tycoon: 'Optional' },
{ name: 'Valuation', scout: 'Basic Score', trader: 'Pounce Score', tycoon: 'Score + SEO' },
{ name: 'TLD Intel', scout: 'Public', trader: 'Renewal Prices', tycoon: 'Full History' },
{ name: 'Yield', scout: '', trader: '', tycoon: '' },
{ name: 'Exclusive Drops', scout: '—', trader: '', tycoon: '24h Early Access' },
{ name: 'API Access', scout: '—', trader: '', tycoon: '' },
]
const faqs = [
{
q: 'How fast will I know when a domain drops?',
a: 'Depends on your plan. Scout: daily. Trader: hourly. Tycoon: every 10 minutes. When it drops, you\'ll know.',
a: 'Depends on your plan. Scout: daily. Trader: hourly. Tycoon: every 5 minutes. When it drops, you\'ll know.',
},
{
q: 'What\'s domain valuation?',

View File

@ -111,7 +111,8 @@ export default function InboxPage() {
const tierName = subscription?.tier_name || subscription?.tier || 'Scout'
const TierIcon = tierName === 'Tycoon' ? Crown : tierName === 'Trader' ? TrendingUp : Zap
const isSeller = tierName !== 'Scout' // Scout can't list domains
// All tiers can now list domains (Scout=1, Trader=10, Tycoon=unlimited)
const isSeller = true
// Load buyer threads
const loadBuyerThreads = useCallback(async () => {

View File

@ -75,11 +75,11 @@ export default function MyListingsPage() {
const [menuOpen, setMenuOpen] = useState(false)
const tier = subscription?.tier || 'scout'
const isScout = tier === 'scout'
const listingLimits: Record<string, number> = { scout: 0, trader: 5, tycoon: 50 }
const maxListings = listingLimits[tier] || 0
const canAddMore = listings.length < maxListings && !isScout
const listingLimits: Record<string, number> = { scout: 1, trader: 10, tycoon: 999999 }
const maxListings = listingLimits[tier] || 1
const canAddMore = listings.length < maxListings
const isTycoon = tier === 'tycoon'
const isUnlimited = tier === 'tycoon'
const activeListings = listings.filter(l => l.status === 'active').length
const draftListings = listings.filter(l => l.status === 'draft').length
@ -87,20 +87,16 @@ export default function MyListingsPage() {
const totalInquiries = listings.reduce((sum, l) => sum + l.inquiry_count, 0)
useEffect(() => { checkAuth() }, [checkAuth])
useEffect(() => { if (prefillDomain && !isScout) setShowCreateWizard(true) }, [prefillDomain, isScout])
useEffect(() => { if (prefillDomain) setShowCreateWizard(true) }, [prefillDomain])
const loadListings = useCallback(async () => {
if (isScout) {
setLoading(false)
return
}
setLoading(true)
try {
const data = await api.getMyListings()
setListings(data)
} catch (err) { console.error(err) }
finally { setLoading(false) }
}, [isScout])
}, [])
useEffect(() => { loadListings() }, [loadListings])
@ -153,68 +149,7 @@ export default function MyListingsPage() {
]
// ============================================================================
// SCOUT UPGRADE PROMPT (Feature not available for free tier)
// ============================================================================
if (isScout) {
return (
<div className="min-h-screen bg-[#020202]">
<div className="hidden lg:block"><Sidebar /></div>
<main className="lg:pl-[240px]">
<div className="min-h-screen flex items-center justify-center p-8">
<div className="max-w-md text-center">
<div className="w-20 h-20 bg-amber-400/10 border border-amber-400/20 flex items-center justify-center mx-auto mb-6">
<Lock className="w-10 h-10 text-amber-400" />
</div>
<h1 className="font-display text-3xl text-white mb-4">For Sale</h1>
<p className="text-white/50 text-sm font-mono mb-2">
List domains on Pounce Direct.
</p>
<p className="text-white/30 text-xs font-mono mb-8">
0% commission. DNS-verified ownership. Direct buyer contact.
</p>
<div className="bg-white/[0.02] border border-white/[0.08] p-6 mb-6">
<div className="flex items-center justify-between mb-4 pb-4 border-b border-white/[0.08]">
<div className="flex items-center gap-3">
<TrendingUp className="w-5 h-5 text-accent" />
<span className="text-sm font-bold text-white">Trader</span>
</div>
<span className="text-accent font-mono text-sm">$9/mo</span>
</div>
<ul className="text-left space-y-2 text-sm text-white/60 mb-4">
<li className="flex items-center gap-2"><CheckCircle className="w-4 h-4 text-accent" />5 Active Listings</li>
<li className="flex items-center gap-2"><CheckCircle className="w-4 h-4 text-accent" />DNS Ownership Verification</li>
<li className="flex items-center gap-2"><CheckCircle className="w-4 h-4 text-accent" />Direct Buyer Contact</li>
</ul>
</div>
<div className="bg-white/[0.02] border border-amber-400/20 p-6 mb-8">
<div className="flex items-center justify-between mb-4 pb-4 border-b border-white/[0.08]">
<div className="flex items-center gap-3">
<Crown className="w-5 h-5 text-amber-400" />
<span className="text-sm font-bold text-white">Tycoon</span>
</div>
<span className="text-amber-400 font-mono text-sm">$29/mo</span>
</div>
<ul className="text-left space-y-2 text-sm text-white/60">
<li className="flex items-center gap-2"><CheckCircle className="w-4 h-4 text-amber-400" />50 Active Listings</li>
<li className="flex items-center gap-2"><CheckCircle className="w-4 h-4 text-amber-400" />Featured Placement</li>
<li className="flex items-center gap-2"><CheckCircle className="w-4 h-4 text-amber-400" />Priority in Market Feed</li>
</ul>
</div>
<Link href="/pricing" className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-black text-xs font-bold uppercase tracking-wider hover:bg-white transition-colors">
<Sparkles className="w-4 h-4" />Upgrade
</Link>
</div>
</div>
</main>
</div>
)
}
// ============================================================================
// MAIN LISTING VIEW (For Trader & Tycoon)
// MAIN LISTING VIEW (All tiers - Scout has 1 listing, Trader 10, Tycoon unlimited)
// ============================================================================
return (
<div className="min-h-screen bg-[#020202]">
@ -229,7 +164,7 @@ export default function MyListingsPage() {
<div className="w-1.5 h-1.5 bg-accent animate-pulse" />
<span className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">For Sale</span>
</div>
<span className="text-[10px] font-mono text-white/40">{listings.length}/{maxListings}</span>
<span className="text-[10px] font-mono text-white/40">{listings.length}{isUnlimited ? '' : `/${maxListings}`}</span>
</div>
<div className="grid grid-cols-4 gap-2">
<div className="bg-accent/[0.05] border border-accent/20 p-2">

View File

@ -84,6 +84,7 @@ export default function SniperAlertsPage() {
const tier = subscription?.tier || 'scout'
const alertLimits: Record<string, number> = { scout: 2, trader: 10, tycoon: 50 }
const maxAlerts = alertLimits[tier] || 2
// Limits match backend TIER_CONFIG.sniper_limit
const canAddMore = alerts.length < maxAlerts
const activeAlerts = alerts.filter(a => a.is_active).length

View File

@ -418,28 +418,8 @@ export default function YieldPage() {
</div>
</header>
{/* COMING SOON BANNER */}
<section className="px-4 lg:px-10 pt-4 lg:pt-10">
<div className="bg-amber-400/5 border border-amber-400/20 p-4 flex items-start gap-4">
<div className="w-10 h-10 bg-amber-400/10 border border-amber-400/20 flex items-center justify-center shrink-0">
<Sparkles className="w-5 h-5 text-amber-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-sm font-bold text-amber-400">Feature in Development</h3>
<span className="px-2 py-0.5 bg-amber-400/20 text-amber-400 text-[9px] font-mono uppercase tracking-wider">Coming Soon</span>
</div>
<p className="text-xs text-white/50 font-mono leading-relaxed">
We're building an automated yield system for your parked domains.
Soon you'll be able to monetize idle domains with intelligent traffic routing.
Stay tuned for updates!
</p>
</div>
</div>
</section>
{/* DESKTOP HEADER */}
<section className="hidden lg:block px-10 pt-6 pb-6">
<section className="hidden lg:block px-10 pt-10 pb-6">
<div className="flex items-end justify-between gap-6">
<div className="space-y-3">
<div className="flex items-center gap-2">