Files
SanatiLeads/app/(app)/leads/page.tsx
2026-02-04 01:05:00 +00:00

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>
)
}