mirror of
https://git.coastit.co.za/caitlin/CRM_ENVR.git
synced 2026-07-10 11:15:43 +02:00
Current state
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback } from "react"
|
||||
import { AIChat } from "@/components/ai/ai-chat"
|
||||
import { JobSelector } from "@/components/ai/job-selector"
|
||||
import { Bot, Lightbulb, Target, MessageSquare } from "lucide-react"
|
||||
|
||||
export default function AIAssistantPage() {
|
||||
const [selectedJob, setSelectedJob] = useState<{ job_title: string; keywords: string[]; industry: string; description: string } | null>(null)
|
||||
|
||||
const handleJobSelect = useCallback((job: typeof selectedJob) => {
|
||||
setSelectedJob(job)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3.5rem)]">
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="border-b border-[#2a2a35] px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-lg bg-[#1BB0CE]/15 flex items-center justify-center">
|
||||
<Bot className="h-5 w-5 text-[#1BB0CE]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-[#e8e8ef]">AI Sales Assistant</h1>
|
||||
<p className="text-xs text-[#6a6a75]">Uncensored sales tips and strategies powered by local AI</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex">
|
||||
<div className="flex-1 flex flex-col min-w-0 border-r border-[#2a2a35]">
|
||||
<AIChat />
|
||||
</div>
|
||||
|
||||
<div className="w-72 flex-none p-4 space-y-4 overflow-y-auto">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-[#6a6a75] uppercase tracking-wider mb-2 flex items-center gap-1.5">
|
||||
<Target className="h-3.5 w-3.5" />
|
||||
Target Job
|
||||
</h3>
|
||||
<JobSelector onSelect={handleJobSelect} />
|
||||
</div>
|
||||
|
||||
{selectedJob && (
|
||||
<div className="bg-[#1a1a24] border border-[#2a2a35] rounded-lg p-3 space-y-2">
|
||||
<h4 className="text-sm font-medium text-[#e8e8ef]">{selectedJob.job_title}</h4>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-[#1BB0CE]/10 text-[#1BB0CE]">{selectedJob.industry}</span>
|
||||
</div>
|
||||
<p className="text-xs text-[#8a8a95]">{selectedJob.description}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedJob.keywords.map((kw, i) => (
|
||||
<span key={i} className="text-xs px-1.5 py-0.5 rounded bg-[#2a2a35] text-[#6a6a75]">
|
||||
{kw}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-[#1a1a24] border border-[#2a2a35] rounded-lg p-3 space-y-2">
|
||||
<h4 className="text-xs font-semibold text-[#6a6a75] uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Lightbulb className="h-3.5 w-3.5" />
|
||||
Tips
|
||||
</h4>
|
||||
<ul className="space-y-1.5 text-xs text-[#8a8a95]">
|
||||
<li className="flex gap-2">
|
||||
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
|
||||
Ask for cold email templates for a specific job
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
|
||||
Request objection handling tips
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
|
||||
Ask for outreach strategies per industry
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,119 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { StatCard } from "@/components/dashboard/stat-card"
|
||||
import { StatCardSkeleton } from "@/components/dashboard/stat-card-skeleton"
|
||||
import { RecentLeadsTable } from "@/components/dashboard/recent-leads-table"
|
||||
import { LeadStatusChart } from "@/components/dashboard/lead-status-chart"
|
||||
import { LeadsPerMonthChart } from "@/components/dashboard/leads-per-month-chart"
|
||||
import {
|
||||
Users,
|
||||
UserPlus,
|
||||
PhoneCall,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
TrendingUp,
|
||||
ListFilter,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { DashboardStats } from "@/types"
|
||||
import { CrossedLightsabers } from "@/components/dashboard/crossed-lightsabers"
|
||||
import { StarField } from "@/components/dashboard/star-field"
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [period, setPeriod] = useState("6months")
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const pollingRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
async function fetchStats(p: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/dashboard?period=${p}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setStats(data)
|
||||
}
|
||||
} catch {
|
||||
console.warn("Failed to fetch dashboard stats")
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats(period)
|
||||
pollingRef.current = setInterval(() => fetchStats(period), 30000)
|
||||
return () => {
|
||||
if (pollingRef.current) clearInterval(pollingRef.current)
|
||||
}
|
||||
}, [period])
|
||||
|
||||
const statCards = stats
|
||||
? [
|
||||
{ title: "Total Leads", value: stats.totalLeads, icon: Users, description: stats.periodLabel, trend: stats.trends.totalLeads, sparklineField: "total" as const },
|
||||
{ title: "Open Leads", value: stats.openLeads, icon: UserPlus, description: "Needs attention", trend: stats.trends.openLeads, sparklineField: "open" as const },
|
||||
{ title: "Contacted", value: stats.contactedLeads, icon: PhoneCall, description: "In conversation", trend: stats.trends.contactedLeads, sparklineField: "contacted" as const },
|
||||
{ title: "Pending", value: stats.pendingLeads, icon: Clock, description: "Awaiting decision", trend: stats.trends.pendingLeads, sparklineField: "pending" as const },
|
||||
{ title: "Closed", value: stats.closedLeads, icon: CheckCircle2, description: "Won", trend: stats.trends.closedLeads, sparklineField: "closed" as const },
|
||||
{ title: "Conversion Rate", value: `${stats.conversionRate}%`, icon: TrendingUp, description: `${stats.closedLeads} of ${stats.totalLeads} leads`, trend: stats.trends.conversionRate, sparklineField: "closed" as const, conversionRate: stats.conversionRate },
|
||||
]
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="space-y-6 relative">
|
||||
<StarField />
|
||||
<div className="relative z-[1] space-y-6">
|
||||
<div>
|
||||
<PageHeader
|
||||
title={<span style={{fontFamily:"'Audiowide',sans-serif"}}>Dashboard</span>}
|
||||
description="Overview of your sales pipeline"
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none select-none text-5xl text-gray-600 dark:text-[#b0f0c0] tracking-wider font-bold text-glow whitespace-nowrap"
|
||||
style={{ fontFamily: "'Cormorant Garamond', serif" }}
|
||||
>
|
||||
Trust the Force, Find your Path
|
||||
</span>
|
||||
<CrossedLightsabers />
|
||||
<Select value={period} onValueChange={setPeriod}>
|
||||
<SelectTrigger className="h-9 w-[160px]">
|
||||
<ListFilter className="mr-2 h-4 w-4" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7days">Last 7 days</SelectItem>
|
||||
<SelectItem value="30days">Last 30 days</SelectItem>
|
||||
<SelectItem value="6months">Last 6 months</SelectItem>
|
||||
<SelectItem value="12months">Last 12 months</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</PageHeader>
|
||||
</div>
|
||||
|
||||
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Pipeline Overview</p>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
{stats
|
||||
? statCards.map((card, i) => (
|
||||
<StatCard key={card.title} {...card} index={i} monthlyBreakdown={stats.monthlyBreakdown} />
|
||||
))
|
||||
: Array.from({ length: 6 }).map((_, i) => <StatCardSkeleton key={i} />)
|
||||
}
|
||||
</div>
|
||||
|
||||
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Analytics</p>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<LeadStatusChart data={stats?.statusDistribution ?? []} />
|
||||
<LeadsPerMonthChart data={stats?.leadsPerMonth ?? []} />
|
||||
</div>
|
||||
|
||||
<RecentLeadsTable leads={stats?.recentLeads ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
|
||||
import { AppShell } from "@/components/layout/app-shell"
|
||||
import { UserProvider, useUser } from "@/providers/user-provider"
|
||||
import { NotificationProvider } from "@/providers/notification-provider"
|
||||
import { ErrorBoundary } from "@/components/shared/error-boundary"
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
function DashboardContent({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useUser()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) return null
|
||||
|
||||
return <AppShell>{children}</AppShell>
|
||||
}
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<UserProvider>
|
||||
<NotificationProvider>
|
||||
<ErrorBoundary>
|
||||
<DashboardContent>{children}</DashboardContent>
|
||||
</ErrorBoundary>
|
||||
</NotificationProvider>
|
||||
</UserProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import Link from "next/link"
|
||||
import { motion } from "framer-motion"
|
||||
import { use } from "react"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { LeadDetailsCard } from "@/components/leads/lead-details-card"
|
||||
import { LeadStatusBadge } from "@/components/leads/lead-status-badge"
|
||||
import { NoteTimeline } from "@/components/notes/note-timeline"
|
||||
import { NoteForm } from "@/components/notes/note-form"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { ArrowLeft, Edit, ExternalLink } from "lucide-react"
|
||||
import { Lead, Note } from "@/types"
|
||||
|
||||
export default function LeadDetailsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const [lead, setLead] = useState<Lead | null>(null)
|
||||
const [leadNotes, setLeadNotes] = useState<Note[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchNotes = useCallback(async () => {
|
||||
const notesRes = await fetch(`/api/leads/${id}/notes`)
|
||||
if (notesRes.ok) setLeadNotes(await notesRes.json())
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [leadRes] = await Promise.all([
|
||||
fetch(`/api/leads/${id}`),
|
||||
fetchNotes(),
|
||||
])
|
||||
if (leadRes.ok) setLead(await leadRes.json())
|
||||
} catch {
|
||||
console.warn("Failed to fetch lead details")
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
fetchData()
|
||||
}, [id, fetchNotes])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[60vh]">
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!lead) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[60vh]">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold">Lead not found</h2>
|
||||
<p className="mt-2 text-muted-foreground">The lead you are looking for does not exist.</p>
|
||||
<Link href="/leads" className="mt-4 inline-block">
|
||||
<Button variant="outline">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Leads
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link
|
||||
href="/leads"
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to leads
|
||||
</Link>
|
||||
|
||||
<PageHeader
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<span>{lead.companyName}</span>
|
||||
<LeadStatusBadge status={lead.status} />
|
||||
</div>
|
||||
}
|
||||
description={lead.contactName}
|
||||
>
|
||||
<Select
|
||||
value={lead.status}
|
||||
onValueChange={async (v) => {
|
||||
const previousStatus = lead.status
|
||||
setLead((prev) => prev ? { ...prev, status: v as Lead["status"] } : prev)
|
||||
try {
|
||||
const res = await fetch(`/api/leads/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: v }),
|
||||
})
|
||||
if (!res.ok) setLead((prev) => prev ? { ...prev, status: previousStatus } : prev)
|
||||
} catch {
|
||||
setLead((prev) => prev ? { ...prev, status: previousStatus } : prev)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="open">Open</SelectItem>
|
||||
<SelectItem value="contacted">Contacted</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
<SelectItem value="ignored">Ignored</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm">
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<LeadDetailsCard lead={lead} />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.1 }}
|
||||
>
|
||||
<NoteForm leadId={lead.id} onNoteAdded={fetchNotes} />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.2 }}
|
||||
>
|
||||
<NoteTimeline notes={leadNotes} />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.15 }}
|
||||
className="rounded-lg border bg-card p-6"
|
||||
>
|
||||
<h3 className="font-semibold mb-4">Activity</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
||||
<div>
|
||||
<p className="font-medium">Lead Created</p>
|
||||
<p className="text-xs text-muted-foreground">{new Date(lead.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
{leadNotes.slice(0, 3).map((note) => (
|
||||
<div key={note.id} className="flex items-start gap-3 text-sm">
|
||||
<div className="h-2 w-2 rounded-full bg-muted-foreground mt-1.5" />
|
||||
<div>
|
||||
<p className="font-medium">{note.authorName}</p>
|
||||
<p className="text-xs text-muted-foreground">{note.note.slice(0, 60)}...</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.25 }}
|
||||
className="rounded-lg border bg-card p-6"
|
||||
>
|
||||
<h3 className="font-semibold mb-4">Quick Actions</h3>
|
||||
<div className="space-y-2">
|
||||
<Button variant="outline" className="w-full justify-start" size="sm">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Send Email
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start" size="sm">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Call Lead
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import * as z from "zod"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import { User } from "@/types"
|
||||
import { useNotifications } from "@/providers/notification-provider"
|
||||
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
|
||||
|
||||
const leadFormSchema = z.object({
|
||||
companyName: z.string().min(1, "Company name is required"),
|
||||
contactName: z.string().min(1, "Contact name is required"),
|
||||
email: z.string().email("Invalid email address"),
|
||||
phone: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
status: z.string(),
|
||||
assignedUserId: z.string().optional(),
|
||||
})
|
||||
|
||||
type LeadFormValues = z.infer<typeof leadFormSchema>
|
||||
|
||||
export default function CreateLeadPage() {
|
||||
const router = useRouter()
|
||||
const { addNotification } = useNotifications()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/users")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setUsers(data.users || []))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const form = useForm<LeadFormValues>({
|
||||
resolver: zodResolver(leadFormSchema),
|
||||
defaultValues: {
|
||||
companyName: "",
|
||||
contactName: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
source: "",
|
||||
description: "",
|
||||
status: "open",
|
||||
assignedUserId: "none",
|
||||
},
|
||||
})
|
||||
|
||||
async function onSubmit(values: LeadFormValues) {
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = {
|
||||
...values,
|
||||
assignedUserId: values.assignedUserId === "none" ? null : (values.assignedUserId ?? null),
|
||||
}
|
||||
const res = await fetch("/api/leads", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to create lead")
|
||||
const data = await res.json()
|
||||
addNotification("lead_created", "New Lead Created", `${values.companyName} — ${values.contactName}`, `/leads/${data.id}`)
|
||||
router.push("/leads")
|
||||
} catch (e) {
|
||||
console.error("Create lead error:", e)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link
|
||||
href="/leads"
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to leads
|
||||
</Link>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Create New Lead</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Fill in the details to add a new lead.</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="companyName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Company Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Acme Corp" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="contactName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Contact Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="John Doe" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="john@acme.com" type="email" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Phone</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="(555) 123-4567" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="source"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Source</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select source" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{LEAD_SOURCES.map((source) => (
|
||||
<SelectItem key={source} value={source}>
|
||||
{source}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Status</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{Object.entries(LEAD_STATUSES).map(([key, { label }]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Brief description of the lead and their requirements..."
|
||||
className="min-h-[100px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="assignedUserId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Assign To</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select user" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Unassigned</SelectItem>
|
||||
{users.filter((u) => u.active).map((user) => (
|
||||
<SelectItem key={user.id} value={user.id}>
|
||||
{user.name} ({user.role})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? "Saving..." : "Create Lead"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.push("/leads")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { LeadsTable } from "@/components/leads/leads-table"
|
||||
import { LeadsTableToolbar } from "@/components/leads/leads-table-toolbar"
|
||||
import { Lead } from "@/types"
|
||||
|
||||
export default function LeadsPage() {
|
||||
const router = useRouter()
|
||||
const [search, setSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState("all")
|
||||
const [periodFilter, setPeriodFilter] = useState("all")
|
||||
const [leadsData, setLeadsData] = useState<Lead[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams()
|
||||
if (search) params.set("search", search)
|
||||
if (statusFilter !== "all") params.set("status", statusFilter)
|
||||
if (periodFilter !== "all") params.set("period", periodFilter)
|
||||
|
||||
setLoading(true)
|
||||
fetch(`/api/leads?${params.toString()}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setLeadsData(data)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => setLoading(false))
|
||||
}, [search, statusFilter, periodFilter])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Leads"
|
||||
description="Manage and track your sales leads"
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="p-4">
|
||||
<LeadsTableToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={setStatusFilter}
|
||||
periodFilter={periodFilter}
|
||||
onPeriodFilterChange={setPeriodFilter}
|
||||
onCreateClick={() => router.push("/leads/new")}
|
||||
/>
|
||||
</div>
|
||||
<LeadsTable data={leadsData} loading={loading} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import { useRef } from "react"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useUser } from "@/providers/user-provider"
|
||||
import { Mail, Calendar, Shield, Activity, Camera } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user, updateAvatar } = useUser()
|
||||
if (!user) return null
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const validTypes = ["image/png", "image/jpeg"]
|
||||
if (!validTypes.includes(file.type)) {
|
||||
toast.error("Only PNG and JPEG files are allowed")
|
||||
return
|
||||
}
|
||||
const reader = new FileReader()
|
||||
reader.onload = async () => {
|
||||
const dataUrl = reader.result as string
|
||||
try {
|
||||
const res = await fetch("/api/users/avatar", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ avatar: dataUrl }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
updateAvatar(data.avatar)
|
||||
toast.success("Avatar updated")
|
||||
} else {
|
||||
toast.error("Failed to save avatar")
|
||||
}
|
||||
} catch {
|
||||
console.warn("Failed to save avatar")
|
||||
toast.error("Failed to save avatar")
|
||||
}
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Profile" description="Your account information" />
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-1">
|
||||
<CardContent className="flex flex-col items-center pt-8">
|
||||
<div className="relative">
|
||||
<Avatar className="h-24 w-24">
|
||||
<AvatarImage src={user.avatar} />
|
||||
<AvatarFallback className="text-2xl">{user.name.split(" ").map((n) => n[0]).join("")}</AvatarFallback>
|
||||
</Avatar>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40 opacity-0 transition-opacity hover:opacity-100"
|
||||
>
|
||||
<Camera className="h-6 w-6 text-white" />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".png,.jpeg,.jpg"
|
||||
className="hidden"
|
||||
onChange={handleAvatarChange}
|
||||
/>
|
||||
</div>
|
||||
<h2 className="mt-4 text-xl font-semibold">{user.name}</h2>
|
||||
<Badge variant="secondary" className="mt-1 capitalize">
|
||||
{user.role}
|
||||
</Badge>
|
||||
<div className="mt-6 flex w-full flex-col gap-3 text-sm">
|
||||
<div className="flex items-center gap-3 rounded-lg bg-muted/50 px-4 py-3">
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">{user.email}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-lg bg-muted/50 px-4 py-3">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
Joined {new Date(user.createdAt).toLocaleDateString("en-US", { month: "long", year: "numeric" })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-lg bg-muted/50 px-4 py-3">
|
||||
<Shield className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground capitalize">{user.role} access</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-lg bg-muted/50 px-4 py-3">
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">{user.active ? "Active" : "Inactive"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Account Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Full Name</span>
|
||||
<p className="text-sm font-medium">{user.name}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Email</span>
|
||||
<p className="text-sm font-medium">{user.email}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Role</span>
|
||||
<p className="text-sm font-medium capitalize">{user.role}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Status</span>
|
||||
<p className="text-sm font-medium">{user.active ? "Active" : "Inactive"}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Member Since</span>
|
||||
<p className="text-sm font-medium">
|
||||
{new Date(user.createdAt).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">User ID</span>
|
||||
<p className="text-sm font-medium font-mono">{user.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { CompanySettingsForm } from "@/components/settings/company-settings-form"
|
||||
import { UserPreferencesForm } from "@/components/settings/user-preferences-form"
|
||||
import { ThemeSettings } from "@/components/settings/theme-settings"
|
||||
import { NotificationSettings } from "@/components/settings/notification-settings"
|
||||
import { Building2, User, Palette, Bell } from "lucide-react"
|
||||
|
||||
export default function SettingsPage() {
|
||||
const tabs = [
|
||||
{ value: "company", label: "Company", icon: Building2, component: CompanySettingsForm },
|
||||
{ value: "preferences", label: "Preferences", icon: User, component: UserPreferencesForm },
|
||||
{ value: "theme", label: "Theme", icon: Palette, component: ThemeSettings },
|
||||
{ value: "notifications", label: "Notifications", icon: Bell, component: NotificationSettings },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Settings"
|
||||
description="Manage your CRM configuration and preferences"
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="company" className="space-y-6">
|
||||
<TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value} className="gap-2">
|
||||
<tab.icon className="h-4 w-4" />
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent key={tab.value} value={tab.value}>
|
||||
<tab.component />
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { UsersTable } from "@/components/users/users-table"
|
||||
import { UserFormDialog } from "@/components/users/user-form-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useUser } from "@/providers/user-provider"
|
||||
import { Plus, Users as UsersIcon, Search } from "lucide-react"
|
||||
import type { User } from "@/types"
|
||||
|
||||
export default function UsersPage() {
|
||||
const { user } = useUser()
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/users")
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setUsers(data.users || [])
|
||||
}
|
||||
} catch {
|
||||
console.warn("Failed to fetch users in users page")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers()
|
||||
}, [fetchUsers])
|
||||
|
||||
const activeUsers = users.filter((u) => u.active !== false)
|
||||
const admins = users.filter((u) => u.role === "admin")
|
||||
|
||||
const stats = [
|
||||
{ label: "Total Users", value: users.length, color: "bg-blue-500/10", textColor: "text-blue-500" },
|
||||
{ label: "Active", value: activeUsers.length, color: "bg-green-500/10", textColor: "text-green-500" },
|
||||
{ label: "Admins", value: admins.length, color: "bg-purple-500/10", textColor: "text-purple-500" },
|
||||
{ label: "Sales", value: users.filter((u) => u.role === "sales").length, color: "bg-orange-500/10", textColor: "text-orange-500" },
|
||||
]
|
||||
|
||||
const filtered = users.filter((u) => {
|
||||
if (!search) return true
|
||||
const q = search.toLowerCase()
|
||||
return u.name?.toLowerCase().includes(q) || u.email?.toLowerCase().includes(q)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Users"
|
||||
description="Manage team members and their roles"
|
||||
>
|
||||
<Button className="gap-2" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add User
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-6">
|
||||
{stats.map((s) => (
|
||||
<div key={s.label} className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-lg ${s.color}`}
|
||||
>
|
||||
<UsersIcon className={`h-5 w-5 ${s.textColor}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{s.value}</p>
|
||||
<p className="text-xs text-muted-foreground">{s.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by name or email..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-9 pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card">
|
||||
<UsersTable data={filtered} loading={loading} onUserDeleted={fetchUsers} />
|
||||
</div>
|
||||
|
||||
<UserFormDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
onUserCreated={fetchUsers}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { chatWithAI } from "@/lib/ai"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
if (!["sales", "admin", "super_admin"].includes(user.role)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
|
||||
const { message } = await request.json()
|
||||
if (!message || typeof message !== "string") {
|
||||
return NextResponse.json({ error: "Message is required" }, { status: 400 })
|
||||
}
|
||||
|
||||
// Forward the JWT from the session cookie to the Rust backend
|
||||
const sessionCookie = request.cookies.get("session")?.value
|
||||
if (!sessionCookie) return NextResponse.json({ error: "No session" }, { status: 401 })
|
||||
|
||||
const response = await chatWithAI(message, sessionCookie)
|
||||
|
||||
return NextResponse.json({ response })
|
||||
} catch (error) {
|
||||
console.error("AI chat error:", error)
|
||||
return NextResponse.json({ error: "AI service unavailable" }, { status: 503 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { fetchJobs } from "@/lib/ai"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
if (!["sales", "admin", "super_admin"].includes(user.role)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
|
||||
const jobs = await fetchJobs()
|
||||
return NextResponse.json({ jobs })
|
||||
} catch {
|
||||
console.warn("Failed to fetch AI jobs in API route")
|
||||
return NextResponse.json({ jobs: [] })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { cookies } from "next/headers"
|
||||
|
||||
export async function GET() {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get("session")?.value
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: "No session" }, { status: 401 })
|
||||
}
|
||||
return NextResponse.json({ token })
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import {
|
||||
comparePassword,
|
||||
getUserByEmail,
|
||||
getUserByUsername,
|
||||
mapDbUserToSessionUser,
|
||||
recordLoginAttempt,
|
||||
incrementFailedAttempts,
|
||||
resetFailedAttempts,
|
||||
isAccountLocked,
|
||||
createSession,
|
||||
} from "@/lib/auth"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { email, username, password } = await request.json()
|
||||
|
||||
const credential = email || username
|
||||
|
||||
if (!credential || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: "Email/Username and password are required." },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (credential.trim().length === 0 || password.trim().length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Credentials cannot be empty." },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const ipAddress =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
request.headers.get("x-real-ip") ||
|
||||
"127.0.0.1"
|
||||
|
||||
const userAgent = request.headers.get("user-agent") || null
|
||||
|
||||
// Try to find user by email first, then by username
|
||||
let dbUser =
|
||||
email || credential.includes("@")
|
||||
? await getUserByEmail(credential)
|
||||
: null
|
||||
|
||||
if (!dbUser) {
|
||||
dbUser = await getUserByUsername(credential)
|
||||
}
|
||||
|
||||
if (!dbUser) {
|
||||
await recordLoginAttempt(
|
||||
null,
|
||||
credential,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
false,
|
||||
"User not found"
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid email/username or password." },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const lockStatus = await isAccountLocked(dbUser)
|
||||
if (lockStatus.locked) {
|
||||
await recordLoginAttempt(
|
||||
dbUser.id,
|
||||
credential,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
false,
|
||||
lockStatus.reason
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: lockStatus.reason },
|
||||
{ status: 423 }
|
||||
)
|
||||
}
|
||||
|
||||
const valid = await comparePassword(password, dbUser.password_hash)
|
||||
if (!valid) {
|
||||
await incrementFailedAttempts(dbUser.id)
|
||||
await recordLoginAttempt(
|
||||
dbUser.id,
|
||||
credential,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
false,
|
||||
"Invalid password"
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid email/username or password." },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
await resetFailedAttempts(dbUser.id)
|
||||
await recordLoginAttempt(
|
||||
dbUser.id,
|
||||
credential,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
true
|
||||
)
|
||||
|
||||
await createSession(dbUser.id, dbUser.role_name)
|
||||
|
||||
const user = mapDbUserToSessionUser(dbUser)
|
||||
|
||||
return NextResponse.json({ user }, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error("Login error:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Authentication service unavailable." },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { destroySession } from "@/lib/auth"
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
await destroySession()
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error("Logout error:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Logout failed." },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
|
||||
}
|
||||
return NextResponse.json({ user }, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error("Auth me error:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Authentication service unavailable." },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import {
|
||||
getSessionUser,
|
||||
decryptPassword,
|
||||
setSessionContext,
|
||||
} from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const sessionUser = await getSessionUser()
|
||||
if (!sessionUser) {
|
||||
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
|
||||
}
|
||||
if (sessionUser.role !== "super_admin") {
|
||||
return NextResponse.json({ error: "Only SUPER_ADMIN can recover passwords." }, { status: 403 })
|
||||
}
|
||||
|
||||
const { userId } = await request.json()
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "userId is required." }, { status: 400 })
|
||||
}
|
||||
|
||||
const ipAddress =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
request.headers.get("x-real-ip") ||
|
||||
"127.0.0.1"
|
||||
|
||||
await setSessionContext(sessionUser.id, ipAddress)
|
||||
|
||||
const result = await query(
|
||||
`SELECT id, username, email, first_name, last_name, password_encrypted
|
||||
FROM users WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[userId]
|
||||
)
|
||||
|
||||
const user = result.rows[0]
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User not found." }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!user.password_encrypted) {
|
||||
return NextResponse.json({ error: "No encrypted password stored for this user." }, { status: 404 })
|
||||
}
|
||||
|
||||
const plaintextPassword = await decryptPassword(user.password_encrypted)
|
||||
if (!plaintextPassword) {
|
||||
return NextResponse.json({ error: "Failed to decrypt password. Master key may have changed." }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
name: `${user.first_name} ${user.last_name}`,
|
||||
},
|
||||
password: plaintextPassword,
|
||||
}, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error("Password recovery error:", error)
|
||||
return NextResponse.json({ error: "Recovery service unavailable." }, { status: 503 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { query } from "@/lib/db"
|
||||
import { getSessionUser, setSessionContext } from "@/lib/auth"
|
||||
|
||||
export async function PATCH(request: NextRequest, { params: routeParams }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const sessionUser = await getSessionUser()
|
||||
if (!sessionUser) {
|
||||
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
|
||||
}
|
||||
if (sessionUser.role !== "admin" && sessionUser.role !== "super_admin") {
|
||||
return NextResponse.json({ error: "Forbidden. Only admins can update bug reports." }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await routeParams
|
||||
const { status, assigned_to, resolution_notes } = await request.json()
|
||||
|
||||
const ipAddress =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
request.headers.get("x-real-ip") ||
|
||||
"127.0.0.1"
|
||||
|
||||
await setSessionContext(sessionUser.id, ipAddress)
|
||||
|
||||
const validStatuses = ["open", "in_progress", "resolved", "closed"]
|
||||
if (status && !validStatuses.includes(status)) {
|
||||
return NextResponse.json({ error: "Invalid status." }, { status: 400 })
|
||||
}
|
||||
|
||||
const existing = await query("SELECT id, status FROM bug_reports WHERE id = $1", [id])
|
||||
if (existing.rows.length === 0) {
|
||||
return NextResponse.json({ error: "Bug report not found." }, { status: 404 })
|
||||
}
|
||||
|
||||
const updates: string[] = []
|
||||
const values: unknown[] = []
|
||||
let paramIndex = 1
|
||||
|
||||
if (status !== undefined) {
|
||||
updates.push(`status = $${paramIndex++}`)
|
||||
values.push(status)
|
||||
}
|
||||
if (assigned_to !== undefined) {
|
||||
updates.push(`assigned_to = $${paramIndex++}`)
|
||||
values.push(assigned_to === "null" ? null : assigned_to)
|
||||
}
|
||||
if (resolution_notes !== undefined) {
|
||||
updates.push(`resolution_notes = $${paramIndex++}`)
|
||||
values.push(resolution_notes)
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return NextResponse.json({ error: "No fields to update." }, { status: 400 })
|
||||
}
|
||||
|
||||
updates.push(`updated_at = NOW()`)
|
||||
values.push(id)
|
||||
|
||||
await query(
|
||||
`UPDATE bug_reports SET ${updates.join(", ")} WHERE id = $${paramIndex}`,
|
||||
values
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error("Error updating bug report:", error)
|
||||
return NextResponse.json({ error: "Failed to update bug report." }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { query } from "@/lib/db"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const sessionUser = await getSessionUser()
|
||||
if (!sessionUser) {
|
||||
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
|
||||
}
|
||||
|
||||
const { title, description, severity, page_url, screenshot_url } = await request.json()
|
||||
|
||||
if (!title || !description) {
|
||||
return NextResponse.json({ error: "Title and description are required." }, { status: 400 })
|
||||
}
|
||||
|
||||
const validSeverities = ["low", "medium", "high", "critical"]
|
||||
if (severity && !validSeverities.includes(severity)) {
|
||||
return NextResponse.json({ error: "Invalid severity." }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO bug_reports (reported_by, title, description, severity, page_url, screenshot_url)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, created_at`,
|
||||
[sessionUser.id, title, description, severity || "medium", page_url || null, screenshot_url || null]
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
id: result.rows[0].id,
|
||||
created_at: result.rows[0].created_at,
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error("Error creating bug report:", error)
|
||||
return NextResponse.json({ error: "Failed to submit bug report." }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const sessionUser = await getSessionUser()
|
||||
if (!sessionUser) {
|
||||
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
|
||||
}
|
||||
if (sessionUser.role !== "admin" && sessionUser.role !== "super_admin") {
|
||||
return NextResponse.json({ error: "Forbidden. Only admins can view bug reports." }, { status: 403 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const status = searchParams.get("status")
|
||||
const severity = searchParams.get("severity")
|
||||
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
||||
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||
|
||||
let sql = `SELECT br.id, br.title, br.description, br.severity, br.page_url,
|
||||
br.screenshot_url, br.status, br.resolution_notes,
|
||||
br.created_at, br.updated_at,
|
||||
reporter.id AS reporter_id,
|
||||
reporter.first_name AS reporter_first_name,
|
||||
reporter.last_name AS reporter_last_name,
|
||||
reporter.email AS reporter_email,
|
||||
assignee.id AS assignee_id,
|
||||
assignee.first_name AS assignee_first_name,
|
||||
assignee.last_name AS assignee_last_name
|
||||
FROM bug_reports br
|
||||
JOIN users reporter ON reporter.id = br.reported_by
|
||||
LEFT JOIN users assignee ON assignee.id = br.assigned_to`
|
||||
const params: unknown[] = []
|
||||
const conditions: string[] = []
|
||||
|
||||
if (status) {
|
||||
conditions.push(`br.status = $${params.length + 1}`)
|
||||
params.push(status)
|
||||
}
|
||||
if (severity) {
|
||||
conditions.push(`br.severity = $${params.length + 1}`)
|
||||
params.push(severity)
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
sql += " WHERE " + conditions.join(" AND ")
|
||||
}
|
||||
|
||||
sql += " ORDER BY br.created_at DESC LIMIT $" + (params.length + 1) + " OFFSET $" + (params.length + 2)
|
||||
params.push(limit, offset)
|
||||
|
||||
const result = await query(sql, params)
|
||||
|
||||
const reports = result.rows.map((row: any) => ({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
severity: row.severity,
|
||||
page_url: row.page_url,
|
||||
screenshot_url: row.screenshot_url,
|
||||
status: row.status,
|
||||
resolution_notes: row.resolution_notes,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
reporter: {
|
||||
id: row.reporter_id,
|
||||
name: `${row.reporter_first_name} ${row.reporter_last_name}`,
|
||||
email: row.reporter_email,
|
||||
},
|
||||
assignee: row.assignee_id ? {
|
||||
id: row.assignee_id,
|
||||
name: `${row.assignee_first_name} ${row.assignee_last_name}`,
|
||||
} : null,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ reports }, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error("Error fetching bug reports:", error)
|
||||
return NextResponse.json({ error: "Failed to fetch bug reports." }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
import { avatarSvgUrl } from "@/lib/avatar"
|
||||
|
||||
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 { searchParams } = new URL(_request.url)
|
||||
const limit = parseInt(searchParams.get("limit") || "100", 10)
|
||||
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||
const before = searchParams.get("before") || ""
|
||||
|
||||
// Verify user is a participant
|
||||
const partCheck = await query(
|
||||
`SELECT 1 FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2`,
|
||||
[id, user.id]
|
||||
)
|
||||
if (partCheck.rows.length === 0) {
|
||||
return NextResponse.json({ error: "Not a participant" }, { status: 403 })
|
||||
}
|
||||
|
||||
let msgSql = `SELECT m.id, m.sender_id, m.content, m.created_at, m.updated_at, m.deleted_at,
|
||||
u.first_name || ' ' || u.last_name AS sender_name,
|
||||
u.email AS sender_email,
|
||||
u.avatar_url AS sender_avatar_url
|
||||
FROM messages m
|
||||
JOIN users u ON u.id = m.sender_id
|
||||
WHERE m.conversation_id = $1 AND m.deleted_at IS NULL`
|
||||
const msgParams: any[] = [id]
|
||||
|
||||
if (before) {
|
||||
msgSql += ` AND m.created_at < $2`
|
||||
msgParams.push(before)
|
||||
}
|
||||
|
||||
msgSql += ` ORDER BY m.created_at ASC`
|
||||
msgSql += ` LIMIT $${msgParams.length + 1} OFFSET $${msgParams.length + 2}`
|
||||
msgParams.push(limit, offset)
|
||||
|
||||
const [msgResult, otherReadResult] = await Promise.all([
|
||||
query(msgSql, msgParams),
|
||||
query(
|
||||
`SELECT last_read_at FROM conversation_participants
|
||||
WHERE conversation_id = $1 AND user_id != $2`,
|
||||
[id, user.id],
|
||||
),
|
||||
])
|
||||
|
||||
const otherLastReadAt = otherReadResult.rows[0]?.last_read_at
|
||||
? new Date(otherReadResult.rows[0].last_read_at).getTime()
|
||||
: 0
|
||||
|
||||
const messages = msgResult.rows.map((row: any) => ({
|
||||
id: row.id,
|
||||
conversationId: id,
|
||||
senderId: row.sender_id,
|
||||
senderName: row.sender_name,
|
||||
senderAvatar: avatarSvgUrl(row.sender_name),
|
||||
content: row.content,
|
||||
timestamp: formatTime(new Date(row.created_at)),
|
||||
createdAt: row.created_at,
|
||||
read: row.sender_id === user.id
|
||||
? new Date(row.created_at).getTime() <= otherLastReadAt
|
||||
: true,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ messages })
|
||||
} catch (error) {
|
||||
console.error("Messages error:", error)
|
||||
return NextResponse.json({ error: "Failed to load messages" }, { 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
|
||||
|
||||
// Verify user is a participant
|
||||
const partCheck = await query(
|
||||
`SELECT 1 FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2`,
|
||||
[id, user.id]
|
||||
)
|
||||
if (partCheck.rows.length === 0) {
|
||||
return NextResponse.json({ error: "Not a participant" }, { status: 403 })
|
||||
}
|
||||
|
||||
const { content } = await request.json()
|
||||
if (!content?.trim()) {
|
||||
return NextResponse.json({ error: "Message content is required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO messages (conversation_id, sender_id, content)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, created_at`,
|
||||
[id, user.id, content.trim()],
|
||||
)
|
||||
|
||||
await query(
|
||||
`UPDATE conversations SET updated_at = NOW() WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
|
||||
const msg = result.rows[0]
|
||||
const senderName = `${user.firstName} ${user.lastName}`
|
||||
|
||||
const otherResult = await query(
|
||||
`SELECT user_id FROM conversation_participants
|
||||
WHERE conversation_id = $1 AND user_id != $2`,
|
||||
[id, user.id],
|
||||
)
|
||||
|
||||
for (const row of otherResult.rows) {
|
||||
await query(
|
||||
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type)
|
||||
VALUES ($1, 'chat_message', 'New Message', $2, '/chats', $3, 'conversation')`,
|
||||
[row.user_id, `${senderName} sent a message`, id],
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: {
|
||||
id: msg.id,
|
||||
conversationId: id,
|
||||
senderId: user.id,
|
||||
senderName,
|
||||
senderAvatar: user.avatar,
|
||||
content: content.trim(),
|
||||
timestamp: formatTime(new Date(msg.created_at)),
|
||||
createdAt: msg.created_at,
|
||||
read: false,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Send message error:", error)
|
||||
return NextResponse.json({ error: "Failed to send message" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
const now = new Date()
|
||||
const isToday = date.toDateString() === now.toDateString()
|
||||
if (isToday) {
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
return date.toLocaleDateString([], { month: "short", day: "numeric" }) + " " +
|
||||
date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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
|
||||
|
||||
await query(
|
||||
`UPDATE conversation_participants
|
||||
SET last_read_at = NOW()
|
||||
WHERE conversation_id = $1 AND user_id = $2`,
|
||||
[id, user.id],
|
||||
)
|
||||
|
||||
await query(
|
||||
`UPDATE notifications SET is_read = TRUE
|
||||
WHERE user_id = $1 AND context_type = 'conversation' AND context_id = $2 AND is_read = FALSE`,
|
||||
[user.id, id],
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Mark read error:", error)
|
||||
return NextResponse.json({ error: "Failed to mark as read" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
import { avatarSvgUrl } from "@/lib/avatar"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const result = await query(
|
||||
`SELECT
|
||||
c.id,
|
||||
c.updated_at,
|
||||
cp_me.last_read_at,
|
||||
u.id AS other_user_id,
|
||||
u.first_name || ' ' || u.last_name AS other_user_name,
|
||||
u.email AS other_user_email,
|
||||
u.avatar_url AS other_user_avatar_url,
|
||||
(SELECT content FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message,
|
||||
(SELECT created_at FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message_time,
|
||||
(SELECT count(*) FROM messages WHERE conversation_id = c.id AND sender_id != $1 AND created_at > COALESCE(cp_me.last_read_at, '1970-01-01')) AS unread
|
||||
FROM conversations c
|
||||
JOIN conversation_participants cp_me ON cp_me.conversation_id = c.id AND cp_me.user_id = $1
|
||||
JOIN conversation_participants cp ON cp.conversation_id = c.id
|
||||
JOIN users u ON u.id = cp.user_id AND u.id != $1
|
||||
WHERE c.id IN (
|
||||
SELECT conversation_id FROM conversation_participants WHERE user_id = $1
|
||||
)
|
||||
ORDER BY c.updated_at DESC
|
||||
LIMIT 50`,
|
||||
[user.id],
|
||||
)
|
||||
|
||||
const conversations = result.rows.map((row: any) => ({
|
||||
id: row.id,
|
||||
updatedAt: row.updated_at,
|
||||
otherUser: {
|
||||
id: row.other_user_id,
|
||||
name: row.other_user_name,
|
||||
email: row.other_user_email,
|
||||
avatar: avatarSvgUrl(row.other_user_name),
|
||||
},
|
||||
lastMessage: row.last_message || "",
|
||||
lastMessageTime: row.last_message_time ? timeAgo(new Date(row.last_message_time)) : "",
|
||||
unread: parseInt(row.unread) || 0,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ conversations })
|
||||
} catch (error) {
|
||||
console.error("Conversations error:", error)
|
||||
return NextResponse.json({ error: "Failed to load conversations" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { userId } = await request.json()
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "userId is required" }, { status: 400 })
|
||||
}
|
||||
|
||||
if (userId === user.id) {
|
||||
return NextResponse.json({ error: "Cannot start a conversation with yourself" }, { status: 400 })
|
||||
}
|
||||
|
||||
const existing = await query(
|
||||
`SELECT c.id FROM conversations c
|
||||
JOIN conversation_participants cp1 ON cp1.conversation_id = c.id AND cp1.user_id = $1
|
||||
JOIN conversation_participants cp2 ON cp2.conversation_id = c.id AND cp2.user_id = $2
|
||||
LIMIT 1`,
|
||||
[user.id, userId],
|
||||
)
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
return NextResponse.json({ conversationId: existing.rows[0].id })
|
||||
}
|
||||
|
||||
const convResult = await query(
|
||||
`INSERT INTO conversations DEFAULT VALUES RETURNING id`,
|
||||
)
|
||||
const conversationId = convResult.rows[0].id
|
||||
|
||||
await query(
|
||||
`INSERT INTO conversation_participants (conversation_id, user_id, last_read_at) VALUES ($1, $2, NOW()), ($1, $3, NOW())`,
|
||||
[conversationId, user.id, userId],
|
||||
)
|
||||
|
||||
const otherUser = await query(
|
||||
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
|
||||
FROM users WHERE id = $1`,
|
||||
[userId],
|
||||
)
|
||||
|
||||
const other = otherUser.rows[0]
|
||||
|
||||
return NextResponse.json({
|
||||
conversation: {
|
||||
id: conversationId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
otherUser: {
|
||||
id: other.id,
|
||||
name: other.name,
|
||||
email: other.email,
|
||||
avatar: avatarSvgUrl(other.name),
|
||||
},
|
||||
lastMessage: "",
|
||||
lastMessageTime: "",
|
||||
unread: 0,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Create conversation error:", error)
|
||||
return NextResponse.json({ error: "Failed to create conversation" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
function timeAgo(date: Date): string {
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
|
||||
if (seconds < 60) return "now"
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 7) return `${days}d ago`
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
import { avatarSvgUrl } from "@/lib/avatar"
|
||||
|
||||
function getPeriodDateRange(period: string): { start: Date; end: Date } {
|
||||
const end = new Date()
|
||||
let start: Date
|
||||
switch (period) {
|
||||
case "7days":
|
||||
start = new Date(end); start.setDate(start.getDate() - 7); break
|
||||
case "30days":
|
||||
start = new Date(end); start.setDate(start.getDate() - 30); break
|
||||
case "12months":
|
||||
start = new Date(end); start.setFullYear(start.getFullYear() - 12); break
|
||||
case "6months":
|
||||
default:
|
||||
start = new Date(end); start.setMonth(start.getMonth() - 6); break
|
||||
}
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
function getPreviousPeriodRange(period: string, currentStart: Date): { start: Date; end: Date } {
|
||||
const end = new Date(currentStart)
|
||||
const diff = end.getTime() - currentStart.getTime()
|
||||
const start = new Date(end.getTime() - diff)
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
const periodLabels: Record<string, string> = {
|
||||
"7days": "Last 7 days",
|
||||
"30days": "Last 30 days",
|
||||
"6months": "Last 6 months",
|
||||
"12months": "Last 12 months",
|
||||
}
|
||||
|
||||
function stageToStatus(name: string): string {
|
||||
switch (name) {
|
||||
case "New": return "open"
|
||||
case "Contacted": return "contacted"
|
||||
case "Qualified":
|
||||
case "Interested":
|
||||
case "Demo Scheduled":
|
||||
case "Negotiation": return "pending"
|
||||
case "Closed Won": return "closed"
|
||||
case "Closed Lost": return "ignored"
|
||||
default: return "open"
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLeadsInRange(start: Date, end: Date, userId?: string, isAdmin?: boolean) {
|
||||
const result = await query(
|
||||
`SELECT l.id, l.created_at, l.company_name, l.contact_name, l.email, l.phone,
|
||||
l.notes, l.assigned_to, l.score,
|
||||
ls.name AS stage_name,
|
||||
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
|
||||
FROM leads l
|
||||
JOIN lead_stages ls ON ls.id = l.stage_id
|
||||
LEFT JOIN users u ON u.id = l.assigned_to
|
||||
WHERE l.deleted_at IS NULL
|
||||
AND l.created_at >= $1 AND l.created_at <= $2
|
||||
${isAdmin ? "" : "AND l.assigned_to = $3"}
|
||||
ORDER BY l.created_at DESC`,
|
||||
isAdmin
|
||||
? [start.toISOString(), end.toISOString()]
|
||||
: [start.toISOString(), end.toISOString(), userId]
|
||||
)
|
||||
return result.rows.map((r: any) => ({
|
||||
...r,
|
||||
status: stageToStatus(r.stage_name),
|
||||
}))
|
||||
}
|
||||
|
||||
function countStatuses(leads: any[]) {
|
||||
const counts = { open: 0, contacted: 0, pending: 0, closed: 0, ignored: 0 }
|
||||
leads.forEach((l: any) => {
|
||||
const s = l.status as keyof typeof counts
|
||||
if (s in counts) counts[s]++
|
||||
})
|
||||
return counts
|
||||
}
|
||||
|
||||
function buildMonthlyBreakdown(leads: any[], period: string) {
|
||||
const { start, end } = getPeriodDateRange(period)
|
||||
const result: { label: string; total: number; open: number; contacted: number; pending: number; closed: number; ignored: number }[] = []
|
||||
const current = new Date(start)
|
||||
const isMonthly = period === "6months" || period === "12months"
|
||||
|
||||
while (current <= end) {
|
||||
const label = isMonthly
|
||||
? current.toLocaleDateString("en-US", { month: "short", year: "2-digit" })
|
||||
: current.toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
||||
|
||||
const ps = new Date(current)
|
||||
const pe = isMonthly
|
||||
? new Date(current.getFullYear(), current.getMonth() + 1, 0, 23, 59, 59)
|
||||
: (() => { const d = new Date(current); d.setHours(23, 59, 59, 999); return d })()
|
||||
|
||||
const inPeriod = leads.filter((l: any) => {
|
||||
const d = new Date(l.created_at)
|
||||
return d >= ps && d <= pe
|
||||
})
|
||||
|
||||
const counts = countStatuses(inPeriod)
|
||||
result.push({ label, total: inPeriod.length, ...counts })
|
||||
|
||||
if (isMonthly) current.setMonth(current.getMonth() + 1)
|
||||
else current.setDate(current.getDate() + 1)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function computeTrend(current: number, previous: number): { pct: number; up: boolean } {
|
||||
if (previous === 0) return { pct: current > 0 ? 100 : 0, up: current > 0 }
|
||||
const pct = Math.round(((current - previous) / previous) * 100)
|
||||
return { pct: Math.abs(pct), up: pct >= 0 }
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const period = searchParams.get("period") || "6months"
|
||||
const yearParam = searchParams.get("year")
|
||||
let start: Date, end: Date, prevRange: { start: Date; end: Date }
|
||||
if (yearParam) {
|
||||
const y = parseInt(yearParam)
|
||||
start = new Date(y, 0, 1)
|
||||
end = new Date(y, 11, 31, 23, 59, 59)
|
||||
prevRange = { start: new Date(y - 1, 0, 1), end: new Date(y - 1, 11, 31, 23, 59, 59) }
|
||||
} else {
|
||||
const r = getPeriodDateRange(period)
|
||||
start = r.start; end = r.end
|
||||
prevRange = getPreviousPeriodRange(period, start)
|
||||
}
|
||||
|
||||
const [currentLeads, prevLeads] = await Promise.all([
|
||||
fetchLeadsInRange(start, end, user.id, isAdmin),
|
||||
fetchLeadsInRange(prevRange.start, prevRange.end, user.id, isAdmin),
|
||||
])
|
||||
|
||||
const currentCounts = countStatuses(currentLeads)
|
||||
const prevCounts = countStatuses(prevLeads)
|
||||
|
||||
const totalLeads = currentLeads.length
|
||||
const closedLeads = currentCounts.closed
|
||||
const conversionRate = totalLeads > 0 ? Math.round((closedLeads / totalLeads) * 100) : 0
|
||||
|
||||
const mappedLeads = currentLeads.map((r: any) => ({
|
||||
id: r.id,
|
||||
companyName: r.company_name || "",
|
||||
contactName: r.contact_name,
|
||||
email: r.email || "",
|
||||
phone: r.phone || "",
|
||||
source: "",
|
||||
description: r.notes || "",
|
||||
status: r.status,
|
||||
assignedUserId: r.assigned_to,
|
||||
assignedUser: r.assigned_to ? {
|
||||
id: r.user_id,
|
||||
name: `${r.first_name} ${r.last_name}`,
|
||||
email: r.user_email,
|
||||
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
|
||||
} : null,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
}))
|
||||
|
||||
const monthlyBreakdown = buildMonthlyBreakdown(currentLeads, period)
|
||||
|
||||
const trends = {
|
||||
totalLeads: computeTrend(currentCounts.open + currentCounts.contacted + currentCounts.pending + currentCounts.closed + currentCounts.ignored,
|
||||
prevCounts.open + prevCounts.contacted + prevCounts.pending + prevCounts.closed + prevCounts.ignored),
|
||||
openLeads: computeTrend(currentCounts.open, prevCounts.open),
|
||||
contactedLeads: computeTrend(currentCounts.contacted, prevCounts.contacted),
|
||||
pendingLeads: computeTrend(currentCounts.pending, prevCounts.pending),
|
||||
closedLeads: computeTrend(currentCounts.closed, prevCounts.closed),
|
||||
conversionRate: computeTrend(conversionRate,
|
||||
prevLeads.length > 0 ? Math.round((prevCounts.closed / prevLeads.length) * 100) : 0),
|
||||
}
|
||||
|
||||
const stats = {
|
||||
totalLeads,
|
||||
openLeads: currentCounts.open,
|
||||
contactedLeads: currentCounts.contacted,
|
||||
pendingLeads: currentCounts.pending,
|
||||
closedLeads,
|
||||
ignoredLeads: currentCounts.ignored,
|
||||
conversionRate,
|
||||
monthlyBreakdown,
|
||||
leadsPerMonth: monthlyBreakdown.map((m: any) => ({ label: m.label, leads: m.total, closed: m.closed })),
|
||||
trends,
|
||||
recentLeads: mappedLeads.slice(0, 10),
|
||||
statusDistribution: [
|
||||
{ name: "Open", value: currentCounts.open, color: "#3b82f6" },
|
||||
{ name: "Contacted", value: currentCounts.contacted, color: "#f59e0b" },
|
||||
{ name: "Pending", value: currentCounts.pending, color: "#8b5cf6" },
|
||||
{ name: "Closed", value: currentCounts.closed, color: "#10b981" },
|
||||
{ name: "Ignored", value: currentCounts.ignored, color: "#6B7280" },
|
||||
],
|
||||
periodLabel: periodLabels[period] ?? "Selected period",
|
||||
}
|
||||
|
||||
return NextResponse.json(stats)
|
||||
} catch (error) {
|
||||
console.error("Dashboard API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load dashboard stats" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { query } from "@/lib/db"
|
||||
import crypto from "crypto"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { phone, token: clientToken } = await request.json()
|
||||
if (!phone) {
|
||||
return NextResponse.json({ error: "Phone number required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const token = clientToken || crypto.randomBytes(24).toString("hex")
|
||||
await query(
|
||||
`INSERT INTO invites (token, phone) VALUES ($1, $2)`,
|
||||
[token, phone],
|
||||
)
|
||||
|
||||
const origin = request.headers.get("origin") || "http://localhost:3000"
|
||||
const inviteUrl = `${origin}/join/${token}`
|
||||
|
||||
return NextResponse.json({ token, inviteUrl })
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Failed to generate invite" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
import { avatarSvgUrl } from "@/lib/avatar"
|
||||
|
||||
async function checkLeadAccess(leadId: string, userId: string): Promise<boolean> {
|
||||
const result = await query(
|
||||
`SELECT 1 FROM leads WHERE id = $1 AND deleted_at IS NULL
|
||||
AND (assigned_to = $2 OR EXISTS (
|
||||
SELECT 1 FROM user_roles ur JOIN roles r ON r.id = ur.role_id
|
||||
WHERE ur.user_id = $2 AND r.name IN ('ADMIN', 'SUPER_ADMIN')
|
||||
))`,
|
||||
[leadId, userId]
|
||||
)
|
||||
return result.rows.length > 0
|
||||
}
|
||||
|
||||
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
|
||||
if (!await checkLeadAccess(id, user.id)) {
|
||||
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
const { content } = await request.json()
|
||||
if (!content?.trim()) {
|
||||
return NextResponse.json({ error: "Content is required" }, { status: 400 })
|
||||
}
|
||||
|
||||
await query(
|
||||
`INSERT INTO customer_notes (customer_id, author_id, content)
|
||||
VALUES ($1, $2, $3)`,
|
||||
[id, user.id, content.trim()]
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Create note error:", error)
|
||||
return NextResponse.json({ error: "Failed to create note" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
if (!await checkLeadAccess(id, user.id)) {
|
||||
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
|
||||
}
|
||||
const { searchParams } = new URL(request.url)
|
||||
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
||||
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||
|
||||
const result = await query(
|
||||
`SELECT cn.id, cn.created_at, cn.updated_at, cn.content,
|
||||
u.id AS user_id, u.first_name, u.last_name, u.avatar_url
|
||||
FROM customer_notes cn
|
||||
JOIN users u ON u.id = cn.author_id
|
||||
WHERE cn.customer_id = $1 AND cn.deleted_at IS NULL
|
||||
ORDER BY cn.created_at DESC
|
||||
LIMIT $2 OFFSET $3`,
|
||||
[id, limit, offset]
|
||||
)
|
||||
|
||||
const notes = result.rows.map((r: any) => ({
|
||||
id: r.id,
|
||||
leadId: id,
|
||||
userId: r.user_id,
|
||||
authorName: `${r.first_name} ${r.last_name}`,
|
||||
authorAvatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
|
||||
note: r.content,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
}))
|
||||
|
||||
return NextResponse.json(notes)
|
||||
} catch (error) {
|
||||
console.error("Lead notes API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load notes" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
import { avatarSvgUrl } from "@/lib/avatar"
|
||||
|
||||
function stageToStatus(name: string): string {
|
||||
switch (name) {
|
||||
case "New": return "open"
|
||||
case "Contacted": return "contacted"
|
||||
case "Qualified":
|
||||
case "Interested":
|
||||
case "Demo Scheduled":
|
||||
case "Negotiation": return "pending"
|
||||
case "Closed Won": return "closed"
|
||||
case "Closed Lost": return "ignored"
|
||||
default: return "open"
|
||||
}
|
||||
}
|
||||
|
||||
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 isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||
|
||||
const result = await query(
|
||||
`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,
|
||||
ls.name AS stage_name,
|
||||
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
|
||||
FROM leads l
|
||||
JOIN lead_stages ls ON ls.id = l.stage_id
|
||||
LEFT JOIN users u ON u.id = l.assigned_to
|
||||
WHERE l.id = $1 AND l.deleted_at IS NULL
|
||||
AND ($2 = true OR l.assigned_to = $3)`,
|
||||
[id, isAdmin, user.id]
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
const r = result.rows[0]
|
||||
const lead = {
|
||||
id: r.id,
|
||||
companyName: r.company_name || "",
|
||||
contactName: r.contact_name,
|
||||
email: r.email || "",
|
||||
phone: r.phone || "",
|
||||
source: "",
|
||||
description: r.notes || "",
|
||||
status: stageToStatus(r.stage_name),
|
||||
assignedUserId: r.assigned_to,
|
||||
assignedUser: r.assigned_to ? {
|
||||
id: r.user_id,
|
||||
name: `${r.first_name} ${r.last_name}`,
|
||||
email: r.user_email,
|
||||
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
|
||||
} : null,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
}
|
||||
|
||||
return NextResponse.json(lead)
|
||||
} catch (error) {
|
||||
console.error("Lead detail API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load lead" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
function statusToStageName(status: string): string {
|
||||
switch (status) {
|
||||
case "open": return "New"
|
||||
case "contacted": return "Contacted"
|
||||
case "pending": return "Qualified"
|
||||
case "closed": return "Closed Won"
|
||||
case "ignored": return "Closed Lost"
|
||||
default: return "New"
|
||||
}
|
||||
}
|
||||
|
||||
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 isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||
|
||||
// Verify access
|
||||
const accessCheck = await query(
|
||||
`SELECT id FROM leads WHERE id = $1 AND deleted_at IS NULL
|
||||
AND ($2 = true OR assigned_to = $3)`,
|
||||
[id, isAdmin, user.id]
|
||||
)
|
||||
if (accessCheck.rows.length === 0) {
|
||||
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const fields: string[] = []
|
||||
const values: any[] = []
|
||||
let idx = 1
|
||||
|
||||
if (body.companyName !== undefined) { fields.push(`company_name = $${idx++}`); values.push(body.companyName) }
|
||||
if (body.contactName !== undefined) { fields.push(`contact_name = $${idx++}`); values.push(body.contactName) }
|
||||
if (body.email !== undefined) { fields.push(`email = $${idx++}`); values.push(body.email) }
|
||||
if (body.phone !== undefined) { fields.push(`phone = $${idx++}`); values.push(body.phone) }
|
||||
if (body.description !== undefined) { fields.push(`notes = $${idx++}`); values.push(body.description) }
|
||||
if (body.source !== undefined) { fields.push(`source_id = $${idx++}`); values.push(body.source) }
|
||||
if (body.assignedUserId !== undefined) {
|
||||
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||
if (!isAdmin) {
|
||||
// non-admin cannot reassign
|
||||
return NextResponse.json({ error: "Only admins can reassign leads" }, { status: 403 })
|
||||
}
|
||||
fields.push(`assigned_to = $${idx++}`)
|
||||
values.push(body.assignedUserId === "none" ? null : body.assignedUserId)
|
||||
}
|
||||
if (body.status !== undefined) {
|
||||
const stageName = statusToStageName(body.status)
|
||||
const stageResult = await query("SELECT id FROM lead_stages WHERE name = $1", [stageName])
|
||||
if (stageResult.rows.length > 0) {
|
||||
fields.push(`stage_id = $${idx++}`)
|
||||
values.push(stageResult.rows[0].id)
|
||||
}
|
||||
}
|
||||
if (body.score !== undefined) {
|
||||
const score = Number(body.score)
|
||||
if (!isFinite(score) || score < 0 || score > 100) {
|
||||
return NextResponse.json({ error: "Score must be a number between 0 and 100" }, { status: 400 })
|
||||
}
|
||||
fields.push(`score = $${idx++}`)
|
||||
values.push(score)
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return NextResponse.json({ error: "No fields to update" }, { status: 400 })
|
||||
}
|
||||
|
||||
fields.push(`updated_at = NOW()`)
|
||||
values.push(id)
|
||||
|
||||
const sql = `UPDATE leads SET ${fields.join(", ")} WHERE id = $${idx} AND deleted_at IS NULL RETURNING id`
|
||||
const result = await query(sql, values)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, id: result.rows[0].id })
|
||||
} catch (error) {
|
||||
console.error("Lead PATCH error:", error)
|
||||
return NextResponse.json({ error: "Failed to update lead" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
import { avatarSvgUrl } from "@/lib/avatar"
|
||||
|
||||
function stageToStatus(name: string): string {
|
||||
switch (name) {
|
||||
case "New": return "open"
|
||||
case "Contacted": return "contacted"
|
||||
case "Qualified":
|
||||
case "Interested":
|
||||
case "Demo Scheduled":
|
||||
case "Negotiation": return "pending"
|
||||
case "Closed Won": return "closed"
|
||||
case "Closed Lost": return "ignored"
|
||||
default: return "open"
|
||||
}
|
||||
}
|
||||
|
||||
function getPeriodDateRange(period: string): { start: Date; end: Date } | null {
|
||||
if (period === "all") return null
|
||||
const end = new Date()
|
||||
let start: Date
|
||||
switch (period) {
|
||||
case "7days": start = new Date(end); start.setDate(start.getDate() - 7); break
|
||||
case "30days": start = new Date(end); start.setDate(start.getDate() - 30); break
|
||||
case "12months": start = new Date(end); start.setFullYear(start.getFullYear() - 12); break
|
||||
case "6months": default: start = new Date(end); start.setMonth(start.getMonth() - 6); break
|
||||
}
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const search = searchParams.get("search") || ""
|
||||
const status = searchParams.get("status") || "all"
|
||||
const period = searchParams.get("period") || "all"
|
||||
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
||||
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||
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,
|
||||
l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
|
||||
ls.name AS stage_name,
|
||||
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
|
||||
FROM leads l
|
||||
JOIN lead_stages ls ON ls.id = l.stage_id
|
||||
LEFT JOIN users u ON u.id = l.assigned_to
|
||||
WHERE l.deleted_at IS NULL`
|
||||
|
||||
const params: any[] = []
|
||||
let paramIdx = 1
|
||||
|
||||
if (period !== "all") {
|
||||
const range = getPeriodDateRange(period)
|
||||
if (range) {
|
||||
sql += ` AND l.created_at >= $${paramIdx} AND l.created_at <= $${paramIdx + 1}`
|
||||
params.push(range.start.toISOString(), range.end.toISOString())
|
||||
paramIdx += 2
|
||||
}
|
||||
}
|
||||
|
||||
if (search) {
|
||||
sql += ` AND (l.contact_name ILIKE $${paramIdx} OR l.company_name ILIKE $${paramIdx} OR l.email ILIKE $${paramIdx} OR l.phone ILIKE $${paramIdx})`
|
||||
params.push(`%${search}%`)
|
||||
paramIdx++
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
sql += ` AND l.assigned_to = $${paramIdx}`
|
||||
params.push(user.id)
|
||||
paramIdx++
|
||||
}
|
||||
|
||||
sql += ` ORDER BY l.created_at DESC`
|
||||
sql += ` LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`
|
||||
params.push(limit, offset)
|
||||
paramIdx += 2
|
||||
|
||||
const result = await query(sql, params)
|
||||
|
||||
let leads = result.rows.map((r: any) => {
|
||||
const s = stageToStatus(r.stage_name)
|
||||
return {
|
||||
id: r.id,
|
||||
companyName: r.company_name || "",
|
||||
contactName: r.contact_name,
|
||||
email: r.email || "",
|
||||
phone: r.phone || "",
|
||||
source: "",
|
||||
description: r.notes || "",
|
||||
status: s,
|
||||
assignedUserId: r.assigned_to,
|
||||
assignedUser: r.assigned_to ? {
|
||||
id: r.user_id,
|
||||
name: `${r.first_name} ${r.last_name}`,
|
||||
email: r.user_email,
|
||||
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
|
||||
} : null,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
}
|
||||
})
|
||||
|
||||
if (status !== "all") {
|
||||
leads = leads.filter((l: any) => l.status === status)
|
||||
}
|
||||
|
||||
return NextResponse.json(leads)
|
||||
} catch (error) {
|
||||
console.error("Leads API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load leads" }, { 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 isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||
|
||||
// Non-admin users can only assign leads to themselves; admin/super_admin can assign to anyone
|
||||
let assignedUserId = body.assignedUserId
|
||||
if (!isAdmin) {
|
||||
assignedUserId = user.id
|
||||
} else if (assignedUserId === "none" || !assignedUserId) {
|
||||
assignedUserId = null
|
||||
}
|
||||
|
||||
const stageResult = await query(
|
||||
"SELECT id FROM lead_stages WHERE name = $1",
|
||||
[body.status === "open" ? "New" : "Contacted"]
|
||||
)
|
||||
const stageId = stageResult.rows[0]?.id || 1
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO leads (company_name, contact_name, email, phone, notes, source_id, stage_id, assigned_to, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
|
||||
RETURNING id`,
|
||||
[
|
||||
body.companyName,
|
||||
body.contactName,
|
||||
body.email,
|
||||
body.phone || null,
|
||||
body.description || null,
|
||||
body.source || null,
|
||||
stageId,
|
||||
assignedUserId,
|
||||
]
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error("Leads POST error:", error)
|
||||
return NextResponse.json({ error: "Failed to create lead" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const id = searchParams.get("id")
|
||||
if (!id) return NextResponse.json({ error: "id is required" }, { status: 400 })
|
||||
|
||||
const result = await query(
|
||||
"UPDATE leads SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL AND ($2 = true OR assigned_to = $3) RETURNING id",
|
||||
[id, isAdmin, user.id]
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Leads DELETE error:", error)
|
||||
return NextResponse.json({ error: "Failed to delete lead" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
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 result = await query(
|
||||
`UPDATE notifications SET is_read = TRUE WHERE id = $1 AND user_id = $2 RETURNING id`,
|
||||
[id, user.id],
|
||||
)
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return NextResponse.json({ error: "Notification not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Notification PATCH error:", error)
|
||||
return NextResponse.json({ error: "Failed to mark notification as read" }, { 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
|
||||
|
||||
const result = await query(
|
||||
`DELETE FROM notifications WHERE id = $1 AND user_id = $2 RETURNING id`,
|
||||
[id, user.id],
|
||||
)
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return NextResponse.json({ error: "Notification not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Notification DELETE error:", error)
|
||||
return NextResponse.json({ error: "Failed to dismiss notification" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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 lead_assigned, lead_status, note_added, daily_digest, weekly_report
|
||||
FROM notification_preferences
|
||||
WHERE user_id = $1`,
|
||||
[user.id],
|
||||
)
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return NextResponse.json({
|
||||
leadAssigned: true,
|
||||
leadStatus: true,
|
||||
noteAdded: false,
|
||||
dailyDigest: false,
|
||||
weeklyReport: true,
|
||||
})
|
||||
}
|
||||
|
||||
const r = result.rows[0]
|
||||
return NextResponse.json({
|
||||
leadAssigned: r.lead_assigned,
|
||||
leadStatus: r.lead_status,
|
||||
noteAdded: r.note_added,
|
||||
dailyDigest: r.daily_digest,
|
||||
weeklyReport: r.weekly_report,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Preferences GET error:", error)
|
||||
return NextResponse.json({ error: "Failed to load preferences" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
await query(
|
||||
`INSERT INTO notification_preferences (user_id, lead_assigned, lead_status, note_added, daily_digest, weekly_report, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE SET lead_assigned = $2, lead_status = $3, note_added = $4,
|
||||
daily_digest = $5, weekly_report = $6, updated_at = NOW()`,
|
||||
[
|
||||
user.id,
|
||||
body.leadAssigned ?? true,
|
||||
body.leadStatus ?? true,
|
||||
body.noteAdded ?? false,
|
||||
body.dailyDigest ?? false,
|
||||
body.weeklyReport ?? true,
|
||||
],
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Preferences PATCH error:", error)
|
||||
return NextResponse.json({ error: "Failed to save preferences" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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 id, type, title, description, link, is_read, created_at, context_id, context_type
|
||||
FROM notifications
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50`,
|
||||
[user.id],
|
||||
)
|
||||
|
||||
const notifications = result.rows.map((r: any) => ({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
link: r.link,
|
||||
read: r.is_read,
|
||||
timestamp: r.created_at,
|
||||
contextId: r.context_id,
|
||||
contextType: r.context_type,
|
||||
}))
|
||||
|
||||
const unreadResult = await query(
|
||||
`SELECT COUNT(*) AS count FROM notifications WHERE user_id = $1 AND is_read = FALSE`,
|
||||
[user.id],
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
notifications,
|
||||
unreadCount: parseInt(unreadResult.rows[0].count, 10),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Notifications GET error:", error)
|
||||
return NextResponse.json({ error: "Failed to load notifications" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { type, title, description, link, userId } = await request.json()
|
||||
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||
const targetUserId = (userId && isAdmin) ? userId : user.id
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO notifications (user_id, type, title, description, link)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, type, title, description, link, is_read, created_at`,
|
||||
[targetUserId, type, title, description || null, link || null],
|
||||
)
|
||||
|
||||
const notif = result.rows[0]
|
||||
return NextResponse.json({
|
||||
id: notif.id,
|
||||
type: notif.type,
|
||||
title: notif.title,
|
||||
description: notif.description,
|
||||
link: notif.link,
|
||||
read: notif.is_read,
|
||||
timestamp: notif.created_at,
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error("Notifications POST error:", error)
|
||||
return NextResponse.json({ error: "Failed to create notification" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH() {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
await query(
|
||||
`UPDATE notifications SET is_read = TRUE WHERE user_id = $1 AND is_read = FALSE`,
|
||||
[user.id],
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Notifications PATCH error:", error)
|
||||
return NextResponse.json({ error: "Failed to mark all as read" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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 })
|
||||
if (user.role !== "admin" && user.role !== "super_admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
|
||||
const result = await query(`SELECT * FROM company_settings ORDER BY updated_at DESC LIMIT 1`)
|
||||
const row = result.rows[0]
|
||||
|
||||
if (!row) {
|
||||
return NextResponse.json({
|
||||
companyName: "Coastal IT Solutions",
|
||||
companyEmail: "info@coastalit.com",
|
||||
companyPhone: "(555) 123-4567",
|
||||
companyWebsite: "https://coastalit.com",
|
||||
companyAddress: "123 Business Ave, Suite 100, San Francisco, CA 94105",
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
companyName: row.company_name || "",
|
||||
companyEmail: row.company_email || "",
|
||||
companyPhone: row.company_phone || "",
|
||||
companyWebsite: row.company_website || "",
|
||||
companyAddress: row.company_address || "",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Company settings GET error:", error)
|
||||
return NextResponse.json({ error: "Failed to load company settings" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
if (user.role !== "admin" && user.role !== "super_admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
await query(
|
||||
`UPDATE company_settings SET
|
||||
company_name = $1, company_email = $2, company_phone = $3,
|
||||
company_website = $4, company_address = $5, updated_by = $6, updated_at = NOW()`,
|
||||
[
|
||||
body.companyName || "",
|
||||
body.companyEmail || "",
|
||||
body.companyPhone || "",
|
||||
body.companyWebsite || "",
|
||||
body.companyAddress || "",
|
||||
user.id,
|
||||
],
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Company settings PATCH error:", error)
|
||||
return NextResponse.json({ error: "Failed to save company settings" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
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 })
|
||||
if (user.role !== "admin" && user.role !== "super_admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
const fields: string[] = []
|
||||
const values: any[] = []
|
||||
let idx = 1
|
||||
|
||||
if (body.isActive !== undefined) {
|
||||
fields.push(`is_active = $${idx++}`)
|
||||
values.push(body.isActive)
|
||||
}
|
||||
if (body.label !== undefined) {
|
||||
fields.push(`label = $${idx++}`)
|
||||
values.push(body.label.trim())
|
||||
}
|
||||
if (body.profilePath !== undefined) {
|
||||
fields.push(`profile_path = $${idx++}, cookie_file = $${idx}`)
|
||||
values.push(body.profilePath.trim())
|
||||
values.push(`${body.profilePath.replace(/\\+$/, '')}\\cookies.sqlite`)
|
||||
idx++
|
||||
}
|
||||
if (body.unflag === true) {
|
||||
fields.push(`flagged = FALSE, flagged_at = NULL, flagged_reason = NULL, consecutive_failures = 0`)
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return NextResponse.json({ error: "No fields to update" }, { status: 400 })
|
||||
}
|
||||
|
||||
fields.push(`updated_at = NOW()`)
|
||||
values.push(id)
|
||||
|
||||
await query(
|
||||
`UPDATE facebook_accounts SET ${fields.join(", ")} WHERE id = $${idx}`,
|
||||
values
|
||||
)
|
||||
|
||||
const updated = await query(
|
||||
`SELECT id, label, profile_path, is_active, flagged, flagged_reason,
|
||||
consecutive_failures, updated_at
|
||||
FROM facebook_accounts WHERE id = $1`,
|
||||
[id]
|
||||
)
|
||||
|
||||
return NextResponse.json(updated.rows[0] || { success: true })
|
||||
} catch (error) {
|
||||
console.error("Facebook accounts PATCH error:", error)
|
||||
return NextResponse.json({ error: "Failed to update Facebook account" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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 })
|
||||
if (user.role !== "admin" && user.role !== "super_admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
|
||||
const accounts = await query(
|
||||
`SELECT fa.id, fa.label, fa.profile_path, fa.is_active,
|
||||
fa.last_scrape_at, fa.last_success_at, fa.last_error_at,
|
||||
fa.last_error_message, fa.consecutive_failures,
|
||||
fa.flagged, fa.flagged_at, fa.flagged_reason,
|
||||
fa.created_at, fa.updated_at,
|
||||
COALESCE(sl.leads_found, 0) AS last_leads_found,
|
||||
sl.success AS last_success,
|
||||
sl.detected_flag AS last_detected_flag
|
||||
FROM facebook_accounts fa
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT leads_found, success, detected_flag
|
||||
FROM facebook_scrape_logs
|
||||
WHERE account_id = fa.id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
) sl ON TRUE
|
||||
ORDER BY fa.created_at ASC`
|
||||
)
|
||||
|
||||
return NextResponse.json(accounts.rows)
|
||||
} catch (error) {
|
||||
console.error("Facebook accounts GET error:", error)
|
||||
return NextResponse.json({ error: "Failed to load Facebook accounts" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
if (user.role !== "admin" && user.role !== "super_admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
|
||||
const { label, profilePath } = await request.json()
|
||||
if (!label?.trim() || !profilePath?.trim()) {
|
||||
return NextResponse.json({ error: "Label and profile path are required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const cookieFile = `${profilePath.replace(/\\+$/, '')}\\cookies.sqlite`
|
||||
const result = await query(
|
||||
`INSERT INTO facebook_accounts (label, profile_path, cookie_file)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, label, profile_path, is_active, created_at`,
|
||||
[label.trim(), profilePath.trim(), cookieFile]
|
||||
)
|
||||
|
||||
return NextResponse.json(result.rows[0], { status: 201 })
|
||||
} catch (error) {
|
||||
console.error("Facebook accounts POST error:", error)
|
||||
return NextResponse.json({ error: "Failed to create Facebook account" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
if (user.role !== "admin" && user.role !== "super_admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const accountId = searchParams.get("accountId")
|
||||
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
||||
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||
|
||||
let sql = `SELECT sl.id, sl.account_id, fa.label AS account_label,
|
||||
sl.started_at, sl.completed_at, sl.success,
|
||||
sl.leads_found, sl.error_message, sl.detected_flag,
|
||||
sl.created_at
|
||||
FROM facebook_scrape_logs sl
|
||||
JOIN facebook_accounts fa ON fa.id = sl.account_id`
|
||||
const params: any[] = []
|
||||
let paramIdx = 1
|
||||
|
||||
if (accountId) {
|
||||
sql += ` WHERE sl.account_id = $${paramIdx++}`
|
||||
params.push(accountId)
|
||||
}
|
||||
|
||||
sql += ` ORDER BY sl.created_at DESC LIMIT $${paramIdx++} OFFSET $${paramIdx++}`
|
||||
params.push(limit, offset)
|
||||
|
||||
const result = await query(sql, params)
|
||||
return NextResponse.json(result.rows)
|
||||
} catch (error) {
|
||||
console.error("Facebook scrape logs GET error:", error)
|
||||
return NextResponse.json({ error: "Failed to load scrape logs" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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 timezone, date_format, items_per_page FROM user_preferences WHERE user_id = $1`,
|
||||
[user.id],
|
||||
)
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return NextResponse.json({
|
||||
timezone: "america-los_angeles",
|
||||
dateFormat: "mdy",
|
||||
itemsPerPage: 20,
|
||||
})
|
||||
}
|
||||
|
||||
const r = result.rows[0]
|
||||
return NextResponse.json({
|
||||
timezone: r.timezone,
|
||||
dateFormat: r.date_format,
|
||||
itemsPerPage: r.items_per_page,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Preferences GET error:", error)
|
||||
return NextResponse.json({ error: "Failed to load preferences" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
await query(
|
||||
`INSERT INTO user_preferences (user_id, timezone, date_format, items_per_page, updated_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE SET timezone = $2, date_format = $3, items_per_page = $4, updated_at = NOW()`,
|
||||
[
|
||||
user.id,
|
||||
body.timezone || "america-los_angeles",
|
||||
body.dateFormat || "mdy",
|
||||
body.itemsPerPage || 20,
|
||||
],
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Preferences PATCH error:", error)
|
||||
return NextResponse.json({ error: "Failed to save preferences" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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 preferences->>'website_theme' AS website_theme FROM users WHERE id = $1`,
|
||||
[user.id],
|
||||
)
|
||||
|
||||
const websiteTheme = result.rows[0]?.website_theme || "spidey"
|
||||
return NextResponse.json({ websiteTheme })
|
||||
} catch (error) {
|
||||
console.error("Website theme GET error:", error)
|
||||
return NextResponse.json({ error: "Failed to load website theme" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const body = await request.json()
|
||||
const theme = body.websiteTheme || "spidey"
|
||||
|
||||
await query(
|
||||
`UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`,
|
||||
[user.id, JSON.stringify({ website_theme: theme })],
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true, websiteTheme: theme })
|
||||
} catch (error) {
|
||||
console.error("Website theme PUT error:", error)
|
||||
return NextResponse.json({ error: "Failed to save website theme" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import os from "os"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
|
||||
let prevCpu = process.cpuUsage()
|
||||
let prevTime = Date.now()
|
||||
|
||||
export async function GET() {
|
||||
const sessionUser = await getSessionUser()
|
||||
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
if (sessionUser.role !== "admin" && sessionUser.role !== "super_admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const elapsed = now - prevTime
|
||||
const currentCpu = process.cpuUsage()
|
||||
|
||||
const user = currentCpu.user - prevCpu.user
|
||||
const sys = currentCpu.system - prevCpu.system
|
||||
const totalUs = user + sys
|
||||
|
||||
// CPU time (ms) / wall time (ms) * 100 = % of one core
|
||||
const cpuPct = elapsed > 0 ? Math.round((totalUs / 1000) / elapsed * 100 * 10) / 10 : 0
|
||||
|
||||
prevCpu = currentCpu
|
||||
prevTime = now
|
||||
|
||||
const mem = process.memoryUsage()
|
||||
|
||||
return NextResponse.json({
|
||||
rssMB: Math.round(mem.rss / 1024 / 1024),
|
||||
cpuPct,
|
||||
cores: os.cpus().length,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { query } from "@/lib/db"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const sessionUser = await getSessionUser()
|
||||
if (!sessionUser) {
|
||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
|
||||
}
|
||||
if (sessionUser.role !== "super_admin") {
|
||||
return NextResponse.json({ error: "Only super admins can delete users" }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
if (id === sessionUser.id) {
|
||||
return NextResponse.json({ error: "Cannot delete yourself" }, { status: 400 })
|
||||
}
|
||||
|
||||
await query(
|
||||
`UPDATE users SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[id]
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error("Error deleting user:", error)
|
||||
return NextResponse.json({ error: "Failed to delete user" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
const ALLOWED_PREFIXES = ["data:image/png;base64,", "data:image/jpeg;base64,", "data:image/gif;base64,"]
|
||||
const MAX_AVATAR_BYTES = 2 * 1024 * 1024 // 2MB
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { avatar } = await request.json()
|
||||
if (!avatar || typeof avatar !== "string") {
|
||||
return NextResponse.json({ error: "Invalid avatar data" }, { status: 400 })
|
||||
}
|
||||
|
||||
const allowed = ALLOWED_PREFIXES.some((p) => avatar.startsWith(p))
|
||||
if (!allowed) {
|
||||
return NextResponse.json({ error: "Avatar must be a PNG, JPEG, or GIF data URL" }, { status: 400 })
|
||||
}
|
||||
|
||||
// Approximate decoded size: base64 is ~4/3 of original
|
||||
const base64Data = avatar.split(",")[1] || ""
|
||||
const estimatedBytes = Math.round(base64Data.length * 0.75)
|
||||
if (estimatedBytes > MAX_AVATAR_BYTES) {
|
||||
return NextResponse.json({ error: "Avatar exceeds 2MB size limit" }, { status: 400 })
|
||||
}
|
||||
|
||||
await query(
|
||||
`UPDATE users SET avatar_url = $1 WHERE id = $2`,
|
||||
[avatar, user.id],
|
||||
)
|
||||
|
||||
return NextResponse.json({ avatar })
|
||||
} catch (error) {
|
||||
console.error("Avatar upload error:", error)
|
||||
return NextResponse.json({ error: "Failed to update avatar" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const phone = request.nextUrl.searchParams.get("phone")
|
||||
if (!phone) {
|
||||
return NextResponse.json({ error: "Phone parameter required" }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await query(
|
||||
`SELECT id, username, first_name, last_name, phone, avatar_url
|
||||
FROM users
|
||||
WHERE phone = $1 AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[phone],
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json({ found: false })
|
||||
}
|
||||
|
||||
const user = result.rows[0]
|
||||
return NextResponse.json({
|
||||
found: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
firstName: user.first_name,
|
||||
lastName: user.last_name,
|
||||
phone: user.phone,
|
||||
avatar: user.avatar_url,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Lookup failed" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { query } from "@/lib/db"
|
||||
import { hashPassword, getSessionUser } from "@/lib/auth"
|
||||
import { avatarSvgUrl } from "@/lib/avatar"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const sessionUser = await getSessionUser()
|
||||
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
if (sessionUser.role !== "admin" && sessionUser.role !== "super_admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
||||
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||
|
||||
const result = await query(
|
||||
`SELECT u.id, u.username, u.email, u.first_name, u.last_name,
|
||||
u.is_active AS active, u.created_at, u.avatar_url,
|
||||
r.name AS role
|
||||
FROM users u
|
||||
JOIN user_roles ur ON ur.user_id = u.id
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
WHERE u.deleted_at IS NULL
|
||||
ORDER BY u.created_at DESC
|
||||
LIMIT $1 OFFSET $2`,
|
||||
[limit, offset]
|
||||
)
|
||||
const users = result.rows.map((row: any) => ({
|
||||
id: row.id,
|
||||
name: `${row.first_name} ${row.last_name}`,
|
||||
email: row.email,
|
||||
role: row.role.toLowerCase(),
|
||||
active: row.active,
|
||||
avatar: avatarSvgUrl(`${row.first_name} ${row.last_name}`),
|
||||
createdAt: row.created_at,
|
||||
}))
|
||||
return NextResponse.json({ users }, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error("Error fetching users:", error)
|
||||
return NextResponse.json({ error: "Failed to fetch users" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const sessionUser = await getSessionUser()
|
||||
if (!sessionUser) {
|
||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
|
||||
}
|
||||
if (sessionUser.role !== "super_admin") {
|
||||
return NextResponse.json({ error: "Only super admins can create users" }, { status: 403 })
|
||||
}
|
||||
|
||||
const { name, email, password, role, active } = await request.json()
|
||||
if (!name || !email || !password || !role) {
|
||||
return NextResponse.json({ error: "Name, email, password, and role are required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const validRoles = ["sales", "admin", "super_admin", "dev"]
|
||||
if (!validRoles.includes(role)) {
|
||||
return NextResponse.json({ error: "Invalid role" }, { status: 400 })
|
||||
}
|
||||
|
||||
const nameParts = name.trim().split(/\s+/)
|
||||
const firstName = nameParts[0]
|
||||
const lastName = nameParts.slice(1).join(" ") || firstName
|
||||
const username = email.split("@")[0]
|
||||
const passwordHash = await hashPassword(password)
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO users (username, email, password_hash, first_name, last_name, is_active, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id`,
|
||||
[username.toLowerCase(), email.toLowerCase(), passwordHash, firstName, lastName, active ?? true, sessionUser.id]
|
||||
)
|
||||
|
||||
const roleId = (
|
||||
await query(`SELECT id FROM roles WHERE LOWER(name) = LOWER($1)`, [role])
|
||||
).rows[0]?.id
|
||||
|
||||
if (roleId) {
|
||||
await query(
|
||||
`INSERT INTO user_roles (user_id, role_id, assigned_by)
|
||||
VALUES ($1, $2, $3)`,
|
||||
[result.rows[0].id, roleId, sessionUser.id]
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
|
||||
} catch (error: any) {
|
||||
console.error("Error creating user:", error)
|
||||
if (error?.constraint === "uq_users_username" || error?.constraint === "uq_users_email") {
|
||||
return NextResponse.json({ error: "A user with this email or username already exists" }, { status: 409 })
|
||||
}
|
||||
return NextResponse.json({ error: error?.message || "Failed to create user" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
import { avatarSvgUrl } from "@/lib/avatar"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const currentUser = await getSessionUser()
|
||||
if (!currentUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
if (currentUser.role !== "admin" && currentUser.role !== "super_admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
|
||||
const q = request.nextUrl.searchParams.get("q") || ""
|
||||
|
||||
if (!q.trim()) {
|
||||
return NextResponse.json({ users: [] })
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
|
||||
FROM users
|
||||
WHERE deleted_at IS NULL
|
||||
AND id != $1
|
||||
AND (LOWER(first_name || ' ' || last_name) LIKE LOWER($2)
|
||||
OR LOWER(email) LIKE LOWER($2))
|
||||
ORDER BY first_name ASC
|
||||
LIMIT 10`,
|
||||
[currentUser.id, `%${q}%`],
|
||||
)
|
||||
|
||||
const users = result.rows.map((row: any) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
avatar: avatarSvgUrl(row.name),
|
||||
}))
|
||||
|
||||
return NextResponse.json({ users })
|
||||
} catch (error) {
|
||||
console.error("User search error:", error)
|
||||
return NextResponse.json({ error: "Search failed" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, use, useCallback } from "react"
|
||||
import { Phone, PhoneOff, Mic, MicOff, Volume2, VolumeX, Users, Loader2 } from "lucide-react"
|
||||
import { useWebRTCCall } from "@/hooks/useWebRTCCall"
|
||||
|
||||
export default function CallRoomPage({ params }: { params: Promise<{ roomId: string }> }) {
|
||||
const { roomId } = use(params)
|
||||
|
||||
const {
|
||||
callState,
|
||||
isMuted,
|
||||
isSpeakerOn,
|
||||
callDuration,
|
||||
error,
|
||||
isReady,
|
||||
participants,
|
||||
joinRoom,
|
||||
endCall,
|
||||
toggleMute,
|
||||
toggleSpeaker,
|
||||
formatDuration,
|
||||
} = useWebRTCCall({ anonymous: true })
|
||||
|
||||
const [displayName, setDisplayName] = useState("")
|
||||
const [joined, setJoined] = useState(false)
|
||||
const [micError, setMicError] = useState<string | null>(null)
|
||||
const [connectionTimeout, setConnectionTimeout] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady) return
|
||||
const timer = setTimeout(() => {
|
||||
if (!isReady) setConnectionTimeout(true)
|
||||
}, 10000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [isReady])
|
||||
|
||||
const handleJoin = useCallback(async () => {
|
||||
if (!displayName.trim()) return
|
||||
setMicError(null)
|
||||
try {
|
||||
await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
joinRoom(roomId, displayName.trim() || "Anonymous")
|
||||
setJoined(true)
|
||||
} catch {
|
||||
setMicError("Microphone access is required to join the call. Please allow microphone access in your browser settings.")
|
||||
}
|
||||
}, [displayName, roomId, joinRoom])
|
||||
|
||||
const handleEnd = useCallback(() => {
|
||||
endCall()
|
||||
}, [endCall])
|
||||
|
||||
if (!joined) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
|
||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||
{connectionTimeout ? (
|
||||
<>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">This call is no longer available.</h1>
|
||||
</>
|
||||
) : !isReady ? (
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-4 py-6">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-[#CC0000]" />
|
||||
<p className="text-[#888888] text-sm">Connecting to call...</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">Join Call</h1>
|
||||
{micError && (
|
||||
<p className="text-[#CC0000] text-xs mb-4">{micError}</p>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleJoin() }}
|
||||
placeholder="Enter your name"
|
||||
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-4 outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]"
|
||||
/>
|
||||
<button
|
||||
onClick={handleJoin}
|
||||
disabled={!displayName.trim()}
|
||||
className="w-full bg-[#CC0000] hover:bg-[#990000] disabled:bg-[#CCCCCC] disabled:cursor-not-allowed dark:bg-[#FF1111] dark:hover:bg-[#CC0000] dark:disabled:bg-[#333333] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200"
|
||||
>
|
||||
<Phone className="h-4 w-4" />
|
||||
Join Call
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
|
||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||
{callState === "calling" && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Calling</h1>
|
||||
<p className="text-[#888888] text-sm animate-pulse">Connecting to call room...</p>
|
||||
<div className="flex justify-center mt-6">
|
||||
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{callState === "waiting" && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Waiting for participant</h1>
|
||||
<p className="text-[#888888] text-sm animate-pulse">Share the call link to invite someone...</p>
|
||||
{participants.length > 0 && (
|
||||
<div className="mt-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
|
||||
<Users className="h-3 w-3" />
|
||||
Participants ({participants.length})
|
||||
</div>
|
||||
{participants.map((p) => (
|
||||
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-center mt-6">
|
||||
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{callState === "participant_joined" && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Participant joined</h1>
|
||||
{participants.length > 0 && (
|
||||
<div className="mb-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
|
||||
<Users className="h-3 w-3" />
|
||||
Participants ({participants.length})
|
||||
</div>
|
||||
{participants.map((p) => (
|
||||
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[#888888] text-sm animate-pulse">Connecting...</p>
|
||||
<div className="flex justify-center mt-6">
|
||||
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{callState === "connecting" && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Connecting</h1>
|
||||
<p className="text-[#888888] text-sm animate-pulse">Establishing secure connection...</p>
|
||||
<div className="flex justify-center mt-6">
|
||||
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{callState === "connected" && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-1">Connected</h1>
|
||||
{participants.length > 0 && (
|
||||
<div className="mb-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
|
||||
<Users className="h-3 w-3" />
|
||||
Participants ({participants.length})
|
||||
</div>
|
||||
{participants.map((p) => (
|
||||
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[#CC0000] font-mono text-lg mb-6">
|
||||
{formatDuration(callDuration)}
|
||||
</div>
|
||||
<div className="flex justify-center gap-4">
|
||||
<button
|
||||
onClick={toggleSpeaker}
|
||||
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isSpeakerOn ? 'bg-[#0033CC] dark:bg-[#1144FF]' : 'bg-[#444444] dark:bg-[#333333]'}`}
|
||||
>
|
||||
{isSpeakerOn ? <Volume2 className="h-5 w-5" /> : <VolumeX className="h-5 w-5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isMuted ? 'bg-[#0033CC] dark:bg-[#1144FF]' : 'bg-[#444444] dark:bg-[#333333]'}`}
|
||||
>
|
||||
{isMuted ? <MicOff className="h-5 w-5" /> : <Mic className="h-5 w-5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEnd}
|
||||
className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200"
|
||||
>
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(callState === "ended" || callState === "idle") && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Call ended</h1>
|
||||
{callDuration > 0 && (
|
||||
<p className="text-[#888888] text-sm mb-6">Duration: {formatDuration(callDuration)}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-[#CC0000] text-xs mt-4">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Bangers&family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500;1,600;1,700&family=Audiowide&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
.text-glow {
|
||||
text-shadow: 0 0 15px #39ff14, 0 0 30px #39ff14, 0 0 50px #39ff14, 0 0 80px rgba(57, 255, 20, 0.6);
|
||||
}
|
||||
|
||||
.dark .text-glow {
|
||||
text-shadow: 0 0 10px #39ff14, 0 0 20px #39ff14, 0 0 35px #39ff14;
|
||||
}
|
||||
|
||||
@keyframes loading {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(400%); }
|
||||
}
|
||||
|
||||
@keyframes pulse-glow-red {
|
||||
0%, 100% { opacity: 0.12; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes pulse-glow-blue {
|
||||
0%, 100% { opacity: 0.12; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes pulse-glow-intersection {
|
||||
0%, 100% { opacity: 0.15; }
|
||||
50% { opacity: 0.65; }
|
||||
}
|
||||
|
||||
.saber-outer { stroke-width: 30; }
|
||||
.dark .saber-outer { stroke-width: 40; }
|
||||
.saber-mid { stroke-width: 18; }
|
||||
.dark .saber-mid { stroke-width: 24; }
|
||||
.saber-core { stroke-width: 6; }
|
||||
.dark .saber-core { stroke-width: 8; }
|
||||
|
||||
.saber-blue-outer { stroke-width: 24; }
|
||||
.dark .saber-blue-outer { stroke-width: 40; }
|
||||
.saber-blue-mid { stroke-width: 13; }
|
||||
.dark .saber-blue-mid { stroke-width: 24; }
|
||||
.saber-blue-core { stroke-width: 4; }
|
||||
.dark .saber-blue-core { stroke-width: 8; }
|
||||
|
||||
.animate-pulse-red {
|
||||
animation: pulse-glow-red 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-pulse-blue {
|
||||
animation: pulse-glow-blue 2.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-pulse-intersection {
|
||||
animation: pulse-glow-intersection 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes star-twinkle {
|
||||
0%, 100% { opacity: 0.1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
@keyframes star-drift {
|
||||
0% { transform: translate(0, 0); }
|
||||
50% { transform: translate(3px, -2px); }
|
||||
100% { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
.star-twinkle {
|
||||
animation: star-twinkle 4s ease-in-out infinite, star-drift 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: 220 14% 84%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 220 10% 91%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 220 14% 96%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 120 100% 50%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 220 14% 88%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 220 14% 88%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 220 14% 88%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 220 14% 85%;
|
||||
--input: 220 14% 85%;
|
||||
--ring: 120 100% 50%;
|
||||
--sidebar: 220 14% 74%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 145 65% 50%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 210 40% 96%;
|
||||
--sidebar-accent-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-border: 220 9% 46%;
|
||||
--sidebar-ring: 120 100% 50%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 10%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 0 0% 9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 11%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 0 100% 53%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 0 100% 53%;
|
||||
--sidebar: 0 0% 11%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 145 65% 50%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 0 100% 53%;
|
||||
}
|
||||
|
||||
.ocean {
|
||||
--primary: 187 75% 42%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--ring: 187 75% 42%;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 187 75% 42%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 187 30% 94%;
|
||||
--sidebar-accent-foreground: 187 50% 20%;
|
||||
--sidebar-border: 187 20% 88%;
|
||||
--sidebar-ring: 187 75% 42%;
|
||||
}
|
||||
|
||||
.dark.ocean {
|
||||
--primary: 187 75% 50%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--ring: 187 75% 50%;
|
||||
--sidebar: 0 0% 11%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 187 75% 55%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 187 75% 50%;
|
||||
}
|
||||
|
||||
.forest {
|
||||
--primary: 142 76% 36%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--ring: 142 76% 36%;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 142 76% 36%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 142 30% 94%;
|
||||
--sidebar-accent-foreground: 142 50% 20%;
|
||||
--sidebar-border: 142 20% 88%;
|
||||
--sidebar-ring: 142 76% 36%;
|
||||
}
|
||||
|
||||
.dark.forest {
|
||||
--primary: 142 76% 44%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--ring: 142 76% 44%;
|
||||
--sidebar: 0 0% 11%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 142 76% 50%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 142 76% 44%;
|
||||
}
|
||||
|
||||
.sunset {
|
||||
--primary: 24 95% 53%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--ring: 24 95% 53%;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 24 95% 53%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 24 30% 94%;
|
||||
--sidebar-accent-foreground: 24 50% 20%;
|
||||
--sidebar-border: 24 20% 88%;
|
||||
--sidebar-ring: 24 95% 53%;
|
||||
}
|
||||
|
||||
.dark.sunset {
|
||||
--primary: 24 95% 58%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--ring: 24 95% 58%;
|
||||
--sidebar: 0 0% 11%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 24 95% 65%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 24 95% 58%;
|
||||
}
|
||||
|
||||
.midnight {
|
||||
--primary: 230 75% 55%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--ring: 230 75% 55%;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 230 75% 55%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 230 30% 94%;
|
||||
--sidebar-accent-foreground: 230 50% 20%;
|
||||
--sidebar-border: 230 20% 88%;
|
||||
--sidebar-ring: 230 75% 55%;
|
||||
}
|
||||
|
||||
.dark.midnight {
|
||||
--primary: 230 75% 62%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--ring: 230 75% 62%;
|
||||
--sidebar: 0 0% 11%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 230 75% 65%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 230 75% 62%;
|
||||
}
|
||||
|
||||
.rose {
|
||||
--primary: 346 77% 50%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--ring: 346 77% 50%;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 346 77% 50%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 346 30% 94%;
|
||||
--sidebar-accent-foreground: 346 50% 20%;
|
||||
--sidebar-border: 346 20% 88%;
|
||||
--sidebar-ring: 346 77% 50%;
|
||||
}
|
||||
|
||||
.dark.rose {
|
||||
--primary: 346 77% 58%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--ring: 346 77% 58%;
|
||||
--sidebar: 0 0% 11%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 346 77% 60%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 346 77% 58%;
|
||||
}
|
||||
|
||||
.amber {
|
||||
--primary: 38 92% 50%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--ring: 38 92% 50%;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 38 92% 50%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 38 30% 94%;
|
||||
--sidebar-accent-foreground: 38 50% 20%;
|
||||
--sidebar-border: 38 20% 88%;
|
||||
--sidebar-ring: 38 92% 50%;
|
||||
}
|
||||
|
||||
.dark.amber {
|
||||
--primary: 38 92% 56%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--ring: 38 92% 56%;
|
||||
--sidebar: 0 0% 11%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 38 92% 60%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 38 92% 56%;
|
||||
}
|
||||
|
||||
.violet {
|
||||
--primary: 262 83% 58%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--ring: 262 83% 58%;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 262 83% 58%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 262 30% 94%;
|
||||
--sidebar-accent-foreground: 262 50% 20%;
|
||||
--sidebar-border: 262 20% 88%;
|
||||
--sidebar-ring: 262 83% 58%;
|
||||
}
|
||||
|
||||
.dark.violet {
|
||||
--primary: 262 83% 65%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--ring: 262 83% 65%;
|
||||
--sidebar: 0 0% 11%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 262 83% 68%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 262 83% 65%;
|
||||
}
|
||||
|
||||
.slate {
|
||||
--primary: 215 20% 45%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--ring: 215 20% 45%;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 215 20% 45%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 215 20% 94%;
|
||||
--sidebar-accent-foreground: 215 50% 20%;
|
||||
--sidebar-border: 215 20% 88%;
|
||||
--sidebar-ring: 215 20% 45%;
|
||||
}
|
||||
|
||||
.dark.slate {
|
||||
--primary: 215 20% 60%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--ring: 215 20% 60%;
|
||||
--sidebar: 0 0% 11%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 215 20% 65%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 215 20% 60%;
|
||||
}
|
||||
|
||||
.ruby {
|
||||
--primary: 351 85% 45%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--ring: 351 85% 45%;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 351 85% 45%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 351 30% 94%;
|
||||
--sidebar-accent-foreground: 351 50% 20%;
|
||||
--sidebar-border: 351 20% 88%;
|
||||
--sidebar-ring: 351 85% 45%;
|
||||
}
|
||||
|
||||
.dark.ruby {
|
||||
--primary: 351 85% 52%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--ring: 351 85% 52%;
|
||||
--sidebar: 0 0% 11%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 351 85% 55%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 351 85% 52%;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
[data-radix-select-trigger] {
|
||||
background-color: hsl(0 0% 100%);
|
||||
}
|
||||
|
||||
main {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
radial-gradient(1px 1px at 10% 20%, rgba(100,100,120,0.4) 0%, transparent 100%),
|
||||
radial-gradient(1px 1px at 20% 40%, rgba(100,100,120,0.3) 0%, transparent 100%),
|
||||
radial-gradient(1.5px 1.5px at 30% 10%, rgba(80,80,100,0.5) 0%, transparent 100%),
|
||||
radial-gradient(1px 1px at 40% 60%, rgba(100,100,120,0.3) 0%, transparent 100%),
|
||||
radial-gradient(1px 1px at 50% 80%, rgba(90,90,110,0.4) 0%, transparent 100%),
|
||||
radial-gradient(1.5px 1.5px at 60% 30%, rgba(80,80,100,0.5) 0%, transparent 100%),
|
||||
radial-gradient(1px 1px at 70% 50%, rgba(100,100,120,0.3) 0%, transparent 100%),
|
||||
radial-gradient(1px 1px at 80% 90%, rgba(90,90,110,0.4) 0%, transparent 100%),
|
||||
radial-gradient(1.5px 1.5px at 90% 15%, rgba(80,80,100,0.5) 0%, transparent 100%),
|
||||
radial-gradient(1px 1px at 15% 70%, rgba(100,100,120,0.3) 0%, transparent 100%),
|
||||
radial-gradient(1px 1px at 25% 90%, rgba(90,90,110,0.4) 0%, transparent 100%),
|
||||
radial-gradient(1.5px 1.5px at 35% 35%, rgba(80,80,100,0.5) 0%, transparent 100%),
|
||||
radial-gradient(1px 1px at 45% 15%, rgba(100,100,120,0.3) 0%, transparent 100%),
|
||||
radial-gradient(1px 1px at 55% 75%, rgba(90,90,110,0.4) 0%, transparent 100%),
|
||||
radial-gradient(1.5px 1.5px at 65% 55%, rgba(80,80,100,0.5) 0%, transparent 100%),
|
||||
radial-gradient(1px 1px at 75% 25%, rgba(100,100,120,0.3) 0%, transparent 100%),
|
||||
radial-gradient(1px 1px at 85% 65%, rgba(90,90,110,0.4) 0%, transparent 100%),
|
||||
radial-gradient(1.5px 1.5px at 95% 45%, rgba(80,80,100,0.5) 0%, transparent 100%);
|
||||
background-size: 200% 200%;
|
||||
animation: drift 60s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes drift {
|
||||
0% { background-position: 0% 0%; }
|
||||
100% { background-position: 100% 100%; }
|
||||
}
|
||||
|
||||
/* Login page custom styles */
|
||||
.left-panel {
|
||||
background: #0a0a0f;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.right-panel {
|
||||
background: #111118;
|
||||
width: 420px;
|
||||
flex: none;
|
||||
position: relative;
|
||||
}
|
||||
.panel-divider {
|
||||
width: 1px;
|
||||
background: rgba(180, 192, 210, 0.1);
|
||||
flex: none;
|
||||
}
|
||||
.growth-word {
|
||||
position: relative;
|
||||
color: #1BB0CE;
|
||||
}
|
||||
.growth-word::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: #1BB0CE;
|
||||
animation: pulseUnderline 2.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulseUnderline {
|
||||
0%, 100% { background-color: #1BB0CE; }
|
||||
50% { background-color: rgba(180, 192, 210, 0.6); }
|
||||
}
|
||||
.stats-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 32px;
|
||||
gap: 0;
|
||||
}
|
||||
.stat {
|
||||
flex: 1;
|
||||
max-width: 120px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
padding-top: 12px;
|
||||
}
|
||||
.stat::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, #1BB0CE, rgba(180,192,210,1), #1BB0CE);
|
||||
background-size: 200% 100%;
|
||||
animation: statSweep 2.5s ease-in-out infinite;
|
||||
}
|
||||
.stat:nth-child(2)::before {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
.stat:nth-child(3)::before {
|
||||
animation-delay: 1.2s;
|
||||
}
|
||||
@keyframes statSweep {
|
||||
0% { background-position: 0% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
.stat-number {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #1BB0CE;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
color: rgba(232,232,239,0.3);
|
||||
margin-top: 4px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.testimonial {
|
||||
border-left: 2px solid rgba(27,176,206,0.25);
|
||||
background: rgba(27,176,206,0.06);
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.stars-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.wave-canvas {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background: #0a0a0f;
|
||||
}
|
||||
.accent-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, #1BB0CE, rgba(232,232,239,0.15), transparent);
|
||||
animation: pulseAccent 3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulseAccent {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
.input-sheen {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.input-sheen::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -10%;
|
||||
left: -100%;
|
||||
width: 60%;
|
||||
height: 120%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.08), transparent);
|
||||
transform: rotate(-15deg);
|
||||
animation: sheenFloat 3.2s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
.input-sheen-delayed::before {
|
||||
animation-delay: 1s;
|
||||
}
|
||||
.input-sheen:focus-within::before {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
}
|
||||
@keyframes sheenFloat {
|
||||
0% { left: -100%; }
|
||||
100% { left: 200%; }
|
||||
}
|
||||
.login-input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
background: linear-gradient(135deg, #0d0d15, #151520, #0d0d15);
|
||||
background-size: 200% 200%;
|
||||
animation: bgShift 6s ease-in-out infinite;
|
||||
border: 1.5px solid rgba(180,192,210,0.15);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: #e8e8ef;
|
||||
padding: 0 12px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.login-input::placeholder {
|
||||
color: rgba(232,232,239,0.2);
|
||||
}
|
||||
.login-input:focus {
|
||||
border-color: #1BB0CE;
|
||||
outline: none;
|
||||
background: #111118;
|
||||
animation: none;
|
||||
}
|
||||
@keyframes bgShift {
|
||||
0%, 100% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
}
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(232,232,239,0.3);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 3;
|
||||
}
|
||||
.password-toggle:hover {
|
||||
color: rgba(232,232,239,0.5);
|
||||
}
|
||||
.login-checkbox {
|
||||
accent-color: #1BB0CE;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.auth-btn {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: #1BB0CE;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
padding: 13px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.auth-btn:hover {
|
||||
background: #17a0bc;
|
||||
}
|
||||
.auth-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.auth-btn::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 60%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
|
||||
animation: btnSweep 2.2s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes btnSweep {
|
||||
0% { left: -100%; }
|
||||
100% { left: 200%; }
|
||||
}
|
||||
.login-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: rgba(232,232,239,0.4);
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-5px); }
|
||||
}
|
||||
.body-text { color: rgba(232,232,239,0.5); }
|
||||
.subheading-text { color: rgba(232,232,239,0.38); }
|
||||
.checkbox-text { color: rgba(232,232,239,0.38); }
|
||||
.footer-text { color: rgba(232,232,239,0.2); }
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { query } from "@/lib/db"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ token: string }>
|
||||
}
|
||||
|
||||
function ErrorPage({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
|
||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">
|
||||
{message}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function JoinPage({ params }: Props) {
|
||||
const { token } = await params
|
||||
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
if (UUID_REGEX.test(token)) {
|
||||
redirect(`/call/${token}`)
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`SELECT phone, created_at, expires_at FROM invites WHERE token = $1 LIMIT 1`,
|
||||
[token],
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return <ErrorPage message="This call link is invalid." />
|
||||
}
|
||||
|
||||
const invite = result.rows[0]
|
||||
|
||||
if (new Date(invite.expires_at) <= new Date()) {
|
||||
return <ErrorPage message="This call has expired." />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
|
||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">
|
||||
You're Invited!
|
||||
</h1>
|
||||
<p className="text-[#888888] text-sm mb-6">
|
||||
Someone wants to connect with you on our platform.
|
||||
</p>
|
||||
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-4 mb-6 text-left">
|
||||
<p className="text-xs text-[#888888] mb-1">Contact Number</p>
|
||||
<p className="text-sm font-medium text-[#111111] dark:text-white">{invite.phone}</p>
|
||||
</div>
|
||||
<p className="text-xs text-[#888888] mb-6">
|
||||
Create an account to start making free voice calls to this person and others on the platform.
|
||||
</p>
|
||||
<a
|
||||
href="/register"
|
||||
className="block w-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-3 transition-all duration-200"
|
||||
>
|
||||
Create Account
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Metadata, Viewport } from "next"
|
||||
import { Inter } from "next/font/google"
|
||||
import { ThemeProvider } from "@/providers/theme-provider"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import "./globals.css"
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
subsets: ["latin"],
|
||||
})
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "CRM",
|
||||
description: "Customer Relationship Management System",
|
||||
icons: {
|
||||
icon: "/logo/CompanyMiniLogo.png",
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={`${inter.variable} min-h-screen antialiased`}>
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" disableTransitionOnChange>
|
||||
{children}
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export default function LoginLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { COMPANY_NAME } from "@/lib/constants"
|
||||
import { Eye, EyeOff, Loader2 } from "lucide-react"
|
||||
|
||||
const waves = [
|
||||
{ a: 16, f: 0.011, s: 0.018, y: 0.38, fill: "rgba(27,176,206,0.18)" },
|
||||
{ a: 20, f: 0.008, s: 0.013, y: 0.52, fill: "rgba(27,176,206,0.25)" },
|
||||
{ a: 12, f: 0.015, s: 0.025, y: 0.28, fill: "rgba(180,192,210,0.06)" },
|
||||
{ a: 26, f: 0.006, s: 0.010, y: 0.65, fill: "rgba(180,192,210,0.15)" },
|
||||
{ a: 10, f: 0.019, s: 0.030, y: 0.20, fill: "rgba(27,176,206,0.10)" },
|
||||
]
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [remember, setRemember] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [counts, setCounts] = useState({ leads: 0, conversion: 0, users: 0 })
|
||||
const counterStarted = useRef(false)
|
||||
const [testimonialIndex, setTestimonialIndex] = useState(0)
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
text: "This CRM transformed how we manage our sales pipeline. We've seen a 40% increase in lead conversion.",
|
||||
author: "Marcus Johnson, Sales Lead at Coast IT",
|
||||
},
|
||||
{
|
||||
text: "When you're not sure, flip a coin, because when the coin is in the air, you realize which option you're actually hoping for.",
|
||||
author: "Dillen van der Merwe, Madman of Coast IT",
|
||||
},
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setTestimonialIndex((prev) => (prev + 1) % testimonials.length)
|
||||
}, 5000)
|
||||
return () => clearInterval(timer)
|
||||
}, [testimonials.length])
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const starsCanvasRightRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
|
||||
let animId: number
|
||||
let time = 0
|
||||
|
||||
const resize = () => {
|
||||
const parent = canvas.parentElement!
|
||||
const rect = parent.getBoundingClientRect()
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
canvas.width = rect.width * dpr
|
||||
canvas.height = 200 * dpr
|
||||
canvas.style.width = rect.width + "px"
|
||||
canvas.style.height = "200px"
|
||||
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
}
|
||||
|
||||
resize()
|
||||
window.addEventListener("resize", resize)
|
||||
|
||||
const draw = () => {
|
||||
const w = canvas.width / (window.devicePixelRatio || 1)
|
||||
const h = 200
|
||||
ctx!.clearRect(0, 0, w, h)
|
||||
|
||||
for (const wave of waves) {
|
||||
ctx!.beginPath()
|
||||
ctx!.moveTo(0, h)
|
||||
for (let x = 0; x <= w; x++) {
|
||||
const y = wave.y * h + Math.sin(x * wave.f + time * wave.s) * wave.a
|
||||
ctx!.lineTo(x, y)
|
||||
}
|
||||
ctx!.lineTo(w, h)
|
||||
ctx!.closePath()
|
||||
ctx!.fillStyle = wave.fill
|
||||
ctx!.fill()
|
||||
}
|
||||
|
||||
time += 1
|
||||
animId = requestAnimationFrame(draw)
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(draw)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animId)
|
||||
window.removeEventListener("resize", resize)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const canvases = [starsCanvasRightRef.current].filter(Boolean) as HTMLCanvasElement[]
|
||||
if (canvases.length === 0) return
|
||||
|
||||
const stars = Array.from({ length: 312 }, () => ({
|
||||
x: Math.random(),
|
||||
y: Math.random(),
|
||||
r: 0.2 + Math.random() * 0.6,
|
||||
baseOpacity: 0.12 + Math.random() * 0.43,
|
||||
speed: 0.003 + Math.random() * 0.015,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
driftX: (Math.random() - 0.5) * 0.00003,
|
||||
driftY: (Math.random() - 0.5) * 0.00003,
|
||||
}))
|
||||
|
||||
let animId: number
|
||||
let time = 0
|
||||
|
||||
const resize = () => {
|
||||
for (const c of canvases) {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const rect = c.getBoundingClientRect()
|
||||
c.width = rect.width * dpr
|
||||
c.height = rect.height * dpr
|
||||
const ctx = c.getContext("2d")
|
||||
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
resize()
|
||||
const ros = canvases.map((c) => {
|
||||
const ro = new ResizeObserver(() => resize())
|
||||
ro.observe(c.parentElement!)
|
||||
return ro
|
||||
})
|
||||
window.addEventListener("resize", resize)
|
||||
|
||||
const draw = () => {
|
||||
time += 1
|
||||
for (const c of canvases) {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const w = c.width / dpr
|
||||
const h = c.height / dpr
|
||||
const ctx = c.getContext("2d")
|
||||
if (!ctx) continue
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
for (const star of stars) {
|
||||
const x = ((star.x + time * star.driftX) % 1) * w
|
||||
const y = ((star.y + time * star.driftY) % 1) * h
|
||||
const opacity = star.baseOpacity * (0.5 + 0.5 * Math.sin(time * star.speed + star.phase))
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, star.r, 0, Math.PI * 2)
|
||||
ctx.fillStyle = `rgba(255,255,255,${opacity})`
|
||||
ctx.fill()
|
||||
|
||||
if (star.r > 0.5) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, star.r * 1.8, 0, Math.PI * 2)
|
||||
ctx.fillStyle = `rgba(255,255,255,${opacity * 0.06})`
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(draw)
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(draw)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animId)
|
||||
ros.forEach((ro) => ro.disconnect())
|
||||
window.removeEventListener("resize", resize)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (counterStarted.current) return
|
||||
counterStarted.current = true
|
||||
const targets = { leads: 1247, conversion: 40, users: 83 }
|
||||
const steps = 150
|
||||
|
||||
const delayTimer = setTimeout(() => {
|
||||
let step = 0
|
||||
const interval = setInterval(() => {
|
||||
step++
|
||||
if (step >= steps) {
|
||||
clearInterval(interval)
|
||||
setCounts(targets)
|
||||
return
|
||||
}
|
||||
setCounts({
|
||||
leads: Math.min(Math.floor((targets.leads / steps) * step), targets.leads),
|
||||
conversion: Math.min(Math.floor((targets.conversion / steps) * step), targets.conversion),
|
||||
users: Math.min(Math.floor((targets.users / steps) * step), targets.users),
|
||||
})
|
||||
}, 15)
|
||||
}, 400)
|
||||
|
||||
return () => clearTimeout(delayTimer)
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
router.push("/dashboard")
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
setError(data.error || "Invalid email or password.")
|
||||
}
|
||||
} catch {
|
||||
setError("Connection error. Please try again.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-[#0a0a0f]">
|
||||
<div className="left-panel flex flex-1 flex-col p-12">
|
||||
<div className="relative z-10">
|
||||
<img
|
||||
src="/logo/CompanyLogo.png"
|
||||
alt={COMPANY_NAME}
|
||||
className="h-44 w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-[34px] font-extrabold text-[#e8e8ef] leading-tight tracking-[-0.6px]">
|
||||
Your Agency's{" "}
|
||||
<span className="growth-word">Growth</span>{" "}
|
||||
Starts Here
|
||||
</h1>
|
||||
<p className="mt-4 text-[13px] leading-[1.7] max-w-[360px] mx-auto body-text">
|
||||
Manage leads, track conversions, and grow your web development business with our CRM.
|
||||
</p>
|
||||
<div className="stats-row">
|
||||
<div className="stat">
|
||||
<div className="stat-number">{counts.leads.toLocaleString()}</div>
|
||||
<div className="stat-label">leads tracked</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-number">{counts.conversion}%</div>
|
||||
<div className="stat-label">conversion lift</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-number">{counts.users}</div>
|
||||
<div className="stat-label">active users</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 testimonial" style={{ background: "rgba(255,255,255,0.7)", padding: "16px 20px 16px 16px", clipPath: "url(#testimonialClip)" }}>
|
||||
<svg width="0" height="0" style={{ position: "absolute" }}>
|
||||
<defs>
|
||||
<clipPath id="testimonialClip" clipPathUnits="objectBoundingBox">
|
||||
<path d="M0,0 L0.92,0 C0.96,0 1,0.03 1,0.08 C1,0.14 0.97,0.22 0.9,0.3 C0.85,0.36 0.83,0.42 0.83,0.5 C0.83,0.58 0.85,0.64 0.9,0.7 C0.97,0.78 1,0.86 1,0.92 C1,0.97 0.96,1 0.92,1 L0,1 Z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<div className="transition-opacity duration-500" key={testimonialIndex}>
|
||||
<p className="text-sm font-semibold leading-relaxed" style={{ color: "#0a0a0f" }}>
|
||||
“{testimonials[testimonialIndex].text}”
|
||||
</p>
|
||||
<p className="text-xs font-bold mt-2" style={{ color: "#0a0a0f" }}>
|
||||
{testimonials[testimonialIndex].author}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-1.5 mt-3">
|
||||
{testimonials.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setTestimonialIndex(i)}
|
||||
className={`h-1.5 rounded-full transition-all duration-300 ${i === testimonialIndex ? "w-5 bg-[#1BB0CE]" : "w-1.5 bg-[#1BB0CE]/30"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<canvas ref={canvasRef} className="wave-canvas" />
|
||||
</div>
|
||||
|
||||
<div className="panel-divider" />
|
||||
|
||||
<div className="right-panel flex items-center justify-center p-10">
|
||||
<canvas ref={starsCanvasRightRef} className="stars-canvas" />
|
||||
<div className="accent-line" />
|
||||
|
||||
<div className="w-full" style={{ maxWidth: 340 }}>
|
||||
<h2 className="text-[24px] font-bold text-[#e8e8ef] tracking-[-0.3px] mb-[5px]">
|
||||
Welcome back
|
||||
</h2>
|
||||
<p className="text-[13px] mb-[26px] subheading-text">
|
||||
Sign in to your account to continue
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded bg-red-500/10 px-4 py-2 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="email" className="login-label">
|
||||
Email
|
||||
</label>
|
||||
<div className="input-sheen">
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="name@company.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="email"
|
||||
className="login-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-[6px]">
|
||||
<label htmlFor="password" className="login-label" style={{ marginBottom: 0 }}>
|
||||
Password
|
||||
</label>
|
||||
<button type="button" className="text-[12px] text-[#1BB0CE] hover:underline" onClick={() => alert("Please contact your administrator to reset your password.")}>
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
<div className="input-sheen input-sheen-delayed">
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="login-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="password-toggle"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
className="login-checkbox"
|
||||
/>
|
||||
<span className="text-[13px] checkbox-text">
|
||||
Remember me
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<button type="submit" className="auth-btn" disabled={loading}>
|
||||
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{loading ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-[11px] mt-4 footer-text">
|
||||
By signing in, you agree to our{" "}
|
||||
<button type="button" className="text-[#1BB0CE] hover:underline">
|
||||
Terms of Service
|
||||
</button>{" "}
|
||||
and{" "}
|
||||
<button type="button" className="text-[#1BB0CE] hover:underline">
|
||||
Privacy Policy
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { motion } from "framer-motion"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { FileQuestion } from "lucide-react"
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex flex-col items-center text-center"
|
||||
>
|
||||
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-muted">
|
||||
<FileQuestion className="h-12 w-12 text-muted-foreground" />
|
||||
</div>
|
||||
<h1 className="mt-6 text-4xl font-bold">404</h1>
|
||||
<p className="mt-2 text-lg text-muted-foreground">Page not found</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
The page you are looking for does not exist.
|
||||
</p>
|
||||
<Link href="/dashboard" className="mt-6">
|
||||
<Button>Go Home</Button>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default function RootPage() {
|
||||
redirect("/dashboard")
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect, Fragment } from "react"
|
||||
import { Send, Loader2, Bot, User, RefreshCw, AlertCircle } from "lucide-react"
|
||||
|
||||
function linkifyText(text: string) {
|
||||
const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/
|
||||
const parts = text.split(urlRegex)
|
||||
return parts.map((part, i) => {
|
||||
if (part.startsWith("http://") || part.startsWith("https://")) {
|
||||
return <a key={i} href={part} target="_blank" rel="noopener noreferrer" className="underline text-[#1BB0CE] hover:text-[#1BB0CE]/80">{part}</a>
|
||||
}
|
||||
return <Fragment key={i}>{part}</Fragment>
|
||||
})
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
}
|
||||
|
||||
export function AIChat() {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [input, setInput] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [ollamaStatus, setOllamaStatus] = useState<boolean | null>(null)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/ai/jobs")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.jobs?.length) {
|
||||
const names = data.jobs.map((j: { job_title: string }) => j.job_title)
|
||||
setMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: `Hi! I'm your Sales AI Assistant. I can help with tips for targeting:\n\n${names.map((n: string) => `• ${n}`).join("\n")}\n\nWhat would you like to know?`,
|
||||
},
|
||||
])
|
||||
} else {
|
||||
setMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Hi! I'm your Sales AI Assistant. Ask me anything about sales strategies and prospect targeting.",
|
||||
},
|
||||
])
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Hi! I'm your Sales AI Assistant. Ask me anything about sales strategies and prospect targeting.",
|
||||
},
|
||||
])
|
||||
})
|
||||
}, [])
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}, [messages])
|
||||
|
||||
const checkOllama = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/ai/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: "__ping__" }),
|
||||
})
|
||||
setOllamaStatus(res.status !== 503)
|
||||
} catch {
|
||||
setOllamaStatus(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { checkOllama() }, [])
|
||||
|
||||
const sendMessage = async () => {
|
||||
const msg = input.trim()
|
||||
if (!msg || loading) return
|
||||
|
||||
setInput("")
|
||||
setError("")
|
||||
setMessages((prev) => [...prev, { role: "user", content: msg }])
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/ai/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: msg }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || "Failed to get response")
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: data.response }])
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : "AI service unavailable"
|
||||
setError(errMsg)
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: "assistant", content: `⚠️ Error: ${errMsg}. Make sure Ollama is running with the model loaded.` },
|
||||
])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
sendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{ollamaStatus === false && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-amber-500/10 border-b border-amber-500/20 text-amber-400 text-xs">
|
||||
<AlertCircle className="h-3.5 w-3.5 flex-none" />
|
||||
<span className="flex-1">Ollama not responding. Start it with <code className="bg-amber-500/20 px-1 rounded">ollama serve</code></span>
|
||||
<button type="button" onClick={checkOllama} className="hover:text-amber-300">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-thin">
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`flex gap-3 ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
|
||||
{msg.role === "assistant" && (
|
||||
<div className="h-8 w-8 rounded-full bg-[#1BB0CE]/20 flex items-center justify-center flex-none">
|
||||
<Bot className="h-4 w-4 text-[#1BB0CE]" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[75%] rounded-lg px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap ${
|
||||
msg.role === "user"
|
||||
? "bg-[#1BB0CE] text-white"
|
||||
: "bg-[#1a1a24] text-[#c8c8d0] border border-[#2a2a35]"
|
||||
}`}
|
||||
>
|
||||
{linkifyText(msg.content)}
|
||||
</div>
|
||||
{msg.role === "user" && (
|
||||
<div className="h-8 w-8 rounded-full bg-[#1BB0CE] flex items-center justify-center flex-none">
|
||||
<User className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{loading && (
|
||||
<div className="flex gap-3 justify-start">
|
||||
<div className="h-8 w-8 rounded-full bg-[#1BB0CE]/20 flex items-center justify-center flex-none">
|
||||
<Bot className="h-4 w-4 text-[#1BB0CE]" />
|
||||
</div>
|
||||
<div className="max-w-[75%] rounded-lg px-4 py-2.5 bg-[#1a1a24] border border-[#2a2a35]">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-[#1BB0CE]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#2a2a35] p-4">
|
||||
{error && (
|
||||
<div className="mb-2 text-xs text-red-400 flex items-center gap-1.5">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask for sales tips..."
|
||||
rows={1}
|
||||
className="flex-1 bg-[#1a1a24] border border-[#2a2a35] rounded-lg px-3 py-2 text-sm text-[#e8e8ef] placeholder-[#6a6a75] resize-none outline-none focus:border-[#1BB0CE]/50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={sendMessage}
|
||||
disabled={loading || !input.trim()}
|
||||
className="h-9 w-9 rounded-lg bg-[#1BB0CE] hover:bg-[#1BB0CE]/80 disabled:opacity-40 flex items-center justify-center flex-none transition-colors"
|
||||
>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Briefcase, ChevronDown, Loader2 } from "lucide-react"
|
||||
|
||||
interface Job {
|
||||
job_title: string
|
||||
keywords: string[]
|
||||
industry: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface JobSelectorProps {
|
||||
onSelect: (job: Job | null) => void
|
||||
}
|
||||
|
||||
export function JobSelector({ onSelect }: JobSelectorProps) {
|
||||
const [jobs, setJobs] = useState<Job[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selected, setSelected] = useState<Job | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/ai/jobs")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setJobs(data.jobs || []))
|
||||
.catch(() => setJobs([]))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleSelect = (job: Job) => {
|
||||
setSelected(job)
|
||||
setOpen(false)
|
||||
onSelect(job)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full flex items-center gap-2 bg-[#1a1a24] border border-[#2a2a35] rounded-lg px-3 py-2 text-sm text-[#e8e8ef] hover:border-[#1BB0CE]/50 transition-colors"
|
||||
>
|
||||
<Briefcase className="h-4 w-4 text-[#1BB0CE] flex-none" />
|
||||
<span className="flex-1 text-left truncate">
|
||||
{selected ? selected.job_title : loading ? "Loading jobs..." : "Select a job category"}
|
||||
</span>
|
||||
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ChevronDown className="h-3.5 w-3.5 text-[#6a6a75]" />}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
||||
<div className="absolute top-full left-0 right-0 mt-1 z-20 bg-[#15151e] border border-[#2a2a35] rounded-lg shadow-xl max-h-60 overflow-y-auto">
|
||||
{jobs.map((job, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => handleSelect(job)}
|
||||
className="w-full text-left px-3 py-2.5 text-sm text-[#c8c8d0] hover:bg-[#1a1a24] hover:text-[#e8e8ef] transition-colors border-b border-[#1a1a24] last:border-0"
|
||||
>
|
||||
<div className="font-medium">{job.job_title}</div>
|
||||
<div className="text-xs text-[#6a6a75] mt-0.5">{job.industry} — {job.description}</div>
|
||||
</button>
|
||||
))}
|
||||
{jobs.length === 0 && !loading && (
|
||||
<div className="px-3 py-4 text-xs text-[#6a6a75] text-center">No job categories loaded</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { Phone, X, Copy, MessageSquare } from "lucide-react"
|
||||
import { getSupabase } from "@/lib/supabase"
|
||||
import { useWebRTCCall } from "@/hooks/useWebRTCCall"
|
||||
|
||||
interface Contact {
|
||||
id: string
|
||||
name: string
|
||||
phone: string
|
||||
avatar_url: string | null
|
||||
}
|
||||
|
||||
interface VoiceCallModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function formatPhoneForWhatsApp(phone: string): string {
|
||||
const digits = phone.replace(/[^0-9]/g, "")
|
||||
if (!digits) return ""
|
||||
if (digits.startsWith("27")) return digits
|
||||
if (digits.startsWith("0")) return "27" + digits.slice(1)
|
||||
return digits
|
||||
}
|
||||
|
||||
export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
|
||||
const [contacts, setContacts] = useState<Contact[]>([])
|
||||
const [contactsLoading, setContactsLoading] = useState(false)
|
||||
const [contactsError, setContactsError] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [phoneNumber, setPhoneNumber] = useState("")
|
||||
const [phoneError, setPhoneError] = useState(false)
|
||||
|
||||
const [callLink, setCallLink] = useState<string | null>(null)
|
||||
const [shareSent, setShareSent] = useState(false)
|
||||
const [shareError, setShareError] = useState<string | null>(null)
|
||||
const [targetPhone, setTargetPhone] = useState<string>("")
|
||||
const [showWaUrl, setShowWaUrl] = useState<string | null>(null)
|
||||
|
||||
const { createRoom } = useWebRTCCall()
|
||||
|
||||
const handleCall = useCallback((phone?: string) => {
|
||||
setShareSent(false)
|
||||
setShareError(null)
|
||||
const { roomId, link } = createRoom()
|
||||
setCallLink(link)
|
||||
setTargetPhone(phone || "")
|
||||
console.log("[call] room created, roomId:", roomId)
|
||||
console.log("[call] join link:", link)
|
||||
console.log("[call] target phone:", phone || "none")
|
||||
}, [createRoom])
|
||||
|
||||
const copyLink = useCallback(async () => {
|
||||
if (!callLink) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(callLink)
|
||||
setShareSent(true)
|
||||
setShareError(null)
|
||||
console.log("[share] link copied")
|
||||
} catch {
|
||||
setShareError("Failed to copy URL")
|
||||
console.error("[share] copy failed")
|
||||
}
|
||||
}, [callLink])
|
||||
|
||||
const sendWhatsApp = useCallback(() => {
|
||||
if (!callLink) return
|
||||
|
||||
const originalPhone = targetPhone
|
||||
const formattedPhone = formatPhoneForWhatsApp(originalPhone)
|
||||
const generatedCallLink = callLink
|
||||
|
||||
console.log("[wa] original phone:", originalPhone)
|
||||
console.log("[wa] formatted phone:", formattedPhone)
|
||||
console.log("[wa] call link:", generatedCallLink)
|
||||
|
||||
if (!formattedPhone) {
|
||||
setShareError("Invalid phone number.")
|
||||
console.error("[wa] invalid phone number")
|
||||
return
|
||||
}
|
||||
|
||||
const msg = encodeURIComponent(`Hi! Join me on this link so we can start our call:\n\n${generatedCallLink}`)
|
||||
const waUrl = `https://wa.me/${formattedPhone}?text=${msg}`
|
||||
|
||||
console.log("[wa] WhatsApp URL:", waUrl)
|
||||
setShowWaUrl(waUrl)
|
||||
|
||||
const w = window.open(waUrl, "_blank")
|
||||
if (!w || w.closed) {
|
||||
setShareError("Could not open WhatsApp. Please check your browser allows popups.")
|
||||
console.error("[wa] failed: window.open returned null or closed")
|
||||
} else {
|
||||
setShareSent(true)
|
||||
setShareError(null)
|
||||
console.log("[wa] success: WhatsApp opened")
|
||||
}
|
||||
}, [callLink, targetPhone])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSearchQuery("")
|
||||
setPhoneNumber("")
|
||||
setPhoneError(false)
|
||||
setCallLink(null)
|
||||
setShareSent(false)
|
||||
setShareError(null)
|
||||
setTargetPhone("")
|
||||
setShowWaUrl(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const FALLBACK_CONTACTS: Contact[] = [
|
||||
{ id: "fallback-dillen", name: "Dillen", phone: "0799158142", avatar_url: null },
|
||||
{ id: "fallback-ewan", name: "Ewan", phone: "0845172665", avatar_url: null },
|
||||
{ id: "fallback-caitlin", name: "Caitlin", phone: "0612729281", avatar_url: null },
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const fetchContacts = async () => {
|
||||
setContactsLoading(true)
|
||||
setContactsError(false)
|
||||
try {
|
||||
const sb = getSupabase()
|
||||
const { data, error } = await sb
|
||||
.from("contacts")
|
||||
.select("id, name, phone, avatar_url")
|
||||
.order("name", { ascending: true })
|
||||
if (error) throw error
|
||||
setContacts([...(data || []), ...FALLBACK_CONTACTS])
|
||||
} catch {
|
||||
setContacts(FALLBACK_CONTACTS)
|
||||
} finally {
|
||||
setContactsLoading(false)
|
||||
}
|
||||
}
|
||||
fetchContacts()
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose()
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
return () => document.removeEventListener("keydown", handleKeyDown)
|
||||
}, [open, onClose])
|
||||
|
||||
const filteredContacts = contacts.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
c.phone.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-6 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-[0_8px_40px_rgba(0,0,0,0.18)] relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-[#888888] hover:text-[#111111] dark:hover:text-white transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{callLink ? (
|
||||
shareSent ? (
|
||||
<>
|
||||
<p className="text-sm text-[#00AA00] font-semibold text-center mb-3">Call link copied.</p>
|
||||
<h2 className="font-bold text-lg text-[#111111] dark:text-white">Waiting for participant</h2>
|
||||
<p className="text-[#888888] text-sm mt-1 mb-4">
|
||||
The recipient must receive and open the call link.
|
||||
</p>
|
||||
|
||||
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3 mb-4 break-all text-xs text-[#111111] dark:text-white">
|
||||
{callLink}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={copyLink}
|
||||
className="flex-1 flex items-center justify-center gap-2 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy URL
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShareSent(false); setShareError(null) }}
|
||||
className="flex-1 flex items-center justify-center gap-2 bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
WhatsApp
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={callLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full mt-4 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 text-center transition-all duration-200"
|
||||
>
|
||||
Open Call
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="font-bold text-lg text-[#111111] dark:text-white">Choose how to notify this person</h2>
|
||||
<p className="text-[#888888] text-sm mt-1 mb-4">
|
||||
Select a method to send the call link
|
||||
</p>
|
||||
|
||||
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3 mb-4 break-all text-xs text-[#111111] dark:text-white">
|
||||
{callLink}
|
||||
</div>
|
||||
|
||||
{showWaUrl && (
|
||||
<div className="bg-[#1A1A1A] dark:bg-[#000000] rounded-xl p-2 mb-3 break-all text-[10px] text-[#00AA00] font-mono">
|
||||
{showWaUrl}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 mb-4">
|
||||
<button
|
||||
onClick={sendWhatsApp}
|
||||
className="flex items-center justify-center gap-2 bg-[#25D366] hover:bg-[#1da851] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
WhatsApp
|
||||
</button>
|
||||
<button
|
||||
onClick={copyLink}
|
||||
className="flex items-center justify-center gap-2 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy URL
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{shareError && (
|
||||
<p className="text-[#CC0000] text-xs text-center">{shareError}</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<h2 className="font-bold text-lg text-[#111111] dark:text-white">Start a Call</h2>
|
||||
<p className="text-[#888888] text-sm mt-1 mb-5">Search contacts or dial a number</p>
|
||||
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-[#888888] dark:text-[#666666] mb-2">
|
||||
CONTACTS
|
||||
</p>
|
||||
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search contacts..."
|
||||
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-3 outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]"
|
||||
/>
|
||||
|
||||
<div className="max-h-[200px] overflow-y-auto space-y-1 mb-4">
|
||||
{contactsLoading && (
|
||||
<p className="text-[#AAAAAA] text-sm text-center py-4 animate-pulse">Loading contacts...</p>
|
||||
)}
|
||||
{contactsError && (
|
||||
<p className="text-[#CC0000] text-sm text-center py-4">Could not load contacts</p>
|
||||
)}
|
||||
{!contactsLoading && !contactsError && filteredContacts.length === 0 && (
|
||||
<p className="text-[#AAAAAA] text-sm text-center py-4">No contacts found</p>
|
||||
)}
|
||||
{!contactsLoading && !contactsError && filteredContacts.map((contact) => (
|
||||
<div
|
||||
key={contact.id}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-xl cursor-pointer transition-all duration-150 hover:bg-[#F5F5F5] dark:hover:bg-[#1A1A1A]"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold shrink-0 bg-[#FFF0F0] dark:bg-[#CC0000]/15 text-[#CC0000] dark:text-[#FF4444]">
|
||||
{contact.avatar_url ? (
|
||||
<img
|
||||
src={contact.avatar_url}
|
||||
alt={contact.name}
|
||||
className="w-full h-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
contact.name.charAt(0).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-[#111111] dark:text-white truncate">{contact.name}</p>
|
||||
<p className="text-xs text-[#888888] dark:[#666666] truncate">{contact.phone}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCall(contact.phone)}
|
||||
className="shrink-0 text-[#CC0000] dark:text-[#FF4444] hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<Phone className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 my-4">
|
||||
<hr className="flex-1 border-t border-[#E0E0E0] dark:border-[#222222]" />
|
||||
<span className="text-xs text-[#AAAAAA] dark:text-[#555555]">OR</span>
|
||||
<hr className="flex-1 border-t border-[#E0E0E0] dark:border-[#222222]" />
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-[#888888] dark:text-[#666666] mb-2">
|
||||
DIAL A NUMBER
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="tel"
|
||||
value={phoneNumber}
|
||||
onChange={(e) => { setPhoneNumber(e.target.value); setPhoneError(false) }}
|
||||
placeholder="+27 000 000 0000"
|
||||
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]"
|
||||
/>
|
||||
{phoneError && (
|
||||
<p className="text-[#CC0000] text-xs mt-1">Please enter a phone number</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
const trimmed = phoneNumber.trim()
|
||||
if (!trimmed) {
|
||||
setPhoneError(true)
|
||||
return
|
||||
}
|
||||
setPhoneError(false)
|
||||
handleCall(trimmed)
|
||||
}}
|
||||
className="w-full mt-3 bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200"
|
||||
>
|
||||
<Phone className="h-4 w-4" />
|
||||
Call Now
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client"
|
||||
|
||||
export function CrossedLightsabers() {
|
||||
return (
|
||||
<svg
|
||||
width="120"
|
||||
height="120"
|
||||
viewBox="0 0 200 200"
|
||||
className="pointer-events-none select-none overflow-visible shrink-0"
|
||||
style={{ overflow: "visible" }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id="purpleGlow" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stopColor="#a855f7" stopOpacity="0.6" />
|
||||
<stop offset="40%" stopColor="#a855f7" stopOpacity="0.25" />
|
||||
<stop offset="100%" stopColor="#a855f7" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<circle cx="100" cy="73" r="24" fill="url(#purpleGlow)" className="animate-pulse-intersection" />
|
||||
|
||||
<g className="animate-pulse-red">
|
||||
<line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" opacity="0.12" className="stroke-[#dc2626] dark:stroke-[#ef4444] saber-outer" />
|
||||
<line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" opacity="0.25" className="stroke-[#dc2626] dark:stroke-[#ef4444] saber-mid" />
|
||||
<line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" className="stroke-[#f87171] dark:stroke-[#fca5a5] saber-core" />
|
||||
</g>
|
||||
|
||||
<g className="animate-pulse-blue">
|
||||
<line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" opacity="0.12" className="stroke-[#1d4ed8] dark:stroke-[#3b82f6] saber-blue-outer" />
|
||||
<line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" opacity="0.25" className="stroke-[#1d4ed8] dark:stroke-[#3b82f6] saber-blue-mid" />
|
||||
<line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" className="stroke-[#60a5fa] dark:stroke-[#93c5fd] saber-blue-core" />
|
||||
</g>
|
||||
|
||||
<rect x="30" y="145" width="10" height="55" rx="2" fill="#374151" />
|
||||
<rect x="160" y="145" width="10" height="55" rx="2" fill="#374151" />
|
||||
|
||||
<rect x="30" y="155" width="10" height="5" fill="#1f2937" />
|
||||
<rect x="30" y="166" width="10" height="5" fill="#1f2937" />
|
||||
<rect x="30" y="177" width="10" height="5" fill="#1f2937" />
|
||||
<rect x="160" y="155" width="10" height="5" fill="#1f2937" />
|
||||
<rect x="160" y="166" width="10" height="5" fill="#1f2937" />
|
||||
<rect x="160" y="177" width="10" height="5" fill="#1f2937" />
|
||||
|
||||
<rect x="32" y="143" width="6" height="4" rx="1" fill="#6b7280" />
|
||||
<rect x="162" y="143" width="6" height="4" rx="1" fill="#6b7280" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
interface StatusData {
|
||||
name: string
|
||||
value: number
|
||||
color: string
|
||||
}
|
||||
|
||||
interface LeadStatusChartProps {
|
||||
data: StatusData[]
|
||||
}
|
||||
|
||||
function polar(cx: number, cy: number, r: number, deg: number) {
|
||||
const rad = ((deg - 90) * Math.PI) / 180
|
||||
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }
|
||||
}
|
||||
|
||||
function arcPath(cx: number, cy: number, oR: number, iR: number, start: number, end: number) {
|
||||
const gap = 3
|
||||
const s = start + gap / 2
|
||||
const e = end - gap / 2
|
||||
const o1 = polar(cx, cy, oR, s)
|
||||
const o2 = polar(cx, cy, oR, e)
|
||||
const i1 = polar(cx, cy, iR, e)
|
||||
const i2 = polar(cx, cy, iR, s)
|
||||
const lg = e - s > 180 ? 1 : 0
|
||||
return `M${o1.x} ${o1.y} A${oR} ${oR} 0 ${lg} 1 ${o2.x} ${o2.y} L${i1.x} ${i1.y} A${iR} ${iR} 0 ${lg} 0 ${i2.x} ${i2.y}Z`
|
||||
}
|
||||
|
||||
export function LeadStatusChart({ data }: LeadStatusChartProps) {
|
||||
const [hov, setHov] = useState<number | null>(null)
|
||||
|
||||
const total = data.reduce((s, d) => s + d.value, 0)
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Lead Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center h-[400px] text-sm text-muted-foreground">
|
||||
No data for this period
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
let cum = 0
|
||||
const segs = data.map((d) => {
|
||||
const start = cum
|
||||
const deg = (d.value / total) * 360
|
||||
cum += deg
|
||||
return { ...d, start, end: cum, deg, pct: Math.round((d.value / total) * 100) }
|
||||
})
|
||||
|
||||
const active = hov !== null ? segs[hov] : null
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.2 }}
|
||||
className="h-full"
|
||||
>
|
||||
<Card className="h-full relative overflow-hidden">
|
||||
<CardHeader>
|
||||
<CardTitle>Lead Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col relative z-10">
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
{/* Donut */}
|
||||
<svg width="100%" height="100%" viewBox="0 0 320 320" className="max-h-full overflow-visible" style={{ maxWidth: "280px" }}>
|
||||
<defs>
|
||||
{segs.map((s, i) => (
|
||||
<radialGradient key={i} id={`chart-grad-${i}`} cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stopColor={s.color} stopOpacity="1" />
|
||||
<stop offset="100%" stopColor={s.color} stopOpacity="0.75" />
|
||||
</radialGradient>
|
||||
))}
|
||||
</defs>
|
||||
|
||||
{/* Background ring */}
|
||||
<circle cx="160" cy="160" r="105" fill="none" stroke="hsl(var(--muted))" strokeWidth="52" />
|
||||
|
||||
{/* Segments */}
|
||||
{segs.map((s, i) => {
|
||||
const isH = hov === i
|
||||
const oR = isH ? 137 : 130
|
||||
const iR = isH ? 73 : 80
|
||||
return (
|
||||
<path
|
||||
key={i}
|
||||
d={arcPath(160, 160, oR, iR, s.start, s.end)}
|
||||
fill={`url(#chart-grad-${i})`}
|
||||
opacity={hov !== null && !isH ? 0.3 : 1}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
transition: "all 0.22s cubic-bezier(.4,0,.2,1)",
|
||||
filter: isH ? `drop-shadow(0 0 10px ${s.color}99)` : "none",
|
||||
}}
|
||||
onMouseEnter={() => setHov(i)}
|
||||
onMouseLeave={() => setHov(null)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Center circle */}
|
||||
<circle cx="160" cy="160" r="66" fill="hsl(var(--card))" />
|
||||
<circle cx="160" cy="160" r="66" fill="none" stroke="hsl(var(--border))" strokeWidth="1" />
|
||||
|
||||
{/* Center text */}
|
||||
{active ? (
|
||||
<>
|
||||
<circle cx="160" cy="160" r="66" fill={active.color + "15"} />
|
||||
<text x="160" y="148" textAnchor="middle" fill="hsl(var(--foreground))" fontSize="30" fontWeight="700" fontFamily="-apple-system,sans-serif">
|
||||
{active.value}
|
||||
</text>
|
||||
<text x="160" y="166" textAnchor="middle" fill="hsl(var(--muted-foreground))" fontSize="12" fontFamily="-apple-system,sans-serif">
|
||||
{active.name}
|
||||
</text>
|
||||
<text x="160" y="184" textAnchor="middle" fill={active.color} fontSize="13" fontWeight="600" fontFamily="-apple-system,sans-serif">
|
||||
{active.pct}%
|
||||
</text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<text x="160" y="152" textAnchor="middle" fill="hsl(var(--foreground))" fontSize="34" fontWeight="700" fontFamily="-apple-system,sans-serif">
|
||||
{total}
|
||||
</text>
|
||||
<text x="160" y="172" textAnchor="middle" fill="hsl(var(--muted-foreground))" fontSize="12" fontFamily="-apple-system,sans-serif">
|
||||
Total Leads
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-6 grid w-full grid-cols-2 gap-2">
|
||||
{segs.map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
onMouseEnter={() => setHov(i)}
|
||||
onMouseLeave={() => setHov(null)}
|
||||
className="flex cursor-pointer items-center gap-2.5 rounded-xl p-2.5 transition-all duration-200"
|
||||
style={{
|
||||
background: hov === i ? `${s.color}18` : "hsl(var(--muted) / 0.3)",
|
||||
border: `1px solid ${hov === i ? s.color + "50" : "hsl(var(--border))"}`,
|
||||
gridColumn: i === segs.length - 1 && segs.length % 2 !== 0 ? "1 / -1" : undefined,
|
||||
maxWidth: i === segs.length - 1 && segs.length % 2 !== 0 ? "50%" : undefined,
|
||||
margin: i === segs.length - 1 && segs.length % 2 !== 0 ? "0 auto" : undefined,
|
||||
width: i === segs.length - 1 && segs.length % 2 !== 0 ? "100%" : undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full transition-shadow duration-200"
|
||||
style={{
|
||||
background: s.color,
|
||||
boxShadow: hov === i ? `0 0 8px ${s.color}` : "none",
|
||||
}}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className="text-xs font-medium transition-colors duration-200"
|
||||
style={{ color: hov === i ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))" }}
|
||||
>
|
||||
{s.name}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px]" style={{ color: "hsl(var(--muted-foreground) / 0.7)" }}>
|
||||
{s.value} · {s.pct}%
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-xs font-semibold transition-colors duration-200"
|
||||
style={{ color: hov === i ? s.color : "hsl(var(--muted-foreground) / 0.5)" }}
|
||||
>
|
||||
{s.pct}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useMemo, useEffect } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
|
||||
interface IntervalData {
|
||||
label: string
|
||||
leads: number
|
||||
closed: number
|
||||
}
|
||||
|
||||
interface LeadsPerMonthChartProps {
|
||||
data: IntervalData[]
|
||||
}
|
||||
|
||||
const NEW_LEADS = "#CC0000"
|
||||
const CLOSED = "#0033CC"
|
||||
|
||||
export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProps) {
|
||||
const [year, setYear] = useState(new Date().getFullYear())
|
||||
const [chartData, setChartData] = useState<IntervalData[]>(initialData)
|
||||
const [hovered, setHovered] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setChartData(initialData)
|
||||
}, [initialData])
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchYearData() {
|
||||
try {
|
||||
const res = await fetch(`/api/dashboard?year=${year}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setChartData(data.leadsPerMonth || [])
|
||||
}
|
||||
} catch {
|
||||
console.warn("Failed to fetch year data in leads per month chart")
|
||||
}
|
||||
}
|
||||
const thisYear = new Date().getFullYear()
|
||||
if (year !== thisYear || initialData.length === 0) {
|
||||
fetchYearData()
|
||||
} else {
|
||||
setChartData(initialData)
|
||||
}
|
||||
}, [year, initialData])
|
||||
|
||||
const width = 880
|
||||
const height = 620
|
||||
const padding = { top: 128, right: 24, bottom: 48, left: 44 }
|
||||
const chartW = width - padding.left - padding.right
|
||||
const chartH = height - padding.top - padding.bottom
|
||||
|
||||
const maxVal = useMemo(() => {
|
||||
if (chartData.length === 0) return 1
|
||||
const max = Math.max(...chartData.map((d) => Math.max(d.leads, d.closed)))
|
||||
return Math.max(Math.ceil(max / 7) * 7, 1)
|
||||
}, [chartData])
|
||||
|
||||
const ticks = useMemo(() => {
|
||||
const steps = 4
|
||||
return Array.from({ length: steps + 1 }, (_, i) => Math.round((maxVal / steps) * i))
|
||||
}, [maxVal])
|
||||
|
||||
const groupW = chartW / Math.max(chartData.length, 1)
|
||||
const barW = groupW * 0.28
|
||||
const gap = groupW * 0.06
|
||||
|
||||
const yScale = (v: number) => chartH - (v / maxVal) * chartH
|
||||
|
||||
const active = hovered
|
||||
const activeDatum = active !== null ? chartData[active] : null
|
||||
|
||||
const totalNew = chartData.reduce((s, d) => s + d.leads, 0)
|
||||
const totalClosed = chartData.reduce((s, d) => s + d.closed, 0)
|
||||
const closeRate = totalNew > 0 ? Math.round((totalClosed / totalNew) * 100) : 0
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Leads Per Month</CardTitle>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setYear((y) => y - 1)}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="min-w-[60px] text-center text-sm font-medium">{year}</span>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setYear((y) => Math.min(y + 1, currentYear))} disabled={year >= currentYear}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center h-[400px] text-sm text-muted-foreground">
|
||||
No data for {year}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.3 }}
|
||||
className="h-full"
|
||||
>
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle>Leads Per Month</CardTitle>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{totalNew} new · {totalClosed} closed · {closeRate}% close rate
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: NEW_LEADS }} />
|
||||
<span className="text-xs text-muted-foreground">New Leads</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: CLOSED }} />
|
||||
<span className="text-xs text-muted-foreground">Closed</span>
|
||||
</div>
|
||||
<div className="ml-2 flex items-center gap-1 rounded-lg border px-2 py-1">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setYear((y) => y - 1)}>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<span className="min-w-[50px] text-center text-sm font-semibold">{year}</span>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setYear((y) => Math.min(y + 1, currentYear))} disabled={year >= currentYear}>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col">
|
||||
<div className="relative flex-1">
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="block overflow-visible"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="newLeadsGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#FF1111" />
|
||||
<stop offset="100%" stopColor={NEW_LEADS} />
|
||||
</linearGradient>
|
||||
<linearGradient id="closedGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#1144FF" />
|
||||
<stop offset="100%" stopColor={CLOSED} />
|
||||
</linearGradient>
|
||||
<filter id="shadowNew">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodColor={NEW_LEADS} floodOpacity="0.4" />
|
||||
</filter>
|
||||
<filter id="shadowClosed">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodColor={CLOSED} floodOpacity="0.4" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<g transform={`translate(${padding.left}, ${padding.top})`}>
|
||||
{/* Grid lines */}
|
||||
{ticks.map((t, i) => (
|
||||
<g key={i}>
|
||||
<line
|
||||
x1={0}
|
||||
x2={chartW}
|
||||
y1={yScale(t)}
|
||||
y2={yScale(t)}
|
||||
stroke="hsl(var(--border))"
|
||||
strokeDasharray={t === 0 ? "0" : "3 5"}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text
|
||||
x={-12}
|
||||
y={yScale(t)}
|
||||
fill="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
textAnchor="end"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
{t}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Bars */}
|
||||
{chartData.map((d, i) => {
|
||||
const groupX = i * groupW
|
||||
const isActive = active === i
|
||||
const newH = chartH - yScale(d.leads)
|
||||
const closedH = chartH - yScale(d.closed)
|
||||
|
||||
return (
|
||||
<g
|
||||
key={d.label}
|
||||
onMouseEnter={() => setHovered(i)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<rect
|
||||
x={groupX}
|
||||
y={0}
|
||||
width={groupW}
|
||||
height={chartH}
|
||||
fill={isActive ? "hsl(var(--muted) / 0.5)" : "transparent"}
|
||||
rx={6}
|
||||
/>
|
||||
|
||||
<rect
|
||||
x={groupX + groupW / 2 - barW - gap / 2}
|
||||
y={yScale(d.leads)}
|
||||
width={barW}
|
||||
height={newH}
|
||||
rx={4}
|
||||
fill="url(#newLeadsGrad)"
|
||||
filter={isActive ? "url(#shadowNew)" : undefined}
|
||||
opacity={hovered !== null && !isActive ? 0.35 : 1}
|
||||
style={{ transition: "opacity 0.15s ease" }}
|
||||
/>
|
||||
|
||||
<rect
|
||||
x={groupX + groupW / 2 + gap / 2}
|
||||
y={yScale(d.closed)}
|
||||
width={barW}
|
||||
height={closedH}
|
||||
rx={4}
|
||||
fill="url(#closedGrad)"
|
||||
filter={isActive ? "url(#shadowClosed)" : undefined}
|
||||
opacity={hovered !== null && !isActive ? 0.35 : 1}
|
||||
style={{ transition: "opacity 0.15s ease" }}
|
||||
/>
|
||||
|
||||
<text
|
||||
x={groupX + groupW / 2}
|
||||
y={chartH + 26}
|
||||
fill={isActive ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))"}
|
||||
fontSize={12.5}
|
||||
fontWeight={isActive ? 600 : 400}
|
||||
textAnchor="middle"
|
||||
>
|
||||
{d.label}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{activeDatum && (
|
||||
<div
|
||||
className="pointer-events-none absolute top-0"
|
||||
style={{
|
||||
left: `${((active! + 0.5) / chartData.length) * 100}%`,
|
||||
transform: active! > chartData.length - 2
|
||||
? "translate(-100%, 0)"
|
||||
: "translate(-12%, 0)",
|
||||
transition: "left 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="min-w-[150px] rounded-xl border p-3 shadow-xl"
|
||||
style={{
|
||||
background: "hsl(var(--popover))",
|
||||
borderColor: "hsl(var(--border))",
|
||||
}}
|
||||
>
|
||||
<div className="mb-1.5 text-xs font-semibold" style={{ color: "hsl(var(--foreground))" }}>
|
||||
{activeDatum.label}
|
||||
</div>
|
||||
<div className="mb-1 flex items-center justify-between gap-4 text-xs" style={{ color: "hsl(var(--muted-foreground))" }}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2 w-2 rounded-sm" style={{ background: NEW_LEADS }} />
|
||||
New Leads
|
||||
</span>
|
||||
<span className="font-semibold" style={{ color: "hsl(var(--foreground))" }}>{activeDatum.leads}</span>
|
||||
</div>
|
||||
<div className="mb-1 flex items-center justify-between gap-4 text-xs" style={{ color: "hsl(var(--muted-foreground))" }}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2 w-2 rounded-sm" style={{ background: CLOSED }} />
|
||||
Closed
|
||||
</span>
|
||||
<span className="font-semibold" style={{ color: "hsl(var(--foreground))" }}>{activeDatum.closed}</span>
|
||||
</div>
|
||||
<div
|
||||
className="mt-1.5 border-t pt-1.5 text-[11px]"
|
||||
style={{ borderColor: "hsl(var(--border))", color: "hsl(var(--muted-foreground))" }}
|
||||
>
|
||||
{activeDatum.leads > 0
|
||||
? `${Math.round((activeDatum.closed / activeDatum.leads) * 100)}% close rate`
|
||||
: "No leads"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { motion } from "framer-motion"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Lead } from "@/types"
|
||||
import { ArrowRight } from "lucide-react"
|
||||
|
||||
const statusStyles: Record<string, string> = {
|
||||
open: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
|
||||
contacted: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
|
||||
pending: "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/20",
|
||||
closed: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
|
||||
ignored: "bg-zinc-500/10 text-zinc-600 dark:text-zinc-400 border-zinc-500/20",
|
||||
}
|
||||
|
||||
interface RecentLeadsTableProps {
|
||||
leads: Lead[]
|
||||
}
|
||||
|
||||
export function RecentLeadsTable({ leads }: RecentLeadsTableProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.4 }}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Recent Leads</CardTitle>
|
||||
<Link
|
||||
href="/leads"
|
||||
className="flex items-center gap-1 text-sm text-primary hover:underline"
|
||||
>
|
||||
View all <ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-3 font-medium">Company</th>
|
||||
<th className="pb-3 font-medium">Contact</th>
|
||||
<th className="hidden pb-3 font-medium md:table-cell">Status</th>
|
||||
<th className="hidden pb-3 font-medium lg:table-cell">Assigned</th>
|
||||
<th className="hidden pb-3 font-medium sm:table-cell">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{leads.map((lead, i) => (
|
||||
<motion.tr
|
||||
key={lead.id}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: i * 0.03 }}
|
||||
className="border-b last:border-0 hover:bg-muted/30"
|
||||
>
|
||||
<td className="py-3">
|
||||
<Link href={`/leads/${lead.id}`} className="font-medium hover:text-primary">
|
||||
{lead.companyName}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-3 text-muted-foreground">{lead.contactName}</td>
|
||||
<td className="hidden py-3 md:table-cell">
|
||||
<Badge variant="outline" className={statusStyles[lead.status]}>
|
||||
{lead.status.charAt(0).toUpperCase() + lead.status.slice(1)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="hidden py-3 lg:table-cell">
|
||||
{lead.assignedUser ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage src={lead.assignedUser.avatar} />
|
||||
<AvatarFallback>{lead.assignedUser.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-muted-foreground">{lead.assignedUser.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground/50">Unassigned</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden py-3 text-muted-foreground sm:table-cell">
|
||||
{new Date(lead.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
|
||||
interface Star {
|
||||
left: number
|
||||
top: number
|
||||
size: number
|
||||
delay: number
|
||||
duration: number
|
||||
}
|
||||
|
||||
export function StarField() {
|
||||
const stars = useMemo<Star[]>(() => {
|
||||
const result: Star[] = []
|
||||
for (let i = 0; i < 160; i++) {
|
||||
result.push({
|
||||
left: Math.random() * 100,
|
||||
top: Math.random() * 100,
|
||||
size: 1.5 + Math.random() * 2,
|
||||
delay: Math.random() * 6,
|
||||
duration: 3 + Math.random() * 4,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 pointer-events-none select-none z-0 overflow-hidden">
|
||||
{stars.map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute rounded-full bg-[#222222] dark:bg-white star-twinkle"
|
||||
style={{
|
||||
left: `${s.left}%`,
|
||||
top: `${s.top}%`,
|
||||
width: `${s.size}px`,
|
||||
height: `${s.size}px`,
|
||||
opacity: 0.35 + Math.random() * 0.45,
|
||||
animationDelay: `${s.delay}s`,
|
||||
animationDuration: `${s.duration}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client"
|
||||
|
||||
export function StatCardSkeleton() {
|
||||
return (
|
||||
<div className="col-span-full flex flex-col items-center justify-center py-20">
|
||||
<p className="text-[#444444] dark:text-[#AAAAAA] text-sm">
|
||||
Loading your data...
|
||||
</p>
|
||||
<div className="w-48 h-1 rounded-full overflow-hidden bg-[#E0E0E0] dark:bg-[#222222] mt-4">
|
||||
<div className="w-full h-full rounded-full bg-sidebar animate-[loading_1.5s_ease-in-out_infinite]" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { LucideIcon } from "lucide-react"
|
||||
|
||||
interface StatCardProps {
|
||||
title: string
|
||||
value: string | number
|
||||
icon: LucideIcon
|
||||
description?: string
|
||||
index?: number
|
||||
trend?: { pct: number; up: boolean }
|
||||
sparklineField?: "total" | "open" | "contacted" | "pending" | "closed" | "ignored"
|
||||
monthlyBreakdown?: { label: string; total: number; open: number; contacted: number; pending: number; closed: number; ignored: number }[]
|
||||
conversionRate?: number
|
||||
}
|
||||
|
||||
const cardColors: Record<string, { bg: string; text: string; glow: string; accent: string }> = {
|
||||
"Total Leads": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" },
|
||||
"Open Leads": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" },
|
||||
"Contacted": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" },
|
||||
"Pending": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" },
|
||||
"Closed": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" },
|
||||
"Conversion Rate": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" },
|
||||
}
|
||||
|
||||
function computeGoal(max: number): number {
|
||||
if (max <= 0) return 100
|
||||
return Math.ceil(max / 100) * 100 || 100
|
||||
}
|
||||
|
||||
function smoothPath(points: { x: number; y: number }[]): string {
|
||||
if (points.length === 0) return ""
|
||||
if (points.length === 1) return `M${points[0].x.toFixed(1)},${points[0].y.toFixed(1)}`
|
||||
let d = `M${points[0].x.toFixed(1)},${points[0].y.toFixed(1)}`
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
const p0 = points[i - 1]
|
||||
const p1 = points[i]
|
||||
const p2 = points[i + 1]
|
||||
const cp1x = p1.x - (p2.x - p0.x) * 0.15
|
||||
const cp1y = p1.y - (p2.y - p0.y) * 0.15
|
||||
const cp2x = p1.x + (p2.x - p0.x) * 0.15
|
||||
const cp2y = p1.y + (p2.y - p0.y) * 0.15
|
||||
d += `C${cp1x.toFixed(1)},${cp1y.toFixed(1)} ${cp2x.toFixed(1)},${cp2y.toFixed(1)} ${p2.x.toFixed(1)},${p2.y.toFixed(1)}`
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
export function StatCard({ title, value, icon: Icon, description, index = 0, trend, sparklineField, monthlyBreakdown, conversionRate }: StatCardProps) {
|
||||
const color = cardColors[title] ?? cardColors["Total Leads"]
|
||||
|
||||
const isNumeric = typeof value === "number"
|
||||
const [display, setDisplay] = useState(0)
|
||||
const target = typeof value === "number" ? value : 0
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNumeric) return
|
||||
const duration = 1000
|
||||
const steps = 40
|
||||
const increment = target / steps
|
||||
let current = 0
|
||||
const timer = setInterval(() => {
|
||||
current += increment
|
||||
if (current >= target) {
|
||||
setDisplay(target)
|
||||
clearInterval(timer)
|
||||
} else {
|
||||
setDisplay(Math.floor(current))
|
||||
}
|
||||
}, duration / steps)
|
||||
return () => clearInterval(timer)
|
||||
}, [target, isNumeric])
|
||||
|
||||
function buildSparklineSvg(): { path: string; area: string; goal: number; gridLines: { y: number }[] } {
|
||||
if (!monthlyBreakdown || !sparklineField) return { path: "", area: "", goal: 100, gridLines: [] }
|
||||
const values = monthlyBreakdown.map((m) => m[sparklineField]).reverse()
|
||||
const max = Math.max(...values, 0)
|
||||
const goal = computeGoal(max)
|
||||
const dataRange = Math.max(max, 1)
|
||||
const range = goal <= 10 ? Math.max(dataRange * 2, 10) : Math.min(goal, Math.max(dataRange * 3, 10))
|
||||
const w = values.length - 1 || 1
|
||||
const pad = 4
|
||||
const graphW = 60 - pad * 2
|
||||
const graphH = 28
|
||||
|
||||
const points = values.map((v, i) => ({
|
||||
x: pad + (i / w) * graphW,
|
||||
y: graphH - (v / range) * (graphH - 3) - 1,
|
||||
}))
|
||||
|
||||
const smooth = smoothPath(points)
|
||||
const area = points.length > 0
|
||||
? `${smooth} L${points[points.length - 1].x.toFixed(1)},${graphH} L${points[0].x.toFixed(1)},${graphH} Z`
|
||||
: ""
|
||||
|
||||
const steps = 4
|
||||
const gridLines = Array.from({ length: steps - 1 }, (_, i) => ({
|
||||
y: graphH - ((i + 1) / steps) * (graphH - 3) - 1,
|
||||
}))
|
||||
|
||||
return { path: smooth, area, goal, gridLines }
|
||||
}
|
||||
|
||||
const { path: sparkPath, area: sparkArea, goal, gridLines } = buildSparklineSvg()
|
||||
const sparkColor = color.accent
|
||||
|
||||
const isRed = index % 2 === 0
|
||||
const stripColor = isRed ? "from-[#CC0000] via-[#FF1111] to-[#CC0000]" : "from-[#0033CC] via-[#1144FF] to-[#0033CC]"
|
||||
const stripGlow = isRed ? "shadow-[#CC0000]/20" : "shadow-[#0033CC]/20"
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||
className="relative"
|
||||
>
|
||||
<Card className="group h-full hover:shadow-xl transition-all duration-200 relative z-10 overflow-hidden">
|
||||
{/* Red/Blue gradient top strip */}
|
||||
<div className={`h-1 w-full bg-gradient-to-r ${stripColor} ${stripGlow} shadow-sm`} />
|
||||
|
||||
<CardContent className="p-6 flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
||||
<p className="text-3xl font-bold tracking-tight text-[#111111] dark:text-white">
|
||||
{isNumeric ? display : value}
|
||||
</p>
|
||||
{description && (
|
||||
<p className="text-xs text-[#888888] dark:text-[#666666]">{description}</p>
|
||||
)}
|
||||
{trend && (
|
||||
<span className={cn(
|
||||
"inline-flex items-center gap-0.5 text-xs font-semibold",
|
||||
trend.up ? "text-emerald-500" : "text-rose-500"
|
||||
)}>
|
||||
{trend.up ? "↑" : "↓"} {trend.pct}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={`flex h-12 w-12 items-center justify-center rounded-xl shrink-0 ${color.bg}`}>
|
||||
<Icon className={`h-6 w-6 ${color.text}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sparkPath && (
|
||||
<div className="mt-auto relative">
|
||||
<svg width="60" height="28" viewBox="0 0 60 28" className="opacity-60 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<defs>
|
||||
<linearGradient id={`spark-fill-${title.replace(/\s/g, "")}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={sparkColor} stopOpacity="0.2" />
|
||||
<stop offset="100%" stopColor={sparkColor} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<filter id={`glow-${title.replace(/\s/g, "")}`}>
|
||||
<feGaussianBlur stdDeviation="2" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
{gridLines.map((gl, i) => (
|
||||
<line key={i} x1="0" y1={gl.y} x2="60" y2={gl.y} stroke="currentColor" strokeOpacity="0.08" strokeWidth="1" strokeDasharray="2,2" />
|
||||
))}
|
||||
<path d={sparkArea} fill={`url(#spark-fill-${title.replace(/\s/g, "")})`} />
|
||||
<path
|
||||
d={sparkPath}
|
||||
fill="none"
|
||||
stroke={sparkColor}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="animate-pulse"
|
||||
filter={`url(#glow-${title.replace(/\s/g, "")})`}
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute -bottom-0.5 right-0 text-[9px] font-medium text-muted-foreground/60">
|
||||
/{goal}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{title === "Conversion Rate" && conversionRate !== undefined && (
|
||||
<div className="mt-3">
|
||||
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full bg-gradient-to-r from-[#CC0000] to-[#0033CC]" style={{ width: `${Math.min(conversionRate, 100)}%` }} />
|
||||
</div>
|
||||
<p className="text-xs text-[#888888] dark:text-[#666666] mt-1">{conversionRate}% conversion rate</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<div className={cn(
|
||||
"absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-transparent to-transparent",
|
||||
color.glow
|
||||
)} />
|
||||
</Card>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { Sidebar } from "./sidebar"
|
||||
import { Topbar } from "./topbar"
|
||||
import { SystemMonitor } from "./system-monitor"
|
||||
|
||||
interface AppShellProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function AppShell({ children }: AppShellProps) {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const pathname = usePathname()
|
||||
|
||||
// Close mobile sidebar on route change
|
||||
useEffect(() => {
|
||||
setMobileOpen(false)
|
||||
}, [pathname])
|
||||
|
||||
// Persist sidebar state
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("sidebar-collapsed")
|
||||
if (saved) setSidebarCollapsed(saved === "true")
|
||||
}, [])
|
||||
|
||||
const toggleSidebar = () => {
|
||||
const next = !sidebarCollapsed
|
||||
setSidebarCollapsed(next)
|
||||
localStorage.setItem("sidebar-collapsed", String(next))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background dark:bg-[#0A0A0A] relative overflow-hidden">
|
||||
|
||||
<SystemMonitor />
|
||||
<Sidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggle={toggleSidebar}
|
||||
mobileOpen={mobileOpen}
|
||||
onMobileClose={() => setMobileOpen(false)}
|
||||
/>
|
||||
|
||||
<div className={cn("transition-all duration-300", sidebarCollapsed ? "lg:ml-16" : "lg:ml-64")}>
|
||||
<Topbar onMenuClick={() => setMobileOpen(true)} />
|
||||
|
||||
<main className="flex-1 p-4 lg:p-6">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={pathname}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function cn(...classes: (string | boolean | undefined | null)[]) {
|
||||
return classes.filter(Boolean).join(" ")
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client"
|
||||
|
||||
export function CrmIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 64 64"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g className="fill-[#333333] dark:fill-white/[0.15] dark:stroke-[#e5e7eb] dark:[stroke-width:1.5px]">
|
||||
<path className="dark:[stroke-linejoin:round]" d="m10.35 35.62s-.75.14-1-1.48-.58-8.61-.3-8.87a10 10 0 0 1 2.07-.63s0-7.54 2.67-11.86 5.4-6.09 10.65-7.56a29.38 29.38 0 0 1 15.18.3c4.41 1.18 8.7 4.62 9.89 8.8a49.27 49.27 0 0 1 1.49 9.55 9.53 9.53 0 0 1 2.52.9c.17.33.43 9.12 0 9.38a1.6 1.6 0 0 1 -.87.23s7.86 18 7.59 19-12.93 6.36-30.1 6.23-26.05-5.41-26.36-6.11 6.57-17.88 6.57-17.88z" />
|
||||
</g>
|
||||
<g className="fill-[#777777] dark:fill-white/[0.1] dark:stroke-[#d4d4d4] dark:[stroke-width:1px]">
|
||||
<path d="m13.24 27.92s-.6-7.65 1.3-11.91a19.6 19.6 0 0 1 7.75-8.23 20.47 20.47 0 0 1 6.51-1.45c.05.16.67 10.23.66 11.61a27 27 0 0 1 -.57 4s-4-2.44-8.16-1.38a10.47 10.47 0 0 0 -7.49 7.36z" />
|
||||
<path d="m30.11 22.18s.53-3.69.54-4.89-.8-10.82-.6-10.95a4.11 4.11 0 0 1 1.88 0c.07.16 1.07 11.66 1.28 13.22a9.33 9.33 0 0 1 .27 2.44 2.85 2.85 0 0 1 -3.37.18z" />
|
||||
<path d="m34.8 21.44a76.48 76.48 0 0 1 -1.4-9.35c-.17-3.62-.51-5.65-.06-5.83s6.66.17 10.42 3.45 4.06 5.61 3.81 5.66-2.52-1-2.64-.77-.15.8.06.92 2.69 1.1 2.82 1.26a4.18 4.18 0 0 1 .28.95 16.94 16.94 0 0 0 -3-1.13c-.12.17-.23.92 0 1s3 1.3 3 1.3l.11.79s-2.83-1.18-3-1.14-.36.8-.15.92 3.16 1.26 3.25 1.43.41 1.36.41 1.36l-1.71-.54s.31.87.48 1 1.3.42 1.31.76a31.35 31.35 0 0 1 .2 3.33c-.12 0-2.2-6.45-5.21-7.09s-8.98 1.72-8.98 1.72z" />
|
||||
<path d="m22.44 22.28c2.21 0 7.19 2.45 9.76 1.84s6.89-2.79 9.51-2.81 4.07 2.48 6.36 7.84 7.14 17.34 7 17.34-8.1-17.89-9.66-20.23-2.66-3-5.86-2.77-6.81 1-6.85 1.41 0 7.17.49 7.75 3.06.71 6.18.51 5-.33 5.77-.64a10.14 10.14 0 0 0 1.69-1l7.79 16.48a30.15 30.15 0 0 0 -2.94-2.56c-.21.05-.31.8-.19.88s3.67 3.33 4 3.7a12.43 12.43 0 0 1 1.61 2.63c-.12.17-.45.55-.66.47s-5-5.71-5.39-5.83-.45.6-.45.6 5.18 5.5 5.06 5.63a8.62 8.62 0 0 1 -1.15.57s-4.34-5-4.5-4.94-.69.6-.4.81a45.71 45.71 0 0 1 4.07 4.36 7 7 0 0 1 -1.2.4 46.49 46.49 0 0 0 -3.58-3.72c-.25 0-.61.39-.49.55s3.34 3.29 3.22 3.46a4 4 0 0 1 -1.16.37s-2.47-2.59-2.65-2.59-.74.31-.53.6 2.39 2.19 2.27 2.28a4.86 4.86 0 0 1 -1 .19c-.13 0-2-2-2.22-2s-.78.19-.65.48 1.83 1.79 1.63 1.92-5.17 1.66-15.76 1.51-14.84-1.85-14.84-1.85a46 46 0 0 0 -4.22-3.69c-.37 0-.78.6-.61.72a29.31 29.31 0 0 1 2.86 2.6c-.16 0-3.27-.92-4.32-1.1a7.6 7.6 0 0 1 -2.26-.61c0-.13 6-14.45 6.27-14.41s4.29 10.07 4.29 10.07a2.77 2.77 0 0 0 1.2 2.89 3.9 3.9 0 0 0 3.49-.25l15.51.08c.12 0 2.65 1.27 3.69-.68s.44-2.67.44-2.67 4-11.27 3.76-11.52a5.63 5.63 0 0 0 -1.76-.35s-2.75 10.32-3 10.33-1.26-.3-1.68-.34a3.18 3.18 0 0 0 -.7 0l-5.48-7.62s1.1-6-3.28-6-3.14 6-3.14 6-1-.06-2 1.51-3.39 6.29-3.39 6.29a3.77 3.77 0 0 0 -1.45.2c-.66.27-1.07.49-1.07.49s-3.54-8.29-3.46-8.46.82-2.15 1.2-2.16 2.93.39 3.93.41 1.16-.11 1.2-.28.27-.8.1-.8-5.11-.57-5.48-.57-1.07 2.14-1.36 2.23-1.32.25-1.2 0 2.16-5.13 2.29-5.1 7.53 1.07 9.73.64 2.76-1.11 3.07-2.16a31.45 31.45 0 0 0 .25-6.59 19.1 19.1 0 0 0 -6.2-1.31c-2.79-.09-4.67-.13-5.87 1.82s-10.79 27.71-10.96 27.76a3.64 3.64 0 0 1 -.92-.29s4.91-14 6.78-19.26 3.47-11.56 9.47-11.46z" />
|
||||
<path d="m28.76 40.3a14.69 14.69 0 0 0 2.25 0l2.29-.09s-.39 9.67 0 9.75.67.27.66-.06-.19-7.75.06-7.79.75 1.52.75 1.52-.14 6.42 0 6.46.88.27.84.06 0-4.75 0-4.75l1.43 2.26-.79.06s-.08 2 .17 2a4.75 4.75 0 0 0 .71 0c.12 0 .11 1.16.11 1.16s-7.26-.19-9.8-.13-2.74.11-2.74.11a6.75 6.75 0 0 0 -.55-2l-.62-1.28s4.86-7.15 5.23-7.28z" />
|
||||
</g>
|
||||
<g className="fill-[#333333] dark:fill-white/[0.15] dark:stroke-[#d4d4d4] dark:[stroke-width:0.7px]">
|
||||
<path d="m30.57 41.13c.28-.1.79-.06.84.1s.41 8.41.17 8.63-.88.14-.88 0-.25-8.69-.13-8.73z" />
|
||||
<path d="m28.74 41.26c.21-.09.7-.19.79 0s.55 8.61.38 8.66-.87.18-.87 0-.3-8.66-.3-8.66z" />
|
||||
<path d="m27 44.8c.06-.14.87-.4.87-.23a52.12 52.12 0 0 1 .21 5.25 3 3 0 0 1 -.79.06s-.38-4.88-.29-5.08z" />
|
||||
<path d="m25.25 46.8c0-.17.57-.64.65-.51a14 14 0 0 1 .26 3.45 4 4 0 0 1 -.79.14s-.16-2.88-.12-3.08z" />
|
||||
<path d="m41.23 35.62c.19-.1 2.79-.16 4.58-.37a11 11 0 0 1 1.78-.25c.21 0 .48.87.36 1a17.41 17.41 0 0 1 -4.32.69c-1.46 0-2.29 0-2.29 0s-.48-.9-.11-1.07z" />
|
||||
<path d="m42 38.39c.34-.09 1.41 1.51 1.41 1.51s-.19.79-.4.63a9.86 9.86 0 0 1 -1.5-1.63c-.08-.21.29-.46.49-.51z" />
|
||||
<path d="m40.41 39.68c.29-.1 2.3 1.78 2.22 2s-.15.63-.28.59a19.84 19.84 0 0 1 -2.43-2.11c-.04-.22.37-.44.49-.48z" />
|
||||
<path d="m39.41 41.16s2.55 2 2.47 2.32a2.36 2.36 0 0 1 -.36.67s-2.93-2.38-2.81-2.6.53-.26.7-.39z" />
|
||||
<path d="m14.45 48.4c.2.07 2.81 2.31 2.82 2.72s-.11.63-.24.63a27 27 0 0 1 -2.93-2.38c-.05-.17.14-1.05.35-.97z" />
|
||||
<path d="m13.67 50.5s3.83 3.16 3.83 3.37-.11.67-.23.67a35.1 35.1 0 0 1 -4.08-3.07c-.04-.25.48-.97.48-.97z" />
|
||||
<path d="m16.27 17c-.17-.24.44-2.3 1.57-3.91s2.19-2.52 2.65-2.4.77.61.6.82a7.05 7.05 0 0 0 -2.72 2.86c-.79 1.85-.55 3-.8 3.06a1.86 1.86 0 0 1 -1.3-.43z" />
|
||||
<path d="m44.85 13.23a2.79 2.79 0 0 1 1.6.54c.09.25.27.83-.11.76a10.8 10.8 0 0 1 -1.43-.53c-.17-.06-.31-.72-.06-.77z" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client"
|
||||
|
||||
export function SidebarNameLogo() {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 192.756 192.756"
|
||||
className="fill-[#333333] dark:fill-[#e5e7eb] w-full h-auto"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g fill-rule="evenodd" clip-rule="evenodd">
|
||||
<path d="M5.669 81.215v12.65h37.003c4.301 0 9.796-3.977 9.796-10.556 0-2.646 1.012-4.372-2.098-7.872l-4.733-5.608c-2.712-2.53.324-2.53 2.602-2.53h15.623v26.566h12.39V67.299h16.699V55.922H38.877c-6.579 0-9.796 6.317-9.615 9.606.182 3.289.787 7.427 6.254 12.398 4.987 4.533-2.469 3.289-3.218 3.289H5.669zM120.348 55.922H100.36L89.155 93.866h12.47l2.023-5.313h13.156l1.953 5.313h12.215l-10.624-37.944zm-13.916 23.522l4.301-13.916 4.049 13.916h-8.35zM170.443 81.215c-4.807 0-4.807-1.771-4.807-1.771 4.119 0 7.771-6.001 7.771-12.145s-6-11.377-10.809-11.377h-26.891v37.944h13.664v-12.65s5.818 6.831 8.854 9.614c3.037 2.783 3.289 3.036 7.41 3.036h21.449v-12.65c.002-.001-11.834-.001-16.641-.001zm-12.398-8.855h-8.672v-6.832h8.672c3.976 0 4.664 6.832 0 6.832zM5.669 98.672h13.979l3.542 12.652 3.289-12.652h14.675l3.795 12.652 3.796-12.652h12.144l-11.133 37.953H38.624l-4.878-17.965-5.496 17.965H16.865L5.669 98.672zM89.578 98.891H69.59l-11.204 37.943h12.469l2.024-5.312h13.157l1.953 5.312h12.216L89.578 98.891zm-13.915 23.521l4.301-13.916 4.048 13.916h-8.349zM170.695 110.059c-2.275 0-4.756.266-2.043 2.795l4.734 5.609c3.109 3.5 3.059 4.959 3.059 7.607 0 6.578-6.508 10.555-10.809 10.555l-29.896.201c-4.119 0-4.371-.252-7.408-3.035-3.035-2.783-8.855-9.615-8.855-9.615v12.65h-13.662V98.883h26.891c4.807 0 10.809 5.234 10.809 11.377 0 6.145-3.652 12.145-7.773 12.145 0 0 1.812 1.822 4.848 1.822 3.037 0 14.727.012 14.727.012.748 0 8.203 1.244 3.217-3.289-5.467-4.971-6.072-9.107-6.254-12.396s2.662-9.881 9.238-9.881h25.57v11.387h-16.393v-.001zm-42.545 5.261h-8.674v-6.832h8.674c3.977 0 4.664 6.832 0 6.832z" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
Settings,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Building2,
|
||||
PanelLeftClose,
|
||||
MessageSquare,
|
||||
Bot,
|
||||
Facebook,
|
||||
} from "lucide-react"
|
||||
import { COMPANY_NAME } from "@/lib/constants"
|
||||
import { useUser } from "@/providers/user-provider"
|
||||
import { useNotifications } from "@/providers/notification-provider"
|
||||
import { FacebookAccountsDialog } from "@/components/settings/facebook-accounts-dialog"
|
||||
|
||||
const navItems = [
|
||||
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ href: "/leads", label: "Leads", icon: Users },
|
||||
{ href: "/chats", label: "Chats", icon: MessageSquare },
|
||||
{ href: "/ai-assistant", label: "AI Assistant", icon: Bot, roles: ["sales", "admin", "super_admin"] },
|
||||
{ href: "/users", label: "Users", icon: Building2 },
|
||||
{ href: "/settings", label: "Settings", icon: Settings },
|
||||
]
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
mobileOpen: boolean
|
||||
onMobileClose: () => void
|
||||
}
|
||||
|
||||
export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: SidebarProps) {
|
||||
const pathname = usePathname()
|
||||
const { user } = useUser()
|
||||
const { unreadChatCount } = useNotifications()
|
||||
const [fbDialogOpen, setFbDialogOpen] = useState(false)
|
||||
if (!user) return null
|
||||
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||
const initials = user.name.split(" ").map((n) => n[0]).join("")
|
||||
|
||||
const sidebarContent = (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full flex-col bg-sidebar text-sidebar-foreground transition-all duration-300",
|
||||
collapsed ? "w-16" : "w-64"
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className={cn("flex h-16 items-center border-b border-sidebar-border px-4", collapsed ? "justify-center" : "justify-between")}>
|
||||
<Link href="/" className="flex items-center gap-3 overflow-hidden">
|
||||
<img
|
||||
src="/logo/CompanyLogo.png"
|
||||
alt={COMPANY_NAME}
|
||||
className="h-8 w-8 shrink-0 rounded-lg object-contain"
|
||||
/>
|
||||
<AnimatePresence mode="wait">
|
||||
{!collapsed && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -10 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="text-sm font-semibold whitespace-nowrap"
|
||||
>
|
||||
{COMPANY_NAME}
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Link>
|
||||
{!collapsed && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggle}
|
||||
className="h-6 w-6 text-sidebar-foreground/60 hover:text-sidebar-foreground"
|
||||
>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-1 p-3">
|
||||
{navItems.filter((item) => !item.roles || item.roles.includes(user.role)).map((item) => {
|
||||
const isActive = pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href))
|
||||
return collapsed ? (
|
||||
<TooltipProvider key={item.href} delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 items-center justify-center rounded-lg transition-colors",
|
||||
isActive
|
||||
? "bg-sidebar-primary text-sidebar-primary-foreground"
|
||||
: "text-sidebar-foreground/60 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
{item.label === "Chats" && unreadChatCount > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-3 w-3 rounded-full bg-red-500 animate-pulse shadow-[0_0_10px_#ef4444]" />
|
||||
)}
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="ml-2">
|
||||
{item.label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-sidebar-primary text-sidebar-primary-foreground"
|
||||
: "text-sidebar-foreground/60 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="relative shrink-0">
|
||||
<item.icon className="h-5 w-5" />
|
||||
{item.label === "Chats" && unreadChatCount > 0 && (
|
||||
<span className="absolute -right-1 -top-1 flex h-2.5 w-2.5 rounded-full bg-red-500 animate-pulse shadow-[0_0_8px_#ef4444]" />
|
||||
)}
|
||||
</div>
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="truncate"
|
||||
>
|
||||
{item.label}
|
||||
</motion.span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
{isAdmin && collapsed && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setFbDialogOpen(true)}
|
||||
className="relative flex h-10 w-10 items-center justify-center rounded-lg transition-colors text-sidebar-foreground/60 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Facebook className="h-5 w-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="ml-2">
|
||||
Facebook Accounts
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{isAdmin && !collapsed && (
|
||||
<button
|
||||
onClick={() => setFbDialogOpen(true)}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors text-sidebar-foreground/60 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Facebook className="h-5 w-5 shrink-0" />
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="truncate"
|
||||
>
|
||||
Facebook Accounts
|
||||
</motion.span>
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Collapse toggle at bottom (only in collapsed mode) */}
|
||||
{collapsed && (
|
||||
<div className="border-t border-sidebar-border p-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggle}
|
||||
className="h-10 w-10 text-sidebar-foreground/60 hover:text-sidebar-foreground"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User info */}
|
||||
<div className={cn("border-t border-sidebar-border p-3", collapsed && "flex justify-center")}>
|
||||
{collapsed ? (
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={user.avatar} />
|
||||
<AvatarFallback className="text-sm font-medium">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage src={user.avatar} />
|
||||
<AvatarFallback className="text-sm font-medium">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<p className="text-sm font-medium truncate">{user.name}</p>
|
||||
<p className="text-xs text-sidebar-foreground/60 truncate capitalize">{user.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="hidden lg:fixed lg:inset-y-0 lg:z-30 lg:flex">
|
||||
{sidebarContent}
|
||||
</aside>
|
||||
|
||||
<FacebookAccountsDialog open={fbDialogOpen} onOpenChange={setFbDialogOpen} />
|
||||
|
||||
{/* Mobile sidebar overlay */}
|
||||
<AnimatePresence>
|
||||
{mobileOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onMobileClose}
|
||||
className="fixed inset-0 z-40 bg-black/60 lg:hidden"
|
||||
/>
|
||||
<motion.aside
|
||||
initial={{ x: -300 }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: -300 }}
|
||||
transition={{ type: "spring", damping: 30, stiffness: 300 }}
|
||||
className="fixed inset-y-0 left-0 z-50 lg:hidden"
|
||||
>
|
||||
{sidebarContent}
|
||||
</motion.aside>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
|
||||
const RAM_LIMIT_MB = 8192
|
||||
const CPU_LIMIT_PCT = 400 // 4 cores * 100%
|
||||
|
||||
export function SystemMonitor() {
|
||||
const [rssMB, setRssMB] = useState(0)
|
||||
const [cpuPct, setCpuPct] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/system/monitor")
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
setRssMB(data.rssMB)
|
||||
setCpuPct(data.cpuPct)
|
||||
} catch {
|
||||
console.warn("Failed to fetch system stats")
|
||||
}
|
||||
}
|
||||
|
||||
fetchStats()
|
||||
const interval = setInterval(fetchStats, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const ramOver = rssMB > RAM_LIMIT_MB
|
||||
const cpuOver = cpuPct > CPU_LIMIT_PCT
|
||||
|
||||
return (
|
||||
<div className="fixed top-0 left-0 z-[9999] flex items-center gap-3 px-3 py-1 text-[11px] font-mono bg-black/80 rounded-br-lg select-none">
|
||||
<span className={ramOver ? "text-red-400" : "text-green-400"}>
|
||||
RAM: {rssMB}MB / 8192MB
|
||||
</span>
|
||||
<span className={cpuOver ? "text-red-400" : "text-green-400"}>
|
||||
CPU: {cpuPct}%
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { useUser } from "@/providers/user-provider";
|
||||
import { useNotifications } from "@/providers/notification-provider";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Search,
|
||||
Bell,
|
||||
Sun,
|
||||
Moon,
|
||||
Menu,
|
||||
LogOut,
|
||||
User,
|
||||
Settings,
|
||||
CheckCheck,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { CrmIcon } from "./crm-icon";
|
||||
|
||||
interface TopbarProps {
|
||||
onMenuClick: () => void;
|
||||
}
|
||||
|
||||
export function Topbar({ onMenuClick }: TopbarProps) {
|
||||
const router = useRouter();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { user, logout } = useUser();
|
||||
const { notifications, unreadCount, markAsRead, markAllAsRead, dismiss } =
|
||||
useNotifications();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
if (!user) return null;
|
||||
|
||||
function formatTimeAgo(ts: string): string {
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return "Just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return new Date(ts).toLocaleDateString();
|
||||
}
|
||||
const initials = user.name
|
||||
.split(" ")
|
||||
.map((n: string) => n[0])
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-card dark:bg-[#141414] px-4 lg:px-6 relative overflow-hidden">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2 mr-2 shrink-0">
|
||||
<CrmIcon />
|
||||
<span className="text-[#CC0000] dark:text-[#FF1111] font-bold text-sm tracking-wide">
|
||||
CRM
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="lg:hidden"
|
||||
onClick={onMenuClick}
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative hidden flex-1 sm:block md:w-80">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#888888] dark:text-[#666666]" />
|
||||
<Input
|
||||
placeholder="Search leads, companies..."
|
||||
className="h-9 w-full max-w-sm pl-9 bg-[#F0F0F0] dark:bg-[#1E1E1E] border-[#E0E0E0] dark:border-[#222222] text-[#111111] dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center justify-end gap-3">
|
||||
{/* Mobile search toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="sm:hidden"
|
||||
onClick={() => setSearchOpen(!searchOpen)}
|
||||
>
|
||||
<Search className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
{/* Theme toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
{mounted ? (
|
||||
theme === "dark" ? (
|
||||
<Sun className="h-5 w-5" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5" />
|
||||
)
|
||||
) : (
|
||||
<div className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Notifications */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative text-muted-foreground"
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-medium text-primary-foreground">
|
||||
{unreadCount}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80">
|
||||
<DropdownMenuLabel className="flex items-center justify-between">
|
||||
<span>Notifications</span>
|
||||
{notifications.length > 0 && (
|
||||
<button
|
||||
onClick={markAllAsRead}
|
||||
className="flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
<CheckCheck className="h-3 w-3" />
|
||||
Mark all read
|
||||
</button>
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{notifications.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
No notifications
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[360px] space-y-1 overflow-y-auto p-2">
|
||||
{notifications.slice(0, 20).map((n) => (
|
||||
<div
|
||||
key={n.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
if (!n.read) markAsRead(n.id);
|
||||
if (n.link) router.push(n.link);
|
||||
}}
|
||||
className={`flex cursor-pointer items-start gap-3 rounded-lg p-2 transition-colors hover:bg-muted/50 ${!n.read ? "bg-muted/30" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`mt-2 h-2 w-2 shrink-0 rounded-full ${n.read ? "bg-transparent" : "bg-primary"}`}
|
||||
/>
|
||||
<div className="flex-1 space-y-0.5">
|
||||
<p className="text-sm font-medium">{n.title}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{n.description}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground/60">
|
||||
{formatTimeAgo(n.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
dismiss(n.id);
|
||||
}}
|
||||
className="mt-1 shrink-0 text-muted-foreground/40 hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* User profile */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-9 gap-2 pl-2 pr-3">
|
||||
<Avatar className="h-7 w-7">
|
||||
<AvatarImage src={user.avatar} />
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden text-sm font-medium md:inline-block">
|
||||
{user.name}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium">{user.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{user.email}</p>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="mt-1 w-fit text-xs capitalize"
|
||||
>
|
||||
{user.role}
|
||||
</Badge>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => router.push("/profile")}>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push("/settings")}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-destructive" onClick={logout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Lead } from "@/types"
|
||||
|
||||
interface DeleteLeadDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
lead: Lead
|
||||
}
|
||||
|
||||
export function DeleteLeadDialog({ open, onOpenChange, lead }: DeleteLeadDialogProps) {
|
||||
const handleDelete = () => {
|
||||
console.log("Delete lead:", lead.id)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Lead</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete <strong>{lead.companyName}</strong>? This action
|
||||
cannot be undone. All associated notes and data will be permanently removed.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Lead } from "@/types"
|
||||
import { LeadFormDialog } from "./lead-form-dialog"
|
||||
import { DeleteLeadDialog } from "./delete-lead-dialog"
|
||||
import { MoreHorizontal, Eye, Edit, Trash2, UserCheck } from "lucide-react"
|
||||
|
||||
interface LeadActionsDropdownProps {
|
||||
lead: Lead
|
||||
}
|
||||
|
||||
export function LeadActionsDropdown({ lead }: LeadActionsDropdownProps) {
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[160px]">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/leads/${lead.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setEditOpen(true)}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<UserCheck className="mr-2 h-4 w-4" />
|
||||
Assign
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setDeleteOpen(true)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<LeadFormDialog open={editOpen} onOpenChange={setEditOpen} lead={lead} />
|
||||
<DeleteLeadDialog open={deleteOpen} onOpenChange={setDeleteOpen} lead={lead} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "framer-motion"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { LeadStatusBadge } from "./lead-status-badge"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Lead } from "@/types"
|
||||
import {
|
||||
Building2,
|
||||
Mail,
|
||||
Phone,
|
||||
Globe,
|
||||
User,
|
||||
CalendarDays,
|
||||
Clock,
|
||||
Tag,
|
||||
} from "lucide-react"
|
||||
|
||||
interface LeadDetailsCardProps {
|
||||
lead: Lead
|
||||
}
|
||||
|
||||
export function LeadDetailsCard({ lead }: LeadDetailsCardProps) {
|
||||
const fields = [
|
||||
{ icon: Building2, label: "Company", value: lead.companyName },
|
||||
{ icon: User, label: "Contact", value: lead.contactName },
|
||||
{ icon: Mail, label: "Email", value: lead.email },
|
||||
{ icon: Phone, label: "Phone", value: lead.phone || "—" },
|
||||
{ icon: Globe, label: "Source", value: lead.source || "—" },
|
||||
{ icon: Tag, label: "Status", value: <LeadStatusBadge status={lead.status} /> },
|
||||
{ icon: CalendarDays, label: "Created", value: new Date(lead.createdAt).toLocaleDateString() },
|
||||
{ icon: Clock, label: "Updated", value: new Date(lead.updatedAt).toLocaleDateString() },
|
||||
]
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Lead Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
{fields.map((field, i) => (
|
||||
<div key={i} className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<field.icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs text-muted-foreground">{field.label}</p>
|
||||
<div className="text-sm font-medium">{field.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{lead.description && (
|
||||
<div className="mt-6 border-t pt-6">
|
||||
<h4 className="mb-2 text-sm font-medium text-muted-foreground">Description</h4>
|
||||
<p className="text-sm leading-relaxed">{lead.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 border-t pt-6">
|
||||
<h4 className="mb-3 text-sm font-medium text-muted-foreground">Assigned User</h4>
|
||||
{lead.assignedUser ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={lead.assignedUser.avatar} />
|
||||
<AvatarFallback>{lead.assignedUser.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{lead.assignedUser.name}</p>
|
||||
<Badge variant="secondary" className="mt-0.5 text-xs">
|
||||
{lead.assignedUser.role}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No user assigned</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import * as z from "zod"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Lead, LeadStatus, User } from "@/types"
|
||||
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
|
||||
import { useNotifications } from "@/providers/notification-provider"
|
||||
|
||||
const leadFormSchema = z.object({
|
||||
companyName: z.string().min(1, "Company name is required"),
|
||||
contactName: z.string().min(1, "Contact name is required"),
|
||||
email: z.string().email("Invalid email address"),
|
||||
phone: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
status: z.string(),
|
||||
assignedUserId: z.string().optional(),
|
||||
})
|
||||
|
||||
type LeadFormValues = z.infer<typeof leadFormSchema>
|
||||
|
||||
interface LeadFormDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
lead?: Lead | null
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
export function LeadFormDialog({ open, onOpenChange, lead, onSuccess }: LeadFormDialogProps) {
|
||||
const isEditing = !!lead
|
||||
const { addNotification } = useNotifications()
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/users")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setUsers(data.users || []))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const form = useForm<LeadFormValues>({
|
||||
resolver: zodResolver(leadFormSchema),
|
||||
defaultValues: {
|
||||
companyName: "",
|
||||
contactName: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
source: "",
|
||||
description: "",
|
||||
status: "open",
|
||||
assignedUserId: "none",
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (lead) {
|
||||
form.reset({
|
||||
companyName: lead.companyName,
|
||||
contactName: lead.contactName,
|
||||
email: lead.email,
|
||||
phone: lead.phone || "",
|
||||
source: lead.source || "",
|
||||
description: lead.description || "",
|
||||
status: lead.status,
|
||||
assignedUserId: lead.assignedUserId || "none",
|
||||
})
|
||||
} else {
|
||||
form.reset({
|
||||
companyName: "",
|
||||
contactName: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
source: "",
|
||||
description: "",
|
||||
status: "open",
|
||||
assignedUserId: "none",
|
||||
})
|
||||
}
|
||||
}, [lead, form])
|
||||
|
||||
async function onSubmit(values: LeadFormValues) {
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = {
|
||||
...values,
|
||||
assignedUserId: values.assignedUserId === "none" ? null : values.assignedUserId,
|
||||
}
|
||||
if (isEditing && lead) {
|
||||
const res = await fetch(`/api/leads/${lead.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to update")
|
||||
addNotification("lead_status_changed", "Lead Updated", `${values.companyName}`, "#")
|
||||
} else {
|
||||
const res = await fetch("/api/leads", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to create")
|
||||
addNotification("lead_created", "New Lead Created", `${values.companyName} — ${values.contactName}`, "#")
|
||||
}
|
||||
onOpenChange(false)
|
||||
onSuccess?.()
|
||||
} catch (e) {
|
||||
console.error("Lead save error:", e)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? "Edit Lead" : "Create New Lead"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEditing
|
||||
? "Update the lead information below."
|
||||
: "Fill in the details to add a new lead."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="companyName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Company Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Acme Corp" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="contactName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Contact Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="John Doe" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="john@acme.com" type="email" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Phone</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="(555) 123-4567" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="source"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Source</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select source" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{LEAD_SOURCES.map((source) => (
|
||||
<SelectItem key={source} value={source}>
|
||||
{source}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Status</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{Object.entries(LEAD_STATUSES).map(([key, { label }]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Brief description of the lead and their requirements..."
|
||||
className="min-h-[100px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="assignedUserId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Assign To</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select user" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Unassigned</SelectItem>
|
||||
{users.filter(u => u.active).map((user) => (
|
||||
<SelectItem key={user.id} value={user.id}>
|
||||
{user.name} ({user.role})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving}>{saving ? "Saving..." : isEditing ? "Save Changes" : "Create Lead"}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { LeadStatus } from "@/types"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const statusConfig: Record<LeadStatus, { label: string; class: string }> = {
|
||||
open: {
|
||||
label: "Open",
|
||||
class: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
|
||||
},
|
||||
contacted: {
|
||||
label: "Contacted",
|
||||
class: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
|
||||
},
|
||||
pending: {
|
||||
label: "Pending",
|
||||
class: "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/20",
|
||||
},
|
||||
closed: {
|
||||
label: "Closed",
|
||||
class: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
|
||||
},
|
||||
ignored: {
|
||||
label: "Ignored",
|
||||
class: "bg-zinc-500/10 text-zinc-600 dark:text-zinc-400 border-zinc-500/20",
|
||||
},
|
||||
}
|
||||
|
||||
interface LeadStatusBadgeProps {
|
||||
status: LeadStatus
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function LeadStatusBadge({ status, className }: LeadStatusBadgeProps) {
|
||||
const config = statusConfig[status]
|
||||
return (
|
||||
<Badge variant="outline" className={cn(config.class, className)}>
|
||||
<span className={cn("mr-1.5 h-1.5 w-1.5 rounded-full", {
|
||||
"bg-blue-500": status === "open",
|
||||
"bg-amber-500": status === "contacted",
|
||||
"bg-purple-500": status === "pending",
|
||||
"bg-emerald-500": status === "closed",
|
||||
"bg-zinc-500": status === "ignored",
|
||||
})} />
|
||||
{config.label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client"
|
||||
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Search, Plus } from "lucide-react"
|
||||
|
||||
interface LeadsTableToolbarProps {
|
||||
search: string
|
||||
onSearchChange: (value: string) => void
|
||||
statusFilter: string
|
||||
onStatusFilterChange: (value: string) => void
|
||||
periodFilter: string
|
||||
onPeriodFilterChange: (value: string) => void
|
||||
onCreateClick: () => void
|
||||
}
|
||||
|
||||
export function LeadsTableToolbar({
|
||||
search,
|
||||
onSearchChange,
|
||||
statusFilter,
|
||||
onStatusFilterChange,
|
||||
periodFilter,
|
||||
onPeriodFilterChange,
|
||||
onCreateClick,
|
||||
}: LeadsTableToolbarProps) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by name, company, email..."
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="h-9 pl-9 w-full sm:max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Select value={periodFilter} onValueChange={onPeriodFilterChange}>
|
||||
<SelectTrigger className="h-9 w-[150px]">
|
||||
<SelectValue placeholder="All Time" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Time</SelectItem>
|
||||
<SelectItem value="7days">Last 7 days</SelectItem>
|
||||
<SelectItem value="30days">Last 30 days</SelectItem>
|
||||
<SelectItem value="6months">Last 6 months</SelectItem>
|
||||
<SelectItem value="12months">Last 12 months</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
|
||||
<SelectTrigger className="h-9 w-[140px]">
|
||||
<SelectValue placeholder="All Statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
<SelectItem value="open">Open</SelectItem>
|
||||
<SelectItem value="contacted">Contacted</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
<SelectItem value="ignored">Ignored</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" className="h-9 gap-2" onClick={onCreateClick}>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Create Lead</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import Link from "next/link"
|
||||
import { ColumnDef } from "@tanstack/react-table"
|
||||
import { DataTable } from "@/components/shared/data-table"
|
||||
import { DataTableColumnHeader } from "@/components/shared/data-table-column-header"
|
||||
import { LeadStatusBadge } from "./lead-status-badge"
|
||||
import { LeadActionsDropdown } from "./lead-actions-dropdown"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Lead } from "@/types"
|
||||
|
||||
interface LeadsTableProps {
|
||||
data: Lead[]
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function LeadsTable({ data, loading }: LeadsTableProps) {
|
||||
const columns: ColumnDef<Lead>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "companyName",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Company" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
href={`/leads/${row.original.id}`}
|
||||
className="font-medium hover:text-primary transition-colors"
|
||||
>
|
||||
{row.getValue("companyName")}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "contactName",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Contact" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground">{row.getValue("contactName")}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Email" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground">{row.getValue("email")}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "phone",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Phone" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground">{row.getValue("phone")}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Status" />
|
||||
),
|
||||
cell: ({ row }) => <LeadStatusBadge status={row.getValue("status")} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "assignedUser",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Assigned" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const user = row.original.assignedUser
|
||||
if (!user) return <span className="text-muted-foreground/50">—</span>
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage src={user.avatar} />
|
||||
<AvatarFallback>{user.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-muted-foreground text-sm">{user.name}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Date Added" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground">
|
||||
{new Date(row.getValue("createdAt")).toLocaleDateString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => <LeadActionsDropdown lead={row.original} />,
|
||||
},
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
loading={loading}
|
||||
emptyMessage="No leads found."
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Send } from "lucide-react"
|
||||
import { useUser } from "@/providers/user-provider"
|
||||
|
||||
interface NoteFormProps {
|
||||
leadId: string
|
||||
onNoteAdded?: () => void
|
||||
}
|
||||
|
||||
export function NoteForm({ leadId, onNoteAdded }: NoteFormProps) {
|
||||
const { user } = useUser()
|
||||
const [note, setNote] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!note.trim()) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await fetch(`/api/leads/${leadId}/notes`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: note }),
|
||||
})
|
||||
setNote("")
|
||||
onNoteAdded?.()
|
||||
} catch {
|
||||
console.warn("Failed to add note")
|
||||
}
|
||||
setSubmitting(false)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Add Note</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
|
||||
<div className="flex gap-4">
|
||||
<Avatar className="h-10 w-10 shrink-0">
|
||||
<AvatarImage src={user?.avatar || undefined} />
|
||||
<AvatarFallback>{user?.name?.charAt(0) || "U"}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 space-y-3">
|
||||
<Textarea
|
||||
placeholder="Write a note about this lead..."
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="min-h-[100px] resize-none"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!note.trim() || submitting}
|
||||
className="gap-2"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
{submitting ? "Adding..." : "Add Note"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "framer-motion"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Note } from "@/types"
|
||||
import { EmptyState } from "@/components/shared/empty-state"
|
||||
import { MessageSquare, Edit2, Trash2 } from "lucide-react"
|
||||
|
||||
interface NoteTimelineProps {
|
||||
notes: Note[]
|
||||
}
|
||||
|
||||
export function NoteTimeline({ notes }: NoteTimelineProps) {
|
||||
if (notes.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EmptyState
|
||||
icon={MessageSquare}
|
||||
title="No notes yet"
|
||||
description="Add the first note to this lead."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notes ({notes.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-0">
|
||||
{notes.map((note, i) => (
|
||||
<motion.div
|
||||
key={note.id}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className="relative flex gap-4 pb-8 last:pb-0"
|
||||
>
|
||||
{/* Timeline line */}
|
||||
{i < notes.length - 1 && (
|
||||
<div className="absolute left-5 top-12 bottom-0 w-px bg-border" />
|
||||
)}
|
||||
|
||||
{/* Avatar */}
|
||||
<div className="relative z-10 shrink-0">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={note.authorAvatar} />
|
||||
<AvatarFallback>{note.authorName[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{note.authorName}</span>
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
{note.authorRole}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(note.createdAt).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{note.note}</p>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground/50 hover:text-muted-foreground">
|
||||
<Edit2 className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground/50 hover:text-destructive">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { toast } from "sonner"
|
||||
|
||||
interface CompanyData {
|
||||
companyName: string
|
||||
companyEmail: string
|
||||
companyPhone: string
|
||||
companyWebsite: string
|
||||
companyAddress: string
|
||||
}
|
||||
|
||||
export function CompanySettingsForm() {
|
||||
const [data, setData] = useState<CompanyData>({
|
||||
companyName: "",
|
||||
companyEmail: "",
|
||||
companyPhone: "",
|
||||
companyWebsite: "",
|
||||
companyAddress: "",
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch("/api/settings/company")
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setData(json)
|
||||
}
|
||||
} catch {
|
||||
console.warn("Failed to load company settings")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch("/api/settings/company", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (res.ok) {
|
||||
toast.success("Company settings saved")
|
||||
} else {
|
||||
toast.error("Failed to save company settings")
|
||||
}
|
||||
} catch {
|
||||
console.warn("Failed to save company settings")
|
||||
toast.error("Failed to save company settings")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function update(field: keyof CompanyData, value: string) {
|
||||
setData((prev) => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Company Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your company information and branding.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="company-name">Company Name</Label>
|
||||
<Input id="company-name" disabled={loading} value={data.companyName} onChange={(e) => update("companyName", e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="company-email">Company Email</Label>
|
||||
<Input id="company-email" type="email" disabled={loading} value={data.companyEmail} onChange={(e) => update("companyEmail", e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="company-phone">Phone</Label>
|
||||
<Input id="company-phone" disabled={loading} value={data.companyPhone} onChange={(e) => update("companyPhone", e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="company-website">Website</Label>
|
||||
<Input id="company-website" disabled={loading} value={data.companyWebsite} onChange={(e) => update("companyWebsite", e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="company-address">Address</Label>
|
||||
<Input id="company-address" disabled={loading} value={data.companyAddress} onChange={(e) => update("companyAddress", e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSave(); }}>
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button type="submit" disabled={loading || saving}>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { ShieldAlert, RefreshCw } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface Account {
|
||||
id: string
|
||||
label: string
|
||||
is_active: boolean
|
||||
last_scrape_at: string | null
|
||||
last_success_at: string | null
|
||||
last_error_at: string | null
|
||||
consecutive_failures: number
|
||||
flagged: boolean
|
||||
flagged_reason: string | null
|
||||
last_leads_found: number
|
||||
last_success: boolean | null
|
||||
}
|
||||
|
||||
export function FacebookAccountsDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
|
||||
const [accounts, setAccounts] = useState<Account[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) load()
|
||||
}, [open])
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch("/api/settings/facebook/accounts")
|
||||
if (res.ok) {
|
||||
setAccounts(await res.json())
|
||||
}
|
||||
} catch {}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<DialogTitle>Facebook Accounts</DialogTitle>
|
||||
<DialogDescription>
|
||||
Status of Facebook profiles used for lead scraping
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={load} disabled={loading}>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">Loading...</div>
|
||||
) : accounts.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">No accounts configured</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Label</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Last Scrape</TableHead>
|
||||
<TableHead>Leads</TableHead>
|
||||
<TableHead>Failures</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{accounts.map((a) => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell className="font-medium">{a.label}</TableCell>
|
||||
<TableCell>
|
||||
{a.flagged ? (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<ShieldAlert className="h-3 w-3" />
|
||||
{a.flagged_reason || "Flagged"}
|
||||
</Badge>
|
||||
) : a.is_active ? (
|
||||
<Badge variant="default" className="bg-green-600">Active</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Disabled</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{a.last_scrape_at ? new Date(a.last_scrape_at).toLocaleString() : "Never"}
|
||||
</TableCell>
|
||||
<TableCell>{a.last_leads_found}</TableCell>
|
||||
<TableCell>{a.consecutive_failures}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { toast } from "sonner"
|
||||
|
||||
interface Preferences {
|
||||
leadAssigned: boolean
|
||||
leadStatus: boolean
|
||||
noteAdded: boolean
|
||||
dailyDigest: boolean
|
||||
weeklyReport: boolean
|
||||
}
|
||||
|
||||
const defaultPreferences: Preferences = {
|
||||
leadAssigned: true,
|
||||
leadStatus: true,
|
||||
noteAdded: false,
|
||||
dailyDigest: false,
|
||||
weeklyReport: true,
|
||||
}
|
||||
|
||||
const notifications = [
|
||||
{ id: "leadAssigned", title: "Lead Assigned", description: "When a lead is assigned to you" },
|
||||
{ id: "leadStatus", title: "Status Changes", description: "When a lead's status is updated" },
|
||||
{ id: "noteAdded", title: "Note Added", description: "When a note is added to your lead" },
|
||||
{ id: "dailyDigest", title: "Daily Digest", description: "Receive a daily summary of lead activity" },
|
||||
{ id: "weeklyReport", title: "Weekly Report", description: "Receive a weekly performance report" },
|
||||
]
|
||||
|
||||
export function NotificationSettings() {
|
||||
const [prefs, setPrefs] = useState<Preferences>(defaultPreferences)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch("/api/notifications/preferences")
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setPrefs(data)
|
||||
}
|
||||
} catch {
|
||||
console.warn("Failed to load notification preferences")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch("/api/notifications/preferences", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(prefs),
|
||||
})
|
||||
if (res.ok) {
|
||||
toast.success("Notification preferences saved")
|
||||
} else {
|
||||
toast.error("Failed to save preferences")
|
||||
}
|
||||
} catch {
|
||||
console.warn("Failed to save notification preferences")
|
||||
toast.error("Failed to save preferences")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(key: keyof Preferences) {
|
||||
setPrefs((prev) => ({ ...prev, [key]: !prev[key] }))
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notification Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Configure which notifications you want to receive.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-0">
|
||||
{notifications.map((n, i) => (
|
||||
<div
|
||||
key={n.id}
|
||||
className={`flex items-center justify-between py-4 ${i < notifications.length - 1 ? "border-b" : ""}`}
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor={n.id} className="text-sm font-medium">
|
||||
{n.title}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">{n.description}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id={n.id}
|
||||
disabled={loading}
|
||||
checked={prefs[n.id as keyof Preferences]}
|
||||
onCheckedChange={() => toggle(n.id as keyof Preferences)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSave(); }}>
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button type="submit" disabled={loading || saving}>
|
||||
{saving ? "Saving..." : "Save Preferences"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Sun, Moon, Monitor } from "lucide-react"
|
||||
|
||||
const COLOR_THEME_KEY = "crm-color-theme"
|
||||
|
||||
const modeOptions = [
|
||||
{ value: "light", icon: Sun, label: "Light" },
|
||||
{ value: "dark", icon: Moon, label: "Dark" },
|
||||
{ value: "system", icon: Monitor, label: "System" },
|
||||
]
|
||||
|
||||
const themeOptions = [
|
||||
{ value: "default", label: "Default", color: "bg-blue-600", ring: "ring-blue-600" },
|
||||
{ value: "ocean", label: "Ocean", color: "bg-teal-500", ring: "ring-teal-500" },
|
||||
{ value: "forest", label: "Forest", color: "bg-green-600", ring: "ring-green-600" },
|
||||
{ value: "sunset", label: "Sunset", color: "bg-orange-500", ring: "ring-orange-500" },
|
||||
{ value: "midnight", label: "Midnight", color: "bg-indigo-600", ring: "ring-indigo-600" },
|
||||
{ value: "rose", label: "Rose", color: "bg-pink-600", ring: "ring-pink-600" },
|
||||
{ value: "amber", label: "Amber", color: "bg-amber-500", ring: "ring-amber-500" },
|
||||
{ value: "violet", label: "Violet", color: "bg-violet-600", ring: "ring-violet-600" },
|
||||
{ value: "slate", label: "Slate", color: "bg-slate-500", ring: "ring-slate-500" },
|
||||
{ value: "ruby", label: "Ruby", color: "bg-red-600", ring: "ring-red-600" },
|
||||
]
|
||||
|
||||
function getStoredColorTheme(): string {
|
||||
if (typeof window === "undefined") return "default"
|
||||
return localStorage.getItem(COLOR_THEME_KEY) || "default"
|
||||
}
|
||||
|
||||
function applyColorTheme(theme: string) {
|
||||
document.documentElement.className = document.documentElement.className
|
||||
.replace(/\b(default|ocean|forest|sunset|midnight|rose|amber|violet|slate|ruby)\b/g, "")
|
||||
.trim()
|
||||
if (theme !== "default") {
|
||||
document.documentElement.classList.add(theme)
|
||||
}
|
||||
localStorage.setItem(COLOR_THEME_KEY, theme)
|
||||
}
|
||||
|
||||
export function ThemeSettings() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [colorTheme, setColorTheme] = useState("default")
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
setColorTheme(getStoredColorTheme())
|
||||
applyColorTheme(getStoredColorTheme())
|
||||
}, [])
|
||||
|
||||
function handleColorChange(value: string) {
|
||||
setColorTheme(value)
|
||||
applyColorTheme(value)
|
||||
}
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Theme Mode</CardTitle>
|
||||
<CardDescription>
|
||||
Choose your preferred appearance mode.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RadioGroup value={theme || "dark"} onValueChange={setTheme} className="grid grid-cols-3 gap-4">
|
||||
{modeOptions.map(({ value, icon: Icon, label }) => (
|
||||
<div key={value}>
|
||||
<RadioGroupItem value={value} id={`mode-${value}`} className="peer sr-only" />
|
||||
<Label
|
||||
htmlFor={`mode-${value}`}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-3 rounded-lg border-2 p-4 hover:bg-accent cursor-pointer transition-all",
|
||||
"peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-6 w-6" />
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Color Theme</CardTitle>
|
||||
<CardDescription>
|
||||
Select a color accent for the dashboard.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RadioGroup value={colorTheme} onValueChange={handleColorChange} className="grid grid-cols-5 gap-4">
|
||||
{themeOptions.map(({ value, label, color, ring }) => (
|
||||
<div key={value}>
|
||||
<RadioGroupItem value={value} id={`color-${value}`} className="peer sr-only" />
|
||||
<Label
|
||||
htmlFor={`color-${value}`}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-3 rounded-lg border-2 p-4 hover:bg-accent cursor-pointer transition-all",
|
||||
"peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5"
|
||||
)}
|
||||
>
|
||||
<div className={cn("h-8 w-8 rounded-full", color, ring, "ring-2 ring-offset-2 ring-offset-background")} />
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
interface Preferences {
|
||||
timezone: string
|
||||
dateFormat: string
|
||||
itemsPerPage: number
|
||||
}
|
||||
|
||||
export function UserPreferencesForm() {
|
||||
const [prefs, setPrefs] = useState<Preferences>({
|
||||
timezone: "america-los_angeles",
|
||||
dateFormat: "mdy",
|
||||
itemsPerPage: 20,
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch("/api/settings/preferences")
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setPrefs(json)
|
||||
}
|
||||
} catch {
|
||||
console.warn("Failed to load user preferences")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch("/api/settings/preferences", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(prefs),
|
||||
})
|
||||
if (res.ok) {
|
||||
toast.success("Preferences saved")
|
||||
} else {
|
||||
toast.error("Failed to save preferences")
|
||||
}
|
||||
} catch {
|
||||
console.warn("Failed to save user preferences")
|
||||
toast.error("Failed to save preferences")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>User Preferences</CardTitle>
|
||||
<CardDescription>
|
||||
Customize your experience and default settings.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timezone">Timezone</Label>
|
||||
<Select disabled={loading} value={prefs.timezone} onValueChange={(v) => setPrefs((p) => ({ ...p, timezone: v }))}>
|
||||
<SelectTrigger id="timezone">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="america-new_york">Eastern Time (ET)</SelectItem>
|
||||
<SelectItem value="america-chicago">Central Time (CT)</SelectItem>
|
||||
<SelectItem value="america-denver">Mountain Time (MT)</SelectItem>
|
||||
<SelectItem value="america-los_angeles">Pacific Time (PT)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="date-format">Date Format</Label>
|
||||
<Select disabled={loading} value={prefs.dateFormat} onValueChange={(v) => setPrefs((p) => ({ ...p, dateFormat: v }))}>
|
||||
<SelectTrigger id="date-format">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mdy">MM/DD/YYYY</SelectItem>
|
||||
<SelectItem value="dmy">DD/MM/YYYY</SelectItem>
|
||||
<SelectItem value="ymd">YYYY-MM-DD</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="items-per-page">Items Per Page</Label>
|
||||
<Select disabled={loading} value={String(prefs.itemsPerPage)} onValueChange={(v) => setPrefs((p) => ({ ...p, itemsPerPage: parseInt(v) }))}>
|
||||
<SelectTrigger id="items-per-page">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="20">20</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSave(); }}>
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button type="submit" disabled={loading || saving}>
|
||||
{saving ? "Saving..." : "Save Preferences"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { X, Bug, Loader2, CheckCircle } from "lucide-react"
|
||||
|
||||
interface BugReportModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function BugReportModal({ open, onClose }: BugReportModalProps) {
|
||||
const [title, setTitle] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [severity, setSeverity] = useState("medium")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/bug-reports", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description,
|
||||
severity,
|
||||
page_url: window.location.href,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setSubmitted(true)
|
||||
setTitle("")
|
||||
setDescription("")
|
||||
setSeverity("medium")
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
setError(data.error || "Failed to submit bug report.")
|
||||
}
|
||||
} catch {
|
||||
setError("Connection error. Please try again.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setSubmitted(false)
|
||||
setError("")
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
className="relative w-full max-w-md rounded-xl border bg-white dark:bg-[#141414] p-6 shadow-2xl"
|
||||
>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="absolute right-4 top-4 text-[#888888] hover:text-[#111111] dark:hover:text-white transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{submitted ? (
|
||||
<div className="flex flex-col items-center py-8 text-center">
|
||||
<CheckCircle className="h-12 w-12 text-emerald-500 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-[#111111] dark:text-white mb-2">
|
||||
Bug Report Submitted
|
||||
</h3>
|
||||
<p className="text-sm text-[#666666] dark:text-[#AAAAAA]">
|
||||
Thank you for your report. The development team will investigate.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="mt-6 rounded-lg bg-[#CC0000] px-6 py-2 text-sm font-medium text-white hover:bg-[#AA0000] transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-100 dark:bg-red-900/30">
|
||||
<Bug className="h-5 w-5 text-[#CC0000]" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[#111111] dark:text-white">
|
||||
Report a Bug
|
||||
</h3>
|
||||
<p className="text-xs text-[#666666] dark:text-[#AAAAAA]">
|
||||
Help us improve the system
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-500/10 px-4 py-2 text-sm text-red-500">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-[#444444] dark:text-[#CCCCCC]">
|
||||
Title <span className="text-[#CC0000]">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Brief description of the issue"
|
||||
required
|
||||
className="w-full rounded-lg border border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#111111] dark:text-white placeholder-[#999999] focus:border-[#CC0000] focus:outline-none focus:ring-1 focus:ring-[#CC0000]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-[#444444] dark:text-[#CCCCCC]">
|
||||
Description <span className="text-[#CC0000]">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What happened? What did you expect?"
|
||||
rows={4}
|
||||
required
|
||||
className="w-full resize-none rounded-lg border border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#111111] dark:text-white placeholder-[#999999] focus:border-[#CC0000] focus:outline-none focus:ring-1 focus:ring-[#CC0000]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-[#444444] dark:text-[#CCCCCC]">
|
||||
Severity
|
||||
</label>
|
||||
<select
|
||||
value={severity}
|
||||
onChange={(e) => setSeverity(e.target.value)}
|
||||
className="w-full rounded-lg border border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#111111] dark:text-white focus:border-[#CC0000] focus:outline-none focus:ring-1 focus:ring-[#CC0000]"
|
||||
>
|
||||
<option value="low">Low — Minor cosmetic issue</option>
|
||||
<option value="medium">Medium — Affects functionality</option>
|
||||
<option value="high">High — Major feature broken</option>
|
||||
<option value="critical">Critical — System is blocked</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] px-3 py-2">
|
||||
<p className="text-xs text-[#888888]">
|
||||
<span className="font-medium">Page:</span> {typeof window !== "undefined" ? window.location.href : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-[#CC0000] px-4 py-2.5 text-sm font-medium text-white hover:bg-[#AA0000] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{loading ? "Submitting..." : "Submit Bug Report"}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import { Column } from "@tanstack/react-table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, EyeOff } from "lucide-react"
|
||||
|
||||
interface DataTableColumnHeaderProps<TData, TValue> extends React.HTMLAttributes<HTMLDivElement> {
|
||||
column: Column<TData, TValue>
|
||||
title: string
|
||||
}
|
||||
|
||||
export function DataTableColumnHeader<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||
if (!column.getCanSort()) {
|
||||
return <div className={cn(className)}>{title}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center space-x-2", className)}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8 data-[state=open]:bg-accent"
|
||||
>
|
||||
<span>{title}</span>
|
||||
{column.getIsSorted() === "desc" ? (
|
||||
<ArrowDown className="ml-2 h-4 w-4" />
|
||||
) : column.getIsSorted() === "asc" ? (
|
||||
<ArrowUp className="ml-2 h-4 w-4" />
|
||||
) : (
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
|
||||
<ArrowUp className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Asc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
||||
<ArrowDown className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Desc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
|
||||
<EyeOff className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Hide
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client"
|
||||
|
||||
import { Table } from "@tanstack/react-table"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react"
|
||||
|
||||
interface DataTablePaginationProps<TData> {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({ table }: DataTablePaginationProps<TData>) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<div className="flex-1 text-sm text-muted-foreground">
|
||||
{table.getFilteredSelectedRowModel().rows.length} of{" "}
|
||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
</div>
|
||||
<div className="flex items-center space-x-6 lg:space-x-8">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-sm font-medium">Rows per page</p>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[70px]">
|
||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[5, 10, 20, 30, 50].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
|
||||
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
SortingState,
|
||||
VisibilityState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { DataTablePagination } from "./data-table-pagination"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[]
|
||||
data: TData[]
|
||||
toolbar?: React.ReactNode
|
||||
loading?: boolean
|
||||
emptyMessage?: string
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
toolbar,
|
||||
loading,
|
||||
emptyMessage = "No results found.",
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [rowSelection, setRowSelection] = React.useState({})
|
||||
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
|
||||
const [sorting, setSorting] = React.useState<SortingState>([])
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{toolbar && <div className="flex items-center justify-between gap-4">{toolbar}</div>}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id} colSpan={header.colSpan}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
{columns.map((_, ci) => (
|
||||
<TableCell key={ci}>
|
||||
<div className="h-4 animate-pulse rounded bg-muted" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className={cn("cursor-pointer")}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<p>{emptyMessage}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DataTablePagination table={table} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "framer-motion"
|
||||
import { LucideIcon } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon
|
||||
title: string
|
||||
description: string
|
||||
actionLabel?: string
|
||||
onAction?: () => void
|
||||
}
|
||||
|
||||
export function EmptyState({ icon: Icon, title, description, actionLabel, onAction }: EmptyStateProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex flex-col items-center justify-center py-16 text-center"
|
||||
>
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted">
|
||||
<Icon className="h-10 w-10 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="mt-6 text-lg font-semibold">{title}</h3>
|
||||
<p className="mt-2 max-w-sm text-sm text-muted-foreground">{description}</p>
|
||||
{actionLabel && onAction && (
|
||||
<Button onClick={onAction} className="mt-6">
|
||||
{actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import { Component, type ReactNode } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error?: Error
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: { componentStack?: string }) {
|
||||
console.error("ErrorBoundary caught:", error, info.componentStack)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) return this.props.fallback
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[60vh] gap-4">
|
||||
<h2 className="text-xl font-bold">Something went wrong</h2>
|
||||
<p className="text-muted-foreground text-sm max-w-md text-center">
|
||||
{this.state.error?.message || "An unexpected error occurred"}
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => {
|
||||
this.setState({ hasError: false, error: undefined })
|
||||
window.location.reload()
|
||||
}}>
|
||||
Reload Page
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "framer-motion"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string | React.ReactNode
|
||||
description?: string
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PageHeader({ title, description, children, className }: PageHeaderProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className={cn("flex items-center justify-between pb-6", className)}
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight border-l-4 border-[#CC0000] dark:border-[#FF1111] pl-4 text-[#111111] dark:text-white">{title}</h1>
|
||||
{description && <p className="text-sm text-[#444444] dark:text-[#AAAAAA] mt-1">{description}</p>}
|
||||
</div>
|
||||
{children && <div className="flex items-center gap-3">{children}</div>}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
|
||||
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn("flex h-full w-full items-center justify-center rounded-full bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground shadow",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||
destructive: "border-transparent bg-destructive text-destructive-foreground shadow",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("rounded-xl border bg-card text-card-foreground shadow-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn("font-semibold leading-none tracking-tight", className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
@@ -0,0 +1,95 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return <span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
} from "react-hook-form"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue)
|
||||
|
||||
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
)
|
||||
FormItem.displayName = "FormItem"
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormLabel.displayName = "FormLabel"
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormControl.displayName = "FormControl"
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-[0.8rem] text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormDescription.displayName = "FormDescription"
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-[0.8rem] font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
FormMessage.displayName = "FormMessage"
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Circle } from "lucide-react"
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return <RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />
|
||||
})
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-primary" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar forceMount />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-opacity",
|
||||
orientation === "vertical" && "h-full w-3 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-muted-foreground/30" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user