Forge: prominent AI mode selector, improved contrast throughout Hunt tabs
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:
2025-12-17 16:28:45 +01:00
parent c23d3c4b6c
commit 5a1fcb30dd
12 changed files with 1320 additions and 179 deletions

View File

@ -0,0 +1,75 @@
"""add llm artifacts and yield landing config
Revision ID: 016_add_llm_artifacts_and_yield_landing_config
Revises: 015_add_subscription_referral_bonus_domains
Create Date: 2025-12-17
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "016_add_llm_artifacts_and_yield_landing_config"
down_revision = "015_add_subscription_referral_bonus_domains"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"llm_artifacts",
sa.Column("id", sa.Integer(), primary_key=True, nullable=False),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
sa.Column("kind", sa.String(length=50), nullable=False),
sa.Column("domain", sa.String(length=255), nullable=False),
sa.Column("prompt_version", sa.String(length=50), nullable=False),
sa.Column("model", sa.String(length=100), nullable=False),
sa.Column("payload_json", sa.Text(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.Column("expires_at", sa.DateTime(), nullable=True),
)
op.create_index("ix_llm_artifacts_id", "llm_artifacts", ["id"])
op.create_index("ix_llm_artifacts_user_id", "llm_artifacts", ["user_id"])
op.create_index("ix_llm_artifacts_kind", "llm_artifacts", ["kind"])
op.create_index("ix_llm_artifacts_domain", "llm_artifacts", ["domain"])
op.create_index("ix_llm_artifacts_prompt_version", "llm_artifacts", ["prompt_version"])
op.create_index("ix_llm_artifacts_created_at", "llm_artifacts", ["created_at"])
op.create_index("ix_llm_artifacts_expires_at", "llm_artifacts", ["expires_at"])
op.create_index(
"ix_llm_artifacts_kind_domain_prompt",
"llm_artifacts",
["kind", "domain", "prompt_version"],
)
# Yield landing config (generated by LLM on activation)
op.add_column("yield_domains", sa.Column("landing_config_json", sa.Text(), nullable=True))
op.add_column("yield_domains", sa.Column("landing_template", sa.String(length=50), nullable=True))
op.add_column("yield_domains", sa.Column("landing_headline", sa.String(length=300), nullable=True))
op.add_column("yield_domains", sa.Column("landing_intro", sa.Text(), nullable=True))
op.add_column("yield_domains", sa.Column("landing_cta_label", sa.String(length=120), nullable=True))
op.add_column("yield_domains", sa.Column("landing_model", sa.String(length=100), nullable=True))
op.add_column("yield_domains", sa.Column("landing_generated_at", sa.DateTime(), nullable=True))
def downgrade() -> None:
op.drop_column("yield_domains", "landing_generated_at")
op.drop_column("yield_domains", "landing_model")
op.drop_column("yield_domains", "landing_cta_label")
op.drop_column("yield_domains", "landing_intro")
op.drop_column("yield_domains", "landing_headline")
op.drop_column("yield_domains", "landing_template")
op.drop_column("yield_domains", "landing_config_json")
op.drop_index("ix_llm_artifacts_kind_domain_prompt", table_name="llm_artifacts")
op.drop_index("ix_llm_artifacts_expires_at", table_name="llm_artifacts")
op.drop_index("ix_llm_artifacts_created_at", table_name="llm_artifacts")
op.drop_index("ix_llm_artifacts_prompt_version", table_name="llm_artifacts")
op.drop_index("ix_llm_artifacts_domain", table_name="llm_artifacts")
op.drop_index("ix_llm_artifacts_kind", table_name="llm_artifacts")
op.drop_index("ix_llm_artifacts_user_id", table_name="llm_artifacts")
op.drop_index("ix_llm_artifacts_id", table_name="llm_artifacts")
op.drop_table("llm_artifacts")

View File

@ -29,6 +29,7 @@ from app.api.cfo import router as cfo_router
from app.api.drops import router as drops_router
from app.api.llm import router as llm_router
from app.api.llm_naming import router as llm_naming_router
from app.api.llm_vision import router as llm_vision_router
api_router = APIRouter()
@ -49,6 +50,7 @@ api_router.include_router(cfo_router, prefix="/cfo", tags=["CFO"])
api_router.include_router(drops_router, tags=["Drops - Zone Files"])
api_router.include_router(llm_router, tags=["LLM"])
api_router.include_router(llm_naming_router, tags=["LLM Naming"])
api_router.include_router(llm_vision_router, tags=["LLM Vision"])
# Marketplace (For Sale) - from analysis_3.md
api_router.include_router(listings_router, prefix="/listings", tags=["Marketplace - For Sale"])

View File

@ -0,0 +1,133 @@
"""
Vision API (Terminal-only).
- Trader + Tycoon: can generate Vision JSON (cached in DB)
- Scout: receives a 403 with an upgrade teaser message
"""
from __future__ import annotations
import json
from datetime import datetime, timedelta
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy import and_, select
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
router = APIRouter(prefix="/llm", tags=["LLM Vision"])
class VisionResponse(BaseModel):
domain: str
cached: bool
model: str
prompt_version: str
generated_at: str
result: VisionResult
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()
if sub:
return sub
sub = Subscription(user_id=user_id, tier=SubscriptionTier.SCOUT, max_domains=5, check_frequency="daily")
db.add(sub)
await db.commit()
await db.refresh(sub)
return sub
def _require_trader_or_higher(sub: Subscription) -> None:
if sub.tier not in (SubscriptionTier.TRADER, SubscriptionTier.TYCOON):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Vision is available on Trader and Tycoon plans. Upgrade to unlock.",
)
@router.get("/vision", response_model=VisionResponse)
async def get_vision(
domain: str = Query(..., min_length=3, max_length=255),
refresh: bool = Query(False, description="Bypass cache and regenerate"),
current_user: CurrentUser = Depends(),
db: Database = Depends(),
):
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 == "vision_v1",
LLMArtifact.domain == normalized,
LLMArtifact.prompt_version == VISION_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 = VisionResult.model_validate(payload)
except Exception:
# Corrupt cache: regenerate.
cached = None
else:
return VisionResponse(
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_vision(normalized)
except LLMGatewayError as e:
raise HTTPException(status_code=502, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Vision generation failed: {e}")
artifact = LLMArtifact(
user_id=current_user.id,
kind="vision_v1",
domain=normalized,
prompt_version=VISION_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 VisionResponse(
domain=normalized,
cached=False,
model=model_used,
prompt_version=VISION_PROMPT_VERSION,
generated_at=now.isoformat(),
result=result,
)

View File

@ -17,6 +17,7 @@ from app.models.telemetry import TelemetryEvent
from app.models.ops_alert import OpsAlertEvent
from app.models.domain_analysis_cache import DomainAnalysisCache
from app.models.zone_file import ZoneSnapshot, DroppedDomain
from app.models.llm_artifact import LLMArtifact
__all__ = [
"User",
@ -55,4 +56,6 @@ __all__ = [
# New: Zone file drops
"ZoneSnapshot",
"DroppedDomain",
# New: LLM artifacts / cache
"LLMArtifact",
]

View File

@ -0,0 +1,52 @@
"""
LLM artifacts / cache.
Stores strict-JSON outputs from our internal LLM gateway for:
- Vision (business concept + buyer matchmaker)
- Yield landing page configs
Important:
- Tier gating is enforced at the API layer; never expose artifacts to Scout users.
"""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, Index, Integer, String, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class LLMArtifact(Base):
__tablename__ = "llm_artifacts"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
# Optional: who generated it (for auditing). Not used for access control.
user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
# What this artifact represents.
# Examples: "vision_v1", "yield_landing_v1"
kind: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
# Domain this artifact belongs to (lowercase).
domain: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
# Prompt/versioning for safe cache invalidation
prompt_version: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
model: Mapped[str] = mapped_column(String(100), nullable=False)
# Strict JSON payload (string)
payload_json: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True, index=True)
__table_args__ = (
Index("ix_llm_artifacts_kind_domain_prompt", "kind", "domain", "prompt_version"),
)

View File

@ -99,6 +99,15 @@ class YieldDomain(Base):
active_route: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) # Partner slug
landing_page_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
# LLM-generated landing page config (used by routing when direct=false)
landing_config_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
landing_template: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
landing_headline: Mapped[Optional[str]] = mapped_column(String(300), nullable=True)
landing_intro: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
landing_cta_label: Mapped[Optional[str]] = mapped_column(String(120), nullable=True)
landing_model: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
landing_generated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
# Status
status: Mapped[str] = mapped_column(String(30), default="pending", index=True)
# pending, verifying, active, paused, inactive, error

View File

@ -0,0 +1,166 @@
"""
Vision + Yield landing generation via internal LLM gateway.
Outputs MUST be strict JSON to support caching + UI rendering.
"""
from __future__ import annotations
import json
from typing import Any
from pydantic import BaseModel, Field, ValidationError, conint
from app.config import get_settings
from app.services.llm_gateway import chat_completions
settings = get_settings()
VISION_PROMPT_VERSION = "v1"
YIELD_LANDING_PROMPT_VERSION = "v1"
class VisionResult(BaseModel):
business_concept: str = Field(..., min_length=10, max_length=240)
industry_vertical: str = Field(..., min_length=2, max_length=60)
buyer_persona: str = Field(..., min_length=5, max_length=120)
cold_email_subject: str = Field(..., min_length=5, max_length=120)
cold_email_body: str = Field(..., min_length=20, max_length=800)
monetization_idea: str = Field(..., min_length=10, max_length=240)
radio_test_score: conint(ge=1, le=10) # type: ignore[valid-type]
reasoning: str = Field(..., min_length=20, max_length=800)
class YieldLandingConfig(BaseModel):
template: str = Field(..., min_length=2, max_length=50) # e.g. "nature", "commerce", "tech"
headline: str = Field(..., min_length=10, max_length=180)
seo_intro: str = Field(..., min_length=80, max_length=800)
cta_label: str = Field(..., min_length=4, max_length=60)
niche: str = Field(..., min_length=2, max_length=60)
color_scheme: str = Field(..., min_length=2, max_length=30)
def _extract_first_json_object(text: str) -> str:
"""
Extract the first {...} JSON object from text.
We do NOT generate fallback content; if parsing fails, caller must raise.
"""
s = (text or "").strip()
if not s:
raise ValueError("Empty LLM response")
if s.startswith("{") and s.endswith("}"):
return s
start = s.find("{")
end = s.rfind("}")
if start == -1 or end == -1 or end <= start:
raise ValueError("LLM response is not JSON")
return s[start : end + 1]
async def generate_vision(domain: str) -> tuple[VisionResult, str]:
"""
Returns (VisionResult, model_used).
"""
model = settings.llm_default_model
system = (
"You are the Pounce AI, a domain intelligence engine.\n"
"You must respond with STRICT JSON only. No markdown. No commentary.\n"
"Language: English.\n"
"If a field is unknown, make a best-effort realistic assumption.\n"
)
user = (
f"Analyze domain '{domain}'.\n"
"Act as a VC + domain broker.\n"
"Create a realistic business concept and a buyer/outreach angle.\n"
"Output STRICT JSON with exactly these keys:\n"
"{\n"
' "business_concept": "...",\n'
' "industry_vertical": "...",\n'
' "buyer_persona": "...",\n'
' "cold_email_subject": "...",\n'
' "cold_email_body": "...",\n'
' "monetization_idea": "...",\n'
' "radio_test_score": 1,\n'
' "reasoning": "..."\n'
"}\n"
)
payload: dict[str, Any] = {
"model": model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"temperature": 0.6,
"stream": False,
}
res = await chat_completions(payload)
content = (
res.get("choices", [{}])[0]
.get("message", {})
.get("content", "")
)
json_str = _extract_first_json_object(str(content))
try:
data = json.loads(json_str)
except Exception as e:
raise ValueError(f"Failed to parse LLM JSON: {e}") from e
try:
return VisionResult.model_validate(data), model
except ValidationError as e:
raise ValueError(f"LLM JSON schema mismatch: {e}") from e
async def generate_yield_landing(domain: str) -> tuple[YieldLandingConfig, str]:
"""
Returns (YieldLandingConfig, model_used).
"""
model = settings.llm_default_model
system = (
"You are the Pounce AI, a domain monetization engine.\n"
"You must respond with STRICT JSON only. No markdown. No commentary.\n"
"Language: English.\n"
"Write helpful, non-spammy copy. Avoid medical/legal claims.\n"
)
user = (
f"Analyze domain '{domain}'.\n"
"Goal: create a minimal SEO-friendly landing page plan that can route visitors to an affiliate offer.\n"
"Output STRICT JSON with exactly these keys:\n"
"{\n"
' "template": "tech|commerce|finance|nature|local|generic",\n'
' "headline": "...",\n'
' "seo_intro": "...",\n'
' "cta_label": "...",\n'
' "niche": "...",\n'
' "color_scheme": "..." \n'
"}\n"
"Keep seo_intro 120-220 words.\n"
)
payload: dict[str, Any] = {
"model": model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"temperature": 0.5,
"stream": False,
}
res = await chat_completions(payload)
content = (
res.get("choices", [{}])[0]
.get("message", {})
.get("content", "")
)
json_str = _extract_first_json_object(str(content))
try:
data = json.loads(json_str)
except Exception as e:
raise ValueError(f"Failed to parse LLM JSON: {e}") from e
try:
return YieldLandingConfig.model_validate(data), model
except ValidationError as e:
raise ValueError(f"LLM JSON schema mismatch: {e}") from e

View File

@ -0,0 +1,294 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import clsx from 'clsx'
import Link from 'next/link'
import { Copy, Check, Sparkles, Lock, RefreshCw, Mail, Target, Coins, Info } from 'lucide-react'
import { api } from '@/lib/api'
import { useStore } from '@/lib/store'
type VisionPayload = {
domain: string
cached: boolean
model: string
prompt_version: string
generated_at: string
result: {
business_concept: string
industry_vertical: string
buyer_persona: string
cold_email_subject: string
cold_email_body: string
monetization_idea: string
radio_test_score: number
reasoning: string
}
}
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
return false
}
}
export function VisionSection({ domain }: { domain: string }) {
const subscription = useStore((s) => s.subscription)
const tier = (subscription?.tier || 'scout').toLowerCase()
const canUse = tier === 'trader' || tier === 'tycoon'
const [data, setData] = useState<VisionPayload | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [copiedKey, setCopiedKey] = useState<string | null>(null)
const headline = useMemo(() => domain?.trim().toLowerCase() || '', [domain])
const run = useCallback(async (opts?: { refresh?: boolean }) => {
setLoading(true)
setError(null)
try {
const res = await api.getVision(headline, Boolean(opts?.refresh))
setData(res)
} catch (e) {
setData(null)
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [headline])
const copy = useCallback(async (key: string, value: string) => {
const ok = await copyToClipboard(value)
setCopiedKey(ok ? key : null)
if (ok) setTimeout(() => setCopiedKey(null), 1200)
}, [])
if (!canUse) {
return (
<div className="p-4 border border-white/[0.08] bg-white/[0.02]">
<div className="flex items-start gap-3">
<div className="w-10 h-10 flex items-center justify-center border border-white/10 bg-white/[0.02] shrink-0">
<Lock className="w-5 h-5 text-white/30" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-bold text-white flex items-center gap-2">
<span className="text-white/60">VISION</span>
<span className="px-2 py-0.5 text-[10px] font-mono uppercase bg-white/10 text-white/50 border border-white/10">Trader+</span>
</div>
<p className="text-sm text-white/40 mt-1">
Turn a domain into a buyer-ready business story: pitch, ideal buyer, and outreach angle.
</p>
<div className="mt-3 flex items-center gap-2">
<Link
href="/pricing"
className="inline-flex items-center gap-2 px-3 py-2 bg-accent text-black text-xs font-bold uppercase tracking-wider hover:bg-white"
>
Upgrade to unlock
<Sparkles className="w-4 h-4" />
</Link>
<span className="text-[10px] font-mono text-white/30">
Scout users see a preview only.
</span>
</div>
</div>
</div>
</div>
)
}
return (
<div className="space-y-3">
<div className="p-4 border border-white/[0.08] bg-white/[0.02]">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-[10px] font-mono text-white/40 uppercase tracking-wider flex items-center gap-2">
<Sparkles className="w-3.5 h-3.5 text-accent" />
Vision Engine
<span className="text-white/10">|</span>
<span className="text-white/30" title="Strict JSON output, cached for 30 days.">cached</span>
</div>
<div className="text-lg font-bold text-white font-mono truncate mt-1">{headline}</div>
<div className="text-xs text-white/40 mt-1 flex items-center gap-2">
<Info className="w-3.5 h-3.5 text-white/20" />
Generates a VC-style pitch + a concrete buyer persona + a ready-to-send outreach message.
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => run({ refresh: false })}
disabled={loading}
className={clsx(
"h-9 px-3 border text-xs font-bold uppercase tracking-wider transition-colors",
"border-white/10 text-white/60 hover:text-white hover:bg-white/5",
loading && "opacity-60"
)}
title="Generate (uses cache if available)"
>
{loading ? <RefreshCw className="w-4 h-4 animate-spin" /> : 'Generate'}
</button>
<button
onClick={() => run({ refresh: true })}
disabled={loading}
className={clsx(
"h-9 w-9 flex items-center justify-center border transition-colors",
"border-white/10 text-white/40 hover:text-white hover:bg-white/5",
loading && "opacity-60"
)}
title="Force refresh (regenerate)"
>
<RefreshCw className={clsx("w-4 h-4", loading && "animate-spin")} />
</button>
</div>
</div>
</div>
{error && (
<div className="p-4 border border-rose-500/30 bg-rose-500/10 text-rose-300 text-sm">
{error}
</div>
)}
{!data && !loading && !error && (
<div className="p-6 border border-dashed border-white/[0.10] text-center text-white/40">
Click <span className="text-white/70 font-bold">Generate</span> to create a Vision for this domain.
</div>
)}
{data && (
<div className="grid grid-cols-1 gap-3">
{/* Business concept */}
<div className="p-4 border border-white/[0.08] bg-[#050505]">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Target className="w-4 h-4 text-accent" />
<div className="text-sm font-bold text-white">Business Concept</div>
</div>
<button
onClick={() => copy('business_concept', data.result.business_concept)}
className="w-9 h-9 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5"
title="Copy"
>
{copiedKey === 'business_concept' ? <Check className="w-4 h-4 text-accent" /> : <Copy className="w-4 h-4" />}
</button>
</div>
<div className="text-sm text-white/70 mt-2">{data.result.business_concept}</div>
<div className="text-[10px] font-mono text-white/40 mt-2">
<span className="text-white/20">Vertical:</span> {data.result.industry_vertical}
</div>
</div>
{/* Buyer persona */}
<div className="p-4 border border-white/[0.08] bg-white/[0.02]">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-emerald-400" />
<div className="text-sm font-bold text-white">Ideal Buyer</div>
</div>
<button
onClick={() => copy('buyer_persona', data.result.buyer_persona)}
className="w-9 h-9 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5"
title="Copy"
>
{copiedKey === 'buyer_persona' ? <Check className="w-4 h-4 text-accent" /> : <Copy className="w-4 h-4" />}
</button>
</div>
<div className="text-sm text-white/70 mt-2">{data.result.buyer_persona}</div>
</div>
{/* Outreach */}
<div className="p-4 border border-white/[0.08] bg-white/[0.02]">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-sky-400" />
<div className="text-sm font-bold text-white">Outreach Draft</div>
</div>
<button
onClick={() => copy('cold_email', `Subject: ${data.result.cold_email_subject}\n\n${data.result.cold_email_body}`)}
className="w-9 h-9 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5"
title="Copy full email"
>
{copiedKey === 'cold_email' ? <Check className="w-4 h-4 text-accent" /> : <Copy className="w-4 h-4" />}
</button>
</div>
<div className="mt-2">
<div className="text-[10px] font-mono text-white/40 uppercase tracking-wider">Subject</div>
<div className="text-sm text-white/70 mt-1">{data.result.cold_email_subject}</div>
</div>
<div className="mt-3">
<div className="text-[10px] font-mono text-white/40 uppercase tracking-wider">Body</div>
<div className="text-sm text-white/60 mt-1 whitespace-pre-wrap">{data.result.cold_email_body}</div>
</div>
</div>
{/* Monetization + radio test */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="p-4 border border-white/[0.08] bg-white/[0.02]">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Coins className="w-4 h-4 text-accent" />
<div className="text-sm font-bold text-white">Monetization</div>
</div>
<button
onClick={() => copy('monetization', data.result.monetization_idea)}
className="w-9 h-9 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5"
title="Copy"
>
{copiedKey === 'monetization' ? <Check className="w-4 h-4 text-accent" /> : <Copy className="w-4 h-4" />}
</button>
</div>
<div className="text-sm text-white/70 mt-2">{data.result.monetization_idea}</div>
</div>
<div className="p-4 border border-white/[0.08] bg-white/[0.02]">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-violet-400" />
<div className="text-sm font-bold text-white">Radio Test</div>
</div>
<button
onClick={() => copy('radio', String(data.result.radio_test_score))}
className="w-9 h-9 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5"
title="Copy"
>
{copiedKey === 'radio' ? <Check className="w-4 h-4 text-accent" /> : <Copy className="w-4 h-4" />}
</button>
</div>
<div className="mt-2 flex items-end justify-between">
<div className="text-4xl font-bold font-mono text-white">{data.result.radio_test_score}</div>
<div className="text-[10px] font-mono text-white/40">110 (higher is better)</div>
</div>
<div className="text-xs text-white/40 mt-2">
Measures how easy the name is to remember and spell when heard.
</div>
</div>
</div>
{/* Reasoning */}
<div className="p-4 border border-white/[0.08] bg-white/[0.02]">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Info className="w-4 h-4 text-white/40" />
<div className="text-sm font-bold text-white">Why this is valuable</div>
</div>
<button
onClick={() => copy('reasoning', data.result.reasoning)}
className="w-9 h-9 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5"
title="Copy"
>
{copiedKey === 'reasoning' ? <Check className="w-4 h-4 text-accent" /> : <Copy className="w-4 h-4" />}
</button>
</div>
<div className="text-sm text-white/60 mt-2 whitespace-pre-wrap">{data.result.reasoning}</div>
<div className="text-[10px] font-mono text-white/30 mt-3">
{data.cached ? 'Cached' : 'Fresh'} {new Date(data.generated_at).toLocaleString()} {data.model}
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -14,7 +14,8 @@ import {
Sparkles,
Lock,
RefreshCw,
Plus,
Brain,
Dices,
} from 'lucide-react'
import Link from 'next/link'
import { api } from '@/lib/api'
@ -26,9 +27,9 @@ import { useStore } from '@/lib/store'
// ============================================================================
const PATTERNS = [
{ key: 'cvcvc', label: 'CVCVC', example: 'Zalor' },
{ key: 'cvccv', label: 'CVCCV', example: 'Bento' },
{ key: 'human', label: 'Human', example: 'Alexa' },
{ key: 'cvcvc', label: 'CVCVC', example: 'Zalor', desc: 'Classic 5-letter' },
{ key: 'cvccv', label: 'CVCCV', example: 'Bento', desc: 'Punchy sound' },
{ key: 'human', label: 'Human', example: 'Alexa', desc: 'AI agent names' },
]
const TLDS = ['com', 'io', 'ai', 'co', 'net', 'app']
@ -44,6 +45,9 @@ export function BrandableForgeTab({ showToast }: { showToast: (msg: string, type
const tier = (subscription?.tier || '').toLowerCase()
const hasAI = tier === 'trader' || tier === 'tycoon'
// Mode selection
const [mode, setMode] = useState<'pattern' | 'ai'>('pattern')
// Config
const [pattern, setPattern] = useState('cvcvc')
const [tlds, setTlds] = useState(['com', 'io'])
@ -79,10 +83,10 @@ export function BrandableForgeTab({ showToast }: { showToast: (msg: string, type
const generateFromConcept = useCallback(async () => {
if (!concept.trim() || !hasAI) return
setAiLoading(true)
setResults([])
try {
const res = await api.generateBrandableNames(concept.trim(), undefined, 12)
const res = await api.generateBrandableNames(concept.trim(), undefined, 15)
if (res.names?.length) {
// Check availability
const checkRes = await api.huntKeywords({ keywords: res.names, tlds })
const available = checkRes.items.filter(i => i.status === 'available')
setResults(available.map(i => ({ domain: i.domain })))
@ -132,26 +136,100 @@ export function BrandableForgeTab({ showToast }: { showToast: (msg: string, type
<Wand2 className="w-5 h-5 text-purple-400" />
Brandable Forge
</h2>
<p className="text-sm text-white/40 mt-1">
<p className="text-sm text-white/60 mt-1">
Generate unique, memorable domain names
</p>
</div>
{/* ═══════════════════════════════════════════════════════════════════════ */}
{/* GENERATOR */}
{/* MODE SELECTOR - BIG CLEAR BUTTONS */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
<div className="bg-white/[0.02] border border-white/10 p-4 space-y-5">
{/* Row 1: Pattern Selection */}
<div className="grid grid-cols-2 gap-3">
{/* Pattern Mode */}
<button
onClick={() => setMode('pattern')}
className={clsx(
"p-4 sm:p-5 border-2 transition-all text-left",
mode === 'pattern'
? "border-accent bg-accent/10"
: "border-white/10 bg-white/[0.02] hover:border-white/20"
)}
>
<div className="flex items-center gap-3 mb-2">
<div className={clsx(
"w-10 h-10 flex items-center justify-center",
mode === 'pattern' ? "bg-accent/20" : "bg-white/5"
)}>
<Dices className={clsx("w-5 h-5", mode === 'pattern' ? "text-accent" : "text-white/50")} />
</div>
<div>
<p className="text-xs text-white/40 font-mono uppercase tracking-wider mb-2">Pattern</p>
<div className="flex gap-2">
<p className={clsx("font-bold", mode === 'pattern' ? "text-accent" : "text-white")}>
Pattern
</p>
<p className={clsx("text-xs", mode === 'pattern' ? "text-accent/70" : "text-white/50")}>
CVCVC, CVCCV, Human
</p>
</div>
</div>
<p className="text-xs text-white/40 hidden sm:block">
Generate random names using proven naming patterns
</p>
</button>
{/* AI Mode */}
<button
onClick={() => hasAI && setMode('ai')}
disabled={!hasAI}
className={clsx(
"p-4 sm:p-5 border-2 transition-all text-left relative",
!hasAI && "opacity-60",
mode === 'ai'
? "border-purple-500 bg-purple-500/10"
: "border-white/10 bg-white/[0.02] hover:border-white/20"
)}
>
{!hasAI && (
<div className="absolute top-2 right-2">
<Lock className="w-4 h-4 text-white/30" />
</div>
)}
<div className="flex items-center gap-3 mb-2">
<div className={clsx(
"w-10 h-10 flex items-center justify-center",
mode === 'ai' ? "bg-purple-500/20" : "bg-white/5"
)}>
<Brain className={clsx("w-5 h-5", mode === 'ai' ? "text-purple-400" : "text-white/50")} />
</div>
<div>
<p className={clsx("font-bold", mode === 'ai' ? "text-purple-400" : "text-white")}>
AI Concept
</p>
<p className={clsx("text-xs", mode === 'ai' ? "text-purple-400/70" : "text-white/50")}>
{hasAI ? 'Describe your brand' : 'Trader+ only'}
</p>
</div>
</div>
<p className="text-xs text-white/40 hidden sm:block">
{hasAI ? 'AI generates names based on your concept' : 'Upgrade to unlock AI naming'}
</p>
</button>
</div>
{/* ═══════════════════════════════════════════════════════════════════════ */}
{/* PATTERN MODE */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{mode === 'pattern' && (
<div className="border border-white/10 bg-white/[0.02] p-4 sm:p-5 space-y-5">
{/* Pattern Selection */}
<div>
<p className="text-xs text-white/60 font-mono uppercase tracking-wider mb-3">Choose Pattern</p>
<div className="grid grid-cols-3 gap-2">
{PATTERNS.map(p => (
<button
key={p.key}
onClick={() => setPattern(p.key)}
className={clsx(
"flex-1 py-3 px-4 border text-center transition-all",
"p-3 border text-center transition-all",
pattern === p.key
? "border-accent bg-accent/10"
: "border-white/10 hover:border-white/20"
@ -159,20 +237,20 @@ export function BrandableForgeTab({ showToast }: { showToast: (msg: string, type
>
<p className={clsx(
"text-sm font-bold font-mono",
pattern === p.key ? "text-accent" : "text-white/60"
pattern === p.key ? "text-accent" : "text-white"
)}>
{p.label}
</p>
<p className="text-[10px] text-white/30 mt-0.5">{p.example}</p>
<p className="text-[10px] text-white/50 mt-0.5">{p.example}</p>
</button>
))}
</div>
</div>
{/* Row 2: TLDs */}
{/* TLDs */}
<div>
<p className="text-xs text-white/40 font-mono uppercase tracking-wider mb-2">TLDs</p>
<div className="flex flex-wrap gap-1.5">
<p className="text-xs text-white/60 font-mono uppercase tracking-wider mb-3">Select TLDs</p>
<div className="flex flex-wrap gap-2">
{TLDS.map(tld => (
<button
key={tld}
@ -180,10 +258,10 @@ export function BrandableForgeTab({ showToast }: { showToast: (msg: string, type
prev.includes(tld) ? prev.filter(t => t !== tld) : [...prev, tld]
)}
className={clsx(
"px-3 py-1.5 text-xs font-mono border transition-all",
"px-3 py-2 text-sm font-mono border transition-all",
tlds.includes(tld)
? "border-accent bg-accent/10 text-accent"
: "border-white/10 text-white/30 hover:text-white"
: "border-white/10 text-white/50 hover:text-white"
)}
>
.{tld}
@ -192,108 +270,121 @@ export function BrandableForgeTab({ showToast }: { showToast: (msg: string, type
</div>
</div>
{/* Row 3: Generate Button */}
{/* Generate */}
<button
onClick={generatePattern}
disabled={isGenerating || tlds.length === 0}
className={clsx(
"w-full py-3.5 text-sm font-bold uppercase tracking-wider flex items-center justify-center gap-2 transition-all",
"w-full py-4 text-sm font-bold uppercase tracking-wider flex items-center justify-center gap-2 transition-all",
isGenerating || tlds.length === 0
? "bg-white/10 text-white/30"
? "bg-white/10 text-white/40"
: "bg-accent text-black hover:bg-white"
)}
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
Generate {pattern.toUpperCase()} Brandables
Generate {pattern.toUpperCase()} Names
</button>
{/* Divider */}
<div className="relative py-2">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10" />
</div>
<div className="relative flex justify-center">
<span className="px-3 bg-[#0a0a0a] text-[10px] text-white/30 font-mono uppercase">or use AI</span>
</div>
</div>
{/* Row 4: AI Concept */}
<div>
<div className="flex items-center gap-2 mb-2">
<p className="text-xs text-white/40 font-mono uppercase tracking-wider">AI Concept Generator</p>
{!hasAI && (
<span className="flex items-center gap-1 text-[10px] text-white/20">
<Lock className="w-3 h-3" />
Trader+
</span>
)}
</div>
<div className="flex gap-2">
<input
{/* ═══════════════════════════════════════════════════════════════════════ */}
{/* AI MODE */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{mode === 'ai' && hasAI && (
<div className="border border-purple-500/30 bg-purple-500/5 p-4 sm:p-5 space-y-5">
{/* Concept Input */}
<div>
<p className="text-xs text-purple-300 font-mono uppercase tracking-wider mb-3">
Describe Your Brand Concept
</p>
<textarea
value={concept}
onChange={(e) => setConcept(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && hasAI && generateFromConcept()}
disabled={!hasAI}
placeholder={hasAI ? "Describe your brand concept..." : "Upgrade to Trader to unlock AI"}
className={clsx(
"flex-1 px-3 py-2.5 border text-sm font-mono outline-none transition-all",
hasAI
? "bg-purple-500/5 border-purple-500/20 text-white placeholder:text-white/25 focus:border-purple-500/50"
: "bg-white/[0.02] border-white/10 text-white/30 placeholder:text-white/20"
)}
placeholder="e.g., AI startup for legal documents, crypto wallet for teens, fitness tracking app..."
rows={3}
className="w-full px-4 py-3 bg-white/5 border border-purple-500/30 text-white placeholder:text-white/30 outline-none focus:border-purple-400 resize-none font-mono text-sm"
/>
{hasAI ? (
</div>
{/* TLDs */}
<div>
<p className="text-xs text-purple-300 font-mono uppercase tracking-wider mb-3">Select TLDs</p>
<div className="flex flex-wrap gap-2">
{TLDS.map(tld => (
<button
key={tld}
onClick={() => setTlds(prev =>
prev.includes(tld) ? prev.filter(t => t !== tld) : [...prev, tld]
)}
className={clsx(
"px-3 py-2 text-sm font-mono border transition-all",
tlds.includes(tld)
? "border-purple-400 bg-purple-500/20 text-purple-300"
: "border-white/10 text-white/50 hover:text-white"
)}
>
.{tld}
</button>
))}
</div>
</div>
{/* Generate */}
<button
onClick={generateFromConcept}
disabled={!concept.trim() || aiLoading}
disabled={aiLoading || !concept.trim() || tlds.length === 0}
className={clsx(
"px-4 text-sm font-bold uppercase flex items-center gap-2 transition-all shrink-0",
!concept.trim() || aiLoading
? "bg-white/10 text-white/30"
"w-full py-4 text-sm font-bold uppercase tracking-wider flex items-center justify-center gap-2 transition-all",
aiLoading || !concept.trim() || tlds.length === 0
? "bg-white/10 text-white/40"
: "bg-purple-500 text-white hover:bg-purple-400"
)}
>
{aiLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Zap className="w-4 h-4" />}
<span className="hidden sm:inline">Generate</span>
{aiLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Brain className="w-4 h-4" />}
Generate AI Names
</button>
) : (
</div>
)}
{/* AI Upgrade CTA */}
{mode === 'ai' && !hasAI && (
<div className="border border-white/10 bg-white/[0.02] p-8 text-center">
<Lock className="w-10 h-10 text-white/20 mx-auto mb-3" />
<h3 className="text-lg font-bold text-white mb-2">AI Naming Requires Upgrade</h3>
<p className="text-sm text-white/50 mb-4 max-w-sm mx-auto">
Describe your brand and let AI generate unique, brandable domain names for you.
</p>
<Link
href="/pricing"
className="px-4 flex items-center gap-2 bg-white/5 border border-white/10 text-white/40 text-xs font-bold uppercase hover:text-white hover:border-white/20 transition-all"
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-black text-sm font-bold uppercase hover:bg-white transition-colors"
>
Upgrade
<Sparkles className="w-4 h-4" />
Upgrade to Trader
</Link>
</div>
)}
</div>
{hasAI && (
<p className="text-[10px] text-white/20 mt-1.5">
Examples: "AI startup for legal documents", "crypto wallet for teens", "fitness app"
</p>
)}
</div>
</div>
{/* ═══════════════════════════════════════════════════════════════════════ */}
{/* RESULTS */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{results.length > 0 && (
<div className="space-y-3">
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-white/50">
<p className="text-sm text-white/70">
<span className="text-accent font-bold">{results.length}</span> available domains
</p>
<div className="flex gap-3">
<button
onClick={copyAll}
className="text-xs font-mono text-white/40 hover:text-accent flex items-center gap-1"
className="text-xs font-mono text-white/50 hover:text-accent flex items-center gap-1"
>
<Copy className="w-3 h-3" />
Copy all
</button>
<button
onClick={generatePattern}
onClick={mode === 'pattern' ? generatePattern : generateFromConcept}
disabled={isGenerating}
className="text-xs font-mono text-white/40 hover:text-white flex items-center gap-1"
className="text-xs font-mono text-white/50 hover:text-white flex items-center gap-1"
>
<RefreshCw className={clsx("w-3 h-3", isGenerating && "animate-spin")} />
Refresh
@ -305,34 +396,52 @@ export function BrandableForgeTab({ showToast }: { showToast: (msg: string, type
{results.map((r, idx) => (
<div
key={r.domain}
className="flex items-center justify-between p-3 bg-accent/5 border border-accent/20 hover:bg-accent/10 transition-all group"
className={clsx(
"flex items-center justify-between p-3 border transition-all group",
mode === 'ai'
? "bg-purple-500/5 border-purple-500/20 hover:bg-purple-500/10"
: "bg-accent/5 border-accent/20 hover:bg-accent/10"
)}
>
<div className="flex items-center gap-2.5 min-w-0">
<span className="w-5 h-5 bg-accent/20 text-accent text-[9px] font-bold font-mono flex items-center justify-center shrink-0">
<span className={clsx(
"w-6 h-6 text-[10px] font-bold font-mono flex items-center justify-center shrink-0",
mode === 'ai'
? "bg-purple-500/20 text-purple-300"
: "bg-accent/20 text-accent"
)}>
{idx + 1}
</span>
<button
onClick={() => openAnalyze(r.domain)}
className="text-sm font-mono font-medium text-white truncate group-hover:text-accent"
className={clsx(
"text-sm font-mono font-medium text-white truncate",
mode === 'ai' ? "group-hover:text-purple-300" : "group-hover:text-accent"
)}
>
{r.domain}
</button>
</div>
<div className="flex items-center gap-0.5 shrink-0 opacity-60 group-hover:opacity-100">
<button onClick={() => copy(r.domain)} className="w-7 h-7 flex items-center justify-center text-white/50 hover:text-white">
<button onClick={() => copy(r.domain)} className="w-7 h-7 flex items-center justify-center text-white/60 hover:text-white">
{copied === r.domain ? <Check className="w-3 h-3 text-accent" /> : <Copy className="w-3 h-3" />}
</button>
<button onClick={() => track(r.domain)} disabled={tracking === r.domain} className="w-7 h-7 flex items-center justify-center text-white/50 hover:text-white">
<button onClick={() => track(r.domain)} disabled={tracking === r.domain} className="w-7 h-7 flex items-center justify-center text-white/60 hover:text-white">
{tracking === r.domain ? <Loader2 className="w-3 h-3 animate-spin" /> : <Eye className="w-3 h-3" />}
</button>
<button onClick={() => openAnalyze(r.domain)} className="w-7 h-7 flex items-center justify-center text-white/50 hover:text-accent">
<button onClick={() => openAnalyze(r.domain)} className="w-7 h-7 flex items-center justify-center text-white/60 hover:text-accent">
<Shield className="w-3 h-3" />
</button>
<a
href={`https://www.namecheap.com/domains/registration/results/?domain=${r.domain}`}
target="_blank"
rel="noopener noreferrer"
className="h-7 px-2 bg-accent text-black text-[9px] font-bold flex items-center"
className={clsx(
"h-7 px-2 text-[10px] font-bold flex items-center",
mode === 'ai'
? "bg-purple-500 text-white hover:bg-purple-400"
: "bg-accent text-black hover:bg-white"
)}
>
Buy
</a>
@ -346,9 +455,12 @@ export function BrandableForgeTab({ showToast }: { showToast: (msg: string, type
{/* Empty State */}
{results.length === 0 && !isGenerating && (
<div className="text-center py-12 border border-dashed border-white/10">
<Wand2 className="w-10 h-10 text-white/10 mx-auto mb-3" />
<p className="text-white/40">Choose a pattern and generate</p>
<p className="text-sm text-white/20 mt-1">or describe your concept for AI suggestions</p>
<Wand2 className="w-10 h-10 text-white/20 mx-auto mb-3" />
<p className="text-white/50">
{mode === 'pattern'
? 'Choose a pattern and click Generate'
: 'Describe your brand concept'}
</p>
</div>
)}

View File

@ -174,7 +174,7 @@ export function TrendSurferTab({ showToast }: { showToast: (msg: string, type?:
<Flame className="w-5 h-5 text-orange-400" />
Trend Surfer
</h2>
<p className="text-sm text-white/40 mt-1">
<p className="text-sm text-white/60 mt-1">
Find domains for trending topics {currentGeo?.flag} {currentGeo?.name}
</p>
</div>
@ -182,7 +182,7 @@ export function TrendSurferTab({ showToast }: { showToast: (msg: string, type?:
<select
value={geo}
onChange={(e) => { setGeo(e.target.value); setSelected(null); setKeywords([]); setResults([]) }}
className="h-9 px-3 bg-white/5 border border-white/10 text-sm text-white outline-none"
className="h-10 px-3 bg-white/5 border border-white/10 text-sm text-white outline-none"
>
{GEOS.map(g => (
<option key={g.code} value={g.code}>{g.flag} {g.name}</option>
@ -191,7 +191,7 @@ export function TrendSurferTab({ showToast }: { showToast: (msg: string, type?:
<button
onClick={loadTrends}
disabled={loading}
className="h-9 w-9 flex items-center justify-center border border-white/10 text-white/50 hover:text-white hover:bg-white/5"
className="h-10 w-10 flex items-center justify-center border border-white/10 text-white/60 hover:text-white hover:bg-white/5"
>
<RefreshCw className={clsx("w-4 h-4", loading && "animate-spin")} />
</button>
@ -202,7 +202,7 @@ export function TrendSurferTab({ showToast }: { showToast: (msg: string, type?:
{/* TRENDS GRID */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
<div>
<p className="text-xs text-white/30 font-mono uppercase tracking-wider mb-3">
<p className="text-xs text-white/50 font-mono uppercase tracking-wider mb-3">
Select a trending topic
</p>
{loading ? (
@ -223,13 +223,13 @@ export function TrendSurferTab({ showToast }: { showToast: (msg: string, type?:
"relative p-3 text-left border transition-all",
isSelected
? "border-accent bg-accent/10"
: "border-white/10 bg-white/[0.02] hover:border-white/20"
: "border-white/10 bg-white/[0.02] hover:border-white/20 hover:bg-white/[0.04]"
)}
>
{idx < 3 && <span className="absolute top-1.5 right-1.5 text-[10px]">🔥</span>}
<p className={clsx(
"text-sm font-medium truncate pr-4",
isSelected ? "text-accent" : "text-white/80"
isSelected ? "text-accent" : "text-white"
)}>
{t.title}
</p>
@ -244,16 +244,16 @@ export function TrendSurferTab({ showToast }: { showToast: (msg: string, type?:
{/* KEYWORD BUILDER */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{selected && (
<div className="bg-white/[0.02] border border-white/10 p-4 space-y-4">
<div className="bg-white/[0.02] border border-white/10 p-4 sm:p-5 space-y-5">
{/* Selected Trend Header */}
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-white/40 font-mono uppercase tracking-wider">Building domains for</p>
<p className="text-xs text-white/50 font-mono uppercase tracking-wider">Building domains for</p>
<h3 className="text-lg font-bold text-white">{selected}</h3>
</div>
<button
onClick={() => { setSelected(null); setKeywords([]); setResults([]) }}
className="p-2 text-white/30 hover:text-white"
className="p-2 text-white/40 hover:text-white"
>
<X className="w-4 h-4" />
</button>
@ -261,8 +261,8 @@ export function TrendSurferTab({ showToast }: { showToast: (msg: string, type?:
{/* Keywords */}
<div>
<div className="flex items-center gap-2 mb-2">
<p className="text-xs text-white/40 font-mono uppercase tracking-wider">Keywords to check</p>
<div className="flex items-center gap-2 mb-3">
<p className="text-xs text-white/60 font-mono uppercase tracking-wider">Keywords to check</p>
{aiLoading && (
<span className="flex items-center gap-1 text-[10px] text-purple-400">
<Loader2 className="w-3 h-3 animate-spin" />
@ -276,21 +276,21 @@ export function TrendSurferTab({ showToast }: { showToast: (msg: string, type?:
</span>
)}
{!hasAI && (
<Link href="/pricing" className="flex items-center gap-1 text-[10px] text-white/30 hover:text-accent">
<Link href="/pricing" className="flex items-center gap-1 text-[10px] text-white/40 hover:text-accent">
<Lock className="w-3 h-3" />
Upgrade for AI
</Link>
)}
</div>
<div className="flex flex-wrap gap-1.5 mb-3">
<div className="flex flex-wrap gap-2 mb-3">
{keywords.map((kw, idx) => (
<span
key={kw}
className={clsx(
"inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-mono border",
"inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-mono border",
idx === 0
? "bg-accent/10 border-accent/30 text-accent"
: "bg-purple-500/10 border-purple-500/20 text-purple-400"
: "bg-purple-500/10 border-purple-500/30 text-purple-300"
)}
>
{kw}
@ -308,7 +308,7 @@ export function TrendSurferTab({ showToast }: { showToast: (msg: string, type?:
onChange={(e) => setCustomKw(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addKeyword()}
placeholder="+ add"
className="w-20 px-2 py-1 bg-transparent border border-white/10 text-xs font-mono text-white placeholder:text-white/20 outline-none focus:border-white/30"
className="w-20 px-2 py-1.5 bg-transparent border border-white/10 text-sm font-mono text-white placeholder:text-white/30 outline-none focus:border-white/30"
/>
</div>
</div>
@ -316,8 +316,8 @@ export function TrendSurferTab({ showToast }: { showToast: (msg: string, type?:
{/* TLDs */}
<div>
<p className="text-xs text-white/40 font-mono uppercase tracking-wider mb-2">TLDs</p>
<div className="flex flex-wrap gap-1.5">
<p className="text-xs text-white/60 font-mono uppercase tracking-wider mb-3">TLDs</p>
<div className="flex flex-wrap gap-2">
{TLDS.map(tld => (
<button
key={tld}
@ -325,10 +325,10 @@ export function TrendSurferTab({ showToast }: { showToast: (msg: string, type?:
prev.includes(tld) ? prev.filter(t => t !== tld) : [...prev, tld]
)}
className={clsx(
"px-2.5 py-1 text-xs font-mono border transition-all",
"px-3 py-2 text-sm font-mono border transition-all",
tlds.includes(tld)
? "border-accent bg-accent/10 text-accent"
: "border-white/10 text-white/30 hover:text-white"
: "border-white/10 text-white/50 hover:text-white"
)}
>
.{tld}
@ -342,9 +342,9 @@ export function TrendSurferTab({ showToast }: { showToast: (msg: string, type?:
onClick={checkAvailability}
disabled={checking || tlds.length === 0 || keywords.length === 0}
className={clsx(
"w-full py-3 text-sm font-bold uppercase tracking-wider flex items-center justify-center gap-2 transition-all",
"w-full py-4 text-sm font-bold uppercase tracking-wider flex items-center justify-center gap-2 transition-all",
checking || tlds.length === 0 || keywords.length === 0
? "bg-white/10 text-white/30"
? "bg-white/10 text-white/40"
: "bg-accent text-black hover:bg-white"
)}
>
@ -358,17 +358,17 @@ export function TrendSurferTab({ showToast }: { showToast: (msg: string, type?:
{/* RESULTS */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{results.length > 0 && (
<div className="space-y-3">
<div className="space-y-4">
{/* Available */}
{availableResults.length > 0 && (
<div>
<p className="text-xs text-accent font-mono uppercase tracking-wider mb-2 flex items-center gap-2">
<p className="text-sm text-accent font-mono uppercase tracking-wider mb-3 flex items-center gap-2">
<span className="w-2 h-2 bg-accent rounded-full" />
{availableResults.length} Available
</p>
<div className="space-y-1">
<div className="space-y-1.5">
{availableResults.map(r => (
<div key={r.domain} className="flex items-center justify-between p-2.5 bg-accent/5 border border-accent/20">
<div key={r.domain} className="flex items-center justify-between p-3 bg-accent/5 border border-accent/20 hover:bg-accent/10 transition-colors">
<button
onClick={() => openAnalyze(r.domain)}
className="text-sm font-mono text-white hover:text-accent truncate"
@ -376,20 +376,20 @@ export function TrendSurferTab({ showToast }: { showToast: (msg: string, type?:
{r.domain}
</button>
<div className="flex items-center gap-1 shrink-0">
<button onClick={() => copy(r.domain)} className="w-8 h-8 flex items-center justify-center text-white/30 hover:text-white">
<button onClick={() => copy(r.domain)} className="w-8 h-8 flex items-center justify-center text-white/50 hover:text-white">
{copied === r.domain ? <Check className="w-3.5 h-3.5 text-accent" /> : <Copy className="w-3.5 h-3.5" />}
</button>
<button onClick={() => track(r.domain)} disabled={tracking === r.domain} className="w-8 h-8 flex items-center justify-center text-white/30 hover:text-white">
<button onClick={() => track(r.domain)} disabled={tracking === r.domain} className="w-8 h-8 flex items-center justify-center text-white/50 hover:text-white">
{tracking === r.domain ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Eye className="w-3.5 h-3.5" />}
</button>
<button onClick={() => openAnalyze(r.domain)} className="w-8 h-8 flex items-center justify-center text-white/30 hover:text-accent">
<button onClick={() => openAnalyze(r.domain)} className="w-8 h-8 flex items-center justify-center text-white/50 hover:text-accent">
<Shield className="w-3.5 h-3.5" />
</button>
<a
href={`https://www.namecheap.com/domains/registration/results/?domain=${r.domain}`}
target="_blank"
rel="noopener noreferrer"
className="h-8 px-3 bg-accent text-black text-[10px] font-bold flex items-center gap-1"
className="h-8 px-3 bg-accent text-black text-[10px] font-bold flex items-center gap-1 hover:bg-white"
>
<ShoppingCart className="w-3 h-3" />
Buy
@ -404,15 +404,15 @@ export function TrendSurferTab({ showToast }: { showToast: (msg: string, type?:
{/* Taken (collapsed) */}
{takenResults.length > 0 && (
<details className="group">
<summary className="text-xs text-white/30 font-mono uppercase tracking-wider cursor-pointer flex items-center gap-2 py-2">
<summary className="text-sm text-white/40 font-mono uppercase tracking-wider cursor-pointer flex items-center gap-2 py-2 hover:text-white/60">
<ChevronDown className="w-3 h-3 group-open:rotate-180 transition-transform" />
{takenResults.length} Taken
</summary>
<div className="space-y-1 mt-2">
<div className="space-y-1.5 mt-2">
{takenResults.map(r => (
<div key={r.domain} className="flex items-center justify-between p-2 bg-white/[0.02] border border-white/5">
<span className="text-sm font-mono text-white/30 truncate">{r.domain}</span>
<button onClick={() => openAnalyze(r.domain)} className="text-white/20 hover:text-white">
<div key={r.domain} className="flex items-center justify-between p-2.5 bg-white/[0.02] border border-white/5">
<span className="text-sm font-mono text-white/40 truncate">{r.domain}</span>
<button onClick={() => openAnalyze(r.domain)} className="text-white/30 hover:text-white">
<Shield className="w-3.5 h-3.5" />
</button>
</div>
@ -426,9 +426,9 @@ export function TrendSurferTab({ showToast }: { showToast: (msg: string, type?:
{/* Empty State */}
{!selected && !loading && trends.length > 0 && (
<div className="text-center py-12 border border-dashed border-white/10">
<Globe className="w-10 h-10 text-white/10 mx-auto mb-3" />
<p className="text-white/40">Select a trending topic above</p>
<p className="text-sm text-white/20 mt-1">We'll find available domains for you</p>
<Globe className="w-10 h-10 text-white/20 mx-auto mb-3" />
<p className="text-white/50">Select a trending topic above</p>
<p className="text-sm text-white/30 mt-1">We'll find available domains for you</p>
</div>
)}
</div>

View File

@ -275,6 +275,29 @@ class ApiClient {
})
}
// LLM Vision (Trader/Tycoon)
async getVision(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: {
business_concept: string
industry_vertical: string
buyer_persona: string
cold_email_subject: string
cold_email_body: string
monetization_idea: string
radio_test_score: number
reasoning: string
}
}>(`/llm/vision?${qs.toString()}`)
}
// CFO (Alpha Terminal - Management)
async getCfoSummary() {
return this.request<{

272
pounce_llm.md Normal file
View File

@ -0,0 +1,272 @@
Ja. Wenn wir Mistral Nemo nicht nur als "Text-Generator", sondern als **"Business-Simulator"** einsetzen, wird es zum Unicorn-Feature.
Die meisten Tools (GoDaddy, Sedo) zeigen dir, was die Domain **IST** (ein Name).
Das Unicorn-Feature zeigt dir, was die Domain **SEIN KÖNNTE** (ein Business).
Hier ist das Konzept für das **"Pounce Vision Module"**. Das ist der Grund, warum Leute ihr $9 Abo niemals kündigen werden.
---
### Das Feature: "The Asset Vision Engine"
Wir verwandeln Mistral Nemo in einen **AI-Investment-Banker**.
Wenn der User auf eine Domain klickt, generiert Nemo in Echtzeit einen **Mini-Business-Plan**.
Wir nennen den Tab im Terminal: **🔮 VISION**.
#### 1. Der "Micro-Acquire" Simulator (Der Flip-Hebel)
*Zielgruppe: Chris Koerner (Hunter)*
Stell dir vor, du schaust dir eine leere Domain an, aber Pounce zeigt dir schon das Verkaufs-Inserat, wie es in 2 Jahren auf *Acquire.com* aussehen könnte.
* **Der Prompt an Nemo:**
> "Analyze domain '{domain}'. Act as a VC. Create a hypothetical startup pitch for this name. What could this business be? How does it make money?"
* **Der Output im Terminal:**
> **🚀 Potential Venture:** "GreenStream" (für `green-stream.io`)
> **Business Model:** SaaS platform for carbon footprint tracking in video streaming.
> **Target Buyer:** Netflix, Amazon AWS, Eco-Tech VCs.
> **Monetization:** B2B Subscription ($99/mo).
**Warum das Unicorn-Level ist:**
Es schließt die **"Imagination Gap"**. Viele User sehen `green-stream.io` und denken "Nett". Pounce zeigt ihnen: "Das ist ein SaaS-Business". Plötzlich wirkt der Preis von $50 billig.
#### 2. Der "Perfect Buyer" Matchmaker (Der Sales-Hebel)
*Zielgruppe: Margot (Händlerin)*
Das schwerste am Verkauf ist: **Wem verkaufe ich das?**
Nemo analysiert die Semantik der Domain und liefert die **Outreach-Strategie**.
* **Der Prompt an Nemo:**
> "Who is the exact end-user for '{domain}'? Don't say 'Doctors'. Be specific. Write a cold email subject line to sell it to them."
* **Der Output im Terminal:**
> **🎯 Ideal Buyer Profile:** High-end Cosmetic Dentists in Miami or LA specializing in veneers.
> **Buyer Persona:** Dr. Smith, owns private practice, spends >$5k/mo on Ads.
> **Cold Email Hook:** "Subject: Acquiring the 'Veneers' authority in Miami before your competitor does."
**Warum das Unicorn-Level ist:**
Du gibst dem User nicht nur die Domain, sondern den **Schlüssel zum Verkauf**. Du machst die Arbeit für ihn.
#### 3. Der "Semantic Search" (Der Discovery-Hebel)
*Zielgruppe: Blogger (Analyst)*
Das ist technisch anspruchsvoll, aber mit Nemo (Embeddings) machbar.
Aktuelle Suche: User tippt "Shoes". Tool zeigt `best-shoes.com`.
**Pounce Vision Suche:** User tippt "Startup selling sustainable sneakers".
* **Der Prozess:**
* Du nutzt Nemo (oder ein kleines Embedding Model), um die *Bedeutung* der Domains zu verstehen.
* Nemo erkennt: `soul-sole.com` oder `green-step.io` passen zu "Sustainable Sneakers", obwohl das Wort "Shoe" fehlt.
* **Der Output:**
> "Du suchst nach Schuhen? Hier sind brandable Domains, die das *Konzept* verkörpern, nicht nur das Keyword."
---
### Wie du das technisch umsetzt (Vibe Coding)
Du hast Mistral Nemo. Das Ding muss **JSON** spucken, keinen Fließtext.
**Der System Prompt:**
```text
You are the Pounce AI, a domain intelligence engine.
Input: A domain name.
Task: Analyze semantic meaning, brand potential, and business use cases.
Output: Strict JSON format only.
{
"business_concept": "Short 1-sentence pitch of a potential startup",
"industry_vertical": "e.g. Fintech / Agrotech",
"buyer_persona": "Specific job title of the person who would buy this",
"radio_test_score": 1-10 integer,
"reasoning": "Why this domain has value"
}
```
**Integration:**
1. Du cachest die Antworten! (Lass Nemo nicht jedes Mal laufen, wenn einer klickt. Das kostet Zeit/Geld).
2. Wenn User auf Domain klickt -> Check Datenbank.
3. Wenn kein Eintrag -> API Call zu Mistral/Groq -> JSON speichern -> Anzeigen.
### Warum das den Markt gewinnt
* **GoDaddy** sagt: "Hier ist `pizza-zurich.ch`. Kostet $12."
* **Pounce** sagt: "Hier ist `pizza-zurich.ch`. Es ist perfekt für eine Lead-Gen-Seite für Lieferdienste. Dein idealer Käufer ist 'Dominos Franchise Zürich'. Hier ist die E-Mail, die du ihnen schicken sollst."
**Das ist der Unterschied.**
Du verkaufst keine Strings (Zeichenketten). Du verkaufst **Business-Opportunitäten**.
Das ist das Unicorn-Feature. 🔮🦄
Das ist der **"Heilige Gral"** der Automatisierung.
Wir verbinden jetzt die **Analyse** (Was ist die Domain?), die **Strategie** (Wie mache ich Geld?) und die **Ausführung** (Bau die Seite) in einen einzigen, unsichtbaren Prozess.
Das Feature heißt: **"The Instant Authority Engine"**.
Anstatt eine "geparkte Seite" (Werbung) zu zeigen, baut Mistral Nemo in Sekunden eine **vollwertige Nischen-Landingpage** mit echtem Content.
Hier ist der technische und konzeptionelle Blueprint, wie du das **direkt** generierst.
---
### Der Workflow: Von der Domain zum Cashflow in 3 Sekunden
Wenn der User im Terminal auf den Button **[⚡ Activate Yield]** klickt, passiert im Hintergrund folgende **Mistral Nemo Chain**:
#### Schritt 1: The "Identity Prompt" (Verstehen & Strategie)
*Nemo analysiert die Domain und entscheidet das Business-Modell.*
* **Input:** `best-garden-shears.com`
* **Nemo Task:** Bestimme Nische, Vibe, Farben und Affiliate-Kategorie.
* **Prompt:**
```json
"Analyze 'best-garden-shears.com'.
1. Define Niche (e.g. Gardening).
2. Define Vibe (e.g. Nature, Green, Trustworthy).
3. Suggest Affiliate Product Category (e.g. Garden Tools).
4. Write a strong H1 Headline.
Output JSON."
```
* **Output:** `{ "niche": "Gardening", "color_scheme": "green", "product": "tools", "h1": "The Best Shears for a Perfect Cut" }`
#### Schritt 2: The "Content Spinner" (SEO Text)
*Nemo schreibt den Inhalt, damit Google die Seite liebt (statt sie als Spam zu blockieren).*
* **Prompt:**
```text
"Write a 150-word helpful intro about choosing garden shears. Include keywords: pruning, durability, ergonomics. Use a professional, helpful tone. No fluff."
```
* **Output:** Ein perfekter kleiner Ratgeber-Text, der erklärt, warum gute Scheren wichtig sind. (Das ist der SEO-Mehrwert!).
#### Schritt 3: The "Template Matcher" (Design & Bau)
*Pounce wählt automatisch das Design basierend auf Schritt 1.*
* Pounce hat 5 "Master Templates" (Next.js Components):
1. **Tech/SaaS** (Dunkel, Clean, Blau/Lila) -> für `.io`, AI-Domains.
2. **Commerce/Review** (Hell, Produkt-Fokus, Orange/Grün) -> für `best-x.com`.
3. **Finance/Trust** (Seriös, Blau/Grau) -> für `crypto`, `bank`.
4. **Health/Nature** (Weich, Grün/Beige) -> für `garden`, `bio`.
5. **Local Service** (Map-Fokus) -> für `plumber-zurich.ch`.
* **Die Magie:** Da Nemo im JSON `"color_scheme": "green"` ausgegeben hat, lädt Pounce automatisch das **Health/Nature Template**.
---
### Das Ergebnis: Die "Pounce Yield Page"
Der User muss **nichts** tun.
Pounce deployt eine Seite unter der Domain, die so aussieht:
1. **Header:** "The Best Shears for a Perfect Cut" (von Nemo).
2. **Body:** Der 150-Wörter SEO-Text (von Nemo).
3. **Call-to-Action (Der Geld-Knopf):**
* Ein großer Button: **"Check Prices on Amazon"** (oder Comparis/BestBuy).
* Dieser Button ist der **Intent Router**. Er enthält den Affiliate-Link des Users (oder deinen Fallback-Link).
---
### Technische Umsetzung (Vibe Coding Guide)
Du brauchst keine Datenbank für den Content. Du generierst ihn "On the Fly" und cachest ihn.
**1. Die Backend-Funktion (Python/Node):**
```python
async def generate_yield_page(domain):
# 1. Frag Mistral Nemo nach der Config
ai_config = await ask_nemo_json(f"Analyze {domain} for landing page...")
# ai_config ist jetzt z.B.:
# {
# "template": "nature",
# "headline": "...",
# "seo_text": "...",
# "affiliate_label": "View Deals on Amazon"
# }
# 2. Speichere das in deiner DB unter 'domains' -> 'yield_config'
save_to_db(domain, ai_config)
return ai_config
```
**2. Das Frontend (Next.js Dynamic Page):**
Du hast eine Datei `pages/_sites/[domain]/index.js` (wenn du Middleware nutzt) oder einfach eine Route.
```jsx
// Pseudo-Code React Component
export default function YieldPage({ config }) {
// Wähle das Design basierend auf AI Config
const Template = Templates[config.template] || Templates.Generic;
return (
<Template>
<h1>{config.headline}</h1>
<p>{config.seo_text}</p>
{/* Der Money Button */}
<a href={getAffiliateLink(config.niche)} className="btn-primary">
{config.affiliate_label}
</a>
<footer className="text-xs">
Monetized by Pounce. Own a domain? Get Yield.
</footer>
</Template>
);
}
```
---
### Warum das "Unicorn" ist (Der USP)
**Sedo Parking:**
* Zeigt wirre Links ("Zahnarzt", "Kredit", "Schuhe").
* Sieht aus wie Spam.
* User klickt weg.
**Pounce Smart Yield:**
* Domain: `best-garden-shears.com`
* Seite: Zeigt Gartenscheren, grünes Design, hilfreichen Text.
* Sieht aus wie eine **echte Marke**.
* User klickt auf "Kaufen".
* **Google indexiert die Seite**, weil echter Text drauf ist. Das bringt *noch mehr* Traffic.
**Das Verkaufsargument:**
> *"Pounce verwandelt deine Domain in 3 Sekunden in eine **SEO-optimierte Authority Site**.
> Kein Coden. Kein Schreiben. Mistral AI baut das Business für dich."*
Das ist die perfekte Synergie aus deinem **LLM** (Nemo) und dem **Yield-Konzept**. Du automatisierst die Wertschöpfung.