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,
|
notes=d.notes,
|
||||||
tags=d.tags,
|
tags=d.tags,
|
||||||
roi=d.roi,
|
roi=d.roi,
|
||||||
is_dns_verified=d.is_dns_verified,
|
is_dns_verified=getattr(d, 'is_dns_verified', False) or False,
|
||||||
verification_status=d.verification_status,
|
verification_status=getattr(d, 'verification_status', 'unverified') or 'unverified',
|
||||||
verification_code=d.verification_code,
|
verification_code=getattr(d, 'verification_code', None),
|
||||||
verified_at=d.verified_at,
|
verified_at=getattr(d, 'verified_at', None),
|
||||||
created_at=d.created_at,
|
created_at=d.created_at,
|
||||||
updated_at=d.updated_at,
|
updated_at=d.updated_at,
|
||||||
)
|
)
|
||||||
@ -381,10 +381,10 @@ async def add_portfolio_domain(
|
|||||||
notes=domain.notes,
|
notes=domain.notes,
|
||||||
tags=domain.tags,
|
tags=domain.tags,
|
||||||
roi=domain.roi,
|
roi=domain.roi,
|
||||||
is_dns_verified=domain.is_dns_verified,
|
is_dns_verified=getattr(domain, 'is_dns_verified', False) or False,
|
||||||
verification_status=domain.verification_status,
|
verification_status=getattr(domain, 'verification_status', 'unverified') or 'unverified',
|
||||||
verification_code=domain.verification_code,
|
verification_code=getattr(domain, 'verification_code', None),
|
||||||
verified_at=domain.verified_at,
|
verified_at=getattr(domain, 'verified_at', None),
|
||||||
created_at=domain.created_at,
|
created_at=domain.created_at,
|
||||||
updated_at=domain.updated_at,
|
updated_at=domain.updated_at,
|
||||||
)
|
)
|
||||||
@ -432,10 +432,10 @@ async def get_portfolio_domain(
|
|||||||
notes=domain.notes,
|
notes=domain.notes,
|
||||||
tags=domain.tags,
|
tags=domain.tags,
|
||||||
roi=domain.roi,
|
roi=domain.roi,
|
||||||
is_dns_verified=domain.is_dns_verified,
|
is_dns_verified=getattr(domain, 'is_dns_verified', False) or False,
|
||||||
verification_status=domain.verification_status,
|
verification_status=getattr(domain, 'verification_status', 'unverified') or 'unverified',
|
||||||
verification_code=domain.verification_code,
|
verification_code=getattr(domain, 'verification_code', None),
|
||||||
verified_at=domain.verified_at,
|
verified_at=getattr(domain, 'verified_at', None),
|
||||||
created_at=domain.created_at,
|
created_at=domain.created_at,
|
||||||
updated_at=domain.updated_at,
|
updated_at=domain.updated_at,
|
||||||
)
|
)
|
||||||
@ -492,10 +492,10 @@ async def update_portfolio_domain(
|
|||||||
notes=domain.notes,
|
notes=domain.notes,
|
||||||
tags=domain.tags,
|
tags=domain.tags,
|
||||||
roi=domain.roi,
|
roi=domain.roi,
|
||||||
is_dns_verified=domain.is_dns_verified,
|
is_dns_verified=getattr(domain, 'is_dns_verified', False) or False,
|
||||||
verification_status=domain.verification_status,
|
verification_status=getattr(domain, 'verification_status', 'unverified') or 'unverified',
|
||||||
verification_code=domain.verification_code,
|
verification_code=getattr(domain, 'verification_code', None),
|
||||||
verified_at=domain.verified_at,
|
verified_at=getattr(domain, 'verified_at', None),
|
||||||
created_at=domain.created_at,
|
created_at=domain.created_at,
|
||||||
updated_at=domain.updated_at,
|
updated_at=domain.updated_at,
|
||||||
)
|
)
|
||||||
@ -552,10 +552,10 @@ async def mark_domain_sold(
|
|||||||
notes=domain.notes,
|
notes=domain.notes,
|
||||||
tags=domain.tags,
|
tags=domain.tags,
|
||||||
roi=domain.roi,
|
roi=domain.roi,
|
||||||
is_dns_verified=domain.is_dns_verified,
|
is_dns_verified=getattr(domain, 'is_dns_verified', False) or False,
|
||||||
verification_status=domain.verification_status,
|
verification_status=getattr(domain, 'verification_status', 'unverified') or 'unverified',
|
||||||
verification_code=domain.verification_code,
|
verification_code=getattr(domain, 'verification_code', None),
|
||||||
verified_at=domain.verified_at,
|
verified_at=getattr(domain, 'verified_at', None),
|
||||||
created_at=domain.created_at,
|
created_at=domain.created_at,
|
||||||
updated_at=domain.updated_at,
|
updated_at=domain.updated_at,
|
||||||
)
|
)
|
||||||
@ -639,10 +639,10 @@ async def refresh_domain_value(
|
|||||||
notes=domain.notes,
|
notes=domain.notes,
|
||||||
tags=domain.tags,
|
tags=domain.tags,
|
||||||
roi=domain.roi,
|
roi=domain.roi,
|
||||||
is_dns_verified=domain.is_dns_verified,
|
is_dns_verified=getattr(domain, 'is_dns_verified', False) or False,
|
||||||
verification_status=domain.verification_status,
|
verification_status=getattr(domain, 'verification_status', 'unverified') or 'unverified',
|
||||||
verification_code=domain.verification_code,
|
verification_code=getattr(domain, 'verification_code', None),
|
||||||
verified_at=domain.verified_at,
|
verified_at=getattr(domain, 'verified_at', None),
|
||||||
created_at=domain.created_at,
|
created_at=domain.created_at,
|
||||||
updated_at=domain.updated_at,
|
updated_at=domain.updated_at,
|
||||||
)
|
)
|
||||||
@ -696,10 +696,11 @@ def _domain_to_response(domain: PortfolioDomain) -> PortfolioDomainResponse:
|
|||||||
notes=domain.notes,
|
notes=domain.notes,
|
||||||
tags=domain.tags,
|
tags=domain.tags,
|
||||||
roi=domain.roi,
|
roi=domain.roi,
|
||||||
is_dns_verified=domain.is_dns_verified,
|
# Use getattr with defaults for new fields that may not exist in DB yet
|
||||||
verification_status=domain.verification_status,
|
is_dns_verified=getattr(domain, 'is_dns_verified', False) or False,
|
||||||
verification_code=domain.verification_code,
|
verification_status=getattr(domain, 'verification_status', 'unverified') or 'unverified',
|
||||||
verified_at=domain.verified_at,
|
verification_code=getattr(domain, 'verification_code', None),
|
||||||
|
verified_at=getattr(domain, 'verified_at', None),
|
||||||
created_at=domain.created_at,
|
created_at=domain.created_at,
|
||||||
updated_at=domain.updated_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")
|
logger.info("DB migrations: adding column users.referral_code")
|
||||||
await conn.execute(text("ALTER TABLE users ADD COLUMN referral_code VARCHAR(100)"))
|
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")
|
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
|
status: Mapped[str] = mapped_column(String(50), default="active") # active, expired, sold, parked
|
||||||
|
|
||||||
# DNS Verification (required for Yield and For Sale)
|
# DNS Verification (required for Yield and For Sale)
|
||||||
is_dns_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
# All fields nullable=True to avoid migration issues on existing databases
|
||||||
verification_status: Mapped[str] = mapped_column(String(50), default="unverified") # unverified, pending, verified, failed
|
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_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
verification_started_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
verification_started_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
verified_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>
|
||||||
<div className="grid grid-cols-3 gap-8 text-right">
|
<div className="grid grid-cols-3 gap-8 text-right">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-3xl font-display text-white mb-1">{allAuctions.length}</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</div>
|
<div className="text-[10px] uppercase tracking-widest text-white/30 font-mono">Live Auctions</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-3xl font-display text-accent mb-1">{endingSoon.length}</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</div>
|
<div className="text-[10px] uppercase tracking-widest text-accent/60 font-mono">Ending 24h</div>
|
||||||
</div>
|
</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 className="text-[10px] uppercase tracking-widest text-white/30 font-mono">Direct</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -6,80 +6,34 @@ import { Footer } from '@/components/Footer'
|
|||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import {
|
import {
|
||||||
TrendingUp,
|
|
||||||
TrendingDown,
|
|
||||||
Search,
|
Search,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Lock,
|
Lock,
|
||||||
ArrowRight,
|
|
||||||
Zap,
|
Zap,
|
||||||
Flame,
|
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
Minus
|
Globe,
|
||||||
|
DollarSign,
|
||||||
|
Loader2
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
interface TldData {
|
interface TldData {
|
||||||
tld: string
|
tld: string
|
||||||
price: number
|
registration_price: number | null
|
||||||
renewal_price: number
|
renewal_price: number | null
|
||||||
change_24h: number
|
type: string | null
|
||||||
change_7d: number
|
registrar_count: number
|
||||||
volume: number
|
|
||||||
risk_level: 'Low' | 'Medium' | 'High'
|
|
||||||
trend: 'up' | 'down' | 'stable'
|
|
||||||
registration_count: number
|
|
||||||
cheapest_registrar: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({ label, value, subvalue, trend, icon: Icon, color }: {
|
function getRiskLevel(regPrice: number | null, renewPrice: number | null): 'Low' | 'Medium' | 'High' {
|
||||||
label: string,
|
if (!regPrice || !renewPrice) return 'Medium'
|
||||||
value: string,
|
const ratio = renewPrice / regPrice
|
||||||
subvalue: string,
|
if (ratio > 5) return 'High'
|
||||||
trend?: 'up' | 'down',
|
if (ratio > 2) return 'Medium'
|
||||||
icon: any,
|
return 'Low'
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DiscoverPage() {
|
export default function DiscoverPage() {
|
||||||
@ -87,305 +41,487 @@ export default function DiscoverPage() {
|
|||||||
const [tlds, setTlds] = useState<TldData[]>([])
|
const [tlds, setTlds] = useState<TldData[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [sortBy, setSortBy] = useState<'volume' | 'change' | 'price' | 'risk'>('volume')
|
const [sortBy, setSortBy] = useState<'popular' | 'price' | 'renewal' | 'risk'>('popular')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkAuth()
|
checkAuth()
|
||||||
// Expanded Mock Data
|
loadTlds()
|
||||||
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)
|
|
||||||
}, [checkAuth])
|
}, [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
|
const filteredTlds = tlds
|
||||||
.filter(t => t.tld.includes(searchQuery.toLowerCase()))
|
.filter(t => t.tld.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
if (sortBy === 'volume') return b.volume - a.volume
|
if (sortBy === 'popular') return b.registrar_count - a.registrar_count
|
||||||
if (sortBy === 'change') return b.change_7d - a.change_7d
|
if (sortBy === 'price') return (a.registration_price || 999) - (b.registration_price || 999)
|
||||||
if (sortBy === 'price') return b.price - a.price
|
if (sortBy === 'renewal') return (a.renewal_price || 999) - (b.renewal_price || 999)
|
||||||
if (sortBy === 'risk') return (a.risk_level === 'High' ? 1 : 0) - (b.risk_level === 'High' ? 1 : 0)
|
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
|
return 0
|
||||||
})
|
})
|
||||||
|
|
||||||
// Top Movers Logic
|
// Stats
|
||||||
const topGainer = [...tlds].sort((a, b) => b.change_7d - a.change_7d)[0]
|
const cheapestTld = [...tlds].filter(t => t.registration_price).sort((a, b) => (a.registration_price || 999) - (b.registration_price || 999))[0]
|
||||||
const topVolume = [...tlds].sort((a, b) => b.volume - a.volume)[0]
|
const mostExpensive = [...tlds].filter(t => t.registration_price).sort((a, b) => (b.registration_price || 0) - (a.registration_price || 0))[0]
|
||||||
const highRisk = [...tlds].find(t => t.risk_level === 'High')
|
const highRiskTld = [...tlds].find(t => getRiskLevel(t.registration_price, t.renewal_price) === 'High')
|
||||||
|
|
||||||
// Gatekeeper Logic
|
// Gatekeeper Logic
|
||||||
const isPublicTld = (tld: string) => ['com', 'net', 'org'].includes(tld)
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-[#020202] text-white relative overflow-x-hidden selection:bg-accent/30 selection:text-white">
|
<div className="min-h-screen bg-[#020202] text-white relative overflow-x-hidden">
|
||||||
{/* Background Atmosphere - Matched with Acquire */}
|
{/* Background */}
|
||||||
<div className="fixed inset-0 pointer-events-none z-0">
|
<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 bg-[url('/noise.png')] opacity-[0.03] mix-blend-overlay" />
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 opacity-[0.03]"
|
className="absolute inset-0 opacity-[0.02]"
|
||||||
style={{
|
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)`,
|
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>
|
</div>
|
||||||
|
|
||||||
<Header />
|
<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">
|
<div className="max-w-[1400px] mx-auto">
|
||||||
|
|
||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
<div className="mb-12 sm:mb-16 animate-fade-in text-left">
|
<div className="mb-12 animate-fade-in">
|
||||||
<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="flex justify-between items-end gap-8 border-b border-white/[0.08] pb-10">
|
||||||
<div>
|
<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" />
|
<div className="w-1.5 h-1.5 bg-accent animate-pulse" />
|
||||||
Global Intelligence
|
TLD Intelligence
|
||||||
</span>
|
</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">
|
<h1 className="font-display text-[5rem] leading-[0.9] tracking-[-0.03em] text-white">
|
||||||
Market Pulse.
|
Discover TLDs.
|
||||||
</h1>
|
</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">
|
<p className="mt-6 text-lg text-white/50 max-w-2xl font-light leading-relaxed">
|
||||||
Real-time inflation monitor. Track pricing and breakout trends.
|
Compare registration and renewal prices across {tlds.length}+ top-level domains.
|
||||||
<span className="block mt-2 text-white/80">886+ TLDs monitored.</span>
|
<span className="block mt-2 text-white/80">Find the best value for your next domain.</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-8 text-right">
|
||||||
<div className="grid grid-cols-2 gap-12 text-right hidden lg:grid">
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-3xl font-display text-white mb-1">886+</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 Monitored</div>
|
<div className="text-[10px] uppercase tracking-widest text-white/30 font-mono">TLDs</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-3xl font-display text-white mb-1">24/7</div>
|
<div className="text-3xl font-display text-accent mb-1">${cheapestTld?.registration_price || '—'}</div>
|
||||||
<div className="text-[10px] uppercase tracking-widest text-accent font-mono">Live Sync</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top Movers Cards */}
|
{/* Stats Cards */}
|
||||||
{topGainer && topVolume && highRisk && (
|
{cheapestTld && mostExpensive && highRiskTld && (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 sm:gap-6 mb-10 sm:mb-16 animate-fade-in">
|
<div className="grid grid-cols-3 gap-4 mb-10">
|
||||||
<StatCard
|
<div className="bg-[#050505] border border-white/10 p-6">
|
||||||
label="Top Gainer (7d)"
|
<div className="flex items-center gap-2 mb-3">
|
||||||
value={`.${topGainer.tld.toUpperCase()}`}
|
<div className="w-8 h-8 bg-accent/10 border border-accent/20 flex items-center justify-center">
|
||||||
subvalue={`+${topGainer.change_7d}% Growth`}
|
<DollarSign className="w-4 h-4 text-accent" />
|
||||||
trend="up"
|
</div>
|
||||||
icon={TrendingUp}
|
<span className="text-xs font-mono text-white/40 uppercase">Best Value</span>
|
||||||
color="accent"
|
</div>
|
||||||
/>
|
<div className="text-2xl font-display text-white">.{cheapestTld.tld.toUpperCase()}</div>
|
||||||
<StatCard
|
<div className="text-sm text-accent font-mono">${cheapestTld.registration_price}/yr</div>
|
||||||
label="Highest Volume"
|
</div>
|
||||||
value={`.${topVolume.tld.toUpperCase()}`}
|
<div className="bg-[#050505] border border-white/10 p-6">
|
||||||
subvalue={`${(topVolume.volume / 1000).toFixed(0)}k Daily Ops`}
|
<div className="flex items-center gap-2 mb-3">
|
||||||
trend="up"
|
<div className="w-8 h-8 bg-blue-500/10 border border-blue-500/20 flex items-center justify-center">
|
||||||
icon={BarChart3}
|
<BarChart3 className="w-4 h-4 text-blue-400" />
|
||||||
color="blue"
|
</div>
|
||||||
/>
|
<span className="text-xs font-mono text-white/40 uppercase">Most Tracked</span>
|
||||||
<StatCard
|
</div>
|
||||||
label="Inflation Risk"
|
<div className="text-2xl font-display text-white">.COM</div>
|
||||||
value={`.${highRisk.tld.toUpperCase()}`}
|
<div className="text-sm text-blue-400 font-mono">Industry Standard</div>
|
||||||
subvalue="Renewal Price Spike"
|
</div>
|
||||||
trend="down"
|
<div className="bg-[#050505] border border-white/10 p-6">
|
||||||
icon={AlertTriangle}
|
<div className="flex items-center gap-2 mb-3">
|
||||||
color="red"
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tech Bar - Filter & Search - Matched Style */}
|
{/* Login Banner */}
|
||||||
<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 */}
|
|
||||||
{!isAuthenticated && (
|
{!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="mb-8 p-1 border border-accent/20 bg-accent/5 max-w-3xl">
|
||||||
<div className="bg-[#080808] p-12 text-center relative overflow-hidden">
|
<div className="bg-[#050505] p-6 flex items-center justify-between gap-6">
|
||||||
<div className="absolute inset-0 bg-[url('/noise.png')] opacity-[0.05]" />
|
<div className="flex items-center gap-4">
|
||||||
<div className="relative z-10">
|
<div className="w-10 h-10 bg-accent/10 border border-accent/20 flex items-center justify-center text-accent">
|
||||||
<div className="inline-flex items-center justify-center w-12 h-12 border border-accent/20 bg-accent/5 text-accent mb-6">
|
<Lock className="w-5 h-5" />
|
||||||
<ShieldAlert className="w-6 h-6" />
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<h3 className="text-3xl font-display text-white mb-4">Protect Your Capital.</h3>
|
<p className="text-base font-bold text-white mb-0.5">Unlock Full Intelligence</p>
|
||||||
<p className="text-white/50 mb-8 max-w-lg mx-auto text-lg font-light leading-relaxed">
|
<p className="text-sm font-mono text-white/50">See renewal prices and risk levels for all TLDs.</p>
|
||||||
Don't buy a cheap domain with expensive renewal fees.
|
</div>
|
||||||
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>
|
</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>
|
</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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -198,16 +198,16 @@ export default function PricingPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<div className="text-left mb-12 sm:mb-16 animate-fade-in">
|
<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">
|
<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" />
|
<div className="w-1.5 h-1.5 bg-accent animate-pulse" />
|
||||||
Clearance Levels
|
Clearance Levels
|
||||||
</span>
|
</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">
|
<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.
|
Pick your weapon.
|
||||||
</h1>
|
</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">
|
<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.
|
Start free. Scale when you're ready. All plans include core features.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -244,9 +244,9 @@ export default function PricingPage() {
|
|||||||
|
|
||||||
<div className="relative flex-1 flex flex-col">
|
<div className="relative flex-1 flex flex-col">
|
||||||
{/* Header */}
|
{/* 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>
|
<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' ? (
|
{tier.price === '0' ? (
|
||||||
<span className="text-3xl sm:text-5xl font-mono text-white tracking-tight">Free</span>
|
<span className="text-3xl sm:text-5xl font-mono text-white tracking-tight">Free</span>
|
||||||
) : (
|
) : (
|
||||||
@ -355,8 +355,8 @@ export default function PricingPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<div className="max-w-3xl mx-auto lg:mx-0">
|
<div className="max-w-3xl mx-auto">
|
||||||
<h2 className="text-2xl sm:text-3xl font-display text-white text-left mb-8 sm:mb-12">Mission Support</h2>
|
<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">
|
<div className="space-y-4">
|
||||||
{faqs.map((faq, i) => (
|
{faqs.map((faq, i) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -533,6 +533,26 @@ function CreateListingWizard({ onClose, onSuccess, prefillDomain }: {
|
|||||||
const [verifying, setVerifying] = useState(false)
|
const [verifying, setVerifying] = useState(false)
|
||||||
const [verified, setVerified] = 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
|
// Step 1: Create listing
|
||||||
const handleCreateListing = async () => {
|
const handleCreateListing = async () => {
|
||||||
if (!domain.trim()) return
|
if (!domain.trim()) return
|
||||||
@ -641,15 +661,34 @@ function CreateListingWizard({ onClose, onSuccess, prefillDomain }: {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[10px] font-mono text-white/40 uppercase mb-2">Domain Name *</label>
|
<label className="block text-[10px] font-mono text-white/40 uppercase mb-2">Select Domain from Portfolio *</label>
|
||||||
<input
|
{loadingDomains ? (
|
||||||
type="text"
|
<div className="flex items-center justify-center py-4">
|
||||||
value={domain}
|
<Loader2 className="w-5 h-5 text-accent animate-spin" />
|
||||||
onChange={(e) => setDomain(e.target.value)}
|
</div>
|
||||||
required
|
) : portfolioDomains.length === 0 ? (
|
||||||
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"
|
<div className="p-4 bg-amber-400/5 border border-amber-400/20 text-center">
|
||||||
placeholder="yourdomain.com"
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -446,15 +446,19 @@ export default function PortfolioPage() {
|
|||||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
"w-8 h-8 flex items-center justify-center border shrink-0",
|
"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>
|
||||||
<div className="min-w-0">
|
<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="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">
|
<div className="flex items-center gap-2 text-[10px] font-mono text-white/30">
|
||||||
<span>{domain.registrar || 'Unknown'}</span>
|
<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 && <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>
|
||||||
</div>
|
</div>
|
||||||
<a href={`https://${domain.domain}`} target="_blank" className="opacity-0 group-hover:opacity-50 hover:!opacity-100 transition-opacity ml-2">
|
<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 */}
|
{/* DOMAIN DETAIL MODAL */}
|
||||||
{selectedDomain && <DomainDetailModal domain={selectedDomain} onClose={() => setSelectedDomain(null)} onUpdate={loadData} canListForSale={canListForSale} />}
|
{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} />}
|
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -873,3 +880,169 @@ function MobileDrawer({ user, tierName, TierIcon, sections, onClose, onLogout }:
|
|||||||
</div>
|
</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 */}
|
{/* ADD DOMAIN + FILTERS */}
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||||
<section className="px-4 lg:px-10 py-4 border-b border-white/[0.08]">
|
<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">
|
<form onSubmit={handleAdd} className="relative mb-4">
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
"flex items-center border-2 transition-all duration-200",
|
"flex items-center border-2 transition-all duration-200",
|
||||||
searchFocused
|
"border-accent/50 bg-accent/[0.03]",
|
||||||
? "border-accent/50 bg-accent/[0.02]"
|
searchFocused && "border-accent bg-accent/[0.05]"
|
||||||
: "border-white/[0.08] bg-white/[0.02]"
|
|
||||||
)}>
|
)}>
|
||||||
<Plus className={clsx(
|
<Plus className="w-4 h-4 ml-4 text-accent transition-colors" />
|
||||||
"w-4 h-4 ml-4 transition-colors",
|
|
||||||
searchFocused ? "text-accent" : "text-white/30"
|
|
||||||
)} />
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newDomain}
|
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 }) {
|
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 [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
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 () => {
|
const handleActivate = async () => {
|
||||||
if (!domain.trim()) return
|
if (!selectedDomain) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
await api.activateYieldDomain(domain.trim(), true)
|
await api.activateYieldDomain(selectedDomain, true)
|
||||||
onSuccess()
|
onSuccess()
|
||||||
onClose()
|
onClose()
|
||||||
setDomain('')
|
setSelectedDomain('')
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed')
|
setError(err.message || 'Failed')
|
||||||
} finally {
|
} 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 justify-between p-4 border-b border-white/[0.08]">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Sparkles className="w-4 h-4 text-accent" />
|
<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>
|
</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>
|
<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>
|
||||||
<div className="p-4 space-y-4">
|
<div className="p-4 space-y-4">
|
||||||
<div>
|
{loadingDomains ? (
|
||||||
<label className="block text-[9px] font-mono text-white/40 uppercase mb-1.5">Domain</label>
|
<div className="flex items-center justify-center py-6">
|
||||||
<input type="text" value={domain} onChange={(e) => setDomain(e.target.value)} placeholder="example.com"
|
<Loader2 className="w-5 h-5 text-accent animate-spin" />
|
||||||
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>
|
||||||
</div>
|
) : verifiedDomains.length === 0 ? (
|
||||||
{error && <div className="p-2 bg-rose-500/10 border border-rose-500/20 text-rose-400 text-xs">{error}</div>}
|
<div className="text-center py-6">
|
||||||
<button onClick={handleActivate} disabled={loading || !domain.trim()}
|
<AlertCircle className="w-8 h-8 text-amber-400 mx-auto mb-3" />
|
||||||
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">
|
<h3 className="text-sm font-bold text-white mb-2">No Verified Domains</h3>
|
||||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
|
<p className="text-xs text-white/50 mb-4">
|
||||||
Activate
|
You need to add domains to your portfolio and verify DNS ownership before activating Yield.
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -31,22 +31,24 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
// Yield Simulator Component - SIMPLIFIED & COMPACT
|
// Yield Simulator Component - CONSERVATIVE VALUES
|
||||||
function YieldSimulator() {
|
function YieldSimulator() {
|
||||||
const [traffic, setTraffic] = useState(2500)
|
const [traffic, setTraffic] = useState(1000)
|
||||||
const [vertical, setVertical] = useState('finance')
|
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 }> = {
|
const verticals: Record<string, { cpa: number, conv: number, label: string }> = {
|
||||||
finance: { cpa: 120, conv: 3.5, label: 'Finance & Loans' },
|
finance: { cpa: 25, conv: 0.8, label: 'Finance & Loans' },
|
||||||
insurance: { cpa: 80, conv: 2.8, label: 'Insurance' },
|
insurance: { cpa: 18, conv: 0.6, label: 'Insurance' },
|
||||||
legal: { cpa: 150, conv: 1.5, label: 'Legal Services' },
|
legal: { cpa: 35, conv: 0.4, label: 'Legal Services' },
|
||||||
medical: { cpa: 60, conv: 4.0, label: 'Medical / Health' },
|
medical: { cpa: 15, conv: 1.0, label: 'Medical / Health' },
|
||||||
tech: { cpa: 45, conv: 2.2, label: 'Software / B2B' }
|
tech: { cpa: 12, conv: 0.5, label: 'Software / B2B' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentVertical = verticals[vertical]
|
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
|
const annualYield = monthlyRevenue * 12
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -80,7 +82,7 @@ function YieldSimulator() {
|
|||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="100"
|
min="100"
|
||||||
max="10000"
|
max="5000"
|
||||||
step="100"
|
step="100"
|
||||||
value={traffic}
|
value={traffic}
|
||||||
onChange={(e) => setTraffic(parseInt(e.target.value))}
|
onChange={(e) => setTraffic(parseInt(e.target.value))}
|
||||||
@ -121,12 +123,29 @@ function YieldSimulator() {
|
|||||||
{/* Result Area */}
|
{/* Result Area */}
|
||||||
<div className="bg-white/[0.03] border border-white/5 p-5 relative overflow-hidden">
|
<div className="bg-white/[0.03] border border-white/5 p-5 relative overflow-hidden">
|
||||||
<div className="relative z-10">
|
<div className="relative z-10">
|
||||||
<div className="flex justify-between items-end mb-2">
|
<div className="flex justify-between items-end mb-3">
|
||||||
<span className="text-[10px] font-mono uppercase tracking-widest text-white/40">Annual Revenue</span>
|
<span className="text-[10px] font-mono uppercase tracking-widest text-white/40">Est. Annual</span>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<span className="block font-display text-3xl text-white tracking-tight leading-none">${annualYield.toLocaleString('en-US')}</span>
|
<span className="block font-display text-3xl text-white tracking-tight leading-none">${annualYield.toLocaleString('en-US')}</span>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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>${monthlyRevenue.toLocaleString('en-US')} / mo</span>
|
||||||
<span>CPA: ${currentVertical.cpa}</span>
|
<span>CPA: ${currentVertical.cpa}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user