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,35 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Lead Scoring ML — Configurable scoring engine with historical training
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lead_scoring_config (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
feature_name VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
feature_type VARCHAR(50) NOT NULL DEFAULT 'keyword',
|
||||||
|
field_ref VARCHAR(100) NOT NULL,
|
||||||
|
weight DECIMAL(5,2) NOT NULL DEFAULT 1.00,
|
||||||
|
value_map JSONB,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_lead_scoring_active ON lead_scoring_config(is_active);
|
||||||
|
|
||||||
|
ALTER TABLE leads ADD COLUMN IF NOT EXISTS ml_score INT DEFAULT 0;
|
||||||
|
ALTER TABLE leads ADD COLUMN IF NOT EXISTS ml_score_details JSONB;
|
||||||
|
|
||||||
|
-- Seed default scoring features
|
||||||
|
INSERT INTO lead_scoring_config (feature_name, feature_type, field_ref, weight, value_map) VALUES
|
||||||
|
('Has Email', 'boolean', 'email', 5, '{"true": 5, "false": 0}'),
|
||||||
|
('Has Phone', 'boolean', 'phone', 5, '{"true": 5, "false": 0}'),
|
||||||
|
('Source: Referral', 'keyword', 'source', 15, '{"referral": 15, "friend": 15, "word_of_mouth": 15}'),
|
||||||
|
('Source: Website', 'keyword', 'source', 10, '{"website": 10, "organic": 8, "direct": 8}'),
|
||||||
|
('Source: Social', 'keyword', 'source', 8, '{"facebook": 8, "linkedin": 8, "twitter": 5, "instagram": 5}'),
|
||||||
|
('Source: Paid', 'keyword', 'source', 6, '{"google_ads": 6, "paid": 6, "ppc": 6}'),
|
||||||
|
('Company Name Present', 'boolean', 'company_name', 10, '{"true": 10, "false": 0}'),
|
||||||
|
('Description Length', 'range', 'description', 10, '{"0": 0, "50": 5, "200": 10, "500": 15}')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Update ml_score for all existing leads
|
||||||
|
UPDATE leads SET ml_score = 0 WHERE ml_score IS NULL;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Email Sequences / Drip Campaigns — Automated follow-up sequences
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS email_campaigns (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
status VARCHAR(50) NOT NULL DEFAULT 'draft',
|
||||||
|
trigger_type VARCHAR(50) NOT NULL DEFAULT 'manual',
|
||||||
|
trigger_config JSONB,
|
||||||
|
created_by UUID REFERENCES users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS email_campaign_steps (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
campaign_id UUID NOT NULL REFERENCES email_campaigns(id) ON DELETE CASCADE,
|
||||||
|
step_order INT NOT NULL DEFAULT 0,
|
||||||
|
delay_days INT NOT NULL DEFAULT 0,
|
||||||
|
subject VARCHAR(500) NOT NULL,
|
||||||
|
body_text TEXT,
|
||||||
|
body_html TEXT,
|
||||||
|
variables JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS email_campaign_subscribers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
campaign_id UUID NOT NULL REFERENCES email_campaigns(id) ON DELETE CASCADE,
|
||||||
|
lead_id UUID REFERENCES leads(id) ON DELETE SET NULL,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
contact_name VARCHAR(255),
|
||||||
|
subscribed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
unsubscribed_at TIMESTAMPTZ,
|
||||||
|
status VARCHAR(50) NOT NULL DEFAULT 'pending',
|
||||||
|
current_step INT NOT NULL DEFAULT 0,
|
||||||
|
last_sent_at TIMESTAMPTZ,
|
||||||
|
CONSTRAINT uq_campaign_lead UNIQUE (campaign_id, lead_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS email_campaign_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
campaign_id UUID REFERENCES email_campaigns(id) ON DELETE CASCADE,
|
||||||
|
subscriber_id UUID REFERENCES email_campaign_subscribers(id) ON DELETE CASCADE,
|
||||||
|
step_id UUID REFERENCES email_campaign_steps(id) ON DELETE SET NULL,
|
||||||
|
recipient VARCHAR(255) NOT NULL,
|
||||||
|
subject VARCHAR(500),
|
||||||
|
body_text TEXT,
|
||||||
|
sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
status VARCHAR(50) NOT NULL DEFAULT 'sent',
|
||||||
|
error_message TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE email_campaign_subscribers ADD CONSTRAINT IF NOT EXISTS uq_campaign_lead UNIQUE (campaign_id, lead_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_campaign_steps_order ON email_campaign_steps(campaign_id, step_order);
|
||||||
|
CREATE INDEX idx_campaign_subscribers ON email_campaign_subscribers(campaign_id, status);
|
||||||
|
CREATE INDEX idx_campaign_logs ON email_campaign_logs(campaign_id, sent_at);
|
||||||
@@ -91,5 +91,11 @@ BEGIN;
|
|||||||
\echo '=== Running 024_resource_planning.sql (Resource Planning) ==='
|
\echo '=== Running 024_resource_planning.sql (Resource Planning) ==='
|
||||||
\i 024_resource_planning.sql
|
\i 024_resource_planning.sql
|
||||||
|
|
||||||
|
\echo '=== Running 025_lead_scoring.sql (Lead Scoring ML) ==='
|
||||||
|
\i 025_lead_scoring.sql
|
||||||
|
|
||||||
|
\echo '=== Running 026_email_campaigns.sql (Email Campaigns) ==='
|
||||||
|
\i 026_email_campaigns.sql
|
||||||
|
|
||||||
\echo '=== Migration Complete ==='
|
\echo '=== Migration Complete ==='
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { PageHeader } from "@/components/shared/page-header"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Card } from "@/components/ui/card"
|
||||||
|
import { ArrowLeft } from "lucide-react"
|
||||||
|
|
||||||
|
export default function NewCampaignPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [description, setDescription] = useState("")
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!name.trim()) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/campaigns", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: name.trim(), description: description.trim() || null }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.id) router.push(`/campaigns/${data.id}`)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-2xl mx-auto">
|
||||||
|
<PageHeader title="New Campaign" description="Create an automated email sequence">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => router.push("/campaigns")}>
|
||||||
|
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
<Card className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Campaign Name *</label>
|
||||||
|
<Input value={name} onChange={e => setName(e.target.value)} placeholder="e.g. New Lead Follow-up" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Description</label>
|
||||||
|
<textarea value={description} onChange={e => setDescription(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="What is this campaign for?" />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button variant="outline" onClick={() => router.push("/campaigns")}>Cancel</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={saving || !name.trim()}>{saving ? "Creating..." : "Create Campaign"}</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { PageHeader } from "@/components/shared/page-header"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card } from "@/components/ui/card"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Plus, Mail, Users, Layers, Calendar } from "lucide-react"
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
draft: "bg-zinc-100 text-zinc-700 border-zinc-300",
|
||||||
|
active: "bg-emerald-100 text-emerald-700 border-emerald-300",
|
||||||
|
paused: "bg-amber-100 text-amber-700 border-amber-300",
|
||||||
|
completed: "bg-blue-100 text-blue-700 border-blue-300",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CampaignsPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [campaigns, setCampaigns] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/campaigns")
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => { if (Array.isArray(d)) setCampaigns(d); else setCampaigns([]) })
|
||||||
|
.catch(() => setCampaigns([]))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PageHeader title="Email Campaigns" description="Automated drip sequences for lead follow-ups">
|
||||||
|
<Button size="sm" onClick={() => router.push("/campaigns/new")}>
|
||||||
|
<Plus className="mr-1.5 h-4 w-4" /> New Campaign
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{[1,2,3].map(i => <Card key={i} className="h-32 animate-pulse p-4"><div className="h-4 w-32 rounded bg-muted" /></Card>)}
|
||||||
|
</div>
|
||||||
|
) : campaigns.length === 0 ? (
|
||||||
|
<Card className="p-12 text-center">
|
||||||
|
<Mail className="h-10 w-10 text-muted-foreground mx-auto mb-3" />
|
||||||
|
<p className="text-muted-foreground">No campaigns yet</p>
|
||||||
|
<Button variant="outline" size="sm" className="mt-4" onClick={() => router.push("/campaigns/new")}>
|
||||||
|
Create your first campaign
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{campaigns.map((c) => (
|
||||||
|
<Card key={c.id} className="p-4 cursor-pointer hover:border-primary/50 transition-colors" onClick={() => router.push(`/campaigns/${c.id}`)}>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium truncate">{c.name}</p>
|
||||||
|
{c.description && <p className="text-xs text-muted-foreground truncate mt-0.5">{c.description}</p>}
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className={`text-[10px] ml-2 ${statusColors[c.status] || ""}`}>{c.status}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1"><Users className="h-3 w-3" />{c.subscriber_count || 0}</span>
|
||||||
|
<span className="flex items-center gap-1"><Layers className="h-3 w-3" />{c.step_count || 0} steps</span>
|
||||||
|
<span className="flex items-center gap-1"><Calendar className="h-3 w-3" />{new Date(c.created_at).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@ import { UserPreferencesForm } from "@/components/settings/user-preferences-form
|
|||||||
import { ThemeSettings } from "@/components/settings/theme-settings"
|
import { ThemeSettings } from "@/components/settings/theme-settings"
|
||||||
import { NotificationSettings } from "@/components/settings/notification-settings"
|
import { NotificationSettings } from "@/components/settings/notification-settings"
|
||||||
import { CustomFieldsManager } from "@/components/settings/custom-fields-manager"
|
import { CustomFieldsManager } from "@/components/settings/custom-fields-manager"
|
||||||
import { Building2, User, Palette, Bell, ListChecks } from "lucide-react"
|
import { LeadScoringSettings } from "@/components/settings/lead-scoring-settings"
|
||||||
|
import { Building2, User, Palette, Bell, ListChecks, Gauge } from "lucide-react"
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const tabs = [
|
const tabs = [
|
||||||
@@ -16,6 +17,7 @@ export default function SettingsPage() {
|
|||||||
{ value: "theme", label: "Theme", icon: Palette, component: ThemeSettings },
|
{ value: "theme", label: "Theme", icon: Palette, component: ThemeSettings },
|
||||||
{ value: "notifications", label: "Notifications", icon: Bell, component: NotificationSettings },
|
{ value: "notifications", label: "Notifications", icon: Bell, component: NotificationSettings },
|
||||||
{ value: "custom-fields", label: "Custom Fields", icon: ListChecks, component: CustomFieldsManager },
|
{ value: "custom-fields", label: "Custom Fields", icon: ListChecks, component: CustomFieldsManager },
|
||||||
|
{ value: "lead-scoring", label: "Lead Scoring", icon: Gauge, component: LeadScoringSettings },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
const { id } = await params
|
||||||
|
const result = await query(
|
||||||
|
`SELECT l.*, s.contact_name, s.email AS subscriber_email
|
||||||
|
FROM email_campaign_logs l
|
||||||
|
LEFT JOIN email_campaign_subscribers s ON s.id = l.subscriber_id
|
||||||
|
WHERE l.campaign_id = $1 ORDER BY l.sent_at DESC LIMIT 100`,
|
||||||
|
[id]
|
||||||
|
)
|
||||||
|
return NextResponse.json(result.rows)
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: "Failed" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
const campaign = await query(
|
||||||
|
`SELECT c.*, u.first_name || ' ' || u.last_name AS creator_name
|
||||||
|
FROM email_campaigns c LEFT JOIN users u ON u.id = c.created_by
|
||||||
|
WHERE c.id = $1 AND c.deleted_at IS NULL`,
|
||||||
|
[id]
|
||||||
|
)
|
||||||
|
if (campaign.rows.length === 0) return NextResponse.json({ error: "Not found" }, { status: 404 })
|
||||||
|
|
||||||
|
const steps = await query(
|
||||||
|
"SELECT * FROM email_campaign_steps WHERE campaign_id = $1 ORDER BY step_order",
|
||||||
|
[id]
|
||||||
|
)
|
||||||
|
|
||||||
|
const subscribers = await query(
|
||||||
|
`SELECT s.*, l.company_name, l.contact_name, l.email AS lead_email
|
||||||
|
FROM email_campaign_subscribers s
|
||||||
|
LEFT JOIN leads l ON l.id = s.lead_id
|
||||||
|
WHERE s.campaign_id = $1 ORDER BY s.subscribed_at DESC`,
|
||||||
|
[id]
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({ ...campaign.rows[0], steps: steps.rows, subscribers: subscribers.rows })
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: "Failed" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
const { id } = await params
|
||||||
|
const body = await request.json()
|
||||||
|
|
||||||
|
const fields: string[] = []
|
||||||
|
const values: any[] = []
|
||||||
|
let idx = 1
|
||||||
|
if (body.name !== undefined) { fields.push(`name = $${idx++}`); values.push(body.name) }
|
||||||
|
if (body.description !== undefined) { fields.push(`description = $${idx++}`); values.push(body.description) }
|
||||||
|
if (body.status !== undefined) { fields.push(`status = $${idx++}`); values.push(body.status) }
|
||||||
|
if (fields.length === 0) return NextResponse.json({ error: "No fields" }, { status: 400 })
|
||||||
|
fields.push("updated_at = NOW()")
|
||||||
|
values.push(id)
|
||||||
|
|
||||||
|
await query(`UPDATE email_campaigns SET ${fields.join(", ")} WHERE id = $${idx}`, values)
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: "Failed" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
const { id } = await params
|
||||||
|
await query("UPDATE email_campaigns SET deleted_at = NOW() WHERE id = $1", [id])
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: "Failed" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
const steps = await query(
|
||||||
|
"SELECT * FROM email_campaign_steps WHERE campaign_id = $1 ORDER BY step_order",
|
||||||
|
[id]
|
||||||
|
)
|
||||||
|
if (steps.rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "No steps defined" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribers = await query(
|
||||||
|
"SELECT * FROM email_campaign_subscribers WHERE campaign_id = $1 AND status = 'pending' AND unsubscribed_at IS NULL",
|
||||||
|
[id]
|
||||||
|
)
|
||||||
|
|
||||||
|
let sent = 0
|
||||||
|
for (const sub of subscribers.rows) {
|
||||||
|
const stepIdx = sub.current_step
|
||||||
|
if (stepIdx >= steps.rows.length) continue
|
||||||
|
const step = steps.rows[stepIdx]
|
||||||
|
|
||||||
|
const body = replaceVariables(step.body_text || step.subject, { name: sub.contact_name || "Valued Client", email: sub.email })
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`INSERT INTO email_campaign_logs (campaign_id, subscriber_id, step_id, recipient, subject, body_text, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, 'sent')`,
|
||||||
|
[id, sub.id, step.id, sub.email, step.subject, body]
|
||||||
|
)
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE email_campaign_subscribers SET current_step = current_step + 1, last_sent_at = NOW(), status = CASE WHEN $1 + 1 >= $2 THEN 'completed' ELSE 'pending' END
|
||||||
|
WHERE id = $3`,
|
||||||
|
[stepIdx, steps.rows.length, sub.id]
|
||||||
|
)
|
||||||
|
sent++
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, sent })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Campaign send error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to send" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceVariables(text: string, vars: Record<string, string>): string {
|
||||||
|
return text.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] || `{{${key}}}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
const { id } = await params
|
||||||
|
const result = await query("SELECT * FROM email_campaign_steps WHERE campaign_id = $1 ORDER BY step_order", [id])
|
||||||
|
return NextResponse.json(result.rows)
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: "Failed" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
const { id } = await params
|
||||||
|
const body = await request.json()
|
||||||
|
|
||||||
|
const maxOrder = await query("SELECT COALESCE(MAX(step_order), -1) + 1 AS next FROM email_campaign_steps WHERE campaign_id = $1", [id])
|
||||||
|
const nextOrder = maxOrder.rows[0]?.next || 0
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO email_campaign_steps (campaign_id, step_order, delay_days, subject, body_text, body_html, variables)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
|
||||||
|
[id, nextOrder, body.delayDays || 0, body.subject, body.bodyText || null, body.bodyHtml || null, body.variables ? JSON.stringify(body.variables) : null]
|
||||||
|
)
|
||||||
|
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: "Failed" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
const { id } = await params
|
||||||
|
const body = await request.json()
|
||||||
|
|
||||||
|
if (body.leadIds && Array.isArray(body.leadIds)) {
|
||||||
|
let added = 0
|
||||||
|
for (const leadId of body.leadIds) {
|
||||||
|
const lead = await query("SELECT id, email, contact_name FROM leads WHERE id = $1 AND deleted_at IS NULL", [leadId])
|
||||||
|
if (lead.rows.length === 0) continue
|
||||||
|
const l = lead.rows[0]
|
||||||
|
if (!l.email) continue
|
||||||
|
try {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO email_campaign_subscribers (campaign_id, lead_id, email, contact_name)
|
||||||
|
VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`,
|
||||||
|
[id, l.id, l.email, l.contact_name]
|
||||||
|
)
|
||||||
|
added++
|
||||||
|
} catch { /* skip duplicates */ }
|
||||||
|
}
|
||||||
|
return NextResponse.json({ success: true, added })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: "leadIds array required" }, { status: 400 })
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: "Failed" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
const result = await query(
|
||||||
|
`SELECT c.*, u.first_name || ' ' || u.last_name AS creator_name,
|
||||||
|
(SELECT COUNT(*) FROM email_campaign_subscribers WHERE campaign_id = c.id) AS subscriber_count,
|
||||||
|
(SELECT COUNT(*) FROM email_campaign_steps WHERE campaign_id = c.id) AS step_count
|
||||||
|
FROM email_campaigns c
|
||||||
|
LEFT JOIN users u ON u.id = c.created_by
|
||||||
|
WHERE c.deleted_at IS NULL
|
||||||
|
ORDER BY c.created_at DESC`
|
||||||
|
)
|
||||||
|
return NextResponse.json(result.rows)
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: "Failed to load campaigns" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
const body = await request.json()
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO email_campaigns (name, description, status, trigger_type, trigger_config, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||||
|
[body.name, body.description || null, body.status || "draft", body.triggerType || "manual", body.triggerConfig ? JSON.stringify(body.triggerConfig) : null, user.id]
|
||||||
|
)
|
||||||
|
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: "Failed to create campaign" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
|||||||
const result = await query(
|
const result = await query(
|
||||||
`SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
|
`SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
|
||||||
l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
|
l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
|
||||||
l.custom_fields,
|
l.custom_fields, l.ml_score,
|
||||||
ls.name AS stage_name,
|
ls.name AS stage_name,
|
||||||
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
|
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
|
||||||
FROM leads l
|
FROM leads l
|
||||||
@@ -61,6 +61,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
|||||||
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
|
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
|
||||||
} : null,
|
} : null,
|
||||||
customFields: r.custom_fields || {},
|
customFields: r.custom_fields || {},
|
||||||
|
mlScore: r.ml_score || 0,
|
||||||
createdAt: r.created_at,
|
createdAt: r.created_at,
|
||||||
updatedAt: r.updated_at,
|
updatedAt: r.updated_at,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||||
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||||
|
|
||||||
let sql = `SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
|
let sql = `SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score, l.ml_score,
|
||||||
l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
|
l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
|
||||||
ls.name AS stage_name,
|
ls.name AS stage_name,
|
||||||
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
|
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
|
||||||
@@ -101,6 +101,7 @@ export async function GET(request: NextRequest) {
|
|||||||
email: r.user_email,
|
email: r.user_email,
|
||||||
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
|
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
|
||||||
} : null,
|
} : null,
|
||||||
|
mlScore: r.ml_score || 0,
|
||||||
createdAt: r.created_at,
|
createdAt: r.created_at,
|
||||||
updatedAt: r.updated_at,
|
updatedAt: r.updated_at,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
const result = await query("SELECT * FROM lead_scoring_config WHERE is_active = true ORDER BY weight DESC")
|
||||||
|
return NextResponse.json(result.rows)
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: "Failed to load scoring config" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user || (user.role !== "admin" && user.role !== "super_admin")) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
const body = await request.json()
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO lead_scoring_config (feature_name, feature_type, field_ref, weight, value_map)
|
||||||
|
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||||
|
[body.featureName, body.featureType || "keyword", body.fieldRef, body.weight || 1, body.valueMap ? JSON.stringify(body.valueMap) : null]
|
||||||
|
)
|
||||||
|
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.constraint === "lead_scoring_config_feature_name_key") {
|
||||||
|
return NextResponse.json({ error: "Feature name already exists" }, { status: 409 })
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: "Failed to create feature" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user || (user.role !== "admin" && user.role !== "super_admin")) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
const body = await request.json()
|
||||||
|
if (!body.id) return NextResponse.json({ error: "id required" }, { status: 400 })
|
||||||
|
|
||||||
|
const fields: string[] = []
|
||||||
|
const values: any[] = []
|
||||||
|
let idx = 1
|
||||||
|
if (body.weight !== undefined) { fields.push(`weight = $${idx++}`); values.push(body.weight) }
|
||||||
|
if (body.isActive !== undefined) { fields.push(`is_active = $${idx++}`); values.push(body.isActive) }
|
||||||
|
if (body.valueMap !== undefined) { fields.push(`value_map = $${idx++}`); values.push(JSON.stringify(body.valueMap)) }
|
||||||
|
if (fields.length === 0) return NextResponse.json({ error: "No fields" }, { status: 400 })
|
||||||
|
fields.push("updated_at = NOW()")
|
||||||
|
values.push(body.id)
|
||||||
|
|
||||||
|
await query(`UPDATE lead_scoring_config SET ${fields.join(", ")} WHERE id = $${idx}`, values)
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: "Failed to update" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user || (user.role !== "admin" && user.role !== "super_admin")) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const id = searchParams.get("id")
|
||||||
|
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 })
|
||||||
|
await query("DELETE FROM lead_scoring_config WHERE id = $1", [id])
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: "Failed to delete" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const leadId = searchParams.get("leadId")
|
||||||
|
|
||||||
|
const configResult = await query("SELECT * FROM lead_scoring_config WHERE is_active = true ORDER BY weight DESC")
|
||||||
|
const config = configResult.rows
|
||||||
|
|
||||||
|
let leads: any[]
|
||||||
|
if (leadId) {
|
||||||
|
const r = await query("SELECT id, company_name, contact_name, email, phone, source, notes, custom_fields FROM leads WHERE id = $1 AND deleted_at IS NULL", [leadId])
|
||||||
|
leads = r.rows
|
||||||
|
} else {
|
||||||
|
const r = await query("SELECT id, company_name, contact_name, email, phone, source, notes, custom_fields FROM leads WHERE deleted_at IS NULL AND ml_score IS NULL")
|
||||||
|
leads = r.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
let scored = 0
|
||||||
|
for (const lead of leads) {
|
||||||
|
let score = 0
|
||||||
|
const details: Record<string, number> = {}
|
||||||
|
|
||||||
|
for (const f of config) {
|
||||||
|
let value: any = (lead as any)[f.field_ref]
|
||||||
|
if (value === undefined || value === null) value = ""
|
||||||
|
|
||||||
|
let points = 0
|
||||||
|
switch (f.feature_type) {
|
||||||
|
case "boolean": {
|
||||||
|
const hasVal = typeof value === "string" ? value.trim().length > 0 : value !== null && value !== undefined
|
||||||
|
if (f.value_map) {
|
||||||
|
const map = f.value_map as Record<string, any>
|
||||||
|
points = hasVal ? Number(map["true"] || 0) : Number(map["false"] || 0)
|
||||||
|
} else {
|
||||||
|
points = hasVal ? f.weight : 0
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "keyword": {
|
||||||
|
if (f.value_map) {
|
||||||
|
const map = f.value_map as Record<string, any>
|
||||||
|
const strVal = String(value).toLowerCase()
|
||||||
|
for (const [key, val] of Object.entries(map)) {
|
||||||
|
if (strVal.includes(key.toLowerCase())) {
|
||||||
|
points = Number(val)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "range": {
|
||||||
|
if (f.value_map) {
|
||||||
|
const map = f.value_map as Record<string, any>
|
||||||
|
const numVal = typeof value === "string" ? value.length : Number(value) || 0
|
||||||
|
let bestMatch = 0
|
||||||
|
for (const [threshold, val] of Object.entries(map)) {
|
||||||
|
if (numVal >= Number(threshold)) bestMatch = Number(val)
|
||||||
|
}
|
||||||
|
points = bestMatch
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
details[f.feature_name] = points
|
||||||
|
score += points
|
||||||
|
}
|
||||||
|
|
||||||
|
score = Math.min(Math.max(score, 0), 100)
|
||||||
|
|
||||||
|
await query("UPDATE leads SET ml_score = $1, ml_score_details = $2 WHERE id = $3", [score, JSON.stringify(details), lead.id])
|
||||||
|
scored++
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, scored })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Lead scoring error:", error)
|
||||||
|
return NextResponse.json({ error: "Scoring failed" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Clock,
|
Clock,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
Mail,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import { useUser } from "@/providers/user-provider"
|
import { useUser } from "@/providers/user-provider"
|
||||||
@@ -40,6 +41,7 @@ const navItems = [
|
|||||||
{ href: "/proposals", label: "Proposals", icon: FileText },
|
{ href: "/proposals", label: "Proposals", icon: FileText },
|
||||||
{ href: "/time-tracking", label: "Time", icon: Clock },
|
{ href: "/time-tracking", label: "Time", icon: Clock },
|
||||||
{ href: "/resource-planning", label: "Resources", icon: BarChart3 },
|
{ href: "/resource-planning", label: "Resources", icon: BarChart3 },
|
||||||
|
{ href: "/campaigns", label: "Campaigns", icon: Mail },
|
||||||
{ href: "/ai-assistant", label: "AI Assistant", icon: Bot, roles: ["sales", "admin", "super_admin"] },
|
{ href: "/ai-assistant", label: "AI Assistant", icon: Bot, roles: ["sales", "admin", "super_admin"] },
|
||||||
{ href: "/users", label: "Users", icon: Building2 },
|
{ href: "/users", label: "Users", icon: Building2 },
|
||||||
{ href: "/settings", label: "Settings", icon: Settings },
|
{ href: "/settings", label: "Settings", icon: Settings },
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Plus, Trash2, RefreshCw, Gauge } from "lucide-react"
|
||||||
|
|
||||||
|
interface ScoringFeature {
|
||||||
|
id: string
|
||||||
|
feature_name: string
|
||||||
|
feature_type: string
|
||||||
|
field_ref: string
|
||||||
|
weight: number
|
||||||
|
value_map: any
|
||||||
|
is_active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LeadScoringSettings() {
|
||||||
|
const [features, setFeatures] = useState<ScoringFeature[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [scoring, setScoring] = useState(false)
|
||||||
|
const [newFeature, setNewFeature] = useState({ featureName: "", fieldRef: "", featureType: "keyword", weight: 5 })
|
||||||
|
|
||||||
|
const loadFeatures = async () => {
|
||||||
|
const res = await fetch("/api/leads/score/config")
|
||||||
|
if (res.ok) setFeatures(await res.json())
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { loadFeatures() }, [])
|
||||||
|
|
||||||
|
const addFeature = async () => {
|
||||||
|
if (!newFeature.featureName) return
|
||||||
|
const res = await fetch("/api/leads/score/config", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(newFeature),
|
||||||
|
})
|
||||||
|
if (res.ok) { setNewFeature({ featureName: "", fieldRef: "", featureType: "keyword", weight: 5 }); loadFeatures() }
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteFeature = async (id: string) => {
|
||||||
|
await fetch(`/api/leads/score/config?id=${id}`, { method: "DELETE" })
|
||||||
|
loadFeatures()
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleFeature = async (f: ScoringFeature) => {
|
||||||
|
await fetch("/api/leads/score/config", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ id: f.id, isActive: !f.is_active }),
|
||||||
|
})
|
||||||
|
loadFeatures()
|
||||||
|
}
|
||||||
|
|
||||||
|
const runScoring = async () => {
|
||||||
|
setScoring(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/leads/score", { method: "POST" })
|
||||||
|
const data = await res.json()
|
||||||
|
alert(`Scored ${data.scored} leads`)
|
||||||
|
} catch { alert("Scoring failed") }
|
||||||
|
setScoring(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeLabels: Record<string, string> = { boolean: "Boolean", keyword: "Keyword Match", range: "Range" }
|
||||||
|
const typeColors: Record<string, string> = { boolean: "bg-blue-100 text-blue-700", keyword: "bg-emerald-100 text-emerald-700", range: "bg-amber-100 text-amber-700" }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">Configure how leads are scored based on their attributes. Scores range from 0-100.</p>
|
||||||
|
<Button size="sm" variant="outline" onClick={runScoring} disabled={scoring}>
|
||||||
|
<RefreshCw className={`mr-1.5 h-4 w-4 ${scoring ? "animate-spin" : ""}`} />
|
||||||
|
{scoring ? "Scoring..." : "Score All Leads"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle className="text-base">Scoring Features</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||||
|
) : features.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No scoring features defined.</p>
|
||||||
|
) : (
|
||||||
|
features.map((f) => (
|
||||||
|
<div key={f.id} className="flex items-center gap-3 rounded-lg border p-3">
|
||||||
|
<Gauge className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">{f.feature_name}</span>
|
||||||
|
<Badge variant="outline" className={`text-[10px] ${typeColors[f.feature_type] || ""}`}>
|
||||||
|
{typeLabels[f.feature_type] || f.feature_type}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">field: {f.field_ref} · weight: {f.weight}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" variant={f.is_active ? "default" : "outline"} className="h-7 text-xs" onClick={() => toggleFeature(f)}>
|
||||||
|
{f.is_active ? "Active" : "Disabled"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => deleteFeature(f.id)}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle className="text-base">Add Feature</CardTitle></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium">Feature Name</label>
|
||||||
|
<Input value={newFeature.featureName} onChange={e => setNewFeature(p => ({ ...p, featureName: e.target.value }))} placeholder="e.g. Has Website" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium">Field Reference</label>
|
||||||
|
<Input value={newFeature.fieldRef} onChange={e => setNewFeature(p => ({ ...p, fieldRef: e.target.value }))} placeholder="e.g. source" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium">Type</label>
|
||||||
|
<select value={newFeature.featureType} onChange={e => setNewFeature(p => ({ ...p, featureType: e.target.value }))} className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm">
|
||||||
|
<option value="keyword">Keyword Match</option>
|
||||||
|
<option value="boolean">Boolean (has value)</option>
|
||||||
|
<option value="range">Range</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium">Weight</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input type="number" min="1" max="100" value={newFeature.weight} onChange={e => setNewFeature(p => ({ ...p, weight: parseInt(e.target.value) || 1 }))} />
|
||||||
|
<Button size="sm" className="shrink-0" onClick={addFeature}><Plus className="h-4 w-4" /></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ export interface Lead {
|
|||||||
assignedUserId: string | null;
|
assignedUserId: string | null;
|
||||||
assignedUser: User | null;
|
assignedUser: User | null;
|
||||||
customFields?: Record<string, any>;
|
customFields?: Record<string, any>;
|
||||||
|
mlScore?: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user