a
This commit is contained in:
304
app/(app)/leads/page.tsx
Normal file
304
app/(app)/leads/page.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery, useMutation } from 'convex/react'
|
||||
import { api } from '@/convex/_generated/api'
|
||||
import { useProject } from '@/components/project-context'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { ExternalLink, Mail, Tag, Target } from 'lucide-react'
|
||||
type Lead = {
|
||||
_id: string
|
||||
title: string
|
||||
url: string
|
||||
snippet: string
|
||||
platform: string
|
||||
relevanceScore: number
|
||||
intent: string
|
||||
status?: string
|
||||
matchedKeywords: string[]
|
||||
matchedProblems: string[]
|
||||
suggestedApproach: string
|
||||
softPitch: boolean
|
||||
createdAt: number
|
||||
notes?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export default function LeadsPage() {
|
||||
const { selectedProjectId } = useProject()
|
||||
const leads = useQuery(
|
||||
api.opportunities.listByProject,
|
||||
selectedProjectId
|
||||
? {
|
||||
projectId: selectedProjectId as any,
|
||||
limit: 200,
|
||||
}
|
||||
: "skip"
|
||||
)
|
||||
const updateOpportunity = useMutation(api.opportunities.updateStatus)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [notes, setNotes] = useState('')
|
||||
const [tags, setTags] = useState('')
|
||||
const [sourceFilter, setSourceFilter] = useState('all')
|
||||
const [ageFilter, setAgeFilter] = useState('all')
|
||||
|
||||
const sortedLeads = useMemo<Lead[]>(() => {
|
||||
if (!leads) return []
|
||||
const normalized = leads as Lead[]
|
||||
const now = Date.now()
|
||||
const ageLimit =
|
||||
ageFilter === '24h'
|
||||
? now - 24 * 60 * 60 * 1000
|
||||
: ageFilter === '7d'
|
||||
? now - 7 * 24 * 60 * 60 * 1000
|
||||
: ageFilter === '30d'
|
||||
? now - 30 * 24 * 60 * 60 * 1000
|
||||
: null
|
||||
const filtered = normalized.filter((lead) => {
|
||||
if (sourceFilter !== 'all' && lead.platform !== sourceFilter) return false
|
||||
if (ageLimit && lead.createdAt < ageLimit) return false
|
||||
return true
|
||||
})
|
||||
return [...filtered].sort((a, b) => b.createdAt - a.createdAt)
|
||||
}, [leads, sourceFilter, ageFilter])
|
||||
|
||||
const sourceOptions = useMemo(() => {
|
||||
if (!leads) return []
|
||||
const set = new Set((leads as Lead[]).map((lead) => lead.platform))
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b))
|
||||
}, [leads])
|
||||
|
||||
const selectedLead = useMemo<Lead | null>(() => {
|
||||
if (!sortedLeads.length) return null
|
||||
const found = sortedLeads.find((lead) => lead._id === selectedId)
|
||||
return found ?? sortedLeads[0]
|
||||
}, [sortedLeads, selectedId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedLead) return
|
||||
setNotes(selectedLead.notes || '')
|
||||
setTags(selectedLead.tags?.join(', ') || '')
|
||||
}, [selectedLead])
|
||||
|
||||
const handleSelect = (lead: Lead) => {
|
||||
setSelectedId(lead._id)
|
||||
setNotes(lead.notes || '')
|
||||
setTags(lead.tags?.join(', ') || '')
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedLead) return
|
||||
const tagList = tags
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean)
|
||||
await updateOpportunity({
|
||||
id: selectedLead._id as any,
|
||||
status: selectedLead.status || 'new',
|
||||
notes: notes || undefined,
|
||||
tags: tagList.length ? tagList : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<div className="w-[420px] border-r border-border bg-card">
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
Inbox
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Newest opportunities ready for outreach.</p>
|
||||
</div>
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<div className="grid gap-3">
|
||||
<div>
|
||||
<Label className="text-xs uppercase text-muted-foreground">Source</Label>
|
||||
<select
|
||||
value={sourceFilter}
|
||||
onChange={(event) => setSourceFilter(event.target.value)}
|
||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-2 text-sm"
|
||||
>
|
||||
<option value="all">All sources</option>
|
||||
{sourceOptions.map((source) => (
|
||||
<option key={source} value={source}>
|
||||
{source}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs uppercase text-muted-foreground">Age</Label>
|
||||
<select
|
||||
value={ageFilter}
|
||||
onChange={(event) => setAgeFilter(event.target.value)}
|
||||
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-2 text-sm"
|
||||
>
|
||||
<option value="all">Any time</option>
|
||||
<option value="24h">Last 24 hours</option>
|
||||
<option value="7d">Last 7 days</option>
|
||||
<option value="30d">Last 30 days</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="h-[calc(100vh-128px)]">
|
||||
<div className="space-y-2 p-4">
|
||||
{sortedLeads.length === 0 && (
|
||||
<Card className="p-6 text-center text-sm text-muted-foreground">
|
||||
No leads yet. Run a search to populate the inbox.
|
||||
</Card>
|
||||
)}
|
||||
{sortedLeads.map((lead) => (
|
||||
<button
|
||||
key={lead._id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(lead as Lead)}
|
||||
className={`w-full rounded-lg border px-3 py-3 text-left transition ${
|
||||
selectedLead?._id === lead._id
|
||||
? "border-foreground/40 bg-muted/50"
|
||||
: "border-border/60 hover:border-foreground/30 hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-semibold line-clamp-1">{lead.title}</div>
|
||||
<Badge variant="outline" className="text-[10px] uppercase">
|
||||
{lead.platform}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground line-clamp-2">{lead.snippet}</p>
|
||||
<div className="mt-2 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<Target className="h-3 w-3" />
|
||||
<span className="capitalize">{lead.intent}</span>
|
||||
<span>•</span>
|
||||
<span>{Math.round(lead.relevanceScore * 100)}% match</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="border-b border-border px-6 py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold">Lead</div>
|
||||
<p className="text-xs text-muted-foreground">Review the thread before reaching out.</p>
|
||||
</div>
|
||||
{selectedLead && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(selectedLead.url, '_blank')}
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open Source
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 px-6 py-6">
|
||||
{selectedLead ? (
|
||||
<>
|
||||
<Card className="p-6">
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge className="bg-muted text-foreground">
|
||||
{Math.round(selectedLead.relevanceScore * 100)}% match
|
||||
</Badge>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{selectedLead.intent}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="capitalize">
|
||||
{selectedLead.status || 'new'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xl font-semibold">{selectedLead.title}</div>
|
||||
<p className="text-sm text-muted-foreground">{selectedLead.snippet}</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="text-xs uppercase text-muted-foreground">Keyword Matches</Label>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{selectedLead.matchedKeywords.map((keyword, index) => (
|
||||
<Badge key={`${keyword}-${index}`} variant="secondary">
|
||||
{keyword}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs uppercase text-muted-foreground">Problem Matches</Label>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{selectedLead.matchedProblems.map((problem, index) => (
|
||||
<Badge key={`${problem}-${index}`} variant="outline">
|
||||
{problem}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="text-xs uppercase text-muted-foreground">Suggested Outreach</Label>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{selectedLead.suggestedApproach}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs uppercase text-muted-foreground">Outreach Tone</Label>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{selectedLead.softPitch ? 'Use a softer, story-led opener.' : 'Lead with a direct solution.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 space-y-4">
|
||||
<div>
|
||||
<Label className="text-xs uppercase text-muted-foreground">Notes</Label>
|
||||
<Textarea
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
rows={4}
|
||||
className="mt-2"
|
||||
placeholder="Add notes about this lead..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs uppercase text-muted-foreground">Tags</Label>
|
||||
<div className="relative mt-2">
|
||||
<Tag className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
value={tags}
|
||||
onChange={(event) => setTags(event.target.value)}
|
||||
className="h-10 w-full rounded-md border border-input bg-background pl-10 pr-3 text-sm"
|
||||
placeholder="e.g. high intent, quick win"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave}>Save Notes</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<Card className="p-10 text-center text-sm text-muted-foreground">
|
||||
Select a lead to view the details.
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user