305 lines
12 KiB
TypeScript
305 lines
12 KiB
TypeScript
'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>
|
|
)
|
|
}
|