(null)
-
- // Load saved messages
- useEffect(() => {
- if (!canChat) return
- try {
- const saved = localStorage.getItem(storageKey)
- if (saved) setMessages(JSON.parse(saved))
- } catch {}
- }, [storageKey, canChat])
-
- // Save messages
- useEffect(() => {
- if (!canChat || !messages.length) return
- try {
- localStorage.setItem(storageKey, JSON.stringify(messages.slice(-30)))
- } catch {}
- }, [messages, storageKey, canChat])
-
- // Auto scroll
- useEffect(() => {
- if (open && scrollRef.current) {
- scrollRef.current.scrollTop = scrollRef.current.scrollHeight
- }
- }, [open, messages])
-
- // Focus input
- useEffect(() => {
- if (open && canChat) {
- setTimeout(() => inputRef.current?.focus(), 100)
- }
- }, [open, canChat])
-
- // Only show in terminal
- if (!pathname?.startsWith('/terminal')) return null
-
- const send = async (text?: string) => {
- const msg = (text || input).trim()
- if (!msg || loading || !canChat) return
-
- const userMsg: ChatMessage = { id: uid(), role: 'user', content: msg }
- const assistantId = uid()
-
- setMessages(prev => [...prev, userMsg, { id: assistantId, role: 'assistant', content: '' }])
- setInput('')
- setLoading(true)
-
- try {
- await sendMessage({
- message: msg,
- path: pathname || '/terminal/hunt',
- onChunk: (chunk) => {
- setMessages(prev => prev.map(m =>
- m.id === assistantId ? { ...m, content: m.content + chunk } : m
- ))
- },
- })
- } catch (e: any) {
- setMessages(prev => prev.map(m =>
- m.id === assistantId ? { ...m, content: `Error: ${e?.message || 'Failed'}` } : m
- ))
- } finally {
- setLoading(false)
- inputRef.current?.focus()
- }
- }
-
- const clear = () => {
- setMessages([])
- localStorage.removeItem(storageKey)
- }
-
- return (
- <>
- {/* FAB Button */}
-
-
- {/* Chat Modal */}
- {open && (
-
- {/* Backdrop */}
-
setOpen(false)} />
-
- {/* Panel */}
-
-
- {/* Header */}
-
-
-
-
- Hunter Companion
-
-
-
- {canChat && messages.length > 0 && (
-
- )}
-
-
-
-
- {/* Content */}
-
- {!canChat ? (
- /* Scout - Locked */
-
-
-
Hunter Companion
-
- AI-powered domain analysis, auction alerts, and portfolio insights.
-
-
- Upgrade to Trader
-
-
- ) : messages.length === 0 ? (
- /* Empty State */
-
-
-
- Type a domain or ask a question
-
-
- {QUICK_ACTIONS.map((qa, i) => (
-
- ))}
-
-
- ) : (
- /* Messages */
-
- {messages.map((m) => (
-
-
- {m.content || (loading ? '...' : '')}
-
-
- ))}
-
- )}
-
-
- {/* Input */}
- {canChat && (
-
-
- setInput(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault()
- send()
- }
- }}
- placeholder="Type a domain or question..."
- className="flex-1 px-3 py-2 bg-white/5 border border-white/10 text-white text-xs font-mono outline-none focus:border-accent/30 placeholder:text-white/25"
- disabled={loading}
- />
-
-
-
- )}
-
-
- )}
- >
- )
-}
diff --git a/frontend/src/components/hunt/BrandableForgeTab.tsx b/frontend/src/components/hunt/BrandableForgeTab.tsx
index ef18cac..5745054 100644
--- a/frontend/src/components/hunt/BrandableForgeTab.tsx
+++ b/frontend/src/components/hunt/BrandableForgeTab.tsx
@@ -17,7 +17,11 @@ import {
Star,
Lightbulb,
RefreshCw,
+ Lock,
+ Brain,
+ MessageSquare,
} from 'lucide-react'
+import Link from 'next/link'
import { api } from '@/lib/api'
import { useAnalyzePanelStore } from '@/lib/analyze-store'
import { useStore } from '@/lib/store'
@@ -68,6 +72,12 @@ const TLDS = [
export function BrandableForgeTab({ showToast }: { showToast: (message: string, type?: any) => void }) {
const openAnalyze = useAnalyzePanelStore((s) => s.open)
const addDomain = useStore((s) => s.addDomain)
+ const subscription = useStore((s) => s.subscription)
+ const tier = (subscription?.tier || '').toLowerCase()
+ const hasAI = tier === 'trader' || tier === 'tycoon'
+
+ // Mode: 'pattern' (classic) or 'ai' (concept-based)
+ const [mode, setMode] = useState<'pattern' | 'ai'>('pattern')
// Config State
const [pattern, setPattern] = useState('cvcvc')
@@ -75,6 +85,14 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
const [limit, setLimit] = useState(30)
const [showConfig, setShowConfig] = useState(false)
+ // AI State
+ const [concept, setConcept] = useState('')
+ const [conceptFocused, setConceptFocused] = useState(false)
+ const [similarBrand, setSimilarBrand] = useState('')
+ const [similarFocused, setSimilarFocused] = useState(false)
+ const [aiNames, setAiNames] = useState
([])
+ const [aiLoading, setAiLoading] = useState(false)
+
// Results State
const [loading, setLoading] = useState(false)
const [items, setItems] = useState>([])
@@ -144,6 +162,60 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
const currentPattern = PATTERNS.find(p => p.key === pattern)
+ // AI Generation
+ const generateFromConcept = useCallback(async () => {
+ if (!concept.trim() || !hasAI || aiLoading) return
+ setAiLoading(true)
+ setAiNames([])
+ try {
+ const res = await api.generateBrandableNames(concept.trim(), undefined, 15)
+ setAiNames(res.names || [])
+ if (res.names?.length) {
+ showToast(`AI generated ${res.names.length} names!`, 'success')
+ } else {
+ showToast('No names generated. Try a different concept.', 'info')
+ }
+ } catch (e) {
+ showToast(e instanceof Error ? e.message : 'AI generation failed', 'error')
+ } finally {
+ setAiLoading(false)
+ }
+ }, [concept, hasAI, aiLoading, showToast])
+
+ const generateFromBrand = useCallback(async () => {
+ if (!similarBrand.trim() || !hasAI || aiLoading) return
+ setAiLoading(true)
+ setAiNames([])
+ try {
+ const res = await api.generateSimilarNames(similarBrand.trim(), 12)
+ setAiNames(res.names || [])
+ if (res.names?.length) {
+ showToast(`AI found ${res.names.length} similar names!`, 'success')
+ }
+ } catch (e) {
+ showToast(e instanceof Error ? e.message : 'AI generation failed', 'error')
+ } finally {
+ setAiLoading(false)
+ }
+ }, [similarBrand, hasAI, aiLoading, showToast])
+
+ // Check AI-generated names for availability
+ const checkAiNames = useCallback(async () => {
+ if (aiNames.length === 0 || selectedTlds.length === 0) return
+ setLoading(true)
+ setItems([])
+ try {
+ const res = await api.huntKeywords({ keywords: aiNames, tlds: selectedTlds })
+ const available = res.items.filter(i => i.status === 'available')
+ setItems(available.map(i => ({ domain: i.domain, status: i.status })))
+ showToast(`Found ${available.length} available domains!`, 'success')
+ } catch (e) {
+ showToast(e instanceof Error ? e.message : 'Availability check failed', 'error')
+ } finally {
+ setLoading(false)
+ }
+ }, [aiNames, selectedTlds, showToast])
+
return (
{/* ═══════════════════════════════════════════════════════════════════════ */}
@@ -160,7 +232,7 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
Brandable Forge
- AI-powered brandable name generator
+ {mode === 'pattern' ? 'Pattern-based name generator' : 'AI-powered concept generator'}
@@ -203,7 +275,150 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
+ {/* Mode Toggle */}
+
+
+
+
+ {!hasAI && (
+
+ Upgrade for AI
+
+ )}
+
+
+
+ {/* AI Concept Mode */}
+ {mode === 'ai' && hasAI && (
+
+ {/* Concept Input */}
+
+
+
+
+ setConcept(e.target.value)}
+ onFocus={() => setConceptFocused(true)}
+ onBlur={() => setConceptFocused(false)}
+ onKeyDown={(e) => e.key === 'Enter' && generateFromConcept()}
+ placeholder="e.g., AI startup for legal documents, crypto wallet for teens..."
+ className="flex-1 bg-transparent py-3 pr-3 text-sm text-white placeholder:text-white/25 outline-none font-mono"
+ />
+
+
+
+
+ {/* Similar Brand Input */}
+
+
+
+
+ setSimilarBrand(e.target.value)}
+ onFocus={() => setSimilarFocused(true)}
+ onBlur={() => setSimilarFocused(false)}
+ onKeyDown={(e) => e.key === 'Enter' && generateFromBrand()}
+ placeholder="e.g., Stripe, Notion, Figma..."
+ className="flex-1 bg-transparent py-3 pr-3 text-sm text-white placeholder:text-white/25 outline-none font-mono"
+ />
+
+
+
+
+ {/* AI Generated Names */}
+ {aiNames.length > 0 && (
+
+
+
+ AI Suggestions ({aiNames.length})
+
+
+
+
+ {aiNames.map((name) => (
+
+ {name}
+
+ ))}
+
+
+ )}
+
+ )}
+
{/* Pattern Selection */}
+ {mode === 'pattern' && (
@@ -256,6 +471,7 @@ export function BrandableForgeTab({ showToast }: { showToast: (message: string,
})}
+ )}
{/* TLD Selection */}
diff --git a/frontend/src/components/hunt/SearchTab.tsx b/frontend/src/components/hunt/SearchTab.tsx
index c1f19d9..a0384b9 100644
--- a/frontend/src/components/hunt/SearchTab.tsx
+++ b/frontend/src/components/hunt/SearchTab.tsx
@@ -25,7 +25,7 @@ import {
import clsx from 'clsx'
// ============================================================================
-// TYPES
+// TYPES & CONSTANTS
// ============================================================================
interface SearchResult {
@@ -35,8 +35,29 @@ interface SearchResult {
registrar: string | null
expiration_date: string | null
loading: boolean
+ error?: string
}
+interface TldCheckResult {
+ tld: string
+ domain: string
+ is_available: boolean | null
+ loading: boolean
+ error?: string
+}
+
+// Popular TLDs to check when user enters only a name without extension
+const POPULAR_TLDS = ['com', 'net', 'org', 'io', 'ch', 'de', 'app', 'dev', 'co', 'ai']
+
+// Known valid TLDs (subset for quick validation)
+const KNOWN_TLDS = new Set([
+ 'com', 'net', 'org', 'io', 'ch', 'de', 'app', 'dev', 'co', 'ai', 'me', 'tv', 'cc',
+ 'xyz', 'info', 'biz', 'online', 'site', 'tech', 'store', 'club', 'shop', 'blog',
+ 'uk', 'fr', 'nl', 'eu', 'be', 'at', 'us', 'ca', 'au', 'li', 'it', 'es', 'pl',
+ 'pro', 'mobi', 'name', 'page', 'new', 'day', 'world', 'email', 'link', 'click',
+ 'digital', 'media', 'agency', 'studio', 'design', 'marketing', 'solutions',
+])
+
// ============================================================================
// COMPONENT
// ============================================================================
@@ -51,9 +72,11 @@ export function SearchTab({ showToast }: SearchTabProps) {
const [searchQuery, setSearchQuery] = useState('')
const [searchResult, setSearchResult] = useState(null)
+ const [tldResults, setTldResults] = useState([])
const [addingToWatchlist, setAddingToWatchlist] = useState(false)
const [searchFocused, setSearchFocused] = useState(false)
const [recentSearches, setRecentSearches] = useState([])
+ const [searchMode, setSearchMode] = useState<'single' | 'multi'>('single')
const searchInputRef = useRef(null)
// Load recent searches from localStorage
@@ -78,30 +101,134 @@ export function SearchTab({ showToast }: SearchTabProps) {
})
}, [])
+ // Check if TLD is valid
+ const isValidTld = useCallback((tld: string): boolean => {
+ return KNOWN_TLDS.has(tld.toLowerCase())
+ }, [])
+
+ // Check single domain
+ const checkSingleDomain = useCallback(async (domain: string): Promise => {
+ try {
+ const result = await api.checkDomain(domain)
+ return {
+ domain: result.domain,
+ status: result.status,
+ is_available: result.is_available,
+ registrar: result.registrar,
+ expiration_date: result.expiration_date,
+ loading: false,
+ }
+ } catch (err: any) {
+ return {
+ domain,
+ status: 'error',
+ is_available: null,
+ registrar: null,
+ expiration_date: null,
+ loading: false,
+ error: err?.message || 'Check failed',
+ }
+ }
+ }, [])
+
+ // Check multiple TLDs for a name
+ const checkMultipleTlds = useCallback(async (name: string) => {
+ // Initialize results with loading state
+ const initialResults: TldCheckResult[] = POPULAR_TLDS.map(tld => ({
+ tld,
+ domain: `${name}.${tld}`,
+ is_available: null,
+ loading: true,
+ }))
+ setTldResults(initialResults)
+
+ // Check each TLD in parallel
+ const results = await Promise.all(
+ POPULAR_TLDS.map(async (tld): Promise => {
+ const domain = `${name}.${tld}`
+ try {
+ const result = await api.checkDomain(domain)
+ return {
+ tld,
+ domain,
+ is_available: result.is_available,
+ loading: false,
+ }
+ } catch {
+ return {
+ tld,
+ domain,
+ is_available: null,
+ loading: false,
+ error: 'Check failed',
+ }
+ }
+ })
+ )
+
+ setTldResults(results)
+ }, [])
+
// Search Handler
const handleSearch = useCallback(async (domainInput: string) => {
if (!domainInput.trim()) {
setSearchResult(null)
+ setTldResults([])
return
}
- const cleanDomain = domainInput.trim().toLowerCase()
- setSearchResult({ domain: cleanDomain, status: 'checking', is_available: null, registrar: null, expiration_date: null, loading: true })
-
- try {
- const whoisResult = await api.checkDomain(cleanDomain).catch(() => null)
- setSearchResult({
- domain: whoisResult?.domain || cleanDomain,
- status: whoisResult?.status || 'unknown',
- is_available: whoisResult?.is_available ?? null,
- registrar: whoisResult?.registrar || null,
- expiration_date: whoisResult?.expiration_date || null,
- loading: false,
- })
- saveToRecent(cleanDomain)
- } catch {
- setSearchResult({ domain: cleanDomain, status: 'error', is_available: null, registrar: null, expiration_date: null, loading: false })
+
+ const cleanInput = domainInput.trim().toLowerCase().replace(/\s+/g, '')
+
+ // Check if input contains a dot (has TLD)
+ if (cleanInput.includes('.')) {
+ // Single domain mode
+ setSearchMode('single')
+ setTldResults([])
+
+ const parts = cleanInput.split('.')
+ const tld = parts[parts.length - 1]
+
+ // Check if TLD is valid
+ if (!isValidTld(tld)) {
+ setSearchResult({
+ domain: cleanInput,
+ status: 'invalid_tld',
+ is_available: null,
+ registrar: null,
+ expiration_date: null,
+ loading: false,
+ error: `".${tld}" is not a valid domain extension`,
+ })
+ return
+ }
+
+ setSearchResult({ domain: cleanInput, status: 'checking', is_available: null, registrar: null, expiration_date: null, loading: true })
+
+ const result = await checkSingleDomain(cleanInput)
+ setSearchResult(result)
+ if (!result.error) saveToRecent(cleanInput)
+ } else {
+ // Multi-TLD mode - check multiple extensions
+ setSearchMode('multi')
+ setSearchResult(null)
+
+ // Validate the name part
+ if (cleanInput.length < 1 || cleanInput.length > 63) {
+ setTldResults([])
+ showToast('Domain name must be 1-63 characters', 'error')
+ return
+ }
+
+ if (!/^[a-z0-9-]+$/.test(cleanInput) || cleanInput.startsWith('-') || cleanInput.endsWith('-')) {
+ setTldResults([])
+ showToast('Domain name contains invalid characters', 'error')
+ return
+ }
+
+ await checkMultipleTlds(cleanInput)
+ saveToRecent(cleanInput)
}
- }, [saveToRecent])
+ }, [saveToRecent, checkSingleDomain, checkMultipleTlds, isValidTld, showToast])
const handleAddToWatchlist = useCallback(async () => {
if (!searchQuery.trim()) return
@@ -119,8 +246,11 @@ export function SearchTab({ showToast }: SearchTabProps) {
// Debounced search
useEffect(() => {
const timer = setTimeout(() => {
- if (searchQuery.length > 3) handleSearch(searchQuery)
- else setSearchResult(null)
+ if (searchQuery.length >= 2) handleSearch(searchQuery)
+ else {
+ setSearchResult(null)
+ setTldResults([])
+ }
}, 500)
return () => clearTimeout(timer)
}, [searchQuery, handleSearch])
@@ -147,7 +277,7 @@ export function SearchTab({ showToast }: SearchTabProps) {
onFocus={() => setSearchFocused(true)}
onBlur={() => setSearchFocused(false)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch(searchQuery)}
- placeholder="example.com"
+ placeholder="domain or name.tld"
className="flex-1 bg-transparent px-3 py-4 text-base lg:text-lg text-white placeholder:text-white/20 outline-none font-mono"
/>
{searchQuery && (
@@ -180,14 +310,29 @@ export function SearchTab({ showToast }: SearchTabProps) {
- {/* Search Result */}
- {searchResult && (
+ {/* Single Domain Result */}
+ {searchMode === 'single' && searchResult && (
{searchResult.loading ? (
Checking availability...
+ ) : searchResult.error ? (
+ // Error state (invalid TLD, check failed, etc.)
+
+
+
+
+
+
+
+
{searchResult.domain}
+
{searchResult.error}
+
+
+
+
) : (
)}
+ {/* Multi-TLD Results */}
+ {searchMode === 'multi' && tldResults.length > 0 && (
+
+
+ {/* Header */}
+
+
+
+
+ Checking {POPULAR_TLDS.length} extensions for "{searchQuery.toLowerCase().replace(/\s+/g, '')}"
+
+
+
+ {tldResults.filter(r => r.is_available === true).length} available
+
+
+
+ {/* TLD Grid */}
+
+ {tldResults.map((result) => (
+
{
+ if (result.is_available && !result.loading) {
+ setSearchQuery(result.domain)
+ setSearchMode('single')
+ setTldResults([])
+ handleSearch(result.domain)
+ }
+ }}
+ >
+ {result.loading ? (
+
+
+
+ ) : (
+ <>
+
+
+ .{result.tld}
+
+ {result.is_available ? (
+
+ ) : (
+
+ )}
+
+
+ {result.is_available ? 'Available' : 'Taken'}
+
+ >
+ )}
+
+ ))}
+
+
+ {/* Footer hint */}
+
+ Click an available extension to see details • Add ".tld" to your search for specific extension
+
+
+
+ )}
+
{/* Recent Searches */}
{!searchResult && recentSearches.length > 0 && (
diff --git a/frontend/src/components/hunt/TrendSurferTab.tsx b/frontend/src/components/hunt/TrendSurferTab.tsx
index 0ab9f26..44737cc 100644
--- a/frontend/src/components/hunt/TrendSurferTab.tsx
+++ b/frontend/src/components/hunt/TrendSurferTab.tsx
@@ -19,11 +19,14 @@ import {
ShoppingCart,
Flame,
ArrowRight,
- AlertCircle
+ AlertCircle,
+ Wand2,
+ Lock,
} from 'lucide-react'
import { api } from '@/lib/api'
import { useAnalyzePanelStore } from '@/lib/analyze-store'
import { useStore } from '@/lib/store'
+import Link from 'next/link'
// ============================================================================
// TYPES & CONSTANTS
@@ -56,6 +59,9 @@ function normalizeKeyword(s: string) {
export function TrendSurferTab({ showToast }: { showToast: (message: string, type?: any) => void }) {
const openAnalyze = useAnalyzePanelStore((s) => s.open)
const addDomain = useStore((s) => s.addDomain)
+ const subscription = useStore((s) => s.subscription)
+ const tier = (subscription?.tier || '').toLowerCase()
+ const hasAI = tier === 'trader' || tier === 'tycoon'
// Trends State
const [loading, setLoading] = useState(true)
@@ -65,6 +71,11 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
const [selected, setSelected] = useState('')
const [refreshing, setRefreshing] = useState(false)
+ // AI Expansion State
+ const [aiKeywords, setAiKeywords] = useState([])
+ const [aiLoading, setAiLoading] = useState(false)
+ const [aiAnalysis, setAiAnalysis] = useState('')
+
// Keyword Check State
const [keywordInput, setKeywordInput] = useState('')
const [keywordFocused, setKeywordFocused] = useState(false)
@@ -82,6 +93,29 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
const [tracking, setTracking] = useState(null)
const [copied, setCopied] = useState(null)
+ // AI Keyword Expansion
+ const expandWithAI = useCallback(async () => {
+ if (!selected || !hasAI || aiLoading) return
+ setAiLoading(true)
+ setAiKeywords([])
+ setAiAnalysis('')
+ try {
+ const [expandRes, analyzeRes] = await Promise.all([
+ api.expandTrendKeywords(selected, geo),
+ api.analyzeTrend(selected, geo),
+ ])
+ setAiKeywords(expandRes.keywords || [])
+ setAiAnalysis(analyzeRes.analysis || '')
+ if (expandRes.keywords?.length) {
+ showToast(`AI found ${expandRes.keywords.length} related keywords!`, 'success')
+ }
+ } catch (e) {
+ showToast(e instanceof Error ? e.message : 'AI expansion failed', 'error')
+ } finally {
+ setAiLoading(false)
+ }
+ }, [selected, geo, hasAI, aiLoading, showToast])
+
const copyDomain = useCallback((domain: string) => {
navigator.clipboard.writeText(domain)
setCopied(domain)
@@ -304,6 +338,77 @@ export function TrendSurferTab({ showToast }: { showToast: (message: string, typ
)}
)}
+
+ {/* AI Expansion Section */}
+ {selected && (
+
+
+
+
+ AI Keyword Expansion
+ {!hasAI && (
+
+ TRADER+
+
+ )}
+
+ {hasAI ? (
+
+ ) : (
+
+
+ Upgrade
+
+ )}
+
+
+ {/* AI Analysis */}
+ {aiAnalysis && (
+
+ )}
+
+ {/* AI Keywords */}
+ {aiKeywords.length > 0 && (
+
+ {aiKeywords.map((kw) => (
+
+ ))}
+
+ )}
+
+ {!aiKeywords.length && !aiLoading && hasAI && (
+
+ Click "Expand with AI" to find related keywords for "{selected}"
+
+ )}
+
+ )}
{/* ═══════════════════════════════════════════════════════════════════════ */}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 2b762a8..7ede93c 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -246,6 +246,35 @@ class ApiClient {
})
}
+ // LLM Naming (AI-powered suggestions for Trends & Forge)
+ async expandTrendKeywords(trend: string, geo: string = 'US') {
+ return this.request<{ keywords: string[]; trend: string }>('/naming/trends/expand', {
+ method: 'POST',
+ body: JSON.stringify({ trend, geo }),
+ })
+ }
+
+ async analyzeTrend(trend: string, geo: string = 'US') {
+ return this.request<{ analysis: string; trend: string }>('/naming/trends/analyze', {
+ method: 'POST',
+ body: JSON.stringify({ trend, geo }),
+ })
+ }
+
+ async generateBrandableNames(concept: string, style?: string, count: number = 15) {
+ return this.request<{ names: string[]; concept: string }>('/naming/forge/generate', {
+ method: 'POST',
+ body: JSON.stringify({ concept, style, count }),
+ })
+ }
+
+ async generateSimilarNames(brand: string, count: number = 12) {
+ return this.request<{ names: string[]; brand: string }>('/naming/forge/similar', {
+ method: 'POST',
+ body: JSON.stringify({ brand, count }),
+ })
+ }
+
// CFO (Alpha Terminal - Management)
async getCfoSummary() {
return this.request<{