Files
Newbie_CRM/src/app/(dashboard)/campaigns/[id]/page.tsx
T
Rene 3fe32d923e
Build & Auto-Repair / build (push) Has been cancelled
Add descriptive error messages across all API routes and toast notifications on client pages
- 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
2026-07-01 15:20:30 +02:00

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 &#123;&#123;name&#125;&#125; 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}},&#10;&#10;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>
}