fix search feature
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
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
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Plus, Edit, Eye, Search, Upload, X, DollarSign } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
@@ -570,15 +570,29 @@ export default function FleetPage() {
|
||||
const [publishedFilter, setPublishedFilter] = useState('')
|
||||
const [maintenanceModal, setMaintenanceModal] = useState<{ vehicleId: string; vehicleName: string } | null>(null)
|
||||
|
||||
const fetchVehicles = () => {
|
||||
const fetchVehicles = useCallback(() => {
|
||||
const params = new URLSearchParams({ pageSize: '100' })
|
||||
const q = search.trim()
|
||||
if (q) params.set('search', q)
|
||||
if (statusFilter) params.set('status', statusFilter)
|
||||
if (categoryFilter) params.set('category', categoryFilter)
|
||||
if (publishedFilter === 'published') params.set('published', 'true')
|
||||
if (publishedFilter === 'unpublished') params.set('published', 'false')
|
||||
|
||||
setLoading(true)
|
||||
apiFetch<Vehicle[]>('/vehicles?pageSize=100')
|
||||
apiFetch<Vehicle[]>(`/vehicles?${params.toString()}`)
|
||||
.then((result) => setVehicles(result ?? []))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
}, [categoryFilter, publishedFilter, search, statusFilter])
|
||||
|
||||
useEffect(() => { fetchVehicles() }, [])
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
fetchVehicles()
|
||||
}, search.trim() ? 250 : 0)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [fetchVehicles, search])
|
||||
|
||||
useEffect(() => {
|
||||
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
|
||||
@@ -644,14 +658,7 @@ export default function FleetPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = vehicles.filter((v) => {
|
||||
if (search && !`${v.make} ${v.model} ${v.licensePlate} ${v.vin ?? ''}`.toLowerCase().includes(search.toLowerCase())) return false
|
||||
if (statusFilter && v.status !== statusFilter) return false
|
||||
if (categoryFilter && v.category !== categoryFilter) return false
|
||||
if (publishedFilter === 'published' && !v.isPublished) return false
|
||||
if (publishedFilter === 'unpublished' && v.isPublished) return false
|
||||
return true
|
||||
})
|
||||
const filtered = vehicles
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -45,10 +45,13 @@ export default function ReservationsPage() {
|
||||
const [showNoVehiclesModal, setShowNoVehiclesModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<ReservationRow[]>('/reservations?pageSize=100')
|
||||
const params = new URLSearchParams({ pageSize: '100' })
|
||||
if (search) params.set('search', search)
|
||||
|
||||
apiFetch<ReservationRow[]>(`/reservations?${params.toString()}`)
|
||||
.then((result) => setRows(result ?? []))
|
||||
.catch((err) => setLoadError(err.message))
|
||||
}, [])
|
||||
}, [search])
|
||||
|
||||
const formatDate = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric' })
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -351,6 +351,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
'/subscription': 'Subscription',
|
||||
'/billing': 'Billing',
|
||||
'/contracts': 'Contracts',
|
||||
'/search': 'Search',
|
||||
'/notifications': 'Notifications',
|
||||
'/settings': 'Settings',
|
||||
},
|
||||
@@ -720,6 +721,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
'/subscription': 'Abonnement',
|
||||
'/billing': 'Facturation',
|
||||
'/contracts': 'Contrats',
|
||||
'/search': 'Recherche',
|
||||
'/notifications': 'Notifications',
|
||||
'/settings': 'Paramètres',
|
||||
},
|
||||
@@ -1089,6 +1091,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
'/subscription': 'الاشتراك',
|
||||
'/billing': 'الفوترة',
|
||||
'/contracts': 'العقود',
|
||||
'/search': 'البحث',
|
||||
'/notifications': 'الإشعارات',
|
||||
'/settings': 'الإعدادات',
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Bell, Search, Settings } from 'lucide-react'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { io } from 'socket.io-client'
|
||||
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
|
||||
import { EMPLOYEE_PROFILE_KEY, apiFetch, resolveApiOrigin } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { toDashboardAppPath } from '@/lib/dashboardPaths'
|
||||
|
||||
@@ -16,30 +16,6 @@ function computeInitials(name: string): string {
|
||||
return `${parts[0].slice(0, 1)}${parts[1].slice(0, 1)}`.toUpperCase()
|
||||
}
|
||||
|
||||
function resolveSocketOrigin(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
const configuredApiUrl = process.env.NEXT_PUBLIC_API_URL
|
||||
if (configuredApiUrl) {
|
||||
try {
|
||||
return new URL(configuredApiUrl, window.location.origin).origin
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
||||
return 'http://localhost:4000'
|
||||
}
|
||||
|
||||
return window.location.origin
|
||||
}
|
||||
|
||||
const SEARCHABLE_ROUTES = ['/reservations', '/fleet', '/customers', '/contracts', '/billing']
|
||||
|
||||
function getSearchTarget(appPath: string): string {
|
||||
const match = SEARCHABLE_ROUTES.find((route) => appPath === route || appPath.startsWith(`${route}/`))
|
||||
return match ?? '/reservations'
|
||||
}
|
||||
|
||||
export default function TopBar() {
|
||||
const { dict } = useDashboardI18n()
|
||||
const pathname = usePathname()
|
||||
@@ -100,12 +76,14 @@ export default function TopBar() {
|
||||
useEffect(() => {
|
||||
if (!socketEnabled) return
|
||||
|
||||
const socketOrigin = resolveSocketOrigin()
|
||||
const socketOrigin = resolveApiOrigin()
|
||||
if (!socketOrigin) return
|
||||
|
||||
const socket = io(socketOrigin, {
|
||||
autoConnect: false,
|
||||
withCredentials: true,
|
||||
reconnectionAttempts: 3,
|
||||
timeout: 5000,
|
||||
transports: ['polling', 'websocket'],
|
||||
})
|
||||
|
||||
@@ -210,14 +188,13 @@ export default function TopBar() {
|
||||
event.preventDefault()
|
||||
|
||||
const query = globalSearch.trim()
|
||||
const target = getSearchTarget(appPath)
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (query) {
|
||||
params.set('search', query)
|
||||
router.push(`${target}?${params.toString()}`)
|
||||
router.push(`/search?${params.toString()}`)
|
||||
} else {
|
||||
router.push(target)
|
||||
router.push('/search')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ function installBrowser(token?: string) {
|
||||
value: {
|
||||
location: {
|
||||
hostname: 'localhost',
|
||||
origin: 'http://localhost',
|
||||
},
|
||||
localStorage: {
|
||||
getItem: vi.fn(() => token ?? null),
|
||||
@@ -28,7 +29,7 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('dashboard apiFetch', () => {
|
||||
it('adds JSON headers and sends cookies for browser requests', async () => {
|
||||
it('sends cookies without forcing JSON headers for bodyless browser requests', async () => {
|
||||
installBrowser()
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
@@ -39,6 +40,23 @@ describe('dashboard apiFetch', () => {
|
||||
const { apiFetch } = await import('./api')
|
||||
await expect(apiFetch('/team')).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/team$/), expect.objectContaining({
|
||||
credentials: 'include',
|
||||
headers: expect.not.objectContaining({ 'Content-Type': expect.any(String) }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('adds JSON headers for JSON payload requests', async () => {
|
||||
installBrowser()
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ data: { ok: true } }),
|
||||
}))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
|
||||
const { apiFetch } = await import('./api')
|
||||
await expect(apiFetch('/team', { method: 'POST', body: JSON.stringify({ name: 'Ops' }) })).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/team$/), expect.objectContaining({
|
||||
credentials: 'include',
|
||||
headers: expect.objectContaining({
|
||||
@@ -133,6 +151,22 @@ describe('dashboard apiFetch', () => {
|
||||
expect(calledUrl).not.toContain('/api/v1/api/v1')
|
||||
})
|
||||
|
||||
it('resolves the API origin for realtime connections from relative and absolute API bases', async () => {
|
||||
installBrowser()
|
||||
setBrowserHostname('rentaldrivego.ma')
|
||||
process.env.NEXT_PUBLIC_API_URL = '/dashboard/api/v1'
|
||||
|
||||
let api = await import('./api')
|
||||
expect(api.resolveApiOrigin()).toBe('http://localhost')
|
||||
|
||||
vi.resetModules()
|
||||
installBrowser()
|
||||
process.env.NEXT_PUBLIC_API_URL = 'http://localhost:4000/api/v1'
|
||||
|
||||
api = await import('./api')
|
||||
expect(api.resolveApiOrigin()).toBe('http://localhost:4000')
|
||||
})
|
||||
|
||||
it('does not force JSON content type for FormData payloads', async () => {
|
||||
installBrowser()
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { uploaded: true } }) }))
|
||||
|
||||
@@ -34,6 +34,16 @@ export function resolveApiBase(): string {
|
||||
return normalizeApiBase(shouldUseDashboardProxy(configuredApiBase) ? DASHBOARD_PROXY_API_BASE : (configuredApiBase || DASHBOARD_PROXY_API_BASE))
|
||||
}
|
||||
|
||||
export function resolveApiOrigin(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
try {
|
||||
return new URL(resolveApiBase(), window.location.origin).origin
|
||||
} catch {
|
||||
return window.location.origin
|
||||
}
|
||||
}
|
||||
|
||||
export const API_BASE = resolveApiBase()
|
||||
|
||||
export const EMPLOYEE_PROFILE_KEY = 'employee_profile'
|
||||
@@ -45,7 +55,7 @@ export async function apiFetch<T>(path: string, options?: RequestInit): Promise<
|
||||
...(options?.headers as Record<string, string> ?? {}),
|
||||
}
|
||||
|
||||
if (!isFormData) {
|
||||
if (!isFormData && options?.body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,12 @@ export const dashboardRoutePolicies: Record<string, DashboardRoutePolicy> = {
|
||||
'/subscription': OWNER_ONLY_BILLING_RECOVERY_POLICY,
|
||||
'/subscription/success': OWNER_ONLY_BILLING_RECOVERY_POLICY,
|
||||
'/subscription/cancel': OWNER_ONLY_BILLING_RECOVERY_POLICY,
|
||||
'/search': {
|
||||
authenticationRequired: true,
|
||||
allowedRoles: null,
|
||||
subscriptionRequired: true,
|
||||
menuRegistrationRequired: false,
|
||||
},
|
||||
}
|
||||
|
||||
export function resolveDashboardRoutePolicy(pathname: string): DashboardRoutePolicy {
|
||||
|
||||
Reference in New Issue
Block a user