🚀 Implement tier-based scan intervals & improve Settings page
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

Backend (Scheduler):
- Add check_domains_by_frequency() for tier-based scanning
- Scout: Daily checks (at configured hour)
- Trader: Hourly checks (every :00)
- Tycoon: 10-minute real-time checks
- Smart tier filtering to avoid duplicate checks

Frontend (Pricing):
- All feature text now white (text-foreground)

Frontend (Settings/Billing):
- Show current plan with visual stats (domains, interval, portfolio)
- Display check frequency in human-readable format
- Full plan comparison table
- Green checkmarks for active features
- Upgrade CTA for free users
This commit is contained in:
yves.gugger
2025-12-09 17:25:44 +01:00
parent c9e30c951e
commit 772dd4a5af
3 changed files with 255 additions and 83 deletions

View File

@ -11,6 +11,7 @@ from app.config import get_settings
from app.database import AsyncSessionLocal from app.database import AsyncSessionLocal
from app.models.domain import Domain, DomainCheck from app.models.domain import Domain, DomainCheck
from app.models.user import User from app.models.user import User
from app.models.subscription import Subscription, SubscriptionTier, TIER_CONFIG
from app.services.domain_checker import domain_checker from app.services.domain_checker import domain_checker
from app.services.email_service import email_service from app.services.email_service import email_service
from app.services.price_tracker import price_tracker from app.services.price_tracker import price_tracker
@ -44,14 +45,38 @@ async def scrape_tld_prices():
logger.exception(f"TLD price scrape failed: {e}") logger.exception(f"TLD price scrape failed: {e}")
async def check_all_domains(): async def check_domains_by_frequency(frequency: str):
"""Check availability of all monitored domains.""" """Check availability of domains based on their subscription frequency.
logger.info("Starting daily domain check...")
Args:
frequency: One of 'daily', 'hourly', 'realtime' (10-min)
"""
logger.info(f"Starting {frequency} domain check...")
start_time = datetime.utcnow() start_time = datetime.utcnow()
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
# Get all domains # Get users with matching check frequency
result = await db.execute(select(Domain)) tiers_for_frequency = []
for tier, config in TIER_CONFIG.items():
if config['check_frequency'] == frequency:
tiers_for_frequency.append(tier)
# Realtime includes hourly and daily too (more frequent = superset)
elif frequency == 'realtime':
tiers_for_frequency.append(tier)
elif frequency == 'hourly' and config['check_frequency'] in ['hourly', 'realtime']:
tiers_for_frequency.append(tier)
# Get domains from users with matching subscription tier
from sqlalchemy.orm import joinedload
result = await db.execute(
select(Domain)
.join(User, Domain.user_id == User.id)
.outerjoin(Subscription, Subscription.user_id == User.id)
.where(
(Subscription.tier.in_(tiers_for_frequency)) |
(Subscription.id.is_(None) & (frequency == 'daily')) # Scout users (no subscription) = daily
)
)
domains = result.scalars().all() domains = result.scalars().all()
logger.info(f"Checking {len(domains)} domains...") logger.info(f"Checking {len(domains)} domains...")
@ -112,14 +137,47 @@ async def check_all_domains():
await send_domain_availability_alerts(db, newly_available) await send_domain_availability_alerts(db, newly_available)
async def check_all_domains():
"""Legacy function - checks all domains (daily)."""
await check_domains_by_frequency('daily')
async def check_hourly_domains():
"""Check domains for Trader users (hourly)."""
await check_domains_by_frequency('hourly')
async def check_realtime_domains():
"""Check domains for Tycoon users (every 10 minutes)."""
await check_domains_by_frequency('realtime')
def setup_scheduler(): def setup_scheduler():
"""Configure and start the scheduler.""" """Configure and start the scheduler."""
# Daily domain check at configured hour # Daily domain check for Scout users at configured hour
scheduler.add_job( scheduler.add_job(
check_all_domains, check_all_domains,
CronTrigger(hour=settings.check_hour, minute=settings.check_minute), CronTrigger(hour=settings.check_hour, minute=settings.check_minute),
id="daily_domain_check", id="daily_domain_check",
name="Daily Domain Availability Check", name="Daily Domain Check (Scout)",
replace_existing=True,
)
# Hourly domain check for Trader users
scheduler.add_job(
check_hourly_domains,
CronTrigger(minute=0), # Every hour at :00
id="hourly_domain_check",
name="Hourly Domain Check (Trader)",
replace_existing=True,
)
# 10-minute domain check for Tycoon users
scheduler.add_job(
check_realtime_domains,
CronTrigger(minute='*/10'), # Every 10 minutes
id="realtime_domain_check",
name="10-Minute Domain Check (Tycoon)",
replace_existing=True, replace_existing=True,
) )
@ -152,7 +210,9 @@ def setup_scheduler():
logger.info( logger.info(
f"Scheduler configured:" f"Scheduler configured:"
f"\n - Domain check at {settings.check_hour:02d}:{settings.check_minute:02d}" f"\n - Scout domain check at {settings.check_hour:02d}:{settings.check_minute:02d} (daily)"
f"\n - Trader domain check every hour at :00"
f"\n - Tycoon domain check every 10 minutes"
f"\n - TLD price scrape at 03:00 UTC" f"\n - TLD price scrape at 03:00 UTC"
f"\n - Price change alerts at 04:00 UTC" f"\n - Price change alerts at 04:00 UTC"
f"\n - Auction scrape every hour at :30" f"\n - Auction scrape every hour at :30"

View File

@ -231,10 +231,7 @@ export default function PricingPage() {
{tier.features.map((feature) => ( {tier.features.map((feature) => (
<li key={feature.text} className="flex items-start gap-3"> <li key={feature.text} className="flex items-start gap-3">
<Check className="w-4 h-4 mt-0.5 shrink-0 text-accent" strokeWidth={2.5} /> <Check className="w-4 h-4 mt-0.5 shrink-0 text-accent" strokeWidth={2.5} />
<span className={clsx( <span className="text-body-sm text-foreground">
"text-body-sm",
feature.highlight ? "text-foreground" : "text-foreground-muted"
)}>
{feature.text} {feature.text}
</span> </span>
</li> </li>

View File

@ -8,7 +8,6 @@ import { useStore } from '@/lib/store'
import { api, PriceAlert } from '@/lib/api' import { api, PriceAlert } from '@/lib/api'
import { import {
User, User,
Mail,
Bell, Bell,
CreditCard, CreditCard,
Shield, Shield,
@ -20,8 +19,8 @@ import {
ExternalLink, ExternalLink,
Crown, Crown,
Zap, Zap,
Settings,
Key, Key,
TrendingUp,
} from 'lucide-react' } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import clsx from 'clsx' import clsx from 'clsx'
@ -466,98 +465,214 @@ export default function SettingsPage() {
{/* Billing Tab */} {/* Billing Tab */}
{activeTab === 'billing' && ( {activeTab === 'billing' && (
<div className="p-6 sm:p-8 bg-background-secondary/30 border border-border rounded-2xl"> <div className="space-y-6">
<h2 className="text-body-lg font-medium text-foreground mb-6">Subscription & Billing</h2> {/* Current Plan */}
<div className="p-6 sm:p-8 bg-background-secondary/30 border border-border rounded-2xl">
<div className="p-5 bg-accent/5 border border-accent/20 rounded-xl mb-6"> <h2 className="text-body-lg font-medium text-foreground mb-6">Your Current Plan</h2>
<div className="flex items-center justify-between mb-4">
<div>
<p className="text-body font-medium text-foreground">{tierName} Plan</p>
<p className="text-body-sm text-foreground-muted">
{subscription?.check_frequency || 'Daily'} checks · {subscription?.domain_limit || 5} domains
</p>
</div>
<span className={clsx(
"px-3 py-1.5 text-ui-xs font-medium rounded-full",
isProOrHigher ? "bg-accent/10 text-accent" : "bg-foreground/5 text-foreground-muted"
)}>
{isProOrHigher ? 'Active' : 'Free'}
</span>
</div>
{isProOrHigher ? ( <div className="p-5 bg-accent/5 border border-accent/20 rounded-xl mb-6">
<button <div className="flex items-center justify-between mb-4">
onClick={handleOpenBillingPortal} <div className="flex items-center gap-3">
className="w-full py-3 bg-background text-foreground text-ui font-medium rounded-xl border border-border {tierName === 'Tycoon' ? (
hover:border-foreground/20 transition-all flex items-center justify-center gap-2" <Crown className="w-6 h-6 text-accent" />
> ) : tierName === 'Trader' ? (
<ExternalLink className="w-4 h-4" /> <TrendingUp className="w-6 h-6 text-accent" />
Manage Subscription ) : (
</button> <Zap className="w-6 h-6 text-accent" />
) : ( )}
<Link <div>
href="/pricing" <p className="text-xl font-semibold text-foreground">{tierName}</p>
className="w-full py-3 bg-accent text-background text-ui font-medium rounded-xl <p className="text-body-sm text-foreground-muted">
hover:bg-accent-hover transition-all flex items-center justify-center gap-2 shadow-lg shadow-accent/20" {tierName === 'Scout' ? 'Free forever' : tierName === 'Trader' ? '$19/month' : '$49/month'}
> </p>
<Zap className="w-4 h-4" /> </div>
Upgrade Plan </div>
</Link> <span className={clsx(
)} "px-3 py-1.5 text-ui-xs font-medium rounded-full",
</div> isProOrHigher ? "bg-accent/10 text-accent" : "bg-foreground/5 text-foreground-muted"
)}>
{isProOrHigher ? 'Active' : 'Free'}
</span>
</div>
{/* Plan Stats */}
<div className="grid grid-cols-3 gap-4 p-4 bg-background/50 rounded-xl mb-4">
<div className="text-center">
<p className="text-2xl font-semibold text-foreground">{subscription?.domain_limit || 5}</p>
<p className="text-xs text-foreground-muted">Domains</p>
</div>
<div className="text-center border-x border-border/50">
<p className="text-2xl font-semibold text-foreground">
{subscription?.check_frequency === 'realtime' ? '10m' :
subscription?.check_frequency === 'hourly' ? '1h' : '24h'}
</p>
<p className="text-xs text-foreground-muted">Check Interval</p>
</div>
<div className="text-center">
<p className="text-2xl font-semibold text-foreground">
{subscription?.portfolio_limit === -1 ? '∞' : subscription?.portfolio_limit || 0}
</p>
<p className="text-xs text-foreground-muted">Portfolio</p>
</div>
</div>
{isProOrHigher ? (
<button
onClick={handleOpenBillingPortal}
className="w-full py-3 bg-background text-foreground text-ui font-medium rounded-xl border border-border
hover:border-foreground/20 transition-all flex items-center justify-center gap-2"
>
<ExternalLink className="w-4 h-4" />
Manage Subscription
</button>
) : (
<Link
href="/pricing"
className="w-full py-3 bg-accent text-background text-ui font-medium rounded-xl
hover:bg-accent-hover transition-all flex items-center justify-center gap-2 shadow-lg shadow-accent/20"
>
<Zap className="w-4 h-4" />
Upgrade Plan
</Link>
)}
</div>
<div className="space-y-3"> {/* Plan Features */}
<h3 className="text-body-sm font-medium text-foreground">Plan Features</h3> <h3 className="text-body-sm font-medium text-foreground mb-3">Your Plan Includes</h3>
<ul className="space-y-2"> <ul className="grid grid-cols-2 gap-2">
{subscription?.features && Object.entries(subscription.features) <li className="flex items-center gap-2 text-body-sm">
.filter(([key]) => !['sms_alerts', 'api_access', 'webhooks', 'bulk_tools', 'seo_metrics'].includes(key)) <Check className="w-4 h-4 text-accent" />
.map(([key, value]) => { <span className="text-foreground">{subscription?.domain_limit || 5} Watchlist Domains</span>
const featureNames: Record<string, string> = { </li>
email_alerts: 'Email Alerts',
priority_alerts: 'Priority Alerts',
full_whois: 'Full WHOIS Data',
expiration_tracking: 'Expiry Tracking',
domain_valuation: 'Domain Valuation',
market_insights: 'Market Insights',
}
return (
<li key={key} className="flex items-center gap-2 text-body-sm">
{value ? (
<Check className="w-4 h-4 text-accent" />
) : (
<span className="w-4 h-4 text-foreground-subtle"></span>
)}
<span className={value ? 'text-foreground' : 'text-foreground-muted'}>
{featureNames[key] || key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
</span>
</li>
)
})}
{/* Show additional plan info */}
<li className="flex items-center gap-2 text-body-sm"> <li className="flex items-center gap-2 text-body-sm">
<Check className="w-4 h-4 text-accent" /> <Check className="w-4 h-4 text-accent" />
<span className="text-foreground"> <span className="text-foreground">
{subscription?.domain_limit} Watchlist Domains {subscription?.check_frequency === 'realtime' ? '10-minute' :
subscription?.check_frequency === 'hourly' ? 'Hourly' : 'Daily'} Scans
</span> </span>
</li> </li>
<li className="flex items-center gap-2 text-body-sm">
<Check className="w-4 h-4 text-accent" />
<span className="text-foreground">Email Alerts</span>
</li>
<li className="flex items-center gap-2 text-body-sm">
<Check className="w-4 h-4 text-accent" />
<span className="text-foreground">TLD Price Data</span>
</li>
{subscription?.features?.domain_valuation && (
<li className="flex items-center gap-2 text-body-sm">
<Check className="w-4 h-4 text-accent" />
<span className="text-foreground">Domain Valuation</span>
</li>
)}
{(subscription?.portfolio_limit ?? 0) !== 0 && ( {(subscription?.portfolio_limit ?? 0) !== 0 && (
<li className="flex items-center gap-2 text-body-sm"> <li className="flex items-center gap-2 text-body-sm">
<Check className="w-4 h-4 text-accent" /> <Check className="w-4 h-4 text-accent" />
<span className="text-foreground"> <span className="text-foreground">
{subscription?.portfolio_limit === -1 ? 'Unlimited' : subscription?.portfolio_limit} Portfolio Domains {subscription?.portfolio_limit === -1 ? 'Unlimited' : subscription?.portfolio_limit} Portfolio
</span> </span>
</li> </li>
)} )}
{subscription?.features?.expiration_tracking && (
<li className="flex items-center gap-2 text-body-sm">
<Check className="w-4 h-4 text-accent" />
<span className="text-foreground">Expiry Tracking</span>
</li>
)}
{(subscription?.history_days ?? 0) !== 0 && ( {(subscription?.history_days ?? 0) !== 0 && (
<li className="flex items-center gap-2 text-body-sm"> <li className="flex items-center gap-2 text-body-sm">
<Check className="w-4 h-4 text-accent" /> <Check className="w-4 h-4 text-accent" />
<span className="text-foreground"> <span className="text-foreground">
{subscription?.history_days === -1 ? 'Full' : `${subscription?.history_days}-day`} Price History {subscription?.history_days === -1 ? 'Full' : `${subscription?.history_days}-day`} History
</span> </span>
</li> </li>
)} )}
</ul> </ul>
</div> </div>
{/* Compare All Plans */}
<div className="p-6 sm:p-8 bg-background-secondary/30 border border-border rounded-2xl">
<h2 className="text-body-lg font-medium text-foreground mb-6">Compare All Plans</h2>
<div className="overflow-x-auto -mx-2">
<table className="w-full min-w-[500px]">
<thead>
<tr className="border-b border-border">
<th className="text-left py-3 px-3 text-body-sm font-medium text-foreground-muted">Feature</th>
<th className={clsx(
"text-center py-3 px-3 text-body-sm font-medium",
tierName === 'Scout' ? "text-accent" : "text-foreground-muted"
)}>Scout</th>
<th className={clsx(
"text-center py-3 px-3 text-body-sm font-medium",
tierName === 'Trader' ? "text-accent" : "text-foreground-muted"
)}>Trader</th>
<th className={clsx(
"text-center py-3 px-3 text-body-sm font-medium",
tierName === 'Tycoon' ? "text-accent" : "text-foreground-muted"
)}>Tycoon</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-border/50">
<td className="py-3 px-3 text-body-sm text-foreground">Price</td>
<td className="py-3 px-3 text-center text-body-sm text-foreground">Free</td>
<td className="py-3 px-3 text-center text-body-sm text-foreground">$19/mo</td>
<td className="py-3 px-3 text-center text-body-sm text-foreground">$49/mo</td>
</tr>
<tr className="border-b border-border/50">
<td className="py-3 px-3 text-body-sm text-foreground">Watchlist Domains</td>
<td className="py-3 px-3 text-center text-body-sm text-foreground">5</td>
<td className="py-3 px-3 text-center text-body-sm text-foreground">50</td>
<td className="py-3 px-3 text-center text-body-sm text-foreground">500</td>
</tr>
<tr className="border-b border-border/50">
<td className="py-3 px-3 text-body-sm text-foreground">Scan Frequency</td>
<td className="py-3 px-3 text-center text-body-sm text-foreground">Daily</td>
<td className="py-3 px-3 text-center text-body-sm text-foreground">Hourly</td>
<td className="py-3 px-3 text-center text-body-sm text-accent font-medium">10 min</td>
</tr>
<tr className="border-b border-border/50">
<td className="py-3 px-3 text-body-sm text-foreground">Portfolio</td>
<td className="py-3 px-3 text-center text-body-sm text-foreground-muted"></td>
<td className="py-3 px-3 text-center text-body-sm text-foreground">25</td>
<td className="py-3 px-3 text-center text-body-sm text-foreground">Unlimited</td>
</tr>
<tr className="border-b border-border/50">
<td className="py-3 px-3 text-body-sm text-foreground">Domain Valuation</td>
<td className="py-3 px-3 text-center text-body-sm text-foreground-muted"></td>
<td className="py-3 px-3 text-center"><Check className="w-5 h-5 text-accent mx-auto" /></td>
<td className="py-3 px-3 text-center"><Check className="w-5 h-5 text-accent mx-auto" /></td>
</tr>
<tr className="border-b border-border/50">
<td className="py-3 px-3 text-body-sm text-foreground">Price History</td>
<td className="py-3 px-3 text-center text-body-sm text-foreground-muted"></td>
<td className="py-3 px-3 text-center text-body-sm text-foreground">90 days</td>
<td className="py-3 px-3 text-center text-body-sm text-foreground">Unlimited</td>
</tr>
<tr>
<td className="py-3 px-3 text-body-sm text-foreground">Expiry Tracking</td>
<td className="py-3 px-3 text-center text-body-sm text-foreground-muted"></td>
<td className="py-3 px-3 text-center"><Check className="w-5 h-5 text-accent mx-auto" /></td>
<td className="py-3 px-3 text-center"><Check className="w-5 h-5 text-accent mx-auto" /></td>
</tr>
</tbody>
</table>
</div>
{!isProOrHigher && (
<div className="mt-6 text-center">
<Link
href="/pricing"
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background text-ui font-medium rounded-xl
hover:bg-accent-hover transition-all shadow-lg shadow-accent/20"
>
<Zap className="w-4 h-4" />
Upgrade Now
</Link>
</div>
)}
</div>
</div> </div>
)} )}