Yield: show landing details in table + Trader preview, Tycoon activation
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:
@ -19,7 +19,14 @@ from app.api.deps import CurrentUser, Database
|
|||||||
from app.models.llm_artifact import LLMArtifact
|
from app.models.llm_artifact import LLMArtifact
|
||||||
from app.models.subscription import Subscription, SubscriptionTier
|
from app.models.subscription import Subscription, SubscriptionTier
|
||||||
from app.services.llm_gateway import LLMGatewayError
|
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"])
|
router = APIRouter(prefix="/llm", tags=["LLM Vision"])
|
||||||
@ -34,6 +41,15 @@ class VisionResponse(BaseModel):
|
|||||||
result: VisionResult
|
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:
|
async def _get_or_create_subscription(db: Database, user_id: int) -> Subscription:
|
||||||
res = await db.execute(select(Subscription).where(Subscription.user_id == user_id))
|
res = await db.execute(select(Subscription).where(Subscription.user_id == user_id))
|
||||||
sub = res.scalar_one_or_none()
|
sub = res.scalar_one_or_none()
|
||||||
@ -131,3 +147,86 @@ async def get_vision(
|
|||||||
result=result,
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
@ -37,6 +37,7 @@ from app.schemas.yield_domain import (
|
|||||||
DNSSetupInstructions,
|
DNSSetupInstructions,
|
||||||
ActivateYieldRequest,
|
ActivateYieldRequest,
|
||||||
ActivateYieldResponse,
|
ActivateYieldResponse,
|
||||||
|
YieldLandingPreview,
|
||||||
)
|
)
|
||||||
from app.services.intent_detector import (
|
from app.services.intent_detector import (
|
||||||
detect_domain_intent,
|
detect_domain_intent,
|
||||||
@ -460,6 +461,14 @@ async def activate_domain_for_yield(
|
|||||||
geo=value_estimate["geo"],
|
geo=value_estimate["geo"],
|
||||||
),
|
),
|
||||||
dns_instructions=dns_instructions,
|
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.",
|
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,
|
intent_confidence=domain.intent_confidence,
|
||||||
active_route=domain.active_route,
|
active_route=domain.active_route,
|
||||||
partner_name=domain.partner.name if domain.partner else None,
|
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=domain.dns_verified,
|
||||||
dns_verified_at=domain.dns_verified_at,
|
dns_verified_at=domain.dns_verified_at,
|
||||||
connected_at=getattr(domain, "connected_at", None),
|
connected_at=getattr(domain, "connected_at", None),
|
||||||
|
|||||||
@ -69,6 +69,14 @@ class YieldDomainResponse(BaseModel):
|
|||||||
# Routing
|
# Routing
|
||||||
active_route: Optional[str] = None
|
active_route: Optional[str] = None
|
||||||
partner_name: 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
|
||||||
dns_verified: bool = False
|
dns_verified: bool = False
|
||||||
@ -263,6 +271,16 @@ class DNSSetupInstructions(BaseModel):
|
|||||||
# Activation Flow
|
# 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):
|
class ActivateYieldRequest(BaseModel):
|
||||||
"""Request to activate a domain for yield."""
|
"""Request to activate a domain for yield."""
|
||||||
domain: str = Field(..., min_length=3, max_length=255)
|
domain: str = Field(..., min_length=3, max_length=255)
|
||||||
@ -281,6 +299,9 @@ class ActivateYieldResponse(BaseModel):
|
|||||||
|
|
||||||
# Setup
|
# Setup
|
||||||
dns_instructions: DNSSetupInstructions
|
dns_instructions: DNSSetupInstructions
|
||||||
|
|
||||||
|
# Generated landing page config (so user can preview instantly)
|
||||||
|
landing: Optional[YieldLandingPreview] = None
|
||||||
|
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|||||||
@ -50,6 +50,11 @@ function ActivateModal({
|
|||||||
onSuccess: () => void
|
onSuccess: () => void
|
||||||
prefillDomain?: string | null
|
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 [selectedDomain, setSelectedDomain] = useState('')
|
||||||
const [verifiedDomains, setVerifiedDomains] = useState<{ id: number; domain: string }[]>([])
|
const [verifiedDomains, setVerifiedDomains] = useState<{ id: number; domain: string }[]>([])
|
||||||
const [loadingDomains, setLoadingDomains] = useState(true)
|
const [loadingDomains, setLoadingDomains] = useState(true)
|
||||||
@ -67,6 +72,14 @@ function ActivateModal({
|
|||||||
cname_target: string
|
cname_target: string
|
||||||
verification_url: string
|
verification_url: string
|
||||||
}
|
}
|
||||||
|
landing?: {
|
||||||
|
template: string
|
||||||
|
headline: string
|
||||||
|
seo_intro: string
|
||||||
|
cta_label: string
|
||||||
|
model?: string | null
|
||||||
|
generated_at?: string | null
|
||||||
|
} | null
|
||||||
}>(null)
|
}>(null)
|
||||||
const [dnsChecking, setDnsChecking] = useState(false)
|
const [dnsChecking, setDnsChecking] = useState(false)
|
||||||
const [dnsResult, setDnsResult] = useState<null | {
|
const [dnsResult, setDnsResult] = useState<null | {
|
||||||
@ -77,6 +90,20 @@ function ActivateModal({
|
|||||||
error: string | null
|
error: string | null
|
||||||
}>(null)
|
}>(null)
|
||||||
const [copied, setCopied] = useState<string | null>(null)
|
const [copied, setCopied] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false)
|
||||||
|
const [previewError, setPreviewError] = useState<string | null>(null)
|
||||||
|
const [preview, setPreview] = useState<null | {
|
||||||
|
template: string
|
||||||
|
headline: string
|
||||||
|
seo_intro: string
|
||||||
|
cta_label: string
|
||||||
|
niche: string
|
||||||
|
color_scheme: string
|
||||||
|
model: string
|
||||||
|
generated_at: string
|
||||||
|
cached: boolean
|
||||||
|
}>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
@ -108,6 +135,9 @@ function ActivateModal({
|
|||||||
setDnsChecking(false)
|
setDnsChecking(false)
|
||||||
setError(null)
|
setError(null)
|
||||||
setSelectedDomain('')
|
setSelectedDomain('')
|
||||||
|
setPreview(null)
|
||||||
|
setPreviewError(null)
|
||||||
|
setPreviewLoading(false)
|
||||||
}, [isOpen])
|
}, [isOpen])
|
||||||
|
|
||||||
const copyToClipboard = async (value: string, key: string) => {
|
const copyToClipboard = async (value: string, key: string) => {
|
||||||
@ -131,6 +161,7 @@ function ActivateModal({
|
|||||||
domain: res.domain,
|
domain: res.domain,
|
||||||
status: res.status,
|
status: res.status,
|
||||||
dns_instructions: res.dns_instructions,
|
dns_instructions: res.dns_instructions,
|
||||||
|
landing: res.landing || null,
|
||||||
})
|
})
|
||||||
setStep(2)
|
setStep(2)
|
||||||
} catch (err: any) {
|
} 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) => {
|
const checkDNS = useCallback(async (domainId: number) => {
|
||||||
setDnsChecking(true)
|
setDnsChecking(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
@ -170,7 +227,9 @@ function ActivateModal({
|
|||||||
<div className="flex items-center justify-between p-4 border-b border-white/[0.08]">
|
<div className="flex items-center justify-between p-4 border-b border-white/[0.08]">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Sparkles className="w-4 h-4 text-accent" />
|
<Sparkles className="w-4 h-4 text-accent" />
|
||||||
<span className="text-xs font-mono text-accent uppercase tracking-wider">Activate Yield</span>
|
<span className="text-xs font-mono text-accent uppercase tracking-wider">
|
||||||
|
{isTycoon ? 'Activate Yield' : 'Yield Preview'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/40"><X className="w-4 h-4" /></button>
|
<button onClick={onClose} className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/40"><X className="w-4 h-4" /></button>
|
||||||
</div>
|
</div>
|
||||||
@ -205,15 +264,75 @@ function ActivateModal({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-accent/5 border border-accent/20 text-xs text-accent/80 font-mono">
|
<div className={clsx(
|
||||||
<p>Only DNS-verified domains from your portfolio can be activated for Yield.</p>
|
"p-3 border text-xs font-mono",
|
||||||
|
isTycoon ? "bg-accent/5 border-accent/20 text-accent/80" : "bg-white/[0.02] border-white/[0.08] text-white/50"
|
||||||
|
)}>
|
||||||
|
{isTycoon ? (
|
||||||
|
<p>Only DNS-verified domains from your portfolio can be activated for Yield.</p>
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
Yield is <span className="text-white/80 font-bold">Tycoon-only</span>. On Trader you can preview the landing page that will be generated.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="p-2 bg-rose-500/10 border border-rose-500/20 text-rose-400 text-xs">{error}</div>}
|
{error && <div className="p-2 bg-rose-500/10 border border-rose-500/20 text-rose-400 text-xs">{error}</div>}
|
||||||
<button onClick={handleActivate} disabled={loading || !selectedDomain}
|
|
||||||
className="w-full py-2.5 bg-accent text-black text-xs font-bold uppercase flex items-center justify-center gap-2 disabled:opacity-50">
|
{!isTycoon && (
|
||||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
<>
|
||||||
Activate Yield
|
<div className="flex gap-2">
|
||||||
</button>
|
<button
|
||||||
|
onClick={() => handlePreview(false)}
|
||||||
|
disabled={previewLoading || !selectedDomain}
|
||||||
|
className="flex-1 py-2.5 bg-white/10 text-white text-xs font-bold uppercase flex items-center justify-center gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{previewLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||||
|
Generate Preview
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href="/pricing"
|
||||||
|
className="px-3 py-2.5 bg-accent text-black text-xs font-bold uppercase flex items-center justify-center"
|
||||||
|
>
|
||||||
|
Upgrade
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{previewError && (
|
||||||
|
<div className="p-3 bg-rose-500/10 border border-rose-500/20 text-rose-300 text-xs">
|
||||||
|
{previewError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{preview && (
|
||||||
|
<div className="p-3 bg-[#050505] border border-white/[0.08] space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-[9px] font-mono text-white/40 uppercase tracking-wider">Landing Page Preview</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handlePreview(true)}
|
||||||
|
className="text-[10px] font-mono text-white/40 hover:text-white"
|
||||||
|
disabled={previewLoading}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-bold text-white">{preview.headline}</div>
|
||||||
|
<div className="text-xs text-white/50">{preview.seo_intro}</div>
|
||||||
|
<div className="flex items-center justify-between text-[10px] font-mono text-white/40 pt-2 border-t border-white/10">
|
||||||
|
<span>CTA: <span className="text-white/70">{preview.cta_label}</span></span>
|
||||||
|
<span>{preview.cached ? 'Cached' : 'Fresh'} • {preview.model}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isTycoon && (
|
||||||
|
<button onClick={handleActivate} disabled={loading || !selectedDomain}
|
||||||
|
className="w-full py-2.5 bg-accent text-black text-xs font-bold uppercase flex items-center justify-center gap-2 disabled:opacity-50">
|
||||||
|
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||||
|
Activate Yield
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@ -227,6 +346,18 @@ function ActivateModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activation?.landing?.headline && (
|
||||||
|
<div className="p-3 bg-[#050505] border border-white/[0.08] space-y-2">
|
||||||
|
<div className="text-[9px] font-mono text-white/40 uppercase tracking-wider">Landing Page (Generated)</div>
|
||||||
|
<div className="text-sm font-bold text-white">{activation.landing.headline}</div>
|
||||||
|
<div className="text-xs text-white/50">{activation.landing.seo_intro}</div>
|
||||||
|
<div className="text-[10px] font-mono text-white/40">
|
||||||
|
CTA: <span className="text-white/70">{activation.landing.cta_label}</span>
|
||||||
|
{activation.landing.model ? <span className="text-white/20"> • {activation.landing.model}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-[10px] font-mono text-white/40 uppercase tracking-wider">Option A (Recommended): Nameservers</div>
|
<div className="text-[10px] font-mono text-white/40 uppercase tracking-wider">Option A (Recommended): Nameservers</div>
|
||||||
<div className="bg-[#020202] border border-white/[0.08]">
|
<div className="bg-[#020202] border border-white/[0.08]">
|
||||||
@ -323,6 +454,10 @@ export default function YieldPage() {
|
|||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
const [deletingId, setDeletingId] = useState<number | null>(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])
|
useEffect(() => { checkAuth() }, [checkAuth])
|
||||||
|
|
||||||
@ -366,8 +501,8 @@ export default function YieldPage() {
|
|||||||
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: false },
|
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: false },
|
||||||
]
|
]
|
||||||
|
|
||||||
const tierName = subscription?.tier_name || subscription?.tier || 'Scout'
|
const tierLabelForDrawer = subscription?.tier_name || subscription?.tier || 'Scout'
|
||||||
const TierIcon = tierName === 'Tycoon' ? Crown : tierName === 'Trader' ? TrendingUp : Zap
|
const TierIcon = tierLabelForDrawer === 'Tycoon' ? Crown : tierLabelForDrawer === 'Trader' ? TrendingUp : Zap
|
||||||
|
|
||||||
const drawerNavSections = [
|
const drawerNavSections = [
|
||||||
{ title: 'Discover', items: [
|
{ title: 'Discover', items: [
|
||||||
@ -430,6 +565,12 @@ export default function YieldPage() {
|
|||||||
<p className="text-sm text-white/40 font-mono mt-2 max-w-lg">
|
<p className="text-sm text-white/40 font-mono mt-2 max-w-lg">
|
||||||
Monetize your parked domains. Route visitor intent to earn passive income.
|
Monetize your parked domains. Route visitor intent to earn passive income.
|
||||||
</p>
|
</p>
|
||||||
|
{!isTycoon && (
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-2 border border-white/10 bg-white/[0.02] text-xs text-white/50 font-mono">
|
||||||
|
<Sparkles className="w-4 h-4 text-accent" />
|
||||||
|
Yield activation is <span className="text-white/70 font-bold">Tycoon-only</span>. You can preview the landing page on Trader.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
{stats && (
|
{stats && (
|
||||||
@ -451,7 +592,7 @@ export default function YieldPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button onClick={() => setShowActivateModal(true)}
|
<button onClick={() => setShowActivateModal(true)}
|
||||||
className="flex items-center gap-2 px-4 py-2.5 bg-accent text-black text-xs font-bold uppercase tracking-wider hover:bg-white">
|
className="flex items-center gap-2 px-4 py-2.5 bg-accent text-black text-xs font-bold uppercase tracking-wider hover:bg-white">
|
||||||
<Plus className="w-4 h-4" />Activate Domain
|
<Plus className="w-4 h-4" />{isTycoon ? 'Activate Domain' : 'Preview Landing'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -462,7 +603,7 @@ export default function YieldPage() {
|
|||||||
<section className="lg:hidden px-4 py-3 border-b border-white/[0.08]">
|
<section className="lg:hidden px-4 py-3 border-b border-white/[0.08]">
|
||||||
<button onClick={() => setShowActivateModal(true)}
|
<button onClick={() => setShowActivateModal(true)}
|
||||||
className="w-full flex items-center justify-center gap-2 py-3 bg-accent text-black text-xs font-bold uppercase tracking-wider">
|
className="w-full flex items-center justify-center gap-2 py-3 bg-accent text-black text-xs font-bold uppercase tracking-wider">
|
||||||
<Plus className="w-4 h-4" />Activate Domain
|
<Plus className="w-4 h-4" />{isTycoon ? 'Activate Domain' : 'Preview Landing'}
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -481,10 +622,11 @@ export default function YieldPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-px bg-white/[0.04] border border-white/[0.08]">
|
<div className="space-y-px bg-white/[0.04] border border-white/[0.08]">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="hidden lg:grid grid-cols-[1fr_80px_120px_80px_80px_80px_60px] gap-4 px-3 py-2 text-[10px] font-mono text-white/40 uppercase tracking-wider border-b border-white/[0.08]">
|
<div className="hidden lg:grid grid-cols-[1fr_80px_160px_120px_80px_80px_80px_60px] gap-4 px-3 py-2 text-[10px] font-mono text-white/40 uppercase tracking-wider border-b border-white/[0.08]">
|
||||||
<div>Domain</div>
|
<div>Domain</div>
|
||||||
<div className="text-center">Status</div>
|
<div className="text-center">Status</div>
|
||||||
<div>Intent</div>
|
<div>Intent</div>
|
||||||
|
<div>Landing</div>
|
||||||
<div className="text-right">Clicks</div>
|
<div className="text-right">Clicks</div>
|
||||||
<div className="text-right">Conv.</div>
|
<div className="text-right">Conv.</div>
|
||||||
<div className="text-right">Revenue</div>
|
<div className="text-right">Revenue</div>
|
||||||
@ -501,6 +643,40 @@ export default function YieldPage() {
|
|||||||
</div>
|
</div>
|
||||||
<StatusBadge status={domain.status} />
|
<StatusBadge status={domain.status} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mb-2">
|
||||||
|
<details className="group">
|
||||||
|
<summary className="cursor-pointer text-[10px] font-mono text-white/50 hover:text-white/70 flex items-center justify-between">
|
||||||
|
<span>
|
||||||
|
Landing:{' '}
|
||||||
|
{domain.landing_headline ? (
|
||||||
|
<span className="text-accent font-bold">Ready</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-amber-400 font-bold">Missing</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-white/30 group-open:text-white/50">expand</span>
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 p-2 bg-[#050505] border border-white/[0.08]">
|
||||||
|
{domain.landing_headline ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-bold text-white">{domain.landing_headline}</div>
|
||||||
|
{domain.landing_intro && <div className="text-[10px] text-white/50">{domain.landing_intro}</div>}
|
||||||
|
<div className="text-[10px] font-mono text-white/40">
|
||||||
|
CTA: <span className="text-white/70">{domain.landing_cta_label || '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-mono text-white/30">
|
||||||
|
{domain.landing_generated_at ? new Date(domain.landing_generated_at).toLocaleString() : '—'}
|
||||||
|
{domain.landing_model ? <span className="text-white/20"> • {domain.landing_model}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-[10px] text-white/40">
|
||||||
|
No landing config stored yet. (Older activation) Remove + re-activate on Tycoon to regenerate.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex gap-4 text-[10px] font-mono text-white/40">
|
<div className="flex gap-4 text-[10px] font-mono text-white/40">
|
||||||
<span>{domain.total_clicks} clicks</span>
|
<span>{domain.total_clicks} clicks</span>
|
||||||
@ -521,12 +697,48 @@ export default function YieldPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop */}
|
{/* Desktop */}
|
||||||
<div className="hidden lg:grid grid-cols-[1fr_80px_120px_80px_80px_80px_60px] gap-4 items-center px-3 py-3">
|
<div className="hidden lg:grid grid-cols-[1fr_80px_160px_120px_80px_80px_80px_60px] gap-4 items-center px-3 py-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm font-bold text-white font-mono">{domain.domain}</span>
|
<span className="text-sm font-bold text-white font-mono">{domain.domain}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-center"><StatusBadge status={domain.status} /></div>
|
<div className="flex justify-center"><StatusBadge status={domain.status} /></div>
|
||||||
<span className="text-xs text-white/60 capitalize font-mono">{domain.detected_intent?.replace('_', ' ') || '—'}</span>
|
<span className="text-xs text-white/60 capitalize font-mono">{domain.detected_intent?.replace('_', ' ') || '—'}</span>
|
||||||
|
<div>
|
||||||
|
<details className="group">
|
||||||
|
<summary
|
||||||
|
className="cursor-pointer text-xs font-mono text-white/50 hover:text-white/70 flex items-center justify-between"
|
||||||
|
title="View landing page details"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{domain.landing_headline ? (
|
||||||
|
<span className="text-accent font-bold">Ready</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-amber-400 font-bold">Missing</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-white/30 group-open:text-white/50">details</span>
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 p-3 bg-[#050505] border border-white/[0.08] space-y-2">
|
||||||
|
{domain.landing_headline ? (
|
||||||
|
<>
|
||||||
|
<div className="text-xs font-bold text-white">{domain.landing_headline}</div>
|
||||||
|
{domain.landing_intro && <div className="text-[10px] text-white/50 max-w-[520px]">{domain.landing_intro}</div>}
|
||||||
|
<div className="text-[10px] font-mono text-white/40">
|
||||||
|
CTA: <span className="text-white/70">{domain.landing_cta_label || '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-mono text-white/30">
|
||||||
|
{domain.landing_generated_at ? new Date(domain.landing_generated_at).toLocaleString() : '—'}
|
||||||
|
{domain.landing_model ? <span className="text-white/20"> • {domain.landing_model}</span> : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-[10px] text-white/40">
|
||||||
|
No landing config stored yet. Remove + re-activate on Tycoon to regenerate.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
<div className="text-right text-xs font-mono text-white/60">{domain.total_clicks}</div>
|
<div className="text-right text-xs font-mono text-white/60">{domain.total_clicks}</div>
|
||||||
<div className="text-right text-xs font-mono text-white/60">{domain.total_conversions}</div>
|
<div className="text-right text-xs font-mono text-white/60">{domain.total_conversions}</div>
|
||||||
<div className="text-right text-sm font-bold font-mono text-accent">${domain.total_revenue}</div>
|
<div className="text-right text-sm font-bold font-mono text-accent">${domain.total_revenue}</div>
|
||||||
@ -598,9 +810,9 @@ export default function YieldPage() {
|
|||||||
<div className="p-4 bg-white/[0.02] border-t border-white/[0.08]">
|
<div className="p-4 bg-white/[0.02] border-t border-white/[0.08]">
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<div className="w-8 h-8 bg-accent/10 border border-accent/20 flex items-center justify-center"><TierIcon className="w-4 h-4 text-accent" /></div>
|
<div className="w-8 h-8 bg-accent/10 border border-accent/20 flex items-center justify-center"><TierIcon className="w-4 h-4 text-accent" /></div>
|
||||||
<div className="flex-1 min-w-0"><p className="text-sm font-bold text-white truncate">{user?.name || user?.email?.split('@')[0] || 'User'}</p><p className="text-[9px] font-mono text-white/40 uppercase">{tierName}</p></div>
|
<div className="flex-1 min-w-0"><p className="text-sm font-bold text-white truncate">{user?.name || user?.email?.split('@')[0] || 'User'}</p><p className="text-[9px] font-mono text-white/40 uppercase">{tierLabelForDrawer}</p></div>
|
||||||
</div>
|
</div>
|
||||||
{tierName === 'Scout' && <Link href="/pricing" onClick={() => 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"><Sparkles className="w-3 h-3" />Upgrade</Link>}
|
{tierLabelForDrawer === 'Scout' && <Link href="/pricing" onClick={() => 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"><Sparkles className="w-3 h-3" />Upgrade</Link>}
|
||||||
<button onClick={() => { logout(); setMenuOpen(false) }} className="flex items-center justify-center gap-2 w-full py-2 border border-white/10 text-white/40 text-[10px] font-mono uppercase"><LogOut className="w-3 h-3" />Sign out</button>
|
<button onClick={() => { logout(); setMenuOpen(false) }} className="flex items-center justify-center gap-2 w-full py-2 border border-white/10 text-white/40 text-[10px] font-mono uppercase"><LogOut className="w-3 h-3" />Sign out</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -298,6 +298,26 @@ class ApiClient {
|
|||||||
}>(`/llm/vision?${qs.toString()}`)
|
}>(`/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)
|
// CFO (Alpha Terminal - Management)
|
||||||
async getCfoSummary() {
|
async getCfoSummary() {
|
||||||
return this.request<{
|
return this.request<{
|
||||||
@ -1841,6 +1861,14 @@ class AdminApiClient extends ApiClient {
|
|||||||
cname_target: string
|
cname_target: string
|
||||||
verification_url: 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
|
message: string
|
||||||
}>('/yield/activate', {
|
}>('/yield/activate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -2032,6 +2060,12 @@ export interface YieldDomain {
|
|||||||
dns_verified: boolean
|
dns_verified: boolean
|
||||||
dns_verified_at: string | null
|
dns_verified_at: string | null
|
||||||
connected_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_clicks: number
|
||||||
total_conversions: number
|
total_conversions: number
|
||||||
total_revenue: number
|
total_revenue: number
|
||||||
|
|||||||
Reference in New Issue
Block a user