Merge remote-tracking branch 'origin/main' into master
This commit is contained in:
@@ -0,0 +1,861 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback, Component, type ReactNode } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { CalendarEvent, EventType } from "@/types"
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useUser } from "@/providers/user-provider"
|
||||
import { useNotifications } from "@/providers/notification-provider"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
ChevronLeft, ChevronRight, Plus, Calendar, Clock, Phone,
|
||||
Video, Users, CheckCircle2, XCircle, RotateCcw, Loader2,
|
||||
ArrowUpRight, Zap, StickyNote,
|
||||
} from "lucide-react"
|
||||
|
||||
const EVENT_ICONS: Record<EventType, React.ElementType> = {
|
||||
meeting: Users,
|
||||
call: Phone,
|
||||
chat: Video,
|
||||
follow_up: Clock,
|
||||
demo: Calendar,
|
||||
other: Calendar,
|
||||
}
|
||||
|
||||
function eventVars(type: EventType) {
|
||||
return `--event-${type}`
|
||||
}
|
||||
|
||||
function eventBgStyle(type: EventType): React.CSSProperties {
|
||||
const v = eventVars(type)
|
||||
return {
|
||||
backgroundColor: `hsl(var(${v}) / 0.1)`,
|
||||
color: `hsl(var(${v}))`,
|
||||
borderColor: `hsl(var(${v}) / 0.25)`,
|
||||
}
|
||||
}
|
||||
|
||||
function eventDotStyle(type: EventType): React.CSSProperties {
|
||||
return { backgroundColor: `hsl(var(--event-${type}))` }
|
||||
}
|
||||
|
||||
function getDaysInMonth(year: number, month: number) {
|
||||
return new Date(year, month + 1, 0).getDate()
|
||||
}
|
||||
|
||||
function getFirstDayOfMonth(year: number, month: number) {
|
||||
return new Date(year, month, 1).getDay()
|
||||
}
|
||||
|
||||
const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
|
||||
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
||||
|
||||
function formatTime(iso: string) {
|
||||
return new Date(iso).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true })
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
|
||||
}
|
||||
|
||||
export default function CalendarPage() {
|
||||
const router = useRouter()
|
||||
const { user } = useUser()
|
||||
const { addNotification } = useNotifications()
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([])
|
||||
const [upcomingEvents, setUpcomingEvents] = useState<CalendarEvent[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [today] = useState(new Date())
|
||||
const [currentMonth, setCurrentMonth] = useState(today.getMonth())
|
||||
const [currentYear, setCurrentYear] = useState(today.getFullYear())
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null)
|
||||
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null)
|
||||
|
||||
const [users, setUsers] = useState<{ id: string; name: string; email: string }[]>([])
|
||||
const [formTitle, setFormTitle] = useState("")
|
||||
const [formDescription, setFormDescription] = useState("")
|
||||
const [formType, setFormType] = useState<EventType>("meeting")
|
||||
const [formDate, setFormDate] = useState("")
|
||||
const [formTime, setFormTime] = useState("")
|
||||
const [formDuration, setFormDuration] = useState("30")
|
||||
const [formParticipantId, setFormParticipantId] = useState("")
|
||||
const [formSaving, setFormSaving] = useState(false)
|
||||
const [editNotes, setEditNotes] = useState(false)
|
||||
const [notesText, setNotesText] = useState("")
|
||||
const [notesSaving, setNotesSaving] = useState(false)
|
||||
|
||||
const fetchMonthEvents = useCallback(async () => {
|
||||
const start = new Date(currentYear, currentMonth, 1).toISOString()
|
||||
const end = new Date(currentYear, currentMonth + 1, 0, 23, 59, 59).toISOString()
|
||||
const res = await fetch(`/api/events?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setEvents(data.events || [])
|
||||
}
|
||||
}, [currentYear, currentMonth])
|
||||
|
||||
const fetchUpcomingEvents = useCallback(async () => {
|
||||
const start = new Date().toISOString()
|
||||
const end = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString()
|
||||
const res = await fetch(`/api/events?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}&status=scheduled`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setUpcomingEvents((data.events || []).slice(0, 10))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) { router.push("/login"); return }
|
||||
fetch("/api/event-users")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setUsers(data.users || []))
|
||||
.catch(() => {})
|
||||
}, [user, router])
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
fetchMonthEvents().catch(() => toast.error("Failed to load events")).finally(() => setLoading(false))
|
||||
}, [fetchMonthEvents])
|
||||
|
||||
useEffect(() => {
|
||||
fetchUpcomingEvents().catch(() => {})
|
||||
}, [fetchUpcomingEvents])
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
fetchMonthEvents().catch(() => {})
|
||||
fetchUpcomingEvents().catch(() => {})
|
||||
}, 15000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchMonthEvents, fetchUpcomingEvents])
|
||||
|
||||
const daysInMonth = getDaysInMonth(currentYear, currentMonth)
|
||||
const firstDay = getFirstDayOfMonth(currentYear, currentMonth)
|
||||
|
||||
const prevMonth = () => {
|
||||
if (currentMonth === 0) { setCurrentMonth(11); setCurrentYear(currentYear - 1) }
|
||||
else setCurrentMonth(currentMonth - 1)
|
||||
}
|
||||
|
||||
const nextMonth = () => {
|
||||
if (currentMonth === 11) { setCurrentMonth(0); setCurrentYear(currentYear + 1) }
|
||||
else setCurrentMonth(currentMonth + 1)
|
||||
}
|
||||
|
||||
const getEventsForDay = useCallback((day: number) => {
|
||||
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
|
||||
return events.filter((e) => e.startTime.startsWith(dateStr))
|
||||
}, [events, currentYear, currentMonth])
|
||||
|
||||
const isToday = (day: number) => {
|
||||
return today.getDate() === day && today.getMonth() === currentMonth && today.getFullYear() === currentYear
|
||||
}
|
||||
|
||||
const openCreateDialog = (day?: number) => {
|
||||
setEditingEvent(null)
|
||||
setFormTitle("")
|
||||
setFormDescription("")
|
||||
setFormType("meeting")
|
||||
setFormDuration("30")
|
||||
setFormParticipantId("")
|
||||
const d = day ? new Date(currentYear, currentMonth, day) : new Date()
|
||||
setFormDate(d.toISOString().split("T")[0])
|
||||
setFormTime("10:00")
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEditDialog = (event: CalendarEvent) => {
|
||||
setEditingEvent(event)
|
||||
setFormTitle(event.title)
|
||||
setFormDescription(event.description || "")
|
||||
setFormType(event.eventType)
|
||||
setFormDuration(String(event.durationMinutes || 30))
|
||||
setFormDate(new Date(event.startTime).toISOString().split("T")[0])
|
||||
setFormTime(new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }))
|
||||
setFormParticipantId(event.participantId || "")
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formTitle.trim() || !formDate || !formTime) {
|
||||
toast.error("Title, date, and time are required")
|
||||
return
|
||||
}
|
||||
|
||||
setFormSaving(true)
|
||||
try {
|
||||
const startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
|
||||
let endTime = null
|
||||
if (formDuration) {
|
||||
const end = new Date(startTime)
|
||||
end.setMinutes(end.getMinutes() + parseInt(formDuration))
|
||||
endTime = end.toISOString()
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
title: formTitle.trim(),
|
||||
description: formDescription.trim() || null,
|
||||
eventType: formType,
|
||||
startTime,
|
||||
endTime,
|
||||
durationMinutes: formDuration ? parseInt(formDuration) : null,
|
||||
}
|
||||
if (formParticipantId) body.participantId = formParticipantId
|
||||
|
||||
let res
|
||||
if (editingEvent) {
|
||||
res = await fetch(`/api/events/${editingEvent.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
} else {
|
||||
res = await fetch("/api/events", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
if (!res.ok) throw new Error("Failed to save")
|
||||
|
||||
const data = await res.json()
|
||||
if (editingEvent) {
|
||||
setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
|
||||
toast.success("Event updated")
|
||||
} else {
|
||||
setEvents((prev) => [...prev, data.event])
|
||||
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar")
|
||||
toast.success("Event created")
|
||||
}
|
||||
|
||||
setDialogOpen(false)
|
||||
} catch {
|
||||
toast.error("Failed to save event")
|
||||
} finally {
|
||||
setFormSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateEventStatus = async (event: CalendarEvent, newStatus: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/events/${event.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to update")
|
||||
const data = await res.json()
|
||||
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
|
||||
toast.success(`Event ${newStatus}`)
|
||||
setDetailEvent(null)
|
||||
} catch {
|
||||
toast.error("Failed to update event")
|
||||
}
|
||||
}
|
||||
|
||||
const saveNotes = async (event: CalendarEvent) => {
|
||||
if (notesText === (event.participantNotes || "")) { setEditNotes(false); return }
|
||||
setNotesSaving(true)
|
||||
try {
|
||||
const res = await fetch(`/api/events/${event.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ participantNotes: notesText || null }),
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to save")
|
||||
const data = await res.json()
|
||||
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
|
||||
if (detailEvent && detailEvent.id === event.id) {
|
||||
setDetailEvent({ ...detailEvent, participantNotes: notesText || null })
|
||||
}
|
||||
toast.success("Notes saved")
|
||||
setEditNotes(false)
|
||||
} catch {
|
||||
toast.error("Failed to save notes")
|
||||
} finally {
|
||||
setNotesSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteEvent = async (event: CalendarEvent) => {
|
||||
try {
|
||||
const res = await fetch(`/api/events/${event.id}`, { method: "DELETE" })
|
||||
if (!res.ok) throw new Error("Failed to delete")
|
||||
setEvents((prev) => prev.filter((e) => e.id !== event.id))
|
||||
toast.success("Event deleted")
|
||||
setDetailEvent(null)
|
||||
setDialogOpen(false)
|
||||
} catch {
|
||||
toast.error("Failed to delete event")
|
||||
}
|
||||
}
|
||||
|
||||
const calendarDays: (number | null)[] = []
|
||||
for (let i = 0; i < firstDay; i++) calendarDays.push(null)
|
||||
for (let d = 1; d <= daysInMonth; d++) calendarDays.push(d)
|
||||
|
||||
if (!user) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-4rem)] bg-gradient-to-b from-background via-background to-muted/20">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-3 border-b shrink-0 bg-gradient-to-r from-background via-accent/5 to-background backdrop-blur-sm shadow-[0_1px_3px_-1px_hsl(var(--border))]">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="p-2 rounded-lg bg-gradient-to-br from-primary/20 to-primary/5 text-primary shadow-sm ring-1 ring-primary/15">
|
||||
<Calendar className="h-4 w-4" />
|
||||
</div>
|
||||
<h1 className="text-lg font-bold tracking-tight">Calendar</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 p-0.5 rounded-lg border bg-card/60 shadow-sm">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-md hover:bg-accent/60 hover:scale-105 active:scale-95 transition-all" onClick={prevMonth}>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<span className="text-sm font-bold min-w-[130px] text-center select-none tracking-tight px-1">
|
||||
{MONTHS[currentMonth]} {currentYear}
|
||||
</span>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-md hover:bg-accent/60 hover:scale-105 active:scale-95 transition-all" onClick={nextMonth}>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="text-xs h-7 px-2 hover:bg-accent/40 hover:scale-105 active:scale-95 transition-all" onClick={() => { setCurrentMonth(today.getMonth()); setCurrentYear(today.getFullYear()) }}>
|
||||
Today
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={() => openCreateDialog()} size="sm" className="shadow-sm hover:shadow-md hover:scale-[1.02] active:scale-[0.98] transition-all bg-gradient-to-r from-primary to-primary/90 h-9">
|
||||
<Plus className="h-4 w-4 mr-1.5" /> New Event
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Body - flex row with independent scroll */}
|
||||
<div className="flex-1 flex overflow-hidden p-4 lg:p-6 gap-4 lg:gap-6">
|
||||
{loading ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="relative">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary/60" />
|
||||
<div className="absolute inset-0 animate-pulse rounded-full bg-primary/5" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground animate-pulse">Loading calendar...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Left: Calendar Grid */}
|
||||
<div className="flex-1 flex flex-col gap-4 min-w-0 overflow-hidden">
|
||||
{/* Stats bar */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="flex items-center gap-2 px-3.5 py-2 rounded-lg bg-primary/5 border border-primary/10 shadow-sm">
|
||||
<div className="h-2 w-2 rounded-full bg-primary" />
|
||||
<span className="text-sm font-bold text-primary">{events.filter(e => e.status === "scheduled").length}</span>
|
||||
<span className="text-[11px] text-primary/60 font-medium">scheduled</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3.5 py-2 rounded-lg bg-emerald-500/5 border border-emerald-500/10 shadow-sm">
|
||||
<div className="h-2 w-2 rounded-full bg-emerald-500" />
|
||||
<span className="text-sm font-bold text-emerald-600 dark:text-emerald-400">{events.filter(e => e.status === "completed").length}</span>
|
||||
<span className="text-[11px] text-emerald-600/60 dark:text-emerald-400/60 font-medium">completed</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3.5 py-2 rounded-lg bg-muted/30 border shadow-sm">
|
||||
<div className="h-2 w-2 rounded-full bg-muted-foreground/30" />
|
||||
<span className="text-sm font-bold text-foreground/70">{events.length}</span>
|
||||
<span className="text-[11px] text-muted-foreground/50 font-medium">total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar grid wrapper - scrollable */}
|
||||
<div className="flex-1 overflow-auto rounded-xl border bg-card shadow-sm">
|
||||
<div className="min-w-[600px]">
|
||||
{/* Day headers */}
|
||||
<div className="grid grid-cols-7 sticky top-0 z-10 bg-gradient-to-r from-muted/30 via-background to-muted/30 border-b border-border/40">
|
||||
{DAYS.map((d, i) => (
|
||||
<div key={d} className={cn(
|
||||
"px-3 py-2.5 text-[11px] font-semibold text-muted-foreground/50 text-center tracking-widest uppercase",
|
||||
i === 0 && "text-red-500/40 dark:text-red-400/40",
|
||||
i === 6 && "text-red-500/40 dark:text-red-400/40",
|
||||
)}>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day cells */}
|
||||
<div className="grid grid-cols-7">
|
||||
{calendarDays.map((day, i) => {
|
||||
if (day === null) return <div key={`empty-${i}`} className="min-h-[110px] border-r border-b border-border/40 bg-gradient-to-b from-muted/[0.01] to-transparent" />
|
||||
const dayEvents = getEventsForDay(day)
|
||||
const todayMark = isToday(day)
|
||||
const hasEvents = dayEvents.length > 0
|
||||
return (
|
||||
<div
|
||||
key={day}
|
||||
className={cn(
|
||||
"min-h-[110px] p-1.5 border-r border-b border-border/40 cursor-pointer transition-all duration-150 relative group",
|
||||
"hover:bg-accent/20 hover:shadow-[inset_0_0_0_1px_hsl(var(--border))]",
|
||||
todayMark && "bg-gradient-to-b from-primary/[0.04] to-transparent",
|
||||
)}
|
||||
onClick={() => { setSelectedDate(new Date(currentYear, currentMonth, day)); openCreateDialog(day) }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1 px-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center h-6 w-6 text-xs font-medium rounded-full transition-all duration-200",
|
||||
todayMark && "bg-primary text-primary-foreground text-[11px] font-bold shadow-md ring-2 ring-primary/30 scale-110",
|
||||
!todayMark && "text-muted-foreground/70 group-hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{day}
|
||||
</span>
|
||||
{hasEvents && (
|
||||
<span className="text-[10px] font-semibold text-muted-foreground/30 group-hover:text-muted-foreground/60 transition-all">
|
||||
{dayEvents.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
{dayEvents.slice(0, 3).map((event) => {
|
||||
const Icon = EVENT_ICONS[event.eventType]
|
||||
return (
|
||||
<button
|
||||
key={event.id}
|
||||
onClick={(e) => { e.stopPropagation(); setDetailEvent(event) }}
|
||||
className={cn(
|
||||
"w-full text-left text-[10px] leading-tight flex items-center gap-1 rounded transition-all duration-150",
|
||||
"hover:shadow-sm hover:scale-[1.02] active:scale-[0.97] group/pill",
|
||||
event.status !== "scheduled" && "opacity-40",
|
||||
)}
|
||||
>
|
||||
<div className="w-0.5 h-5 rounded-full shrink-0" style={eventDotStyle(event.eventType)} />
|
||||
<div style={eventBgStyle(event.eventType)}
|
||||
className="flex-1 flex items-center gap-1 px-1.5 py-0.5 rounded-r min-w-0"
|
||||
>
|
||||
<Icon className="h-2 w-2 shrink-0 opacity-50" />
|
||||
<span className="truncate font-medium">{event.title}</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{dayEvents.length > 3 && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation() }}
|
||||
className="text-[10px] text-muted-foreground/40 hover:text-muted-foreground pl-0.5 font-semibold transition-colors"
|
||||
>
|
||||
+{dayEvents.length - 3} more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Upcoming sidebar */}
|
||||
<div className="w-72 lg:w-80 shrink-0 flex flex-col gap-4 overflow-y-auto">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<h2 className="text-sm font-bold flex items-center gap-2 tracking-tight">
|
||||
<ArrowUpRight className="h-4 w-4 text-primary/70" />
|
||||
Upcoming
|
||||
</h2>
|
||||
{upcomingEvents.length > 0 && (
|
||||
<Badge variant="secondary" className="text-[10px] font-semibold px-2 py-0.5 ring-1 ring-border/50">
|
||||
{upcomingEvents.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{upcomingEvents.length === 0 ? (
|
||||
<Card className="p-6 border-dashed bg-card/50">
|
||||
<div className="flex flex-col items-center gap-2.5 text-center">
|
||||
<div className="p-3 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 ring-1 ring-border/20">
|
||||
<Calendar className="h-6 w-6 text-muted-foreground/30" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">No upcoming events</p>
|
||||
<p className="text-[11px] text-muted-foreground/40 mt-0.5">Schedule one to get started</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => openCreateDialog()} className="hover:scale-105 active:scale-95 transition-all shadow-sm">
|
||||
<Plus className="h-3 w-3 mr-1.5" /> Schedule event
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{upcomingEvents.map((event) => {
|
||||
const Icon = EVENT_ICONS[event.eventType]
|
||||
return (
|
||||
<button
|
||||
key={event.id}
|
||||
onClick={() => setDetailEvent(event)}
|
||||
className={cn(
|
||||
"w-full text-left rounded-lg border bg-card/80 hover:bg-card transition-all duration-150 overflow-hidden",
|
||||
"hover:shadow-md hover:border-accent-foreground/10 active:scale-[0.98]",
|
||||
)}
|
||||
>
|
||||
<div className="flex">
|
||||
<div className="w-0.5 shrink-0" style={eventDotStyle(event.eventType)} />
|
||||
<div className="flex-1 p-3">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className={cn(
|
||||
"p-1.5 rounded shrink-0 ring-1 ring-border/20",
|
||||
event.userId === user?.id ? "bg-primary/5" : "bg-muted",
|
||||
)}>
|
||||
<Icon className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
event.userId === user?.id ? "text-primary/60" : "text-muted-foreground/40",
|
||||
)} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
<p className="text-sm font-semibold truncate">{event.title}</p>
|
||||
<span className={cn(
|
||||
"text-[9px] font-semibold px-1.5 py-0.5 rounded capitalize shrink-0",
|
||||
event.status === "scheduled" && "bg-primary/10 text-primary",
|
||||
event.status === "completed" && "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
||||
event.status === "cancelled" && "bg-red-500/10 text-red-600 dark:text-red-400",
|
||||
)}>
|
||||
{event.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1 text-[10px] text-muted-foreground/60 font-medium">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatDate(event.startTime)}
|
||||
<span className="text-muted-foreground/20">·</span>
|
||||
{formatTime(event.startTime)}
|
||||
</div>
|
||||
{event.durationMinutes && (
|
||||
<div className="flex items-center gap-1 mt-0.5 text-[10px] text-muted-foreground/40 font-medium">
|
||||
<Zap className="h-2.5 w-2.5" />
|
||||
{event.durationMinutes} min
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<span className={cn(
|
||||
"h-3.5 w-3.5 rounded-full text-[7px] flex items-center justify-center font-bold ring-1",
|
||||
event.userId === user?.id
|
||||
? "bg-primary/10 text-primary ring-primary/20"
|
||||
: "bg-muted text-muted-foreground/60 ring-border",
|
||||
)}>
|
||||
{event.userId === user?.id
|
||||
? (event.participant?.name.charAt(0) || "?")
|
||||
: (event.creator?.name.charAt(0) || "?")}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/60 font-medium">
|
||||
{event.userId === user?.id
|
||||
? (event.participant?.name || "No participant")
|
||||
: (event.creator?.name || "Unknown")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
"p-3 rounded-xl border shadow-sm",
|
||||
editingEvent
|
||||
? "bg-gradient-to-br from-muted to-muted/50 text-muted-foreground border-border"
|
||||
: "bg-gradient-to-br from-primary/15 to-primary/5 text-primary border-primary/20",
|
||||
)}>
|
||||
{editingEvent ? <Clock className="h-5 w-5" /> : <Plus className="h-5 w-5" />}
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-lg font-bold">{editingEvent ? "Edit Event" : "Create Event"}</DialogTitle>
|
||||
<p className="text-xs text-muted-foreground/60 mt-0.5">
|
||||
{editingEvent ? "Update the event details below" : "Fill in the details for your new event"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div className="space-y-5 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title" className="text-xs font-semibold">Title</Label>
|
||||
<Input id="title" value={formTitle} onChange={(e) => setFormTitle(e.target.value)} placeholder="e.g. Product demo with client" className="h-10" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="date" className="text-xs font-semibold">Date</Label>
|
||||
<Input id="date" type="date" value={formDate} onChange={(e) => setFormDate(e.target.value)} className="h-10" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="time" className="text-xs font-semibold">Time</Label>
|
||||
<Input id="time" type="time" value={formTime} onChange={(e) => setFormTime(e.target.value)} className="h-10" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type" className="text-xs font-semibold">Type</Label>
|
||||
<Select value={formType} onValueChange={(v: EventType) => setFormType(v)}>
|
||||
<SelectTrigger id="type" className="h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="meeting">Meeting</SelectItem>
|
||||
<SelectItem value="call">Call</SelectItem>
|
||||
<SelectItem value="chat">Video Chat</SelectItem>
|
||||
<SelectItem value="follow_up">Follow Up</SelectItem>
|
||||
<SelectItem value="demo">Demo</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="duration" className="text-xs font-semibold">Duration</Label>
|
||||
<Select value={formDuration} onValueChange={setFormDuration}>
|
||||
<SelectTrigger id="duration" className="h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="15">15 min</SelectItem>
|
||||
<SelectItem value="30">30 min</SelectItem>
|
||||
<SelectItem value="45">45 min</SelectItem>
|
||||
<SelectItem value="60">1 hour</SelectItem>
|
||||
<SelectItem value="90">1.5 hours</SelectItem>
|
||||
<SelectItem value="120">2 hours</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="participant" className="text-xs font-semibold">Participant</Label>
|
||||
<Select value={formParticipantId || "__none__"} onValueChange={(v) => setFormParticipantId(v === "__none__" ? "" : v)}>
|
||||
<SelectTrigger id="participant" className="h-10">
|
||||
<SelectValue placeholder="Select a participant…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">None</SelectItem>
|
||||
{users.map((u) => (
|
||||
<SelectItem key={u.id} value={u.id}>
|
||||
{u.name} ({u.email})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description" className="text-xs font-semibold">Description</Label>
|
||||
<Textarea id="description" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Add any notes or agenda items..." rows={3} className="resize-none" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="flex items-center justify-between border-t pt-4">
|
||||
<div>
|
||||
{editingEvent && (
|
||||
<Button variant="destructive" size="sm" onClick={() => deleteEvent(editingEvent)} className="shadow-sm">
|
||||
Delete event
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleSave} disabled={formSaving} className="shadow-sm">
|
||||
{formSaving && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||
{editingEvent ? "Update Event" : "Create Event"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Event Detail Dialog */}
|
||||
<Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}>
|
||||
{detailEvent && (
|
||||
<DialogContent className="sm:max-w-[460px]">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 rounded-xl border shadow-sm" style={eventBgStyle(detailEvent.eventType)}>
|
||||
{(() => { const Icon = EVENT_ICONS[detailEvent.eventType]; return Icon ? <Icon className="h-5 w-5" /> : <Calendar className="h-5 w-5" /> })()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<DialogTitle className="text-lg font-bold truncate">{detailEvent.title}</DialogTitle>
|
||||
<span className={cn(
|
||||
"inline-flex text-[10px] font-semibold px-2 py-0.5 rounded-full capitalize mt-0.5 ring-1",
|
||||
detailEvent.status === "scheduled" && "bg-primary/10 text-primary ring-primary/20",
|
||||
detailEvent.status === "completed" && "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 ring-emerald-500/20",
|
||||
detailEvent.status === "cancelled" && "bg-red-500/10 text-red-600 dark:text-red-400 ring-red-500/20",
|
||||
)}>
|
||||
{detailEvent.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{/* Date / Time */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
Date
|
||||
</div>
|
||||
<p className="text-sm font-bold">{formatDate(detailEvent.startTime)}</p>
|
||||
</div>
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
Time
|
||||
</div>
|
||||
<p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration + Type */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{detailEvent.durationMinutes && (
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
Duration
|
||||
</div>
|
||||
<p className="text-sm font-bold">{detailEvent.durationMinutes} min</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 capitalize">
|
||||
{(() => { const Icon = EVENT_ICONS[detailEvent.eventType]; return Icon ? <Icon className="h-3.5 w-3.5" /> : <Calendar className="h-3.5 w-3.5" /> })()}
|
||||
Type
|
||||
</div>
|
||||
<p className="text-sm font-bold capitalize">{detailEvent.eventType.replace("_", " ")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{detailEvent.description && (
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Description</p>
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed">{detailEvent.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Linked lead */}
|
||||
{detailEvent.lead && (
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Linked Lead</p>
|
||||
<p className="text-sm font-bold">{detailEvent.lead.contactName} — {detailEvent.lead.companyName}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Organizer & Participant - cross-linked */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
|
||||
<Users className="h-3 w-3" />
|
||||
{detailEvent.userId === user?.id ? "You (Organizer)" : "Organizer"}
|
||||
</p>
|
||||
<p className="text-sm font-bold flex items-center gap-2">
|
||||
<span className="h-6 w-6 rounded-full bg-muted text-muted-foreground text-[10px] flex items-center justify-center font-bold ring-1 ring-border">
|
||||
{detailEvent.creator?.name.charAt(0).toUpperCase() || "?"}
|
||||
</span>
|
||||
<span className="truncate">{detailEvent.creator?.name || "Unknown"}</span>
|
||||
{detailEvent.creator?.role && (
|
||||
<span className="text-[10px] font-medium text-muted-foreground/50 capitalize shrink-0">({detailEvent.creator.role})</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-primary/5 to-primary/[0.02] border shadow-sm">
|
||||
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
|
||||
<Users className="h-3 w-3" />
|
||||
{detailEvent.participantId === user?.id ? "You (Participant)" : "Participant"}
|
||||
</p>
|
||||
{detailEvent.participant ? (
|
||||
<p className="text-sm font-bold flex items-center gap-2">
|
||||
<span className="h-6 w-6 rounded-full bg-primary/10 text-primary text-[10px] flex items-center justify-center font-bold ring-1 ring-primary/20">
|
||||
{detailEvent.participant.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span className="truncate">{detailEvent.participant.name}</span>
|
||||
{detailEvent.participant.role && (
|
||||
<span className="text-[10px] font-medium text-muted-foreground/50 capitalize shrink-0">({detailEvent.participant.role})</span>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground/50 italic">No participant</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Participant Notes (editable by both parties) */}
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<StickyNote className="h-3 w-3" />
|
||||
Notes
|
||||
</p>
|
||||
{!editNotes && (
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[11px] font-semibold hover:bg-accent/40" onClick={() => { setNotesText(detailEvent.participantNotes || ""); setEditNotes(true) }}>
|
||||
{detailEvent.participantNotes ? "Edit" : "Add note"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{editNotes ? (
|
||||
<div className="space-y-2.5">
|
||||
<Textarea value={notesText} onChange={(e) => setNotesText(e.target.value)} rows={3} className="resize-none text-sm" placeholder="Add your notes about this event…" />
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={() => setEditNotes(false)} disabled={notesSaving}>Cancel</Button>
|
||||
<Button size="sm" onClick={() => saveNotes(detailEvent)} disabled={notesSaving} className="shadow-sm">
|
||||
{notesSaving && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : detailEvent.participantNotes ? (
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed font-medium">{detailEvent.participantNotes}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground/40 italic font-medium">No notes yet</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="flex items-center gap-2 border-t pt-4">
|
||||
{detailEvent.status === "scheduled" && (
|
||||
<>
|
||||
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm">
|
||||
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => updateEventStatus(detailEvent, "cancelled")}>
|
||||
<XCircle className="h-4 w-4 mr-1.5" /> Cancel
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => { setDetailEvent(null); openEditDialog(detailEvent) }}>
|
||||
Edit
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{detailEvent.status === "completed" && (
|
||||
<Button variant="outline" size="sm" onClick={() => updateEventStatus(detailEvent, "scheduled")}>
|
||||
<RotateCcw className="h-4 w-4 mr-1.5" /> Reopen
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
)}
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+559
-108
@@ -15,51 +15,52 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
Search, Send, Phone, MoreHorizontal, Paperclip,
|
||||
Search, Send, Phone, Video, MoreHorizontal, Paperclip,
|
||||
Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck,
|
||||
CornerDownRight, Forward, Pencil, Download,
|
||||
CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2, FolderOpen, Mail,
|
||||
} from "lucide-react"
|
||||
import { hasBlockedCodeExtension, filterBlockedFiles } from "@/lib/blocked-extensions"
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||
DialogFooter, DialogClose,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { useTheme } from "next-themes"
|
||||
import { useUser } from "@/providers/user-provider"
|
||||
import { toast } from "sonner"
|
||||
import { io, Socket } from "socket.io-client"
|
||||
import VoiceCallModal from "@/components/chats/voice-call-modal"
|
||||
import data from "@emoji-mart/data"
|
||||
import Picker from "@emoji-mart/react"
|
||||
import MediaPicker from "@/components/chats/media-picker"
|
||||
|
||||
function VoiceMessagePlayer({ src, initialDuration }: { src: string; initialDuration: number }) {
|
||||
const activeAudioRef: { current: HTMLAudioElement | null } = { current: null }
|
||||
const previewAudioRef: { current: HTMLAudioElement | null } = { current: null }
|
||||
const previewAnimRef: { current: number } = { current: 0 }
|
||||
|
||||
function VoiceMessagePlayer({ src, initialDuration, isOwn }: { src: string; initialDuration: number; isOwn?: boolean }) {
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [current, setCurrent] = useState(0)
|
||||
const [duration, setDuration] = useState(initialDuration)
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
const animRef = useRef<number>(0)
|
||||
const progRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
const onLoaded = () => {
|
||||
if (isFinite(audio.duration)) setDuration(audio.duration)
|
||||
}
|
||||
const onLoaded = () => { if (isFinite(audio.duration)) setDuration(audio.duration) }
|
||||
const onEnded = () => { setPlaying(false); setCurrent(0) }
|
||||
audio.addEventListener("loadedmetadata", onLoaded)
|
||||
audio.addEventListener("ended", onEnded)
|
||||
return () => {
|
||||
audio.removeEventListener("loadedmetadata", onLoaded)
|
||||
audio.removeEventListener("ended", onEnded)
|
||||
}
|
||||
return () => { audio.removeEventListener("loadedmetadata", onLoaded); audio.removeEventListener("ended", onEnded) }
|
||||
}, [src])
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing) { cancelAnimationFrame(animRef.current); return }
|
||||
const tick = () => {
|
||||
if (audioRef.current) setCurrent(audioRef.current.currentTime)
|
||||
animRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
const tick = () => { if (audioRef.current) setCurrent(audioRef.current.currentTime); animRef.current = requestAnimationFrame(tick) }
|
||||
animRef.current = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(animRef.current)
|
||||
}, [playing])
|
||||
@@ -67,26 +68,55 @@ function VoiceMessagePlayer({ src, initialDuration }: { src: string; initialDura
|
||||
const toggle = () => {
|
||||
if (!audioRef.current) return
|
||||
if (playing) { audioRef.current.pause(); setPlaying(false) }
|
||||
else { audioRef.current.play(); setPlaying(true) }
|
||||
else {
|
||||
if (activeAudioRef.current && activeAudioRef.current !== audioRef.current) { activeAudioRef.current.pause() }
|
||||
activeAudioRef.current = audioRef.current
|
||||
audioRef.current.play(); setPlaying(true)
|
||||
}
|
||||
}
|
||||
|
||||
const seek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = parseFloat(e.target.value)
|
||||
if (audioRef.current) { audioRef.current.currentTime = val; setCurrent(val) }
|
||||
}
|
||||
|
||||
const replay = () => {
|
||||
if (!audioRef.current) return
|
||||
audioRef.current.currentTime = 0; setCurrent(0)
|
||||
if (!playing) { audioRef.current.play(); setPlaying(true) }
|
||||
}
|
||||
|
||||
const displayDuration = isFinite(duration) && duration > 0 ? duration : initialDuration
|
||||
const pct = displayDuration > 0 ? (current / displayDuration) * 100 : 0
|
||||
const fmt = (s: number) => {
|
||||
const secs = isFinite(s) ? Math.max(0, Math.floor(s)) : 0
|
||||
return `${Math.floor(secs / 60)}:${(secs % 60).toString().padStart(2, "0")}`
|
||||
}
|
||||
const fmt = (s: number) => { const secs = isFinite(s) ? Math.max(0, Math.floor(s)) : 0; return `${Math.floor(secs / 60)}:${(secs % 60).toString().padStart(2, "0")}` }
|
||||
|
||||
const barCount = 28
|
||||
const waveform = Array.from({ length: barCount }, (_, i) => {
|
||||
const peak = 0.15 + Math.sin(i * 1.1) * 0.35 + Math.sin(i * 2.3) * 0.2 + Math.sin(i * 0.7) * 0.3
|
||||
return Math.max(0.15, Math.min(1, peak))
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-[180px]">
|
||||
<div className="flex items-center gap-2 min-w-[200px] select-none">
|
||||
<audio ref={audioRef} src={src} preload="metadata" />
|
||||
<button type="button" onClick={toggle} className="h-8 w-8 shrink-0 rounded-full flex items-center justify-center hover:bg-black/10 transition-colors">
|
||||
{playing ? <Pause className="h-4 w-4 fill-current" /> : <Play className="h-4 w-4 ml-0.5 fill-current" />}
|
||||
</button>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-white/30 relative overflow-hidden">
|
||||
<div className="h-full rounded-full bg-white transition-all duration-100" style={{ width: `${pct}%` }} />
|
||||
<div className="flex-1 flex flex-col gap-0.5 min-w-0">
|
||||
<div className="flex items-center gap-1 h-8">
|
||||
{waveform.map((h, i) => {
|
||||
const filled = (i / barCount) <= (pct / 100)
|
||||
return <div key={i} className="flex-1 rounded-full transition-colors duration-75" style={{ height: `${h * 100}%`, minHeight: 3, background: filled ? "currentColor" : "currentColor", opacity: filled ? 1 : 0.35 }} />
|
||||
})}
|
||||
</div>
|
||||
<input type="range" ref={progRef} min={0} max={displayDuration || 1} step={0.1} value={Math.min(current, displayDuration)} onChange={seek} className="w-full h-1 cursor-pointer appearance-none bg-transparent [&::-webkit-slider-runnable-track]:h-1 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-white/20 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-0" />
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-0.5 shrink-0">
|
||||
<span className="text-[11px] tabular-nums opacity-70">{fmt(current)}</span>
|
||||
<button type="button" onClick={replay} className="opacity-50 hover:opacity-100 transition-opacity">
|
||||
<Undo2 className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-[11px] tabular-nums opacity-70 w-8 text-right">{fmt(displayDuration)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -100,6 +130,7 @@ export default function ChatsPage() {
|
||||
const [activeChat, setActiveChat] = useState<string | null>(null)
|
||||
const [messageInput, setMessageInput] = useState("")
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||
const [showAttachMenu, setShowAttachMenu] = useState(false)
|
||||
const [attachments, setAttachments] = useState<File[]>([])
|
||||
const [panelWidth, setPanelWidth] = useState(320)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
@@ -113,6 +144,11 @@ export default function ChatsPage() {
|
||||
const [isPaused, setIsPaused] = useState(false)
|
||||
const [recordingDuration, setRecordingDuration] = useState(0)
|
||||
const recordingDurationRef = useRef(0)
|
||||
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null)
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
const [previewDuration, setPreviewDuration] = useState(0)
|
||||
const [isPreviewPlaying, setIsPreviewPlaying] = useState(false)
|
||||
const [previewCurrent, setPreviewCurrent] = useState(0)
|
||||
const [voiceMessages, setVoiceMessages] = useState<Map<string, { url: string; duration: number }>>(new Map())
|
||||
const [messageAttachments, setMessageAttachments] = useState<Map<string, { name: string; type: string; url: string }[]>>(new Map())
|
||||
const [replyingTo, setReplyingTo] = useState<any>(null)
|
||||
@@ -125,20 +161,46 @@ export default function ChatsPage() {
|
||||
const [searchResults, setSearchResults] = useState<any[]>([])
|
||||
const [searchingUsers, setSearchingUsers] = useState(false)
|
||||
const [unreadMap, setUnreadMap] = useState<Map<string, number>>(new Map())
|
||||
const [isCallModalOpen, setIsCallModalOpen] = useState(false)
|
||||
const [previewAvatarUrl, setPreviewAvatarUrl] = useState<string | null>(null)
|
||||
<<<<<<< HEAD
|
||||
const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false)
|
||||
const [scheduleTitle, setScheduleTitle] = useState("")
|
||||
const [scheduleDate, setScheduleDate] = useState("")
|
||||
const [scheduleTime, setScheduleTime] = useState("")
|
||||
const [scheduleDuration, setScheduleDuration] = useState("30")
|
||||
const [scheduleType, setScheduleType] = useState<string>("meeting")
|
||||
const [scheduleSaving, setScheduleSaving] = useState(false)
|
||||
=======
|
||||
const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null)
|
||||
>>>>>>> 21868fe336a205bcd2ef4a16283753f8dd3052bc
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const emojiPickerRef = useRef<HTMLDivElement>(null)
|
||||
const isSendingRef = useRef(false)
|
||||
const attachMenuRef = useRef<HTMLDivElement>(null)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||
const recordingChunksRef = useRef<Blob[]>([])
|
||||
const resizeStartRef = useRef({ x: 0, width: 0 })
|
||||
const blobUrlsRef = useRef<string[]>([])
|
||||
const conversationOrderRef = useRef<string[]>([])
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const socketRef = useRef<Socket | null>(null)
|
||||
const isDeletingRef = useRef(false)
|
||||
|
||||
const isOnlyEmoji = (text: string) => /^(\p{Extended_Pictographic}\s*)+$/u.test(text.trim())
|
||||
|
||||
const formatPreviewContent = (content: string) => {
|
||||
if (!content?.startsWith("{")) return content
|
||||
try {
|
||||
const p = JSON.parse(content)
|
||||
if (p.gif) return "📷 GIF"
|
||||
if (p.v) return "🎤 Voice message"
|
||||
if (p.sticker) return "💮 Sticker"
|
||||
} catch {}
|
||||
return content
|
||||
}
|
||||
|
||||
const autoResizeTextarea = useCallback(() => {
|
||||
const ta = textareaRef.current
|
||||
if (!ta) return
|
||||
@@ -208,6 +270,17 @@ export default function ChatsPage() {
|
||||
setMessageAttachments((a) => {
|
||||
const n = new Map(a)
|
||||
for (const k of n.keys()) if (!validMsgIds.has(k)) n.delete(k)
|
||||
// Restore persisted file attachments from JSON content
|
||||
for (const msgs of next.values()) {
|
||||
for (const m of msgs) {
|
||||
try {
|
||||
const parsed = JSON.parse(m.content)
|
||||
if (parsed.fileAttachments && Array.isArray(parsed.fileAttachments)) {
|
||||
n.set(m.id, parsed.fileAttachments)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return n
|
||||
})
|
||||
setReplyMap((r) => {
|
||||
@@ -257,6 +330,26 @@ export default function ChatsPage() {
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [])
|
||||
|
||||
// Strip blocked files from attachments on every change
|
||||
useEffect(() => {
|
||||
if (attachments.length === 0) return
|
||||
const { allowed, rejected } = filterBlockedFiles(attachments)
|
||||
if (rejected.length > 0) {
|
||||
toast.error(`Blocked file type: ${rejected[0].name}`)
|
||||
setAttachments(allowed)
|
||||
}
|
||||
}, [attachments])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (attachMenuRef.current && !attachMenuRef.current.contains(e.target as Node)) {
|
||||
setShowAttachMenu(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [])
|
||||
|
||||
// revoke blob URLs on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -265,6 +358,65 @@ export default function ChatsPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Poll for new messages every 4 seconds for real-time sync
|
||||
useEffect(() => {
|
||||
if (!activeChat) return
|
||||
pollTimerRef.current = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/conversations/${activeChat}/messages?limit=50`)
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
const serverMsgs = data.messages || []
|
||||
setConversationMessages((prev) => {
|
||||
const existing = prev.get(activeChat) || []
|
||||
if (serverMsgs.length === existing.length) return prev
|
||||
const next = new Map(prev)
|
||||
next.set(activeChat, serverMsgs)
|
||||
return next
|
||||
})
|
||||
setConversations((prev) => {
|
||||
const updated = [...prev]
|
||||
const convIdx = updated.findIndex((c) => c.id === activeChat)
|
||||
if (convIdx >= 0 && serverMsgs.length > 0) {
|
||||
const last = serverMsgs[serverMsgs.length - 1]
|
||||
updated[convIdx] = { ...updated[convIdx], lastMessage: last.content, lastMessageTime: last.timestamp }
|
||||
}
|
||||
return updated
|
||||
})
|
||||
} catch { /* polling silently */ }
|
||||
}, 4000)
|
||||
return () => { if (pollTimerRef.current) clearInterval(pollTimerRef.current) }
|
||||
}, [activeChat])
|
||||
|
||||
// Connect to signaling server for real-time message deletion events
|
||||
useEffect(() => {
|
||||
let socket: Socket | null = null
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/jwt")
|
||||
if (!res.ok) return
|
||||
const { token } = await res.json()
|
||||
if (cancelled) return
|
||||
socket = io(process.env.NEXT_PUBLIC_SIGNALING_URL || "http://localhost:3007", {
|
||||
auth: { token },
|
||||
transports: ["websocket", "polling"],
|
||||
})
|
||||
socketRef.current = socket
|
||||
socket.on("message:deleted", ({ conversationId, messageId }: { conversationId: string; messageId: string }) => {
|
||||
setConversationMessages((prev) => {
|
||||
const existing = prev.get(conversationId)
|
||||
if (!existing) return prev
|
||||
const next = new Map(prev)
|
||||
next.set(conversationId, existing.filter((m) => m.id !== messageId))
|
||||
return next
|
||||
})
|
||||
})
|
||||
} catch { /* socket connection failed silently */ }
|
||||
})()
|
||||
return () => { cancelled = true; socket?.disconnect(); socketRef.current = null }
|
||||
}, [])
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setIsResizing(true)
|
||||
@@ -291,23 +443,32 @@ export default function ChatsPage() {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}, [conversations])
|
||||
|
||||
const handleEmojiSelect = (emoji: { native: string }) => {
|
||||
setMessageInput((prev) => prev + emoji.native)
|
||||
const handleEmojiSelect = (emoji: string) => {
|
||||
setMessageInput((prev) => prev + emoji)
|
||||
setShowEmojiPicker(false)
|
||||
setTimeout(() => autoResizeTextarea(), 0)
|
||||
}
|
||||
|
||||
const handleMediaSelect = (content: string) => {
|
||||
addMessageToChat(content)
|
||||
setShowEmojiPicker(false)
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files ?? [])
|
||||
const oversized = files.filter((f) => f.size > MAX_FILE_SIZE)
|
||||
const raw = Array.from(e.target.files ?? [])
|
||||
const oversized = raw.filter((f) => f.size > MAX_FILE_SIZE)
|
||||
if (oversized.length > 0) {
|
||||
toast.error(`${oversized[0].name} exceeds the 10MB file size limit`)
|
||||
if (e.target) e.target.value = ""
|
||||
return
|
||||
}
|
||||
setAttachments((prev) => [...prev, ...files])
|
||||
const { allowed, rejected } = filterBlockedFiles(raw)
|
||||
if (rejected.length > 0) {
|
||||
toast.error(`Blocked file type: ${rejected[0].name}`)
|
||||
}
|
||||
setAttachments((prev) => [...prev, ...allowed])
|
||||
if (e.target) e.target.value = ""
|
||||
}
|
||||
|
||||
@@ -317,7 +478,11 @@ export default function ChatsPage() {
|
||||
|
||||
const addMessageToChat = async (content: string, voice?: { url: string; duration: number }, replyTo?: { senderName: string; content: string }, fileAttachments?: { name: string; type: string; url: string }[]) => {
|
||||
if (!activeChat || !user) return
|
||||
const payload = voice ? "[Voice message]" : content
|
||||
const payload = voice
|
||||
? "[Voice message]"
|
||||
: fileAttachments && fileAttachments.length > 0
|
||||
? JSON.stringify({ text: content, fileAttachments })
|
||||
: content
|
||||
try {
|
||||
const res = await fetch(`/api/conversations/${activeChat}/messages`, {
|
||||
method: "POST",
|
||||
@@ -336,7 +501,7 @@ export default function ChatsPage() {
|
||||
setConversations((prev) => {
|
||||
const updated = prev.map((conv) =>
|
||||
conv.id === activeChat
|
||||
? { ...conv, lastMessage: content, lastMessageTime: "Just now" }
|
||||
? { ...conv, lastMessage: getDisplayContent(content), lastMessageTime: "Just now" }
|
||||
: conv
|
||||
)
|
||||
const idx = updated.findIndex((c) => c.id === activeChat)
|
||||
@@ -378,73 +543,114 @@ export default function ChatsPage() {
|
||||
const handleSend = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!messageInput.trim() && attachments.length === 0) return
|
||||
if (editingMessage) {
|
||||
if (isSendingRef.current) return
|
||||
// Check blocked files before sending
|
||||
if (attachments.some((f) => hasBlockedCodeExtension(f.name))) {
|
||||
toast.error("Remove blocked file types before sending")
|
||||
return
|
||||
}
|
||||
isSendingRef.current = true
|
||||
try {
|
||||
if (editingMessage) {
|
||||
setConversationMessages((prev) => {
|
||||
const next = new Map(prev)
|
||||
const msgs = (next.get(activeChat || "") || []).map((m) =>
|
||||
m.id === editingMessage.id ? { ...m, content: messageInput.trim() } : m
|
||||
)
|
||||
next.set(activeChat || "", msgs)
|
||||
return next
|
||||
})
|
||||
setEditingMessage(null)
|
||||
setMessageInput("")
|
||||
setTimeout(() => autoResizeTextarea(), 0)
|
||||
return
|
||||
}
|
||||
const fileAttachments = attachments.length > 0
|
||||
? await Promise.all(attachments.map(async (f) => {
|
||||
const url = await fileToDataURL(f)
|
||||
return { name: f.name, type: f.type, url }
|
||||
}))
|
||||
: undefined
|
||||
if (replyingTo) {
|
||||
const replySender = replyingTo.senderId === user.id ? user.name : otherParticipant(conversation!).name
|
||||
await addMessageToChat(messageInput.trim(), undefined, { senderName: replySender, content: replyingTo.content }, fileAttachments)
|
||||
setReplyingTo(null)
|
||||
} else {
|
||||
await addMessageToChat(messageInput.trim(), undefined, undefined, fileAttachments)
|
||||
}
|
||||
setMessageInput("")
|
||||
setTimeout(() => autoResizeTextarea(), 0)
|
||||
setAttachments([])
|
||||
} finally {
|
||||
isSendingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteMessage = async (messageId: string) => {
|
||||
if (!activeChat || !user) return
|
||||
if (isDeletingRef.current) return
|
||||
isDeletingRef.current = true
|
||||
|
||||
const prevMessages = conversationMessages.get(activeChat) || []
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/conversations/${activeChat}/messages/${messageId}`, { method: "DELETE" })
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error(err.error || "Delete failed")
|
||||
}
|
||||
|
||||
// Broadcast deletion to other participants via Socket.io
|
||||
const socket = socketRef.current
|
||||
if (socket?.connected) {
|
||||
socket.emit("message:deleted", { conversationId: activeChat, messageId, senderId: user.id })
|
||||
}
|
||||
|
||||
// Revoke blob URLs for attachments and voice
|
||||
const files = messageAttachments.get(messageId)
|
||||
if (files) files.forEach((f) => { URL.revokeObjectURL(f.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== f.url) })
|
||||
const voice = voiceMessages.get(messageId)
|
||||
if (voice) { URL.revokeObjectURL(voice.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== voice.url) }
|
||||
|
||||
// Remove from local state
|
||||
setConversationMessages((prev) => {
|
||||
const next = new Map(prev)
|
||||
const msgs = (next.get(activeChat || "") || []).map((m) =>
|
||||
m.id === editingMessage.id ? { ...m, content: messageInput.trim() } : m
|
||||
)
|
||||
const msgs = (next.get(activeChat || "") || []).filter((m) => m.id !== messageId)
|
||||
next.set(activeChat || "", msgs)
|
||||
return next
|
||||
})
|
||||
setEditingMessage(null)
|
||||
setMessageInput("")
|
||||
setTimeout(() => autoResizeTextarea(), 0)
|
||||
return
|
||||
}
|
||||
const fileAttachments = attachments.length > 0
|
||||
? attachments.map((f) => {
|
||||
const url = URL.createObjectURL(f)
|
||||
blobUrlsRef.current.push(url)
|
||||
return { name: f.name, type: f.type, url }
|
||||
})
|
||||
: undefined
|
||||
if (replyingTo) {
|
||||
const replySender = replyingTo.senderId === user.id ? user.name : otherParticipant(conversation!).name
|
||||
await addMessageToChat(messageInput.trim(), undefined, { senderName: replySender, content: replyingTo.content }, fileAttachments)
|
||||
setReplyingTo(null)
|
||||
} else {
|
||||
await addMessageToChat(messageInput.trim(), undefined, undefined, fileAttachments)
|
||||
}
|
||||
setMessageInput("")
|
||||
setTimeout(() => autoResizeTextarea(), 0)
|
||||
setAttachments([])
|
||||
}
|
||||
|
||||
const handleDeleteMessage = (messageId: string) => {
|
||||
const files = messageAttachments.get(messageId)
|
||||
if (files) files.forEach((f) => { URL.revokeObjectURL(f.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== f.url) })
|
||||
const voice = voiceMessages.get(messageId)
|
||||
if (voice) { URL.revokeObjectURL(voice.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== voice.url) }
|
||||
setConversationMessages((prev) => {
|
||||
const next = new Map(prev)
|
||||
const msgs = (next.get(activeChat || "") || []).filter((m) => m.id !== messageId)
|
||||
next.set(activeChat || "", msgs)
|
||||
return next
|
||||
})
|
||||
setConversations((prev) =>
|
||||
prev.map((conv) =>
|
||||
conv.id === activeChat
|
||||
? { ...conv, lastMessage: conversationMessages.get(activeChat || "")?.filter((m) => m.id !== messageId).at(-1)?.content ?? conv.lastMessage }
|
||||
: conv
|
||||
setConversations((prev) =>
|
||||
prev.map((conv) =>
|
||||
conv.id === activeChat
|
||||
? { ...conv, lastMessage: getDisplayContent(prevMessages.filter((m) => m.id !== messageId).at(-1)?.content ?? "") }
|
||||
: conv
|
||||
)
|
||||
)
|
||||
)
|
||||
toast.success("Message deleted")
|
||||
toast.success("Message deleted")
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || "Unable to delete voice note. Please try again.")
|
||||
} finally {
|
||||
isDeletingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
const recorder = new MediaRecorder(stream, { mimeType: "audio/webm" })
|
||||
const mime = MediaRecorder.isTypeSupported("audio/webm;codecs=opus") ? "audio/webm;codecs=opus" : "audio/webm"
|
||||
const recorder = new MediaRecorder(stream, { mimeType: mime })
|
||||
recordingChunksRef.current = []
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) recordingChunksRef.current.push(e.data)
|
||||
}
|
||||
recorder.onstop = () => {
|
||||
const blob = new Blob(recordingChunksRef.current, { type: "audio/webm" })
|
||||
if (recordingChunksRef.current.length === 0) return
|
||||
const blob = new Blob(recordingChunksRef.current, { type: mime })
|
||||
const url = URL.createObjectURL(blob)
|
||||
blobUrlsRef.current.push(url)
|
||||
addMessageToChat("", { url, duration: recordingDurationRef.current })
|
||||
setPreviewBlob(blob)
|
||||
setPreviewUrl(url)
|
||||
setPreviewDuration(recordingDurationRef.current)
|
||||
stream.getTracks().forEach((t) => t.stop())
|
||||
setRecordingDuration(0)
|
||||
}
|
||||
@@ -452,9 +658,12 @@ export default function ChatsPage() {
|
||||
recorder.start()
|
||||
setIsRecording(true)
|
||||
setIsPaused(false)
|
||||
} catch {
|
||||
console.warn("Failed to start recording in chats page")
|
||||
toast.error("Microphone access denied")
|
||||
} catch (err: any) {
|
||||
if (err?.name === "NotAllowedError" || err?.name === "PermissionDeniedError") {
|
||||
toast.error("Microphone access is required to record voice notes.")
|
||||
} else {
|
||||
toast.error("Failed to start recording. Please check your microphone.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,6 +697,60 @@ export default function ChatsPage() {
|
||||
setRecordingDuration(0)
|
||||
recordingDurationRef.current = 0
|
||||
recordingChunksRef.current = []
|
||||
clearPreview()
|
||||
}
|
||||
|
||||
const clearPreview = () => {
|
||||
if (previewUrl) { URL.revokeObjectURL(previewUrl); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== previewUrl) }
|
||||
setPreviewBlob(null)
|
||||
setPreviewUrl(null)
|
||||
setPreviewDuration(0)
|
||||
setIsPreviewPlaying(false)
|
||||
setPreviewCurrent(0)
|
||||
}
|
||||
|
||||
const cancelVoicePreview = () => {
|
||||
if (previewAudioRef.current) { previewAudioRef.current.pause(); previewAudioRef.current = null! }
|
||||
cancelAnimationFrame(previewAnimRef.current)
|
||||
clearPreview()
|
||||
}
|
||||
|
||||
const togglePreviewPlay = () => {
|
||||
if (!previewUrl) return
|
||||
if (isPreviewPlaying) {
|
||||
previewAudioRef.current?.pause()
|
||||
setIsPreviewPlaying(false)
|
||||
} else {
|
||||
const audio = new Audio(previewUrl)
|
||||
audio.onloadedmetadata = () => { if (isFinite(audio.duration)) setPreviewDuration(audio.duration) }
|
||||
audio.onended = () => { setIsPreviewPlaying(false); setPreviewCurrent(0) }
|
||||
audio.onerror = () => { toast.error("Failed to play preview"); setIsPreviewPlaying(false) }
|
||||
previewAudioRef.current = audio
|
||||
audio.play().then(() => setIsPreviewPlaying(true)).catch(() => toast.error("Failed to play preview"))
|
||||
const tick = () => { if (previewAudioRef.current) setPreviewCurrent(previewAudioRef.current.currentTime); previewAnimRef.current = requestAnimationFrame(tick) }
|
||||
cancelAnimationFrame(previewAnimRef.current)
|
||||
previewAnimRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
}
|
||||
|
||||
const sendVoiceRecording = async () => {
|
||||
if (!previewBlob || !previewUrl) return
|
||||
try {
|
||||
cancelAnimationFrame(previewAnimRef.current)
|
||||
previewAudioRef.current?.pause()
|
||||
const formData = new FormData()
|
||||
const ext = previewBlob.type.includes("webm") ? "webm" : "webm"
|
||||
formData.append("audio", previewBlob, `voice.${ext}`)
|
||||
formData.append("duration", previewDuration.toString())
|
||||
const uploadRes = await fetch("/api/upload/voice", { method: "POST", body: formData })
|
||||
if (!uploadRes.ok) { toast.error("Failed to upload voice recording"); return }
|
||||
const { url: serverUrl, duration } = await uploadRes.json()
|
||||
const voicePayload = JSON.stringify({ v: true, u: serverUrl, d: duration || previewDuration })
|
||||
await addMessageToChat(voicePayload, { url: serverUrl, duration: duration || previewDuration })
|
||||
clearPreview()
|
||||
} catch {
|
||||
toast.error("Failed to send voice recording")
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -515,6 +778,21 @@ export default function ChatsPage() {
|
||||
|
||||
const isImageFile = (file: File) => file.type.startsWith("image/")
|
||||
|
||||
const getDisplayContent = (content: string) => {
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
if (parsed.text !== undefined) return parsed.text
|
||||
} catch {}
|
||||
return content
|
||||
}
|
||||
|
||||
const fileToDataURL = (file: File): Promise<string> => new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-8rem)] -m-4 lg:-m-6 rounded-lg border bg-card overflow-hidden">
|
||||
{/* Conversations list - left panel */}
|
||||
@@ -564,7 +842,7 @@ export default function ChatsPage() {
|
||||
<span className="text-sm font-medium truncate">{person.name}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">{conv.lastMessageTime}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">{conv.lastMessage}</p>
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">{formatPreviewContent(conv.lastMessage)}</p>
|
||||
</div>
|
||||
{!isActive && (unreadMap.get(conv.id) || 0) > 0 && (
|
||||
<div className="shrink-0 h-3 w-3 rounded-full bg-red-500 animate-pulse shadow-[0_0_10px_#ef4444] self-center" />
|
||||
@@ -644,9 +922,15 @@ export default function ChatsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setIsCallModalOpen(true)}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Voice calling coming soon")}>
|
||||
<Phone className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Video calling coming soon")}>
|
||||
<Video className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setScheduleDialogOpen(true)}>
|
||||
<CalendarDays className="h-4 w-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
@@ -676,7 +960,17 @@ export default function ChatsPage() {
|
||||
<div className="space-y-4 min-h-0">
|
||||
{messages.map((msg) => {
|
||||
const isMe = msg.senderId === user?.id
|
||||
const voiceUrl = voiceMessages.get(msg.id)
|
||||
let voiceUrl = voiceMessages.get(msg.id)
|
||||
let gifData: { url: string; w: number; h: number } | null = null
|
||||
let stickerData: { url: string; w: number; h: number; emoji?: string } | null = null
|
||||
if (msg.content?.startsWith("{")) {
|
||||
try {
|
||||
const p = JSON.parse(msg.content)
|
||||
if (p.v && p.u) voiceUrl = { url: p.u, duration: p.d || 0 }
|
||||
else if (p.gif) gifData = { url: p.gif, w: p.w || 320, h: p.h || 240 }
|
||||
else if (p.sticker) stickerData = { url: p.sticker, w: p.w || 200, h: p.h || 200, emoji: p.id ? undefined : p.sticker }
|
||||
} catch {}
|
||||
}
|
||||
return (
|
||||
<div key={msg.id} className={cn("group flex gap-3", isMe && "flex-row-reverse")}>
|
||||
<Avatar className="h-8 w-8 mt-0.5 shrink-0 cursor-pointer" onClick={() => setPreviewAvatarUrl(msg.senderAvatar)}>
|
||||
@@ -743,13 +1037,22 @@ export default function ChatsPage() {
|
||||
<p className="text-[11px] text-white/60 truncate">{replyInfo.repliedToContent}</p>
|
||||
</div>
|
||||
)}
|
||||
{voiceUrl ? (
|
||||
<div>
|
||||
<VoiceMessagePlayer src={voiceUrl.url} initialDuration={voiceUrl.duration} />
|
||||
{msg.content && (
|
||||
<p className="mt-1 text-sm text-black">{msg.content}</p>
|
||||
{gifData ? (
|
||||
<div className="max-w-[280px]">
|
||||
<img src={gifData.url} alt="GIF" className="w-full rounded-lg" style={{ aspectRatio: `${gifData.w}/${gifData.h}` }} loading="lazy" />
|
||||
</div>
|
||||
) : stickerData ? (
|
||||
<div className="max-w-[200px]">
|
||||
{stickerData.emoji ? (
|
||||
<span className="text-7xl block text-center">{stickerData.emoji}</span>
|
||||
) : (
|
||||
<div className="w-full" dangerouslySetInnerHTML={{ __html: stickerData.url.replace(/<svg /, '<svg style="width:100%;height:auto;max-height:200px" ') }} />
|
||||
)}
|
||||
</div>
|
||||
) : voiceUrl ? (
|
||||
<div>
|
||||
<VoiceMessagePlayer src={voiceUrl.url} initialDuration={voiceUrl.duration} isOwn={isMe} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{(() => {
|
||||
@@ -759,26 +1062,30 @@ export default function ChatsPage() {
|
||||
{files.map((file, i) =>
|
||||
file.type.startsWith("image/") ? (
|
||||
<div key={i} className="relative group">
|
||||
<img src={file.url} alt={file.name} className="max-w-full rounded-lg max-h-60 object-cover cursor-pointer" onClick={() => window.open(file.url, "_blank")} />
|
||||
<img src={file.url} alt={file.name} className="max-w-full rounded-lg max-h-60 object-cover cursor-pointer" onClick={() => setPreviewImageUrl(file.url)} />
|
||||
<a href={file.url} download={file.name} className="absolute top-2 right-2 h-7 w-7 rounded-full bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Download className="h-3.5 w-3.5 text-white" />
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<a key={i} href={file.url} download={file.name} className="flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2 text-sm hover:bg-muted/80 transition-colors">
|
||||
<FileIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 min-w-0 truncate">{file.name}</span>
|
||||
<Download className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</a>
|
||||
<div key={i} className="flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2 text-sm hover:bg-muted/80 transition-colors">
|
||||
<button type="button" className="flex items-center gap-2 flex-1 min-w-0 text-left" onClick={() => window.open(file.url, "_blank")} title="Open">
|
||||
<FileIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 min-w-0 truncate">{file.name}</span>
|
||||
</button>
|
||||
<a href={file.url} download={file.name} className="shrink-0 text-muted-foreground hover:text-foreground" title="Download">
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : null
|
||||
})()}
|
||||
{isOnlyEmoji(msg.content) ? (
|
||||
<span className="text-4xl leading-none">{msg.content.trim()}</span>
|
||||
{isOnlyEmoji(getDisplayContent(msg.content)) ? (
|
||||
<span className="text-4xl leading-none">{getDisplayContent(msg.content).trim()}</span>
|
||||
) : (
|
||||
msg.content
|
||||
getDisplayContent(msg.content)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -847,9 +1154,27 @@ export default function ChatsPage() {
|
||||
)}
|
||||
<form onSubmit={handleSend} className="flex items-center gap-2">
|
||||
<input ref={fileInputRef} type="file" multiple accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt" className="hidden" onChange={handleFileSelect} />
|
||||
<Button type="button" variant="ghost" size="icon" className="h-9 w-9 shrink-0" onClick={() => fileInputRef.current?.click()}>
|
||||
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
<div className="relative">
|
||||
<Button type="button" variant="ghost" size="icon" className="h-9 w-9 shrink-0" onClick={() => setShowAttachMenu(!showAttachMenu)}>
|
||||
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
{showAttachMenu && (
|
||||
<div ref={attachMenuRef} className="absolute bottom-full left-0 mb-2 z-50 bg-popover border rounded-lg shadow-lg p-1.5 min-w-[140px]">
|
||||
<button type="button" className="flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded-md hover:bg-muted" onClick={() => { fileInputRef.current?.setAttribute("accept", "image/*"); fileInputRef.current?.click(); setShowAttachMenu(false) }}>
|
||||
<Image className="h-4 w-4" /> Photos
|
||||
</button>
|
||||
<button type="button" className="flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded-md hover:bg-muted" onClick={() => { fileInputRef.current?.setAttribute("accept", ".pdf,.doc,.docx,.xls,.xlsx,.txt"); fileInputRef.current?.click(); setShowAttachMenu(false) }}>
|
||||
<FileIcon className="h-4 w-4" /> Documents
|
||||
</button>
|
||||
<button type="button" className="flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded-md hover:bg-muted" onClick={() => { fileInputRef.current?.removeAttribute("accept"); fileInputRef.current?.click(); setShowAttachMenu(false) }}>
|
||||
<FolderOpen className="h-4 w-4" /> Files
|
||||
</button>
|
||||
<button type="button" className="flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded-md hover:bg-muted" onClick={() => { fileInputRef.current?.setAttribute("accept", ".eml,.msg"); fileInputRef.current?.click(); setShowAttachMenu(false) }}>
|
||||
<Mail className="h-4 w-4" /> Emails
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative flex-1">
|
||||
{isRecording ? (
|
||||
<div className="flex h-9 items-center gap-1 rounded-md border bg-muted/30 px-2">
|
||||
@@ -866,9 +1191,25 @@ export default function ChatsPage() {
|
||||
<Square className="h-3.5 w-3.5 fill-current" />
|
||||
</Button>
|
||||
</div>
|
||||
) : previewUrl ? (
|
||||
<div className="flex h-9 items-center gap-2 rounded-md border bg-muted/30 px-3">
|
||||
<button type="button" onClick={togglePreviewPlay} className="h-6 w-6 shrink-0 rounded-full flex items-center justify-center hover:bg-black/10 transition-colors">
|
||||
{isPreviewPlaying ? <Pause className="h-3.5 w-3.5 fill-current" /> : <Play className="h-3.5 w-3.5 ml-0.5 fill-current" />}
|
||||
</button>
|
||||
<div className="flex-1 h-1 rounded-full bg-muted-foreground/20 relative overflow-hidden">
|
||||
<div className="h-full rounded-full bg-primary transition-all duration-100" style={{ width: `${previewDuration > 0 ? (previewCurrent / previewDuration) * 100 : 0}%` }} />
|
||||
</div>
|
||||
<span className="text-xs tabular-nums text-muted-foreground w-8 text-right">{formatDuration(Math.floor(previewCurrent))}/{formatDuration(previewDuration)}</span>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-6 w-6 shrink-0 text-destructive hover:text-destructive" onClick={cancelVoicePreview}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-6 w-6 shrink-0 text-primary hover:text-primary" onClick={sendVoiceRecording}>
|
||||
<Send className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<textarea ref={textareaRef} value={messageInput} onChange={(e) => { setMessageInput(e.target.value); autoResizeTextarea() }} onPaste={(e) => { if (!e.clipboardData) return; const items = Array.from(e.clipboardData.items).filter((item) => item.type.startsWith("image/")); if (items.length > 0) { e.preventDefault(); const files = items.map((item) => item.getAsFile()).filter((f): f is File => f !== null); setAttachments((prev) => [...prev, ...files]) } }} placeholder={editingMessage ? "Edit message..." : "Type a message..."} className="w-full resize-none overflow-hidden rounded-md border border-input bg-transparent px-3 py-1.5 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 min-h-[36px] max-h-[200px] pr-16" style={{ height: "auto" }} rows={1} />
|
||||
<textarea ref={textareaRef} value={messageInput} onChange={(e) => { setMessageInput(e.target.value); autoResizeTextarea() }} onPaste={(e) => { if (!e.clipboardData) return; const items = Array.from(e.clipboardData.items).filter((item) => item.type.startsWith("image/")); if (items.length > 0) { e.preventDefault(); const files = items.map((item) => item.getAsFile()).filter((f): f is File => f !== null); setAttachments((prev) => [...prev, ...filterBlockedFiles(files).allowed]) } }} placeholder={editingMessage ? "Edit message..." : "Type a message..."} className="w-full resize-none overflow-hidden rounded-md border border-input bg-transparent px-3 py-1.5 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 min-h-[36px] max-h-[200px] pr-16" style={{ height: "auto" }} rows={1} />
|
||||
<div className="absolute right-0 top-0 bottom-0 flex items-center pr-1" ref={emojiPickerRef}>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setShowEmojiPicker(!showEmojiPicker)}>
|
||||
<Smile className="h-4 w-4 text-muted-foreground" />
|
||||
@@ -878,14 +1219,14 @@ export default function ChatsPage() {
|
||||
</Button>
|
||||
{showEmojiPicker && (
|
||||
<div className="absolute bottom-full right-0 mb-2 z-50">
|
||||
<Picker data={data} onEmojiSelect={handleEmojiSelect} theme={theme === "dark" ? "dark" : "light"} previewPosition="none" skinTonePosition="none" set="native" emojiSize={36} maxFrequentRows={2} />
|
||||
<MediaPicker onEmojiSelect={handleEmojiSelect} onMediaSelect={handleMediaSelect} onClose={() => setShowEmojiPicker(false)} theme={theme} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" size="icon" className="h-9 w-9 shrink-0" disabled={!messageInput.trim() && attachments.length === 0}>
|
||||
<Button type="submit" size="icon" className="h-9 w-9 shrink-0" disabled={(!messageInput.trim() && attachments.length === 0) || attachments.some(f => hasBlockedCodeExtension(f.name))}>
|
||||
{editingMessage ? <Pencil className="h-4 w-4" /> : <Send className="h-4 w-4" />}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -918,6 +1259,20 @@ export default function ChatsPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Image preview dialog */}
|
||||
<Dialog open={!!previewImageUrl} onOpenChange={(o) => { if (!o) setPreviewImageUrl(null) }}>
|
||||
<DialogContent className="sm:max-w-3xl p-0 overflow-hidden bg-transparent border-0 shadow-none">
|
||||
<DialogTitle className="sr-only">Image preview</DialogTitle>
|
||||
{previewImageUrl && (
|
||||
<img
|
||||
src={previewImageUrl}
|
||||
alt="Image preview"
|
||||
className="w-full h-auto max-h-[85vh] object-contain rounded-2xl"
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Avatar preview dialog */}
|
||||
<Dialog open={!!previewAvatarUrl} onOpenChange={(o) => { if (!o) setPreviewAvatarUrl(null) }}>
|
||||
<DialogContent className="sm:max-w-sm p-0 overflow-hidden bg-transparent border-0 shadow-none">
|
||||
@@ -994,8 +1349,104 @@ export default function ChatsPage() {
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<<<<<<< Updated upstream
|
||||
=======
|
||||
|
||||
<VoiceCallModal open={isCallModalOpen} onClose={() => setIsCallModalOpen(false)} />
|
||||
{/* Schedule from Chat Dialog */}
|
||||
<Dialog open={scheduleDialogOpen} onOpenChange={setScheduleDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[440px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Schedule with {conversation ? otherParticipant(conversation).name : "..."}</DialogTitle>
|
||||
<DialogDescription>Create an event and notify the participant</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="sched-title">Title</Label>
|
||||
<Input id="sched-title" value={scheduleTitle} onChange={(e) => setScheduleTitle(e.target.value)} placeholder="e.g. Follow-up call" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="sched-date">Date</Label>
|
||||
<Input id="sched-date" type="date" value={scheduleDate} onChange={(e) => setScheduleDate(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="sched-time">Time</Label>
|
||||
<Input id="sched-time" type="time" value={scheduleTime} onChange={(e) => setScheduleTime(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="sched-type">Type</Label>
|
||||
<Select value={scheduleType} onValueChange={setScheduleType}>
|
||||
<SelectTrigger id="sched-type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="meeting">Meeting</SelectItem>
|
||||
<SelectItem value="call">Call</SelectItem>
|
||||
<SelectItem value="follow_up">Follow Up</SelectItem>
|
||||
<SelectItem value="demo">Demo</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="sched-duration">Duration</Label>
|
||||
<Select value={scheduleDuration} onValueChange={setScheduleDuration}>
|
||||
<SelectTrigger id="sched-duration">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="15">15 min</SelectItem>
|
||||
<SelectItem value="30">30 min</SelectItem>
|
||||
<SelectItem value="60">1 hour</SelectItem>
|
||||
<SelectItem value="90">1.5 hours</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setScheduleDialogOpen(false)}>Cancel</Button>
|
||||
<Button disabled={scheduleSaving || !scheduleTitle || !scheduleDate || !scheduleTime} onClick={async () => {
|
||||
if (!conversation || !user) return
|
||||
setScheduleSaving(true)
|
||||
try {
|
||||
const startTime = new Date(`${scheduleDate}T${scheduleTime}:00`).toISOString()
|
||||
const end = new Date(startTime)
|
||||
end.setMinutes(end.getMinutes() + parseInt(scheduleDuration))
|
||||
const res = await fetch("/api/events", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: scheduleTitle,
|
||||
eventType: scheduleType,
|
||||
startTime,
|
||||
endTime: end.toISOString(),
|
||||
durationMinutes: parseInt(scheduleDuration),
|
||||
participantId: otherParticipant(conversation).id,
|
||||
conversationId: conversation.id,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed")
|
||||
toast.success("Event scheduled! Notification sent.")
|
||||
setScheduleDialogOpen(false)
|
||||
setScheduleTitle("")
|
||||
setScheduleDate("")
|
||||
setScheduleTime("")
|
||||
} catch {
|
||||
toast.error("Failed to schedule event")
|
||||
} finally {
|
||||
setScheduleSaving(false)
|
||||
}
|
||||
}}>
|
||||
{scheduleSaving && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||
Schedule
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
>>>>>>> Stashed changes
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,8 +24,12 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { DashboardStats } from "@/types"
|
||||
import { CrossedLightsabers } from "@/components/dashboard/crossed-lightsabers"
|
||||
import { StarField } from "@/components/dashboard/star-field"
|
||||
import { useWebsiteTheme } from "@/providers/website-theme-provider"
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { websiteTheme: theme } = useWebsiteTheme()
|
||||
const [period, setPeriod] = useState("6months")
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const pollingRef = useRef<NodeJS.Timeout | null>(null)
|
||||
@@ -63,47 +67,63 @@ export default function DashboardPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6 relative">
|
||||
{theme === "starforce" && <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"
|
||||
>
|
||||
{theme === "starforce" && (
|
||||
<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>
|
||||
)}
|
||||
{theme === "starforce" && <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>
|
||||
{/* Daily Bugle watermark */}
|
||||
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.04] dark:opacity-[0.06]">
|
||||
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#e62020] dark:text-[#FF6666] tracking-wider">
|
||||
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#CC0000] dark:text-[#FF1111] tracking-wider">
|
||||
DAILY BUGLE
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<PageHeader title={<span style={{fontFamily:"'Bangers',cursive"}}>Dashboard</span>} description="Overview of your sales pipeline">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useUser } from "@/providers/user-provider"
|
||||
import { Mail, Clock } from "lucide-react"
|
||||
|
||||
export default function EmailsPage() {
|
||||
const router = useRouter()
|
||||
const { user } = useUser()
|
||||
const [emails, setEmails] = useState<any[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) { router.push("/login"); return }
|
||||
if (user.role !== "admin" && user.role !== "super_admin") { router.push("/dashboard"); return }
|
||||
fetch("/api/emails").then((r) => r.ok ? r.json() : { emails: [] }).then((d) => setEmails(d.emails || []))
|
||||
}, [user, router])
|
||||
|
||||
if (!user || (user.role !== "admin" && user.role !== "super_admin")) return null
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-xl font-bold mb-4">Email Log</h1>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
Emails are stored locally. To actually deliver them, add <code className="bg-muted px-1 rounded">EMAIL_API_KEY</code> to your .env.
|
||||
</p>
|
||||
{emails.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No emails sent yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{emails.map((e: any) => {
|
||||
const withMatch = e.bodyText?.match(/With:\s*(.+)/)
|
||||
const withName = withMatch ? withMatch[1].trim() : null
|
||||
return (
|
||||
<div key={e.id} className="flex items-center gap-3 p-3 rounded-lg border bg-card">
|
||||
<Mail className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{e.subject}</p>
|
||||
<p className="text-xs text-muted-foreground">To: {e.recipient}</p>
|
||||
{withName && <p className="text-xs text-muted-foreground/70 mt-0.5">With: {withName}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
|
||||
<Clock className="h-3 w-3" />
|
||||
{new Date(e.createdAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { chatWithAI } from "@/lib/ai"
|
||||
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 (!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()
|
||||
@@ -14,16 +16,15 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Message is required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const sessionCookie = request.cookies.get("session")
|
||||
const jwtToken = sessionCookie?.value
|
||||
if (!jwtToken) {
|
||||
return NextResponse.json({ error: "No session token" }, { status: 401 })
|
||||
}
|
||||
// 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)
|
||||
|
||||
const response = await chatWithAI(message, jwtToken)
|
||||
return NextResponse.json({ response })
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error("AI chat error:", error)
|
||||
return NextResponse.json({ error: error.message || "AI service error" }, { status: 500 })
|
||||
return NextResponse.json({ error: "AI service unavailable" }, { status: 503 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
resetFailedAttempts,
|
||||
isAccountLocked,
|
||||
createSession,
|
||||
setSessionContext,
|
||||
} from "@/lib/auth"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -107,7 +106,6 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
|
||||
await createSession(dbUser.id, dbUser.role_name)
|
||||
await setSessionContext(dbUser.id, ipAddress)
|
||||
|
||||
const user = mapDbUserToSessionUser(dbUser)
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
import { unlink } from "node:fs/promises"
|
||||
import { join } from "node:path"
|
||||
import { existsSync } from "node:fs"
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; messageId: string }> },
|
||||
) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { id: conversationId, messageId } = await params
|
||||
|
||||
// Verify user is a participant in this conversation
|
||||
const partCheck = await query(
|
||||
`SELECT 1 FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2`,
|
||||
[conversationId, user.id],
|
||||
)
|
||||
if (partCheck.rows.length === 0) {
|
||||
return NextResponse.json({ error: "Not a participant" }, { status: 403 })
|
||||
}
|
||||
|
||||
// Fetch the message to verify it belongs to the sender and check if it's a voice message
|
||||
const msgResult = await query(
|
||||
`SELECT id, sender_id, content FROM messages WHERE id = $1 AND conversation_id = $2 AND deleted_at IS NULL`,
|
||||
[messageId, conversationId],
|
||||
)
|
||||
if (msgResult.rows.length === 0) {
|
||||
return NextResponse.json({ error: "Message not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
const message = msgResult.rows[0]
|
||||
|
||||
// Only the sender can delete their own message
|
||||
if (message.sender_id !== user.id) {
|
||||
return NextResponse.json({ error: "Cannot delete another user's message" }, { status: 403 })
|
||||
}
|
||||
|
||||
// Delete the associated audio file if this is a voice message
|
||||
let storageDeleted = false
|
||||
try {
|
||||
const content = message.content
|
||||
if (content?.startsWith("{")) {
|
||||
const parsed = JSON.parse(content)
|
||||
if (parsed.v && parsed.u) {
|
||||
const filename = parsed.u.replace(/^\/uploads\/voice\//, "")
|
||||
const filePath = join(process.cwd(), "public", "uploads", "voice", filename)
|
||||
if (existsSync(filePath)) {
|
||||
await unlink(filePath)
|
||||
storageDeleted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If file deletion fails, log but still proceed with DB deletion
|
||||
console.warn(`[voice-delete] Failed to delete audio file for message ${messageId}`)
|
||||
}
|
||||
|
||||
// Soft-delete the message in the database
|
||||
const deleteResult = await query(
|
||||
`UPDATE messages SET deleted_at = NOW() WHERE id = $1 AND conversation_id = $2 RETURNING id`,
|
||||
[messageId, conversationId],
|
||||
)
|
||||
|
||||
const dbDeleted = deleteResult.rows.length > 0
|
||||
|
||||
console.log(
|
||||
`[voice-delete] id=${messageId} conv=${conversationId} user=${user.id} db=${dbDeleted} storage=${storageDeleted}`,
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
messageId,
|
||||
dbDeleted,
|
||||
storageDeleted,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[voice-delete] Error:", error)
|
||||
return NextResponse.json({ error: "Unable to delete voice note. Please try again." }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
import { avatarSvgUrl } from "@/lib/avatar"
|
||||
import { hasBlockedCodeExtension } from "@/lib/blocked-extensions"
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
@@ -103,6 +104,18 @@ export async function POST(
|
||||
return NextResponse.json({ error: "Message content is required" }, { status: 400 })
|
||||
}
|
||||
|
||||
// Server-side blocked code extension check
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
if (parsed.fileAttachments && Array.isArray(parsed.fileAttachments)) {
|
||||
for (const f of parsed.fileAttachments) {
|
||||
if (f.name && hasBlockedCodeExtension(f.name)) {
|
||||
return NextResponse.json({ error: `Blocked file type: ${f.name}` }, { status: 400 })
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO messages (conversation_id, sender_id, content)
|
||||
VALUES ($1, $2, $3)
|
||||
@@ -160,3 +173,62 @@ function formatTime(date: Date): string {
|
||||
return date.toLocaleDateString([], { month: "short", day: "numeric" }) + " " +
|
||||
date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
|
||||
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 body = await _request.json()
|
||||
const messageId = body.messageId
|
||||
if (!messageId) return NextResponse.json({ error: "messageId required" }, { status: 400 })
|
||||
|
||||
const result = await query(
|
||||
`UPDATE messages SET deleted_at = NOW() WHERE id = $1 AND sender_id = $2 AND conversation_id = $3 RETURNING id`,
|
||||
[messageId, user.id, id],
|
||||
)
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json({ error: "Message not found or not yours" }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Delete message error:", error)
|
||||
return NextResponse.json({ error: "Failed to delete message" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
const messageId = body.messageId
|
||||
const newContent = body.content
|
||||
if (!messageId || !newContent?.trim()) {
|
||||
return NextResponse.json({ error: "messageId and content required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`UPDATE messages SET content = $1, updated_at = NOW() WHERE id = $2 AND sender_id = $3 AND conversation_id = $4 AND deleted_at IS NULL RETURNING id`,
|
||||
[newContent.trim(), messageId, user.id, id],
|
||||
)
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json({ error: "Message not found or not yours" }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Edit message error:", error)
|
||||
return NextResponse.json({ error: "Failed to edit message" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ export async function GET() {
|
||||
u.email AS other_user_email,
|
||||
u.phone AS other_user_phone,
|
||||
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 content FROM messages WHERE conversation_id = c.id AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1) AS last_message,
|
||||
(SELECT created_at FROM messages WHERE conversation_id = c.id AND deleted_at IS NULL 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
|
||||
@@ -40,7 +40,6 @@ export async function GET() {
|
||||
id: row.other_user_id,
|
||||
name: row.other_user_name,
|
||||
email: row.other_user_email,
|
||||
phone: row.other_user_phone || "",
|
||||
avatar: avatarSvgUrl(row.other_user_name),
|
||||
},
|
||||
lastMessage: row.last_message || "",
|
||||
@@ -92,7 +91,7 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
|
||||
const otherUser = await query(
|
||||
`SELECT id, first_name || ' ' || last_name AS name, email, phone, avatar_url
|
||||
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
|
||||
FROM users WHERE id = $1`,
|
||||
[userId],
|
||||
)
|
||||
@@ -107,7 +106,6 @@ export async function POST(request: NextRequest) {
|
||||
id: other.id,
|
||||
name: other.name,
|
||||
email: other.email,
|
||||
phone: other.phone || "",
|
||||
avatar: avatarSvgUrl(other.name),
|
||||
},
|
||||
lastMessage: "",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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 isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||
if (!isAdmin) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
|
||||
const result = await query(
|
||||
`SELECT id, recipient, subject, body_text, created_at FROM sent_emails ORDER BY created_at DESC LIMIT 50`,
|
||||
)
|
||||
|
||||
return NextResponse.json({ emails: result.rows.map((r: any) => ({
|
||||
id: r.id,
|
||||
recipient: r.recipient,
|
||||
subject: r.subject,
|
||||
bodyText: r.body_text,
|
||||
createdAt: r.created_at,
|
||||
})) })
|
||||
} catch (error) {
|
||||
console.error("Emails GET error:", error)
|
||||
return NextResponse.json({ error: "Failed to load sent emails" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { 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 u.id, u.first_name, u.last_name, u.email
|
||||
FROM users u
|
||||
WHERE u.deleted_at IS NULL AND u.id != $1
|
||||
ORDER BY u.first_name ASC`,
|
||||
[user.id],
|
||||
)
|
||||
|
||||
const users = result.rows.map((r: any) => ({
|
||||
id: r.id,
|
||||
name: `${r.first_name} ${r.last_name}`,
|
||||
email: r.email,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ users })
|
||||
} catch (error) {
|
||||
console.error("Event users error:", error)
|
||||
return NextResponse.json({ error: "Failed to load users" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
import { buildIcs } from "@/lib/ics"
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const result = await query(
|
||||
`SELECT e.id, e.user_id, e.participant_id, e.title, e.description, e.event_type,
|
||||
e.start_time, e.end_time, e.duration_minutes, e.status,
|
||||
u.email AS user_email, u.first_name AS user_first, u.last_name AS user_last,
|
||||
p.email AS p_email, p.first_name AS p_first, p.last_name AS p_last
|
||||
FROM scheduled_events e
|
||||
JOIN users u ON u.id = e.user_id
|
||||
LEFT JOIN users p ON p.id = e.participant_id
|
||||
WHERE e.id = $1 AND e.user_id = $2`,
|
||||
[id, user.id],
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json({ error: "Event not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
const r = result.rows[0]
|
||||
|
||||
const icsContent = buildIcs({
|
||||
uid: r.id,
|
||||
title: r.title,
|
||||
description: r.description || undefined,
|
||||
startTime: new Date(r.start_time),
|
||||
endTime: r.end_time ? new Date(r.end_time) : undefined,
|
||||
durationMinutes: r.duration_minutes || undefined,
|
||||
organizerName: `${r.user_first} ${r.user_last}`,
|
||||
organizerEmail: r.user_email,
|
||||
attendeeName: r.p_first ? `${r.p_first} ${r.p_last}` : undefined,
|
||||
attendeeEmail: r.p_email || undefined,
|
||||
})
|
||||
|
||||
return new NextResponse(icsContent, {
|
||||
headers: {
|
||||
"Content-Type": "text/calendar; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="event-${r.id}.ics"`,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("ICS GET error:", error)
|
||||
return NextResponse.json({ error: "Failed to generate calendar file" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
import { sendEventRescheduled } from "@/lib/email"
|
||||
|
||||
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 { title, description, eventType, startTime, endTime, durationMinutes, status, participantId, participantNotes } = await request.json()
|
||||
|
||||
const existing = await query(
|
||||
`SELECT e.user_id, e.participant_id, e.title, e.description, e.participant_notes, e.event_type,
|
||||
e.start_time, e.end_time, e.duration_minutes, e.status,
|
||||
u.email AS u_email, u.first_name AS u_first, u.last_name AS u_last,
|
||||
p.email AS p_email, p.first_name AS p_first, p.last_name AS p_last
|
||||
FROM scheduled_events e
|
||||
JOIN users u ON u.id = e.user_id
|
||||
LEFT JOIN users p ON p.id = e.participant_id
|
||||
WHERE e.id = $1`,
|
||||
[id],
|
||||
user.id,
|
||||
)
|
||||
|
||||
if (existing.rows.length === 0) {
|
||||
return NextResponse.json({ error: "Event not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
const old = existing.rows[0]
|
||||
const isCreator = old.user_id === user.id
|
||||
const isParticipant = old.participant_id === user.id
|
||||
|
||||
if (!isCreator && !isParticipant) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!isCreator && (
|
||||
(title !== undefined && title !== old.title) ||
|
||||
(eventType !== undefined && eventType !== old.event_type) ||
|
||||
(startTime !== undefined && startTime !== old.start_time) ||
|
||||
(endTime !== undefined && endTime !== old.end_time) ||
|
||||
(durationMinutes !== undefined && durationMinutes !== old.duration_minutes) ||
|
||||
(participantId !== undefined && participantId !== old.participant_id)
|
||||
)) {
|
||||
return NextResponse.json({ error: "Only the creator can edit event details" }, { status: 403 })
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`UPDATE scheduled_events SET
|
||||
title = COALESCE($1, title),
|
||||
description = COALESCE($2, description),
|
||||
participant_notes = COALESCE($3, participant_notes),
|
||||
event_type = COALESCE($4, event_type),
|
||||
start_time = COALESCE($5, start_time),
|
||||
end_time = COALESCE($6, end_time),
|
||||
duration_minutes = COALESCE($7, duration_minutes),
|
||||
status = COALESCE($8, status),
|
||||
participant_id = COALESCE($9, participant_id),
|
||||
updated_at = NOW()
|
||||
WHERE id = $10
|
||||
RETURNING id, user_id, participant_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, status, created_at`,
|
||||
[
|
||||
title || null,
|
||||
description ?? null,
|
||||
participantNotes ?? null,
|
||||
eventType || null,
|
||||
startTime || null,
|
||||
endTime ?? null,
|
||||
durationMinutes ?? null,
|
||||
status || null,
|
||||
participantId ?? null,
|
||||
id,
|
||||
],
|
||||
user.id,
|
||||
)
|
||||
|
||||
const r = result.rows[0]
|
||||
|
||||
const isChanged = r.start_time !== old.start_time || r.end_time !== old.end_time ||
|
||||
r.title !== old.title || r.event_type !== old.event_type ||
|
||||
r.status !== old.status
|
||||
|
||||
if (isChanged && r.participant_id && r.participant_id !== user.id) {
|
||||
sendEventRescheduled({
|
||||
creatorName: `${user.firstName} ${user.lastName}`,
|
||||
creatorEmail: user.email,
|
||||
participantName: old.p_first ? `${old.p_first} ${old.p_last}` : null,
|
||||
participantEmail: old.p_email || null,
|
||||
title: r.title,
|
||||
description: r.description || undefined,
|
||||
eventType: r.event_type,
|
||||
startTime: r.start_time,
|
||||
endTime: r.end_time || undefined,
|
||||
durationMinutes: r.duration_minutes || undefined,
|
||||
}).catch((err) => console.error("Reschedule email error:", err))
|
||||
}
|
||||
|
||||
const creatorResult = await query(
|
||||
`SELECT u.id, u.first_name, u.last_name, u.email, u.avatar_url,
|
||||
ur.role_id, r.name AS role_name, r.display_name AS role_display
|
||||
FROM users u
|
||||
LEFT JOIN user_roles ur ON ur.user_id = u.id
|
||||
LEFT JOIN roles r ON r.id = ur.role_id
|
||||
WHERE u.id = $1`,
|
||||
[old.user_id],
|
||||
)
|
||||
const creatorInfo = creatorResult.rows[0]
|
||||
const participantInfo = old.participant_id ? { id: old.participant_id, name: `${old.p_first} ${old.p_last}` || old.participant_id, email: old.p_email || null } : null
|
||||
|
||||
return NextResponse.json({
|
||||
event: {
|
||||
id: r.id,
|
||||
userId: r.user_id,
|
||||
participantId: r.participant_id,
|
||||
leadId: r.lead_id,
|
||||
conversationId: r.conversation_id,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
participantNotes: r.participant_notes,
|
||||
eventType: r.event_type,
|
||||
startTime: r.start_time,
|
||||
endTime: r.end_time,
|
||||
durationMinutes: r.duration_minutes,
|
||||
status: r.status,
|
||||
creator: creatorInfo ? { id: creatorInfo.id, name: `${creatorInfo.first_name} ${creatorInfo.last_name}`, email: creatorInfo.email, role: creatorInfo.role_display || creatorInfo.role_name, avatar: creatorInfo.avatar_url } : { id: old.user_id, name: "Unknown" },
|
||||
participant: participantInfo,
|
||||
lead: null,
|
||||
createdAt: r.created_at,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Events PATCH error:", error)
|
||||
return NextResponse.json({ error: "Failed to update event" }, { 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 existing = await query(
|
||||
`SELECT user_id FROM scheduled_events WHERE id = $1`,
|
||||
[id],
|
||||
user.id,
|
||||
)
|
||||
|
||||
if (existing.rows.length === 0) {
|
||||
return NextResponse.json({ error: "Event not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
if (existing.rows[0].user_id !== user.id) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
|
||||
await query(`DELETE FROM scheduled_events WHERE id = $1`, [id], user.id)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Events DELETE error:", error)
|
||||
return NextResponse.json({ error: "Failed to delete event" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
import { sendEventConfirmation } from "@/lib/email"
|
||||
|
||||
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 start = searchParams.get("start")
|
||||
const end = searchParams.get("end")
|
||||
const status = searchParams.get("status")
|
||||
|
||||
let sql = `SELECT e.id, e.user_id, e.participant_id, e.lead_id, e.conversation_id,
|
||||
e.title, e.description, e.participant_notes, e.event_type,
|
||||
e.start_time, e.end_time, e.duration_minutes, e.status, e.created_at,
|
||||
u.id AS u_id, u.first_name AS u_first, u.last_name AS u_last, u.email AS u_email, u.avatar_url AS u_avatar,
|
||||
ur.role_id AS u_role_id, r.name AS u_role_name, r.display_name AS u_role_display,
|
||||
p.id AS p_id, p.first_name AS p_first, p.last_name AS p_last, p.email AS p_email, p.avatar_url AS p_avatar,
|
||||
pr.role_id AS p_role_id, pr2.name AS p_role_name, pr2.display_name AS p_role_display,
|
||||
l.id AS l_id, l.company_name, l.contact_name
|
||||
FROM scheduled_events e
|
||||
JOIN users u ON u.id = e.user_id
|
||||
LEFT JOIN users p ON p.id = e.participant_id
|
||||
LEFT JOIN leads l ON l.id = e.lead_id
|
||||
LEFT JOIN user_roles ur ON ur.user_id = e.user_id
|
||||
LEFT JOIN roles r ON r.id = ur.role_id
|
||||
LEFT JOIN user_roles pr ON pr.user_id = e.participant_id
|
||||
LEFT JOIN roles pr2 ON pr2.id = pr.role_id`
|
||||
const params: unknown[] = []
|
||||
let idx = 1
|
||||
|
||||
if (user.role !== "super_admin") {
|
||||
sql += ` WHERE e.user_id = $${idx} OR e.participant_id = $${idx}`
|
||||
params.push(user.id)
|
||||
idx++
|
||||
} else {
|
||||
sql += ` WHERE 1=1`
|
||||
}
|
||||
|
||||
if (start) {
|
||||
sql += ` AND e.start_time >= $${idx}`
|
||||
params.push(start)
|
||||
idx++
|
||||
}
|
||||
|
||||
if (end) {
|
||||
sql += ` AND e.start_time <= $${idx}`
|
||||
params.push(end)
|
||||
idx++
|
||||
}
|
||||
|
||||
if (status) {
|
||||
sql += ` AND e.status = $${idx}`
|
||||
params.push(status)
|
||||
idx++
|
||||
}
|
||||
|
||||
sql += " ORDER BY e.start_time ASC"
|
||||
|
||||
const result = await query(sql, params, user.id)
|
||||
|
||||
const events = result.rows.map((r: any) => ({
|
||||
id: r.id,
|
||||
userId: r.user_id,
|
||||
participantId: r.participant_id,
|
||||
leadId: r.lead_id,
|
||||
conversationId: r.conversation_id,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
participantNotes: r.participant_notes,
|
||||
eventType: r.event_type,
|
||||
startTime: r.start_time,
|
||||
endTime: r.end_time,
|
||||
durationMinutes: r.duration_minutes,
|
||||
status: r.status,
|
||||
creator: { id: r.u_id, name: `${r.u_first} ${r.u_last}`, email: r.u_email, role: r.u_role_display || r.u_role_name, avatar: r.u_avatar },
|
||||
participant: r.p_id ? { id: r.p_id, name: `${r.p_first} ${r.p_last}`, email: r.p_email, role: r.p_role_display || r.p_role_name, avatar: r.p_avatar } : null,
|
||||
lead: r.l_id ? { id: r.l_id, companyName: r.company_name, contactName: r.contact_name } : null,
|
||||
createdAt: r.created_at,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ events })
|
||||
} catch (error) {
|
||||
console.error("Events GET error:", error)
|
||||
return NextResponse.json({ error: "Failed to load events" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const {
|
||||
participantId, leadId, conversationId,
|
||||
title, description, eventType,
|
||||
startTime, endTime, durationMinutes,
|
||||
} = await request.json()
|
||||
|
||||
if (!title || !startTime) {
|
||||
return NextResponse.json({ error: "Title and start time are required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO scheduled_events (user_id, participant_id, lead_id, conversation_id, title, description, event_type, start_time, end_time, duration_minutes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id, user_id, participant_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, status, created_at`,
|
||||
[
|
||||
user.id,
|
||||
participantId || null,
|
||||
leadId || null,
|
||||
conversationId || null,
|
||||
title,
|
||||
description || null,
|
||||
eventType || "meeting",
|
||||
startTime,
|
||||
endTime || null,
|
||||
durationMinutes || null,
|
||||
],
|
||||
user.id,
|
||||
)
|
||||
|
||||
const r = result.rows[0]
|
||||
|
||||
if (participantId && participantId !== user.id) {
|
||||
await query(
|
||||
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[
|
||||
participantId,
|
||||
"event_scheduled",
|
||||
`Meeting scheduled: ${title}`,
|
||||
`${user.firstName} ${user.lastName} scheduled a ${eventType || "meeting"} with you`,
|
||||
`/calendar`,
|
||||
r.id,
|
||||
"scheduled_event",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
const participantResult = participantId ? await query(
|
||||
`SELECT email, first_name, last_name FROM users WHERE id = $1`,
|
||||
[participantId],
|
||||
) : null
|
||||
const participant = participantResult?.rows[0] || null
|
||||
|
||||
sendEventConfirmation({
|
||||
creatorName: `${user.firstName} ${user.lastName}`,
|
||||
creatorEmail: user.email,
|
||||
participantName: participant ? `${participant.first_name} ${participant.last_name}` : null,
|
||||
participantEmail: participant?.email || null,
|
||||
title,
|
||||
description: description || undefined,
|
||||
eventType: eventType || "meeting",
|
||||
startTime,
|
||||
endTime: endTime || undefined,
|
||||
durationMinutes: durationMinutes || undefined,
|
||||
}).catch((err) => console.error("Email error:", err))
|
||||
|
||||
return NextResponse.json({
|
||||
event: {
|
||||
id: r.id,
|
||||
userId: r.user_id,
|
||||
participantId: r.participant_id,
|
||||
leadId: r.lead_id,
|
||||
conversationId: r.conversation_id,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
participantNotes: r.participant_notes,
|
||||
eventType: r.event_type,
|
||||
startTime: r.start_time,
|
||||
endTime: r.end_time,
|
||||
durationMinutes: r.duration_minutes,
|
||||
status: r.status,
|
||||
creator: { id: user.id, name: `${user.firstName} ${user.lastName}`, email: user.email, role: user.role },
|
||||
participant: participantId ? { id: participantId, name: participant ? `${participant.first_name} ${participant.last_name}` : participantId, email: participant?.email || null } : null,
|
||||
lead: leadId ? { id: leadId, companyName: "", contactName: "" } : null,
|
||||
createdAt: r.created_at,
|
||||
},
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error("Events POST error:", error)
|
||||
return NextResponse.json({ error: "Failed to create event" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
|
||||
const TENOR_API_KEY = process.env.TENOR_API_KEY || ""
|
||||
const TENOR_BASE = "https://tenor.googleapis.com/v2"
|
||||
|
||||
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 q = searchParams.get("q") || ""
|
||||
const limit = Math.min(parseInt(searchParams.get("limit") || "20", 10), 50)
|
||||
const pos = searchParams.get("pos") || ""
|
||||
|
||||
if (!TENOR_API_KEY) {
|
||||
return NextResponse.json({
|
||||
results: [],
|
||||
error: "TENOR_API_KEY not configured",
|
||||
noKey: true,
|
||||
})
|
||||
}
|
||||
|
||||
const endpoint = q
|
||||
? `${TENOR_BASE}/search?q=${encodeURIComponent(q)}&key=${TENOR_API_KEY}&limit=${limit}&media_filter=minimal`
|
||||
: `${TENOR_BASE}/featured?key=${TENOR_API_KEY}&limit=${limit}&media_filter=minimal`
|
||||
|
||||
const url = pos ? `${endpoint}&pos=${pos}` : endpoint
|
||||
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(8000) })
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ results: [], error: "Tenor API error" }, { status: 502 })
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
const results = (data.results || []).map((item: any) => {
|
||||
const media = item.media_formats?.gif || item.media_formats?.tinygif || {}
|
||||
const preview = item.media_formats?.tinygif || item.media_formats?.gif || {}
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title || "",
|
||||
url: media.url || "",
|
||||
previewUrl: preview.url || "",
|
||||
width: media.dims?.[0] || 200,
|
||||
height: media.dims?.[1] || 200,
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({ results, next: data.next || "" })
|
||||
} catch (error) {
|
||||
console.error("GIF API error:", error)
|
||||
return NextResponse.json({ results: [], error: "Failed to fetch GIFs" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export async function GET() {
|
||||
[user.id],
|
||||
)
|
||||
|
||||
const websiteTheme = result.rows[0]?.website_theme || "spidey"
|
||||
const websiteTheme = result.rows[0]?.website_theme || "starforce"
|
||||
return NextResponse.json({ websiteTheme })
|
||||
} catch (error) {
|
||||
console.error("Website theme GET error:", error)
|
||||
@@ -26,7 +26,7 @@ export async function PUT(request: NextRequest) {
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const body = await request.json()
|
||||
const theme = body.websiteTheme || "spidey"
|
||||
const theme = body.websiteTheme || "starforce"
|
||||
|
||||
await query(
|
||||
`UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { writeFile } from "node:fs/promises"
|
||||
import { join } from "node:path"
|
||||
import crypto from "node:crypto"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const formData = await request.formData()
|
||||
const file = formData.get("audio") as File | null
|
||||
if (!file) return NextResponse.json({ error: "No audio file" }, { status: 400 })
|
||||
|
||||
const duration = parseFloat(formData.get("duration")?.toString() || "0")
|
||||
|
||||
const ext = file.name.endsWith(".webm") ? "webm" : "webm"
|
||||
const filename = `${crypto.randomUUID()}.${ext}`
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
const savePath = join(process.cwd(), "public", "uploads", "voice", filename)
|
||||
await writeFile(savePath, buffer)
|
||||
|
||||
return NextResponse.json({ url: `/uploads/voice/${filename}`, duration })
|
||||
} catch (error) {
|
||||
console.error("Voice upload error:", error)
|
||||
return NextResponse.json({ error: "Upload failed" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { query } from "@/lib/db"
|
||||
import { hashPassword, getSessionUser, encryptPassword, setSessionContext } from "@/lib/auth"
|
||||
import { hashPassword, getSessionUser } from "@/lib/auth"
|
||||
import { avatarSvgUrl } from "@/lib/avatar"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -63,20 +63,17 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Invalid role" }, { status: 400 })
|
||||
}
|
||||
|
||||
await setSessionContext(sessionUser.id)
|
||||
|
||||
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 passwordEncrypted = await encryptPassword(password)
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO users (username, email, password_hash, password_encrypted, first_name, last_name, is_active, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`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, passwordEncrypted, firstName, lastName, active ?? true, sessionUser.id]
|
||||
[username.toLowerCase(), email.toLowerCase(), passwordHash, firstName, lastName, active ?? true, sessionUser.id]
|
||||
)
|
||||
|
||||
const roleId = (
|
||||
|
||||
+102
-264
@@ -1,29 +1,94 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Bangers&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Raleway:wght@800&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?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: 210 40% 98%;
|
||||
--background: 220 14% 84%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card: 220 10% 91%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover: 220 14% 96%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 0 100% 40%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--primary: 120 100% 50%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 220 14% 88%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted: 220 14% 88%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent: 220 14% 88%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
@@ -40,14 +105,20 @@
|
||||
--sidebar-ring: 0 76.3% 48%;
|
||||
--radius: 0.5rem;
|
||||
--theme-primary: hsl(var(--primary));
|
||||
--event-meeting: 217 91% 55%;
|
||||
--event-call: 160 84% 39%;
|
||||
--event-chat: 271 81% 53%;
|
||||
--event-follow_up: 38 92% 48%;
|
||||
--event-demo: 346 77% 48%;
|
||||
--event-other: 215 20% 55%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--background: 0 0% 10%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card: 0 0% 9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover: 222.2 84% 11%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 0 100% 53%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
@@ -62,266 +133,15 @@
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 0 100% 53%;
|
||||
--sidebar: 222.2 84% 4.9%;
|
||||
--sidebar: 0 0% 11%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 0 100% 53%;
|
||||
--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: 222.2 84% 4.9%;
|
||||
--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: 222.2 84% 4.9%;
|
||||
--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: 222.2 84% 4.9%;
|
||||
--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: 222.2 84% 4.9%;
|
||||
--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: 222.2 84% 4.9%;
|
||||
--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: 222.2 84% 4.9%;
|
||||
--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: 222.2 84% 4.9%;
|
||||
--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: 222.2 84% 4.9%;
|
||||
--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: 222.2 84% 4.9%;
|
||||
--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%;
|
||||
}
|
||||
|
||||
.ocean {
|
||||
@@ -643,6 +463,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
[data-radix-select-trigger] {
|
||||
background-color: hsl(0 0% 100%);
|
||||
}
|
||||
|
||||
main {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@keyframes drift {
|
||||
0% { background-position: 0% 0%; }
|
||||
100% { background-position: 100% 100%; }
|
||||
}
|
||||
|
||||
/* Login page custom styles */
|
||||
.left-panel {
|
||||
background: #0a0a0f;
|
||||
@@ -888,6 +722,10 @@
|
||||
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); }
|
||||
|
||||
@@ -4,6 +4,8 @@ import { ThemeProvider } from "@/providers/theme-provider"
|
||||
import { WebsiteThemeProvider } from "@/providers/website-theme-provider"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import "./globals.css"
|
||||
import "../../Web_Backgrounds/spidey-theme.css"
|
||||
import "../../Web_Backgrounds/starforce-theme.css"
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
|
||||
@@ -13,8 +13,7 @@ const waves = [
|
||||
]
|
||||
|
||||
export default function LoginPage() {
|
||||
const searchParams = useSearchParams()
|
||||
const redirectTo = searchParams.get("redirect") || "/dashboard"
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
@@ -215,7 +214,7 @@ export default function LoginPage() {
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
window.location.href = redirectTo
|
||||
router.push("/dashboard")
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
setError(data.error || "Invalid email or password.")
|
||||
|
||||
Reference in New Issue
Block a user