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 (
-
-
-
-
-
-
-
-
-
{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 */}
+ {/* ═══════════════════════════════════════════════════════════════════════ */}
+
+
+
+
+
+ {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) => (
+ setSortBy(filter.id)}
+ className={clsx(
+ "px-3 py-2 text-[10px] font-mono uppercase tracking-wider border transition-all whitespace-nowrap",
+ sortBy === filter.id
+ ? "bg-accent text-black border-accent"
+ : "bg-white/[0.02] border-white/[0.08] text-white/50"
+ )}
+ >
+ {filter.label}
+
+ ))}
+
+
+
+ {/* 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 && (
+
+
+
+
.{cheapestTld.tld.toUpperCase()}
+
${cheapestTld.registration_price}/yr
+
+
+
+
.COM
+
Industry Standard
+
+
+
+
.{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) => (
- setSortBy(filter.id as any)}
- className={clsx(
- "px-6 py-3.5 flex items-center gap-2 text-xs font-bold uppercase tracking-widest transition-all border border-white/10 bg-[#0A0A0A] hover:bg-white/5 whitespace-nowrap",
- sortBy === filter.id ? "text-accent border-accent" : "text-white/40 hover:text-white"
- )}
- >
- {filter.label}
-
- ))}
-
-
-
-
- {/* Data Grid - Matched Font Sizes and Styles */}
-
-
-
-
-
- Asset Class
- Buy Price
- Trend (7d)
- Renewal Cost
- Registrations (24h)
- Risk Level
-
-
-
-
- {filteredTlds.map((tld) => {
- const isLocked = !isAuthenticated && !isPublicTld(tld.tld)
-
- return (
-
-
-
- {/* 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
-
-
-
- {/* Renewal Price - Locked for guests */}
-
- {isLocked ? (
-
-
- $XX.XX
-
- ) : (
-
- tld.price * 2 ? "text-red-400" : "text-white/80"
- )}>${tld.renewal_price}
- Recurring
-
- )}
-
-
- {/* Volume - Locked for guests */}
-
- {isLocked ? (
-
- XXXX
-
- ) : (
- {tld.registration_count.toLocaleString()}
- )}
-
-
- {/* Risk Level - Locked for guests */}
-
- {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) => (
+ setSortBy(filter.id)}
+ className={clsx(
+ "px-5 py-3 text-xs font-bold uppercase tracking-widest transition-all border",
+ sortBy === filter.id
+ ? "bg-white/10 text-white border-accent"
+ : "bg-[#0A0A0A] border-white/10 text-white/40 hover:text-white"
+ )}
+ >
+ {filter.label}
+
+ ))}
+
+
+
+
+ {/* 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 }: {
-
Domain Name *
-
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"
- />
+
Select Domain from Portfolio *
+ {loadingDomains ? (
+
+
+
+ ) : portfolioDomains.length === 0 ? (
+
+ ) : (
+
setDomain(e.target.value)}
+ className="w-full px-4 py-3 bg-white/5 border border-white/10 text-white text-sm font-mono outline-none focus:border-accent/50"
+ >
+ — Select a domain —
+ {portfolioDomains.map(d => (
+ {d.domain}
+ ))}
+
+ )}
+
+ 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() {
)
@@ -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
+ handleCopy('_pounce')} className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/40 hover:text-white">
+ {copied ? : }
+
+
+
+
+
+
Value
+
+ {verificationData.verification_code}
+ handleCopy(verificationData.verification_code)} className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/40 hover:text-white">
+ {copied ? : }
+
+
+
+
+
+
+ {/* Info */}
+
+
DNS changes can take up to 48 hours to propagate, but usually complete within minutes.
+
+
+ {/* Error/Check Result */}
+ {error && (
+
+ {error}
+
+ )}
+ {checkResult && (
+
+ {checkResult}
+
+ )}
+
+ {/* Actions */}
+
+
+ Cancel
+
+
+ Check Verification
+
+
+ >
+ )}
+
+ {step === 'checking' && (
+
+
+
Checking DNS records...
+
+ )}
+
+ {step === 'instructions' && !verificationData && 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 */}
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}