Yield: show landing details in table + Trader preview, Tycoon activation
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:
2025-12-17 16:47:34 +01:00
parent f9e1da9ba0
commit f711ac23b9
5 changed files with 398 additions and 17 deletions

View File

@ -19,7 +19,14 @@ from app.api.deps import CurrentUser, Database
from app.models.llm_artifact import LLMArtifact from app.models.llm_artifact import LLMArtifact
from app.models.subscription import Subscription, SubscriptionTier from app.models.subscription import Subscription, SubscriptionTier
from app.services.llm_gateway import LLMGatewayError from app.services.llm_gateway import LLMGatewayError
from app.services.llm_vision import VISION_PROMPT_VERSION, VisionResult, generate_vision from app.services.llm_vision import (
VISION_PROMPT_VERSION,
YIELD_LANDING_PROMPT_VERSION,
VisionResult,
YieldLandingConfig,
generate_vision,
generate_yield_landing,
)
router = APIRouter(prefix="/llm", tags=["LLM Vision"]) router = APIRouter(prefix="/llm", tags=["LLM Vision"])
@ -34,6 +41,15 @@ class VisionResponse(BaseModel):
result: VisionResult result: VisionResult
class YieldLandingPreviewResponse(BaseModel):
domain: str
cached: bool
model: str
prompt_version: str
generated_at: str
result: YieldLandingConfig
async def _get_or_create_subscription(db: Database, user_id: int) -> Subscription: async def _get_or_create_subscription(db: Database, user_id: int) -> Subscription:
res = await db.execute(select(Subscription).where(Subscription.user_id == user_id)) res = await db.execute(select(Subscription).where(Subscription.user_id == user_id))
sub = res.scalar_one_or_none() sub = res.scalar_one_or_none()
@ -131,3 +147,86 @@ async def get_vision(
result=result, result=result,
) )
@router.get("/yield/landing-preview", response_model=YieldLandingPreviewResponse)
async def get_yield_landing_preview(
current_user: CurrentUser,
db: Database,
domain: str = Query(..., min_length=3, max_length=255),
refresh: bool = Query(False, description="Bypass cache and regenerate"),
):
"""
Generate a Yield landing page configuration preview for Terminal UX.
Trader + Tycoon: allowed.
Scout: blocked (upgrade teaser).
"""
sub = await _get_or_create_subscription(db, current_user.id)
_require_trader_or_higher(sub)
normalized = domain.strip().lower()
now = datetime.utcnow()
ttl_days = 30
if not refresh:
cached = (
await db.execute(
select(LLMArtifact)
.where(
and_(
LLMArtifact.kind == "yield_landing_preview_v1",
LLMArtifact.domain == normalized,
LLMArtifact.prompt_version == YIELD_LANDING_PROMPT_VERSION,
(LLMArtifact.expires_at.is_(None) | (LLMArtifact.expires_at > now)),
)
)
.order_by(LLMArtifact.created_at.desc())
.limit(1)
)
).scalar_one_or_none()
if cached:
try:
payload = json.loads(cached.payload_json)
result = YieldLandingConfig.model_validate(payload)
except Exception:
cached = None
else:
return YieldLandingPreviewResponse(
domain=normalized,
cached=True,
model=cached.model,
prompt_version=cached.prompt_version,
generated_at=cached.created_at.isoformat(),
result=result,
)
try:
result, model_used = await generate_yield_landing(normalized)
except LLMGatewayError as e:
raise HTTPException(status_code=502, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Landing preview generation failed: {e}")
artifact = LLMArtifact(
user_id=current_user.id,
kind="yield_landing_preview_v1",
domain=normalized,
prompt_version=YIELD_LANDING_PROMPT_VERSION,
model=model_used,
payload_json=result.model_dump_json(),
created_at=now,
updated_at=now,
expires_at=now + timedelta(days=ttl_days),
)
db.add(artifact)
await db.commit()
return YieldLandingPreviewResponse(
domain=normalized,
cached=False,
model=model_used,
prompt_version=YIELD_LANDING_PROMPT_VERSION,
generated_at=now.isoformat(),
result=result,
)

View File

@ -37,6 +37,7 @@ from app.schemas.yield_domain import (
DNSSetupInstructions, DNSSetupInstructions,
ActivateYieldRequest, ActivateYieldRequest,
ActivateYieldResponse, ActivateYieldResponse,
YieldLandingPreview,
) )
from app.services.intent_detector import ( from app.services.intent_detector import (
detect_domain_intent, detect_domain_intent,
@ -460,6 +461,14 @@ async def activate_domain_for_yield(
geo=value_estimate["geo"], geo=value_estimate["geo"],
), ),
dns_instructions=dns_instructions, dns_instructions=dns_instructions,
landing=YieldLandingPreview(
template=yield_domain.landing_template or "generic",
headline=yield_domain.landing_headline or "",
seo_intro=yield_domain.landing_intro or "",
cta_label=yield_domain.landing_cta_label or "View offers",
model=getattr(yield_domain, "landing_model", None),
generated_at=getattr(yield_domain, "landing_generated_at", None),
),
message="Domain registered! Point your DNS to our nameservers to complete activation.", message="Domain registered! Point your DNS to our nameservers to complete activation.",
) )
@ -771,6 +780,12 @@ def _domain_to_response(domain: YieldDomain) -> YieldDomainResponse:
intent_confidence=domain.intent_confidence, intent_confidence=domain.intent_confidence,
active_route=domain.active_route, active_route=domain.active_route,
partner_name=domain.partner.name if domain.partner else None, partner_name=domain.partner.name if domain.partner else None,
landing_template=getattr(domain, "landing_template", None),
landing_headline=getattr(domain, "landing_headline", None),
landing_intro=getattr(domain, "landing_intro", None),
landing_cta_label=getattr(domain, "landing_cta_label", None),
landing_model=getattr(domain, "landing_model", None),
landing_generated_at=getattr(domain, "landing_generated_at", None),
dns_verified=domain.dns_verified, dns_verified=domain.dns_verified,
dns_verified_at=domain.dns_verified_at, dns_verified_at=domain.dns_verified_at,
connected_at=getattr(domain, "connected_at", None), connected_at=getattr(domain, "connected_at", None),

