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
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:
@ -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")
|
||||
|
||||
@ -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"])
|
||||
|
||||
133
backend/app/api/llm_vision.py
Normal file
133
backend/app/api/llm_vision.py
Normal 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,
|
||||
)
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
52
backend/app/models/llm_artifact.py
Normal file
52
backend/app/models/llm_artifact.py
Normal 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"),
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
166
backend/app/services/llm_vision.py
Normal file
166
backend/app/services/llm_vision.py
Normal 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
|
||||
|
||||
294
frontend/src/components/analyze/VisionSection.tsx
Normal file
294
frontend/src/components/analyze/VisionSection.tsx
Normal 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">1–10 (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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,168 +136,255 @@ 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>
|
||||
<p className="text-xs text-white/40 font-mono uppercase tracking-wider mb-2">Pattern</p>
|
||||
<div className="flex 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",
|
||||
pattern === p.key
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-white/10 hover:border-white/20"
|
||||
)}
|
||||
>
|
||||
<p className={clsx(
|
||||
"text-sm font-bold font-mono",
|
||||
pattern === p.key ? "text-accent" : "text-white/60"
|
||||
)}>
|
||||
{p.label}
|
||||
</p>
|
||||
<p className="text-[10px] text-white/30 mt-0.5">{p.example}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: 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">
|
||||
{TLDS.map(tld => (
|
||||
<button
|
||||
key={tld}
|
||||
onClick={() => setTlds(prev =>
|
||||
prev.includes(tld) ? prev.filter(t => t !== tld) : [...prev, tld]
|
||||
)}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 text-xs font-mono border transition-all",
|
||||
tlds.includes(tld)
|
||||
? "border-accent bg-accent/10 text-accent"
|
||||
: "border-white/10 text-white/30 hover:text-white"
|
||||
)}
|
||||
>
|
||||
.{tld}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Generate Button */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Pattern Mode */}
|
||||
<button
|
||||
onClick={generatePattern}
|
||||
disabled={isGenerating || tlds.length === 0}
|
||||
onClick={() => setMode('pattern')}
|
||||
className={clsx(
|
||||
"w-full py-3.5 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-accent text-black hover:bg-white"
|
||||
"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"
|
||||
)}
|
||||
>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||
Generate {pattern.toUpperCase()} Brandables
|
||||
<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={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>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative py-2">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-white/10" />
|
||||
{/* 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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||
{/* 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(
|
||||
"p-3 border text-center transition-all",
|
||||
pattern === p.key
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-white/10 hover:border-white/20"
|
||||
)}
|
||||
>
|
||||
<p className={clsx(
|
||||
"text-sm font-bold font-mono",
|
||||
pattern === p.key ? "text-accent" : "text-white"
|
||||
)}>
|
||||
{p.label}
|
||||
</p>
|
||||
<p className="text-[10px] text-white/50 mt-0.5">{p.example}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
|
||||
{/* TLDs */}
|
||||
<div>
|
||||
<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}
|
||||
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-accent bg-accent/10 text-accent"
|
||||
: "border-white/10 text-white/50 hover:text-white"
|
||||
)}
|
||||
>
|
||||
.{tld}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Generate */}
|
||||
<button
|
||||
onClick={generatePattern}
|
||||
disabled={isGenerating || tlds.length === 0}
|
||||
className={clsx(
|
||||
"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/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()} Names
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
||||
{/* 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 ? (
|
||||
<button
|
||||
onClick={generateFromConcept}
|
||||
disabled={!concept.trim() || aiLoading}
|
||||
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"
|
||||
: "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>
|
||||
</button>
|
||||
) : (
|
||||
<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"
|
||||
>
|
||||
Upgrade
|
||||
</Link>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 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={aiLoading || !concept.trim() || tlds.length === 0}
|
||||
className={clsx(
|
||||
"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" /> : <Brain className="w-4 h-4" />}
|
||||
Generate AI Names
|
||||
</button>
|
||||
</div>
|
||||
</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="inline-flex items-center gap-2 px-6 py-3 bg-accent text-black text-sm font-bold uppercase hover:bg-white transition-colors"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Upgrade to Trader
|
||||
</Link>
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
272
pounce_llm.md
Normal 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.
|
||||
Reference in New Issue
Block a user