Add Lead Scoring ML + Email Sequences/Drip Campaigns
Build & Auto-Repair / build (push) Has been cancelled
Build & Auto-Repair / build (push) Has been cancelled
Lead Scoring:
- lead_scoring_config table with configurable feature weights
- Scoring engine: boolean, keyword, and range-based feature scoring
- Settings tab: configure scoring features, run scoring on all leads
- ml_score + ml_score_details columns on leads table
- API: /api/leads/score, /api/leads/score/config
- mlScore returned in lead detail and list APIs
Email Campaigns:
- email_campaigns, email_campaign_steps, subscribers, logs tables
- Campaign list page with status badges and subscriber counts
- Campaign detail with step management, subscriber search/add, send
- Drip logic: steps with configurable delay days, template variables
- Variable replacement ({{name}}, {{email}}) in email bodies
- Sidebar: Campaigns nav item
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { use } from "react"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
ArrowLeft, Plus, Trash2, Send, Play, Pause, Users, Mail, Clock,
|
||||
} from "lucide-react"
|
||||
|
||||
export default function CampaignDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const router = useRouter()
|
||||
const [campaign, setCampaign] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [newStep, setNewStep] = useState({ subject: "", bodyText: "", delayDays: 3 })
|
||||
const [addingStep, setAddingStep] = useState(false)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [leadSearch, setLeadSearch] = useState("")
|
||||
const [searchResults, setSearchResults] = useState<any[]>([])
|
||||
const [subscribing, setSubscribing] = useState(false)
|
||||
|
||||
const fetchCampaign = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/campaigns/${id}`)
|
||||
if (res.ok) setCampaign(await res.json())
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { fetchCampaign() }, [id])
|
||||
|
||||
const searchLeads = async (q: string) => {
|
||||
setLeadSearch(q)
|
||||
if (q.length < 2) { setSearchResults([]); return }
|
||||
const res = await fetch(`/api/leads?search=${encodeURIComponent(q)}&limit=10`)
|
||||
if (res.ok) setSearchResults(await res.json())
|
||||
}
|
||||
|
||||
const addStep = async () => {
|
||||
if (!newStep.subject.trim()) return
|
||||
setAddingStep(true)
|
||||
try {
|
||||
await fetch(`/api/campaigns/${id}/steps`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(newStep),
|
||||
})
|
||||
setNewStep({ subject: "", bodyText: "", delayDays: 3 })
|
||||
fetchCampaign()
|
||||
} catch { /* ignore */ }
|
||||
setAddingStep(false)
|
||||
}
|
||||
|
||||
const subscribeLeads = async (leadIds: string[]) => {
|
||||
setSubscribing(true)
|
||||
try {
|
||||
await fetch(`/api/campaigns/${id}/subscribe`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ leadIds }),
|
||||
})
|
||||
setLeadSearch("")
|
||||
setSearchResults([])
|
||||
fetchCampaign()
|
||||
} catch { /* ignore */ }
|
||||
setSubscribing(false)
|
||||
}
|
||||
|
||||
const sendCampaign = async () => {
|
||||
setSending(true)
|
||||
try {
|
||||
const res = await fetch(`/api/campaigns/${id}/send`, { method: "POST" })
|
||||
const data = await res.json()
|
||||
if (data.sent > 0) fetchCampaign()
|
||||
} catch { /* ignore */ }
|
||||
setSending(false)
|
||||
}
|
||||
|
||||
const updateStatus = async (status: string) => {
|
||||
await fetch(`/api/campaigns/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status }),
|
||||
})
|
||||
fetchCampaign()
|
||||
}
|
||||
|
||||
if (loading) return <div className="p-8 text-center text-muted-foreground">Loading...</div>
|
||||
if (!campaign) return <div className="p-8 text-center text-muted-foreground">Campaign not found</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={campaign.name}
|
||||
description={campaign.description || "Email campaign"}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => router.push("/campaigns")}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card className="p-4 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Status</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{campaign.status}</Badge>
|
||||
{campaign.status === "draft" && <Button size="sm" variant="outline" className="h-6 text-xs" onClick={() => updateStatus("active")}><Play className="h-3 w-3 mr-1" />Activate</Button>}
|
||||
{campaign.status === "active" && <Button size="sm" variant="outline" className="h-6 text-xs" onClick={() => updateStatus("paused")}><Pause className="h-3 w-3 mr-1" />Pause</Button>}
|
||||
{campaign.status === "paused" && <Button size="sm" variant="outline" className="h-6 text-xs" onClick={() => updateStatus("active")}><Play className="h-3 w-3 mr-1" />Resume</Button>}
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Subscribers</p>
|
||||
<p className="text-2xl font-bold flex items-center gap-2"><Users className="h-5 w-5 text-muted-foreground" />{campaign.subscribers?.length || 0}</p>
|
||||
</Card>
|
||||
<Card className="p-4 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Steps</p>
|
||||
<p className="text-2xl font-bold flex items-center gap-2"><Layers className="h-5 w-5 text-muted-foreground" />{campaign.steps?.length || 0}</p>
|
||||
</Card>
|
||||
<Card className="p-4 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Created</p>
|
||||
<p className="text-sm font-medium">{new Date(campaign.created_at).toLocaleDateString()}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="steps">
|
||||
<TabsList>
|
||||
<TabsTrigger value="steps">Steps ({campaign.steps?.length || 0})</TabsTrigger>
|
||||
<TabsTrigger value="subscribers">Subscribers ({campaign.subscribers?.length || 0})</TabsTrigger>
|
||||
<TabsTrigger value="send">Send</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="steps" className="space-y-4">
|
||||
<Card className="p-4 space-y-3">
|
||||
<p className="text-sm font-medium">Add Step</p>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Subject *</label>
|
||||
<Input value={newStep.subject} onChange={e => setNewStep(p => ({ ...p, subject: e.target.value }))} placeholder="Follow-up: {{name}}" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Delay (days)</label>
|
||||
<Input type="number" min="0" value={newStep.delayDays} onChange={e => setNewStep(p => ({ ...p, delayDays: parseInt(e.target.value) || 0 }))} />
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button size="sm" onClick={addStep} disabled={addingStep || !newStep.subject.trim()}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" /> Add Step
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Email Body (use {{name}} for recipient name)</label>
|
||||
<textarea value={newStep.bodyText} onChange={e => setNewStep(p => ({ ...p, bodyText: e.target.value }))} className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm" placeholder="Hi {{name}}, We'd love to help with..." />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{campaign.steps?.length === 0 ? (
|
||||
<Card className="p-8 text-center text-muted-foreground">
|
||||
<p>No steps defined yet. Add your first email above.</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{campaign.steps.map((step: any, i: number) => (
|
||||
<Card key={step.id} className="p-4 flex items-start gap-4">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-bold text-primary">
|
||||
{i + 1}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{step.subject}</p>
|
||||
{step.body_text && <p className="text-xs text-muted-foreground mt-1 line-clamp-2">{step.body_text}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground shrink-0">
|
||||
<Clock className="h-3 w-3" />
|
||||
{step.delay_days > 0 ? `Wait ${step.delay_days}d` : "Immediate"}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="subscribers" className="space-y-4">
|
||||
<Card className="p-4">
|
||||
<p className="text-sm font-medium mb-2">Add Leads to Campaign</p>
|
||||
<div className="flex gap-2">
|
||||
<Input placeholder="Search leads by name or company..." value={leadSearch} onChange={e => searchLeads(e.target.value)} className="flex-1" />
|
||||
</div>
|
||||
{searchResults.length > 0 && (
|
||||
<div className="mt-2 border rounded-lg divide-y max-h-48 overflow-y-auto">
|
||||
{searchResults.map((l: any) => (
|
||||
<div key={l.id} className="flex items-center justify-between px-3 py-2 hover:bg-muted/50">
|
||||
<div>
|
||||
<p className="text-sm">{l.companyName || l.contactName}</p>
|
||||
<p className="text-xs text-muted-foreground">{l.email}</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs"
|
||||
disabled={subscribing || campaign.subscribers?.some((s: any) => s.lead_id === l.id)}
|
||||
onClick={() => subscribeLeads([l.id])}
|
||||
>
|
||||
{campaign.subscribers?.some((s: any) => s.lead_id === l.id) ? "Added" : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{campaign.subscribers?.length === 0 ? (
|
||||
<Card className="p-8 text-center text-muted-foreground">
|
||||
<p>No subscribers yet. Search and add leads above.</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="divide-y rounded-lg border">
|
||||
{campaign.subscribers.map((s: any) => (
|
||||
<div key={s.id} className="flex items-center justify-between px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{s.contact_name || s.lead_email || s.email}</p>
|
||||
<p className="text-xs text-muted-foreground">{s.email} · Step {s.current_step + 1}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px]">{s.status}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="send" className="space-y-4">
|
||||
<Card className="p-6 text-center space-y-4">
|
||||
<Mail className="h-10 w-10 text-muted-foreground mx-auto" />
|
||||
<div>
|
||||
<p className="font-medium">Send Next Emails</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{campaign.subscribers?.filter((s: any) => s.status === "pending").length || 0} subscribers ready for the next step
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={sendCampaign} disabled={sending || campaign.steps?.length === 0}>
|
||||
<Send className="mr-1.5 h-4 w-4" />
|
||||
{sending ? "Sending..." : "Send Next Batch"}
|
||||
</Button>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Layers({ className }: { className?: string }) {
|
||||
return <svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.57 3.91a2 2 0 0 0 1.66 0l8.57-3.91a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></svg>
|
||||
}
|
||||
Reference in New Issue
Block a user