View File

@ -69,6 +69,14 @@ class YieldDomainResponse(BaseModel):
# Routing # Routing
active_route: Optional[str] = None active_route: Optional[str] = None
partner_name: Optional[str] = None partner_name: Optional[str] = None
# Landing page (generated at activation time)
landing_template: Optional[str] = None
landing_headline: Optional[str] = None
landing_intro: Optional[str] = None
landing_cta_label: Optional[str] = None
landing_model: Optional[str] = None
landing_generated_at: Optional[datetime] = None
# DNS # DNS
dns_verified: bool = False dns_verified: bool = False
@ -263,6 +271,16 @@ class DNSSetupInstructions(BaseModel):
# Activation Flow # Activation Flow
# ============================================================================ # ============================================================================
class YieldLandingPreview(BaseModel):
"""LLM-generated landing page config preview."""
template: str
headline: str
seo_intro: str
cta_label: str
model: Optional[str] = None
generated_at: Optional[datetime] = None
class ActivateYieldRequest(BaseModel): class ActivateYieldRequest(BaseModel):
"""Request to activate a domain for yield.""" """Request to activate a domain for yield."""
domain: str = Field(..., min_length=3, max_length=255) domain: str = Field(..., min_length=3, max_length=255)
@ -281,6 +299,9 @@ class ActivateYieldResponse(BaseModel):
# Setup # Setup
dns_instructions: DNSSetupInstructions dns_instructions: DNSSetupInstructions
# Generated landing page config (so user can preview instantly)
landing: Optional[YieldLandingPreview] = None
message: str message: str

View File

