From cfc69d47d3ce7337f6a18c3a4c9fe3fc9c5d8ac6 Mon Sep 17 00:00:00 2001 From: JCBSComputer Date: Fri, 3 Jul 2026 11:46:37 +0200 Subject: [PATCH] =?UTF-8?q?Fixed=20AI.=20Optimized.=20Colour/Theme=20fixed?= =?UTF-8?q?.=20Added=20Forgot=20Password.=20Cuba=20theme=20=E2=80=94=20Add?= =?UTF-8?q?ed=20to=20client=20portal=20settings.=20Topbar=20search.=20Sear?= =?UTF-8?q?ch,=20loading=20states?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- database/migrations/021_password_reset.sql | 11 ++ src/app/api/auth/forgot-password/route.ts | 33 +++++ src/app/api/auth/reset-password/route.ts | 38 ++++++ src/app/api/search/route.ts | 46 +++++++ src/app/client-portal/activity/page.tsx | 16 ++- src/app/client-portal/dashboard/page.tsx | 51 +++++--- src/app/client-portal/login/page.tsx | 11 +- src/app/client-portal/profile/page.tsx | 2 +- src/app/client-portal/projects/page.tsx | 16 ++- src/app/client-portal/receipts/page.tsx | 16 ++- src/app/client-portal/settings/page.tsx | 1 + src/app/client-portal/support/page.tsx | 2 +- src/app/forgot-password/page.tsx | 110 ++++++++++++++++ src/app/login/page.tsx | 5 +- src/app/reset-password/[token]/page.tsx | 138 +++++++++++++++++++++ src/components/layout/topbar.tsx | 89 ++++++++++++- 16 files changed, 546 insertions(+), 39 deletions(-) create mode 100644 database/migrations/021_password_reset.sql create mode 100644 src/app/api/auth/forgot-password/route.ts create mode 100644 src/app/api/auth/reset-password/route.ts create mode 100644 src/app/api/search/route.ts create mode 100644 src/app/forgot-password/page.tsx create mode 100644 src/app/reset-password/[token]/page.tsx diff --git a/database/migrations/021_password_reset.sql b/database/migrations/021_password_reset.sql new file mode 100644 index 0000000..d11da57 --- /dev/null +++ b/database/migrations/021_password_reset.sql @@ -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); diff --git a/src/app/api/auth/forgot-password/route.ts b/src/app/api/auth/forgot-password/route.ts new file mode 100644 index 0000000..1aaedde --- /dev/null +++ b/src/app/api/auth/forgot-password/route.ts @@ -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 }) + } +} diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/auth/reset-password/route.ts new file mode 100644 index 0000000..9f4f1e7 --- /dev/null +++ b/src/app/api/auth/reset-password/route.ts @@ -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 }) + } +} diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts new file mode 100644 index 0000000..c19d952 --- /dev/null +++ b/src/app/api/search/route.ts @@ -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 }) + } +} diff --git a/src/app/client-portal/activity/page.tsx b/src/app/client-portal/activity/page.tsx index f45e571..f02c962 100644 --- a/src/app/client-portal/activity/page.tsx +++ b/src/app/client-portal/activity/page.tsx @@ -22,12 +22,14 @@ interface Event { export default function ClientActivity() { const router = useRouter() const [events, setEvents] = useState([]) + const [loading, setLoading] = useState(true) useEffect(() => { fetch("/client-portal/api/activity", { credentials: "include" }) .then((r) => r.json()) .then((d) => setEvents(d.events || [])) - .catch(() => {}) + .catch((err) => console.error("Failed to fetch activity:", err)) + .finally(() => setLoading(false)) }, []) const getIcon = (type: string) => { @@ -57,6 +59,18 @@ export default function ClientActivity() { return date.toLocaleDateString([], { month: "short", day: "numeric" }) } + if (loading) { + return ( +
+

Activity

+

Recent project updates and changes

+
+
+
+
+ ) + } + return (

Activity

diff --git a/src/app/client-portal/dashboard/page.tsx b/src/app/client-portal/dashboard/page.tsx index 48f40f6..0305283 100644 --- a/src/app/client-portal/dashboard/page.tsx +++ b/src/app/client-portal/dashboard/page.tsx @@ -14,6 +14,7 @@ interface Deadline { export default function ClientDashboard() { const router = useRouter() + const [loading, setLoading] = useState(true) const [counts, setCounts] = useState({ projects: 0, invoices: 0, tickets: 0 }) const [stats, setStats] = useState({ totalBilled: 0, totalPaid: 0, totalOverdue: 0, upcomingDeadlines: [] as Deadline[] }) const [name, setName] = useState("") @@ -21,30 +22,32 @@ export default function ClientDashboard() { const safeJson = (r: Response) => r.ok ? r.json() : Promise.reject() useEffect(() => { - fetch("/client-portal/api/projects", { credentials: "include" }) - .then(safeJson) - .then((d) => setCounts((prev) => ({ ...prev, projects: d.projects?.length || 0 }))) - .catch(() => {}) + Promise.allSettled([ + fetch("/client-portal/api/projects", { credentials: "include" }) + .then(safeJson) + .then((d) => setCounts((prev) => ({ ...prev, projects: d.projects?.length || 0 }))) + .catch((err) => console.error("Failed to fetch projects:", err)), - fetch("/client-portal/api/invoices", { credentials: "include" }) - .then(safeJson) - .then((d) => setCounts((prev) => ({ ...prev, invoices: d.invoices?.length || 0 }))) - .catch(() => {}) + fetch("/client-portal/api/invoices", { credentials: "include" }) + .then(safeJson) + .then((d) => setCounts((prev) => ({ ...prev, invoices: d.invoices?.length || 0 }))) + .catch((err) => console.error("Failed to fetch invoices:", err)), - fetch("/client-portal/api/support", { credentials: "include" }) - .then(safeJson) - .then((d) => setCounts((prev) => ({ ...prev, tickets: d.tickets?.length || 0 }))) - .catch(() => {}) + fetch("/client-portal/api/support", { credentials: "include" }) + .then(safeJson) + .then((d) => setCounts((prev) => ({ ...prev, tickets: d.tickets?.length || 0 }))) + .catch((err) => console.error("Failed to fetch tickets:", err)), - fetch("/client-portal/api/dashboard/stats", { credentials: "include" }) - .then(safeJson) - .then((d) => setStats(d)) - .catch(() => {}) + fetch("/client-portal/api/dashboard/stats", { credentials: "include" }) + .then(safeJson) + .then((d) => setStats(d)) + .catch((err) => console.error("Failed to fetch stats:", err)), - fetch("/api/auth/me", { credentials: "include" }) - .then(safeJson) - .then((d) => setName(d.user?.firstName || d.user?.email || "Client")) - .catch(() => {}) + fetch("/api/auth/me", { credentials: "include" }) + .then(safeJson) + .then((d) => setName(d.user?.firstName || d.user?.email || "Client")) + .catch((err) => console.error("Failed to fetch user:", err)), + ]).finally(() => setLoading(false)) }, []) const cards = [ @@ -58,6 +61,14 @@ export default function ClientDashboard() { return `$${num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` } + if (loading) { + return ( +
+
+
+ ) + } + return (

Welcome, {name}

diff --git a/src/app/client-portal/login/page.tsx b/src/app/client-portal/login/page.tsx index 3191261..9e50b6a 100644 --- a/src/app/client-portal/login/page.tsx +++ b/src/app/client-portal/login/page.tsx @@ -12,7 +12,6 @@ export default function ClientLoginPage() { const [loading, setLoading] = useState(false) const [error, setError] = useState("") const [checking, setChecking] = useState(true) - const [debugInfo, setDebugInfo] = useState("") const [sessionUser, setSessionUser] = useState<{ email: string; role: string } | null>(null) @@ -38,7 +37,6 @@ export default function ClientLoginPage() { e.preventDefault() setLoading(true) setError("") - setDebugInfo("") try { const res = await fetch("/api/auth/login", { @@ -48,27 +46,21 @@ export default function ClientLoginPage() { }) const data = await res.json() - console.log("LOGIN RESPONSE:", res.status, data) if (!res.ok) { setError(data.error || "Login failed") - setDebugInfo(`HTTP ${res.status}`) return } if (data.user?.role !== "client") { setError("This portal is for clients only") - setDebugInfo(`User role: ${data.user?.role}`) return } setError("Login successful! Redirecting...") - setDebugInfo("") setTimeout(() => { window.location.href = "/client-portal/dashboard" }, 300) - } catch (err) { - console.error("LOGIN FETCH ERROR:", err) + } catch { setError("Connection error") - setDebugInfo("Check network tab for details") } finally { setLoading(false) } @@ -147,7 +139,6 @@ export default function ClientLoginPage() { {error !== "Login successful! Redirecting..." && } {error}
- {debugInfo &&
{debugInfo}
}
)} diff --git a/src/app/client-portal/profile/page.tsx b/src/app/client-portal/profile/page.tsx index 24b5d21..ae3ef1b 100644 --- a/src/app/client-portal/profile/page.tsx +++ b/src/app/client-portal/profile/page.tsx @@ -40,7 +40,7 @@ export default function ClientProfile() { setProfile(d.profile || null) setContacts(d.contacts || []) }) - .catch(() => {}) + .catch((err) => console.error("Failed to fetch profile:", err)) }, []) if (!profile) { diff --git a/src/app/client-portal/projects/page.tsx b/src/app/client-portal/projects/page.tsx index 98518e4..ec48fb1 100644 --- a/src/app/client-portal/projects/page.tsx +++ b/src/app/client-portal/projects/page.tsx @@ -18,14 +18,28 @@ interface Project { export default function ClientProjects() { const router = useRouter() const [projects, setProjects] = useState([]) + const [loading, setLoading] = useState(true) useEffect(() => { fetch("/client-portal/api/projects", { credentials: "include" }) .then((r) => r.json()) .then((d) => setProjects(d.projects || [])) - .catch(() => {}) + .catch((err) => console.error("Failed to fetch projects:", err)) + .finally(() => setLoading(false)) }, []) + if (loading) { + return ( +
+

Projects

+

View your project milestones and progress

+
+
+
+
+ ) + } + return (

Projects

diff --git a/src/app/client-portal/receipts/page.tsx b/src/app/client-portal/receipts/page.tsx index f459353..7ff20d4 100644 --- a/src/app/client-portal/receipts/page.tsx +++ b/src/app/client-portal/receipts/page.tsx @@ -15,14 +15,28 @@ interface Receipt { export default function ClientReceipts() { const [receipts, setReceipts] = useState([]) + const [loading, setLoading] = useState(true) useEffect(() => { fetch("/client-portal/api/receipts", { credentials: "include" }) .then((r) => r.json()) .then((d) => setReceipts(d.receipts || [])) - .catch(() => {}) + .catch((err) => console.error("Failed to fetch receipts:", err)) + .finally(() => setLoading(false)) }, []) + if (loading) { + return ( +
+

Receipts

+

View all your payment receipts in one place

+
+
+
+
+ ) + } + return (

Receipts

diff --git a/src/app/client-portal/settings/page.tsx b/src/app/client-portal/settings/page.tsx index 3ab9e82..0faf2de 100644 --- a/src/app/client-portal/settings/page.tsx +++ b/src/app/client-portal/settings/page.tsx @@ -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: "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: "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 { diff --git a/src/app/client-portal/support/page.tsx b/src/app/client-portal/support/page.tsx index 964da64..8dff324 100644 --- a/src/app/client-portal/support/page.tsx +++ b/src/app/client-portal/support/page.tsx @@ -35,7 +35,7 @@ export default function ClientSupport() { fetch("/client-portal/api/support", { credentials: "include" }) .then((r) => r.json()) .then((d) => setTickets(d.tickets || [])) - .catch(() => {}) + .catch((err) => console.error("Failed to fetch tickets:", err)) .finally(() => setLoading(false)) } diff --git a/src/app/forgot-password/page.tsx b/src/app/forgot-password/page.tsx new file mode 100644 index 0000000..f6841de --- /dev/null +++ b/src/app/forgot-password/page.tsx @@ -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 ( +
+
+ +

Check your email

+

+ If an account with that email exists, we've sent a password reset link. +

+ {resetUrl && ( +
+

Development mode — reset link:

+ {resetUrl} +
+ )} + + Back to login + +
+
+ ) + } + + return ( +
+
+ + + Back to login + + +

Reset your password

+

+ Enter your email and we'll send you a reset link. +

+ + {error && ( +
+ + {error} +
+ )} + +
+
+ +
+ + 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 + /> +
+
+ + +
+
+
+ ) +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 6c28596..d615afc 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef, Suspense } from "react" import { useSearchParams, useRouter } from "next/navigation" +import Link from "next/link" import { Eye, EyeOff, Loader2 } from "lucide-react" const WEBSITE_THEME_KEY = "crm-website-theme" @@ -384,9 +385,9 @@ function LoginForm() { - +
diff --git a/src/app/reset-password/[token]/page.tsx b/src/app/reset-password/[token]/page.tsx new file mode 100644 index 0000000..9d9d27b --- /dev/null +++ b/src/app/reset-password/[token]/page.tsx @@ -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 ( +
+
+ +

Password reset

+

+ Your password has been reset successfully. +

+ + Sign in + +
+
+ ) + } + + return ( +
+
+

Set new password

+

Enter your new password below.

+ + {error && ( +
+ + {error} +
+ )} + +
+
+ +
+ 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} + /> + +
+
+ +
+ + 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} + /> +
+ + +
+ +

+ + Back to login + +

+
+
+ ) +} diff --git a/src/components/layout/topbar.tsx b/src/components/layout/topbar.tsx index 4cd7e5f..7decf12 100644 --- a/src/components/layout/topbar.tsx +++ b/src/components/layout/topbar.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { useRouter } from "next/navigation"; import { useTheme } from "next-themes"; import { cn } from "@/lib/utils"; @@ -31,6 +31,11 @@ import { Bug, CheckCheck, Trash2, + Building2, + FolderKanban, + FileText, + MessageSquare, + Loader2, } from "lucide-react"; import { useWebsiteTheme } from "@/providers/website-theme-provider"; import { CaptainAmericaShield } from "@/components/shared/captain-america-shield"; @@ -48,11 +53,60 @@ export function Topbar({ onMenuClick }: TopbarProps) { useNotifications(); const [mounted, setMounted] = 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(null); + const searchTimer = useRef>(); const [bugModalOpen, setBugModalOpen] = useState(false); useEffect(() => { 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 = { lead: `/leads/${id}`, project: `/projects/${id}`, proposal: `/proposals/${id}`, conversation: `/conversations/${id}` } + router.push(paths[type] || "/") + }, [router]); + + const typeIcons: Record = { lead: Building2, project: FolderKanban, proposal: FileText, conversation: MessageSquare } if (!user) return null; function formatTimeAgo(ts: string): string { @@ -93,12 +147,43 @@ export function Topbar({ onMenuClick }: TopbarProps) { {/* Search */} -
+
handleSearchInput(e.target.value)} + onKeyDown={handleSearchKeyDown} + onFocus={() => query.trim() && setShowResults(true)} placeholder="Search leads, companies..." className="h-9 w-full max-w-sm pl-9 bg-muted border-border text-foreground" /> + {showResults && (results.length > 0 || searching) && ( +
+ {searching ? ( +
+ + Searching... +
+ ) : ( + results.map((r) => { + const Icon = typeIcons[r.type] || Building2 + return ( + + ) + }) + )} +
+ )}