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
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:
@ -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
|
||||
@ -387,6 +387,11 @@ async def activate_domain_for_yield(
|
||||
# Analyze domain intent
|
||||
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(
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
},
|
||||
|
||||
105
backend/app/services/yield_landing_page.py
Normal file
105
backend/app/services/yield_landing_page.py
Normal 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>
|
||||
"""
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user