Deploy: 2025-12-17 16:34
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:34:27 +01:00
parent 5a1fcb30dd
commit 8c499ddccd
5 changed files with 174 additions and 12 deletions

View File

@ -351,7 +351,7 @@ async def activate_domain_for_yield(
if not has_yield:
raise HTTPException(
status_code=403,
detail="Yield is not available on Scout plan. Upgrade to Trader or Tycoon.",
detail="Yield is available on Tycoon plan only. Upgrade to unlock.",
)
# Yield limits: Trader = 10, Tycoon = unlimited
@ -388,6 +388,11 @@ async def activate_domain_for_yield(
intent_result = detect_domain_intent(domain)
value_estimate = get_intent_detector().estimate_value(domain)
# Generate landing page config (Tycoon-only yield requirement)
# No fallback: if the LLM gateway is unavailable, activation must fail.
from app.services.llm_vision import generate_yield_landing
landing_cfg, landing_model = await generate_yield_landing(domain)
# Create yield domain record
yield_domain = YieldDomain(
user_id=current_user.id,
@ -396,6 +401,13 @@ async def activate_domain_for_yield(
intent_confidence=intent_result.confidence,
intent_keywords=json.dumps(intent_result.keywords_matched),
status="pending",
landing_config_json=landing_cfg.model_dump_json(),
landing_template=landing_cfg.template,
landing_headline=landing_cfg.headline,
landing_intro=landing_cfg.seo_intro,
landing_cta_label=landing_cfg.cta_label,
landing_model=landing_model,
landing_generated_at=datetime.utcnow(),
)
# Find best matching partner

View File

@ -18,7 +18,7 @@ from typing import Optional
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import RedirectResponse
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
@ -27,6 +27,7 @@ from app.config import get_settings
from app.models.yield_domain import YieldDomain, YieldTransaction, AffiliatePartner
from app.services.intent_detector import detect_domain_intent
from app.services.telemetry import track_event
from app.services.yield_landing_page import render_yield_landing_html
logger = logging.getLogger(__name__)
settings = get_settings()
@ -105,7 +106,7 @@ async def route_yield_domain(
domain: str,
request: Request,
db: AsyncSession = Depends(get_db),
direct: bool = Query(True, description="Direct redirect without landing page"),
direct: bool = Query(False, description="Direct redirect without landing page"),
):
"""
Route traffic for a yield domain.
@ -167,6 +168,29 @@ async def route_yield_domain(
if not partner:
raise HTTPException(status_code=503, detail="No active partner available for this domain intent.")
# Landing page mode: do NOT record a click yet.
# The CTA will call this endpoint again with direct=true, which records the click + redirects.
if not direct:
cta_url = f"/api/v1/r/{yield_domain.domain}?direct=true"
try:
html = render_yield_landing_html(yield_domain=yield_domain, cta_url=cta_url)
except Exception as e:
raise HTTPException(status_code=503, detail=f"Landing page not available: {e}")
await track_event(
db,
event_name="yield_landing_view",
request=request,
user_id=yield_domain.user_id,
is_authenticated=None,
source="routing",
domain=yield_domain.domain,
yield_domain_id=yield_domain.id,
metadata={"partner": partner.slug},
)
await db.commit()
return HTMLResponse(content=html, status_code=200)
# Rate limit: max 120 clicks/10min per IP per domain
client_ip = _get_client_ip(request)
ip_hash = hash_ip(client_ip) if client_ip else None
@ -241,7 +265,6 @@ async def route_yield_domain(
await db.commit()
# Only direct redirect for MVP
return RedirectResponse(url=destination_url, status_code=302)
@ -304,5 +327,5 @@ async def catch_all_route(
if not _:
raise HTTPException(status_code=404, detail="Host not configured for yield routing.")
return RedirectResponse(url=f"/api/v1/r/{host}?direct=true", status_code=302)
return RedirectResponse(url=f"/api/v1/r/{host}?direct=false", status_code=302)

View File

@ -81,7 +81,8 @@ TIER_CONFIG = {
"webhooks": False,
"bulk_tools": False,
"seo_metrics": False,
"yield": True,
# Yield (DNS routing + landing) is Tycoon-only. Trader can preview ideas in VISION.
"yield": False,
"daily_drop_digest": False,
}
},

View File

@ -0,0 +1,105 @@
"""
Generate a minimal, SEO-friendly landing page HTML for Yield domains.
The content comes from LLM-generated config stored on YieldDomain.
No placeholders or demo content: if required fields are missing, caller should error.
"""
from __future__ import annotations
from html import escape
from app.models.yield_domain import YieldDomain
def render_yield_landing_html(*, yield_domain: YieldDomain, cta_url: str) -> str:
headline = (yield_domain.landing_headline or "").strip()
intro = (yield_domain.landing_intro or "").strip()
cta_label = (yield_domain.landing_cta_label or "").strip()
if not headline or not intro or not cta_label:
raise ValueError("Yield landing config missing (headline/intro/cta_label)")
# Simple premium dark theme, fast to render, readable.
# Important: CTA must point to cta_url (which will record the click + redirect).
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{escape(headline)}</title>
<meta name="description" content="{escape(intro[:160])}" />
<style>
:root {{
--bg: #050505;
--panel: rgba(255,255,255,0.03);
--border: rgba(255,255,255,0.12);
--text: rgba(255,255,255,0.92);
--muted: rgba(255,255,255,0.60);
--accent: #10b981;
}}
html, body {{
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
}}
.wrap {{
max-width: 920px;
margin: 0 auto;
padding: 48px 20px;
}}
.card {{
border: 1px solid var(--border);
background: var(--panel);
padding: 28px;
}}
h1 {{
font-size: 34px;
line-height: 1.15;
margin: 0 0 14px;
letter-spacing: -0.02em;
}}
p {{
font-size: 16px;
line-height: 1.6;
margin: 0;
color: var(--muted);
}}
.cta {{
margin-top: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
height: 44px;
padding: 0 18px;
background: var(--accent);
color: #000;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 12px;
text-decoration: none;
}}
.fine {{
margin-top: 18px;
font-size: 12px;
color: rgba(255,255,255,0.35);
}}
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<h1>{escape(headline)}</h1>
<p>{escape(intro)}</p>
<a class="cta" href="{escape(cta_url)}" rel="nofollow noopener">{escape(cta_label)}</a>
<div class="fine">Powered by Pounce Yield</div>
</div>
</div>
</body>
</html>
"""

View File

@ -22,10 +22,12 @@ import {
ChevronUp,
CheckCircle2,
XCircle,
Sparkles,
} from 'lucide-react'
import { api } from '@/lib/api'
import { useAnalyzePanelStore } from '@/lib/analyze-store'
import type { AnalyzeResponse, AnalyzeSection, AnalyzeItem } from '@/components/analyze/types'
import { VisionSection } from '@/components/analyze/VisionSection'
// ============================================================================
// HELPERS
@ -54,6 +56,8 @@ function getSectionIcon(key: string) {
return AlertTriangle
case 'value':
return DollarSign
case 'vision':
return Sparkles
default:
return Globe
}
@ -69,6 +73,8 @@ function getSectionColor(key: string) {
return { text: 'text-amber-400', bg: 'bg-amber-500/10', border: 'border-amber-500/30' }
case 'value':
return { text: 'text-violet-400', bg: 'bg-violet-500/10', border: 'border-violet-500/30' }
case 'vision':
return { text: 'text-accent', bg: 'bg-accent/10', border: 'border-accent/30' }
default:
return { text: 'text-white/60', bg: 'bg-white/5', border: 'border-white/20' }
}
@ -119,7 +125,8 @@ export function AnalyzePanel() {
authority: true,
market: true,
risk: true,
value: true
value: true,
vision: true,
})
const refresh = useCallback(async () => {
@ -176,9 +183,14 @@ export function AnalyzePanel() {
const visibleSections = useMemo(() => {
const sections = data?.sections || []
const order = ['authority', 'market', 'risk', 'value']
return [...sections]
const sorted = [...sections]
.sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key))
.filter((s) => sectionVisibility[s.key] !== false)
// Append VISION (Terminal-only LLM augmentation)
if (sectionVisibility.vision === false) return sorted
const visionSection: AnalyzeSection = { key: 'vision', title: 'VISION', items: [] }
return [...sorted, visionSection]
}, [data, sectionVisibility])
// Calculate overall score
@ -364,9 +376,14 @@ export function AnalyzePanel() {
<span className={clsx("text-sm font-bold uppercase tracking-wider", sectionStyle.text)}>
{section.title}
</span>
<span className="text-sm font-mono text-white/40 ml-2">
{section.items.length} checks
</span>
{section.key !== 'vision' && (
<span className="text-sm font-mono text-white/40 ml-2">
{section.items.length} checks
</span>
)}
{section.key === 'vision' && (
<span className="text-sm font-mono text-white/40 ml-2">AI</span>
)}
</div>
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-white/40" />
@ -378,7 +395,11 @@ export function AnalyzePanel() {
{/* Section Items - BETTER CONTRAST */}
{isExpanded && (
<div className="border-t border-white/10">
{section.items.map((item) => {
{section.key === 'vision' ? (
<div className="px-5 py-5">
<VisionSection domain={headerDomain} />
</div>
) : section.items.map((item) => {
const statusStyle = getStatusColor(item.status)
const StatusIcon = statusStyle.icon