Current state

This commit is contained in:
Chariah
2026-06-26 14:31:38 +02:00
commit 7a76841309
982 changed files with 451988 additions and 0 deletions
+86
View File
@@ -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
+119
View File
@@ -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>
)
}
+39
View File
@@ -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>
)
}
+205
View File
@@ -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>
)
}
+277
View File
@@ -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>
)
}
+57
View File
@@ -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>
)
}
+143
View File
@@ -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>
)
}
+43
View File
@@ -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>
)
}
+105
View File
@@ -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>
)
}
+30
View File
@@ -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 })
}
}
+20
View File
@@ -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: [] })
}
}
+11
View File
@@ -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 })
}
+120
View File
@@ -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 }
)
}
}
+15
View File
@@ -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 }
)
}
}
+18
View File
@@ -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 }
)
}
}
+64
View File
@@ -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 })
}
}
+69
View File
@@ -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 })
}
}
+118
View File
@@ -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 })
}
}
+131
View File
@@ -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()
}
+213
View File
@@ -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 })
}
}
+25
View File
@@ -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 })
}
}
+86
View File
@@ -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 })
}
}
+158
View File
@@ -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 })
}
}
+190
View File
@@ -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 })
}
}
+49
View File
@@ -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 })
}
}
+93
View File
@@ -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 })
}
}
+68
View File
@@ -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 })
}
}
+60
View File
@@ -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 })
}
}
+36
View File
@@ -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,
})
}
+31
View File
@@ -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 })
}
}
+40
View File
@@ -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 })
}
}
+38
View File
@@ -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 })
}
}
+99
View File
@@ -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 })
}
}
+44
View File
@@ -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 })
}
}
+226
View File
@@ -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>
)
}
+696
View File
@@ -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); }
+69
View File
@@ -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&apos;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>
)
}
+40
View File
@@ -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>
)
}
+7
View File
@@ -0,0 +1,7 @@
export default function LoginLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}
+406
View File
@@ -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&apos;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" }}>
&ldquo;{testimonials[testimonialIndex].text}&rdquo;
</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>
)
}
+30
View File
@@ -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>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation"
export default function RootPage() {
redirect("/dashboard")
}
+201
View File
@@ -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>
)
}
+74
View File
@@ -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>
)
}
+350
View File
@@ -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} &middot; {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 &middot; {totalClosed} closed &middot; {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>
)
}
+47
View File
@@ -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>
)
}
+203
View File
@@ -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>
)
}
+70
View File
@@ -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(" ")
}
+37
View File
@@ -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>
)
}
+256
View File
@@ -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>
</>
)
}
+43
View File
@@ -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>
)
}
+246
View File
@@ -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>
)
}
+311
View File
@@ -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>
)
}
+135
View File
@@ -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."
/>
)
}
+81
View File
@@ -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>
)
}
+94
View File
@@ -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>
)
}
+124
View File
@@ -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>
)
}
+188
View File
@@ -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>
)
}
+129
View File
@@ -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>
)
}
+35
View File
@@ -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>
)
}
+50
View File
@@ -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
}
}
+28
View File
@@ -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>
)
}
+113
View File
@@ -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,
}
+41
View File
@@ -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 }
+30
View File
@@ -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 }
+52
View File
@@ -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 }
+50
View File
@@ -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 }
+25
View File
@@ -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 }
+95
View File
@@ -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,
}
+172
View File
@@ -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,
}
+170
View File
@@ -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,
}
+21
View File
@@ -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 }
+23
View File
@@ -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 }
+35
View File
@@ -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 }
+43
View File
@@ -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