fix: Conservative yield calculator, real TLD data on discover, fix acquire/pricing
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
This commit is contained in:
@ -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')
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -411,15 +411,15 @@ export default function AcquirePage() {
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-8 text-right">
|
||||
<div>
|
||||
<div className="text-3xl font-display text-white mb-1">{allAuctions.length}</div>
|
||||
<div className="text-[10px] uppercase tracking-widest text-white/30 font-mono">Live</div>
|
||||
<div className="text-3xl font-display text-white mb-1">{allAuctions.length.toLocaleString()}</div>
|
||||
<div className="text-[10px] uppercase tracking-widest text-white/30 font-mono">Live Auctions</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-display text-accent mb-1">{endingSoon.length}</div>
|
||||
<div className="text-[10px] uppercase tracking-widest text-accent/60 font-mono">Ending</div>
|
||||
<div className="text-3xl font-display text-accent mb-1">{endingSoon.length.toLocaleString()}</div>
|
||||
<div className="text-[10px] uppercase tracking-widest text-accent/60 font-mono">Ending 24h</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-display text-white mb-1">{pounceItems.length}</div>
|
||||
<div className="text-3xl font-display text-white mb-1">{pounceItems.length.toLocaleString()}</div>
|
||||
<div className="text-[10px] uppercase tracking-widest text-white/30 font-mono">Direct</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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 (
|
||||
<div className="bg-[#050505] border border-white/10 p-6 relative overflow-hidden group hover:border-white/20 transition-colors">
|
||||
<div className={clsx(
|
||||
"absolute top-0 right-0 p-4 opacity-10 transition-opacity group-hover:opacity-20",
|
||||
color === 'accent' ? "text-accent" : color === 'red' ? "text-red-500" : "text-blue-500"
|
||||
)}>
|
||||
<Icon className="w-16 h-16" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className={clsx(
|
||||
"w-8 h-8 flex items-center justify-center border",
|
||||
color === 'accent' ? "border-accent/20 bg-accent/5 text-accent" :
|
||||
color === 'red' ? "border-red-500/20 bg-red-500/5 text-red-500" :
|
||||
"border-blue-500/20 bg-blue-500/5 text-blue-500"
|
||||
)}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<span className="text-xs font-mono uppercase tracking-widest text-white/40">{label}</span>
|
||||
</div>
|
||||
|
||||
<div className="text-3xl font-display text-white mb-1">{value}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{trend && (
|
||||
<span className={clsx(
|
||||
"text-xs font-bold uppercase tracking-wide",
|
||||
trend === 'up' ? "text-accent" : "text-red-500"
|
||||
)}>
|
||||
{trend === 'up' ? '↗' : '↘'} {trend === 'up' ? 'Bullish' : 'Bearish'}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs font-mono text-white/30">{subvalue}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
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<TldData[]>([])
|
||||
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 (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#020202]">
|
||||
<Loader2 className="w-8 h-8 text-accent animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#020202] text-white relative overflow-x-hidden selection:bg-accent/30 selection:text-white">
|
||||
{/* Background Atmosphere - Matched with Acquire */}
|
||||
<div className="min-h-screen bg-[#020202] text-white relative overflow-x-hidden">
|
||||
{/* Background */}
|
||||
<div className="fixed inset-0 pointer-events-none z-0">
|
||||
<div className="absolute inset-0 bg-[url('/noise.png')] opacity-[0.03] mix-blend-overlay" />
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.03]"
|
||||
className="absolute inset-0 opacity-[0.02]"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(rgba(255,255,255,0.3) 0.5px, transparent 0.5px), linear-gradient(90deg, rgba(255,255,255,0.3) 0.5px, transparent 0.5px)`,
|
||||
backgroundSize: '160px 160px',
|
||||
backgroundSize: '80px 80px',
|
||||
}}
|
||||
/>
|
||||
<div className="absolute top-[20%] right-[-10%] w-[800px] h-[800px] bg-accent/[0.02] rounded-full blur-[150px]" />
|
||||
</div>
|
||||
|
||||
<Header />
|
||||
|
||||
<main className="relative pt-20 sm:pt-32 pb-16 sm:pb-24 px-4 sm:px-6 flex-1">
|
||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||
{/* MOBILE HEADER */}
|
||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||
<header className="lg:hidden sticky top-14 z-40 bg-[#020202]/95 backdrop-blur-md border-b border-white/[0.08]">
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 bg-accent animate-pulse" />
|
||||
<span className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">TLD Intel</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px] font-mono text-white/40">
|
||||
<span>{tlds.length} TLDs</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="bg-white/[0.02] border border-white/[0.08] p-2">
|
||||
<div className="text-lg font-bold text-white tabular-nums">{tlds.length}</div>
|
||||
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">Total</div>
|
||||
</div>
|
||||
<div className="bg-accent/[0.05] border border-accent/20 p-2">
|
||||
<div className="text-lg font-bold text-accent tabular-nums">
|
||||
${cheapestTld?.registration_price || '—'}
|
||||
</div>
|
||||
<div className="text-[9px] font-mono text-accent/60 uppercase tracking-wider">Cheapest</div>
|
||||
</div>
|
||||
<div className="bg-white/[0.02] border border-white/[0.08] p-2">
|
||||
<div className="text-lg font-bold text-white tabular-nums">
|
||||
${mostExpensive?.registration_price || '—'}
|
||||
</div>
|
||||
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">Premium</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* MOBILE SEARCH & FILTERS */}
|
||||
<section className="lg:hidden px-4 py-3 border-b border-white/[0.08]">
|
||||
<div className="relative border-2 border-white/[0.08] bg-white/[0.02] mb-3">
|
||||
<div className="flex items-center">
|
||||
<Search className="w-4 h-4 text-white/30 ml-3" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="search tlds..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{[
|
||||
{ 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) => (
|
||||
<button
|
||||
key={filter.id}
|
||||
onClick={() => 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}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* MOBILE TLD LIST */}
|
||||
<section className="lg:hidden px-4 py-4 pb-20">
|
||||
{!isAuthenticated && (
|
||||
<div className="mb-4 p-3 bg-accent/5 border border-accent/20">
|
||||
<div className="flex items-center gap-3">
|
||||
<Lock className="w-4 h-4 text-accent shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-bold text-white">Unlock Full Data</p>
|
||||
<p className="text-[10px] text-white/50">Renewal prices & risk levels</p>
|
||||
</div>
|
||||
<Link href="/register" className="px-3 py-1.5 bg-accent text-black text-[10px] font-bold uppercase">
|
||||
Join
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
||||
</div>
|
||||
) : filteredTlds.length === 0 ? (
|
||||
<div className="text-center py-20 text-white/30 font-mono text-sm">No TLDs found</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredTlds.slice(0, 50).map((tld) => {
|
||||
const isLocked = !isAuthenticated && !isPublicTld(tld.tld)
|
||||
const riskLevel = getRiskLevel(tld.registration_price, tld.renewal_price)
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tld.tld}
|
||||
href={`/terminal/intel/${tld.tld}`}
|
||||
className="block p-3 bg-[#0A0A0A] border border-white/[0.08] active:bg-white/[0.03] transition-all"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-white/[0.02] border border-white/[0.06] flex items-center justify-center">
|
||||
<Globe className="w-4 h-4 text-white/40" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-white font-mono">.{tld.tld}</div>
|
||||
<div className="text-[10px] text-white/30 font-mono uppercase">{tld.type || 'TLD'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-bold text-accent font-mono">
|
||||
${tld.registration_price || '—'}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
{isLocked ? (
|
||||
<span className="text-[10px] text-white/20 flex items-center gap-1">
|
||||
<Lock className="w-3 h-3" /> Renew
|
||||
</span>
|
||||
) : (
|
||||
<span className={clsx(
|
||||
"text-[10px] font-mono",
|
||||
riskLevel === 'High' ? 'text-red-400' :
|
||||
riskLevel === 'Medium' ? 'text-amber-400' : 'text-white/40'
|
||||
)}>
|
||||
${tld.renewal_price || '—'}/yr
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||
{/* DESKTOP LAYOUT */}
|
||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||
<main className="hidden lg:block relative pt-32 pb-24 px-6">
|
||||
<div className="max-w-[1400px] mx-auto">
|
||||
|
||||
{/* Header Section */}
|
||||
<div className="mb-12 sm:mb-16 animate-fade-in text-left">
|
||||
<div className="flex flex-col lg:flex-row justify-between items-end gap-6 sm:gap-8 border-b border-white/[0.08] pb-8 sm:pb-12">
|
||||
<div className="mb-12 animate-fade-in">
|
||||
<div className="flex justify-between items-end gap-8 border-b border-white/[0.08] pb-10">
|
||||
<div>
|
||||
<span className="text-accent font-mono text-[10px] sm:text-xs uppercase tracking-[0.2em] mb-3 sm:mb-4 block flex items-center gap-2">
|
||||
<span className="text-accent font-mono text-xs uppercase tracking-[0.2em] mb-4 block flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 bg-accent animate-pulse" />
|
||||
Global Intelligence
|
||||
TLD Intelligence
|
||||
</span>
|
||||
<h1 className="font-display text-[2.5rem] sm:text-[4rem] md:text-[5rem] lg:text-[6rem] leading-[0.9] tracking-[-0.03em] text-white">
|
||||
Market Pulse.
|
||||
<h1 className="font-display text-[5rem] leading-[0.9] tracking-[-0.03em] text-white">
|
||||
Discover TLDs.
|
||||
</h1>
|
||||
<p className="mt-5 sm:mt-8 text-sm sm:text-lg lg:text-xl text-white/50 max-w-2xl font-light leading-relaxed">
|
||||
Real-time inflation monitor. Track pricing and breakout trends.
|
||||
<span className="block mt-2 text-white/80">886+ TLDs monitored.</span>
|
||||
<p className="mt-6 text-lg text-white/50 max-w-2xl font-light leading-relaxed">
|
||||
Compare registration and renewal prices across {tlds.length}+ top-level domains.
|
||||
<span className="block mt-2 text-white/80">Find the best value for your next domain.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-12 text-right hidden lg:grid">
|
||||
<div className="grid grid-cols-3 gap-8 text-right">
|
||||
<div>
|
||||
<div className="text-3xl font-display text-white mb-1">886+</div>
|
||||
<div className="text-[10px] uppercase tracking-widest text-white/30 font-mono">TLDs Monitored</div>
|
||||
<div className="text-3xl font-display text-white mb-1">{tlds.length}</div>
|
||||
<div className="text-[10px] uppercase tracking-widest text-white/30 font-mono">TLDs</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-display text-white mb-1">24/7</div>
|
||||
<div className="text-[10px] uppercase tracking-widest text-accent font-mono">Live Sync</div>
|
||||
<div className="text-3xl font-display text-accent mb-1">${cheapestTld?.registration_price || '—'}</div>
|
||||
<div className="text-[10px] uppercase tracking-widest text-accent/60 font-mono">Cheapest</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-display text-white mb-1">${mostExpensive?.registration_price || '—'}</div>
|
||||
<div className="text-[10px] uppercase tracking-widest text-white/30 font-mono">Premium</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Movers Cards */}
|
||||
{topGainer && topVolume && highRisk && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 sm:gap-6 mb-10 sm:mb-16 animate-fade-in">
|
||||
<StatCard
|
||||
label="Top Gainer (7d)"
|
||||
value={`.${topGainer.tld.toUpperCase()}`}
|
||||
subvalue={`+${topGainer.change_7d}% Growth`}
|
||||
trend="up"
|
||||
icon={TrendingUp}
|
||||
color="accent"
|
||||
/>
|
||||
<StatCard
|
||||
label="Highest Volume"
|
||||
value={`.${topVolume.tld.toUpperCase()}`}
|
||||
subvalue={`${(topVolume.volume / 1000).toFixed(0)}k Daily Ops`}
|
||||
trend="up"
|
||||
icon={BarChart3}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
label="Inflation Risk"
|
||||
value={`.${highRisk.tld.toUpperCase()}`}
|
||||
subvalue="Renewal Price Spike"
|
||||
trend="down"
|
||||
icon={AlertTriangle}
|
||||
color="red"
|
||||
/>
|
||||
{/* Stats Cards */}
|
||||
{cheapestTld && mostExpensive && highRiskTld && (
|
||||
<div className="grid grid-cols-3 gap-4 mb-10">
|
||||
<div className="bg-[#050505] border border-white/10 p-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-8 h-8 bg-accent/10 border border-accent/20 flex items-center justify-center">
|
||||
<DollarSign className="w-4 h-4 text-accent" />
|
||||
</div>
|
||||
<span className="text-xs font-mono text-white/40 uppercase">Best Value</span>
|
||||
</div>
|
||||
<div className="text-2xl font-display text-white">.{cheapestTld.tld.toUpperCase()}</div>
|
||||
<div className="text-sm text-accent font-mono">${cheapestTld.registration_price}/yr</div>
|
||||
</div>
|
||||
<div className="bg-[#050505] border border-white/10 p-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-8 h-8 bg-blue-500/10 border border-blue-500/20 flex items-center justify-center">
|
||||
<BarChart3 className="w-4 h-4 text-blue-400" />
|
||||
</div>
|
||||
<span className="text-xs font-mono text-white/40 uppercase">Most Tracked</span>
|
||||
</div>
|
||||
<div className="text-2xl font-display text-white">.COM</div>
|
||||
<div className="text-sm text-blue-400 font-mono">Industry Standard</div>
|
||||
</div>
|
||||
<div className="bg-[#050505] border border-white/10 p-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-8 h-8 bg-red-500/10 border border-red-500/20 flex items-center justify-center">
|
||||
<AlertTriangle className="w-4 h-4 text-red-400" />
|
||||
</div>
|
||||
<span className="text-xs font-mono text-white/40 uppercase">Watch Renewal</span>
|
||||
</div>
|
||||
<div className="text-2xl font-display text-white">.{highRiskTld.tld.toUpperCase()}</div>
|
||||
<div className="text-sm text-red-400 font-mono">High renewal cost</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tech Bar - Filter & Search - Matched Style */}
|
||||
<div className="mb-10 animate-slide-up sticky top-20 z-30 backdrop-blur-xl bg-[#020202]/90 border-y border-white/[0.08] py-5 -mx-4 px-4 sm:mx-0 sm:px-0">
|
||||
<div className="flex flex-col lg:flex-row gap-5 justify-between items-center max-w-[1400px] mx-auto">
|
||||
<div className="relative w-full lg:w-[480px] group">
|
||||
<Search className="absolute left-5 top-1/2 -translate-y-1/2 w-5 h-5 text-white/40 group-focus-within:text-accent transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="SEARCH_TLDS..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 overflow-x-auto pb-2 lg:pb-0 w-full lg:w-auto">
|
||||
{[
|
||||
{ id: 'volume', label: 'Volume' },
|
||||
{ id: 'change', label: 'Momentum' },
|
||||
{ id: 'price', label: 'Buy Price' },
|
||||
{ id: 'risk', label: 'Risk Factor' }
|
||||
].map((filter) => (
|
||||
<button
|
||||
key={filter.id}
|
||||
onClick={() => 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}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Grid - Matched Font Sizes and Styles */}
|
||||
<div className="border border-white/[0.08] bg-[#050505] animate-slide-up shadow-2xl mb-20">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-[#0A0A0A] border-b border-white/[0.08]">
|
||||
<th className="text-left px-8 py-5 text-xs uppercase tracking-widest text-white/40 font-bold">Asset Class</th>
|
||||
<th className="text-right px-8 py-5 text-xs uppercase tracking-widest text-white/40 font-bold">Buy Price</th>
|
||||
<th className="text-right px-8 py-5 text-xs uppercase tracking-widest text-white/40 font-bold">Trend (7d)</th>
|
||||
<th className="text-right px-8 py-5 text-xs uppercase tracking-widest text-white/40 font-bold hidden md:table-cell">Renewal Cost</th>
|
||||
<th className="text-right px-8 py-5 text-xs uppercase tracking-widest text-white/40 font-bold hidden lg:table-cell">Registrations (24h)</th>
|
||||
<th className="text-center px-8 py-5 text-xs uppercase tracking-widest text-white/40 font-bold hidden lg:table-cell">Risk Level</th>
|
||||
<th className="px-8 py-5"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.03]">
|
||||
{filteredTlds.map((tld) => {
|
||||
const isLocked = !isAuthenticated && !isPublicTld(tld.tld)
|
||||
|
||||
return (
|
||||
<tr key={tld.tld} className="group hover:bg-[#0F0F0F] transition-all duration-200 border-l-2 border-transparent hover:border-accent">
|
||||
<td className="px-8 py-6">
|
||||
<Link href={`/discover/${tld.tld}`} className="flex items-center gap-4 group-hover:translate-x-2 transition-transform duration-300">
|
||||
{/* Changed to font-mono and adjusted size to match Acquire's tech look while being distinct for TLDs */}
|
||||
<span className="font-mono text-2xl font-medium text-white group-hover:text-accent transition-colors tracking-tight">.{tld.tld}</span>
|
||||
{tld.change_7d > 10 && <Flame className="w-4 h-4 text-accent animate-pulse" />}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-8 py-6 text-right">
|
||||
<div className="flex flex-col items-end">
|
||||
{/* Matched font-mono text-lg from Acquire */}
|
||||
<span className="font-mono text-lg font-medium text-white">${tld.price}</span>
|
||||
<span className="text-[10px] text-white/30 uppercase tracking-widest">Entry</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-6 text-right">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className={clsx(
|
||||
"font-mono text-base flex items-center gap-1",
|
||||
tld.change_7d > 0 ? "text-accent" : tld.change_7d < 0 ? "text-red-400" : "text-white/40"
|
||||
)}>
|
||||
{tld.change_7d > 0 ? <TrendingUp className="w-3 h-3"/> : tld.change_7d < 0 ? <TrendingDown className="w-3 h-3"/> : <Minus className="w-3 h-3"/>}
|
||||
{Math.abs(tld.change_7d)}%
|
||||
</span>
|
||||
<span className="text-[10px] text-white/30 uppercase tracking-widest">Momentum</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Renewal Price - Locked for guests */}
|
||||
<td className="px-8 py-6 text-right hidden md:table-cell">
|
||||
{isLocked ? (
|
||||
<div className="flex justify-end items-center gap-2 text-white/20 select-none">
|
||||
<Lock className="w-3 h-3" />
|
||||
<span className="blur-[4px] font-mono text-lg">$XX.XX</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-end">
|
||||
<span className={clsx(
|
||||
"font-mono text-lg font-medium text-white/80",
|
||||
tld.renewal_price > tld.price * 2 ? "text-red-400" : "text-white/80"
|
||||
)}>${tld.renewal_price}</span>
|
||||
<span className="text-[10px] text-white/30 uppercase tracking-widest">Recurring</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Volume - Locked for guests */}
|
||||
<td className="px-8 py-6 text-right hidden lg:table-cell">
|
||||
{isLocked ? (
|
||||
<div className="flex justify-end items-center gap-2 text-white/20 select-none">
|
||||
<span className="blur-[4px] font-mono text-base">XXXX</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="font-mono text-base text-white/60">{tld.registration_count.toLocaleString()}</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Risk Level - Locked for guests */}
|
||||
<td className="px-8 py-6 text-center hidden lg:table-cell">
|
||||
{isLocked ? (
|
||||
<div className="flex justify-center">
|
||||
<span className="bg-white/5 text-white/20 text-[10px] font-bold uppercase tracking-widest px-3 py-1 blur-[2px]">LOCKED</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className={clsx(
|
||||
"text-[10px] font-bold uppercase tracking-widest px-3 py-1 border",
|
||||
tld.risk_level === 'Low' ? "border-accent/20 text-accent bg-accent/5" :
|
||||
tld.risk_level === 'Medium' ? "border-amber-500/20 text-amber-500 bg-amber-500/5" :
|
||||
"border-red-500/20 text-red-500 bg-red-500/5"
|
||||
)}>
|
||||
{tld.risk_level}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="px-8 py-6 text-right">
|
||||
<Link
|
||||
href={`/discover/${tld.tld}`}
|
||||
className="inline-flex items-center justify-center w-10 h-10 border border-white/10 text-white/40 hover:text-black hover:bg-white hover:border-white transition-all duration-300 group-hover:border-accent group-hover:text-accent opacity-0 group-hover:opacity-100 transform translate-x-2 group-hover:translate-x-0"
|
||||
>
|
||||
<ArrowUpRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA for Locked Data */}
|
||||
{/* Login Banner */}
|
||||
{!isAuthenticated && (
|
||||
<div className="mt-20 p-px bg-gradient-to-r from-accent/20 via-white/5 to-accent/20 max-w-4xl mx-auto">
|
||||
<div className="bg-[#080808] p-12 text-center relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[url('/noise.png')] opacity-[0.05]" />
|
||||
<div className="relative z-10">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 border border-accent/20 bg-accent/5 text-accent mb-6">
|
||||
<ShieldAlert className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-3xl font-display text-white mb-4">Protect Your Capital.</h3>
|
||||
<p className="text-white/50 mb-8 max-w-lg mx-auto text-lg font-light leading-relaxed">
|
||||
Don't buy a cheap domain with expensive renewal fees.
|
||||
Our 'Trader' plan reveals hidden costs and risk levels for every TLD.
|
||||
</p>
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="inline-flex items-center gap-3 px-8 py-4 bg-accent text-black text-xs font-bold uppercase tracking-widest hover:bg-white transition-all"
|
||||
style={{ clipPath: 'polygon(10px 0, 100% 0, 100% 100%, 0 100%, 0 10px)' }}
|
||||
>
|
||||
Unlock Intelligence
|
||||
<Zap className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mb-8 p-1 border border-accent/20 bg-accent/5 max-w-3xl">
|
||||
<div className="bg-[#050505] p-6 flex items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-accent/10 border border-accent/20 flex items-center justify-center text-accent">
|
||||
<Lock className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-base font-bold text-white mb-0.5">Unlock Full Intelligence</p>
|
||||
<p className="text-sm font-mono text-white/50">See renewal prices and risk levels for all TLDs.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/register"
|
||||
className="shrink-0 px-6 py-3 bg-accent text-black text-xs font-bold uppercase tracking-widest hover:bg-white transition-all"
|
||||
>
|
||||
Sign Up Free
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search & Filters Bar */}
|
||||
<div className="mb-6 sticky top-20 z-30 backdrop-blur-xl bg-[#020202]/90 border-y border-white/[0.08] py-4 -mx-6 px-6">
|
||||
<div className="flex gap-4 justify-between items-center">
|
||||
<div className="relative w-[400px] group">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40 group-focus-within:text-accent transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search TLDs..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ 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) => (
|
||||
<button
|
||||
key={filter.id}
|
||||
onClick={() => 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}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Table */}
|
||||
<div className="border border-white/[0.08] bg-[#050505]">
|
||||
{/* Table Header */}
|
||||
<div className="grid grid-cols-[1fr_120px_120px_100px_80px] gap-4 px-6 py-4 bg-[#0A0A0A] border-b border-white/[0.08] text-[10px] font-mono text-white/40 uppercase tracking-wider">
|
||||
<div>Extension</div>
|
||||
<div className="text-right">Registration</div>
|
||||
<div className="text-right">Renewal</div>
|
||||
<div className="text-center">Risk</div>
|
||||
<div></div>
|
||||
</div>
|
||||
|
||||
{/* Table Body */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
||||
</div>
|
||||
) : filteredTlds.length === 0 ? (
|
||||
<div className="text-center py-20 text-white/30 font-mono">No TLDs found</div>
|
||||
) : (
|
||||
<div>
|
||||
{filteredTlds.map((tld) => {
|
||||
const isLocked = !isAuthenticated && !isPublicTld(tld.tld)
|
||||
const riskLevel = getRiskLevel(tld.registration_price, tld.renewal_price)
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tld.tld}
|
||||
href={`/terminal/intel/${tld.tld}`}
|
||||
className="grid grid-cols-[1fr_120px_120px_100px_80px] gap-4 items-center px-6 py-4 border-b border-white/[0.03] hover:bg-white/[0.02] transition-all group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-xl text-white group-hover:text-accent transition-colors">.{tld.tld}</span>
|
||||
<span className="text-[10px] text-white/30 font-mono uppercase">{tld.type}</span>
|
||||
</div>
|
||||
<div className="text-right font-mono text-base text-accent">
|
||||
${tld.registration_price || '—'}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{isLocked ? (
|
||||
<span className="text-white/20 flex items-center gap-1 justify-end">
|
||||
<Lock className="w-3 h-3" />
|
||||
<span className="blur-[3px]">$XX</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className={clsx(
|
||||
"font-mono text-base",
|
||||
riskLevel === 'High' ? 'text-red-400' : 'text-white/60'
|
||||
)}>
|
||||
${tld.renewal_price || '—'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
{isLocked ? (
|
||||
<span className="text-[10px] text-white/20 blur-[2px]">---</span>
|
||||
) : (
|
||||
<span className={clsx(
|
||||
"text-[10px] font-bold uppercase tracking-widest px-2 py-1 border",
|
||||
riskLevel === 'Low' ? "border-accent/20 text-accent bg-accent/5" :
|
||||
riskLevel === 'Medium' ? "border-amber-500/20 text-amber-500 bg-amber-500/5" :
|
||||
"border-red-500/20 text-red-500 bg-red-500/5"
|
||||
)}>
|
||||
{riskLevel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<div className="w-8 h-8 border border-white/10 flex items-center justify-center text-white/30 group-hover:bg-white group-hover:text-black transition-all">
|
||||
<ArrowUpRight className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats Footer */}
|
||||
{!loading && (
|
||||
<div className="mt-4 flex justify-between text-[10px] font-mono text-white/30 uppercase tracking-widest">
|
||||
<span>Data updated daily</span>
|
||||
<span>TLDs: {filteredTlds.length}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom CTA */}
|
||||
{!isAuthenticated && (
|
||||
<div className="mt-16 p-px bg-gradient-to-r from-accent/20 via-white/5 to-accent/20 max-w-3xl">
|
||||
<div className="bg-[#080808] p-10 text-left">
|
||||
<div className="inline-flex items-center justify-center w-10 h-10 border border-accent/20 bg-accent/5 text-accent mb-4">
|
||||
<ShieldAlert className="w-5 h-5" />
|
||||
</div>
|
||||
<h3 className="text-xl font-display text-white mb-2">Avoid Hidden Costs.</h3>
|
||||
<p className="text-white/50 mb-6 max-w-md text-sm">
|
||||
Some TLDs have renewal prices 5-10x higher than registration.
|
||||
Unlock full pricing data to make informed decisions.
|
||||
</p>
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-black text-xs font-bold uppercase tracking-widest hover:bg-white transition-all"
|
||||
>
|
||||
Unlock Intel <Zap className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -198,16 +198,16 @@ export default function PricingPage() {
|
||||
)}
|
||||
|
||||
{/* Hero */}
|
||||
<div className="text-left mb-12 sm:mb-16 animate-fade-in">
|
||||
<span className="text-accent font-mono text-[10px] sm:text-xs uppercase tracking-[0.2em] mb-3 sm:mb-4 block flex items-center gap-2">
|
||||
<div className="text-left lg:text-center mb-12 sm:mb-16 animate-fade-in">
|
||||
<span className="text-accent font-mono text-[10px] sm:text-xs uppercase tracking-[0.2em] mb-3 sm:mb-4 block flex items-center gap-2 lg:justify-center">
|
||||
<div className="w-1.5 h-1.5 bg-accent animate-pulse" />
|
||||
Clearance Levels
|
||||
</span>
|
||||
<h1 className="font-display text-[2.5rem] sm:text-[4rem] md:text-[5rem] lg:text-[6rem] leading-[0.9] tracking-[-0.03em] text-white">
|
||||
Pick your weapon.
|
||||
</h1>
|
||||
<p className="mt-5 sm:mt-8 text-sm sm:text-lg lg:text-xl text-white/50 max-w-xl font-light leading-relaxed">
|
||||
Start free. Scale when you're ready.
|
||||
<p className="mt-5 sm:mt-8 text-sm sm:text-lg lg:text-xl text-white/50 max-w-xl lg:mx-auto font-light leading-relaxed">
|
||||
Start free. Scale when you're ready. All plans include core features.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -244,9 +244,9 @@ export default function PricingPage() {
|
||||
|
||||
<div className="relative flex-1 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-6 sm:mb-8 text-left sm:text-center pb-6 sm:pb-8 border-b border-white/10">
|
||||
<div className="mb-6 sm:mb-8 text-left lg:text-center pb-6 sm:pb-8 border-b border-white/10">
|
||||
<h3 className="text-xl sm:text-2xl font-display text-white mb-3 sm:mb-4">{tier.name}</h3>
|
||||
<div className="flex items-baseline justify-start sm:justify-center gap-1 mb-3 sm:mb-4">
|
||||
<div className="flex items-baseline justify-start lg:justify-center gap-1 mb-3 sm:mb-4">
|
||||
{tier.price === '0' ? (
|
||||
<span className="text-3xl sm:text-5xl font-mono text-white tracking-tight">Free</span>
|
||||
) : (
|
||||
@ -355,8 +355,8 @@ export default function PricingPage() {
|
||||
</div>
|
||||
|
||||
{/* FAQ */}
|
||||
<div className="max-w-3xl mx-auto lg:mx-0">
|
||||
<h2 className="text-2xl sm:text-3xl font-display text-white text-left mb-8 sm:mb-12">Mission Support</h2>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<h2 className="text-2xl sm:text-3xl font-display text-white text-left lg:text-center mb-8 sm:mb-12">Mission Support</h2>
|
||||
<div className="space-y-4">
|
||||
{faqs.map((faq, i) => (
|
||||
<div
|
||||
|
||||
@ -532,6 +532,26 @@ function CreateListingWizard({ onClose, onSuccess, prefillDomain }: {
|
||||
const [verificationData, setVerificationData] = useState<any>(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 }: {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[10px] font-mono text-white/40 uppercase mb-2">Domain Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={domain}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<label className="block text-[10px] font-mono text-white/40 uppercase mb-2">Select Domain from Portfolio *</label>
|
||||
{loadingDomains ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="w-5 h-5 text-accent animate-spin" />
|
||||
</div>
|
||||
) : portfolioDomains.length === 0 ? (
|
||||
<div className="p-4 bg-amber-400/5 border border-amber-400/20 text-center">
|
||||
<AlertCircle className="w-6 h-6 text-amber-400 mx-auto mb-2" />
|
||||
<p className="text-xs text-amber-400 font-mono mb-3">No domains in your portfolio</p>
|
||||
<a href="/terminal/portfolio" className="inline-flex items-center gap-2 px-4 py-2 bg-accent text-black text-xs font-bold uppercase">
|
||||
Add Domains to Portfolio
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={domain}
|
||||
onChange={(e) => 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"
|
||||
>
|
||||
<option value="">— Select a domain —</option>
|
||||
{portfolioDomains.map(d => (
|
||||
<option key={d.id} value={d.domain}>{d.domain}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<p className="text-[9px] font-mono text-white/30 mt-2">
|
||||
Only domains from your portfolio can be listed. DNS verification required after listing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@ -446,15 +446,19 @@ export default function PortfolioPage() {
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className={clsx(
|
||||
"w-8 h-8 flex items-center justify-center border shrink-0",
|
||||
domain.is_sold ? "bg-white/[0.02] border-white/[0.06]" : "bg-accent/10 border-accent/20"
|
||||
domain.is_sold ? "bg-white/[0.02] border-white/[0.06]" :
|
||||
domain.is_dns_verified ? "bg-accent/10 border-accent/20" : "bg-blue-400/10 border-blue-400/20"
|
||||
)}>
|
||||
{domain.is_sold ? <CheckCircle className="w-4 h-4 text-white/30" /> : <Briefcase className="w-4 h-4 text-accent" />}
|
||||
{domain.is_sold ? <CheckCircle className="w-4 h-4 text-white/30" /> :
|
||||
domain.is_dns_verified ? <ShieldCheck className="w-4 h-4 text-accent" /> : <ShieldAlert className="w-4 h-4 text-blue-400" />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-bold text-white font-mono truncate group-hover:text-accent transition-colors">{domain.domain}</div>
|
||||
<div className="flex items-center gap-2 text-[10px] font-mono text-white/30">
|
||||
<span>{domain.registrar || 'Unknown'}</span>
|
||||
{domain.is_sold && <span className="px-1 py-0.5 bg-white/5 text-white/40">SOLD</span>}
|
||||
{!domain.is_sold && domain.is_dns_verified && <span className="px-1 py-0.5 bg-accent/10 text-accent">VERIFIED</span>}
|
||||
{!domain.is_sold && !domain.is_dns_verified && <span className="px-1 py-0.5 bg-blue-400/10 text-blue-400">UNVERIFIED</span>}
|
||||
</div>
|
||||
</div>
|
||||
<a href={`https://${domain.domain}`} target="_blank" className="opacity-0 group-hover:opacity-50 hover:!opacity-100 transition-opacity ml-2">
|
||||
@ -572,6 +576,9 @@ export default function PortfolioPage() {
|
||||
{/* DOMAIN DETAIL MODAL */}
|
||||
{selectedDomain && <DomainDetailModal domain={selectedDomain} onClose={() => setSelectedDomain(null)} onUpdate={loadData} canListForSale={canListForSale} />}
|
||||
|
||||
{/* DNS VERIFICATION MODAL */}
|
||||
{verifyingDomain && <DnsVerificationModal domain={verifyingDomain} onClose={() => setVerifyingDomain(null)} onSuccess={() => { loadData(); setVerifyingDomain(null) }} />}
|
||||
|
||||
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
|
||||
</div>
|
||||
)
|
||||
@ -873,3 +880,169 @@ function MobileDrawer({ user, tierName, TierIcon, sections, onClose, onLogout }:
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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<string | null>(null)
|
||||
const [checkResult, setCheckResult] = useState<string | null>(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 (
|
||||
<div className="fixed inset-0 z-[110] bg-black/80 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="w-full max-w-lg bg-[#0A0A0A] border border-white/[0.08]" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/[0.08]">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-xs font-mono text-blue-400 uppercase tracking-wider">Verify Domain Ownership</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/40">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Domain Header */}
|
||||
<div className="text-center py-3 border-b border-white/[0.08]">
|
||||
<h2 className="text-xl font-bold font-mono text-white">{domain.domain}</h2>
|
||||
<p className="text-xs font-mono text-white/40 mt-1">DNS Verification Required</p>
|
||||
</div>
|
||||
|
||||
{step === 'loading' && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'instructions' && verificationData && (
|
||||
<>
|
||||
{/* Instructions */}
|
||||
<div className="p-4 bg-blue-400/5 border border-blue-400/20">
|
||||
<h3 className="text-sm font-bold text-white mb-2">Add this TXT record to your DNS:</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-[9px] font-mono text-white/40 uppercase mb-1">Host / Name</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3 py-2 bg-black/50 text-sm font-mono text-white break-all">_pounce</code>
|
||||
<button onClick={() => handleCopy('_pounce')} className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/40 hover:text-white">
|
||||
{copied ? <Check className="w-4 h-4 text-accent" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[9px] font-mono text-white/40 uppercase mb-1">Type</div>
|
||||
<code className="block px-3 py-2 bg-black/50 text-sm font-mono text-white">TXT</code>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[9px] font-mono text-white/40 uppercase mb-1">Value</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3 py-2 bg-black/50 text-sm font-mono text-accent break-all">{verificationData.verification_code}</code>
|
||||
<button onClick={() => 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 ? <Check className="w-4 h-4 text-accent" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-3 bg-white/[0.02] border border-white/[0.08] text-xs text-white/50 font-mono">
|
||||
<p>DNS changes can take up to 48 hours to propagate, but usually complete within minutes.</p>
|
||||
</div>
|
||||
|
||||
{/* Error/Check Result */}
|
||||
{error && (
|
||||
<div className="p-3 bg-rose-500/10 border border-rose-500/20 text-rose-400 text-xs font-mono">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{checkResult && (
|
||||
<div className="p-3 bg-amber-400/10 border border-amber-400/20 text-amber-400 text-xs font-mono">
|
||||
{checkResult}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button onClick={onClose} className="flex-1 py-2.5 border border-white/10 text-white/60 text-xs font-mono uppercase">
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={handleCheck} className="flex-1 py-2.5 bg-blue-400 text-black text-xs font-bold uppercase flex items-center justify-center gap-2">
|
||||
<RefreshCw className="w-4 h-4" />Check Verification
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'checking' && (
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-3">
|
||||
<Loader2 className="w-6 h-6 text-blue-400 animate-spin" />
|
||||
<p className="text-sm font-mono text-white/60">Checking DNS records...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'instructions' && !verificationData && error && (
|
||||
<div className="p-4 bg-rose-500/10 border border-rose-500/20">
|
||||
<p className="text-rose-400 text-sm">{error}</p>
|
||||
<button onClick={onClose} className="mt-4 w-full py-2.5 border border-white/10 text-white/60 text-xs font-mono uppercase">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -362,18 +362,14 @@ export default function WatchlistPage() {
|
||||
{/* ADD DOMAIN + FILTERS */}
|
||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||
<section className="px-4 lg:px-10 py-4 border-b border-white/[0.08]">
|
||||
{/* Add Domain Form */}
|
||||
{/* Add Domain Form - Always visible with accent border */}
|
||||
<form onSubmit={handleAdd} className="relative mb-4">
|
||||
<div className={clsx(
|
||||
"flex items-center border-2 transition-all duration-200",
|
||||
searchFocused
|
||||
? "border-accent/50 bg-accent/[0.02]"
|
||||
: "border-white/[0.08] bg-white/[0.02]"
|
||||
"border-accent/50 bg-accent/[0.03]",
|
||||
searchFocused && "border-accent bg-accent/[0.05]"
|
||||
)}>
|
||||
<Plus className={clsx(
|
||||
"w-4 h-4 ml-4 transition-colors",
|
||||
searchFocused ? "text-accent" : "text-white/30"
|
||||
)} />
|
||||
<Plus className="w-4 h-4 ml-4 text-accent transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
value={newDomain}
|
||||
|
||||
@ -35,23 +35,41 @@ function StatusBadge({ status }: { status: string }) {
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ACTIVATE MODAL (simplified)
|
||||
// ACTIVATE MODAL - Only verified portfolio domains
|
||||
// ============================================================================
|
||||
|
||||
function ActivateModal({ isOpen, onClose, onSuccess }: { isOpen: boolean; onClose: () => 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<string | null>(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
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/[0.08]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-accent" />
|
||||
<span className="text-xs font-mono text-accent uppercase tracking-wider">Add Domain</span>
|
||||
<span className="text-xs font-mono text-accent uppercase tracking-wider">Activate Yield</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/40"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-[9px] font-mono text-white/40 uppercase mb-1.5">Domain</label>
|
||||
<input type="text" value={domain} onChange={(e) => 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" />
|
||||
</div>
|
||||
{error && <div className="p-2 bg-rose-500/10 border border-rose-500/20 text-rose-400 text-xs">{error}</div>}
|
||||
<button onClick={handleActivate} disabled={loading || !domain.trim()}
|
||||
className="w-full py-2.5 bg-accent text-black text-xs font-bold uppercase flex items-center justify-center gap-2 disabled:opacity-50">
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
|
||||
Activate
|
||||
</button>
|
||||
{loadingDomains ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="w-5 h-5 text-accent animate-spin" />
|
||||
</div>
|
||||
) : verifiedDomains.length === 0 ? (
|
||||
<div className="text-center py-6">
|
||||
<AlertCircle className="w-8 h-8 text-amber-400 mx-auto mb-3" />
|
||||
<h3 className="text-sm font-bold text-white mb-2">No Verified Domains</h3>
|
||||
<p className="text-xs text-white/50 mb-4">
|
||||
You need to add domains to your portfolio and verify DNS ownership before activating Yield.
|
||||
</p>
|
||||
<a href="/terminal/portfolio" className="inline-flex items-center gap-2 px-4 py-2 bg-accent text-black text-xs font-bold uppercase">
|
||||
Go to Portfolio
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-[9px] font-mono text-white/40 uppercase mb-1.5">Select Domain (DNS Verified)</label>
|
||||
<select
|
||||
value={selectedDomain}
|
||||
onChange={(e) => setSelectedDomain(e.target.value)}
|
||||
className="w-full px-3 py-2.5 bg-white/5 border border-white/10 text-white text-sm font-mono outline-none focus:border-accent/50"
|
||||
>
|
||||
<option value="">— Select a domain —</option>
|
||||
{verifiedDomains.map(d => (
|
||||
<option key={d.id} value={d.domain}>{d.domain}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="p-3 bg-accent/5 border border-accent/20 text-xs text-accent/80 font-mono">
|
||||
<p>Only DNS-verified domains from your portfolio can be activated for Yield.</p>
|
||||
</div>
|
||||
{error && <div className="p-2 bg-rose-500/10 border border-rose-500/20 text-rose-400 text-xs">{error}</div>}
|
||||
<button onClick={handleActivate} disabled={loading || !selectedDomain}
|
||||
className="w-full py-2.5 bg-accent text-black text-xs font-bold uppercase flex items-center justify-center gap-2 disabled:opacity-50">
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||
Activate Yield
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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<string, { cpa: number, conv: number, label: string }> = {
|
||||
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() {
|
||||
<input
|
||||
type="range"
|
||||
min="100"
|
||||
max="10000"
|
||||
max="5000"
|
||||
step="100"
|
||||
value={traffic}
|
||||
onChange={(e) => setTraffic(parseInt(e.target.value))}
|
||||
@ -121,12 +123,29 @@ function YieldSimulator() {
|
||||
{/* Result Area */}
|
||||
<div className="bg-white/[0.03] border border-white/5 p-5 relative overflow-hidden">
|
||||
<div className="relative z-10">
|
||||
<div className="flex justify-between items-end mb-2">
|
||||
<span className="text-[10px] font-mono uppercase tracking-widest text-white/40">Annual Revenue</span>
|
||||
<div className="flex justify-between items-end mb-3">
|
||||
<span className="text-[10px] font-mono uppercase tracking-widest text-white/40">Est. Annual</span>
|
||||
<div className="text-right">
|
||||
<span className="block font-display text-3xl text-white tracking-tight leading-none">${annualYield.toLocaleString('en-US')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conversion breakdown */}
|
||||
<div className="bg-white/[0.02] border border-white/5 p-3 mb-3">
|
||||
<div className="flex justify-between text-[10px] font-mono mb-1">
|
||||
<span className="text-white/40">Visitors</span>
|
||||
<span className="text-white">{traffic.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] font-mono mb-1">
|
||||
<span className="text-white/40">Conv. Rate</span>
|
||||
<span className="text-accent">{currentVertical.conv}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] font-mono">
|
||||
<span className="text-white/40">Conversions/mo</span>
|
||||
<span className="text-white">{conversions}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-3 border-t border-white/5 text-[10px] font-mono text-white/30">
|
||||
<span>${monthlyRevenue.toLocaleString('en-US')} / mo</span>
|
||||
<span>CPA: ${currentVertical.cpa}</span>
|
||||
|
||||
Reference in New Issue
Block a user