'use client' import { useEffect, useMemo, useRef, useState } from 'react' import { useRouter } from 'next/navigation' import { useMutation, useQuery } from 'convex/react' import { api } from '@/convex/_generated/api' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Checkbox } from '@/components/ui/checkbox' import { Slider } from '@/components/ui/slider' import { Alert, AlertDescription } from '@/components/ui/alert' import { Progress } from '@/components/ui/progress' import { Separator } from '@/components/ui/separator' import { ScrollArea } from '@/components/ui/scroll-area' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { Textarea } from '@/components/ui/textarea' import { Label } from '@/components/ui/label' import { Search, Loader2, ExternalLink, Globe, ChevronDown, ChevronUp, Settings, Target, AlertCircle, BarChart3, CheckCircle2, Eye, Plus, X, Users, Copy } from 'lucide-react' import { useProject } from '@/components/project-context' import type { EnhancedProductAnalysis, Opportunity, PlatformConfig, SearchStrategy, SearchConfig } from '@/lib/types' import { estimateSearchTime } from '@/lib/query-generator' const STRATEGY_INFO: Record = { 'direct-keywords': { name: 'Direct Keywords', description: 'People looking for your product category' }, 'problem-pain': { name: 'Problem/Pain', description: 'People experiencing problems you solve' }, 'competitor-alternative': { name: 'Competitor Alternatives', description: 'People switching from competitors' }, 'how-to': { name: 'How-To/Tutorials', description: 'People learning about solutions' }, 'emotional-frustrated': { name: 'Frustration Posts', description: 'Emotional posts about pain points' }, 'comparison': { name: 'Comparisons', description: '"X vs Y" comparison posts' }, 'recommendation': { name: 'Recommendations', description: '"What do you use" requests' } } const STRATEGY_GROUPS: { title: string; description: string; strategies: SearchStrategy[] }[] = [ { title: "High intent", description: "People actively looking or comparing options.", strategies: ["direct-keywords", "competitor-alternative", "comparison", "recommendation"], }, { title: "Problem-driven", description: "Pain and frustration expressed in public threads.", strategies: ["problem-pain", "emotional-frustrated"], }, { title: "Learning intent", description: "Educational and how-to discovery signals.", strategies: ["how-to"], }, ] function ChannelLogo({ id }: { id: string }) { const common = "h-4 w-4 text-foreground" const wrapper = "flex h-7 w-7 items-center justify-center rounded-md border border-border/60 bg-background" const icons: Record = { reddit: ( ), twitter: ( ), hackernews: ( ), indiehackers: ( ), quora: ( ), stackoverflow: ( ), linkedin: ( ), } const icon = icons[id] return (
{icon || }
) } const GOAL_PRESETS: { id: string title: string description: string strategies: SearchStrategy[] maxQueries: number }[] = [ { id: "high-intent", title: "High-intent leads", description: "Shortlist people actively searching to buy or switch.", strategies: ["direct-keywords", "competitor-alternative", "comparison", "recommendation"], maxQueries: 30, }, { id: "pain-first", title: "Problem pain", description: "Find people expressing frustration or blockers.", strategies: ["problem-pain", "emotional-frustrated"], maxQueries: 40, }, { id: "market-scan", title: "Market scan", description: "Broader sweep to map demand and platforms.", strategies: ["direct-keywords", "problem-pain", "how-to", "recommendation"], maxQueries: 50, }, ] export default function OpportunitiesPage() { const router = useRouter() const { selectedProjectId } = useProject() const upsertOpportunities = useMutation(api.opportunities.upsertBatch) const updateOpportunity = useMutation(api.opportunities.updateStatus) const createSearchJob = useMutation(api.searchJobs.create) const [analysis, setAnalysis] = useState(null) const [platforms, setPlatforms] = useState([]) const [strategies, setStrategies] = useState([ 'direct-keywords', 'problem-pain', 'competitor-alternative' ]) const [maxQueries, setMaxQueries] = useState(50) const [goalPreset, setGoalPreset] = useState('high-intent') const [isSearching, setIsSearching] = useState(false) const [opportunities, setOpportunities] = useState([]) const [generatedQueries, setGeneratedQueries] = useState([]) const [showQueries, setShowQueries] = useState(false) const [selectedOpportunity, setSelectedOpportunity] = useState(null) const [replyText, setReplyText] = useState('') const [stats, setStats] = useState(null) const [searchError, setSearchError] = useState('') const [missingSources, setMissingSources] = useState([]) const [lastSearchConfig, setLastSearchConfig] = useState(null) const [statusFilter, setStatusFilter] = useState('all') const [intentFilter, setIntentFilter] = useState('all') const [limit, setLimit] = useState(50) const [statusInput, setStatusInput] = useState('new') const [notesInput, setNotesInput] = useState('') const [tagsInput, setTagsInput] = useState('') const [showAdvanced, setShowAdvanced] = useState(false) const [showSourceDialog, setShowSourceDialog] = useState(false) const [customSourceName, setCustomSourceName] = useState('') const [customSourceSite, setCustomSourceSite] = useState('') const [customSourceTemplate, setCustomSourceTemplate] = useState('{site} {term} {intent}') const planLoadedRef = useRef(null) const defaultPlatformsRef = useRef(null) const projects = useQuery(api.projects.getProjects) const latestAnalysis = useQuery( api.analyses.getLatestByProject, selectedProjectId ? { projectId: selectedProjectId as any } : 'skip' ) const savedOpportunities = useQuery( api.opportunities.listByProject, selectedProjectId ? { projectId: selectedProjectId as any, status: statusFilter === 'all' ? undefined : statusFilter, intent: intentFilter === 'all' ? undefined : intentFilter, limit, } : 'skip' ) const displayOpportunities = useMemo(() => { if (!savedOpportunities || savedOpportunities.length === 0) return opportunities return savedOpportunities.map((opp: any) => ({ id: opp._id, title: opp.title, url: opp.url, snippet: opp.snippet, platform: opp.platform, source: opp.platform, relevanceScore: opp.relevanceScore, emotionalIntensity: 'low', intent: opp.intent, matchedKeywords: opp.matchedKeywords, matchedProblems: opp.matchedProblems, suggestedApproach: opp.suggestedApproach, softPitch: opp.softPitch, status: opp.status, notes: opp.notes, tags: opp.tags, })) as Opportunity[] }, [savedOpportunities, opportunities]) const selectedSources = useQuery( api.dataSources.getProjectDataSources, selectedProjectId ? { projectId: selectedProjectId as any } : "skip" ) const searchJobs = useQuery( api.searchJobs.listByProject, selectedProjectId ? { projectId: selectedProjectId as any } : "skip" ) const selectedProject = projects?.find((project: any) => project._id === selectedProjectId) const selectedSourceIds = selectedProject?.dorkingConfig?.selectedSourceIds || [] const activeSources = selectedSources?.filter((source: any) => selectedSourceIds.includes(source._id) ) || [] const enabledPlatforms = platforms.filter((platform) => platform.enabled) const estimatedMinutes = estimateSearchTime(Math.max(maxQueries, 1), enabledPlatforms.map((platform) => platform.id)) useEffect(() => { const stored = localStorage.getItem('productAnalysis') if (stored) { setAnalysis(JSON.parse(stored)) } fetch('/api/opportunities') .then(r => { if (r.redirected) { router.push('/auth?next=/opportunities') return null } return r.json() }) .then(data => { if (data) { setPlatforms((prev) => (prev.length > 0 ? prev : data.platforms)) defaultPlatformsRef.current = data.platforms } }) }, [router]) useEffect(() => { if (!selectedProjectId) return if (platforms.length === 0) return if (planLoadedRef.current === selectedProjectId) return const key = `searchPlan:${selectedProjectId}` const stored = localStorage.getItem(key) if (stored) { try { const parsed = JSON.parse(stored) if (Array.isArray(parsed.strategies)) { setStrategies(parsed.strategies) } if (typeof parsed.maxQueries === 'number') { setMaxQueries(Math.min(Math.max(parsed.maxQueries, 10), 50)) } if (typeof parsed.goalPreset === 'string') { setGoalPreset(parsed.goalPreset) } if (Array.isArray(parsed.platforms)) { setPlatforms(parsed.platforms) } else if (Array.isArray(parsed.platformIds)) { setPlatforms((prev) => prev.map((platform) => ({ ...platform, enabled: parsed.platformIds.includes(platform.id), })) ) } } catch { // Ignore invalid cached config. } } else if (defaultPlatformsRef.current) { setStrategies(['direct-keywords', 'problem-pain', 'competitor-alternative']) setMaxQueries(50) setGoalPreset('high-intent') setPlatforms(defaultPlatformsRef.current) } planLoadedRef.current = selectedProjectId }, [selectedProjectId, platforms]) useEffect(() => { if (!analysis && latestAnalysis) { setAnalysis(latestAnalysis as any) } }, [analysis, latestAnalysis]) useEffect(() => { if (!selectedProjectId) return if (planLoadedRef.current !== selectedProjectId) return const key = `searchPlan:${selectedProjectId}` const payload = { goalPreset, strategies, maxQueries, platforms, } localStorage.setItem(key, JSON.stringify(payload)) }, [selectedProjectId, goalPreset, strategies, maxQueries, platforms]) useEffect(() => { if (!analysis && latestAnalysis === null) { router.push('/onboarding') } }, [analysis, latestAnalysis, router]) const togglePlatform = (platformId: string) => { setPlatforms(prev => prev.map(p => p.id === platformId ? { ...p, enabled: !p.enabled } : p )) } const removeCustomSource = (platformId: string) => { setPlatforms(prev => prev.filter((platform) => platform.id !== platformId)) } const toggleStrategy = (strategy: SearchStrategy) => { setStrategies(prev => prev.includes(strategy) ? prev.filter(s => s !== strategy) : [...prev, strategy] ) } const applyGoalPreset = (presetId: string) => { const preset = GOAL_PRESETS.find((item) => item.id === presetId) if (!preset) return setGoalPreset(preset.id) setStrategies(preset.strategies) setMaxQueries(preset.maxQueries) } const addCustomSource = () => { const name = customSourceName.trim() const site = customSourceSite.trim().replace(/^https?:\/\//, '').replace(/\/.*$/, '') if (!name || !site) return const idSeed = `${name}-${site}`.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') const customId = `custom-${idSeed}-${Date.now().toString(36)}` setPlatforms((prev) => [ ...prev, { id: customId, name, icon: 'Globe', enabled: true, searchTemplate: customSourceTemplate.trim() || '{site} {term} {intent}', rateLimit: 20, site, custom: true, }, ]) setCustomSourceName('') setCustomSourceSite('') setCustomSourceTemplate('{site} {term} {intent}') setShowSourceDialog(false) } const executeSearch = async (overrideConfig?: SearchConfig) => { if (!analysis) return if (!selectedProjectId) return setIsSearching(true) setOpportunities([]) setSearchError('') try { const config = overrideConfig ?? { platforms: platforms.map((platform) => ({ ...platform, icon: platform.icon ?? "", searchTemplate: platform.searchTemplate ?? "", })), strategies, maxResults: Math.min(maxQueries, 50) } setLastSearchConfig(config as SearchConfig) const jobId = await createSearchJob({ projectId: selectedProjectId as any, config, }) const response = await fetch('/api/opportunities', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectId: selectedProjectId, config, jobId }) }) if (response.redirected) { router.push('/auth?next=/opportunities') return } const responseText = await response.text() const data = responseText ? JSON.parse(responseText) : null if (!response.ok) { throw new Error(data?.error || 'Failed to search for opportunities') } if (data?.success) { const mapped = data.data.opportunities.map((opp: Opportunity) => ({ ...opp, status: 'new', })) setOpportunities(mapped) setGeneratedQueries(data.data.queries) setStats(data.data.stats) setMissingSources(data.data.missingSources || []) await upsertOpportunities({ projectId: selectedProjectId as any, analysisId: latestAnalysis?._id, opportunities: mapped.map((opp: Opportunity) => ({ url: opp.url, platform: opp.platform, title: opp.title, snippet: opp.snippet, relevanceScore: opp.relevanceScore, intent: opp.intent, suggestedApproach: opp.suggestedApproach, matchedKeywords: opp.matchedKeywords, matchedProblems: opp.matchedProblems, softPitch: opp.softPitch, })), }) } else { throw new Error('Search returned no data') } } catch (error: any) { console.error('Search error:', error) setSearchError(error?.message || 'Search failed. Please try again.') } finally { setIsSearching(false) } } const generateReply = (opp: Opportunity) => { const template = opp.softPitch ? `Hey, I saw your post about ${opp.matchedProblems[0] || 'your challenge'}. We faced something similar and ended up building ${analysis?.productName} specifically for this. Happy to share what worked for us.` : `Hi! I noticed you're looking for solutions to ${opp.matchedProblems[0]}. I work on ${analysis?.productName} that helps teams with this - specifically ${opp.matchedKeywords.slice(0, 2).join(' and ')}. Would love to show you how it works.` setReplyText(template) } const getIntentIcon = (intent: string) => { switch (intent) { case 'frustrated': return case 'comparing': return case 'learning': return default: return } } useEffect(() => { if (selectedOpportunity) { setStatusInput(selectedOpportunity.status || 'new') setNotesInput(selectedOpportunity.notes || '') setTagsInput(selectedOpportunity.tags?.join(', ') || '') } }, [selectedOpportunity]) const handleSaveOpportunity = async () => { if (!selectedOpportunity?.id) return const tags = tagsInput .split(',') .map((tag) => tag.trim()) .filter(Boolean) await updateOpportunity({ id: selectedOpportunity.id as any, status: statusInput, notes: notesInput || undefined, tags: tags.length > 0 ? tags : undefined, }) } if (!analysis) return null const latestJob = searchJobs && searchJobs.length > 0 ? searchJobs[0] : null return (
{/* Sidebar */}

Search Setup

Pick a goal, tune sources, then run the scan.

{/* Platforms */}
{platforms.map(platform => { const isEnabled = platform.enabled return ( )}
) })}
Max Queries {maxQueries}
setMaxQueries(v)} min={10} max={50} step={5} />
Up to {maxQueries} queries · est. {estimatedMinutes} min
{enabledPlatforms.length || 0} sources · {strategies.length} triggers · max {maxQueries} queries
{platforms.filter(p => p.enabled).length === 0 && (

Select at least one source to search.

)} {selectedSourceIds.length === 0 && (

Select data sources to build search context.

)}
Advanced settings Fine-tune goals and triggers for this search.
{GOAL_PRESETS.map((preset) => ( ))}
{STRATEGY_GROUPS.map((group) => (
{group.title}
{group.description}
{group.strategies.map((strategy) => (
toggleStrategy(strategy)} />

{STRATEGY_INFO[strategy].description}

))}
))}
Add source Add a custom site to search.
setCustomSourceName(event.target.value)} className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm" placeholder="e.g. Product Hunt" />
setCustomSourceSite(event.target.value)} className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm" placeholder="producthunt.com" />