From 31b02e67906bd01150ec914275ddfdcdfdf98114 Mon Sep 17 00:00:00 2001 From: Yves Gugger Date: Sat, 13 Dec 2025 18:04:09 +0100 Subject: [PATCH] fix: Conservative yield calculator, real TLD data on discover, fix acquire/pricing --- .../006_add_portfolio_dns_verification.py | 34 + backend/app/api/portfolio.py | 57 +- backend/app/db_migrations.py | 20 + backend/app/models/portfolio.py | 5 +- frontend/src/app/acquire/page.tsx | 10 +- frontend/src/app/discover/page.tsx | 744 +++++++++++------- frontend/src/app/pricing/page.tsx | 16 +- frontend/src/app/terminal/listing/page.tsx | 57 +- frontend/src/app/terminal/portfolio/page.tsx | 177 ++++- frontend/src/app/terminal/watchlist/page.tsx | 12 +- frontend/src/app/terminal/yield/page.tsx | 82 +- frontend/src/app/yield/page.tsx | 43 +- 12 files changed, 862 insertions(+), 395 deletions(-) create mode 100644 backend/alembic/versions/006_add_portfolio_dns_verification.py diff --git a/backend/alembic/versions/006_add_portfolio_dns_verification.py b/backend/alembic/versions/006_add_portfolio_dns_verification.py new file mode 100644 index 0000000..fad65b5 --- /dev/null +++ b/backend/alembic/versions/006_add_portfolio_dns_verification.py @@ -0,0 +1,34 @@ +"""Add DNS verification fields to portfolio_domains + +Revision ID: 006 +Revises: 005 +Create Date: 2025-12-13 +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '006' +down_revision = '005' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Add DNS verification columns to portfolio_domains table.""" + # Add columns with default values (nullable to avoid issues with existing rows) + op.add_column('portfolio_domains', sa.Column('is_dns_verified', sa.Boolean(), nullable=True, server_default='0')) + op.add_column('portfolio_domains', sa.Column('verification_status', sa.String(50), nullable=True, server_default='unverified')) + op.add_column('portfolio_domains', sa.Column('verification_code', sa.String(100), nullable=True)) + op.add_column('portfolio_domains', sa.Column('verification_started_at', sa.DateTime(), nullable=True)) + op.add_column('portfolio_domains', sa.Column('verified_at', sa.DateTime(), nullable=True)) + + +def downgrade() -> None: + """Remove DNS verification columns from portfolio_domains table.""" + op.drop_column('portfolio_domains', 'verified_at') + op.drop_column('portfolio_domains', 'verification_started_at') + op.drop_column('portfolio_domains', 'verification_code') + op.drop_column('portfolio_domains', 'verification_status') + op.drop_column('portfolio_domains', 'is_dns_verified') diff --git a/backend/app/api/portfolio.py b/backend/app/api/portfolio.py index 501bbd9..8bcd729 100644 --- a/backend/app/api/portfolio.py +++ b/backend/app/api/portfolio.py @@ -230,10 +230,10 @@ async def get_portfolio( notes=d.notes, tags=d.tags, roi=d.roi, - is_dns_verified=d.is_dns_verified, - verification_status=d.verification_status, - verification_code=d.verification_code, - verified_at=d.verified_at, + is_dns_verified=getattr(d, 'is_dns_verified', False) or False, + verification_status=getattr(d, 'verification_status', 'unverified') or 'unverified', + verification_code=getattr(d, 'verification_code', None), + verified_at=getattr(d, 'verified_at', None), created_at=d.created_at, updated_at=d.updated_at, ) @@ -381,10 +381,10 @@ async def add_portfolio_domain( notes=domain.notes, tags=domain.tags, roi=domain.roi, - is_dns_verified=domain.is_dns_verified, - verification_status=domain.verification_status, - verification_code=domain.verification_code, - verified_at=domain.verified_at, + is_dns_verified=getattr(domain, 'is_dns_verified', False) or False, + verification_status=getattr(domain, 'verification_status', 'unverified') or 'unverified', + verification_code=getattr(domain, 'verification_code', None), + verified_at=getattr(domain, 'verified_at', None), created_at=domain.created_at, updated_at=domain.updated_at, ) @@ -432,10 +432,10 @@ async def get_portfolio_domain( notes=domain.notes, tags=domain.tags, roi=domain.roi, - is_dns_verified=domain.is_dns_verified, - verification_status=domain.verification_status, - verification_code=domain.verification_code, - verified_at=domain.verified_at, + is_dns_verified=getattr(domain, 'is_dns_verified', False) or False, + verification_status=getattr(domain, 'verification_status', 'unverified') or 'unverified', + verification_code=getattr(domain, 'verification_code', None), + verified_at=getattr(domain, 'verified_at', None), created_at=domain.created_at, updated_at=domain.updated_at, ) @@ -492,10 +492,10 @@ async def update_portfolio_domain( notes=domain.notes, tags=domain.tags, roi=domain.roi, - is_dns_verified=domain.is_dns_verified, - verification_status=domain.verification_status, - verification_code=domain.verification_code, - verified_at=domain.verified_at, + is_dns_verified=getattr(domain, 'is_dns_verified', False) or False, + verification_status=getattr(domain, 'verification_status', 'unverified') or 'unverified', + verification_code=getattr(domain, 'verification_code', None), + verified_at=getattr(domain, 'verified_at', None), created_at=domain.created_at, updated_at=domain.updated_at, ) @@ -552,10 +552,10 @@ async def mark_domain_sold( notes=domain.notes, tags=domain.tags, roi=domain.roi, - is_dns_verified=domain.is_dns_verified, - verification_status=domain.verification_status, - verification_code=domain.verification_code, - verified_at=domain.verified_at, + is_dns_verified=getattr(domain, 'is_dns_verified', False) or False, + verification_status=getattr(domain, 'verification_status', 'unverified') or 'unverified', + verification_code=getattr(domain, 'verification_code', None), + verified_at=getattr(domain, 'verified_at', None), created_at=domain.created_at, updated_at=domain.updated_at, ) @@ -639,10 +639,10 @@ async def refresh_domain_value( notes=domain.notes, tags=domain.tags, roi=domain.roi, - is_dns_verified=domain.is_dns_verified, - verification_status=domain.verification_status, - verification_code=domain.verification_code, - verified_at=domain.verified_at, + is_dns_verified=getattr(domain, 'is_dns_verified', False) or False, + verification_status=getattr(domain, 'verification_status', 'unverified') or 'unverified', + verification_code=getattr(domain, 'verification_code', None), + verified_at=getattr(domain, 'verified_at', None), created_at=domain.created_at, updated_at=domain.updated_at, ) @@ -696,10 +696,11 @@ def _domain_to_response(domain: PortfolioDomain) -> PortfolioDomainResponse: notes=domain.notes, tags=domain.tags, roi=domain.roi, - is_dns_verified=domain.is_dns_verified, - verification_status=domain.verification_status, - verification_code=domain.verification_code, - verified_at=domain.verified_at, + # Use getattr with defaults for new fields that may not exist in DB yet + is_dns_verified=getattr(domain, 'is_dns_verified', False) or False, + verification_status=getattr(domain, 'verification_status', 'unverified') or 'unverified', + verification_code=getattr(domain, 'verification_code', None), + verified_at=getattr(domain, 'verified_at', None), created_at=domain.created_at, updated_at=domain.updated_at, ) diff --git a/backend/app/db_migrations.py b/backend/app/db_migrations.py index 24e8bd3..29cd5f4 100644 --- a/backend/app/db_migrations.py +++ b/backend/app/db_migrations.py @@ -180,6 +180,26 @@ async def apply_migrations(conn: AsyncConnection) -> None: logger.info("DB migrations: adding column users.referral_code") await conn.execute(text("ALTER TABLE users ADD COLUMN referral_code VARCHAR(100)")) + # ---------------------------------------------------- + # 7) Portfolio DNS verification columns + # ---------------------------------------------------- + if await _table_exists(conn, "portfolio_domains"): + if not await _has_column(conn, "portfolio_domains", "is_dns_verified"): + logger.info("DB migrations: adding column portfolio_domains.is_dns_verified") + await conn.execute(text("ALTER TABLE portfolio_domains ADD COLUMN is_dns_verified BOOLEAN DEFAULT 0")) + if not await _has_column(conn, "portfolio_domains", "verification_status"): + logger.info("DB migrations: adding column portfolio_domains.verification_status") + await conn.execute(text("ALTER TABLE portfolio_domains ADD COLUMN verification_status VARCHAR(50) DEFAULT 'unverified'")) + if not await _has_column(conn, "portfolio_domains", "verification_code"): + logger.info("DB migrations: adding column portfolio_domains.verification_code") + await conn.execute(text("ALTER TABLE portfolio_domains ADD COLUMN verification_code VARCHAR(100)")) + if not await _has_column(conn, "portfolio_domains", "verification_started_at"): + logger.info("DB migrations: adding column portfolio_domains.verification_started_at") + await conn.execute(text("ALTER TABLE portfolio_domains ADD COLUMN verification_started_at DATETIME")) + if not await _has_column(conn, "portfolio_domains", "verified_at"): + logger.info("DB migrations: adding column portfolio_domains.verified_at") + await conn.execute(text("ALTER TABLE portfolio_domains ADD COLUMN verified_at DATETIME")) + logger.info("DB migrations: done") diff --git a/backend/app/models/portfolio.py b/backend/app/models/portfolio.py index 2a916fe..e5a9ba9 100644 --- a/backend/app/models/portfolio.py +++ b/backend/app/models/portfolio.py @@ -46,8 +46,9 @@ class PortfolioDomain(Base): status: Mapped[str] = mapped_column(String(50), default="active") # active, expired, sold, parked # DNS Verification (required for Yield and For Sale) - is_dns_verified: Mapped[bool] = mapped_column(Boolean, default=False) - verification_status: Mapped[str] = mapped_column(String(50), default="unverified") # unverified, pending, verified, failed + # All fields nullable=True to avoid migration issues on existing databases + is_dns_verified: Mapped[Optional[bool]] = mapped_column(Boolean, default=False, nullable=True) + verification_status: Mapped[Optional[str]] = mapped_column(String(50), default="unverified", nullable=True) verification_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) verification_started_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) verified_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) diff --git a/frontend/src/app/acquire/page.tsx b/frontend/src/app/acquire/page.tsx index f42b5ba..6070d2e 100644 --- a/frontend/src/app/acquire/page.tsx +++ b/frontend/src/app/acquire/page.tsx @@ -411,15 +411,15 @@ export default function AcquirePage() {
-
{allAuctions.length}
-
Live
+
{allAuctions.length.toLocaleString()}
+
Live Auctions
-
{endingSoon.length}
-
Ending
+
{endingSoon.length.toLocaleString()}
+
Ending 24h
-
{pounceItems.length}
+
{pounceItems.length.toLocaleString()}
Direct
diff --git a/frontend/src/app/discover/page.tsx b/frontend/src/app/discover/page.tsx index a84b0b7..d393bab 100644 --- a/frontend/src/app/discover/page.tsx +++ b/frontend/src/app/discover/page.tsx @@ -6,80 +6,34 @@ import { Footer } from '@/components/Footer' import { useStore } from '@/lib/store' import { api } from '@/lib/api' import { - TrendingUp, - TrendingDown, Search, ShieldAlert, Lock, - ArrowRight, Zap, - Flame, AlertTriangle, BarChart3, ArrowUpRight, - Minus + Globe, + DollarSign, + Loader2 } from 'lucide-react' import Link from 'next/link' import clsx from 'clsx' interface TldData { tld: string - price: number - renewal_price: number - change_24h: number - change_7d: number - volume: number - risk_level: 'Low' | 'Medium' | 'High' - trend: 'up' | 'down' | 'stable' - registration_count: number - cheapest_registrar: string + registration_price: number | null + renewal_price: number | null + type: string | null + registrar_count: number } -function StatCard({ label, value, subvalue, trend, icon: Icon, color }: { - label: string, - value: string, - subvalue: string, - trend?: 'up' | 'down', - icon: any, - color: 'accent' | 'red' | 'blue' -}) { - return ( -
-
- -
- -
-
-
- -
- {label} -
- -
{value}
-
- {trend && ( - - {trend === 'up' ? '↗' : '↘'} {trend === 'up' ? 'Bullish' : 'Bearish'} - - )} - {subvalue} -
-
-
- ) +function getRiskLevel(regPrice: number | null, renewPrice: number | null): 'Low' | 'Medium' | 'High' { + if (!regPrice || !renewPrice) return 'Medium' + const ratio = renewPrice / regPrice + if (ratio > 5) return 'High' + if (ratio > 2) return 'Medium' + return 'Low' } export default function DiscoverPage() { @@ -87,305 +41,487 @@ export default function DiscoverPage() { const [tlds, setTlds] = useState([]) const [loading, setLoading] = useState(true) const [searchQuery, setSearchQuery] = useState('') - const [sortBy, setSortBy] = useState<'volume' | 'change' | 'price' | 'risk'>('volume') + const [sortBy, setSortBy] = useState<'popular' | 'price' | 'renewal' | 'risk'>('popular') useEffect(() => { checkAuth() - // Expanded Mock Data - const mockData: TldData[] = [ - { tld: 'ai', price: 65, renewal_price: 85, change_24h: 2.4, change_7d: 15.2, volume: 12500, risk_level: 'High', trend: 'up', registration_count: 1500, cheapest_registrar: 'Namecheap' }, - { tld: 'io', price: 35, renewal_price: 45, change_24h: -0.5, change_7d: 1.2, volume: 8400, risk_level: 'Medium', trend: 'stable', registration_count: 800, cheapest_registrar: 'Dynadot' }, - { tld: 'com', price: 12, renewal_price: 14, change_24h: 0.1, change_7d: 0.3, volume: 450000, risk_level: 'Low', trend: 'stable', registration_count: 25000, cheapest_registrar: 'Cloudflare' }, - { tld: 'net', price: 10, renewal_price: 12, change_24h: 0.0, change_7d: -0.1, volume: 120000, risk_level: 'Low', trend: 'stable', registration_count: 5000, cheapest_registrar: 'Porkbun' }, - { tld: 'org', price: 11, renewal_price: 13, change_24h: 0.2, change_7d: 0.5, volume: 95000, risk_level: 'Low', trend: 'stable', registration_count: 4200, cheapest_registrar: 'NameSilo' }, - { tld: 'xyz', price: 2, renewal_price: 15, change_24h: 5.1, change_7d: 22.4, volume: 65000, risk_level: 'High', trend: 'up', registration_count: 12000, cheapest_registrar: 'GenXYZ' }, - { tld: 'app', price: 18, renewal_price: 22, change_24h: 1.2, change_7d: 4.5, volume: 15000, risk_level: 'Medium', trend: 'up', registration_count: 900, cheapest_registrar: 'Google' }, - { tld: 'dev', price: 16, renewal_price: 20, change_24h: 0.8, change_7d: 3.2, volume: 12000, risk_level: 'Medium', trend: 'up', registration_count: 750, cheapest_registrar: 'Google' }, - { tld: 'me', price: 25, renewal_price: 28, change_24h: -1.2, change_7d: -2.5, volume: 8000, risk_level: 'Medium', trend: 'down', registration_count: 300, cheapest_registrar: 'GoDaddy' }, - { tld: 'co', price: 28, renewal_price: 32, change_24h: 0.5, change_7d: 1.8, volume: 25000, risk_level: 'Low', trend: 'stable', registration_count: 1100, cheapest_registrar: 'Namecheap' }, - { tld: 'so', price: 45, renewal_price: 55, change_24h: 3.2, change_7d: 8.5, volume: 4500, risk_level: 'High', trend: 'up', registration_count: 200, cheapest_registrar: 'Hexonet' }, - { tld: 'tv', price: 32, renewal_price: 38, change_24h: 0.1, change_7d: 0.9, volume: 6000, risk_level: 'Medium', trend: 'stable', registration_count: 150, cheapest_registrar: 'Dynadot' }, - ] - setTlds(mockData) - setLoading(false) + loadTlds() }, [checkAuth]) + const loadTlds = async () => { + setLoading(true) + try { + // Load real TLD data from the API + const response = await api.getTldOverview({ limit: 100, offset: 0 }) + + // Transform the API response to our format + const tldList: TldData[] = response.tlds.map((tld: any) => ({ + tld: tld.tld.replace('.', ''), + registration_price: tld.min_registration_price || tld.avg_registration_price, + renewal_price: tld.min_renewal_price || tld.avg_renewal_price, + type: tld.type, + registrar_count: tld.registrar_count || 1 + })) + + setTlds(tldList) + } catch (error) { + console.error('Failed to load TLDs:', error) + // Fallback to basic data if API fails + setTlds([ + { tld: 'com', registration_price: 12, renewal_price: 14, type: 'gTLD', registrar_count: 25 }, + { tld: 'net', registration_price: 10, renewal_price: 12, type: 'gTLD', registrar_count: 20 }, + { tld: 'org', registration_price: 11, renewal_price: 13, type: 'gTLD', registrar_count: 18 }, + { tld: 'io', registration_price: 35, renewal_price: 45, type: 'ccTLD', registrar_count: 15 }, + { tld: 'ai', registration_price: 65, renewal_price: 85, type: 'ccTLD', registrar_count: 10 }, + { tld: 'co', registration_price: 28, renewal_price: 32, type: 'ccTLD', registrar_count: 12 }, + { tld: 'app', registration_price: 18, renewal_price: 22, type: 'gTLD', registrar_count: 8 }, + { tld: 'dev', registration_price: 16, renewal_price: 20, type: 'gTLD', registrar_count: 8 }, + ]) + } finally { + setLoading(false) + } + } + const filteredTlds = tlds - .filter(t => t.tld.includes(searchQuery.toLowerCase())) + .filter(t => t.tld.toLowerCase().includes(searchQuery.toLowerCase())) .sort((a, b) => { - if (sortBy === 'volume') return b.volume - a.volume - if (sortBy === 'change') return b.change_7d - a.change_7d - if (sortBy === 'price') return b.price - a.price - if (sortBy === 'risk') return (a.risk_level === 'High' ? 1 : 0) - (b.risk_level === 'High' ? 1 : 0) + if (sortBy === 'popular') return b.registrar_count - a.registrar_count + if (sortBy === 'price') return (a.registration_price || 999) - (b.registration_price || 999) + if (sortBy === 'renewal') return (a.renewal_price || 999) - (b.renewal_price || 999) + if (sortBy === 'risk') { + const riskA = getRiskLevel(a.registration_price, a.renewal_price) + const riskB = getRiskLevel(b.registration_price, b.renewal_price) + const order = { 'High': 0, 'Medium': 1, 'Low': 2 } + return order[riskA] - order[riskB] + } return 0 }) - // Top Movers Logic - const topGainer = [...tlds].sort((a, b) => b.change_7d - a.change_7d)[0] - const topVolume = [...tlds].sort((a, b) => b.volume - a.volume)[0] - const highRisk = [...tlds].find(t => t.risk_level === 'High') + // Stats + const cheapestTld = [...tlds].filter(t => t.registration_price).sort((a, b) => (a.registration_price || 999) - (b.registration_price || 999))[0] + const mostExpensive = [...tlds].filter(t => t.registration_price).sort((a, b) => (b.registration_price || 0) - (a.registration_price || 0))[0] + const highRiskTld = [...tlds].find(t => getRiskLevel(t.registration_price, t.renewal_price) === 'High') // Gatekeeper Logic const isPublicTld = (tld: string) => ['com', 'net', 'org'].includes(tld) - if (authLoading) return null + if (authLoading) { + return ( +
+ +
+ ) + } return ( -
- {/* Background Atmosphere - Matched with Acquire */} +
+ {/* Background */}
-
-
+ {/* ═══════════════════════════════════════════════════════════════════════ */} + {/* MOBILE HEADER */} + {/* ═══════════════════════════════════════════════════════════════════════ */} +
+
+
+
+
+ TLD Intel +
+
+ {tlds.length} TLDs +
+
+ +
+
+
{tlds.length}
+
Total
+
+
+
+ ${cheapestTld?.registration_price || '—'} +
+
Cheapest
+
+
+
+ ${mostExpensive?.registration_price || '—'} +
+
Premium
+
+
+
+
+ + {/* MOBILE SEARCH & FILTERS */} +
+
+
+ + setSearchQuery(e.target.value)} + className="flex-1 bg-transparent px-3 py-2.5 text-sm font-mono text-white placeholder:text-white/20 outline-none" + /> +
+
+ +
+ {[ + { id: 'popular' as const, label: 'Popular' }, + { id: 'price' as const, label: 'Price' }, + { id: 'renewal' as const, label: 'Renewal' }, + { id: 'risk' as const, label: 'Risk' }, + ].map((filter) => ( + + ))} +
+
+ + {/* MOBILE TLD LIST */} +
+ {!isAuthenticated && ( +
+
+ +
+

Unlock Full Data

+

Renewal prices & risk levels

+
+ + Join + +
+
+ )} + + {loading ? ( +
+ +
+ ) : filteredTlds.length === 0 ? ( +
No TLDs found
+ ) : ( +
+ {filteredTlds.slice(0, 50).map((tld) => { + const isLocked = !isAuthenticated && !isPublicTld(tld.tld) + const riskLevel = getRiskLevel(tld.registration_price, tld.renewal_price) + + return ( + +
+
+
+ +
+
+
.{tld.tld}
+
{tld.type || 'TLD'}
+
+
+
+
+ ${tld.registration_price || '—'} +
+
+ {isLocked ? ( + + Renew + + ) : ( + + ${tld.renewal_price || '—'}/yr + + )} +
+
+
+ + ) + })} +
+ )} +
+ + {/* ═══════════════════════════════════════════════════════════════════════ */} + {/* DESKTOP LAYOUT */} + {/* ═══════════════════════════════════════════════════════════════════════ */} +
{/* Header Section */} -
-
+
+
- +
- Global Intelligence + TLD Intelligence -

- Market Pulse. +

+ Discover TLDs.

-

- Real-time inflation monitor. Track pricing and breakout trends. - 886+ TLDs monitored. +

+ Compare registration and renewal prices across {tlds.length}+ top-level domains. + Find the best value for your next domain.

- -
+
-
886+
-
TLDs Monitored
+
{tlds.length}
+
TLDs
-
24/7
-
Live Sync
+
${cheapestTld?.registration_price || '—'}
+
Cheapest
+
+
+
${mostExpensive?.registration_price || '—'}
+
Premium
- {/* Top Movers Cards */} - {topGainer && topVolume && highRisk && ( -
- - - + {/* Stats Cards */} + {cheapestTld && mostExpensive && highRiskTld && ( +
+
+
+
+ +
+ Best Value +
+
.{cheapestTld.tld.toUpperCase()}
+
${cheapestTld.registration_price}/yr
+
+
+
+
+ +
+ Most Tracked +
+
.COM
+
Industry Standard
+
+
+
+
+ +
+ Watch Renewal +
+
.{highRiskTld.tld.toUpperCase()}
+
High renewal cost
+
)} - {/* Tech Bar - Filter & Search - Matched Style */} -
-
-
- - setSearchQuery(e.target.value)} - className="w-full pl-14 pr-5 py-3.5 bg-[#0A0A0A] border border-white/10 text-white placeholder:text-white/20 font-mono text-base focus:outline-none focus:border-accent focus:bg-[#0F0F0F] transition-all rounded-none" - /> -
- -
- {[ - { id: 'volume', label: 'Volume' }, - { id: 'change', label: 'Momentum' }, - { id: 'price', label: 'Buy Price' }, - { id: 'risk', label: 'Risk Factor' } - ].map((filter) => ( - - ))} -
-
-
- - {/* Data Grid - Matched Font Sizes and Styles */} -
-
- - - - - - - - - - - - - - {filteredTlds.map((tld) => { - const isLocked = !isAuthenticated && !isPublicTld(tld.tld) - - return ( - - - - - - {/* Renewal Price - Locked for guests */} - - - {/* Volume - Locked for guests */} - - - {/* Risk Level - Locked for guests */} - - - - - ) - })} - -
Asset ClassBuy PriceTrend (7d)Renewal CostRegistrations (24h)Risk Level
- - {/* Changed to font-mono and adjusted size to match Acquire's tech look while being distinct for TLDs */} - .{tld.tld} - {tld.change_7d > 10 && } - - -
- {/* Matched font-mono text-lg from Acquire */} - ${tld.price} - Entry -
-
-
- 0 ? "text-accent" : tld.change_7d < 0 ? "text-red-400" : "text-white/40" - )}> - {tld.change_7d > 0 ? : tld.change_7d < 0 ? : } - {Math.abs(tld.change_7d)}% - - Momentum -
-
- {isLocked ? ( -
- - $XX.XX -
- ) : ( -
- tld.price * 2 ? "text-red-400" : "text-white/80" - )}>${tld.renewal_price} - Recurring -
- )} -
- {isLocked ? ( -
- XXXX -
- ) : ( - {tld.registration_count.toLocaleString()} - )} -
- {isLocked ? ( -
- LOCKED -
- ) : ( - - {tld.risk_level} - - )} -
- - - -
-
-
- - {/* CTA for Locked Data */} + {/* Login Banner */} {!isAuthenticated && ( -
-
-
-
-
- -
-

Protect Your Capital.

-

- Don't buy a cheap domain with expensive renewal fees. - Our 'Trader' plan reveals hidden costs and risk levels for every TLD. -

- - Unlock Intelligence - - -
+
+
+
+
+ +
+
+

Unlock Full Intelligence

+

See renewal prices and risk levels for all TLDs.

+
+ + Sign Up Free + +
)} + {/* Search & Filters Bar */} +
+
+
+ + setSearchQuery(e.target.value)} + className="w-full pl-12 pr-4 py-3 bg-[#0A0A0A] border border-white/10 text-white placeholder:text-white/20 font-mono text-sm focus:outline-none focus:border-accent transition-all" + /> +
+ +
+ {[ + { id: 'popular' as const, label: 'Popular' }, + { id: 'price' as const, label: 'Price' }, + { id: 'renewal' as const, label: 'Renewal' }, + { id: 'risk' as const, label: 'Risk' }, + ].map((filter) => ( + + ))} +
+
+
+ + {/* Desktop Table */} +
+ {/* Table Header */} +
+
Extension
+
Registration
+
Renewal
+
Risk
+
+
+ + {/* Table Body */} + {loading ? ( +
+ +
+ ) : filteredTlds.length === 0 ? ( +
No TLDs found
+ ) : ( +
+ {filteredTlds.map((tld) => { + const isLocked = !isAuthenticated && !isPublicTld(tld.tld) + const riskLevel = getRiskLevel(tld.registration_price, tld.renewal_price) + + return ( + +
+ .{tld.tld} + {tld.type} +
+
+ ${tld.registration_price || '—'} +
+
+ {isLocked ? ( + + + $XX + + ) : ( + + ${tld.renewal_price || '—'} + + )} +
+
+ {isLocked ? ( + --- + ) : ( + + {riskLevel} + + )} +
+
+
+ +
+
+ + ) + })} +
+ )} +
+ + {/* Stats Footer */} + {!loading && ( +
+ Data updated daily + TLDs: {filteredTlds.length} +
+ )} + + {/* Bottom CTA */} + {!isAuthenticated && ( +
+
+
+ +
+

Avoid Hidden Costs.

+

+ Some TLDs have renewal prices 5-10x higher than registration. + Unlock full pricing data to make informed decisions. +

+ + Unlock Intel + +
+
+ )}
+
) diff --git a/frontend/src/app/pricing/page.tsx b/frontend/src/app/pricing/page.tsx index 941e3c2..0e3b4d5 100644 --- a/frontend/src/app/pricing/page.tsx +++ b/frontend/src/app/pricing/page.tsx @@ -198,16 +198,16 @@ export default function PricingPage() { )} {/* Hero */} -
- +
+
Clearance Levels

