From 5a1fcb30dd1bf278d50244264a6c7746ebd2cc4b Mon Sep 17 00:00:00 2001 From: Yves Gugger Date: Wed, 17 Dec 2025 16:28:45 +0100 Subject: [PATCH] Forge: prominent AI mode selector, improved contrast throughout Hunt tabs --- ..._llm_artifacts_and_yield_landing_config.py | 75 ++++ backend/app/api/__init__.py | 2 + backend/app/api/llm_vision.py | 133 ++++++ backend/app/models/__init__.py | 3 + backend/app/models/llm_artifact.py | 52 +++ backend/app/models/yield_domain.py | 9 + backend/app/services/llm_vision.py | 166 ++++++++ .../src/components/analyze/VisionSection.tsx | 294 +++++++++++++ .../src/components/hunt/BrandableForgeTab.tsx | 394 +++++++++++------- .../src/components/hunt/TrendSurferTab.tsx | 76 ++-- frontend/src/lib/api.ts | 23 + pounce_llm.md | 272 ++++++++++++ 12 files changed, 1320 insertions(+), 179 deletions(-) create mode 100644 backend/alembic/versions/016_add_llm_artifacts_and_yield_landing_config.py create mode 100644 backend/app/api/llm_vision.py create mode 100644 backend/app/models/llm_artifact.py create mode 100644 backend/app/services/llm_vision.py create mode 100644 frontend/src/components/analyze/VisionSection.tsx create mode 100644 pounce_llm.md diff --git a/backend/alembic/versions/016_add_llm_artifacts_and_yield_landing_config.py b/backend/alembic/versions/016_add_llm_artifacts_and_yield_landing_config.py new file mode 100644 index 0000000..94a8263 --- /dev/null +++ b/backend/alembic/versions/016_add_llm_artifacts_and_yield_landing_config.py @@ -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") + diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 4830050..533d79c 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -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"]) diff --git a/backend/app/api/llm_vision.py b/backend/app/api/llm_vision.py new file mode 100644 index 0000000..e6e2770 --- /dev/null +++ b/backend/app/api/llm_vision.py @@ -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, + ) + diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 6cfc27b..add8b70 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/llm_artifact.py b/backend/app/models/llm_artifact.py new file mode 100644 index 0000000..ef97f6c --- /dev/null +++ b/backend/app/models/llm_artifact.py @@ -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"), + ) + diff --git a/backend/app/models/yield_domain.py b/backend/app/models/yield_domain.py index 59da7ae..8eff0e6 100644 --- a/backend/app/models/yield_domain.py +++ b/backend/app/models/yield_domain.py @@ -98,6 +98,15 @@ class YieldDomain(Base): partner_id: Mapped[Optional[int]] = mapped_column(ForeignKey("affiliate_partners.id"), nullable=True) 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) diff --git a/backend/app/services/llm_vision.py b/backend/app/services/llm_vision.py new file mode 100644 index 0000000..03e96c8 --- /dev/null +++ b/backend/app/services/llm_vision.py @@ -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 + diff --git a/frontend/src/components/analyze/VisionSection.tsx b/frontend/src/components/analyze/VisionSection.tsx new file mode 100644 index 0000000..d89f754 --- /dev/null +++ b/frontend/src/components/analyze/VisionSection.tsx @@ -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(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [copiedKey, setCopiedKey] = useState(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 ( +
+
+
+ +
+
+
+ VISION + Trader+ +
+

+ Turn a domain into a buyer-ready business story: pitch, ideal buyer, and outreach angle. +

+
+ + Upgrade to unlock + + + + Scout users see a preview only. + +
+
+
+
+ ) + } + + return ( +
+
+
+
+
+ + Vision Engine + | + cached +
+
{headline}
+
+ + Generates a VC-style pitch + a concrete buyer persona + a ready-to-send outreach message. +
+
+
+ + +
+
+
+ + {error && ( +
+ {error} +
+ )} + + {!data && !loading && !error && ( +
+ Click Generate to create a Vision for this domain. +
+ )} + + {data && ( +
+ {/* Business concept */} +
+
+
+ +
Business Concept
+
+ +
+
{data.result.business_concept}
+
+ Vertical: {data.result.industry_vertical} +
+
+ + {/* Buyer persona */} +
+
+
+ +
Ideal Buyer
+
+ +
+
{data.result.buyer_persona}
+
+ + {/* Outreach */} +
+
+
+ +
Outreach Draft
+
+ +
+
+
Subject
+
{data.result.cold_email_subject}
+
+
+
Body
+
{data.result.cold_email_body}
+
+
+ + {/* Monetization + radio test */} +
+
+
+
+ +
Monetization
+
+ +
+
{data.result.monetization_idea}
+
+ +
+
+
+ +
Radio Test
+
+ +
+
+
{data.result.radio_test_score}
+
1–10 (higher is better)
+
+
+ Measures how easy the name is to remember and spell when heard. +
+
+
+ + {/* Reasoning */} +
+
+
+ +
Why this is valuable
+
+ +
+
{data.result.reasoning}
+
+ {data.cached ? 'Cached' : 'Fresh'} • {new Date(data.generated_at).toLocaleString()} • {data.model} +
+
+
+ )} +
+ ) +} + diff --git a/frontend/src/components/hunt/BrandableForgeTab.tsx b/frontend/src/components/hunt/BrandableForgeTab.tsx index 1a86a8c..7f48da7 100644 --- a/frontend/src/components/hunt/BrandableForgeTab.tsx +++ b/frontend/src/components/hunt/BrandableForgeTab.tsx @@ -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 Brandable Forge -

+

Generate unique, memorable domain names

{/* ═══════════════════════════════════════════════════════════════════════ */} - {/* GENERATOR */} + {/* MODE SELECTOR - BIG CLEAR BUTTONS */} {/* ═══════════════════════════════════════════════════════════════════════ */} -
- - {/* Row 1: Pattern Selection */} -
-

Pattern

-
- {PATTERNS.map(p => ( - - ))} -
-
- - {/* Row 2: TLDs */} -
-

TLDs

-
- {TLDS.map(tld => ( - - ))} -
-
- - {/* Row 3: Generate Button */} +
+ {/* Pattern Mode */} - {/* Divider */} -
-
-
+ {/* AI Mode */} +
+

+ {hasAI ? 'AI generates names based on your concept' : 'Upgrade to unlock AI naming'} +

+ +
- {/* Row 4: AI Concept */} -
-
-

AI Concept Generator

- {!hasAI && ( - - - Trader+ - - )} + {/* ═══════════════════════════════════════════════════════════════════════ */} + {/* PATTERN MODE */} + {/* ═══════════════════════════════════════════════════════════════════════ */} + {mode === 'pattern' && ( +
+ {/* Pattern Selection */} +
+

Choose Pattern

+
+ {PATTERNS.map(p => ( + + ))} +
-
- +

Select TLDs

+
+ {TLDS.map(tld => ( + + ))} +
+
+ + {/* Generate */} + +
+ )} + + {/* ═══════════════════════════════════════════════════════════════════════ */} + {/* AI MODE */} + {/* ═══════════════════════════════════════════════════════════════════════ */} + {mode === 'ai' && hasAI && ( +
+ {/* Concept Input */} +
+

+ Describe Your Brand Concept +

+