Fixed AI. Optimized. Colour/Theme fixed. Added Forgot Password. Cuba theme — Added to client portal settings. Topbar search. Search, loading states
Build & Auto-Repair / build (push) Has been cancelled

This commit is contained in:
JCBSComputer
2026-07-03 11:46:37 +02:00
parent 51ec96ce7b
commit cfc69d47d3
16 changed files with 546 additions and 39 deletions
@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user ON password_reset_tokens(user_id);
+33
View File
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from "next/server"
import crypto from "crypto"
import { query } from "@/lib/db"
import { getUserByEmail } from "@/lib/auth"
export async function POST(request: NextRequest) {
try {
const { email } = await request.json()
if (!email || typeof email !== "string") {
return NextResponse.json({ error: "Email is required" }, { status: 400 })
}
const user = await getUserByEmail(email)
if (!user) {
return NextResponse.json({ message: "If that email exists, a reset link has been generated." })
}
const token = crypto.randomBytes(32).toString("hex")
const expiresAt = new Date(Date.now() + 60 * 60 * 1000) // 1 hour
await query(
`INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES ($1, $2, $3)`,
[user.id, token, expiresAt],
)
const resetUrl = `${request.nextUrl.origin}/reset-password/${token}`
return NextResponse.json({ message: "If that email exists, a reset link has been generated.", resetUrl })
} catch (error) {
console.error("Forgot password error:", error)
return NextResponse.json({ error: "Something went wrong" }, { status: 500 })
}
}
+38
View File
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
import { hashPassword } from "@/lib/auth"
export async function POST(request: NextRequest) {
try {
const { token, password } = await request.json()
if (!token || !password) {
return NextResponse.json({ error: "Token and password are required" }, { status: 400 })
}
if (password.length < 6) {
return NextResponse.json({ error: "Password must be at least 6 characters" }, { status: 400 })
}
const result = await query(
`SELECT id, user_id, expires_at FROM password_reset_tokens
WHERE token = $1 AND used_at IS NULL AND expires_at > NOW()`,
[token],
)
const row = result.rows[0]
if (!row) {
return NextResponse.json({ error: "Invalid or expired reset token" }, { status: 400 })
}
const passwordHash = await hashPassword(password)
await query(`UPDATE users SET password_hash = $1, password_change_required = FALSE WHERE id = $2`, [passwordHash, row.user_id])
await query(`UPDATE password_reset_tokens SET used_at = NOW() WHERE id = $1`, [row.id])
return NextResponse.json({ message: "Password has been reset successfully." })
} catch (error) {
console.error("Reset password error:", error)
return NextResponse.json({ error: "Something went wrong" }, { status: 500 })
}
}
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
export async function GET(request: NextRequest) {
try {
const currentUser = await getSessionUser()
if (!currentUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const q = request.nextUrl.searchParams.get("q") || ""
if (!q.trim()) return NextResponse.json({ results: [] })
const like = `%${q}%`
const [leadsResult, projectsResult, proposalsResult, conversationsResult] = await Promise.all([
query(
`SELECT id, company_name AS title, 'lead' AS type FROM leads WHERE deleted_at IS NULL AND company_name ILIKE $1 LIMIT 5`,
[like],
),
query(
`SELECT id, name AS title, 'project' AS type FROM projects WHERE deleted_at IS NULL AND name ILIKE $1 LIMIT 5`,
[like],
),
query(
`SELECT id, title, 'proposal' AS type FROM proposals WHERE deleted_at IS NULL AND title ILIKE $1 LIMIT 5`,
[like],
),
query(
`SELECT id, title, 'conversation' AS type FROM conversations WHERE title ILIKE $1 LIMIT 5`,
[like],
),
])
const results = [
...leadsResult.rows,
...projectsResult.rows,
...proposalsResult.rows,
...conversationsResult.rows,
]
return NextResponse.json({ results })
} catch (error) {
console.error("Search error:", error)
return NextResponse.json({ error: "Search failed" }, { status: 500 })
}
}
+15 -1
View File
@@ -22,12 +22,14 @@ interface Event {
export default function ClientActivity() { export default function ClientActivity() {
const router = useRouter() const router = useRouter()
const [events, setEvents] = useState<Event[]>([]) const [events, setEvents] = useState<Event[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
fetch("/client-portal/api/activity", { credentials: "include" }) fetch("/client-portal/api/activity", { credentials: "include" })
.then((r) => r.json()) .then((r) => r.json())
.then((d) => setEvents(d.events || [])) .then((d) => setEvents(d.events || []))
.catch(() => {}) .catch((err) => console.error("Failed to fetch activity:", err))
.finally(() => setLoading(false))
}, []) }, [])
const getIcon = (type: string) => { const getIcon = (type: string) => {
@@ -57,6 +59,18 @@ export default function ClientActivity() {
return date.toLocaleDateString([], { month: "short", day: "numeric" }) return date.toLocaleDateString([], { month: "short", day: "numeric" })
} }
if (loading) {
return (
<div>
<h1 className="text-xl font-bold text-foreground mb-1">Activity</h1>
<p className="text-sm text-muted-foreground mb-6">Recent project updates and changes</p>
<div className="flex items-center justify-center h-32">
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
</div>
)
}
return ( return (
<div> <div>
<h1 className="text-xl font-bold text-foreground mb-1">Activity</h1> <h1 className="text-xl font-bold text-foreground mb-1">Activity</h1>
+16 -5
View File
@@ -14,6 +14,7 @@ interface Deadline {
export default function ClientDashboard() { export default function ClientDashboard() {
const router = useRouter() const router = useRouter()
const [loading, setLoading] = useState(true)
const [counts, setCounts] = useState({ projects: 0, invoices: 0, tickets: 0 }) const [counts, setCounts] = useState({ projects: 0, invoices: 0, tickets: 0 })
const [stats, setStats] = useState({ totalBilled: 0, totalPaid: 0, totalOverdue: 0, upcomingDeadlines: [] as Deadline[] }) const [stats, setStats] = useState({ totalBilled: 0, totalPaid: 0, totalOverdue: 0, upcomingDeadlines: [] as Deadline[] })
const [name, setName] = useState("") const [name, setName] = useState("")
@@ -21,30 +22,32 @@ export default function ClientDashboard() {
const safeJson = (r: Response) => r.ok ? r.json() : Promise.reject() const safeJson = (r: Response) => r.ok ? r.json() : Promise.reject()
useEffect(() => { useEffect(() => {
Promise.allSettled([
fetch("/client-portal/api/projects", { credentials: "include" }) fetch("/client-portal/api/projects", { credentials: "include" })
.then(safeJson) .then(safeJson)
.then((d) => setCounts((prev) => ({ ...prev, projects: d.projects?.length || 0 }))) .then((d) => setCounts((prev) => ({ ...prev, projects: d.projects?.length || 0 })))
.catch(() => {}) .catch((err) => console.error("Failed to fetch projects:", err)),
fetch("/client-portal/api/invoices", { credentials: "include" }) fetch("/client-portal/api/invoices", { credentials: "include" })
.then(safeJson) .then(safeJson)
.then((d) => setCounts((prev) => ({ ...prev, invoices: d.invoices?.length || 0 }))) .then((d) => setCounts((prev) => ({ ...prev, invoices: d.invoices?.length || 0 })))
.catch(() => {}) .catch((err) => console.error("Failed to fetch invoices:", err)),
fetch("/client-portal/api/support", { credentials: "include" }) fetch("/client-portal/api/support", { credentials: "include" })
.then(safeJson) .then(safeJson)
.then((d) => setCounts((prev) => ({ ...prev, tickets: d.tickets?.length || 0 }))) .then((d) => setCounts((prev) => ({ ...prev, tickets: d.tickets?.length || 0 })))
.catch(() => {}) .catch((err) => console.error("Failed to fetch tickets:", err)),
fetch("/client-portal/api/dashboard/stats", { credentials: "include" }) fetch("/client-portal/api/dashboard/stats", { credentials: "include" })
.then(safeJson) .then(safeJson)
.then((d) => setStats(d)) .then((d) => setStats(d))
.catch(() => {}) .catch((err) => console.error("Failed to fetch stats:", err)),
fetch("/api/auth/me", { credentials: "include" }) fetch("/api/auth/me", { credentials: "include" })
.then(safeJson) .then(safeJson)
.then((d) => setName(d.user?.firstName || d.user?.email || "Client")) .then((d) => setName(d.user?.firstName || d.user?.email || "Client"))
.catch(() => {}) .catch((err) => console.error("Failed to fetch user:", err)),
]).finally(() => setLoading(false))
}, []) }, [])
const cards = [ const cards = [
@@ -58,6 +61,14 @@ export default function ClientDashboard() {
return `$${num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` return `$${num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
} }
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
)
}
return ( return (
<div> <div>
<h1 className="text-xl font-bold text-foreground mb-1">Welcome, {name}</h1> <h1 className="text-xl font-bold text-foreground mb-1">Welcome, {name}</h1>
+1 -10
View File
@@ -12,7 +12,6 @@ export default function ClientLoginPage() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState("") const [error, setError] = useState("")
const [checking, setChecking] = useState(true) const [checking, setChecking] = useState(true)
const [debugInfo, setDebugInfo] = useState("")
const [sessionUser, setSessionUser] = useState<{ email: string; role: string } | null>(null) const [sessionUser, setSessionUser] = useState<{ email: string; role: string } | null>(null)
@@ -38,7 +37,6 @@ export default function ClientLoginPage() {
e.preventDefault() e.preventDefault()
setLoading(true) setLoading(true)
setError("") setError("")
setDebugInfo("")
try { try {
const res = await fetch("/api/auth/login", { const res = await fetch("/api/auth/login", {
@@ -48,27 +46,21 @@ export default function ClientLoginPage() {
}) })
const data = await res.json() const data = await res.json()
console.log("LOGIN RESPONSE:", res.status, data)
if (!res.ok) { if (!res.ok) {
setError(data.error || "Login failed") setError(data.error || "Login failed")
setDebugInfo(`HTTP ${res.status}`)
return return
} }
if (data.user?.role !== "client") { if (data.user?.role !== "client") {
setError("This portal is for clients only") setError("This portal is for clients only")
setDebugInfo(`User role: ${data.user?.role}`)
return return
} }
setError("Login successful! Redirecting...") setError("Login successful! Redirecting...")
setDebugInfo("")
setTimeout(() => { window.location.href = "/client-portal/dashboard" }, 300) setTimeout(() => { window.location.href = "/client-portal/dashboard" }, 300)
} catch (err) { } catch {
console.error("LOGIN FETCH ERROR:", err)
setError("Connection error") setError("Connection error")
setDebugInfo("Check network tab for details")
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -147,7 +139,6 @@ export default function ClientLoginPage() {
{error !== "Login successful! Redirecting..." && <AlertCircle className="h-4 w-4 shrink-0" />} {error !== "Login successful! Redirecting..." && <AlertCircle className="h-4 w-4 shrink-0" />}
<span className="font-medium">{error}</span> <span className="font-medium">{error}</span>
</div> </div>
{debugInfo && <div className="mt-1 text-xs opacity-60">{debugInfo}</div>}
</div> </div>
)} )}
+1 -1
View File
@@ -40,7 +40,7 @@ export default function ClientProfile() {
setProfile(d.profile || null) setProfile(d.profile || null)
setContacts(d.contacts || []) setContacts(d.contacts || [])
}) })
.catch(() => {}) .catch((err) => console.error("Failed to fetch profile:", err))
}, []) }, [])
if (!profile) { if (!profile) {
+15 -1
View File
@@ -18,14 +18,28 @@ interface Project {
export default function ClientProjects() { export default function ClientProjects() {
const router = useRouter() const router = useRouter()
const [projects, setProjects] = useState<Project[]>([]) const [projects, setProjects] = useState<Project[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
fetch("/client-portal/api/projects", { credentials: "include" }) fetch("/client-portal/api/projects", { credentials: "include" })
.then((r) => r.json()) .then((r) => r.json())
.then((d) => setProjects(d.projects || [])) .then((d) => setProjects(d.projects || []))
.catch(() => {}) .catch((err) => console.error("Failed to fetch projects:", err))
.finally(() => setLoading(false))
}, []) }, [])
if (loading) {
return (
<div>
<h1 className="text-xl font-bold text-foreground mb-1">Projects</h1>
<p className="text-sm text-muted-foreground mb-6">View your project milestones and progress</p>
<div className="flex items-center justify-center h-32">
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
</div>
)
}
return ( return (
<div> <div>
<h1 className="text-xl font-bold text-foreground mb-1">Projects</h1> <h1 className="text-xl font-bold text-foreground mb-1">Projects</h1>
+15 -1
View File
@@ -15,14 +15,28 @@ interface Receipt {
export default function ClientReceipts() { export default function ClientReceipts() {
const [receipts, setReceipts] = useState<Receipt[]>([]) const [receipts, setReceipts] = useState<Receipt[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
fetch("/client-portal/api/receipts", { credentials: "include" }) fetch("/client-portal/api/receipts", { credentials: "include" })
.then((r) => r.json()) .then((r) => r.json())
.then((d) => setReceipts(d.receipts || [])) .then((d) => setReceipts(d.receipts || []))
.catch(() => {}) .catch((err) => console.error("Failed to fetch receipts:", err))
.finally(() => setLoading(false))
}, []) }, [])
if (loading) {
return (
<div>
<h1 className="text-xl font-bold text-foreground mb-1">Receipts</h1>
<p className="text-sm text-muted-foreground mb-6">View all your payment receipts in one place</p>
<div className="flex items-center justify-center h-32">
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
</div>
)
}
return ( return (
<div> <div>
<h1 className="text-xl font-bold text-foreground mb-1">Receipts</h1> <h1 className="text-xl font-bold text-foreground mb-1">Receipts</h1>
+1
View File
@@ -36,6 +36,7 @@ const bgOptions = [
{ value: "bw", label: "Black & White", color: "bg-gradient-to-r from-gray-900 to-gray-400", ring: "ring-gray-500", desc: "Clean monochrome" }, { value: "bw", label: "Black & White", color: "bg-gradient-to-r from-gray-900 to-gray-400", ring: "ring-gray-500", desc: "Clean monochrome" },
{ value: "forest", label: "Forest", color: "bg-gradient-to-r from-green-700 to-green-400", ring: "ring-green-600", desc: "Earthy greens with tree silhouettes" }, { value: "forest", label: "Forest", color: "bg-gradient-to-r from-green-700 to-green-400", ring: "ring-green-600", desc: "Earthy greens with tree silhouettes" },
{ value: "luminous", label: "Luminous", color: "bg-gradient-to-r from-blue-500 to-teal-400", ring: "ring-blue-500", desc: "Deep Glass 2.0 with specular highlights" }, { value: "luminous", label: "Luminous", color: "bg-gradient-to-r from-blue-500 to-teal-400", ring: "ring-blue-500", desc: "Deep Glass 2.0 with specular highlights" },
{ value: "cuba", label: "Cuba", color: "bg-gradient-to-r from-orange-500 to-turquoise-400", ring: "ring-orange-500", desc: "Classic Cuban car on a playa at golden hour" },
] ]
function getStoredColorTheme(): string { function getStoredColorTheme(): string {
+1 -1
View File
@@ -35,7 +35,7 @@ export default function ClientSupport() {
fetch("/client-portal/api/support", { credentials: "include" }) fetch("/client-portal/api/support", { credentials: "include" })
.then((r) => r.json()) .then((r) => r.json())
.then((d) => setTickets(d.tickets || [])) .then((d) => setTickets(d.tickets || []))
.catch(() => {}) .catch((err) => console.error("Failed to fetch tickets:", err))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
} }
+110
View File
@@ -0,0 +1,110 @@
"use client"
import { useState } from "react"
import { Mail, ArrowLeft, CheckCircle, AlertCircle, Loader2 } from "lucide-react"
import Link from "next/link"
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("")
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const [sent, setSent] = useState(false)
const [resetUrl, setResetUrl] = useState("")
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError("")
try {
const res = await fetch("/api/auth/forgot-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error || "Something went wrong")
return
}
setSent(true)
if (data.resetUrl) setResetUrl(data.resetUrl)
} catch {
setError("Connection error")
} finally {
setLoading(false)
}
}
if (sent) {
return (
<div className="flex min-h-screen bg-background items-center justify-center p-4">
<div className="w-full max-w-sm text-center">
<CheckCircle className="h-12 w-12 text-primary mx-auto mb-4" />
<h1 className="text-xl font-bold text-foreground mb-2">Check your email</h1>
<p className="text-sm text-muted-foreground mb-6">
If an account with that email exists, we&apos;ve sent a password reset link.
</p>
{resetUrl && (
<div className="bg-card border border-border rounded-xl p-4 mb-6 text-left">
<p className="text-xs text-muted-foreground mb-2">Development mode reset link:</p>
<a href={resetUrl} className="text-sm text-primary break-all hover:underline">{resetUrl}</a>
</div>
)}
<Link href="/login" className="text-sm text-primary hover:underline">
Back to login
</Link>
</div>
</div>
)
}
return (
<div className="flex min-h-screen bg-background items-center justify-center p-4">
<div className="w-full max-w-sm">
<Link href="/login" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground mb-8 transition-colors">
<ArrowLeft className="h-4 w-4" />
Back to login
</Link>
<h1 className="text-xl font-bold text-foreground mb-2">Reset your password</h1>
<p className="text-sm text-muted-foreground mb-6">
Enter your email and we&apos;ll send you a reset link.
</p>
{error && (
<div className="mb-4 flex items-center gap-2 text-sm text-destructive bg-destructive/5 border border-destructive/10 rounded-lg px-3 py-2">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-muted-foreground mb-1.5">Email</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full bg-muted border border-border rounded-lg pl-10 pr-3 py-2.5 text-sm text-foreground placeholder-muted-foreground outline-none focus:border-primary/50"
placeholder="your@email.com"
required
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-primary hover:bg-primary/80 text-primary-foreground rounded-lg px-4 py-2.5 text-sm font-medium transition-all disabled:opacity-40 flex items-center justify-center gap-2"
>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
{loading ? "Sending..." : "Send reset link"}
</button>
</form>
</div>
</div>
)
}
+3 -2
View File
@@ -2,6 +2,7 @@
import { useState, useEffect, useRef, Suspense } from "react" import { useState, useEffect, useRef, Suspense } from "react"
import { useSearchParams, useRouter } from "next/navigation" import { useSearchParams, useRouter } from "next/navigation"
import Link from "next/link"
import { Eye, EyeOff, Loader2 } from "lucide-react" import { Eye, EyeOff, Loader2 } from "lucide-react"
const WEBSITE_THEME_KEY = "crm-website-theme" const WEBSITE_THEME_KEY = "crm-website-theme"
@@ -384,9 +385,9 @@ function LoginForm() {
<label htmlFor="password" className="login-label" style={{ marginBottom: 0 }}> <label htmlFor="password" className="login-label" style={{ marginBottom: 0 }}>
Password Password
</label> </label>
<button type="button" className="text-[12px] text-[#1BB0CE] hover:underline" onClick={() => alert("Please contact your administrator to reset your password.")}> <Link href="/forgot-password" className="text-[12px] text-[#1BB0CE] hover:underline">
Forgot password? Forgot password?
</button> </Link>
</div> </div>
<div className="input-sheen input-sheen-delayed"> <div className="input-sheen input-sheen-delayed">
<div className="relative"> <div className="relative">
+138
View File
@@ -0,0 +1,138 @@
"use client"
import { useState } from "react"
import { useParams, useRouter } from "next/navigation"
import { Eye, EyeOff, AlertCircle, CheckCircle, Loader2 } from "lucide-react"
import Link from "next/link"
export default function ResetPasswordPage() {
const { token } = useParams<{ token: string }>()
const router = useRouter()
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [showPassword, setShowPassword] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const [done, setDone] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
if (password !== confirmPassword) {
setError("Passwords do not match")
return
}
if (password.length < 6) {
setError("Password must be at least 6 characters")
return
}
setLoading(true)
try {
const res = await fetch("/api/auth/reset-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, password }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error || "Something went wrong")
return
}
setDone(true)
} catch {
setError("Connection error")
} finally {
setLoading(false)
}
}
if (done) {
return (
<div className="flex min-h-screen bg-background items-center justify-center p-4">
<div className="w-full max-w-sm text-center">
<CheckCircle className="h-12 w-12 text-primary mx-auto mb-4" />
<h1 className="text-xl font-bold text-foreground mb-2">Password reset</h1>
<p className="text-sm text-muted-foreground mb-6">
Your password has been reset successfully.
</p>
<Link
href="/login"
className="inline-block bg-primary hover:bg-primary/80 text-primary-foreground rounded-lg px-6 py-2.5 text-sm font-medium transition-all"
>
Sign in
</Link>
</div>
</div>
)
}
return (
<div className="flex min-h-screen bg-background items-center justify-center p-4">
<div className="w-full max-w-sm">
<h1 className="text-xl font-bold text-foreground mb-2">Set new password</h1>
<p className="text-sm text-muted-foreground mb-6">Enter your new password below.</p>
{error && (
<div className="mb-4 flex items-center gap-2 text-sm text-destructive bg-destructive/5 border border-destructive/10 rounded-lg px-3 py-2">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-muted-foreground mb-1.5">New password</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-muted border border-border rounded-lg px-3 py-2.5 pr-10 text-sm text-foreground placeholder-muted-foreground outline-none focus:border-primary/50"
placeholder="Min 6 characters"
required
minLength={6}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div>
<label className="block text-sm text-muted-foreground mb-1.5">Confirm password</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full bg-muted border border-border rounded-lg px-3 py-2.5 text-sm text-foreground placeholder-muted-foreground outline-none focus:border-primary/50"
placeholder="Repeat password"
required
minLength={6}
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-primary hover:bg-primary/80 text-primary-foreground rounded-lg px-4 py-2.5 text-sm font-medium transition-all disabled:opacity-40 flex items-center justify-center gap-2"
>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
{loading ? "Resetting..." : "Reset password"}
</button>
</form>
<p className="text-center mt-6">
<Link href="/login" className="text-sm text-primary hover:underline">
Back to login
</Link>
</p>
</div>
</div>
)
}
+87 -2
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -31,6 +31,11 @@ import {
Bug, Bug,
CheckCheck, CheckCheck,
Trash2, Trash2,
Building2,
FolderKanban,
FileText,
MessageSquare,
Loader2,
} from "lucide-react"; } from "lucide-react";
import { useWebsiteTheme } from "@/providers/website-theme-provider"; import { useWebsiteTheme } from "@/providers/website-theme-provider";
import { CaptainAmericaShield } from "@/components/shared/captain-america-shield"; import { CaptainAmericaShield } from "@/components/shared/captain-america-shield";
@@ -48,11 +53,60 @@ export function Topbar({ onMenuClick }: TopbarProps) {
useNotifications(); useNotifications();
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [searchOpen, setSearchOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false);
const [query, setQuery] = useState("");
const [results, setResults] = useState<{ id: number; title: string; type: string }[]>([]);
const [searching, setSearching] = useState(false);
const [showResults, setShowResults] = useState(false);
const searchRef = useRef<HTMLDivElement>(null);
const searchTimer = useRef<ReturnType<typeof setTimeout>>();
const [bugModalOpen, setBugModalOpen] = useState(false); const [bugModalOpen, setBugModalOpen] = useState(false);
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
}, []); }, []);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
setShowResults(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const doSearch = useCallback(async (q: string) => {
if (!q.trim()) { setResults([]); setSearching(false); return }
setSearching(true);
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`)
const data = await res.json()
setResults(data.results || [])
} catch { setResults([]) }
setSearching(false)
}, []);
const handleSearchInput = useCallback((value: string) => {
setQuery(value)
setShowResults(true)
if (searchTimer.current) clearTimeout(searchTimer.current)
if (!value.trim()) { setResults([]); return }
searchTimer.current = setTimeout(() => doSearch(value), 250)
}, [doSearch]);
const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Escape") setShowResults(false)
}, []);
const navigateTo = useCallback((type: string, id: number) => {
setShowResults(false)
setQuery("")
setResults([])
const paths: Record<string, string> = { lead: `/leads/${id}`, project: `/projects/${id}`, proposal: `/proposals/${id}`, conversation: `/conversations/${id}` }
router.push(paths[type] || "/")
}, [router]);
const typeIcons: Record<string, typeof Building2> = { lead: Building2, project: FolderKanban, proposal: FileText, conversation: MessageSquare }
if (!user) return null; if (!user) return null;
function formatTimeAgo(ts: string): string { function formatTimeAgo(ts: string): string {
@@ -93,12 +147,43 @@ export function Topbar({ onMenuClick }: TopbarProps) {
</Button> </Button>
{/* Search */} {/* Search */}
<div className="relative hidden flex-1 sm:block md:w-80"> <div className="relative hidden flex-1 sm:block md:w-80" ref={searchRef}>
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
value={query}
onChange={(e) => handleSearchInput(e.target.value)}
onKeyDown={handleSearchKeyDown}
onFocus={() => query.trim() && setShowResults(true)}
placeholder="Search leads, companies..." placeholder="Search leads, companies..."
className="h-9 w-full max-w-sm pl-9 bg-muted border-border text-foreground" className="h-9 w-full max-w-sm pl-9 bg-muted border-border text-foreground"
/> />
{showResults && (results.length > 0 || searching) && (
<div className="absolute left-0 right-0 top-full mt-1 bg-card border border-border rounded-xl shadow-xl overflow-hidden z-50">
{searching ? (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Searching...
</div>
) : (
results.map((r) => {
const Icon = typeIcons[r.type] || Building2
return (
<button
key={`${r.type}-${r.id}`}
onClick={() => navigateTo(r.type, r.id)}
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-left hover:bg-muted/50 transition-colors"
>
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-primary/10 text-primary">
<Icon className="h-3.5 w-3.5" />
</div>
<span className="font-medium text-foreground truncate">{r.title}</span>
<span className="ml-auto text-[10px] uppercase tracking-wider text-muted-foreground/60">{r.type}</span>
</button>
)
})
)}
</div>
)}
</div> </div>
<div className="flex flex-1 items-center justify-end gap-3"> <div className="flex flex-1 items-center justify-end gap-3">