'use client' import { useEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' import { usePathname } from 'next/navigation' import Link from 'next/link' import { MessageSquare, X, Send, Sparkles, Loader2, Lock, Trash2, TrendingUp, Search, Briefcase, Target, Zap, } from 'lucide-react' import { useStore } from '@/lib/store' type Role = 'system' | 'user' | 'assistant' type ChatMessage = { id: string role: Exclude content: string createdAt: number } function uid() { return `${Date.now()}_${Math.random().toString(16).slice(2)}` } function getApiBase(): string { if (typeof window === 'undefined') return 'http://localhost:8000/api/v1' const { protocol, hostname } = window.location if (hostname === 'localhost' || hostname === '127.0.0.1') return 'http://localhost:8000/api/v1' if (/^(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)/.test(hostname)) return `http://${hostname}:8000/api/v1` return `${protocol}//${hostname}/api/v1` } async function streamChat(opts: { messages: Array<{ role: Role; content: string }> path: string onDelta: (delta: string) => void }): Promise { const res = await fetch(`${getApiBase()}/llm/agent`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: opts.messages, temperature: 0.5, stream: true, path: opts.path, }), }) if (!res.ok) { const data = await res.json().catch(() => null) throw new Error(data?.detail || res.statusText || 'Request failed') } const reader = res.body?.getReader() if (!reader) throw new Error('Streaming not supported') const decoder = new TextDecoder('utf-8') let buffer = '' for (;;) { const { value, done } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const parts = buffer.split('\n\n') buffer = parts.pop() || '' for (const p of parts) { const line = p.split('\n').find((l) => l.startsWith('data: ')) if (!line) continue const payload = line.replace(/^data:\s*/, '') if (payload === '[DONE]') return try { const json = JSON.parse(payload) const delta = json?.choices?.[0]?.delta?.content if (typeof delta === 'string' && delta.length) opts.onDelta(delta) } catch { // ignore } } } } function getTier(subscription: any): 'scout' | 'trader' | 'tycoon' { const t = (subscription?.tier || '').toLowerCase() if (t === 'trader') return 'trader' if (t === 'tycoon') return 'tycoon' return 'scout' } // Simple markdown-like formatting to clean HTML function formatMessage(text: string): string { if (!text) return '' let html = text // Escape HTML .replace(/&/g, '&') .replace(//g, '>') // Bold: **text** or __text__ .replace(/\*\*(.+?)\*\*/g, '$1') .replace(/__(.+?)__/g, '$1') // Italic: *text* or _text_ (but not inside words) .replace(/(?$1') .replace(/(?$1') // Inline code: `code` .replace(/`([^`]+)`/g, '$1') // Line breaks .replace(/\n/g, '
') // Bullet points: - item or • item .replace(/
[-•]\s+/g, '
• ') return html } // Suggestion chips based on current page function getSuggestions(path: string): Array<{ label: string; prompt: string }> { const p = (path || '').split('?')[0] if (p.startsWith('/terminal/hunt')) { return [ { label: 'Ending soon', prompt: 'Show auctions ending in the next 2 hours' }, { label: 'Find brandables', prompt: 'Generate 10 available 5-letter brandable .com domains' }, { label: 'Trending now', prompt: 'What keywords are trending today?' }, ] } if (p.startsWith('/terminal/market')) { return [ { label: 'Best deals', prompt: 'Show the best deals under $100' }, { label: 'Ending today', prompt: 'What auctions are ending today?' }, ] } if (p.startsWith('/terminal/watchlist')) { return [ { label: 'My watchlist', prompt: 'Show my watchlist' }, { label: 'Analyze top', prompt: 'Analyze my most recently added domain' }, ] } if (p.startsWith('/terminal/portfolio')) { return [ { label: 'Portfolio ROI', prompt: 'What is my overall portfolio ROI?' }, { label: 'Renewals', prompt: 'Which domains are up for renewal soon?' }, { label: 'Top performers', prompt: 'Show my top 5 domains by value' }, ] } if (p.startsWith('/terminal/sniper')) { return [ { label: 'My alerts', prompt: 'Show my sniper alerts' }, { label: 'Matching now', prompt: 'Are there auctions matching my alerts right now?' }, ] } if (p.startsWith('/terminal/listing')) { return [ { label: 'My listings', prompt: 'Show my active listings' }, { label: 'New leads', prompt: 'Do I have any new leads?' }, ] } if (p.startsWith('/terminal/inbox')) { return [ { label: 'Unread count', prompt: 'How many unread messages do I have?' }, { label: 'Recent threads', prompt: 'Show my recent inquiry threads' }, ] } if (p.startsWith('/terminal/yield')) { return [ { label: 'Yield stats', prompt: 'Show my yield dashboard stats' }, { label: 'Top earners', prompt: 'Which domains are earning the most?' }, ] } // Default return [ { label: 'Market snapshot', prompt: 'Give me a quick market snapshot' }, { label: 'Analyze domain', prompt: 'Analyze ' }, ] } // Teaser examples for Scout const TEASER_EXAMPLES = [ { icon: Search, text: '"Analyze startup.io — should I buy?"' }, { icon: TrendingUp, text: '"What auctions are ending in 2 hours?"' }, { icon: Briefcase, text: '"What\'s my portfolio ROI?"' }, { icon: Target, text: '"Find 5-letter .com brandables"' }, { icon: Zap, text: '"Any new leads on my listings?"' }, ] export function HunterCompanion() { const pathname = usePathname() const { subscription, user } = useStore() const tier = getTier(subscription) const canChat = tier === 'trader' || tier === 'tycoon' const storageKey = useMemo(() => { const uidPart = user?.id ? String(user.id) : 'anon' return `pounce:hunter_companion:v2:${uidPart}` }, [user?.id]) const [open, setOpen] = useState(false) const [input, setInput] = useState('') const [messages, setMessages] = useState([]) const [sending, setSending] = useState(false) const listRef = useRef(null) const inputRef = useRef(null) // Load from localStorage useEffect(() => { if (!canChat) return try { const raw = localStorage.getItem(storageKey) if (raw) { const parsed = JSON.parse(raw) if (Array.isArray(parsed)) setMessages(parsed) } } catch { // ignore } }, [storageKey, canChat]) // Save to localStorage useEffect(() => { if (!canChat) return try { localStorage.setItem(storageKey, JSON.stringify(messages.slice(-60))) } catch { // ignore } }, [messages, storageKey, canChat]) // Auto-scroll useEffect(() => { if (!open) return const el = listRef.current if (el) el.scrollTop = el.scrollHeight }, [open, messages.length]) // Focus input useEffect(() => { if (open && canChat) { setTimeout(() => inputRef.current?.focus(), 50) } }, [open, canChat]) // Only show in terminal if (!pathname?.startsWith('/terminal')) return null const send = async (text?: string) => { const msg = (text || input).trim() if (!msg || sending || !canChat) return const userMsg: ChatMessage = { id: uid(), role: 'user', content: msg, createdAt: Date.now() } const assistantId = uid() setMessages((prev) => [...prev, userMsg, { id: assistantId, role: 'assistant', content: '', createdAt: Date.now() }]) setInput('') setSending(true) const history = messages.slice(-20).map((m) => ({ role: m.role as Role, content: m.content })) try { await streamChat({ messages: [...history, { role: 'user', content: msg }], path: pathname || '/terminal/hunt', onDelta: (delta) => { setMessages((prev) => prev.map((m) => (m.id === assistantId ? { ...m, content: (m.content || '') + delta } : m)) ) }, }) } catch (e: any) { setMessages((prev) => prev.map((m) => m.id === assistantId ? { ...m, content: `Error: ${e?.message || 'Request failed'}` } : m ) ) } finally { setSending(false) setTimeout(() => inputRef.current?.focus(), 0) } } const clear = () => { setMessages([]) try { localStorage.removeItem(storageKey) } catch { // ignore } } const suggestions = getSuggestions(pathname || '/terminal/hunt') return ( <> {/* FAB */} {/* Panel */} {open && (
setOpen(false)} />
{/* Header */}
{canChat ? (
) : ( )} Hunter Companion {!canChat && ( LOCKED )}
{canChat ? 'Domain trading AI assistant' : 'Upgrade to unlock'}
{canChat && messages.length > 0 && ( )}
{/* Body */}
{!canChat ? ( /* Scout Teaser */

AI Trading Assistant

Get instant domain analysis, market insights, portfolio advice, and deal recommendations.

{TEASER_EXAMPLES.map((ex, i) => (
{ex.text}
))}
Upgrade to Trader

Available on Trader & Tycoon plans

) : messages.length === 0 ? ( /* Empty state for Trader/Tycoon */

Ready to hunt

Ask me anything about domains, auctions, or your portfolio.

{/* Quick suggestions */}
{suggestions.map((s, i) => ( ))}
) : ( /* Chat messages */
{messages.map((m) => (
{m.role === 'assistant' ? (
) : ( {m.content} )}
))} {/* Suggestion chips after messages */} {!sending && messages.length > 0 && (
{suggestions.slice(0, 2).map((s, i) => ( ))}
)}
)}
{/* Input (only for Trader/Tycoon) */} {canChat && (
setInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() send() } }} placeholder="Ask about domains, auctions, portfolio…" className="flex-1 px-3 py-2.5 bg-white/[0.03] border border-white/[0.08] text-white text-xs font-mono outline-none focus:border-accent/30 placeholder:text-white/25" disabled={sending} />
)}
)} {/* Styles for formatted messages */} ) }