pounce/frontend/src/components/hunt/TrendSurferTab.tsx
Yves Gugger 460074d01f
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
Hunt Module UI Optimization: unified award-winning design
- Unified table headers, rows, and action alignments across all tabs
- Enhanced SearchTab: improved result display and global TLD grid
- Refined DropsTab: standardized table layouts and mobile views
- Optimized AuctionsTab: improved column alignment and source badges
- Modernized TrendSurfer: better viral topics grid and keyword builder
- Polished BrandableForge: refined mode selectors and synthesis config
- Standardized all borders, backgrounds, and spacing for Terminal v1.0
- All UI text in English with enhanced tracking and monospaced typography
2025-12-18 09:30:50 +01:00

437 lines
19 KiB
TypeScript

'use client'
import { useCallback, useEffect, useState } from 'react'
import clsx from 'clsx'
import {
Loader2,
Shield,
Eye,
RefreshCw,
Zap,
Check,
Copy,
ShoppingCart,
Flame,
Sparkles,
Lock,
Globe,
X,
ChevronDown,
} from 'lucide-react'
import Link from 'next/link'
import { api } from '@/lib/api'
import { useAnalyzePanelStore } from '@/lib/analyze-store'
import { useStore } from '@/lib/store'
// ============================================================================
// CONSTANTS
// ============================================================================
const GEOS = [
{ code: 'US', flag: '🇺🇸', name: 'USA' },
{ code: 'DE', flag: '🇩🇪', name: 'Germany' },
{ code: 'GB', flag: '🇬🇧', name: 'UK' },
{ code: 'CH', flag: '🇨🇭', name: 'Switzerland' },
{ code: 'FR', flag: '🇫🇷', name: 'France' },
]
const TLDS = ['com', 'io', 'ai', 'co', 'net', 'app']
// ============================================================================
// COMPONENT
// ============================================================================
export function TrendSurferTab({ showToast }: { showToast: (msg: 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'
// State
const [geo, setGeo] = useState('US')
const [loading, setLoading] = useState(true)
const [trends, setTrends] = useState<Array<{ title: string; approx_traffic?: string | null }>>([])
const [selected, setSelected] = useState<string | null>(null)
const [tlds, setTlds] = useState(['com', 'io', 'ai'])
// Keywords to check (original + AI expanded)
const [keywords, setKeywords] = useState<string[]>([])
const [aiLoading, setAiLoading] = useState(false)
// Results
const [results, setResults] = useState<Array<{ domain: string; available: boolean }>>([])
const [checking, setChecking] = useState(false)
const [copied, setCopied] = useState<string | null>(null)
const [tracking, setTracking] = useState<string | null>(null)
// Load trends
const loadTrends = useCallback(async () => {
setLoading(true)
try {
const res = await api.getHuntTrends(geo)
setTrends(res.items || [])
} catch (e) {
showToast('Failed to load trends', 'error')
} finally {
setLoading(false)
}
}, [geo, showToast])
useEffect(() => {
loadTrends()
}, [loadTrends])
// When a trend is selected, set the base keyword and auto-expand with AI if available
const selectTrend = useCallback(async (trend: string) => {
const baseKeyword = trend.toLowerCase().replace(/\s+/g, '')
setSelected(trend)
setKeywords([baseKeyword])
setResults([])
// Auto-expand with AI if available
if (hasAI) {
setAiLoading(true)
try {
const res = await api.expandTrendKeywords(trend, geo)
if (res.keywords?.length) {
// Combine base + AI keywords, remove duplicates
const all = [baseKeyword, ...res.keywords.filter(k => k !== baseKeyword)]
setKeywords(all.slice(0, 8)) // Max 8 keywords
}
} catch (e) {
// Silent fail for AI
} finally {
setAiLoading(false)
}
}
}, [geo, hasAI])
// Check availability for all keywords
const checkAvailability = useCallback(async () => {
if (keywords.length === 0 || tlds.length === 0) return
setChecking(true)
setResults([])
try {
const res = await api.huntKeywords({ keywords, tlds })
setResults(res.items.map(i => ({ domain: i.domain, available: i.status === 'available' })))
const avail = res.items.filter(i => i.status === 'available').length
showToast(`Found ${avail} available domains!`, 'success')
} catch (e) {
showToast('Check failed', 'error')
} finally {
setChecking(false)
}
}, [keywords, tlds, showToast])
// Remove a keyword
const removeKeyword = (kw: string) => {
setKeywords(prev => prev.filter(k => k !== kw))
}
// Add custom keyword
const [customKw, setCustomKw] = useState('')
const addKeyword = () => {
const kw = customKw.trim().toLowerCase().replace(/\s+/g, '')
if (kw && !keywords.includes(kw)) {
setKeywords(prev => [...prev, kw])
setCustomKw('')
}
}
// Actions
const copy = (domain: string) => {
navigator.clipboard.writeText(domain)
setCopied(domain)
setTimeout(() => setCopied(null), 1500)
}
const track = async (domain: string) => {
if (tracking) return
setTracking(domain)
try {
await addDomain(domain)
showToast(`Added: ${domain}`, 'success')
} catch (e) {
showToast('Failed to track', 'error')
} finally {
setTracking(null)
}
}
const availableResults = results.filter(r => r.available)
const takenResults = results.filter(r => !r.available)
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-5 border-b border-white/[0.08]">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-orange-500/10 border border-orange-500/20 flex items-center justify-center shrink-0">
<Flame className="w-6 h-6 text-orange-400" />
</div>
<div>
<h2 className="text-xl font-bold text-white font-mono tracking-tight">Trend Surfer</h2>
<p className="text-[10px] font-mono text-white/40 uppercase tracking-widest mt-1">Ride the viral wave with AI-powered domain hunt</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<select
value={geo}
onChange={(e) => { setGeo(e.target.value); setSelected(null); setKeywords([]); setResults([]) }}
className="h-10 pl-4 pr-10 bg-white/[0.02] border border-white/10 text-xs font-mono text-white uppercase tracking-widest appearance-none outline-none focus:border-accent/30 transition-all cursor-pointer"
>
{GEOS.map(g => (
<option key={g.code} value={g.code}>{g.flag} {g.name}</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-white/30 pointer-events-none" />
</div>
<button
onClick={loadTrends}
disabled={loading}
className="h-10 w-10 flex items-center justify-center border border-white/10 text-white/40 hover:text-white hover:bg-white/5 transition-all"
>
<RefreshCw className={clsx("w-4 h-4", loading && "animate-spin")} />
</button>
</div>
</div>
{/* Trends Grid */}
<div className="space-y-3">
<div className="flex items-center gap-2 px-1 text-[10px] font-mono text-white/30 uppercase tracking-[0.2em]">
<div className="w-1 h-1 bg-orange-500 rounded-full animate-pulse" />
<span>Real-time Viral Topics ({geo})</span>
</div>
{loading ? (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
{[...Array(12)].map((_, i) => (
<div key={i} className="h-14 bg-white/[0.01] border border-white/[0.05] animate-pulse" />
))}
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
{trends.slice(0, 12).map((t, idx) => {
const isSelected = selected === t.title
return (
<button
key={t.title}
onClick={() => selectTrend(t.title)}
className={clsx(
"relative px-4 py-3.5 text-left border transition-all duration-300 group overflow-hidden",
isSelected
? "border-accent bg-accent/10 shadow-[0_0_20px_-10px_rgba(34,211,126,0.3)]"
: "border-white/[0.08] bg-white/[0.01] hover:border-white/20 hover:bg-white/[0.03]"
)}
>
<div className="relative z-10 flex items-center justify-between gap-2">
<span className={clsx(
"text-xs font-bold font-mono truncate tracking-tight",
isSelected ? "text-accent" : "text-white/70 group-hover:text-white"
)}>
{t.title}
</span>
{idx < 3 && !isSelected && <Flame className="w-3 h-3 text-orange-500/40 group-hover:text-orange-500 transition-colors" />}
</div>
{isSelected && <div className="absolute top-0 right-0 w-1.5 h-1.5 bg-accent" />}
</button>
)
})}
</div>
)}
</div>
{/* Keyword Builder */}
{selected && (
<div className="border border-white/[0.08] bg-white/[0.01] overflow-hidden animate-in fade-in slide-in-from-top-4 duration-500">
<div className="px-5 py-4 border-b border-white/[0.08] flex items-center justify-between bg-white/[0.01]">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-accent/10 border border-accent/20 flex items-center justify-center">
<Sparkles className="w-4 h-4 text-accent" />
</div>
<div>
<div className="text-[10px] font-mono text-white/30 uppercase tracking-[0.2em]">Target Concept</div>
<div className="text-sm font-bold text-white uppercase tracking-wider">{selected}</div>
</div>
</div>
<button
onClick={() => { setSelected(null); setKeywords([]); setResults([]) }}
className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/30 hover:text-white hover:bg-white/5 transition-all"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="p-5 space-y-6">
{/* Keywords */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-mono text-white/30 uppercase tracking-widest">Constructed Keywords</span>
{aiLoading && (
<span className="flex items-center gap-2 text-[9px] font-mono text-purple-400 uppercase font-bold animate-pulse">
<Loader2 className="w-3 h-3 animate-spin" />
AI Expansion in progress...
</span>
)}
</div>
<div className="flex flex-wrap gap-2">
{keywords.map((kw, idx) => (
<span
key={kw}
className={clsx(
"inline-flex items-center gap-2 px-3 py-1.5 text-xs font-mono border transition-all",
idx === 0
? "bg-accent/10 border-accent/30 text-accent font-bold"
: "bg-purple-500/10 border-purple-500/20 text-purple-300"
)}
>
{kw}
<button onClick={() => removeKeyword(kw)} className="text-white/20 hover:text-rose-400 transition-colors">
<X className="w-3 h-3" />
</button>
</span>
))}
{/* Add custom keyword */}
<div className="relative">
<input
value={customKw}
onChange={(e) => setCustomKw(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addKeyword()}
placeholder="+ CUSTOM KW"
className="w-28 px-3 py-1.5 bg-white/[0.03] border border-white/10 text-[10px] font-mono text-white placeholder:text-white/20 outline-none focus:border-accent/30 uppercase tracking-wider transition-all"
/>
</div>
</div>
</div>
{/* TLDs */}
<div className="space-y-3">
<span className="text-[10px] font-mono text-white/30 uppercase tracking-widest">Selected Extensions</span>
<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-[10px] font-mono border transition-all uppercase tracking-widest",
tlds.includes(tld)
? "border-accent bg-accent/10 text-accent font-black"
: "border-white/10 text-white/40 hover:text-white hover:bg-white/5"
)}
>
.{tld}
</button>
))}
</div>
</div>
{/* Check Button */}
<button
onClick={checkAvailability}
disabled={checking || tlds.length === 0 || keywords.length === 0}
className={clsx(
"w-full py-4 text-[11px] font-black uppercase tracking-[0.2em] flex items-center justify-center gap-3 transition-all",
checking || tlds.length === 0 || keywords.length === 0
? "bg-white/5 text-white/20 border border-white/5"
: "bg-accent text-black hover:bg-white shadow-[0_0_30px_-10px_rgba(34,211,126,0.4)]"
)}
>
{checking ? <Loader2 className="w-4 h-4 animate-spin" /> : <Zap className="w-4 h-4" />}
Check {keywords.length * tlds.length} Variations
</button>
</div>
</div>
)}
{/* Results */}
{results.length > 0 && (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
{/* Available */}
{availableResults.length > 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between px-1">
<p className="text-[10px] font-mono text-accent uppercase tracking-[0.2em] font-black flex items-center gap-2">
<span className="w-1.5 h-1.5 bg-accent rounded-full animate-pulse" />
{availableResults.length} High-Potential Assets Identified
</p>
<div className="flex gap-4">
<button onClick={() => setResults(availableResults.map(r => ({ domain: r.domain, available: true })))} className="text-[9px] font-mono text-white/20 hover:text-accent uppercase tracking-widest transition-colors">
Copy List
</button>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{availableResults.map(r => (
<div key={r.domain} className="flex items-center justify-between p-4 bg-accent/[0.02] border border-accent/20 hover:border-accent/40 hover:bg-accent/[0.04] transition-all group">
<button
onClick={() => openAnalyze(r.domain)}
className="text-sm font-bold font-mono text-white group-hover:text-accent truncate tracking-tight transition-colors"
>
{r.domain}
</button>
<div className="flex items-center gap-1.5 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity">
<button onClick={() => copy(r.domain)} className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/40 hover:text-white hover:bg-white/5" title="Copy">
{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 border border-white/10 text-white/40 hover:text-white hover:bg-white/5" title="Track">
{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 border border-white/10 text-white/40 hover:text-accent hover:border-accent/20 hover:bg-accent/5" title="Analyze">
<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-black uppercase tracking-widest flex items-center gap-1.5 hover:bg-white"
>
Buy
</a>
</div>
</div>
))}
</div>
</div>
)}
{/* Taken (collapsed) */}
{takenResults.length > 0 && (
<details className="group">
<summary className="text-[10px] font-mono text-white/20 uppercase tracking-[0.2em] cursor-pointer flex items-center gap-2 py-3 hover:text-white/40 transition-colors list-none border-t border-white/[0.04]">
<ChevronDown className="w-3 h-3 group-open:rotate-180 transition-transform" />
{takenResults.length} Registered Variations
</summary>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2 mt-3 animate-in slide-in-from-top-2">
{takenResults.map(r => (
<div key={r.domain} className="flex items-center justify-between px-3 py-2 bg-white/[0.01] border border-white/[0.05] group">
<span className="text-[11px] font-mono text-white/20 truncate group-hover:text-white/40 transition-colors">{r.domain}</span>
<button onClick={() => openAnalyze(r.domain)} className="text-white/10 hover:text-accent transition-colors">
<Shield className="w-3 h-3" />
</button>
</div>
))}
</div>
</details>
)}
</div>
)}
{/* Empty State */}
{!selected && !loading && trends.length > 0 && (
<div className="text-center py-24 border border-dashed border-white/[0.08] bg-white/[0.01]">
<Globe className="w-12 h-12 text-white/5 mx-auto mb-4" />
<p className="text-white/40 text-sm font-mono uppercase tracking-widest font-bold">Select a trending topic above</p>
<p className="text-white/20 text-[10px] font-mono mt-3 uppercase tracking-wider max-w-xs mx-auto leading-relaxed">
Our engines will analyze the viral potential and suggest premium assets
</p>
</div>
)}
</div>
)
}