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: if not has_yield:
raise HTTPException( raise HTTPException(
status_code=403, 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 # Yield limits: Trader = 10, Tycoon = unlimited
@ -388,6 +388,11 @@ async def activate_domain_for_yield(
intent_result = detect_domain_intent(domain) intent_result = detect_domain_intent(domain)
value_estimate = get_intent_detector().estimate_value(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 # Create yield domain record
yield_domain = YieldDomain( yield_domain = YieldDomain(
user_id=current_user.id, user_id=current_user.id,
@ -396,6 +401,13 @@ async def activate_domain_for_yield(
intent_confidence=intent_result.confidence, intent_confidence=intent_result.confidence,
intent_keywords=json.dumps(intent_result.keywords_matched), intent_keywords=json.dumps(intent_result.keywords_matched),
status="pending", 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 # Find best matching partner

View File

@ -18,7 +18,7 @@ from typing import Optional
from uuid import uuid4 from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Query, Request 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 import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession 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.models.yield_domain import YieldDomain, YieldTransaction, AffiliatePartner
from app.services.intent_detector import detect_domain_intent from app.services.intent_detector import detect_domain_intent
from app.services.telemetry import track_event from app.services.telemetry import track_event
from app.services.yield_landing_page import render_yield_landing_html
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
settings = get_settings() settings = get_settings()
@ -105,7 +106,7 @@ async def route_yield_domain(
domain: str, domain: str,
request: Request, request: Request,
db: AsyncSession = Depends(get_db), 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. Route traffic for a yield domain.
@ -167,6 +168,29 @@ async def route_yield_domain(
if not partner: if not partner:
raise HTTPException(status_code=503, detail="No active partner available for this domain intent.") 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 # Rate limit: max 120 clicks/10min per IP per domain
client_ip = _get_client_ip(request) client_ip = _get_client_ip(request)
ip_hash = hash_ip(client_ip) if client_ip else None ip_hash = hash_ip(client_ip) if client_ip else None
@ -241,7 +265,6 @@ async def route_yield_domain(
await db.commit() await db.commit()
# Only direct redirect for MVP
return RedirectResponse(url=destination_url, status_code=302) return RedirectResponse(url=destination_url, status_code=302)
@ -304,5 +327,5 @@ async def catch_all_route(
if not _: if not _:
raise HTTPException(status_code=404, detail="Host not configured for yield routing.") 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, "webhooks": False,
"bulk_tools": False, "bulk_tools": False,
"seo_metrics": 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, "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, ChevronUp,
CheckCircle2, CheckCircle2,
XCircle, XCircle,
Sparkles,
} from 'lucide-react' } from 'lucide-react'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { useAnalyzePanelStore } from '@/lib/analyze-store' import { useAnalyzePanelStore } from '@/lib/analyze-store'
import type { AnalyzeResponse, AnalyzeSection, AnalyzeItem } from '@/components/analyze/types' import type { AnalyzeResponse, AnalyzeSection, AnalyzeItem } from '@/components/analyze/types'
import { VisionSection } from '@/components/analyze/VisionSection'
// ============================================================================ // ============================================================================
// HELPERS // HELPERS
@ -54,6 +56,8 @@ function getSectionIcon(key: string) {
return AlertTriangle return AlertTriangle
case 'value': case 'value':
return DollarSign return DollarSign
case 'vision':
return Sparkles
default: default:
return Globe 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' } return { text: 'text-amber-400', bg: 'bg-amber-500/10', border: 'border-amber-500/30' }
case 'value': case 'value':
return { text: 'text-violet-400', bg: 'bg-violet-500/10', border: 'border-violet-500/30' } 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: default:
return { text: 'text-white/60', bg: 'bg-white/5', border: 'border-white/20' } return { text: 'text-white/60', bg: 'bg-white/5', border: 'border-white/20' }
} }
@ -119,7 +125,8 @@ export function AnalyzePanel() {
authority: true, authority: true,
market: true, market: true,
risk: true, risk: true,
value: true value: true,
vision: true,
}) })
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
@ -176,9 +183,14 @@ export function AnalyzePanel() {
const visibleSections = useMemo(() => { const visibleSections = useMemo(() => {
const sections = data?.sections || [] const sections = data?.sections || []
const order = ['authority', 'market', 'risk', 'value'] const order = ['authority', 'market', 'risk', 'value']
return [...sections] const sorted = [...sections]
.sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key)) .sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key))
.filter((s) => sectionVisibility[s.key] !== false) .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]) }, [data, sectionVisibility])
// Calculate overall score // Calculate overall score
@ -364,9 +376,14 @@ export function AnalyzePanel() {
<span className={clsx("text-sm font-bold uppercase tracking-wider", sectionStyle.text)}> <span className={clsx("text-sm font-bold uppercase tracking-wider", sectionStyle.text)}>
{section.title} {section.title}
</span> </span>
{section.key !== 'vision' && (
<span className="text-sm font-mono text-white/40 ml-2"> <span className="text-sm font-mono text-white/40 ml-2">
{section.items.length} checks {section.items.length} checks
</span> </span>
)}
{section.key === 'vision' && (
<span className="text-sm font-mono text-white/40 ml-2">AI</span>
)}
</div> </div>
{isExpanded ? ( {isExpanded ? (
<ChevronUp className="w-5 h-5 text-white/40" /> <ChevronUp className="w-5 h-5 text-white/40" />
@ -378,7 +395,11 @@ export function AnalyzePanel() {
{/* Section Items - BETTER CONTRAST */} {/* Section Items - BETTER CONTRAST */}
{isExpanded && ( {isExpanded && (
<div className="border-t border-white/10"> <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 statusStyle = getStatusColor(item.status)
const StatusIcon = statusStyle.icon const StatusIcon = statusStyle.icon