From f711ac23b9470b769fc82365ff75daf119ef7dfc Mon Sep 17 00:00:00 2001 From: Yves Gugger Date: Wed, 17 Dec 2025 16:47:34 +0100 Subject: [PATCH] Yield: show landing details in table + Trader preview, Tycoon activation --- backend/app/api/llm_vision.py | 101 +++++++++- backend/app/api/yield_domains.py | 15 ++ backend/app/schemas/yield_domain.py | 21 ++ frontend/src/app/terminal/yield/page.tsx | 244 +++++++++++++++++++++-- frontend/src/lib/api.ts | 34 ++++ 5 files changed, 398 insertions(+), 17 deletions(-) diff --git a/backend/app/api/llm_vision.py b/backend/app/api/llm_vision.py index 6ecaa10..f5b8eac 100644 --- a/backend/app/api/llm_vision.py +++ b/backend/app/api/llm_vision.py @@ -19,7 +19,14 @@ from app.api.deps import CurrentUser, Database from app.models.llm_artifact import LLMArtifact from app.models.subscription import Subscription, SubscriptionTier from app.services.llm_gateway import LLMGatewayError -from app.services.llm_vision import VISION_PROMPT_VERSION, VisionResult, generate_vision +from app.services.llm_vision import ( + VISION_PROMPT_VERSION, + YIELD_LANDING_PROMPT_VERSION, + VisionResult, + YieldLandingConfig, + generate_vision, + generate_yield_landing, +) router = APIRouter(prefix="/llm", tags=["LLM Vision"]) @@ -34,6 +41,15 @@ class VisionResponse(BaseModel): result: VisionResult +class YieldLandingPreviewResponse(BaseModel): + domain: str + cached: bool + model: str + prompt_version: str + generated_at: str + result: YieldLandingConfig + + async def _get_or_create_subscription(db: Database, user_id: int) -> Subscription: res = await db.execute(select(Subscription).where(Subscription.user_id == user_id)) sub = res.scalar_one_or_none() @@ -131,3 +147,86 @@ async def get_vision( result=result, ) + +@router.get("/yield/landing-preview", response_model=YieldLandingPreviewResponse) +async def get_yield_landing_preview( + current_user: CurrentUser, + db: Database, + domain: str = Query(..., min_length=3, max_length=255), + refresh: bool = Query(False, description="Bypass cache and regenerate"), +): + """ + Generate a Yield landing page configuration preview for Terminal UX. + + Trader + Tycoon: allowed. + Scout: blocked (upgrade teaser). + """ + sub = await _get_or_create_subscription(db, current_user.id) + _require_trader_or_higher(sub) + + normalized = domain.strip().lower() + now = datetime.utcnow() + ttl_days = 30 + + if not refresh: + cached = ( + await db.execute( + select(LLMArtifact) + .where( + and_( + LLMArtifact.kind == "yield_landing_preview_v1", + LLMArtifact.domain == normalized, + LLMArtifact.prompt_version == YIELD_LANDING_PROMPT_VERSION, + (LLMArtifact.expires_at.is_(None) | (LLMArtifact.expires_at > now)), + ) + ) + .order_by(LLMArtifact.created_at.desc()) + .limit(1) + ) + ).scalar_one_or_none() + if cached: + try: + payload = json.loads(cached.payload_json) + result = YieldLandingConfig.model_validate(payload) + except Exception: + cached = None + else: + return YieldLandingPreviewResponse( + domain=normalized, + cached=True, + model=cached.model, + prompt_version=cached.prompt_version, + generated_at=cached.created_at.isoformat(), + result=result, + ) + + try: + result, model_used = await generate_yield_landing(normalized) + except LLMGatewayError as e: + raise HTTPException(status_code=502, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Landing preview generation failed: {e}") + + artifact = LLMArtifact( + user_id=current_user.id, + kind="yield_landing_preview_v1", + domain=normalized, + prompt_version=YIELD_LANDING_PROMPT_VERSION, + model=model_used, + payload_json=result.model_dump_json(), + created_at=now, + updated_at=now, + expires_at=now + timedelta(days=ttl_days), + ) + db.add(artifact) + await db.commit() + + return YieldLandingPreviewResponse( + domain=normalized, + cached=False, + model=model_used, + prompt_version=YIELD_LANDING_PROMPT_VERSION, + generated_at=now.isoformat(), + result=result, + ) + diff --git a/backend/app/api/yield_domains.py b/backend/app/api/yield_domains.py index db3f9ef..8f0a857 100644 --- a/backend/app/api/yield_domains.py +++ b/backend/app/api/yield_domains.py @@ -37,6 +37,7 @@ from app.schemas.yield_domain import ( DNSSetupInstructions, ActivateYieldRequest, ActivateYieldResponse, + YieldLandingPreview, ) from app.services.intent_detector import ( detect_domain_intent, @@ -460,6 +461,14 @@ async def activate_domain_for_yield( geo=value_estimate["geo"], ), dns_instructions=dns_instructions, + landing=YieldLandingPreview( + template=yield_domain.landing_template or "generic", + headline=yield_domain.landing_headline or "", + seo_intro=yield_domain.landing_intro or "", + cta_label=yield_domain.landing_cta_label or "View offers", + model=getattr(yield_domain, "landing_model", None), + generated_at=getattr(yield_domain, "landing_generated_at", None), + ), message="Domain registered! Point your DNS to our nameservers to complete activation.", ) @@ -771,6 +780,12 @@ def _domain_to_response(domain: YieldDomain) -> YieldDomainResponse: intent_confidence=domain.intent_confidence, active_route=domain.active_route, partner_name=domain.partner.name if domain.partner else None, + landing_template=getattr(domain, "landing_template", None), + landing_headline=getattr(domain, "landing_headline", None), + landing_intro=getattr(domain, "landing_intro", None), + landing_cta_label=getattr(domain, "landing_cta_label", None), + landing_model=getattr(domain, "landing_model", None), + landing_generated_at=getattr(domain, "landing_generated_at", None), dns_verified=domain.dns_verified, dns_verified_at=domain.dns_verified_at, connected_at=getattr(domain, "connected_at", None), diff --git a/backend/app/schemas/yield_domain.py b/backend/app/schemas/yield_domain.py index 5de90fb..32ba357 100644 --- a/backend/app/schemas/yield_domain.py +++ b/backend/app/schemas/yield_domain.py @@ -69,6 +69,14 @@ class YieldDomainResponse(BaseModel): # Routing active_route: Optional[str] = None partner_name: Optional[str] = None + + # Landing page (generated at activation time) + landing_template: Optional[str] = None + landing_headline: Optional[str] = None + landing_intro: Optional[str] = None + landing_cta_label: Optional[str] = None + landing_model: Optional[str] = None + landing_generated_at: Optional[datetime] = None # DNS dns_verified: bool = False @@ -263,6 +271,16 @@ class DNSSetupInstructions(BaseModel): # Activation Flow # ============================================================================ +class YieldLandingPreview(BaseModel): + """LLM-generated landing page config preview.""" + template: str + headline: str + seo_intro: str + cta_label: str + model: Optional[str] = None + generated_at: Optional[datetime] = None + + class ActivateYieldRequest(BaseModel): """Request to activate a domain for yield.""" domain: str = Field(..., min_length=3, max_length=255) @@ -281,6 +299,9 @@ class ActivateYieldResponse(BaseModel): # Setup dns_instructions: DNSSetupInstructions + + # Generated landing page config (so user can preview instantly) + landing: Optional[YieldLandingPreview] = None message: str diff --git a/frontend/src/app/terminal/yield/page.tsx b/frontend/src/app/terminal/yield/page.tsx index d46754e..a7fbba6 100644 --- a/frontend/src/app/terminal/yield/page.tsx +++ b/frontend/src/app/terminal/yield/page.tsx @@ -50,6 +50,11 @@ function ActivateModal({ onSuccess: () => void prefillDomain?: string | null }) { + const subscription = useStore((s) => s.subscription) + const tier = (subscription?.tier || 'scout').toLowerCase() + const isTycoon = tier === 'tycoon' + const canPreview = tier === 'trader' || tier === 'tycoon' + const [selectedDomain, setSelectedDomain] = useState('') const [verifiedDomains, setVerifiedDomains] = useState<{ id: number; domain: string }[]>([]) const [loadingDomains, setLoadingDomains] = useState(true) @@ -67,6 +72,14 @@ function ActivateModal({ cname_target: string verification_url: string } + landing?: { + template: string + headline: string + seo_intro: string + cta_label: string + model?: string | null + generated_at?: string | null + } | null }>(null) const [dnsChecking, setDnsChecking] = useState(false) const [dnsResult, setDnsResult] = useState(null) const [copied, setCopied] = useState(null) + + const [previewLoading, setPreviewLoading] = useState(false) + const [previewError, setPreviewError] = useState(null) + const [preview, setPreview] = useState(null) useEffect(() => { if (!isOpen) return @@ -108,6 +135,9 @@ function ActivateModal({ setDnsChecking(false) setError(null) setSelectedDomain('') + setPreview(null) + setPreviewError(null) + setPreviewLoading(false) }, [isOpen]) const copyToClipboard = async (value: string, key: string) => { @@ -131,6 +161,7 @@ function ActivateModal({ domain: res.domain, status: res.status, dns_instructions: res.dns_instructions, + landing: res.landing || null, }) setStep(2) } catch (err: any) { @@ -140,6 +171,32 @@ function ActivateModal({ } } + const handlePreview = async (refresh: boolean = false) => { + if (!selectedDomain) return + if (!canPreview) return + setPreviewLoading(true) + setPreviewError(null) + setPreview(null) + try { + const res = await api.getYieldLandingPreview(selectedDomain, refresh) + setPreview({ + template: res.result.template, + headline: res.result.headline, + seo_intro: res.result.seo_intro, + cta_label: res.result.cta_label, + niche: res.result.niche, + color_scheme: res.result.color_scheme, + model: res.model, + generated_at: res.generated_at, + cached: res.cached, + }) + } catch (err: any) { + setPreviewError(err.message || 'Preview failed') + } finally { + setPreviewLoading(false) + } + } + const checkDNS = useCallback(async (domainId: number) => { setDnsChecking(true) setError(null) @@ -170,7 +227,9 @@ function ActivateModal({
- Activate Yield + + {isTycoon ? 'Activate Yield' : 'Yield Preview'} +
@@ -205,15 +264,75 @@ function ActivateModal({ ))} -
-

Only DNS-verified domains from your portfolio can be activated for Yield.

+
+ {isTycoon ? ( +

Only DNS-verified domains from your portfolio can be activated for Yield.

+ ) : ( +

+ Yield is Tycoon-only. On Trader you can preview the landing page that will be generated. +

+ )}
{error &&
{error}
} - + + {!isTycoon && ( + <> +
+ + + Upgrade + +
+ + {previewError && ( +
+ {previewError} +
+ )} + + {preview && ( +
+
+
Landing Page Preview
+ +
+
{preview.headline}
+
{preview.seo_intro}
+
+ CTA: {preview.cta_label} + {preview.cached ? 'Cached' : 'Fresh'} • {preview.model} +
+
+ )} + + )} + + {isTycoon && ( + + )} ) : ( <> @@ -227,6 +346,18 @@ function ActivateModal({
+ {activation?.landing?.headline && ( +
+
Landing Page (Generated)
+
{activation.landing.headline}
+
{activation.landing.seo_intro}
+
+ CTA: {activation.landing.cta_label} + {activation.landing.model ? • {activation.landing.model} : null} +
+
+ )} +
Option A (Recommended): Nameservers
@@ -323,6 +454,10 @@ export default function YieldPage() { const [refreshing, setRefreshing] = useState(false) const [menuOpen, setMenuOpen] = useState(false) const [deletingId, setDeletingId] = useState(null) + + const tier = (subscription?.tier || 'scout').toLowerCase() + const tierName = subscription?.tier_name || (tier.charAt(0).toUpperCase() + tier.slice(1)) + const isTycoon = tier === 'tycoon' useEffect(() => { checkAuth() }, [checkAuth]) @@ -366,8 +501,8 @@ export default function YieldPage() { { href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: false }, ] - const tierName = subscription?.tier_name || subscription?.tier || 'Scout' - const TierIcon = tierName === 'Tycoon' ? Crown : tierName === 'Trader' ? TrendingUp : Zap + const tierLabelForDrawer = subscription?.tier_name || subscription?.tier || 'Scout' + const TierIcon = tierLabelForDrawer === 'Tycoon' ? Crown : tierLabelForDrawer === 'Trader' ? TrendingUp : Zap const drawerNavSections = [ { title: 'Discover', items: [ @@ -430,6 +565,12 @@ export default function YieldPage() {

Monetize your parked domains. Route visitor intent to earn passive income.

+ {!isTycoon && ( +
+ + Yield activation is Tycoon-only. You can preview the landing page on Trader. +
+ )}
{stats && ( @@ -451,7 +592,7 @@ export default function YieldPage() {
@@ -462,7 +603,7 @@ export default function YieldPage() {
@@ -481,10 +622,11 @@ export default function YieldPage() { ) : (
{/* Header */} -
+
Domain
Status
Intent
+
Landing
Clicks
Conv.
Revenue
@@ -501,6 +643,40 @@ export default function YieldPage() {
+
+
+ + + Landing:{' '} + {domain.landing_headline ? ( + Ready + ) : ( + Missing + )} + + expand + +
+ {domain.landing_headline ? ( +
+
{domain.landing_headline}
+ {domain.landing_intro &&
{domain.landing_intro}
} +
+ CTA: {domain.landing_cta_label || '—'} +
+
+ {domain.landing_generated_at ? new Date(domain.landing_generated_at).toLocaleString() : '—'} + {domain.landing_model ? • {domain.landing_model} : null} +
+
+ ) : ( +
+ No landing config stored yet. (Older activation) Remove + re-activate on Tycoon to regenerate. +
+ )} +
+
+
{domain.total_clicks} clicks @@ -521,12 +697,48 @@ export default function YieldPage() {
{/* Desktop */} -
+
{domain.domain}
{domain.detected_intent?.replace('_', ' ') || '—'} +
+
+ + + {domain.landing_headline ? ( + Ready + ) : ( + Missing + )} + + details + +
+ {domain.landing_headline ? ( + <> +
{domain.landing_headline}
+ {domain.landing_intro &&
{domain.landing_intro}
} +
+ CTA: {domain.landing_cta_label || '—'} +
+
+ {domain.landing_generated_at ? new Date(domain.landing_generated_at).toLocaleString() : '—'} + {domain.landing_model ? • {domain.landing_model} : null} +
+ + ) : ( +
+ No landing config stored yet. Remove + re-activate on Tycoon to regenerate. +
+ )} +
+
+
{domain.total_clicks}
{domain.total_conversions}
${domain.total_revenue}
@@ -598,9 +810,9 @@ export default function YieldPage() {
-

{user?.name || user?.email?.split('@')[0] || 'User'}

{tierName}

+

{user?.name || user?.email?.split('@')[0] || 'User'}

{tierLabelForDrawer}

- {tierName === 'Scout' && setMenuOpen(false)} className="flex items-center justify-center gap-2 w-full py-2.5 bg-accent text-black text-xs font-bold uppercase mb-2">Upgrade} + {tierLabelForDrawer === 'Scout' && setMenuOpen(false)} className="flex items-center justify-center gap-2 w-full py-2.5 bg-accent text-black text-xs font-bold uppercase mb-2">Upgrade}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 678bc4b..1880fd1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -298,6 +298,26 @@ class ApiClient { }>(`/llm/vision?${qs.toString()}`) } + async getYieldLandingPreview(domain: string, refresh: boolean = false) { + const qs = new URLSearchParams({ domain }) + if (refresh) qs.set('refresh', 'true') + return this.request<{ + domain: string + cached: boolean + model: string + prompt_version: string + generated_at: string + result: { + template: string + headline: string + seo_intro: string + cta_label: string + niche: string + color_scheme: string + } + }>(`/llm/yield/landing-preview?${qs.toString()}`) + } + // CFO (Alpha Terminal - Management) async getCfoSummary() { return this.request<{ @@ -1841,6 +1861,14 @@ class AdminApiClient extends ApiClient { cname_target: string verification_url: string } + landing?: { + template: string + headline: string + seo_intro: string + cta_label: string + model?: string | null + generated_at?: string | null + } | null message: string }>('/yield/activate', { method: 'POST', @@ -2032,6 +2060,12 @@ export interface YieldDomain { dns_verified: boolean dns_verified_at: string | null connected_at: string | null + landing_template?: string | null + landing_headline?: string | null + landing_intro?: string | null + landing_cta_label?: string | null + landing_model?: string | null + landing_generated_at?: string | null total_clicks: number total_conversions: number total_revenue: number