3fe32d923e
Build & Auto-Repair / build (push) Has been cancelled
- 86 catch blocks in 49 API route files now return the actual error message via error.message
- 14 campaign/lead/config catch blocks that lacked console.error now log errors
- 17 client pages/components now show toast.error notifications on API failures
- Silent .catch(() => {}) and finally-only try blocks now surface errors to users
260 lines
12 KiB
TypeScript
260 lines
12 KiB
TypeScript
"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"
|
|
import { toast } from "sonner"
|
|
|
|
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 (e) { console.error("Failed to load campaign", e); toast.error("Failed to load campaign") }
|
|
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 (e) { console.error("Failed to add step", e); toast.error("Failed to add step") }
|
|
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 (e) { console.error("Failed to subscribe leads", e); toast.error("Failed to subscribe leads") }
|
|
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 (e) { console.error("Failed to send campaign", e); toast.error("Failed to send campaign") }
|
|
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>
|
|
}
|