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""" + + + + + {escape(headline)} + + + + +
+
+

{escape(headline)}

+

{escape(intro)}

+ {escape(cta_label)} +
Powered by Pounce Yield
+
+
+ + +""" + diff --git a/frontend/src/components/analyze/AnalyzePanel.tsx b/frontend/src/components/analyze/AnalyzePanel.tsx index 199eedf..ee91b0f 100644 --- a/frontend/src/components/analyze/AnalyzePanel.tsx +++ b/frontend/src/components/analyze/AnalyzePanel.tsx @@ -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() { {section.title} - - {section.items.length} checks - + {section.key !== 'vision' && ( + + {section.items.length} checks + + )} + {section.key === 'vision' && ( + AI + )} {isExpanded ? ( @@ -378,7 +395,11 @@ export function AnalyzePanel() { {/* Section Items - BETTER CONTRAST */} {isExpanded && (
- {section.items.map((item) => { + {section.key === 'vision' ? ( +
+ +
+ ) : section.items.map((item) => { const statusStyle = getStatusColor(item.status) const StatusIcon = statusStyle.icon