diff --git a/backend/app/api/yield_domains.py b/backend/app/api/yield_domains.py index 0e5764c..db3f9ef 100644 --- a/backend/app/api/yield_domains.py +++ b/backend/app/api/yield_domains.py @@ -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 diff --git a/backend/app/api/yield_routing.py b/backend/app/api/yield_routing.py index 49b4955..a1e9401 100644 --- a/backend/app/api/yield_routing.py +++ b/backend/app/api/yield_routing.py @@ -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) diff --git a/backend/app/models/subscription.py b/backend/app/models/subscription.py index 80b3706..dc960db 100644 --- a/backend/app/models/subscription.py +++ b/backend/app/models/subscription.py @@ -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, } }, diff --git a/backend/app/services/yield_landing_page.py b/backend/app/services/yield_landing_page.py new file mode 100644 index 0000000..1861e62 --- /dev/null +++ b/backend/app/services/yield_landing_page.py @@ -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""" + +
+ + +