2b422ca197
Build & Push / Pipeline Tests (push) Failing after 1m14s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 53s
Test / API Unit Tests (push) Failing after 50s
Test / Homepage Unit Tests (push) Successful in 45s
Test / Carplace Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 43s
Test / API Integration Tests (push) Successful in 1m4s
154 lines
5.6 KiB
TypeScript
154 lines
5.6 KiB
TypeScript
'use client'
|
|
|
|
import Link from 'next/link'
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
import { useSearchParams } from 'next/navigation'
|
|
import { Search, Car, Users, CalendarClock, FileText, Receipt, CreditCard, BadgePercent, UserCog, LayoutDashboard } from 'lucide-react'
|
|
import { apiFetch } from '@/lib/api'
|
|
|
|
type GlobalSearchResultType =
|
|
| 'page'
|
|
| 'vehicle'
|
|
| 'customer'
|
|
| 'reservation'
|
|
| 'contract'
|
|
| 'invoice'
|
|
| 'payment'
|
|
| 'offer'
|
|
| 'team'
|
|
|
|
type GlobalSearchResult = {
|
|
id: string
|
|
type: GlobalSearchResultType
|
|
title: string
|
|
subtitle: string
|
|
href: string
|
|
meta?: string
|
|
}
|
|
|
|
type GlobalSearchResponse = {
|
|
query: string
|
|
results: GlobalSearchResult[]
|
|
groups: Record<GlobalSearchResultType, GlobalSearchResult[]>
|
|
total: number
|
|
}
|
|
|
|
const GROUPS: Array<{ key: GlobalSearchResultType; label: string; icon: typeof Search }> = [
|
|
{ key: 'page', label: 'Pages', icon: LayoutDashboard },
|
|
{ key: 'vehicle', label: 'Vehicles', icon: Car },
|
|
{ key: 'customer', label: 'Customers', icon: Users },
|
|
{ key: 'reservation', label: 'Reservations', icon: CalendarClock },
|
|
{ key: 'contract', label: 'Contracts', icon: FileText },
|
|
{ key: 'invoice', label: 'Invoices', icon: Receipt },
|
|
{ key: 'payment', label: 'Payments', icon: CreditCard },
|
|
{ key: 'offer', label: 'Offers', icon: BadgePercent },
|
|
{ key: 'team', label: 'Team', icon: UserCog },
|
|
]
|
|
|
|
function resultTypeLabel(type: GlobalSearchResultType) {
|
|
return GROUPS.find((group) => group.key === type)?.label ?? type
|
|
}
|
|
|
|
export default function GlobalSearchPage() {
|
|
const searchParams = useSearchParams()
|
|
const query = (searchParams.get('search') ?? '').trim()
|
|
const [data, setData] = useState<GlobalSearchResponse | null>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
setData(null)
|
|
setError(null)
|
|
|
|
if (!query) {
|
|
setLoading(false)
|
|
return () => { cancelled = true }
|
|
}
|
|
|
|
setLoading(true)
|
|
const params = new URLSearchParams({ search: query, limit: '6' })
|
|
apiFetch<GlobalSearchResponse>(`/search?${params.toString()}`)
|
|
.then((result) => {
|
|
if (!cancelled) setData(result)
|
|
})
|
|
.catch((err) => {
|
|
if (!cancelled) setError(err.message ?? 'Search failed')
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoading(false)
|
|
})
|
|
|
|
return () => { cancelled = true }
|
|
}, [query])
|
|
|
|
const populatedGroups = useMemo(() => {
|
|
if (!data) return []
|
|
return GROUPS.map((group) => ({ ...group, items: data.groups[group.key] ?? [] })).filter((group) => group.items.length > 0)
|
|
}, [data])
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<h2 className="text-xl font-semibold text-slate-900 dark:text-slate-50">Search</h2>
|
|
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
|
{query ? `${data?.total ?? 0} result${data?.total === 1 ? '' : 's'} for "${query}"` : 'Search vehicles, reservations, customers, billing, offers, team, and pages.'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{!query ? (
|
|
<div className="card p-8 text-center">
|
|
<Search className="mx-auto h-8 w-8 text-slate-400" />
|
|
<p className="mt-3 text-sm text-slate-500 dark:text-slate-400">Type a search term in the top bar.</p>
|
|
</div>
|
|
) : loading ? (
|
|
<div className="card p-8 text-center">
|
|
<div className="mx-auto h-8 w-8 rounded-full border-4 border-blue-600 border-t-transparent animate-spin" />
|
|
</div>
|
|
) : error ? (
|
|
<div className="card p-5 text-sm text-red-600">{error}</div>
|
|
) : populatedGroups.length === 0 ? (
|
|
<div className="card p-8 text-center">
|
|
<p className="text-sm text-slate-500 dark:text-slate-400">No results found.</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-5">
|
|
{populatedGroups.map((group) => {
|
|
const Icon = group.icon
|
|
return (
|
|
<section key={group.key} className="space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<Icon className="h-4 w-4 text-blue-700 dark:text-blue-300" />
|
|
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">{group.label}</h3>
|
|
</div>
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
{group.items.map((item) => (
|
|
<Link
|
|
key={`${item.type}-${item.id}`}
|
|
href={item.href}
|
|
className="card block p-4 transition hover:border-blue-300 hover:shadow-md dark:hover:border-blue-400/40"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-50">{item.title}</p>
|
|
<p className="mt-1 line-clamp-2 text-sm text-slate-500 dark:text-slate-400">{item.subtitle}</p>
|
|
</div>
|
|
<span className="shrink-0 rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-500/10 dark:text-blue-200">
|
|
{resultTypeLabel(item.type)}
|
|
</span>
|
|
</div>
|
|
{item.meta ? <p className="mt-3 text-xs text-slate-400 dark:text-slate-500">{item.meta}</p> : null}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|