'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, MessageSquare, Twitter, Globe, Users, HelpCircle, Filter, ChevronDown, ChevronUp, Target, Zap, AlertCircle, BarChart3, CheckCircle2, Eye, 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"], }, ] 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 [minScore, setMinScore] = useState(0) const [limit, setLimit] = useState(50) const [statusInput, setStatusInput] = useState('new') const [notesInput, setNotesInput] = useState('') const [tagsInput, setTagsInput] = useState('') 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, minScore: minScore > 0 ? minScore / 100 : undefined, 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(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.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, platformIds: platforms.filter((platform) => platform.enabled).map((platform) => platform.id), } 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 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 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 Plan

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

{/* Goal presets */}
{GOAL_PRESETS.map((preset) => ( ))}
{/* Platforms */}
{platforms.map(platform => { const isEnabled = platform.enabled const iconMap: Record = { reddit: , twitter: , hackernews: , indiehackers: , quora: , stackoverflow: , linkedin: } return ( ) })}
{/* Strategies */}
{STRATEGY_GROUPS.map((group) => (
{group.title}
{group.description}
{group.strategies.map((strategy) => (
toggleStrategy(strategy)} />

{STRATEGY_INFO[strategy].description}

))}
))}
{/* Queries */}
Max Queries {maxQueries}
setMaxQueries(v)} min={10} max={50} step={5} />
Up to {maxQueries} queries · est. {estimatedMinutes} min
{enabledPlatforms.length || 0} channels · {strategies.length} signals · max {maxQueries} queries
{platforms.filter(p => p.enabled).length === 0 && (

Select at least one platform to search.

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

Select data sources to build search context.

)}
{/* Main Content */}
{/* Header */}

Opportunity Finder

Discover potential customers for {analysis.productName}

{stats && (
{stats.opportunitiesFound}
Found
{stats.highRelevance}
High Quality
)}
Goal: {GOAL_PRESETS.find((preset) => preset.id === goalPreset)?.title || "Custom"} Max queries: {maxQueries}
{latestJob && (latestJob.status === "running" || latestJob.status === "pending") && ( Search in progress Current job: {latestJob.status}
{typeof latestJob.progress === "number" ? `${latestJob.progress}% complete` : "Starting..."}
)} {latestJob && latestJob.status === "failed" && ( Search failed: {latestJob.error || "Unknown error"}.{" "} )} {searchError && ( {searchError} {searchError.includes('SERPER_API_KEY') && ( Add `SERPER_API_KEY` to your environment and restart the app to enable search. )} )} {/* Active Sources */} {activeSources.length > 0 && ( Active Data Sources Sources selected for this project will drive opportunity search. {activeSources.map((source: any) => ( {source.name || source.url} ))} )} {selectedSources && selectedSourceIds.length === 0 && ( No data sources selected. Add and select sources to generate opportunities. )} {missingSources.length > 0 && ( Some selected sources don't have analysis yet:{" "} {missingSources .map((missing) => activeSources.find((source: any) => source._id === missing.sourceId)?.name || activeSources.find((source: any) => source._id === missing.sourceId)?.url || missing.sourceId ) .join(", ")} . Run onboarding or re-analyze them for best results. )} {/* Generated Queries */} {generatedQueries.length > 0 && (
Generated Queries ({generatedQueries.length}) {showQueries ? : }
{generatedQueries.slice(0, 20).map((q, i) => (
{q.query}
))}
)} {/* Results Table */} {displayOpportunities.length > 0 ? (
setMinScore(Number(e.target.value))} className="h-8 w-20 rounded-md border border-input bg-background px-2 text-xs" />
Platform Intent Score Status Post Actions {displayOpportunities.slice(0, limit).map((opp) => ( {opp.platform}
{getIntentIcon(opp.intent)} {opp.intent}
= 0.8 ? 'bg-green-500/20 text-green-400' : opp.relevanceScore >= 0.6 ? 'bg-amber-500/20 text-amber-400' : 'bg-red-500/20 text-red-400'}> {Math.round(opp.relevanceScore * 100)}% {opp.status || 'new'}

{opp.title}

{opp.snippet}

))}
) : isSearching ? (

Searching...

Scanning platforms for opportunities

) : (

Ready to Search

Select platforms and strategies, then click Find Opportunities

)}
{/* Detail Dialog */} setSelectedOpportunity(null)}> {selectedOpportunity && ( <>
= 0.8 ? 'bg-green-500/20 text-green-400' : selectedOpportunity.relevanceScore >= 0.6 ? 'bg-amber-500/20 text-amber-400' : 'bg-red-500/20 text-red-400'}> {Math.round(selectedOpportunity.relevanceScore * 100)}% Match {selectedOpportunity.platform} {selectedOpportunity.intent}
{selectedOpportunity.title} {selectedOpportunity.snippet}
setTagsInput(e.target.value)} className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm" placeholder="reddit, high-intent" />