feat: Implement core application structure with new dashboard, settings, and help pages, and enhance opportunities management with persistence and filtering.

This commit is contained in:
2026-02-03 20:05:30 +00:00
parent 609b9da020
commit 885bbbf954
21 changed files with 1282 additions and 106 deletions

View File

@@ -1,12 +1,15 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, 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 { Separator } from '@/components/ui/separator'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
@@ -47,6 +50,7 @@ import {
Eye,
Copy
} from 'lucide-react'
import { useProject } from '@/components/project-context'
import type {
EnhancedProductAnalysis,
Opportunity,
@@ -66,6 +70,9 @@ const STRATEGY_INFO: Record<SearchStrategy, { name: string; description: string
export default function OpportunitiesPage() {
const router = useRouter()
const { selectedProjectId } = useProject()
const upsertOpportunities = useMutation(api.opportunities.upsertBatch)
const updateOpportunity = useMutation(api.opportunities.updateStatus)
const [analysis, setAnalysis] = useState<EnhancedProductAnalysis | null>(null)
const [platforms, setPlatforms] = useState<PlatformConfig[]>([])
const [strategies, setStrategies] = useState<SearchStrategy[]>([
@@ -81,6 +88,54 @@ export default function OpportunitiesPage() {
const [selectedOpportunity, setSelectedOpportunity] = useState<Opportunity | null>(null)
const [replyText, setReplyText] = useState('')
const [stats, setStats] = useState<any>(null)
const [searchError, setSearchError] = useState('')
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 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])
useEffect(() => {
const stored = localStorage.getItem('productAnalysis')
@@ -121,9 +176,11 @@ export default function OpportunitiesPage() {
const executeSearch = async () => {
if (!analysis) return
if (!selectedProjectId) return
setIsSearching(true)
setOpportunities([])
setSearchError('')
try {
const config = {
@@ -147,12 +204,34 @@ export default function OpportunitiesPage() {
const data = await response.json()
if (data.success) {
setOpportunities(data.data.opportunities)
const mapped = data.data.opportunities.map((opp: Opportunity) => ({
...opp,
status: 'new',
}))
setOpportunities(mapped)
setGeneratedQueries(data.data.queries)
setStats(data.data.stats)
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,
})),
})
}
} catch (error) {
} catch (error: any) {
console.error('Search error:', error)
setSearchError(error?.message || 'Search failed. Please try again.')
} finally {
setIsSearching(false)
}
@@ -174,6 +253,30 @@ export default function OpportunitiesPage() {
}
}
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
return (
@@ -280,6 +383,11 @@ export default function OpportunitiesPage() {
</div>
)}
</div>
{searchError && (
<Alert variant="destructive">
<AlertDescription>{searchError}</AlertDescription>
</Alert>
)}
{/* Generated Queries */}
{generatedQueries.length > 0 && (
@@ -307,20 +415,72 @@ export default function OpportunitiesPage() {
)}
{/* Results Table */}
{opportunities.length > 0 ? (
{displayOpportunities.length > 0 ? (
<Card>
<div className="flex flex-wrap items-center gap-3 border-b border-border p-4">
<div className="flex items-center gap-2">
<Label className="text-xs text-muted-foreground">Status</Label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
>
<option value="all">All</option>
<option value="new">New</option>
<option value="viewed">Viewed</option>
<option value="contacted">Contacted</option>
<option value="responded">Responded</option>
<option value="converted">Converted</option>
<option value="ignored">Ignored</option>
</select>
</div>
<div className="flex items-center gap-2">
<Label className="text-xs text-muted-foreground">Intent</Label>
<select
value={intentFilter}
onChange={(e) => setIntentFilter(e.target.value)}
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
>
<option value="all">All</option>
<option value="frustrated">Frustrated</option>
<option value="comparing">Comparing</option>
<option value="learning">Learning</option>
<option value="recommending">Recommending</option>
<option value="looking">Looking</option>
</select>
</div>
<div className="flex items-center gap-2">
<Label className="text-xs text-muted-foreground">Min Score</Label>
<input
type="number"
min={0}
max={100}
value={minScore}
onChange={(e) => setMinScore(Number(e.target.value))}
className="h-8 w-20 rounded-md border border-input bg-background px-2 text-xs"
/>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setLimit((prev) => prev + 50)}
>
Load more
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Platform</TableHead>
<TableHead>Intent</TableHead>
<TableHead>Score</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-1/2">Post</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{opportunities.slice(0, 50).map((opp) => (
{displayOpportunities.slice(0, limit).map((opp) => (
<TableRow key={opp.id}>
<TableCell><Badge variant="outline">{opp.platform}</Badge></TableCell>
<TableCell>
@@ -334,6 +494,11 @@ export default function OpportunitiesPage() {
{Math.round(opp.relevanceScore * 100)}%
</Badge>
</TableCell>
<TableCell>
<Badge variant="secondary" className="capitalize">
{opp.status || 'new'}
</Badge>
</TableCell>
<TableCell>
<p className="font-medium line-clamp-1">{opp.title}</p>
<p className="text-sm text-muted-foreground line-clamp-2">{opp.snippet}</p>
@@ -387,6 +552,43 @@ export default function OpportunitiesPage() {
</DialogHeader>
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Status</Label>
<select
value={statusInput}
onChange={(e) => setStatusInput(e.target.value)}
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
>
<option value="new">New</option>
<option value="viewed">Viewed</option>
<option value="contacted">Contacted</option>
<option value="responded">Responded</option>
<option value="converted">Converted</option>
<option value="ignored">Ignored</option>
</select>
</div>
<div className="space-y-2">
<Label>Tags (comma separated)</Label>
<input
value={tagsInput}
onChange={(e) => setTagsInput(e.target.value)}
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
placeholder="reddit, high-intent"
/>
</div>
</div>
<div className="space-y-2">
<Label>Notes</Label>
<Textarea
value={notesInput}
onChange={(e) => setNotesInput(e.target.value)}
placeholder="Add notes about this lead..."
rows={3}
/>
</div>
{selectedOpportunity.matchedKeywords.length > 0 && (
<div>
<Label className="text-sm text-muted-foreground">Matched Keywords</Label>
@@ -425,8 +627,11 @@ export default function OpportunitiesPage() {
<Button variant="outline" onClick={() => window.open(selectedOpportunity.url, '_blank')}>
<ExternalLink className="h-4 w-4 mr-2" /> View Post
</Button>
<Button onClick={() => setSelectedOpportunity(null)}>
<CheckCircle2 className="h-4 w-4 mr-2" /> Mark as Viewed
<Button onClick={async () => {
await handleSaveOpportunity()
setSelectedOpportunity(null)
}}>
<CheckCircle2 className="h-4 w-4 mr-2" /> Save Updates
</Button>
</DialogFooter>
</>