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
832 lines
41 KiB
TypeScript
832 lines
41 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import { useSearchParams } from 'next/navigation'
|
|
import {
|
|
TrendingUp, DollarSign, Zap, Plus, CheckCircle2, Clock, AlertCircle,
|
|
MousePointer, Target, Wallet, RefreshCw, ChevronRight, Copy, Check,
|
|
XCircle, Sparkles, Loader2, Eye, Gavel, Menu, Settings, Shield, LogOut,
|
|
Crown, Coins, Tag, X, Briefcase, Trash2
|
|
} from 'lucide-react'
|
|
import { api, YieldDomain, YieldTransaction } from '@/lib/api'
|
|
import { useStore } from '@/lib/store'
|
|
import { Sidebar } from '@/components/Sidebar'
|
|
import clsx from 'clsx'
|
|
import Link from 'next/link'
|
|
import Image from 'next/image'
|
|
|
|
// ============================================================================
|
|
// STATUS BADGE
|
|
// ============================================================================
|
|
|
|
function StatusBadge({ status }: { status: string }) {
|
|
const config: Record<string, { color: string; icon: any }> = {
|
|
active: { color: 'bg-accent/10 text-accent border-accent/20', icon: CheckCircle2 },
|
|
pending: { color: 'bg-amber-400/10 text-amber-400 border-amber-400/20', icon: Clock },
|
|
verifying: { color: 'bg-blue-400/10 text-blue-400 border-blue-400/20', icon: RefreshCw },
|
|
paused: { color: 'bg-white/5 text-white/40 border-white/10', icon: AlertCircle },
|
|
error: { color: 'bg-red-400/10 text-red-400 border-red-400/20', icon: XCircle },
|
|
}
|
|
const { color, icon: Icon } = config[status] || config.pending
|
|
return (
|
|
<span className={`inline-flex items-center gap-1 px-1.5 py-0.5 text-[9px] font-mono border ${color}`}>
|
|
<Icon className="w-3 h-3" />{status}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
// ============================================================================
|
|
// ACTIVATE MODAL - Only verified portfolio domains
|
|
// ============================================================================
|
|
|
|
function ActivateModal({
|
|
isOpen,
|
|
onClose,
|
|
onSuccess,
|
|
prefillDomain,
|
|
}: {
|
|
isOpen: boolean
|
|
onClose: () => void
|
|
onSuccess: () => void
|
|
prefillDomain?: string | null
|
|
}) {
|
|
const subscription = useStore((s) => s.subscription)
|
|
const tier = (subscription?.tier || 'scout').toLowerCase()
|
|
const isTycoon = tier === 'tycoon'
|
|
const canPreview = tier === 'trader' || tier === 'tycoon'
|
|
|
|
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)
|
|
const [step, setStep] = useState<1 | 2>(1)
|
|
const [activation, setActivation] = useState<null | {
|
|
domain_id: number
|
|
domain: string
|
|
status: string
|
|
dns_instructions: {
|
|
domain: string
|
|
nameservers: string[]
|
|
cname_host: string
|
|
cname_target: string
|
|
verification_url: string
|
|
}
|
|
landing?: {
|
|
template: string
|
|
headline: string
|
|
seo_intro: string
|
|
cta_label: string
|
|
model?: string | null
|
|
generated_at?: string | null
|
|
} | null
|
|
}>(null)
|
|
const [dnsChecking, setDnsChecking] = useState(false)
|
|
const [dnsResult, setDnsResult] = useState<null | {
|
|
verified: boolean
|
|
expected_ns: string[]
|
|
actual_ns: string[]
|
|
cname_ok: boolean
|
|
error: string | null
|
|
}>(null)
|
|
const [copied, setCopied] = useState<string | null>(null)
|
|
|
|
const [previewLoading, setPreviewLoading] = useState(false)
|
|
const [previewError, setPreviewError] = useState<string | null>(null)
|
|
const [preview, setPreview] = useState<null | {
|
|
template: string
|
|
headline: string
|
|
seo_intro: string
|
|
cta_label: string
|
|
niche: string
|
|
color_scheme: string
|
|
model: string
|
|
generated_at: string
|
|
cached: boolean
|
|
}>(null)
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return
|
|
if (prefillDomain) {
|
|
setSelectedDomain(prefillDomain)
|
|
setStep(1)
|
|
setActivation(null)
|
|
setDnsResult(null)
|
|
}
|
|
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])
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return
|
|
setStep(1)
|
|
setActivation(null)
|
|
setDnsResult(null)
|
|
setDnsChecking(false)
|
|
setError(null)
|
|
setSelectedDomain('')
|
|
setPreview(null)
|
|
setPreviewError(null)
|
|
setPreviewLoading(false)
|
|
}, [isOpen])
|
|
|
|
const copyToClipboard = async (value: string, key: string) => {
|
|
try {
|
|
await navigator.clipboard.writeText(value)
|
|
setCopied(key)
|
|
setTimeout(() => setCopied(null), 1200)
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
const handleActivate = async () => {
|
|
if (!selectedDomain) return
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const res = await api.activateYieldDomain(selectedDomain, true)
|
|
setActivation({
|
|
domain_id: res.domain_id,
|
|
domain: res.domain,
|
|
status: res.status,
|
|
dns_instructions: res.dns_instructions,
|
|
landing: res.landing || null,
|
|
})
|
|
setStep(2)
|
|
} catch (err: any) {
|
|
setError(err.message || 'Failed')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handlePreview = async (refresh: boolean = false) => {
|
|
if (!selectedDomain) return
|
|
if (!canPreview) return
|
|
setPreviewLoading(true)
|
|
setPreviewError(null)
|
|
setPreview(null)
|
|
try {
|
|
const res = await api.getYieldLandingPreview(selectedDomain, refresh)
|
|
setPreview({
|
|
template: res.result.template,
|
|
headline: res.result.headline,
|
|
seo_intro: res.result.seo_intro,
|
|
cta_label: res.result.cta_label,
|
|
niche: res.result.niche,
|
|
color_scheme: res.result.color_scheme,
|
|
model: res.model,
|
|
generated_at: res.generated_at,
|
|
cached: res.cached,
|
|
})
|
|
} catch (err: any) {
|
|
setPreviewError(err.message || 'Preview failed')
|
|
} finally {
|
|
setPreviewLoading(false)
|
|
}
|
|
}
|
|
|
|
const checkDNS = useCallback(async (domainId: number) => {
|
|
setDnsChecking(true)
|
|
setError(null)
|
|
try {
|
|
const res = await api.verifyYieldDomainDNS(domainId)
|
|
setDnsResult({
|
|
verified: res.verified,
|
|
expected_ns: res.expected_ns,
|
|
actual_ns: res.actual_ns,
|
|
cname_ok: res.cname_ok,
|
|
error: res.error,
|
|
})
|
|
if (res.verified) {
|
|
onSuccess()
|
|
}
|
|
} catch (err: any) {
|
|
setError(err.message || 'DNS check failed')
|
|
} finally {
|
|
setDnsChecking(false)
|
|
}
|
|
}, [onSuccess])
|
|
|
|
if (!isOpen) return null
|
|
|
|
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-md 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">
|
|
<Sparkles className="w-4 h-4 text-accent" />
|
|
<span className="text-xs font-mono text-accent uppercase tracking-wider">
|
|
{isTycoon ? 'Activate Yield' : 'Yield Preview'}
|
|
</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">
|
|
{step === 1 && loadingDomains ? (
|
|
<div className="flex items-center justify-center py-6">
|
|
<Loader2 className="w-5 h-5 text-accent animate-spin" />
|
|
</div>
|
|
) : step === 1 && 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>
|
|
) : step === 1 ? (
|
|
<>
|
|
<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={clsx(
|
|
"p-3 border text-xs font-mono",
|
|
isTycoon ? "bg-accent/5 border-accent/20 text-accent/80" : "bg-white/[0.02] border-white/[0.08] text-white/50"
|
|
)}>
|
|
{isTycoon ? (
|
|
<p>Only DNS-verified domains from your portfolio can be activated for Yield.</p>
|
|
) : (
|
|
<p>
|
|
Yield is <span className="text-white/80 font-bold">Tycoon-only</span>. On Trader you can preview the landing page that will be generated.
|
|
</p>
|
|
)}
|
|
</div>
|
|
{error && <div className="p-2 bg-rose-500/10 border border-rose-500/20 text-rose-400 text-xs">{error}</div>}
|
|
|
|
{!isTycoon && (
|
|
<>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => handlePreview(false)}
|
|
disabled={previewLoading || !selectedDomain}
|
|
className="flex-1 py-2.5 bg-white/10 text-white text-xs font-bold uppercase flex items-center justify-center gap-2 disabled:opacity-50"
|
|
>
|
|
{previewLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
|
Generate Preview
|
|
</button>
|
|
<Link
|
|
href="/pricing"
|
|
className="px-3 py-2.5 bg-accent text-black text-xs font-bold uppercase flex items-center justify-center"
|
|
>
|
|
Upgrade
|
|
</Link>
|
|
</div>
|
|
|
|
{previewError && (
|
|
<div className="p-3 bg-rose-500/10 border border-rose-500/20 text-rose-300 text-xs">
|
|
{previewError}
|
|
</div>
|
|
)}
|
|
|
|
{preview && (
|
|
<div className="p-3 bg-[#050505] border border-white/[0.08] space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-[9px] font-mono text-white/40 uppercase tracking-wider">Landing Page Preview</div>
|
|
<button
|
|
onClick={() => handlePreview(true)}
|
|
className="text-[10px] font-mono text-white/40 hover:text-white"
|
|
disabled={previewLoading}
|
|
>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
<div className="text-sm font-bold text-white">{preview.headline}</div>
|
|
<div className="text-xs text-white/50">{preview.seo_intro}</div>
|
|
<div className="flex items-center justify-between text-[10px] font-mono text-white/40 pt-2 border-t border-white/10">
|
|
<span>CTA: <span className="text-white/70">{preview.cta_label}</span></span>
|
|
<span>{preview.cached ? 'Cached' : 'Fresh'} • {preview.model}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{isTycoon && (
|
|
<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 className="p-3 bg-white/[0.02] border border-white/[0.08]">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<div className="text-[9px] font-mono text-white/40 uppercase tracking-wider">Domain</div>
|
|
<div className="text-sm font-bold text-white font-mono">{activation?.domain}</div>
|
|
</div>
|
|
<StatusBadge status={activation?.status || 'pending'} />
|
|
</div>
|
|
</div>
|
|
|
|
{activation?.landing?.headline && (
|
|
<div className="p-3 bg-[#050505] border border-white/[0.08] space-y-2">
|
|
<div className="text-[9px] font-mono text-white/40 uppercase tracking-wider">Landing Page (Generated)</div>
|
|
<div className="text-sm font-bold text-white">{activation.landing.headline}</div>
|
|
<div className="text-xs text-white/50">{activation.landing.seo_intro}</div>
|
|
<div className="text-[10px] font-mono text-white/40">
|
|
CTA: <span className="text-white/70">{activation.landing.cta_label}</span>
|
|
{activation.landing.model ? <span className="text-white/20"> • {activation.landing.model}</span> : null}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<div className="text-[10px] font-mono text-white/40 uppercase tracking-wider">Option A (Recommended): Nameservers</div>
|
|
<div className="bg-[#020202] border border-white/[0.08]">
|
|
{(activation?.dns_instructions.nameservers || []).map((ns, idx) => (
|
|
<div key={ns} className={clsx("flex items-center justify-between px-3 py-2", idx > 0 && "border-t border-white/[0.06]")}>
|
|
<span className="text-xs font-mono text-white/80">{ns}</span>
|
|
<button onClick={() => copyToClipboard(ns, `ns-${idx}`)} className="p-1.5 border border-white/10 text-white/40 hover:text-white">
|
|
{copied === `ns-${idx}` ? <Check className="w-4 h-4 text-accent" /> : <Copy className="w-4 h-4" />}
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="text-[10px] font-mono text-white/40 uppercase tracking-wider">Option B: CNAME / ALIAS</div>
|
|
<div className="bg-[#020202] border border-white/[0.08] p-3 space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-xs font-mono text-white/70">
|
|
<span className="text-white/40">Host:</span> {activation?.dns_instructions.cname_host} <span className="text-white/40">→ Target:</span> {activation?.dns_instructions.cname_target}
|
|
</div>
|
|
<button onClick={() => copyToClipboard(activation?.dns_instructions.cname_target || '', `cname-target`)} className="p-1.5 border border-white/10 text-white/40 hover:text-white">
|
|
{copied === `cname-target` ? <Check className="w-4 h-4 text-accent" /> : <Copy className="w-4 h-4" />}
|
|
</button>
|
|
</div>
|
|
<p className="text-[10px] font-mono text-white/35">
|
|
Some DNS providers use ALIAS/ANAME for apex. We accept both CNAME and ALIAS-style flattening.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{dnsResult && (
|
|
<div className={clsx("p-3 border text-xs font-mono", dnsResult.verified ? "bg-accent/5 border-accent/20 text-accent/80" : "bg-amber-400/5 border-amber-400/20 text-amber-400/80")}>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span>{dnsResult.verified ? 'Connected. Domain is active.' : 'Not connected yet. Waiting for DNS propagation.'}</span>
|
|
{dnsResult.verified ? <CheckCircle2 className="w-4 h-4 text-accent" /> : <Clock className="w-4 h-4 text-amber-400" />}
|
|
</div>
|
|
{dnsResult.error && <div className="mt-2 text-rose-400/80">Error: {dnsResult.error}</div>}
|
|
{!dnsResult.verified && (
|
|
<div className="mt-2 text-white/40">
|
|
<div>Expected NS: {dnsResult.expected_ns?.join(', ') || '—'}</div>
|
|
<div>Actual NS: {dnsResult.actual_ns?.join(', ') || '—'}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{error && <div className="p-2 bg-rose-500/10 border border-rose-500/20 text-rose-400 text-xs">{error}</div>}
|
|
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => { setStep(1); setActivation(null); setDnsResult(null) }}
|
|
className="flex-1 py-2.5 border border-white/10 text-white/60 text-xs font-bold uppercase"
|
|
>
|
|
Back
|
|
</button>
|
|
<button
|
|
onClick={() => activation?.domain_id && checkDNS(activation.domain_id)}
|
|
disabled={dnsChecking || !activation?.domain_id}
|
|
className="flex-[1.4] py-2.5 bg-accent text-black text-xs font-bold uppercase flex items-center justify-center gap-2 disabled:opacity-50"
|
|
>
|
|
{dnsChecking ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
|
Verify DNS
|
|
</button>
|
|
</div>
|
|
|
|
{dnsResult?.verified && (
|
|
<button
|
|
onClick={() => { onClose(); onSuccess() }}
|
|
className="w-full py-2.5 bg-white text-black text-xs font-bold uppercase flex items-center justify-center gap-2"
|
|
>
|
|
View Yield Dashboard <ChevronRight className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ============================================================================
|
|
// MAIN PAGE
|
|
// ============================================================================
|
|
|
|
export default function YieldPage() {
|
|
const { subscription, user, logout, checkAuth } = useStore()
|
|
const searchParams = useSearchParams()
|
|
const [loading, setLoading] = useState(true)
|
|
const [dashboard, setDashboard] = useState<any>(null)
|
|
const [showActivateModal, setShowActivateModal] = useState(false)
|
|
const [prefillDomain, setPrefillDomain] = useState<string | null>(null)
|
|
const [refreshing, setRefreshing] = useState(false)
|
|
const [menuOpen, setMenuOpen] = useState(false)
|
|
const [deletingId, setDeletingId] = useState<number | null>(null)
|
|
|
|
const tier = (subscription?.tier || 'scout').toLowerCase()
|
|
const tierName = subscription?.tier_name || (tier.charAt(0).toUpperCase() + tier.slice(1))
|
|
const isTycoon = tier === 'tycoon'
|
|
|
|
useEffect(() => { checkAuth() }, [checkAuth])
|
|
|
|
const fetchDashboard = useCallback(async () => {
|
|
try {
|
|
const data = await api.getYieldDashboard()
|
|
setDashboard(data)
|
|
} catch (err) { console.error(err) }
|
|
finally { setLoading(false); setRefreshing(false) }
|
|
}, [])
|
|
|
|
const handleDeleteYield = useCallback(async (domainId: number, domainName: string) => {
|
|
if (!confirm(`Remove ${domainName} from Yield? This will stop all revenue tracking.`)) return
|
|
setDeletingId(domainId)
|
|
try {
|
|
await api.deleteYieldDomain(domainId)
|
|
fetchDashboard()
|
|
} catch (err) {
|
|
console.error('Failed to remove yield domain:', err)
|
|
} finally {
|
|
setDeletingId(null)
|
|
}
|
|
}, [fetchDashboard])
|
|
|
|
useEffect(() => { fetchDashboard() }, [fetchDashboard])
|
|
|
|
useEffect(() => {
|
|
const activate = searchParams.get('activate')
|
|
if (activate) {
|
|
setPrefillDomain(activate)
|
|
setShowActivateModal(true)
|
|
}
|
|
}, [searchParams])
|
|
|
|
const stats = dashboard?.stats
|
|
|
|
const mobileNavItems = [
|
|
{ href: '/terminal/hunt', label: 'Radar', icon: Target, active: false },
|
|
{ href: '/terminal/market', label: 'Market', icon: Gavel, active: false },
|
|
{ href: '/terminal/watchlist', label: 'Watch', icon: Eye, active: false },
|
|
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: false },
|
|
]
|
|
|
|
const tierLabelForDrawer = subscription?.tier_name || subscription?.tier || 'Scout'
|
|
const TierIcon = tierLabelForDrawer === 'Tycoon' ? Crown : tierLabelForDrawer === 'Trader' ? TrendingUp : Zap
|
|
|
|
const drawerNavSections = [
|
|
{ title: 'Discover', items: [
|
|
{ href: '/terminal/hunt', label: 'Radar', icon: Target },
|
|
{ href: '/terminal/market', label: 'Market', icon: Gavel },
|
|
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp },
|
|
]},
|
|
{ title: 'Manage', items: [
|
|
{ href: '/terminal/watchlist', label: 'Watchlist', icon: Eye },
|
|
{ href: '/terminal/portfolio', label: 'Portfolio', icon: Briefcase },
|
|
{ href: '/terminal/sniper', label: 'Sniper', icon: Target },
|
|
]},
|
|
{ title: 'Monetize', items: [
|
|
{ href: '/terminal/yield', label: 'Yield', icon: Coins, active: true },
|
|
{ href: '/terminal/listing', label: 'For Sale', icon: Tag },
|
|
]}
|
|
]
|
|
|
|
return (
|
|
<div className="min-h-screen bg-[#020202]">
|
|
<div className="hidden lg:block"><Sidebar /></div>
|
|
|
|
<main className="lg:pl-[240px]">
|
|
{/* MOBILE HEADER */}
|
|
<header className="lg:hidden sticky top-0 z-40 bg-[#020202]/95 backdrop-blur-md border-b border-white/[0.08]" style={{ paddingTop: 'env(safe-area-inset-top)' }}>
|
|
<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">Yield</span>
|
|
</div>
|
|
<span className="text-[10px] font-mono text-white/40">{stats?.active_domains || 0} active</span>
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<div className="bg-accent/[0.05] border border-accent/20 p-2">
|
|
<div className="text-lg font-bold text-accent tabular-nums font-mono">${stats?.monthly_revenue || 0}</div>
|
|
<div className="text-[9px] font-mono text-accent/60 uppercase tracking-wider">Monthly</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">{stats?.monthly_clicks || 0}</div>
|
|
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">Clicks</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">${stats?.pending_payout || 0}</div>
|
|
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">Pending</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* DESKTOP HEADER */}
|
|
<section className="hidden lg:block px-10 pt-10 pb-6">
|
|
<div className="flex items-end justify-between gap-6">
|
|
<div className="space-y-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">Yield</span>
|
|
</div>
|
|
<h1 className="font-display text-[2.5rem] leading-[1] tracking-[-0.02em] text-white">Yield</h1>
|
|
<p className="text-sm text-white/40 font-mono mt-2 max-w-lg">
|
|
Monetize your parked domains. Route visitor intent to earn passive income.
|
|
</p>
|
|
{!isTycoon && (
|
|
<div className="inline-flex items-center gap-2 px-3 py-2 border border-white/10 bg-white/[0.02] text-xs text-white/50 font-mono">
|
|
<Sparkles className="w-4 h-4 text-accent" />
|
|
Yield activation is <span className="text-white/70 font-bold">Tycoon-only</span>. You can preview the landing page on Trader.
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-6">
|
|
{stats && (
|
|
<div className="flex gap-8">
|
|
<div className="text-right">
|
|
<div className="text-2xl font-bold text-accent font-mono">${stats.monthly_revenue}</div>
|
|
<div className="text-[9px] font-mono text-white/30 uppercase">Monthly</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="text-2xl font-bold text-white font-mono">{stats.active_domains}</div>
|
|
<div className="text-[9px] font-mono text-white/30 uppercase">Active</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="flex gap-2">
|
|
<button onClick={() => { setRefreshing(true); fetchDashboard() }} disabled={refreshing}
|
|
className="w-9 h-9 flex items-center justify-center border border-white/10 text-white/40 hover:text-white">
|
|
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
|
|
</button>
|
|
<button onClick={() => setShowActivateModal(true)}
|
|
className="flex items-center gap-2 px-4 py-2.5 bg-accent text-black text-xs font-bold uppercase tracking-wider hover:bg-white">
|
|
<Plus className="w-4 h-4" />{isTycoon ? 'Activate Domain' : 'Preview Landing'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* ADD BUTTON MOBILE */}
|
|
<section className="lg:hidden px-4 py-3 border-b border-white/[0.08]">
|
|
<button onClick={() => setShowActivateModal(true)}
|
|
className="w-full flex items-center justify-center gap-2 py-3 bg-accent text-black text-xs font-bold uppercase tracking-wider">
|
|
<Plus className="w-4 h-4" />{isTycoon ? 'Activate Domain' : 'Preview Landing'}
|
|
</button>
|
|
</section>
|
|
|
|
{/* CONTENT */}
|
|
<section className="px-4 lg:px-10 py-4 pb-28 lg:pb-10">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-20">
|
|
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
|
</div>
|
|
) : !dashboard?.domains?.length ? (
|
|
<div className="text-center py-16 border border-dashed border-white/[0.08]">
|
|
<TrendingUp className="w-8 h-8 text-white/10 mx-auto mb-3" />
|
|
<p className="text-white/40 text-sm font-mono mb-2">No yield domains yet</p>
|
|
<p className="text-white/25 text-xs font-mono">Activate domains to earn passive income</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-px bg-white/[0.04] border border-white/[0.08]">
|
|
{/* Header */}
|
|
<div className="hidden lg:grid grid-cols-[1fr_80px_160px_120px_80px_80px_80px_60px] gap-4 px-3 py-2 text-[10px] font-mono text-white/40 uppercase tracking-wider border-b border-white/[0.08]">
|
|
<div>Domain</div>
|
|
<div className="text-center">Status</div>
|
|
<div>Intent</div>
|
|
<div>Landing</div>
|
|
<div className="text-right">Clicks</div>
|
|
<div className="text-right">Conv.</div>
|
|
<div className="text-right">Revenue</div>
|
|
<div className="text-right">Action</div>
|
|
</div>
|
|
|
|
{dashboard.domains.map((domain: YieldDomain) => (
|
|
<div key={domain.id} className="bg-[#020202] hover:bg-white/[0.02] transition-all">
|
|
{/* Mobile */}
|
|
<div className="lg:hidden p-3">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-sm font-bold text-white font-mono">{domain.domain}</span>
|
|
</div>
|
|
<StatusBadge status={domain.status} />
|
|
</div>
|
|
<div className="mb-2">
|
|
<details className="group">
|
|
<summary className="cursor-pointer text-[10px] font-mono text-white/50 hover:text-white/70 flex items-center justify-between">
|
|
<span>
|
|
Landing:{' '}
|
|
{domain.landing_headline ? (
|
|
<span className="text-accent font-bold">Ready</span>
|
|
) : (
|
|
<span className="text-amber-400 font-bold">Missing</span>
|
|
)}
|
|
</span>
|
|
<span className="text-white/30 group-open:text-white/50">expand</span>
|
|
</summary>
|
|
<div className="mt-2 p-2 bg-[#050505] border border-white/[0.08]">
|
|
{domain.landing_headline ? (
|
|
<div className="space-y-2">
|
|
<div className="text-xs font-bold text-white">{domain.landing_headline}</div>
|
|
{domain.landing_intro && <div className="text-[10px] text-white/50">{domain.landing_intro}</div>}
|
|
<div className="text-[10px] font-mono text-white/40">
|
|
CTA: <span className="text-white/70">{domain.landing_cta_label || '—'}</span>
|
|
</div>
|
|
<div className="text-[10px] font-mono text-white/30">
|
|
{domain.landing_generated_at ? new Date(domain.landing_generated_at).toLocaleString() : '—'}
|
|
{domain.landing_model ? <span className="text-white/20"> • {domain.landing_model}</span> : null}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="text-[10px] text-white/40">
|
|
No landing config stored yet. (Older activation) Remove + re-activate on Tycoon to regenerate.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</details>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex gap-4 text-[10px] font-mono text-white/40">
|
|
<span>{domain.total_clicks} clicks</span>
|
|
<span className="text-accent font-bold">${domain.total_revenue}</span>
|
|
</div>
|
|
<button
|
|
onClick={() => handleDeleteYield(domain.id, domain.domain)}
|
|
disabled={deletingId === domain.id}
|
|
className="p-1.5 text-white/30 hover:text-rose-400 disabled:opacity-50 transition-colors"
|
|
>
|
|
{deletingId === domain.id ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<Trash2 className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Desktop */}
|
|
<div className="hidden lg:grid grid-cols-[1fr_80px_160px_120px_80px_80px_80px_60px] gap-4 items-center px-3 py-3">
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-sm font-bold text-white font-mono">{domain.domain}</span>
|
|
</div>
|
|
<div className="flex justify-center"><StatusBadge status={domain.status} /></div>
|
|
<span className="text-xs text-white/60 capitalize font-mono">{domain.detected_intent?.replace('_', ' ') || '—'}</span>
|
|
<div>
|
|
<details className="group">
|
|
<summary
|
|
className="cursor-pointer text-xs font-mono text-white/50 hover:text-white/70 flex items-center justify-between"
|
|
title="View landing page details"
|
|
>
|
|
<span>
|
|
{domain.landing_headline ? (
|
|
<span className="text-accent font-bold">Ready</span>
|
|
) : (
|
|
<span className="text-amber-400 font-bold">Missing</span>
|
|
)}
|
|
</span>
|
|
<span className="text-white/30 group-open:text-white/50">details</span>
|
|
</summary>
|
|
<div className="mt-2 p-3 bg-[#050505] border border-white/[0.08] space-y-2">
|
|
{domain.landing_headline ? (
|
|
<>
|
|
<div className="text-xs font-bold text-white">{domain.landing_headline}</div>
|
|
{domain.landing_intro && <div className="text-[10px] text-white/50 max-w-[520px]">{domain.landing_intro}</div>}
|
|
<div className="text-[10px] font-mono text-white/40">
|
|
CTA: <span className="text-white/70">{domain.landing_cta_label || '—'}</span>
|
|
</div>
|
|
<div className="text-[10px] font-mono text-white/30">
|
|
{domain.landing_generated_at ? new Date(domain.landing_generated_at).toLocaleString() : '—'}
|
|
{domain.landing_model ? <span className="text-white/20"> • {domain.landing_model}</span> : null}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="text-[10px] text-white/40">
|
|
No landing config stored yet. Remove + re-activate on Tycoon to regenerate.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</details>
|
|
</div>
|
|
<div className="text-right text-xs font-mono text-white/60">{domain.total_clicks}</div>
|
|
<div className="text-right text-xs font-mono text-white/60">{domain.total_conversions}</div>
|
|
<div className="text-right text-sm font-bold font-mono text-accent">${domain.total_revenue}</div>
|
|
<div className="flex justify-end">
|
|
<button
|
|
onClick={() => handleDeleteYield(domain.id, domain.domain)}
|
|
disabled={deletingId === domain.id}
|
|
className="p-1.5 text-white/30 hover:text-rose-400 disabled:opacity-50 transition-colors"
|
|
title="Remove from Yield"
|
|
>
|
|
{deletingId === domain.id ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<Trash2 className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* MOBILE BOTTOM NAV */}
|
|
<nav className="lg:hidden fixed bottom-0 left-0 right-0 z-50 bg-[#020202] border-t border-white/[0.08]" style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
|
|
<div className="flex items-stretch h-14">
|
|
{mobileNavItems.map((item) => (
|
|
<Link key={item.href} href={item.href} className={clsx("flex-1 flex flex-col items-center justify-center gap-0.5 relative", item.active ? "text-accent" : "text-white/40")}>
|
|
{item.active && <div className="absolute top-0 left-1/2 -translate-x-1/2 w-10 h-0.5 bg-accent" />}
|
|
<item.icon className="w-5 h-5" />
|
|
<span className="text-[9px] font-mono uppercase tracking-wider">{item.label}</span>
|
|
</Link>
|
|
))}
|
|
<button onClick={() => setMenuOpen(true)} className="flex-1 flex flex-col items-center justify-center gap-0.5 text-white/40">
|
|
<Menu className="w-5 h-5" /><span className="text-[9px] font-mono uppercase tracking-wider">Menu</span>
|
|
</button>
|
|
</div>
|
|
</nav>
|
|
|
|
{/* DRAWER */}
|
|
{menuOpen && (
|
|
<div className="lg:hidden fixed inset-0 z-[100]">
|
|
<div className="absolute inset-0 bg-black/80" onClick={() => setMenuOpen(false)} />
|
|
<div className="absolute top-0 right-0 bottom-0 w-[80%] max-w-[300px] bg-[#0A0A0A] border-l border-white/[0.08] flex flex-col" style={{ paddingTop: 'env(safe-area-inset-top)', paddingBottom: 'env(safe-area-inset-bottom)' }}>
|
|
<div className="flex items-center justify-between p-4 border-b border-white/[0.08]">
|
|
<div className="flex items-center gap-3">
|
|
<Image src="/pounce-puma.png" alt="Pounce" width={28} height={28} className="object-contain" />
|
|
<div><h2 className="text-sm font-bold text-white">POUNCE</h2><p className="text-[9px] text-white/40 font-mono uppercase">Terminal v1.0</p></div>
|
|
</div>
|
|
<button onClick={() => setMenuOpen(false)} className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/60"><X className="w-4 h-4" /></button>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto py-4">
|
|
{drawerNavSections.map((section) => (
|
|
<div key={section.title} className="mb-4">
|
|
<div className="flex items-center gap-2 px-4 mb-2"><div className="w-1 h-3 bg-accent" /><span className="text-[9px] font-bold text-white/30 uppercase tracking-[0.2em]">{section.title}</span></div>
|
|
{section.items.map((item: any) => (
|
|
<Link key={item.href} href={item.href} onClick={() => setMenuOpen(false)} className={clsx("flex items-center gap-3 px-4 py-2.5 border-l-2 border-transparent", item.active ? "text-accent border-accent bg-white/[0.02]" : "text-white/60")}>
|
|
<item.icon className="w-4 h-4 text-white/30" /><span className="text-sm font-medium flex-1">{item.label}</span>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
))}
|
|
<div className="pt-3 border-t border-white/[0.08] mx-4">
|
|
<Link href="/terminal/settings" onClick={() => setMenuOpen(false)} className="flex items-center gap-3 py-2.5 text-white/50"><Settings className="w-4 h-4" /><span className="text-sm">Settings</span></Link>
|
|
{user?.is_admin && <Link href="/admin" onClick={() => setMenuOpen(false)} className="flex items-center gap-3 py-2.5 text-amber-500/70"><Shield className="w-4 h-4" /><span className="text-sm">Admin</span></Link>}
|
|
</div>
|
|
</div>
|
|
<div className="p-4 bg-white/[0.02] border-t border-white/[0.08]">
|
|
<div className="flex items-center gap-3 mb-3">
|
|
<div className="w-8 h-8 bg-accent/10 border border-accent/20 flex items-center justify-center"><TierIcon className="w-4 h-4 text-accent" /></div>
|
|
<div className="flex-1 min-w-0"><p className="text-sm font-bold text-white truncate">{user?.name || user?.email?.split('@')[0] || 'User'}</p><p className="text-[9px] font-mono text-white/40 uppercase">{tierLabelForDrawer}</p></div>
|
|
</div>
|
|
{tierLabelForDrawer === 'Scout' && <Link href="/pricing" onClick={() => setMenuOpen(false)} className="flex items-center justify-center gap-2 w-full py-2.5 bg-accent text-black text-xs font-bold uppercase mb-2"><Sparkles className="w-3 h-3" />Upgrade</Link>}
|
|
<button onClick={() => { logout(); setMenuOpen(false) }} className="flex items-center justify-center gap-2 w-full py-2 border border-white/10 text-white/40 text-[10px] font-mono uppercase"><LogOut className="w-3 h-3" />Sign out</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</main>
|
|
|
|
<ActivateModal
|
|
isOpen={showActivateModal}
|
|
prefillDomain={prefillDomain}
|
|
onClose={() => { setShowActivateModal(false); setPrefillDomain(null) }}
|
|
onSuccess={fetchDashboard}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|