Pick your weapon.

-

- Start free. Scale when you're ready. +

+ Start free. Scale when you're ready. All plans include core features.

@@ -244,9 +244,9 @@ export default function PricingPage() {
{/* Header */} -
+

{tier.name}

-
+
{tier.price === '0' ? ( Free ) : ( @@ -355,8 +355,8 @@ export default function PricingPage() {
{/* FAQ */} -
-

Mission Support

+
+

Mission Support

{faqs.map((faq, i) => (
(null) const [verifying, setVerifying] = useState(false) const [verified, setVerified] = useState(false) + + // Portfolio domains (for dropdown selection) + const [portfolioDomains, setPortfolioDomains] = useState<{ id: number; domain: string }[]>([]) + const [loadingDomains, setLoadingDomains] = useState(true) + + useEffect(() => { + const fetchPortfolioDomains = async () => { + setLoadingDomains(true) + try { + const domains = await api.getPortfolio() + // Filter out sold domains + setPortfolioDomains(domains.filter(d => !d.is_sold).map(d => ({ id: d.id, domain: d.domain }))) + } catch (err) { + console.error('Failed to load portfolio domains:', err) + } finally { + setLoadingDomains(false) + } + } + fetchPortfolioDomains() + }, []) // Step 1: Create listing const handleCreateListing = async () => { @@ -641,15 +661,34 @@ function CreateListingWizard({ onClose, onSuccess, prefillDomain }: {
- - setDomain(e.target.value)} - required - className="w-full px-4 py-3 bg-white/5 border border-white/10 text-white text-sm font-mono placeholder:text-white/20 outline-none focus:border-accent/50" - placeholder="yourdomain.com" - /> + + {loadingDomains ? ( +
+ +
+ ) : portfolioDomains.length === 0 ? ( +
+ +

No domains in your portfolio

+ + Add Domains to Portfolio + +
+ ) : ( + + )} +

+ Only domains from your portfolio can be listed. DNS verification required after listing. +

diff --git a/frontend/src/app/terminal/portfolio/page.tsx b/frontend/src/app/terminal/portfolio/page.tsx index 5ecd4ab..cf3dced 100755 --- a/frontend/src/app/terminal/portfolio/page.tsx +++ b/frontend/src/app/terminal/portfolio/page.tsx @@ -446,15 +446,19 @@ export default function PortfolioPage() {
- {domain.is_sold ? : } + {domain.is_sold ? : + domain.is_dns_verified ? : }
{domain.domain}
{domain.registrar || 'Unknown'} {domain.is_sold && SOLD} + {!domain.is_sold && domain.is_dns_verified && VERIFIED} + {!domain.is_sold && !domain.is_dns_verified && UNVERIFIED}
@@ -572,6 +576,9 @@ export default function PortfolioPage() { {/* DOMAIN DETAIL MODAL */} {selectedDomain && setSelectedDomain(null)} onUpdate={loadData} canListForSale={canListForSale} />} + {/* DNS VERIFICATION MODAL */} + {verifyingDomain && setVerifyingDomain(null)} onSuccess={() => { loadData(); setVerifyingDomain(null) }} />} + {toast && }
) @@ -873,3 +880,169 @@ function MobileDrawer({ user, tierName, TierIcon, sections, onClose, onLogout }:
) } + +// ============================================================================ +// DNS VERIFICATION MODAL +// ============================================================================ + +function DnsVerificationModal({ domain, onClose, onSuccess }: { domain: PortfolioDomain; onClose: () => void; onSuccess: () => void }) { + const [step, setStep] = useState<'loading' | 'instructions' | 'checking'>('loading') + const [verificationData, setVerificationData] = useState<{ + verification_code: string + dns_record_name: string + dns_record_value: string + instructions: string + } | null>(null) + const [error, setError] = useState(null) + const [checkResult, setCheckResult] = useState(null) + const [copied, setCopied] = useState(false) + + useEffect(() => { + const startVerification = async () => { + try { + const data = await api.startPortfolioDnsVerification(domain.id) + setVerificationData({ + verification_code: data.verification_code, + dns_record_name: data.dns_record_name, + dns_record_value: data.dns_record_value, + instructions: data.instructions, + }) + setStep('instructions') + } catch (err: any) { + setError(err.message || 'Failed to start verification') + setStep('instructions') + } + } + startVerification() + }, [domain.id]) + + const handleCheck = async () => { + setStep('checking') + setCheckResult(null) + setError(null) + try { + const result = await api.checkPortfolioDnsVerification(domain.id) + if (result.verified) { + onSuccess() + } else { + setCheckResult(result.message) + setStep('instructions') + } + } catch (err: any) { + setError(err.message || 'Verification check failed') + setStep('instructions') + } + } + + const handleCopy = (text: string) => { + navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( +
+
e.stopPropagation()}> +
+
+ + Verify Domain Ownership +
+ +
+ +
+ {/* Domain Header */} +
+

{domain.domain}

+

DNS Verification Required

+
+ + {step === 'loading' && ( +
+ +
+ )} + + {step === 'instructions' && verificationData && ( + <> + {/* Instructions */} +
+

Add this TXT record to your DNS:

+
+
+
Host / Name
+
+ _pounce + +
+
+
+
Type
+ TXT +
+
+
Value
+
+ {verificationData.verification_code} + +
+
+
+
+ + {/* Info */} +
+

DNS changes can take up to 48 hours to propagate, but usually complete within minutes.

+
+ + {/* Error/Check Result */} + {error && ( +
+ {error} +
+ )} + {checkResult && ( +
+ {checkResult} +
+ )} + + {/* Actions */} +
+ + +
+ + )} + + {step === 'checking' && ( +
+ +

Checking DNS records...

+
+ )} + + {step === 'instructions' && !verificationData && error && ( +
+

{error}

+ +
+ )} +
+
+
+ ) +} diff --git a/frontend/src/app/terminal/watchlist/page.tsx b/frontend/src/app/terminal/watchlist/page.tsx index 71d5286..2d1795a 100755 --- a/frontend/src/app/terminal/watchlist/page.tsx +++ b/frontend/src/app/terminal/watchlist/page.tsx @@ -362,18 +362,14 @@ export default function WatchlistPage() { {/* ADD DOMAIN + FILTERS */} {/* ═══════════════════════════════════════════════════════════════════════ */}
- {/* Add Domain Form */} + {/* Add Domain Form - Always visible with accent border */}
- + void; onSuccess: () => void }) { - const [domain, setDomain] = useState('') + const [selectedDomain, setSelectedDomain] = useState('') + const [verifiedDomains, setVerifiedDomains] = useState<{ id: number; domain: string }[]>([]) + const [loadingDomains, setLoadingDomains] = useState(true) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + useEffect(() => { + if (!isOpen) return + const fetchVerifiedDomains = async () => { + setLoadingDomains(true) + try { + const domains = await api.getVerifiedPortfolioDomains() + setVerifiedDomains(domains.map(d => ({ id: d.id, domain: d.domain }))) + } catch (err) { + console.error('Failed to load verified domains:', err) + } finally { + setLoadingDomains(false) + } + } + fetchVerifiedDomains() + }, [isOpen]) + const handleActivate = async () => { - if (!domain.trim()) return + if (!selectedDomain) return setLoading(true) setError(null) try { - await api.activateYieldDomain(domain.trim(), true) + await api.activateYieldDomain(selectedDomain, true) onSuccess() onClose() - setDomain('') + setSelectedDomain('') } catch (err: any) { setError(err.message || 'Failed') } finally { @@ -67,22 +85,52 @@ function ActivateModal({ isOpen, onClose, onSuccess }: { isOpen: boolean; onClos
- Add Domain + Activate Yield
-
- - setDomain(e.target.value)} placeholder="example.com" - className="w-full px-3 py-2.5 bg-white/5 border border-white/10 text-white text-sm font-mono placeholder:text-white/20 outline-none focus:border-accent/50" /> -
- {error &&
{error}
} - + {loadingDomains ? ( +
+ +
+ ) : verifiedDomains.length === 0 ? ( +
+ ) : ( + <> +
+ + +
+
+

Only DNS-verified domains from your portfolio can be activated for Yield.

+
+ {error &&
{error}
} + + + )}
diff --git a/frontend/src/app/yield/page.tsx b/frontend/src/app/yield/page.tsx index f218f09..d64b071 100644 --- a/frontend/src/app/yield/page.tsx +++ b/frontend/src/app/yield/page.tsx @@ -31,22 +31,24 @@ import { } from 'lucide-react' import clsx from 'clsx' -// Yield Simulator Component - SIMPLIFIED & COMPACT +// Yield Simulator Component - CONSERVATIVE VALUES function YieldSimulator() { - const [traffic, setTraffic] = useState(2500) + const [traffic, setTraffic] = useState(1000) const [vertical, setVertical] = useState('finance') - // Vertical data (average CPA and Conversion) + // Vertical data - CONSERVATIVE conversion rates and CPAs + // These are industry-realistic values for generic domain traffic const verticals: Record = { - finance: { cpa: 120, conv: 3.5, label: 'Finance & Loans' }, - insurance: { cpa: 80, conv: 2.8, label: 'Insurance' }, - legal: { cpa: 150, conv: 1.5, label: 'Legal Services' }, - medical: { cpa: 60, conv: 4.0, label: 'Medical / Health' }, - tech: { cpa: 45, conv: 2.2, label: 'Software / B2B' } + finance: { cpa: 25, conv: 0.8, label: 'Finance & Loans' }, + insurance: { cpa: 18, conv: 0.6, label: 'Insurance' }, + legal: { cpa: 35, conv: 0.4, label: 'Legal Services' }, + medical: { cpa: 15, conv: 1.0, label: 'Medical / Health' }, + tech: { cpa: 12, conv: 0.5, label: 'Software / B2B' } } const currentVertical = verticals[vertical] - const monthlyRevenue = Math.floor(traffic * (currentVertical.conv / 100) * currentVertical.cpa) + const conversions = Math.floor(traffic * (currentVertical.conv / 100)) + const monthlyRevenue = conversions * currentVertical.cpa const annualYield = monthlyRevenue * 12 return ( @@ -80,7 +82,7 @@ function YieldSimulator() { setTraffic(parseInt(e.target.value))} @@ -121,12 +123,29 @@ function YieldSimulator() { {/* Result Area */}
-
- Annual Revenue +
+ Est. Annual
${annualYield.toLocaleString('en-US')}
+ + {/* Conversion breakdown */} +
+
+ Visitors + {traffic.toLocaleString()} +
+
+ Conv. Rate + {currentVertical.conv}% +
+
+ Conversions/mo + {conversions} +
+
+
${monthlyRevenue.toLocaleString('en-US')} / mo CPA: ${currentVertical.cpa}