@ -50,6 +50,11 @@ function ActivateModal({
onSuccess: () => void onSuccess: () => void
prefillDomain?: string | null 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 [selectedDomain, setSelectedDomain] = useState('')
const [verifiedDomains, setVerifiedDomains] = useState<{ id: number; domain: string }[]>([]) const [verifiedDomains, setVerifiedDomains] = useState<{ id: number; domain: string }[]>([])
const [loadingDomains, setLoadingDomains] = useState(true) const [loadingDomains, setLoadingDomains] = useState(true)
@ -67,6 +72,14 @@ function ActivateModal({
cname_target: string cname_target: string
verification_url: string verification_url: string
} }
landing?: {
template: string
headline: string
seo_intro: string
cta_label: string
model?: string | null
generated_at?: string | null
} | null
}>(null) }>(null)
const [dnsChecking, setDnsChecking] = useState(false) const [dnsChecking, setDnsChecking] = useState(false)
const [dnsResult, setDnsResult] = useState<null | { const [dnsResult, setDnsResult] = useState<null | {
@ -77,6 +90,20 @@ function ActivateModal({
error: string | null error: string | null
}>(null) }>(null)
const [copied, setCopied] = useState<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(() => { useEffect(() => {
if (!isOpen) return if (!isOpen) return
@ -108,6 +135,9 @@ function ActivateModal({
setDnsChecking(false) setDnsChecking(false)
setError(null) setError(null)
setSelectedDomain('') setSelectedDomain('')
setPreview(null)
setPreviewError(null)
setPreviewLoading(false)
}, [isOpen]) }, [isOpen])
const copyToClipboard = async (value: string, key: string) => { const copyToClipboard = async (value: string, key: string) => {
@ -131,6 +161,7 @@ function ActivateModal({
domain: res.domain, domain: res.domain,
status: res.status, status: res.status,
dns_instructions: res.dns_instructions, dns_instructions: res.dns_instructions,
landing: res.landing || null,
}) })
setStep(2) setStep(2)
} catch (err: any) { } catch (err: any) {
@ -140,6 +171,32 @@ function ActivateModal({
} }
} }
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) => { const checkDNS = useCallback(async (domainId: number) => {
setDnsChecking(true) setDnsChecking(true)
setError(null) setError(null)
@ -170,7 +227,9 @@ function ActivateModal({
<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">Activate Yield</span> <span className="text-xs font-mono text-accent uppercase tracking-wider">
{isTycoon ? 'Activate Yield' : 'Yield Preview'}
</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>
@ -205,15 +264,75 @@ function ActivateModal({
))} ))}
</select> </select>
</div> </div>
<div className="p-3 bg-accent/5 border border-accent/20 text-xs text-accent/80 font-mono"> <div className={clsx(
<p>Only DNS-verified domains from your portfolio can be activated for Yield.</p> "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> </div>
{error && <div className="p-2 bg-rose-500/10 border border-rose-500/20 text-rose-400 text-xs">{error}</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"> {!isTycoon && (
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />} <>
Activate Yield <div className="flex gap-2">
</button> <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>
)}
</> </>
) : ( ) : (
<> <>
@ -227,6 +346,18 @@ function ActivateModal({
</div> </div>
</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="space-y-2">
<div className="text-[10px] font-mono text-white/40 uppercase tracking-wider">Option A (Recommended): Nameservers</div> <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]"> <div className="bg-[#020202] border border-white/[0.08]">
@ -323,6 +454,10 @@ export default function YieldPage() {
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [deletingId, setDeletingId] = useState<number | null>(null) 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]) useEffect(() => { checkAuth() }, [checkAuth])
@ -366,8 +501,8 @@ export default function YieldPage() {
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: false }, { href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: false },
] ]
const tierName = subscription?.tier_name || subscription?.tier || 'Scout' const tierLabelForDrawer = subscription?.tier_name || subscription?.tier || 'Scout'
const TierIcon = tierName === 'Tycoon' ? Crown : tierName === 'Trader' ? TrendingUp : Zap const TierIcon = tierLabelForDrawer === 'Tycoon' ? Crown : tierLabelForDrawer === 'Trader' ? TrendingUp : Zap
const drawerNavSections = [ const drawerNavSections = [
{ title: 'Discover', items: [ { title: 'Discover', items: [
@ -430,6 +565,12 @@ export default function YieldPage() {
<p className="text-sm text-white/40 font-mono mt-2 max-w-lg"> <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. Monetize your parked domains. Route visitor intent to earn passive income.
</p> </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>
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
{stats && ( {stats && (
@ -451,7 +592,7 @@ export default function YieldPage() {
</button> </button>
<button onClick={() => setShowActivateModal(true)} <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"> 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" />Activate Domain <Plus className="w-4 h-4" />{isTycoon ? 'Activate Domain' : 'Preview Landing'}
</button> </button>
</div> </div>
</div> </div>
@ -462,7 +603,7 @@ export default function YieldPage() {
<section className="lg:hidden px-4 py-3 border-b border-white/[0.08]"> <section className="lg:hidden px-4 py-3 border-b border-white/[0.08]">
<button onClick={() => setShowActivateModal(true)} <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"> 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" />Activate Domain <Plus className="w-4 h-4" />{isTycoon ? 'Activate Domain' : 'Preview Landing'}
</button> </button>
</section> </section>
@ -481,10 +622,11 @@ export default function YieldPage() {
) : ( ) : (
<div className="space-y-px bg-white/[0.04] border border-white/[0.08]"> <div className="space-y-px bg-white/[0.04] border border-white/[0.08]">
{/* Header */} {/* Header */}
<div className="hidden lg:grid grid-cols-[1fr_80px_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 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>Domain</div>
<div className="text-center">Status</div> <div className="text-center">Status</div>
<div>Intent</div> <div>Intent</div>
<div>Landing</div>
<div className="text-right">Clicks</div> <div className="text-right">Clicks</div>
<div className="text-right">Conv.</div> <div className="text-right">Conv.</div>
<div className="text-right">Revenue</div> <div className="text-right">Revenue</div>
@ -501,6 +643,40 @@ export default function YieldPage() {
</div> </div>
<StatusBadge status={domain.status} /> <StatusBadge status={domain.status} />
</div> </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 items-center justify-between">
<div className="flex gap-4 text-[10px] font-mono text-white/40"> <div className="flex gap-4 text-[10px] font-mono text-white/40">
<span>{domain.total_clicks} clicks</span> <span>{domain.total_clicks} clicks</span>
@ -521,12 +697,48 @@ export default function YieldPage() {
</div> </div>
{/* Desktop */} {/* Desktop */}
<div className="hidden lg:grid grid-cols-[1fr_80px_120px_80px_80px_80px_60px] gap-4 items-center px-3 py-3"> <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"> <div className="flex items-center gap-3">
<span className="text-sm font-bold text-white font-mono">{domain.domain}</span> <span className="text-sm font-bold text-white font-mono">{domain.domain}</span>
</div> </div>
<div className="flex justify-center"><StatusBadge status={domain.status} /></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> <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_clicks}</div>
<div className="text-right text-xs font-mono text-white/60">{domain.total_conversions}</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="text-right text-sm font-bold font-mono text-accent">${domain.total_revenue}</div>
@ -598,9 +810,9 @@ export default function YieldPage() {
<div className="p-4 bg-white/[0.02] border-t border-white/[0.08]"> <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="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="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">{tierName}</p></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> </div>
{tierName === '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>} {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> <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> </div>

View File

@ -298,6 +298,26 @@ class ApiClient {
}>(`/llm/vision?${qs.toString()}`) }>(`/llm/vision?${qs.toString()}`)
} }
async getYieldLandingPreview(domain: string, refresh: boolean = false) {
const qs = new URLSearchParams({ domain })
if (refresh) qs.set('refresh', 'true')
return this.request<{
domain: string
cached: boolean
model: string
prompt_version: string
generated_at: string
result: {
template: string
headline: string
seo_intro: string
cta_label: string
niche: string
color_scheme: string
}
}>(`/llm/yield/landing-preview?${qs.toString()}`)
}
// CFO (Alpha Terminal - Management) // CFO (Alpha Terminal - Management)
async getCfoSummary() { async getCfoSummary() {
return this.request<{ return this.request<{
@ -1841,6 +1861,14 @@ class AdminApiClient extends ApiClient {
cname_target: string cname_target: string
verification_url: string verification_url: string
} }
landing?: {
template: string
headline: string
seo_intro: string
cta_label: string
model?: string | null
generated_at?: string | null
} | null
message: string message: string
}>('/yield/activate', { }>('/yield/activate', {
method: 'POST', method: 'POST',
@ -2032,6 +2060,12 @@ export interface YieldDomain {
dns_verified: boolean dns_verified: boolean
dns_verified_at: string | null dns_verified_at: string | null
connected_at: string | null connected_at: string | null
landing_template?: string | null
landing_headline?: string | null
landing_intro?: string | null
landing_cta_label?: string | null
landing_model?: string | null
landing_generated_at?: string | null
total_clicks: number total_clicks: number
total_conversions: number total_conversions: number
total_revenue: number total_revenue: number